From f1d3720fe13ed856bef3dcb73a9a24a74be9a787 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Wed, 23 Sep 2026 19:42:03 +0800 Subject: [PATCH 1/4] Never auto-merge a similarity-proposed pair on the batch verdict alone; tell the adjudicator the names differ Co-Authored-By: Claude Fable 5.1 Signed-off-by: Wayland Yang --- crates/utopia-extract/src/governor.rs | 8 ++- crates/utopia-extract/src/lib.rs | 73 ++++++++++++++++++++++- crates/utopia-server/src/adjudication.rs | 75 +++++++++++++++++++++++- crates/utopia-server/src/governance.rs | 1 + docs/design/identity.md | 5 +- 5 files changed, 158 insertions(+), 4 deletions(-) diff --git a/crates/utopia-extract/src/governor.rs b/crates/utopia-extract/src/governor.rs index 475e38453..8ca7b585b 100644 --- a/crates/utopia-extract/src/governor.rs +++ b/crates/utopia-extract/src/governor.rs @@ -149,8 +149,13 @@ pub fn messages(pair: &AdjudicationPair, earlier: &EarlierLook) -> Vec { format!("Precedents (decided by people in this base):\n{lines}\n") }; let why = earlier.why.map(|w| format!(" — {w}")).unwrap_or_default(); + let why_paired = pair + .proposed_because + .as_deref() + .map(|w| format!("Why paired: {w}\n")) + .unwrap_or_default(); let user = format!( - "{}\n{}\n{precedents}The earlier look said: {} ({:.2}){why}.", + "{}\n{}\n{why_paired}{precedents}The earlier look said: {} ({:.2}){why}.", side("A", &pair.left), side("B", &pair.right), earlier.verdict, @@ -288,6 +293,7 @@ mod tests { facts: vec![], }, precedents: vec!["this same pair was kept apart by a person on 2026-09-01".into()], + proposed_because: None, }; let m = messages( &pair, diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index a1b5e05db..d25359f17 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -146,6 +146,23 @@ pub struct AdjudicationPair { pub right: AdjudicationSide, /// 这个库里的人对这一对、这个名字、这种类型对做过什么(0025)。空就不提 pub precedents: Vec, + /// 这一对是**怎么**被提出来的,只在提议依据不是「同名」时写:名字向量召回(0041 第 2 刀) + /// 提的是两个**不同的字符串**,裁决器不知道这一点就会把「张伟」当成「财务部总监张伟」 + /// 去掉限定词后的同一个人——测量台上那次错合就是这么来的。空就不提,同名对照旧 + pub proposed_because: Option, +} + +/// 审核对的 `reason` 变成裁决器读得懂的一句提议依据。只认名字向量召回(`name_vector|<余弦>`): +/// 其余原因(同名灰区、同名并列、包含)都是「同一个字符串」的家族,裁决器的规则本来就是 +/// 为它们写的 +pub fn proposed_because(reason: Option<&str>) -> Option { + let reason = reason?; + let cosine = reason.strip_prefix("name_vector|")?; + Some(format!( + "the names are similar but NOT the same string (name-vector cosine {cosine}): this may be a \ + short form, another script, or a different thing with a similar name; a dropped qualifier is \ + not evidence here, the facts are" + )) } #[derive(Debug, Deserialize)] @@ -291,8 +308,13 @@ pub fn build_adjudication_messages(pairs: &[AdjudicationPair]) -> Vec bool { + item.reason + .as_deref() + .is_some_and(|r| r.starts_with("name_vector|")) +} + +fn needs_second_look( + item: &ReviewItem, + p: &gov::Precedents, + same: Option, + conf: f32, +) -> bool { + wants_another_look(item, p, same, conf) || (similarity_proposed(item) && same == Some(true)) +} + /// 一次裁决落地成了什么:第二层的行按它记 applied 还是 proposed enum Outcome { Merged(Uuid), @@ -201,6 +222,7 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul facts: item.right.top_facts.clone(), }, precedents: precedents.clone(), + proposed_because: utopia_extract::proposed_because(item.reason.as_deref()), }, ) .collect(); @@ -227,7 +249,7 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul let conf = v.confidence.unwrap_or(0.5).clamp(0.0, 1.0); // 第二层(0028):攒批没定的,带工具再看一遍再落地。预算用完或 // 循环没跑成就照攒批的看法办 - if wants_another_look(item, p, same, conf) { + if needs_second_look(item, p, same, conf) { let earlier = Look::from_batch(same, conf, v.why.clone()); if let Some(look) = look_again( state, @@ -432,3 +454,54 @@ async fn apply_verdict( }; Ok(outcome) } + +#[cfg(test)] +mod tests { + use super::*; + use utopia_core::models::{ReviewItem, ReviewSide}; + + fn item(reason: &str) -> ReviewItem { + let side = |name: &str| ReviewSide { + id: Uuid::now_v7(), + name: name.into(), + type_label: Some("person".into()), + color: String::new(), + disambiguator: None, + degree: 0, + top_facts: vec![], + }; + ReviewItem { + id: Uuid::now_v7(), + score: 0.78, + reason: Some(reason.into()), + stage: "adjudicating".into(), + created_at: chrono::Utc::now(), + left: side("张伟"), + right: side("财务部总监张伟"), + proposal: None, + } + } + + /// 名字向量提的对:批量说 same 再有把握也要第二眼;说 different / unsure 照旧 + #[test] + fn a_similarity_proposed_same_always_gets_the_second_look() { + let p = gov::Precedents::default(); + let it = item("name_vector|0.78"); + assert!(needs_second_look(&it, &p, Some(true), 0.99)); + assert!(needs_second_look(&it, &p, Some(true), 0.85)); + assert!( + !needs_second_look(&it, &p, Some(false), 0.9), + "分开是安全方向,照旧落地" + ); + assert!(needs_second_look(&it, &p, None, 0.5), "没定的本来就要再看"); + } + + /// 同名家族的对不受影响:够线就照旧自动落地 + #[test] + fn a_same_name_pair_keeps_the_old_rule() { + let p = gov::Precedents::default(); + let it = item("ambiguous_name|0.41"); + assert!(!needs_second_look(&it, &p, Some(true), 0.9)); + assert!(needs_second_look(&it, &p, Some(true), 0.6), "不到线才再看"); + } +} diff --git a/crates/utopia-server/src/governance.rs b/crates/utopia-server/src/governance.rs index 367250fca..bc1a8256a 100644 --- a/crates/utopia-server/src/governance.rs +++ b/crates/utopia-server/src/governance.rs @@ -366,6 +366,7 @@ fn pair_of(item: &ReviewItem, p: &Precedents) -> utopia_extract::AdjudicationPai left: side(&item.left), right: side(&item.right), precedents: gov::render_lines(p), + proposed_because: utopia_extract::proposed_because(item.reason.as_deref()), } } diff --git a/docs/design/identity.md b/docs/design/identity.md index d12bafb89..74e0b562c 100644 --- a/docs/design/identity.md +++ b/docs/design/identity.md @@ -23,7 +23,10 @@ vectors of the base (`name_vectors`, one row per name fact, embedded after each 0080): the nearest few within the same type family at cosine 0.60 or above are *proposed* as a `name_vector` pair for the adjudicator and never attached, so a short form or a name in another script meets its entity through a question rather than a silent second entity [0041 d3 channel 2, -#709]. Only the first channel decides anything: a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine +#709]. Such a pair says in the adjudicator's prompt that its names are similar, not the same +string, and a batch verdict of *same* on it is never applied directly: it takes the tool-using +second look first, whatever its confidence, because the identity bench showed the batch step +merging 张伟 into 财务部总监张伟 on name alone. Only the first channel decides anything: a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine 0.55 or above, makes a new entity below 0.35, and in between makes a new entity and a pair for the adjudicator; two same-name candidates within a tie margin go to a person unless a candidate's object name appears in the chunk [0041, #270, #331]. A name another entity of a compatible type already From 5c48371b518f730e9e70e72025da3e82e5f0e177 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Wed, 23 Sep 2026 20:15:55 +0800 Subject: [PATCH 2/4] The second look records what it tried after its lookup budget ran out 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 Signed-off-by: Wayland Yang --- crates/utopia-extract/src/governor.rs | 5 +++++ crates/utopia-server/src/governance.rs | 23 +++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/utopia-extract/src/governor.rs b/crates/utopia-extract/src/governor.rs index 8ca7b585b..759615040 100644 --- a/crates/utopia-extract/src/governor.rs +++ b/crates/utopia-extract/src/governor.rs @@ -13,6 +13,11 @@ use crate::{AdjudicationPair, IDENTITY_RULES}; /// 一对最多查几次再表态。够看两侧的事实与原文各一次、翻一次台账,还剩一次 pub const MAX_STEPS: usize = 6; +/// 查够了还想查时的回话:不给结果,只提醒收尾 +pub const LIMIT_REACHED: &str = "Lookup limit reached: you have seen what can be seen. \ + Answer now with decide (same or different, with your confidence) or defer (with the \ + question a person should answer)."; + /// 攒批那一眼说了什么:带进第二眼,模型知道自己上次为什么没定 pub struct EarlierLook<'a> { /// same | different | unsure diff --git a/crates/utopia-server/src/governance.rs b/crates/utopia-server/src/governance.rs index bc1a8256a..00f514a4d 100644 --- a/crates/utopia-server/src/governance.rs +++ b/crates/utopia-server/src/governance.rs @@ -608,9 +608,13 @@ async fn investigate( let mut trace: Vec = Vec::new(); let mut calls = 0; let mut nudged = false; + let mut walls = 0; + let mut lookups = 0; - // 回合上限 = 查询次数 + 收尾那一次 + 一次提醒 - for _ in 0..(governor::MAX_STEPS + 2) { + // 回合上限 = 查询次数 + 撞两次上限 + 一次提醒 + 收尾那一次。模型多半一回合只查一件事, + // 六次查完常常还想再查:撞上限的那一回合得算在预算外,不然它连收尾的机会都没有 + // (identity bench 上,第二眼「看了没收尾」九次里有五次是这么来的) + for _ in 0..(governor::MAX_STEPS + 4) { let turn = { let _permit = permit(ctx).await; ctx.client.chat_tools(&messages, &tools).await? @@ -618,6 +622,8 @@ async fn investigate( calls += 1; messages.push(turn.to_message()); if turn.tool_calls.is_empty() { + // 没调工具就说话:记下它说了什么,下次看轨迹能知道它卡在哪 + trace.push(json!({ "said": turn.content.as_deref().unwrap_or("").chars().take(200).collect::() })); if nudged { break; } @@ -652,9 +658,14 @@ async fn investigate( }); } Step::Lookup { tool, args } => { - let out = if trace.len() >= governor::MAX_STEPS { - "Lookup limit reached; finish with decide or defer.".to_string() + let out = if lookups >= governor::MAX_STEPS { + walls += 1; + trace.push( + json!({ "tool": tool, "args": args, "note": "refused: lookup limit" }), + ); + governor::LIMIT_REACHED.to_string() } else { + lookups += 1; let (out, note) = lookup(ctx, item, &tool, &args).await?; trace.push(json!({ "tool": tool, "args": args, "note": note })); out @@ -666,6 +677,10 @@ async fn investigate( } } } + // 撞了两次上限还在查:不会收尾了,别再花回合 + if walls >= 2 { + break; + } } // 看了,没收尾:当没定,轨迹留下 Ok(Look { From 51beb99a0e1379e397d3261331bd453caadaafc3 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Wed, 23 Sep 2026 21:08:23 +0800 Subject: [PATCH 3/4] The second look may look eight times, the length of its own menu 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 Signed-off-by: Wayland Yang --- crates/utopia-extract/src/governor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/utopia-extract/src/governor.rs b/crates/utopia-extract/src/governor.rs index 759615040..76ffb0f87 100644 --- a/crates/utopia-extract/src/governor.rs +++ b/crates/utopia-extract/src/governor.rs @@ -10,8 +10,10 @@ use serde_json::{json, Value}; use crate::{AdjudicationPair, IDENTITY_RULES}; -/// 一对最多查几次再表态。够看两侧的事实与原文各一次、翻一次台账,还剩一次 -pub const MAX_STEPS: usize = 6; +/// 一对最多查几次再表态。模型按菜单走:两侧的事实、两侧的原文、两个名字的台账、 +/// 同名者、合并会碰到什么——八次。原来给六次,identity bench 上第二眼三分之一 +/// 「看了没收尾」:轨迹显示它把最后两次花在被拒的同名者与后果查询上,没回合表态 +pub const MAX_STEPS: usize = 8; /// 查够了还想查时的回话:不给结果,只提醒收尾 pub const LIMIT_REACHED: &str = "Lookup limit reached: you have seen what can be seen. \ From 23a65bd673cd1b564fd4bfa062e25ec5ab6c8c61 Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Fri, 25 Sep 2026 10:55:41 +0800 Subject: [PATCH 4/4] A similarity-proposed same is escalated when the second look cannot run, 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 Signed-off-by: Wayland Yang --- crates/utopia-core/src/lib.rs | 40 ++++++++++++++ crates/utopia-extract/src/lib.rs | 29 ++++------- crates/utopia-server/src/adjudication.rs | 52 +++++++++++++++++-- crates/utopia-server/src/governance.rs | 36 +++++++++++-- crates/utopia-store/src/resolution.rs | 6 ++- .../0041-a-name-is-a-claim-about-an-entity.md | 2 +- docs/design/identity.md | 6 ++- 7 files changed, 140 insertions(+), 31 deletions(-) diff --git a/crates/utopia-core/src/lib.rs b/crates/utopia-core/src/lib.rs index 70e46f478..165c0e0fe 100644 --- a/crates/utopia-core/src/lib.rs +++ b/crates/utopia-core/src/lib.rs @@ -8,3 +8,43 @@ pub mod text; pub use error::{is_deferred, is_terminal, AppError, AppResult, Deferred, Terminal}; pub use text::without_nul; + +/// 审核对的 `reason` 里,召回通道留下的记号。名字向量召回(0041 第 2 刀通道 2)提的是两个 +/// **不同的字符串**,和同名家族(`shared_name|`、`ambiguous_name|`、`namesake_tie|`)是两种 +/// 证据强度:裁决器读它、治理闸门看它、召回写它,都从这里认,不各自拼前缀 +pub mod review_reasons { + /// `name_vector|<余弦>`:名字向量召回提的对 + pub const NAME_VECTOR: &str = "name_vector|"; + + /// 这一对是名字向量召回提出来的(两个相近但不同的字符串) + pub fn similarity_proposed(reason: Option<&str>) -> bool { + reason.is_some_and(|r| r.starts_with(NAME_VECTOR)) + } + + /// 名字向量召回记下的余弦文本;不是这种对时为 None + pub fn name_vector_cosine(reason: Option<&str>) -> Option<&str> { + reason?.strip_prefix(NAME_VECTOR) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn only_the_name_vector_prefix_counts() { + assert!(similarity_proposed(Some("name_vector|0.78"))); + assert_eq!(name_vector_cosine(Some("name_vector|0.78")), Some("0.78")); + for r in [ + "ambiguous_name|0.41", + "namesake_tie|0.55", + "shared_name|张伟", + "contains", + "", + ] { + assert!(!similarity_proposed(Some(r)), "{r}"); + assert_eq!(name_vector_cosine(Some(r)), None, "{r}"); + } + assert!(!similarity_proposed(None)); + } + } +} diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index d25359f17..8af91dff7 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -152,12 +152,12 @@ pub struct AdjudicationPair { pub proposed_because: Option, } -/// 审核对的 `reason` 变成裁决器读得懂的一句提议依据。只认名字向量召回(`name_vector|<余弦>`): -/// 其余原因(同名灰区、同名并列、包含)都是「同一个字符串」的家族,裁决器的规则本来就是 -/// 为它们写的 -pub fn proposed_because(reason: Option<&str>) -> Option { - let reason = reason?; - let cosine = reason.strip_prefix("name_vector|")?; +/// 名字向量召回提的对,写成裁决器读得懂的一句提议依据;`cosine` 是召回记下的余弦文本 +/// (`utopia_core::review_reasons::name_vector_cosine` 从审核对的 reason 里取)。其余原因 +/// (同名灰区、同名并列、包含)都是「同一个字符串」的家族,裁决器的规则本来就是为它们写的, +/// 传 None +pub fn proposed_because(cosine: Option<&str>) -> Option { + let cosine = cosine?; Some(format!( "the names are similar but NOT the same string (name-vector cosine {cosine}): this may be a \ short form, another script, or a different thing with a similar name; a dropped qualifier is \ @@ -1101,13 +1101,13 @@ mod tests { left: side("张伟"), right: side("财务部总监张伟"), precedents: vec![], - proposed_because: proposed_because(Some("name_vector|0.78")), + proposed_because: proposed_because(Some("0.78")), }, AdjudicationPair { left: side("张伟"), right: side("张伟"), precedents: vec![], - proposed_because: proposed_because(Some("ambiguous_name|0.41")), + proposed_because: proposed_because(None), }, ]; let user = &build_adjudication_messages(&pairs)[1].content; @@ -1124,17 +1124,8 @@ mod tests { } #[test] - fn only_name_vector_reasons_become_a_proposal_note() { - assert!(proposed_because(Some("name_vector|0.62")).is_some()); - for r in [ - "ambiguous_name|0.41", - "namesake_tie|0.55", - "shared_name|张伟", - "contains", - "", - ] { - assert!(proposed_because(Some(r)).is_none(), "{r}"); - } + fn a_proposal_note_needs_a_cosine() { + assert!(proposed_because(Some("0.62")).is_some()); assert!(proposed_because(None).is_none()); } diff --git a/crates/utopia-server/src/adjudication.rs b/crates/utopia-server/src/adjudication.rs index 6e0a55b6a..8012b4a51 100644 --- a/crates/utopia-server/src/adjudication.rs +++ b/crates/utopia-server/src/adjudication.rs @@ -63,9 +63,7 @@ fn wants_another_look( /// 照旧,分开是安全的方向。不看 `ruled` 与撤回:那两条是「再看也改不了」的省事,这里要的 /// 恰恰是再看 fn similarity_proposed(item: &ReviewItem) -> bool { - item.reason - .as_deref() - .is_some_and(|r| r.starts_with("name_vector|")) + utopia_core::review_reasons::similarity_proposed(item.reason.as_deref()) } fn needs_second_look( @@ -74,9 +72,19 @@ fn needs_second_look( same: Option, conf: f32, ) -> bool { - wants_another_look(item, p, same, conf) || (similarity_proposed(item) && same == Some(true)) + wants_another_look(item, p, same, conf) || !batch_verdict_may_apply(item, same) } +/// 攒批那一眼的看法能不能不经第二眼就落地。名字向量提的对说 same 不能:第二眼没跑成 +/// (预算用完、模型出错)就上交给人,不照攒批的看法合,也不把那个看法记进缓存——记了 +/// 之后这一对每次再来都从缓存直接合,第二眼永远轮不到(#889 评审) +pub(crate) fn batch_verdict_may_apply(item: &ReviewItem, same: Option) -> bool { + !(similarity_proposed(item) && same == Some(true)) +} + +/// 第二眼没跑成时上交的理由 +pub(crate) const SECOND_LOOK_UNAVAILABLE: &str = "escalate_unsure|second_look_unavailable"; + /// 一次裁决落地成了什么:第二层的行按它记 applied 还是 proposed enum Outcome { Merged(Uuid), @@ -222,7 +230,9 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul facts: item.right.top_facts.clone(), }, precedents: precedents.clone(), - proposed_because: utopia_extract::proposed_because(item.reason.as_deref()), + proposed_because: utopia_extract::proposed_because( + utopia_core::review_reasons::name_vector_cosine(item.reason.as_deref()), + ), }, ) .collect(); @@ -295,6 +305,16 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul record_look(state, kb_id, run_id, item, p, &look, &outcome).await?; continue; } + // 第二眼没跑成:相似提的 same 不能照攒批的看法合,也不进缓存 + if !batch_verdict_may_apply(item, same) { + utopia_store::resolution::escalate_review( + &state.pool, + item.id, + SECOND_LOOK_UNAVAILABLE, + ) + .await?; + continue; + } } utopia_store::resolution::put_verdict( &state.pool, @@ -496,6 +516,28 @@ mod tests { assert!(needs_second_look(&it, &p, None, 0.5), "没定的本来就要再看"); } + /// 第二眼没跑成时:相似提的 same 不落地也不进缓存;其余照攒批的看法办 + #[test] + fn a_similarity_proposed_same_never_applies_on_the_batch_verdict_alone() { + assert!(!batch_verdict_may_apply( + &item("name_vector|0.78"), + Some(true) + )); + assert!(batch_verdict_may_apply( + &item("name_vector|0.78"), + Some(false) + )); + assert!(batch_verdict_may_apply(&item("name_vector|0.78"), None)); + assert!(batch_verdict_may_apply( + &item("ambiguous_name|0.41"), + Some(true) + )); + assert!(batch_verdict_may_apply( + &item("shared_name|张伟"), + Some(true) + )); + } + /// 同名家族的对不受影响:够线就照旧自动落地 #[test] fn a_same_name_pair_keeps_the_old_rule() { diff --git a/crates/utopia-server/src/governance.rs b/crates/utopia-server/src/governance.rs index 00f514a4d..34334da68 100644 --- a/crates/utopia-server/src/governance.rs +++ b/crates/utopia-server/src/governance.rs @@ -366,7 +366,9 @@ fn pair_of(item: &ReviewItem, p: &Precedents) -> utopia_extract::AdjudicationPai left: side(&item.left), right: side(&item.right), precedents: gov::render_lines(p), - proposed_because: utopia_extract::proposed_because(item.reason.as_deref()), + proposed_because: utopia_extract::proposed_because( + utopia_core::review_reasons::name_vector_cosine(item.reason.as_deref()), + ), } } @@ -387,10 +389,15 @@ fn wants_second_look(item: &ReviewItem, p: &Precedents, look: &Look) -> bool { && look.calls == 0; let doubted_merge = name_doubts(shape) && !types_conflict && look.same == Some(true) && look.calls == 0; + // 名字向量提的对说 same:不论把握多高都先带工具看一遍(与不带治理的裁决同一条规矩, + // `adjudication::batch_verdict_may_apply`);只看第一层的,第二层看过的不再看 + let similarity_same = + !crate::adjudication::batch_verdict_may_apply(item, look.same) && look.calls == 0; ((gov::gate(look.same, look.conf, types_conflict, shape, p) == Gate::Propose && look.uncertain()) || doubted_split - || doubted_merge) + || doubted_merge + || similarity_same) && p.reverts.is_empty() } @@ -495,6 +502,29 @@ async fn apply(ctx: &Ctx<'_>, item: &ReviewItem, p: &Precedents, look: Look) -> calls: look.calls, }; + // 第二眼没跑成(预算用完、模型出错)的相似提的 same:过闸也不合,上交给人, + // 看法照记成建议——名字相近不是同一个东西的证据,事实才是 + if look.same == Some(true) + && look.calls == 0 + && !crate::adjudication::batch_verdict_may_apply(item, look.same) + { + utopia_store::resolution::escalate_review( + pool, + item.id, + crate::adjudication::SECOND_LOOK_UNAVAILABLE, + ) + .await?; + gov::record( + pool, + kb_id, + NewDecision { + reason: Some("held for a person: the names are similar, not the same string, and the second look did not run"), + ..decision("proposed", None) + }, + ) + .await?; + return Ok(()); + } match gov::gate(look.same, look.conf, types_conflict, shape, p) { Gate::Apply if look.same == Some(true) => { let reason = format!("governed|{conf:.2}"); @@ -612,7 +642,7 @@ async fn investigate( let mut lookups = 0; // 回合上限 = 查询次数 + 撞两次上限 + 一次提醒 + 收尾那一次。模型多半一回合只查一件事, - // 六次查完常常还想再查:撞上限的那一回合得算在预算外,不然它连收尾的机会都没有 + // 查够 MAX_STEPS 次常常还想再查:撞上限的那一回合得算在预算外,不然它连收尾的机会都没有 // (identity bench 上,第二眼「看了没收尾」九次里有五次是这么来的) for _ in 0..(governor::MAX_STEPS + 4) { let turn = { diff --git a/crates/utopia-store/src/resolution.rs b/crates/utopia-store/src/resolution.rs index 14549d698..fc4c706ae 100644 --- a/crates/utopia-store/src/resolution.rs +++ b/crates/utopia-store/src/resolution.rs @@ -290,7 +290,11 @@ pub async fn resolve_mention( r.reviews.push(ReviewRequest { other_id: near.entity_id, score: near.similarity, - reason: format!("name_vector|{:.2}", near.similarity), + reason: format!( + "{}{:.2}", + utopia_core::review_reasons::NAME_VECTOR, + near.similarity + ), stage: ReviewStage::Adjudicating, }); } diff --git a/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md b/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md index 14dff1554..5405633f5 100644 --- a/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md +++ b/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md @@ -1,6 +1,6 @@ # 0041 · A name is a claim about an entity -- **Status**: decision 1 settled 2026-09-13 (names are facts) · cut 0 built: `scripts/bench/identity.mjs`, baseline on `dev` forward F1 0.43, reverse 0.54 · cut 1 implemented (#670): migration 0055 and `names` in the store, the extractor's `names`, shared-name pairs to the adjudicator; forward 0.68, reverse 0.68, the two orders agree on all 210 pairs (one run each; two cut-1 runs differed by 0.07 forward) · re-measured on DeepSeek-V3 after the review fixes: dev 0.46 / 0.40 (2 of 21 anchors unresolved), cut 1 0.61 / 0.44–0.53 over three runs; on V3 the adjudicator keeps 海洋探测器1号 and 海探1 apart in reverse order even with the shared name listed, which is cut 3's question · decisions 2 and 5 revised by cut 1 · cut 2 channel 2 (name vectors) built 2026-09-23: migration 0080 `name_vectors`, recall proposes a `name_vector|` pair for the adjudicator and never merges; channel 3 (neighbours) and the retirement of `recall_keys` wait for the bench · cuts 3–4 not started +- **Status**: decision 1 settled 2026-09-13 (names are facts) · cut 0 built: `scripts/bench/identity.mjs`, baseline on `dev` forward F1 0.43, reverse 0.54 · cut 1 implemented (#670): migration 0055 and `names` in the store, the extractor's `names`, shared-name pairs to the adjudicator; forward 0.68, reverse 0.68, the two orders agree on all 210 pairs (one run each; two cut-1 runs differed by 0.07 forward) · re-measured on DeepSeek-V3 after the review fixes: dev 0.46 / 0.40 (2 of 21 anchors unresolved), cut 1 0.61 / 0.44–0.53 over three runs; on V3 the adjudicator keeps 海洋探测器1号 and 海探1 apart in reverse order even with the shared name listed, which is cut 3's question · decisions 2 and 5 revised by cut 1 · cut 2 channel 2 (name vectors) built 2026-09-23: migration 0080 `name_vectors`, recall proposes a `name_vector|` pair for the adjudicator and never merges; revised 2026-09-25 (#889): a batch *same* on such a pair is never applied on its own, it takes the tool-using second look or goes to a person when that look cannot run; channel 3 (neighbours) and the retirement of `recall_keys` wait for the bench · cuts 3–4 not started - **Written**: 2026-09-13 (conventions in the [README](README.md)) - **Related**: [0009](0009-no-type-is-a-type.md) made an undecided type an honest state, and [0016](0016-close-the-open-seams-before-cutting-new-ones.md) B3 let a declared `disjointWith` keep names apart; #270 stopped a namesake tie from being settled by candidate order and #331 let facts break it; #428 and [0025](0025-governance-reads-the-ledger-before-it-decides.md) moved duplicates through a queue an agent works; #582 and #583 made the extractor copy the words that name each side of a fact; [0037](0037-a-relation-carries-its-own-attributes.md) put attributes on edges. diff --git a/docs/design/identity.md b/docs/design/identity.md index 74e0b562c..36d8b87c7 100644 --- a/docs/design/identity.md +++ b/docs/design/identity.md @@ -25,8 +25,10 @@ vectors of the base (`name_vectors`, one row per name fact, embedded after each script meets its entity through a question rather than a silent second entity [0041 d3 channel 2, #709]. Such a pair says in the adjudicator's prompt that its names are similar, not the same string, and a batch verdict of *same* on it is never applied directly: it takes the tool-using -second look first, whatever its confidence, because the identity bench showed the batch step -merging 张伟 into 财务部总监张伟 on name alone. Only the first channel decides anything: a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine +second look first, whatever its confidence, and when that look cannot run (the daily loop budget +is spent, the model fails) the pair goes to a person as `second_look_unavailable` instead, with the +batch verdict left out of the verdict cache; the same rule holds with governance on. The identity +bench showed the batch step merging 张伟 into 财务部总监张伟 on name alone. Only the first channel decides anything: a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine 0.55 or above, makes a new entity below 0.35, and in between makes a new entity and a pair for the adjudicator; two same-name candidates within a tie margin go to a person unless a candidate's object name appears in the chunk [0041, #270, #331]. A name another entity of a compatible type already