ADR 0021: `composition` holds a second module, and retrieval is what it composes
Status
Accepted. Amends
0007 composition is the only top layer
in fact. 0007's reasoning stands unchanged and is what this decision was
tested against — its Decision 1 said the layer holds exactly one module, and
its own "admission test" section said what a second one would have to
demonstrate. This records that a candidate met it.
0006 the public surface is gated
stands. 0002 two store ports stands — retrieval
reads through both ports and adds no method to either.
0017 the embedding provider port
stands, and this is its second consumer.
Context
0007 put one module on the top layer and left the constraint keeping it there
to review rather than to a gate, saying so plainly: lint-imports checks
direction and not population, so a second file under redstring/composition/
would inherit the top layer's permissions and pass. The section that closes
the gap is an admission test, and it asks one question about the candidate —
**which two layers, forbidden from importing each other, does this module
join?**
Entity retrieval needs three collaborators at once: an EmbeddingProvider to
turn the query into a vector, a VectorStore to search, and a GraphStore to
generate lexical candidates and to resolve matches back into entities.
Decision
composition is a package holding two modules, build_graph and
retrieval, and membership continues to require naming the pair of layers a
module joins.
retrieval joins vector, graph and llm. Those three are siblings in the
import contract: vector and graph may not import each other, and neither
may import llm. So the set of layers that may hold all three collaborators
is exactly one, the top, and a Retriever placed anywhere else would require
a cross-sibling import — which is to say, it would require the contract to be
weakened rather than the module to be placed.
That is the same argument that admitted build_graph, applied to a different
pair. build_graph joins extraction and projections, whose separation is
what keeps a store reference out of the pipeline.
redstring.composition re-exports everything the module of that name
exported, so no import path changed.
Consequences
The layer is no longer visibly disproportionate, and that costs something.
0007 observed that one file above six siblings invites a tidying instinct, and
placed three copies of the reasoning where an author would meet it mid-edit.
Two files invite it less — which means the shape of the package now argues
less loudly than the prose does, and the prose has to carry more of the weight.
The admission question is therefore repeated verbatim in
src/redstring/composition/__init__.py, in pyproject.toml's layer comment,
and in CLAUDE.md.
The population constraint is still not a gate. Nothing fails when a third
module appears; exhaustive = true catches a new top-level package, which
is the opposite mistake. This ADR does not change that, and pretending
otherwise would be worse than admitting it. What it does change is the
precedent: the layer has now admitted a module on a stated argument, so the
next candidate is compared against two worked examples rather than one rule.
A module here can never be reused by the code it supports. Nothing may
import the top layer, so Retriever is unavailable to extraction,
consolidation and every sibling. That is the constraint working as intended —
a component the composed path needs to call belongs below, not beside.
The submodule and its principal function share a name.
redstring.composition.build_graph resolves to the function, not the module,
because the package __init__ rebinds it after the submodule loads. That is
deliberate and load-bearing: it is what preserves the pre-existing import
path. Renaming the submodule to un-shadow it would break that path.