Skip to content

Never auto-merge a similarity-proposed pair on the batch verdict alone; tell the adjudicator the names differ - #889

Merged
WaylandYang merged 6 commits into
devfrom
feat/name-vector-pairs-second-look
Sep 25, 2026
Merged

WaylandYang merged 6 commits into
devfrom
feat/name-vector-pairs-second-look

Conversation

@WaylandYang

@WaylandYang WaylandYang commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #877, from the identity bench rather than from a review.

What the bench showed

Two runs each of dev before and after #877 on scripts/bench/identity.mjs (DeepSeek-V3.2 extraction, bge-m3 embeddings, forward and reverse ingest order). Channel 2 fixed the case it was built for: with the short form arriving first, 海洋探测器1号 and 海探1 were split in both dev runs and merged in both branch runs, from a name_vector pair with cosine 0.78. But one branch run also produced three false merges, all from a single batch verdict: the adjudicator merged the bare 张伟 (engineering) into 财务部总监张伟 (finance) at confidence 0.85, over the 0.8 auto-merge line. That pair only exists because channel 2 proposed it; the literal channel never puts two different strings together, so dev kept the namesakes apart partly by luck.

The batch step is the wrong place to settle such a pair. It sees two names and a few facts, and the identity rules it reads say a dropped qualifier is the same thing, which is exactly wrong when the qualifier is what tells two namesakes apart.

What this changes

Two small things, both scoped to pairs whose review reason is name_vector|…:

  1. The prompt says why the pair exists. AdjudicationPair gains proposed_because; for a similarity-proposed pair both the batch prompt and the second-look prompt carry a Why paired: line stating that the names are similar but not the same string, so a dropped qualifier is not evidence and the facts are. Same-name pairs are unchanged and carry no such line.
  2. A batch verdict of same on such a pair is never applied directly. needs_second_look routes it to the existing tool-using second look regardless of confidence; that look can read each side's facts and passages before deciding, and its result goes through the same 0.8 gate as before. different and unsure follow the existing rules: keeping two records apart is the safe direction, and the second look already handles the unsettled ones.

"Proposed because similar" and "proposed because identical" are two strengths of evidence; they no longer share one auto-merge line. The threshold number itself is not touched: the model's confidences are not calibrated, so moving 0.8 to 0.9 would only shift which lucky verdicts get through.

A third, smaller change came out of reading the bench traces (second commit): the second look's trace now records a lookup it refused for being over budget and a turn that only spoke, and turns that hit the limit are budgeted separately from lookups. See "What it costs" for why.

Measured

Same bench, same models, two runs per configuration. P/R/F1 are pairwise over the anchors the run resolved; missing is anchors the run could not locate (type binding varies between runs, so runs are comparable in kind, not in F1 to the second decimal).

config order P R F1 missing false merges split entities
dev run1 forward 1.00 0.90 0.95 8 0 INST
dev run1 reverse 1.00 0.36 0.53 4 0 INST, P1, ZW_ENG, LN
dev run2 forward 1.00 0.73 0.84 7 0 INST, P1
dev run2 reverse 1.00 0.43 0.60 4 0 INST, P1, LN
#877 run1 forward 0.75 0.56 0.64 3 3 INST, ZW_ENG, LN
#877 run1 reverse 1.00 0.57 0.73 4 0 INST, LN
#877 run2 forward 1.00 0.90 0.95 8 0 INST
#877 run2 reverse 1.00 0.50 0.67 3 0 INST, LN
this PR run1 forward (no anchors resolved: extraction bound no types, 0 pairs) 21 0
this PR run1 reverse 1.00 0.50 0.67 14 0 INST
this PR run2 forward 1.00 0.73 0.84 7 0 INST, P1
this PR run2 reverse 1.00 0.43 0.60 4 0 INST, P1, LN
this PR run3 forward 1.00 0.91 0.95 7 0 INST
this PR run3 reverse 1.00 0.77 0.87 3 0 INST, LN
this PR run4 forward 1.00 0.50 0.67 3 0 INST, P1, LN
this PR run4 reverse 1.00 0.38 0.55 3 0 INST, P1, LN
this PR run5 forward 1.00 0.42 0.59 2 0 INST, P1, ZW_ENG, LN
this PR run5 reverse 1.00 0.50 0.67 4 0 INST, P1, ZW_ENG, LN
this PR run6 forward (extraction bound no types) 21 0
this PR run6 reverse 1.00 0.50 0.67 4 0 INST, ZW_ENG, LN
MAX_STEPS 8, run7 forward 1.00 0.59 0.74 2 0 INST, P2, LN
MAX_STEPS 8, run7 reverse (extraction bound no types) 21 0
MAX_STEPS 8, run8 forward 1.00 0.90 0.95 9 0 INST
MAX_STEPS 8, run8 reverse 1.00 0.53 0.70 3 0 INST, P2, LN

