diff --git a/crates/utopia-extract/src/governor.rs b/crates/utopia-extract/src/governor.rs index 26c8c7377..475e38453 100644 --- a/crates/utopia-extract/src/governor.rs +++ b/crates/utopia-extract/src/governor.rs @@ -1,7 +1,8 @@ //! 治理的第二层(0025 第二刀):攒批判不定的对,逐条带工具再看一遍。 //! //! 模型能看的东西:一侧的全部事实、一侧的原文片段、台账里人对某个名字的决定、 -//! 库里名字相近的其他实体。结束只有两种:`decide`(same / different + 置信度 + +//! 库里名字相近的其他实体、合并会牵动什么(0028:一致性检查会开出的矛盾、靠着 +//! 一边的派生、点过名的回答、两边的类型是不是一个大类)。结束只有两种:`decide`(same / different + 置信度 + //! 一句理由)或 `defer`(留给人一个具体的问题)。工具定义、提示词与回合的解析 //! 在这里;跑循环、查库的在 server 的 governance 任务里——这里不碰库也不碰模型。 @@ -23,7 +24,7 @@ pub struct EarlierLook<'a> { /// 模型在一个回合里要的事 #[derive(Debug, PartialEq)] pub enum Step { - /// 查一样东西:facts / quotes / ledger / namesakes + /// 查一样东西:facts / quotes / ledger / namesakes / consequences Lookup { tool: String, args: Value, @@ -69,6 +70,10 @@ pub fn tools() -> Value { "name": "namesakes", "description": "Other entities in this base whose name contains the query, with their type and how many facts they carry.", "parameters": query }}, + { "type": "function", "function": { + "name": "consequences", + "description": "What merging A and B would touch: relations that allow one value where the two sides hold different ones, derived facts resting on either side, chat answers that named either side, and whether the two types belong to one family. A merge that would touch any of these is held for a person whatever your confidence.", + "parameters": { "type": "object", "properties": {} } }}, { "type": "function", "function": { "name": "decide", "description": "Give the verdict for this pair.", @@ -105,7 +110,11 @@ pub fn messages(pair: &AdjudicationPair, earlier: &EarlierLook) -> Vec { \n\ You may look things up before answering, at most {MAX_STEPS} lookups: the facts of a \ side, the source passages that mention a side, what people in this base decided about \ - a name, and other entities with a similar name. People's earlier decisions are how the \ + a name, other entities with a similar name, and what merging the two would touch (a \ + relation that allows one value where the sides hold different ones is a contradiction, \ + and a merge that would touch anything outside the graph is held for a person whatever \ + your confidence: prefer to defer with the question that would settle it). People's \ + earlier decisions are how the \ owners of this base want such cases judged; follow them unless the facts of this pair \ clearly differ, and never let one override a contradiction in the facts. Look only for \ what would change your answer.\n\ @@ -174,6 +183,10 @@ pub fn read_step(name: &str, arguments: &str) -> Step { }, _ => Step::Unknown(format!("{name} needs a query")), }, + "consequences" => Step::Lookup { + tool: name.into(), + args: json!({}), + }, "decide" => { let same = match args["verdict"].as_str() { Some("same") => true, @@ -299,7 +312,15 @@ mod tests { .collect(); assert_eq!( names, - ["facts", "quotes", "ledger", "namesakes", "decide", "defer"] + [ + "facts", + "quotes", + "ledger", + "namesakes", + "consequences", + "decide", + "defer" + ] ); } } diff --git a/crates/utopia-server/src/adjudication.rs b/crates/utopia-server/src/adjudication.rs index d6186ecee..14644a18c 100644 --- a/crates/utopia-server/src/adjudication.rs +++ b/crates/utopia-server/src/adjudication.rs @@ -3,6 +3,7 @@ //! 高置信 same → 自动合并(可回滚),高置信 different → 自动保持分开, //! 其余转人工。未配模型时全部转人工——本任务失败或缺席都不影响抽取与查询。 +use crate::governance::{look_again, Look}; use crate::llm_util; use crate::state::AppState; use sha2::{Digest, Sha256}; @@ -45,6 +46,74 @@ fn sampled_for_a_person(item: &ReviewItem) -> bool { item.id.as_u128() % 100 < HUMAN_SAMPLE_PCT } +/// 攒批没定的对要不要带工具再看一遍(0028):没判决或把握不到线;硬规则拦得住的不看 +/// (大类不同、版本尾巴、含名字的一句话——再看也改不了规则);有撤回的不看,那是人的事 +fn wants_another_look( + item: &ReviewItem, + p: &gov::Precedents, + same: Option, + conf: f32, +) -> bool { + let unsettled = same.is_none() || conf < AUTO_CONF; + let ruled = gov::types_conflict( + item.left.type_label.as_deref(), + item.right.type_label.as_deref(), + ) || matches!( + gov::name_shape(&item.left.name, &item.right.name), + gov::NameShape::Version | gov::NameShape::Phrase + ); + unsettled && !ruled && p.reverts.is_empty() +} + +/// 一次裁决落地成了什么:第二层的行按它记 applied 还是 proposed +enum Outcome { + Merged(Uuid), + Kept, + Escalated, +} + +/// 第二层看过的一对,记一行 `agent_decisions`(0028):轨迹、问题、花的调用都在里面。 +/// 预算按这些行算,Agent 队列也从这里读——治理开没开,机器去看过的都看得见 +async fn record_look( + state: &AppState, + kb_id: Uuid, + run_id: Uuid, + item: &ReviewItem, + p: &gov::Precedents, + look: &Look, + outcome: &Outcome, +) -> anyhow::Result<()> { + let action = match look.same { + Some(true) => "merge", + Some(false) => "keep", + None => "unsure", + }; + let (status, merge_id) = match outcome { + Outcome::Merged(id) => ("applied", Some(*id)), + Outcome::Kept => ("applied", None), + Outcome::Escalated => ("proposed", None), + }; + gov::record( + &state.pool, + kb_id, + gov::NewDecision { + run_id, + target_id: item.id, + action, + confidence: look.conf, + reason: look.why.as_deref(), + precedents: gov::precedents_json(p), + status, + merge_id, + question: look.question.as_deref(), + trace: serde_json::Value::Array(look.trace.clone()), + calls: look.calls, + }, + ) + .await?; + Ok(()) +} + pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> { let kb = utopia_store::kbs::get(&state.pool, kb_id).await?; let settings = utopia_store::settings::get(&state.pool, kb.workspace_id).await?; @@ -65,6 +134,8 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul state.emit_review(kb_id); return Ok(()); }; + // 第二层的行都挂在这一次任务上 + let run_id = Uuid::now_v7(); for _ in 0..MAX_ROUNDS { let items = @@ -75,16 +146,16 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul // 第一层:裁决缓存。先例(人在这个库里对这些名字做过什么,连同他们写的理由) // 先取出来:它既进提示词也进缓存键 - let mut to_ask: Vec<(ReviewItem, String, Vec)> = Vec::new(); + let mut to_ask: Vec<(ReviewItem, String, gov::Precedents, Vec)> = Vec::new(); for item in items { - let precedents = - gov::render_lines(&gov::precedents_for(&state.pool, kb_id, &item).await?); + let p = gov::precedents_for(&state.pool, kb_id, &item).await?; + let precedents = gov::render_lines(&p); let key = pair_key(&item, &precedents); match utopia_store::resolution::get_verdict(&state.pool, kb_id, &key).await? { Some((same, conf)) => { - apply_verdict(state, kb_id, &item, same, conf, "cached", None).await? + apply_verdict(state, kb_id, &item, same, conf, "cached", None).await?; } - None => to_ask.push((item, key, precedents)), + None => to_ask.push((item, key, p, precedents)), } } if to_ask.is_empty() { @@ -94,27 +165,29 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul // 第二层:攒批 LLM 裁决 let pairs: Vec = to_ask .iter() - .map(|(item, _, precedents)| utopia_extract::AdjudicationPair { - left: utopia_extract::AdjudicationSide { - name: item.left.name.clone(), - type_label: item - .left - .type_label - .clone() - .unwrap_or_else(|| "untyped".into()), - facts: item.left.top_facts.clone(), - }, - right: utopia_extract::AdjudicationSide { - name: item.right.name.clone(), - type_label: item - .right - .type_label - .clone() - .unwrap_or_else(|| "untyped".into()), - facts: item.right.top_facts.clone(), + .map( + |(item, _, _, precedents)| utopia_extract::AdjudicationPair { + left: utopia_extract::AdjudicationSide { + name: item.left.name.clone(), + type_label: item + .left + .type_label + .clone() + .unwrap_or_else(|| "untyped".into()), + facts: item.left.top_facts.clone(), + }, + right: utopia_extract::AdjudicationSide { + name: item.right.name.clone(), + type_label: item + .right + .type_label + .clone() + .unwrap_or_else(|| "untyped".into()), + facts: item.right.top_facts.clone(), + }, + precedents: precedents.clone(), }, - precedents: precedents.clone(), - }) + ) .collect(); let messages = utopia_extract::build_adjudication_messages(&pairs); // 调用/解析失败 → 任务按退避重试;重试耗尽后行停留在队列里,人工仍可定夺 @@ -128,7 +201,7 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul let by_i: HashMap = verdicts.iter().map(|v| (v.i, v)).collect(); - for (idx, (item, key, _)) in to_ask.iter().enumerate() { + for (idx, (item, key, p, _)) in to_ask.iter().enumerate() { match by_i.get(&idx) { Some(v) => { let same = match v.verdict.as_str() { @@ -137,6 +210,55 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul _ => None, }; let conf = v.confidence.unwrap_or(0.5).clamp(0.0, 1.0); + // 第二层(0028):攒批没定的,带工具再看一遍再落地。预算用完或 + // 循环没跑成就照攒批的看法办 + if wants_another_look(item, p, same, conf) { + let earlier = Look::from_batch(same, conf, v.why.clone()); + if let Some(look) = look_again( + state, + kb_id, + &client, + &settings, + item, + &pairs[idx], + &earlier, + ) + .await + { + let outcome = if look.same.is_some() { + utopia_store::resolution::put_verdict( + &state.pool, + kb_id, + key, + look.same, + look.conf, + &model, + ) + .await?; + apply_verdict( + state, + kb_id, + item, + look.same, + look.conf, + "investigated", + look.why.as_deref(), + ) + .await? + } else { + // 问了一个问题,或看了没结论:留给人,卡片上带着建议与问题 + utopia_store::resolution::escalate_review( + &state.pool, + item.id, + "proposed", + ) + .await?; + Outcome::Escalated + }; + record_look(state, kb_id, run_id, item, p, &look, &outcome).await?; + continue; + } + } utopia_store::resolution::put_verdict( &state.pool, kb_id, @@ -182,7 +304,7 @@ async fn apply_verdict( conf: f32, via: &str, why: Option<&str>, -) -> anyhow::Result<()> { +) -> anyhow::Result { // 抽给人的那一份:机器有把握也不动手(0026)。理由写进队列那一列, // 界面会说"这一对是抽样给你的",而不是让人以为裁决器没把握 if same.is_some() && conf >= AUTO_CONF && sampled_for_a_person(item) { @@ -197,9 +319,9 @@ async fn apply_verdict( &format!("escalate_sample|{verdict} {conf:.2}"), ) .await?; - return Ok(()); + return Ok(Outcome::Escalated); } - match same { + let outcome = match same { Some(true) if conf >= AUTO_CONF => { // 执行闸门(0027):合并会立刻送出图外的东西——违规、派生、答案——留给人, // 把握再高也不动手。人看到的是留下的原因,不是「裁决器没把握」 @@ -217,7 +339,7 @@ async fn apply_verdict( &format!("escalate_impact|{hold}"), ) .await?; - return Ok(()); + return Ok(Outcome::Escalated); } let (target, source) = utopia_store::resolution::merge_direction(&state.pool, item.left.id, item.right.id) @@ -233,7 +355,7 @@ async fn apply_verdict( ) .await { - Ok(_) => { + Ok(merge_id) => { utopia_store::resolution::close_review_auto( &state.pool, item.id, @@ -258,6 +380,7 @@ async fn apply_verdict( }), ) .await; + Outcome::Merged(merge_id) } // 同批次连锁合并可能已吞掉其中一方:转人工而不是让任务失败 Err(AppError::Conflict(_)) | Err(AppError::NotFound) => { @@ -267,6 +390,7 @@ async fn apply_verdict( "escalate_entity_changed", ) .await?; + Outcome::Escalated } Err(e) => return Err(e.into()), } @@ -293,6 +417,7 @@ async fn apply_verdict( }), ) .await; + Outcome::Kept } _ => { utopia_store::resolution::escalate_review( @@ -301,7 +426,8 @@ async fn apply_verdict( &format!("escalate_unsure|{via} {conf:.2}"), ) .await?; + Outcome::Escalated } - } - Ok(()) + }; + Ok(outcome) } diff --git a/crates/utopia-server/src/governance.rs b/crates/utopia-server/src/governance.rs index ea98b6109..3e4253822 100644 --- a/crates/utopia-server/src/governance.rs +++ b/crates/utopia-server/src/governance.rs @@ -48,21 +48,22 @@ struct Ctx<'a> { settings: &'a Option, } -/// 对一对的一次看法:第一层给的,或第二层看完改过的 -struct Look { - same: Option, - conf: f32, - why: Option, +/// 对一对的一次看法:第一层给的,或第二层看完改过的。裁决器(治理关着时)也用它: +/// 判不定的对走同一个第二层(0028) +pub(crate) struct Look { + pub(crate) same: Option, + pub(crate) conf: f32, + pub(crate) why: Option, /// defer 留下的问题 - question: Option, + pub(crate) question: Option, /// 第二层看了什么 - trace: Vec, + pub(crate) trace: Vec, /// 第二层花的模型调用 - calls: i32, + pub(crate) calls: i32, } impl Look { - fn from_batch(same: Option, conf: f32, why: Option) -> Self { + pub(crate) fn from_batch(same: Option, conf: f32, why: Option) -> Self { Look { same, conf, @@ -386,6 +387,27 @@ fn wants_second_look(item: &ReviewItem, p: &Precedents, look: &Look) -> bool { && p.reverts.is_empty() } +/// 裁决器的入口(0028):治理关着,攒批判不定的对也带工具再看一遍——同一个循环、 +/// 同一份预算。每次调用一个 run_id:一次裁决任务就是一次 run +pub(crate) async fn look_again( + state: &AppState, + kb_id: Uuid, + client: &LlmClient, + settings: &Option, + item: &ReviewItem, + pair: &utopia_extract::AdjudicationPair, + earlier: &Look, +) -> Option { + let ctx = Ctx { + state, + kb_id, + run_id: Uuid::now_v7(), + client, + settings, + }; + second_look(&ctx, item, pair, earlier).await +} + /// 第二层看一对:预算够就看,看完的看法替掉第一层的;看不成(预算用完、模型出错)回 None, /// 照第一层的看法办 async fn second_look( @@ -711,6 +733,24 @@ async fn lookup( }; (out, format!("{n} decisions about \"{q}\"")) } + // 合并会牵动什么(0028):模型先看到闸门(0027)会看到的东西,再决定是裁还是问 + "consequences" => { + let impact = + execution_gate::impact_of(pool, kb_id, item.left.id, item.right.id).await?; + let families = if gov::types_conflict( + item.left.type_label.as_deref(), + item.right.type_label.as_deref(), + ) { + "\n- the two types belong to different families; the rules never merge across families" + } else { + "" + }; + let out = format!("{}{families}", impact.describe()); + let held = execution_gate::hold(&impact) + .map(|h| h.to_string()) + .unwrap_or_else(|| "nothing held".into()); + (out, format!("what a merge would touch: {held}")) + } "namesakes" => { let q = args["query"].as_str().unwrap_or(""); let rows = gov::namesakes(pool, kb_id, q, 10).await?; diff --git a/crates/utopia-store/src/execution_gate.rs b/crates/utopia-store/src/execution_gate.rs index ce217cd77..dfde2c68f 100644 --- a/crates/utopia-store/src/execution_gate.rs +++ b/crates/utopia-store/src/execution_gate.rs @@ -60,6 +60,38 @@ impl Hold { } } +impl Impact { + /// 给第二层的工具看的一段(0028):合并会牵动什么,一行一件;什么都不牵动也说出来 + pub fn describe(&self) -> String { + let mut lines = Vec::new(); + for p in &self.contradictions { + lines.push(format!( + "- merging would put two \"{p}\" facts on one entity; \"{p}\" allows one value" + )); + } + if self.derived > 0 { + lines.push(format!( + "- {} derived fact(s) rest on one side and would be rewritten", + self.derived + )); + } + if self.answered > 0 { + lines.push(format!( + "- one side was named in {} chat answer(s)", + self.answered + )); + } + if lines.is_empty() { + "(merging would touch nothing outside the graph: no one-value relation clashes, no derived facts, no answers named either side)".to_string() + } else { + lines.join( + " +", + ) + } + } +} + /// 这一次合并该不该留给人。矛盾最先说——它会立刻开出违规;其次派生,其次答案。 /// 什么都不牵动就是 None:把握够就照旧自动 pub fn hold(impact: &Impact) -> Option { @@ -154,6 +186,22 @@ mod tests { assert_eq!(hold(&i).unwrap().to_string(), "contradiction CEO of"); } + #[test] + fn describe_says_what_would_move_or_that_nothing_would() { + assert!(Impact::default() + .describe() + .contains("nothing outside the graph")); + let i = Impact { + contradictions: vec!["CEO of".into()], + derived: 2, + answered: 0, + }; + let text = i.describe(); + assert!(text.contains("two \"CEO of\" facts")); + assert!(text.contains("2 derived fact")); + assert!(!text.contains("chat answer")); + } + #[test] fn derived_then_answered() { let i = Impact { diff --git a/docs/decisions/0025-governance-reads-the-ledger-before-it-decides.md b/docs/decisions/0025-governance-reads-the-ledger-before-it-decides.md index 132a0b527..1a4731e19 100644 --- a/docs/decisions/0025-governance-reads-the-ledger-before-it-decides.md +++ b/docs/decisions/0025-governance-reads-the-ledger-before-it-decides.md @@ -1,6 +1,6 @@ # 0025 · Governance reads the ledger before it decides -- **Status**: decision 3 revised 2026-09-07 (three clusters a round, #458) · decision 10 added 2026-09-07 (identity rules, name shapes, type families, the labeled set and `govern.mjs`) · decision 4 revised 2026-09-06 (the agent's own confidence decides, history moves the bar or blocks) · cut 1 implemented (#434) · migration 0035 adds `knowledge_bases.governance` and `agent_decisions`; `governance` in the store holds the precedent families, the first-in-first-out queue with its clusters, the gate and the table; the `govern` job in the server reads the switch, calls the model with precedents and applies or proposes; `?queue=agent`, `ReviewCounts.agent` and `POST /kbs/{id}/review/agent/{decision_id}` on the API · cut 3 (UI, #437) implemented: the switch in base settings, the Agent queue with its rows and answers, the proposal chip on a duplicate card whose Merge / Keep answers the proposal, the Agent section on the Overview · cut 2 implemented: migration 0036 adds `question`, `trace` and `calls` to `agent_decisions`; `governor` in the extract crate holds the tools, the opening and the step reader; `investigate` in the server job runs the loop for pairs the batch could not settle (decision 8) · cut 4 implemented: migration 0037 adds `knowledge_bases.governance_since`; `fuse` in the server job turns the switch off after two reverts of the agent's merges since it was turned on, within seven days, raises `governance.tripped` and writes the ledger (decision 9) · [0026](0026-a-decision-records-why.md) gives a decision its stated reason, hands the batch adjudicator the same precedents and quotes the reason into them +- **Status**: decision 3 revised 2026-09-07 (three clusters a round, #458) · decision 10 added 2026-09-07 (identity rules, name shapes, type families, the labeled set and `govern.mjs`) · decision 4 revised 2026-09-06 (the agent's own confidence decides, history moves the bar or blocks) · cut 1 implemented (#434) · migration 0035 adds `knowledge_bases.governance` and `agent_decisions`; `governance` in the store holds the precedent families, the first-in-first-out queue with its clusters, the gate and the table; the `govern` job in the server reads the switch, calls the model with precedents and applies or proposes; `?queue=agent`, `ReviewCounts.agent` and `POST /kbs/{id}/review/agent/{decision_id}` on the API · cut 3 (UI, #437) implemented: the switch in base settings, the Agent queue with its rows and answers, the proposal chip on a duplicate card whose Merge / Keep answers the proposal, the Agent section on the Overview · cut 2 implemented: migration 0036 adds `question`, `trace` and `calls` to `agent_decisions`; `governor` in the extract crate holds the tools, the opening and the step reader; `investigate` in the server job runs the loop for pairs the batch could not settle (decision 8) · cut 4 implemented: migration 0037 adds `knowledge_bases.governance_since`; `fuse` in the server job turns the switch off after two reverts of the agent's merges since it was turned on, within seven days, raises `governance.tripped` and writes the ledger (decision 9) · [0026](0026-a-decision-records-why.md) gives a decision its stated reason, hands the batch adjudicator the same precedents and quotes the reason into them · [0028](0028-the-adjudicator-looks-before-it-asks.md) opens the loop of cut 2 to the adjudicator with governance off and gives it a `consequences` tool - **Written**: 2026-09-06 (conventions in the [README](README.md)) - **Related**: [0016](0016-close-the-open-seams-before-cutting-new-ones.md) C2 gave a base its first automation switch, `auto_type_resolution`, and this record copies its shape; #428 asked for bulk and automatic handling of same-name pairs and got the batch path (#429, #430) this builds on; [0020](0020-an-auditor-reads-it-without-us.md) made the ledger complete enough to be read back; [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) keeps a person's own sentences out of any machine's reach. diff --git a/docs/decisions/0026-a-decision-records-why.md b/docs/decisions/0026-a-decision-records-why.md index 64f2587ec..c9af0fa60 100644 --- a/docs/decisions/0026-a-decision-records-why.md +++ b/docs/decisions/0026-a-decision-records-why.md @@ -52,7 +52,7 @@ The duplicate card has an input beside Keep / Merge; the batch toolbar has one i ## Open questions -- **Impact, not confidence** (#357). The threshold and the sample are still about confidence. What a merge would drag along, the degree of each side, the facts that move, an earlier revert on the same names, is the next gate. -- **An adjudicator that investigates** (#358). The batch sees names, types, top facts and now precedents; the governor's loop has tools. Whether the batch should escalate into the loop instead of into the human queue is the question. +- **Impact, not confidence** (#357). Answered by [0027](0027-an-automatic-merge-is-gated-by-what-it-can-undo.md): a merge that would leave the graph is held for a person whatever the confidence. +- **An adjudicator that investigates** (#358). Answered by [0028](0028-the-adjudicator-looks-before-it-asks.md): the batch escalates its unsettled pairs into the governor's loop, with governance off too. - **The agreement rate.** The sampled rows carry both verdicts; nothing computes the rate yet, and nothing moves `HUMAN_SAMPLE_PCT` from it. - **History on a merge target.** The merge shows under the withdrawals it caused in the same second, which is honest chronology and hard to read. Folding consequences under their cause is presentation, and belongs with the rationale it now has. diff --git a/docs/decisions/0028-the-adjudicator-looks-before-it-asks.md b/docs/decisions/0028-the-adjudicator-looks-before-it-asks.md new file mode 100644 index 000000000..8cc30e7f6 --- /dev/null +++ b/docs/decisions/0028-the-adjudicator-looks-before-it-asks.md @@ -0,0 +1,53 @@ +# 0028 · The adjudicator looks before it asks + +- **Status**: Implemented · `consequences` joins the second look's tools (facts, quotes, ledger, namesakes): what a merge would touch, read from 0027's gate, and whether the two types share a family · with governance off, the batch adjudicator sends the pairs it cannot settle through the same loop under the same daily budget, and every look is a row in `agent_decisions` so the Agent queue and the budget see it · the number that will justify or retire it comes from 0026's sample, split by `via` +- **Written**: 2026-09-08 (conventions in the [README](README.md)) +- **Related**: #358 asked for it and said when to build it. [0025](0025-governance-reads-the-ledger-before-it-decides.md) cut 2 built the loop this record reuses; [0026](0026-a-decision-records-why.md) keeps the sample that measures it; [0027](0027-an-automatic-merge-is-gated-by-what-it-can-undo.md) draws the boundary the loop now sees before it decides. + +> Two records named "Mercury": one with three facts about a planet, one with none. The batch look says unsure at 0.55 and the pair goes to a person, who opens the source document, finds "Mercury, the messaging startup, raised…", and keeps them apart in ten seconds. The code could have opened the same document. With governance off, it never did. + +## What the issue asked for, and what was known + +#358 asked for an adjudicator that can go and look, several rounds, with tools it already has a backend for, and said not to build it until a number said which kind of miss the adjudicator makes: no comparable precedent (then retrieval is the answer), or evidence in a document nobody put on the card (then investigation is). + +Two things happened since. 0025 cut 2 built exactly that loop for the governed path, with four tools and a daily budget, and 0025 decision 10 measured the whole governed path on 589 pairs against a hand-labeled set: 96.9% agreement, four wrong merges, eleven pairs left for people. Of what disagreed, most was judgment (whether "Claude Mythos 5" is "Claude Mythos"), two of the four wrong merges followed a precedent a simulated person left, and pairs with no facts on one side cannot be settled by anyone. That corpus did not say "the evidence was in a document nobody read"; it said the remaining misses are the kind more looking does not fix. This record does not claim the loop raises agreement on that corpus. + +What it does claim is narrower and cheap, because the loop exists: + +- A base with governance **off** had no second look at all. Everything the batch could not settle, the pairs below 0.8 and the ones without a verdict, went to a person unlooked-at. 0026 sends one confident pair in ten to a person on purpose; sending every unsettled pair with no attempt is not the same policy. +- The loop could not see what the consistency check sees, or what 0027's gate will hold. It could decide "same" on a pair whose merge would put two CEOs on one company, and only then be held. + +## Decisions + +### 1. One loop, both deciders + +`look_again` in the governance job wraps the loop for the adjudicator: a fresh `run_id` per adjudication job, the same tools, the same `LOOP_DAILY_CALLS`. The pairs it takes are the ones the batch left unsettled (no verdict, or below `AUTO_CONF`) that a hard rule would not hold anyway (different type families, a version tail, a phrase containing a name: looking again cannot change a rule) and that carry no revert (that is a person's matter). The loop's verdict replaces the batch's in the cache, is applied through the same `apply_verdict` (so 0026's sample and 0027's gate still apply), and the ledger says `via: investigated`. + +### 2. A look is a row, whatever the switch + +Every second look the adjudicator takes is an `agent_decisions` row with its trace, its calls and, if it deferred, its question; `applied` when the verdict landed, `proposed` when the pair went to a person. Two consequences: the daily budget, which counts `calls` on those rows, is shared between the governor and the adjudicator; and the Agent queue shows what a machine looked at with tools regardless of whether governance is on. The switch turns the governor on, not the queue. A deferred pair reaches its card with the proposal chip and the question, and the person's answer walks the same path as an answer to the governor. + +### 3. The loop sees the boundary before it decides + +`consequences` takes no arguments and returns what `impact_of` returns, rendered: relations that allow one value where the sides hold different ones, derived facts resting on either side, chat answers that named either side, and whether the two types share a family. The prompt says what to do with it: a merge that would touch anything outside the graph is held for a person whatever the confidence, so prefer to defer with the question that would settle it. The model learns nothing new about identity from this tool; it learns what its decision would cost, which is the difference between deciding "same 0.9" and being held, and asking "is the CEO Alice or Bob?" and being answered. + +### 4. Nothing else moves + +The batch prompt is unchanged; confident batch verdicts never enter the loop (0026's sample covers them); the governor's own second-look rule is unchanged. The adjudicator's bar stays 0.8 and the governor's 0.85 / 0.75. + +## What a reader sees + +With governance off, an unsettled pair that the loop decided shows in Merges or Decisions with the machine as actor, and in the Agent queue as an applied row with its lookups. One it deferred shows on its card as "Agent: unsure" with the question, and in the Agent queue with "Asks: …"; the Merge / Keep on either answers it. The Agent queue's empty state still says governance is off, because it is. + +## Dead ends + +- **A second loop for the adjudicator with fewer tools.** Two prompts to keep aligned, two budgets, two trace shapes. The loop was already written against a pair and an earlier look; the adjudicator has both. +- **Looking before the batch, for every pair.** Up to seven calls per pair instead of one per twelve; the batch settles most pairs and the loop is for the rest. +- **An ontology tool that dumps the class hierarchy.** The pair already shows the types; what the model needs is whether the rules let them merge (families) and what the check would open (contradictions). Both are in `consequences`. +- **Recording adjudicator looks outside `agent_decisions`.** A parallel table for the same shape of row, invisible to the queue and to the budget. + +## Open questions + +- **The number.** 0026's sampled pairs carry the machine's verdict in the reason and the person's decision in the ledger, with `via` saying whether the batch or the loop produced the verdict. When enough of them exist, the agreement rate split by `via` says whether the loop earns its calls with governance off; nothing computes it yet. +- **Tools the loop still lacks** (0025's list): the graph beyond a side's direct facts, a full-text search of the corpus, the disambiguator history. +- **Whether a deferred question should skip the queue** when governance is off and land on the card only. Today it does both. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d42121700..354ec88c5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -51,6 +51,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0025 | [Governance reads the ledger before it decides](0025-governance-reads-the-ledger-before-it-decides.md) | Cut 1 implemented · a per-base `governance` switch, a `govern` job that works the duplicates queue first in, first out with each head's cluster, precedents pulled from the ledger into the prompt, a gate where the agent's own confidence decides and history lowers the bar or blocks (revised after the first big-file run), every look a row in `agent_decisions`, answers through the person's own decide path · the switch, the Agent queue, the proposal chip and the Overview section in the UI · a pair the batch cannot settle is looked at again with tools and then decided or asked about, within a daily budget · two reverts in a week turn the switch off and raise an alert · identity rules the model reads and two it cannot argue with (name shapes, type families), measured against a hand-labeled set of 411 name pairs by `scripts/bench/govern.mjs` | | 0026 | [A decision records why](0026-a-decision-records-why.md) | Implemented · `resolution_reviews.rationale` and `why` on the ledger event from every human decide path, precedents quote it into the prompt and the `ledger_search` tool, the batch adjudicator reads precedents and keys its cache on them, one confident pair in ten goes to a person anyway (`escalate_sample`), the model's own why is kept beside machine decisions · the impact gate (#357) and the investigating adjudicator (#358) follow | | 0027 | [An automatic merge is gated by what it can undo](0027-an-automatic-merge-is-gated-by-what-it-can-undo.md) | Implemented · `execution_gate` in the store, asked by the batch adjudicator and the governor before an automatic merge: a contradiction the consistency check would open, a derivation resting on either side, or an answer that named either side holds the pair for a person as `escalate_impact` whatever the confidence · keeps are not gated · exports and a per-deployment opt-in stay open | +| 0028 | [The adjudicator looks before it asks](0028-the-adjudicator-looks-before-it-asks.md) | Implemented · `consequences` joins the second look's tools (what a merge would touch, from 0027's gate, and whether the types share a family) · with governance off the batch adjudicator sends its unsettled pairs through the same loop under the same budget, each look a row in `agent_decisions` · the number that justifies or retires it comes from 0026's sample split by `via` | | 0031 | [An event holds at the moment it names](0031-an-event-holds-at-the-moment-it-names.md) | Implemented (#486) · `Validity::under` normalises every write by the predicate's `temporal` (an event is one moment written at both ends, an eternal fact has no dates), `world_axis` and `read_span` read an event as the bucket it names and an undated event at no moment, an eternal fact at every moment · the prompt marks `[event]` / `[eternal]` and says what to write · no schema change, old rows read correctly · the panel's point rendering and the ontology hint are the UI cut | ## Not a decision record