27. _produce returns Produced, so a candidate can carry its score¶
Context¶
ADR 0026 made _produce(text, pack, params) -> list[str] the single primitive
across all 27 generators, and it was right about the shape a generator needs
when it has several answers. It was not enough for a generator that has an
opinion about them.
Two things a generator knows have nowhere to go in a list of strings.
The first is a score. The anagram search ranks its covers by the SCOWL band of
their least common word, so room dirty comes ahead of morty dior for a
reason a caller can read. A bare string arrives with that reason stripped off,
and the caller is left trusting the order without being told what produced it.
The second is that the search gave up. Production.truncated already answers
"was there more?", but the spine derives it from len(found) > limit — it can
see that max_results capped a result set, and that is the only way it can
ever see anything. A search that abandons its own node budget returns fewer
results, not more, so that derivation is false precisely when the honest answer
is true. ADR 0026's design spec named this case in advance as one the surface
did not yet carry.
Decision¶
_produce(text, pack, params) -> Produced, where
class Produced(BaseModel):
candidates: list[Candidate]
truncated: bool = False
Produced.truncated is the generator's own statement, and produce() combines
it with the spine's: produced.truncated or len(found) > limit. Both mean the
same thing to a reader — what you were shown is not everything — and only one
of them is visible from the spine.
Still one primitive. A _produce_scored overlay implemented by anagram alone
would have been a smaller diff, and it is the thing ADR 0026 refused in
writing: two primitives make every consumer ask which a given procedure
implements. Changing the one primitive's type keeps that question unaskable.
plain(texts) wraps a list of strings into a Produced with no metrics and no
truncation. All twenty-seven generators say exactly that today, in one call
each, rather than spelling out a Candidate(text=...) comprehension; anagram
is the one expected to stop, once its search has a band to report.
Consequences¶
This is churn across 27 files to serve one row's ranking, on a surface
stabilised the day before. Twenty-six of those files gain nothing from the
change and pay for it anyway — two import lines and a plain(...) wrapper
each. The anagram search that motivates it does not exist yet at the time of
this ADR; the type is being widened ahead of the caller that needs it, which is
the opposite of the order this project usually works in. The argument for doing
it now rather than later is that the alternative is worse, not that the timing
is good.
The alternative considered was a _produce_scored overlay, left beside the
existing _produce and implemented by anagram only. Rejected because it
reintroduces exactly the two-primitive condition ADR 0026 named — one day after
it was named — and because paragram's history is the argument against it: a
generator moved from needing one shape to needing another without its own logic
changing at all, and the same can happen to any of the other twenty-six with
respect to scores.
Production.texts is retained as a computed field rather than removed, so
nothing outside had to change to read a Production: apply --json, the MCP
surface and every Python caller still ask for texts and still get a list of
strings, and it still appears in model_dump().
That holds for readers only, and the qualifier is the point. texts became
read-only, so anything that built a Production had to move from texts= to
candidates= — the four constructions in this project's own test suite did,
and any code outside it that constructs one will have to as well. This ADR is
not claiming the change was free; it is claiming the break fell on writers,
who are few and inside the library, rather than on readers, who are many and
outside it.
The retained field is also the honest cost on the reading side — the scores are
invisible to every existing consumer until one asks for candidates by name. A
caller reading texts today sees a ranking with its reasons removed and no
indication that reasons exist. Nothing prompts them to look.
Produced and Production are two models with a truncated field that mean
related but different things, and a reader will have to keep them apart. The
docstrings say which is which; that is the whole mitigation, and it is a real
cost of not simply reusing Production as the primitive's return type — which
was not available, because Production carries procedure and
metrics["found"], both of which the spine computes and a generator should not
be asked to fill in.
This amends ADR 0026 rather than replacing it. Everything 0026 decided still
stands: one primitive and not two, best first, apply as produce(...).texts[0],
the filtering guard, NOTHING unwaivable by allow_identity, and
max_results defaulting to ten. Only the primitive's return type changed, and
the empty check moved from if not produced to if not produced.candidates —
a Produced with no candidates is still DegenerateOutput(NOTHING), on the
same path, before the filter runs.
Out of scope, and unchanged: no generator gains real metrics here. paragram
computes a ranking it discards and is the obvious candidate, but its score is
not sourced the way a SCOWL band is, and naming a metric for it is a separate
argument this change does not need to win. It wraps with plain() like the
other twenty-six.