ADR 0015: Consolidation gets a composed entry point, and an absent graph signal stops meaning zero

Status: accepted. Amended by [0025 consolidation's substitution points

are protocols](0025-consolidation-substitution-is-two-protocols.md), which

retypes resolve's finder and adjudicator against one-method protocols.

The Decision below stands unchanged: same entry point, same arguments, same

defaults, same banding.

Also amended by [0034 neighbours are compared by

name](0034-neighbours-are-compared-by-name.md). The decision this ADR is

named for โ€” an absent graph signal must not read as zero โ€” stands entirely.

What 0034 corrects is the clause immediately after it, that a disjoint

neighbourhood with structure on either side "still scores 0.0, because that

is a real finding about two entities". Ids are namespaced per document by

entity_id_for, so across two documents that disjointness was an artefact

rather than a finding, and it scored the same 0.7143 rejection this ADR was

written to remove.

Also amended by [0041 the consolidation pass is

decide-then-emit](0041-the-consolidation-pass-is-decide-then-emit.md), which

gives the composed entry point a corpus-level method alongside the

per-subject one this ADR describes. The Decision below is otherwise

unchanged.

Why this is an ADR: it changes the public surface, and it changes what a

merge decision is. Either alone would qualify under

.claude/rules/definition-of-done.md; they are one ADR because the second was

found by doing the first, and neither is fully motivated without the other.

Context

This library states a three-part thesis: extraction, consolidation, and

storage that can be rebuilt. The README argues the middle one hardest โ€”

without it a graph accumulates one node per mention, which looks like a

knowledge graph and answers every question wrong, because each entity's edges

are split across its aliases.

Consolidation was real, tested, and not on the public surface. Reaching it

meant redstring.consolidation.service.ConsolidationService, a dotted path

the documentation simultaneously described as liable to move without notice.

So the library told its users that the step was essential and then declined to

ship it as API.

The stated reason was that its shape was "still being decided by the callers

it does not have", and that was honest rather than evasive. But the deciding

factor turned out to be assembly cost, which is measurable: constructing a

working consolidation required six objects from three packages, two of

them eventsource internals (AggregateStore, SnapshotStore) that no

redstring documentation mentions. build_graph had already solved the same

problem for extraction and nobody had done it here.

Decision

1. `Consolidator` is the composed entry point, and it is exported

It stands to ConsolidationService exactly as build_graph stands to

ExtractionPipeline: the service decides and emits, GraphProjection writes,

and this holds both so a caller does not have to.


consolidator = Consolidator(store)
report = await consolidator.resolve(subject)

Three methods โ€” merge (the decision is already made), resolve (block,

score, band, adjudicate, merge), undo โ€” each returning a

ConsolidationReport describing what happened to the graph.

It never writes to the store directly. Every change arrives through a

projection applying an event, so a store maintained by this class and a store

rebuilt by replay end up identical. That is the property

ADR 0004 exists to protect, and a

composed entry point is exactly where it would have been convenient to break

it.

2. The event store defaults to in-memory, and the cost is named

undo takes a merge's event id and nothing describing what to restore โ€”

the aggregate rehydrates its own history, because a caller supplying the

restoration would be a caller able to restore something that never happened.

That history lives in an event store.

Requiring one would reintroduce the assembly cost this ADR exists to remove.

Defaulting to in-memory means merge, resolve and undo all work, the graph is

always correct, and the history dies with the object: a new Consolidator

cannot undo an earlier one's merge, and raises UnknownMergeError โ€” which is

also what it raises for a merge that never happened.

This is the same trade build_graph makes ("there is no log to rebuild

from"). It is accepted on the same grounds and, unlike there, it is also

asserted: test_a_fresh_consolidator_cannot_undo_an_in_memory_merge pins

the limitation so it cannot be discovered after a restart, and

remembers_merges_across_restarts reports which arrangement is in use.

3. An empty-vs-empty neighbour comparison is absent, not zero

CandidateFinder scored the graph feature by Jaccard overlap of two neighbour

sets. graph_similarity returns 0.0 for two empty sets, and documents that

choice deliberately: "nothing is known about either" must not read as "these

agree perfectly", so it is not 1.0.

That reasoning is right and the conclusion did not follow, because there was a

third option the module already uses everywhere else โ€” absent. None is

what the finder returns when the signal is switched off, precisely so a

configuration flag cannot invent evidence. Two empty sets are the same

situation arrived at differently.

Scoring them 0.0 says the entities positively disagree. Measured, on two

entities named "Ada Lovelace" with no edges: combined score 0.7143 with

the graph signal on, 1.0 with it off. LOW_SIMILARITY is 0.75, so an

identical-name pair was **rejected outright โ€” not merged, and not even

adjudicated** โ€” by a feature that had nothing to say.

The finder now returns None for that case. A disjoint neighbourhood where at

least one side has structure still scores 0.0, because that is a real

finding about two entities.

**That last sentence is true of one document and was not true across two; see

0034.** Neighbours were compared

by id, and ids are namespaced by source_id, so two extractions of one

neighbour could not overlap however completely they agreed. The reasoning here

is unchanged โ€” disagreement is still disagreement โ€” but the comparison could

not tell disagreement from an id scheme.

Consequences

**The public surface grows by fifteen names, and that is the mechanism

working.** ADR 0006 says exporting one

name pulls its closure, and the gate enumerated it rather than leaving it to

judgement: the two events a report carries, the two collaborators resolve

names, the four value types those name in turn, the four consolidation errors,

and AggregateStore/SnapshotStore recorded as foreign. The closure

terminated on its own; nothing had to be argued about.

The four consolidation errors left

UNEXPORTED_BECAUSE_THEIR_RAISER_IS in the same commit, which is the pairing

that dict was built for.

Merge outcomes change for existing callers. Anyone running a

CandidateFinder with the graph signal on will now see pairs merge that

previously scored below LOW_SIMILARITY. This is the intended correction, but

it is a behaviour change and not a bug fix from the caller's side: a corpus

consolidated before and after this version can differ. Nothing is released

yet, so no one is affected in practice.

**The consolidation suite had a blind spot and it should be assumed to have

others.** Every existing test built its finder with use_graph_signal=False,

so no test in the package had ever reached the branch this ADR corrects. The

one test that did assert on the graph feature used two isolated entities and

asserted graph is not None โ€” encoding the defect as the specification, which

is recurring-defects.md ยง4. It now uses a fixture where the feature has

something to report, and two new tests cover the empty-vs-empty and

disjoint cases separately.

Temporal inference is still not exported, and the argument for it is now

weaker rather than stronger. What changed here was not a discovery that the

shape was obvious; it was doing the composition work. redstring.temporal

deserves the same treatment and has not had it.

Alternatives rejected

Export ConsolidationService directly. It would have satisfied the letter

of the complaint and left the assembly cost intact, which was the actual

barrier.

Require an event store. Honest, and it puts eventsource's two ports in the

first line of every consolidation example โ€” reintroducing the friction that

kept this unexported for four slices.

Default use_graph_signal=False on Consolidator. This was the tempting

fix when identical entities failed to merge, and it would have worked. It also

would have buried a defect in CandidateFinder under a default, leaving the

next caller who turns the signal on to rediscover it โ€” and leaving

use_graph_signal=True, which is CandidateFinder's own default, as a

setting that silently degrades the most common case.