Repository navigation
NU-010 Merge gate 2: candidate generation and human merge confirmation #24
Description
Part of #30.
The second human gate: surface what the new document connects to, and let a human confirm merges.
Build
Candidate generation, two signals:
- Normalised-name overlap → the same named entity already in the graph. This auto-joins;
the reviewer is shown what connected and asked nothing. - Cosine similarity → differently-named same-referents. This is a question.
What the human is asked: merges only ("is sessions table the same thing as session store?"). Not links. On confirm: rewrite every inbound/outbound edge of the losing node onto
the survivor, union the provenance ref sets, then delete the losing node — retired, not left
behind as a zero-edge orphan.
Not one transaction — the alias lives in Postgres, the node/edge mutation in Neo4j, and there is
no cross-store transaction in this design. Write the Postgres alias first (ON CONFLICT DO NOTHING), then do the Neo4j rewrite-and-delete. A crash between them leaves the losing node
still in Neo4j with edges not yet rewired — recoverable, and already correct where it matters: a
new document naming the losing name resolves through the alias to the survivor immediately, since
resolve_key checks aliases first. The Neo4j half is retry-idempotent: it checks whether the
losing node still exists before rewiring/deleting, so a retry or a replayed confirm is a no-op once
it's gone. Reversing the order is worse — if Neo4j finished but the Postgres write then failed, a
document arriving with the losing name in that window would find no alias and create a genuinely
duplicate node.
Overload guards: cap at 5 candidates, gate on a precision bar — starting cosine floor 0.85
(conservative; log rejected candidates below it to calibrate a real threshold once corpus volume
allows), default every suggestion to "not the same" so a fatigued reviewer's inaction is the
safe outcome, and suppress anything the natural key already auto-joined.
Why auto-join is information, not a question
The natural key already unifies identical normalised names at persist time with no human involved.
That auto-join is what makes A1's traversal path exist from compiler output alone:
A compiles → Decision(JWT) ─AFFECTS→ Entity(session store)
B compiles → Entity(rate limiter) ─DEPENDS_ON→ Entity(session store)
▲ natural key joins both subgraphs here
Asking about it would be pure reviewer tax.
Why merges and not links
Aliases make merges compound: key lookup becomes normalize(name) → node with many names per
node, so every confirmed merge permanently improves the matcher. No threshold, no tuning,
monotonic, never regresses. It is the only part of the system that gets better as it is used — and
strictly better than a tuned cosine threshold at the one job a human does best.
Links are deferred because they need a typed, directed edge vocabulary, and that cannot be
designed from a five-document fixture. possible_relations is a closed Literal: get it wrong and
reviewers either cannot express the link they see or pick the first option. Untyped RELATED_TO
edges would make a similarity blob that path_depth=2 expands into indiscriminately, actively
degrading retrieval.
Log every "I want to link these but there is no type for it" moment — that log designs the
vocabulary, not imagination.
Closes
- A4 — merge-confirmation half. NU-007 (review gate 1) closes the content-approval half.
- A10 — confirmation half. NU-003 (deterministic core) closes the matcher half: that story
proves the natural-key/alias matching logic; this story proves the human-confirm flow that
invokes it and makes re-ingesting D a no-op.
Activity
[-]Merge gate 2: candidate generation and human merge confirmation[/-][+]NU-009 Merge gate 2: candidate generation and human merge confirmation[/+][-]NU-009 Merge gate 2: candidate generation and human merge confirmation[/-][+]NU-010 Merge gate 2: candidate generation and human merge confirmation[/+]