What the traces say, pair by pair:

  • The false merge is gone. In run1 the pair 张伟 ~ 财务部总监张伟 was routed to the second look, which kept them apart at 0.95 after reading both sides' passages ("one is 工程部工程师张伟, the other 财务部总监张伟"). No run of this PR produced a false merge; precision is 1.00 in all seven runs that resolved anything.
  • The true short-form merge now depends on the second look concluding. 海探1 ~ 海洋探测器1号 was merged by the second look in run1 forward and both orders of run3 (0.95 to 0.98, "the source passage says 海洋探测器1号(简称'海探1')"). In run2 (both orders) and run4 forward it stayed pending because the second look ended as "looked but did not conclude". In run4 reverse the pair was never proposed: the embedding endpoint failed once and that batch fell back to literal recall, which is Recall a mention by its name vector so a short form or another script meets its entity #877's designed fallback and unrelated to this PR.
  • "Looked but did not conclude" was the cost centre, and it is settled. Through run 6 (33 second looks, MAX_STEPS 6) the pattern was identical: one lookup per turn, six lookups, then two turns that never reached decide or defer. The trace recording added in the second commit showed what those turns were: requests for the two lookups the prompt still lists, namesakes and consequences, refused as over budget. Refused once, the look still concluded on its last turn; refused twice, it never got the turn. The prompt's menu is eight lookups long, so the third commit sets MAX_STEPS to 8. Runs 7 and 8 on that build: nine second looks, none inconclusive; 海探1 ~ 海洋探测器1号 merged by the second look in all four orders (0.95, 0.98, 0.99, 0.95, each citing the passage that says 海洋探测器1号 is abbreviated 海探1); the two unsure outcomes are proper deferrals that carry a question for a person. Cost per second look went from 8 chat calls to 9 or 10.
  • P2 split in two of the four MAX_STEPS 8 orders is not this PR's. In those runs extraction emitted 海洋探测器2号 and 海探2 as two entities from the same document. Channel 2 looks for neighbours when a mention is resolved, and a sibling from the same document has no name vector yet at that moment, so no pair was ever proposed and neither adjudicator saw it. That is Recall a mention by its name vector so a short form or another script meets its entity #877's resolve-at-mention timing, which cut 3 (resolve after parse) is for.
  • INST and LN stay split in every configuration, including dev: the cross-script name pairs have bge-m3 cosine around 0.50, below channel 2's 0.60 floor, so no pair is ever proposed for them. Cut 3 territory, not this PR's.

What it costs

One extra tool-using round for every similarity-proposed pair the batch step calls same: 2 to 4 pairs per 12 documents on the bench, 9 or 10 chat calls each with prompts of 2k to 4k tokens. Measured on the server's usage log, all second looks together are 5 to 6 percent of the pipeline's DeepSeek tokens for a run, and the guard's share of those is smaller still. Nothing changes for pairs from the literal channel. A second look takes 40 to 60 seconds of wall time in the background job, so a short-form merge lands a minute later than #877 applied it, with the agent's trace attached to the review.

Verified

  • cargo test -p utopia-extract 75/0 (prompt carries the line for a name-vector pair and not for a same-name pair; only name_vector| reasons become a note); cargo test -p utopia-server (same → second look regardless of confidence; different unchanged; same-name pairs keep the old rule); clippy clean; fmt clean.
  • Identity bench as tabled above: eight runs on this branch, the last two on the final build.

