Skip to content

25. apply gets the spine check has

Context

ADR 0002 made apply optional and left it off BaseProcedure. check is a template method that resolves the pack, enforces meta.requires and refuses unknown parameters, so — in its own docstring — "an individual procedure module cannot forget those checks". apply had none of that, and all 27 generators hand-rolled the same preamble.

Each forgot something different. cut_up never validated parameters at all. Four hand-rolled require_capability. anagram could not declare the lexicon its generator uses, because requires gates check too. And seed, named explicitly in the Constructive signature, bound before **params on every call, so no params model ever saw it and all 27 accepted seed="not-an-int".

The codebase had reached for this twice. diastic carries a twelve-line comment documenting the seed collision and renames its field to dodge it; cut_up carried CutUpApplyParams, a params model for the apply half that nothing used.

Separately, the eval harness asserted check(apply(text)) is satisfied for every constructive procedure — a property the identity passes trivially. A generator returning its input satisfied every gate the project ran.

Decision

ConstructiveProcedure carries a concrete apply() delegating to an abstract _apply(), mirroring check()/_check(). It resolves the pack, enforces meta.requires and meta.apply_requires, validates parameters, and refuses output identical to its input.

seed becomes a field on SeedParams, carried only by the ten procedures that draw. allow_identity is a field on ApplyParams, carried by all of them. source, where a generator's params model has it, is supplied by apply from the text it was called with — a caller who also passes source names two texts for one argument, and parse_apply_params raises InvalidParams rather than choosing one silently.

lang stays a reserved keyword on the shared apply signature, the same way seed used to be on Constructive.apply: no field on ApplyParams, or on anything it is mixed into, may be named lang, because Python still binds a keyword matching an explicit signature parameter before any of it reaches **params. Closing the collision for seed did not close the mechanism that causes it — only that one name.

ADR 0002 is amended, not reversed: apply is still optional, and a procedure without one is still registered on its checker alone. What changes is that a procedure which has one inherits the spine rather than rebuilding it.

Consequences

seed is typed, reaches a caller who never touches Python, and cannot be passed to a procedure that does not draw — the exclusion-by-type ADR 0009 gives fold_diacritics, applied again. This is a break: apply(text, seed=5) on a deterministic procedure used to be accepted and ignored, and now raises.

Where it reaches that caller is describe()'s apply_params, not params_schema(). Those are two schemas, not one: params_schema() is the checker's model, and seed, allow_identity and a generator's own fields belong to apply_params_model(), which no non-Python surface exposed at all until Description.apply_params carried it to denckring describe and MCP's describe_procedure. Merging them would have been the other option and is wrong: source is a checker parameter that apply supplies for itself and refuses from a caller, so one schema could not describe both calls.

apply_requires lets anagram declare lexicon.words without gating a checker that has always run on core alone, which is what makes missing honest for the generator half.

The non-degeneracy guard surfaced five raise sites across four generators that returned their input when it could not feed them — boustrophedon, cent_mille_milliards, recombination, and wechselsatz twice over, once for a frame offering no choice at all and once for a frame whose alternatives the tokenizer cannot read back as single words. They now raise InputTooShort naming what they needed — as do spoonerism and ideenwuerfeln, which were refusing for the identical reason under NoCandidateWord and MalformedCorpus respectively, so the code a caller retries on depended on which procedure they had called. NoCandidateWord keeps its narrower meaning (enough units, none of them suitable) and MalformedCorpus keeps corpus.parse's (unreadable, not merely small). Finding them is the argument for the guard: each had been silently no-opping, and the round-trip property called it a pass.

The guard refuses an empty result from non-empty input under the same error and the same allow_identity, because it is the same defect: _report scores an empty text 1.0 — vacuously satisfied — so melting_text.apply("hello", seed=0) returning "" was a satisfied report on a text nobody wrote, and every_nth_word with a stride longer than its input could do it too. A caller cannot act differently on "identical to the input" and "nothing at all", so they share a code; detail()["observed"] says which was seen.

The second wechselsatz case is a guard around a gap the guard did not close. _apply splits its frame on whitespace; _check reads the produced line back with word_spans, a different tokenizer. An alternative like Nacht-Tag, which the German pack reads as two words, could be drawn, written out, and then rejected by the checker that supposedly verifies it. wechselsatz now filters such alternatives out of the draw rather than offering them — which means a frame that offers only ones like it now raises InputTooShort for want of a choice, and a frame that offers Nacht-Tag alongside ordinary words simply never draws it, silently, for the reader. That is the same class of defect this chapter exists to end, one level down in the same procedure, and it is deliberately not fixed here: the actual fix is a frame contract stating what an alternative may be, refused at the door, which is the template-grammar work of chapter 2. The filter is the narrow, honest stopgap until then.

kind is untouched. It is a claim about the form, and nine rows are honestly both with honestly no generator here; describe reports constructive alongside it so both statements can be true at once.

Amendment

ADR 0026 replaces _apply(...) -> str with _produce(...) -> list[str] as the primitive, and defines apply as produce(...).texts[0]. The spine this ADR describes is unchanged in what it enforces — pack, both capability lists, parameters, and a guard against output that misrepresents what ran. Only the shape of what _apply returned has moved.