🤖 Generated with Claude Code

@WaylandYang

Copy link
Copy Markdown
Contributor Author

Two more bench runs with the trace recording (runs 5 and 6), and what they settled.

run order P R F1 missing false merges split
5 forward 1.00 0.42 0.59 2 0 INST, P1, ZW_ENG, LN
5 reverse 1.00 0.50 0.67 4 0 INST, P1, ZW_ENG, LN
6 forward (no anchors resolved: extraction bound no types, second time in 12 runs) 21 0
6 reverse 1.00 0.50 0.67 4 0 INST, ZW_ENG, LN

Still no false merge. The traces of the twelve second looks answer the budget question directly. After the six lookups every one of them asked for the two lookups the prompt still lists, namesakes and consequences, and was refused:

keep   0.95  tail=consequences(refused)               concluded on the last turn
merge  0.95  tail=consequences(refused)               concluded on the last turn
merge  0.98  tail=consequences(refused)               concluded on the last turn
unsure       tail=namesakes(refused) | consequences(refused)   "looked but did not conclude"
unsure       tail=ledger(refused) | consequences(refused)      "looked but did not conclude"

Refused once, it still concludes; refused twice, it never gets the turn. The model works down the menu the prompt offers, and that menu is eight lookups long (facts ×2, passages ×2, ledger ×2, namesakes, consequences), not six. Third commit: MAX_STEPS is now 8, the length of the menu. Cost per second look goes from 8 chat calls to about 10; on the bench a second look is a few fen, so this stays below a tenth of the pipeline.

Runs 7 and 8 on that build are in progress; the numbers to watch are the count of "looked but did not conclude" and whether 海探1 ~ 海洋探测器1号 merges in both orders.

Unrelated but recorded: two of twelve bench runs ended with zero typed entities after extraction, which makes every anchor missing. That is type binding, not resolution, and it is not this PR's.

WaylandYang and others added 3 commits September 23, 2026 21:14
…e; tell the adjudicator the names differ

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>
On the identity bench, roughly one second look in three ended as "the agent
looked but did not conclude": six lookups, then two more turns that never
reached decide or defer. The trace did not say what those two turns were, so
it now records a refused lookup (with the tool and arguments the model asked
for) and a turn that only spoke (its first 200 characters). Turns that hit
the limit are budgeted separately from lookups, and the limit message says
to answer with decide or defer. Whether the budget itself is too small is
what the next bench run is for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>
Two bench runs with the trace recording showed what the inconclusive second
looks were doing after six lookups: asking for the two lookups the prompt
still lists, namesakes and consequences, and being refused both. A look that
was refused once still concluded on its last turn; one refused twice never
did. Six was two short of the menu the prompt offers; eight is that menu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>
@WaylandYang
WaylandYang force-pushed the feat/name-vector-pairs-second-look branch from 11b2763 to 51beb99 Compare September 23, 2026 13:14
@WaylandYang

Copy link
Copy Markdown
Contributor Author

Runs 7 and 8 on the MAX_STEPS 8 build.

run order P R F1 missing false merges split
7 forward 1.00 0.59 0.74 2 0 INST, P2, LN
7 reverse (extraction bound no types) 21 0
8 forward 1.00 0.90 0.95 9 0 INST
8 reverse 1.00 0.53 0.70 3 0 INST, P2, LN

Nine second looks, none ended as "looked but did not conclude". 海探1 ~ 海洋探测器1号 merged in all four orders (0.95, 0.98, 0.99, 0.95), each citing the passage that abbreviates it. The two unsure outcomes now carry a question for a person (both about the two 李娜), which is what defer is for. The model still hits the limit once in most looks, asking for consequences a second time or ledger a third; refused once, it concludes.

P2 split in two orders is extraction emitting 海洋探测器2号 and 海探2 as two entities from one document; a same-document sibling has no name vector yet when the mention is resolved, so no pair was proposed and neither adjudicator saw it. That is #877's resolve-at-mention timing and belongs to cut 3, not here. The body is updated with the full table and this analysis.

@WaylandYang

Copy link
Copy Markdown
Contributor Author

Review (posted as a comment because GitHub refuses a formal review on a PR under the same account).

The direction is right and the bench write-up is convincing, but the title's "never" does not hold yet. Two paths still apply a batch same on a name_vector| pair directly:

  1. crates/utopia-server/src/adjudication.rs:252-317: when look_again returns None (daily loop budget spent, governance.rs:457-460; budget query error, :454; or an LLM/tool error in investigate, :464-467) the code falls through to put_verdict(key, Some(true), 0.85) + apply_verdict, which merges at conf >= 0.8, and the batch verdict is now cached, so every later encounter of that key skips the second look through the cached arm (:190-192). Scenario: the bench pair 张伟 ~ 财务部总监张伟 arrives after an import has used the 2000-call budget → merged at 0.85, exactly as before this PR.
  2. With governance on, extraction enqueues govern, not adjudicate_entities (extraction_open.rs:901-916), and gov::queue takes every pending pair; wants_second_look and gov::gate have no similarity check, so a same at 0.85 on a NameShape::Unrelated pair (张伟 inside 财务部总监张伟, CJK has no whole-word match) merges on the batch verdict alone (governance.rs:378-395, 498-550). The Why paired: line reaches that prompt, but a hint is not the gate the title promises.

Suggest: escalate (escalate_unsure|second_look_unavailable) rather than apply when the second look is unavailable for a similarity-proposed same, and do not cache the batch verdict in that case; add the same routing to the governor's wants_second_look, sharing similarity_proposed from the store crate instead of duplicating the name_vector| prefix in three crates; make that decision a pure function with its own test (the new tests only cover needs_second_look and the prompt line); fix the stale "六次查完" comment at governance.rs:615 now that MAX_STEPS is 8; and either soften "is never applied directly" in docs/design/identity.md:26-29 or make it true, ideally with a dated Revised note in ADR 0041 since this changes how the auto-merge line is read.

Branch is clean against dev (behind, no conflicts), CI green, DCO fine.

WaylandYang and others added 2 commits September 25, 2026 10:51
…un, under governance too

The batch verdict on a name-vector pair could still merge on two paths:
when look_again returned None (loop budget spent, model error) the
adjudicator fell through to put_verdict and apply_verdict, and the
cached verdict then skipped the second look on every later encounter;
and with governance on, wants_second_look had no similarity rule, so a
0.85 same on an unrelated-shaped pair merged on the batch alone.

Now batch_verdict_may_apply says when a batch verdict may land without
the second look (never for a similarity-proposed same), and both paths
escalate as second_look_unavailable instead, without caching. The
name_vector prefix lives once, in utopia_core::review_reasons; the
store writes it, the server reads it, and the extractor takes only the
cosine. The stale six-lookups comment follows MAX_STEPS. The design
note and 0041's status line say what the rule is now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>
@WaylandYang

Copy link
Copy Markdown
Contributor Author

Applied the review as maintainer edits so the title holds, rebased onto current dev.

  • A batch same on a name_vector| pair is never applied on its own: batch_verdict_may_apply says so in one place, and when look_again returns None the adjudicator escalates the pair as second_look_unavailable and does not cache the batch verdict, so a later encounter still gets the second look.
  • With governance on, wants_second_look routes a first-layer similarity same to the investigation, and apply escalates rather than merges when that look did not run (calls == 0), recording the decision as proposed.
  • The name_vector| prefix is spelled once, in utopia_core::review_reasons; the store writes it, the server reads it, and proposed_because takes only the cosine.
  • Pure function with its own test; the six-lookups comment follows MAX_STEPS; the design note describes the escalation and 0041's status line carries a dated revision.

Locally: core, extract (83), adjudication, governance and store resolution tests pass; clippy clean. Merging once CI agrees, after #906.

@WaylandYang
WaylandYang merged commit 2f90d8b into dev Sep 25, 2026
7 checks passed
@WaylandYang
WaylandYang deleted the feat/name-vector-pairs-second-look branch September 25, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant