From fe7e8788933ca06654afba0e57c461574169ffe4 Mon Sep 17 00:00:00 2001 From: WaylandYang <145302500+WaylandYang@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:41:32 +0800 Subject: [PATCH 1/2] A phrase decision records the inputs it considered, so staleness is a changed basis rather than a clock Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com> --- crates/utopia-cli/src/main.rs | 4 +- crates/utopia-extract/src/phrase_align.rs | 13 +- crates/utopia-server/src/api/review_routes.rs | 1 + .../src/api/review_routes_phrase_tests.rs | 1 + crates/utopia-server/src/phrase_alignment.rs | 330 ++++++++++-- .../src/phrase_alignment_tests.rs | 488 ++++++++++++++++++ .../src/materialize_delivery_tests.rs | 1 + crates/utopia-store/src/phrase_bindings.rs | 68 ++- .../src/phrase_bindings_delivery_tests.rs | 2 + .../human_phrase_materialization_delivery.rs | 2 + .../tests/materialization_is_serial.rs | 1 + .../tests/negative_binding_definition_edit.rs | 1 + ...ology-is-a-view-over-what-documents-say.md | 2 + ...cision-records-the-inputs-it-considered.md | 94 ++++ docs/decisions/README.md | 4 + docs/design/ontology.md | 14 +- ...ision_records_the_inputs_it_considered.sql | 11 + web/src/api.ts | 8 +- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/pages/Review.tsx | 6 + 21 files changed, 995 insertions(+), 58 deletions(-) create mode 100644 crates/utopia-server/src/phrase_alignment_tests.rs create mode 100644 docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md create mode 100644 migrations/0072_a_phrase_decision_records_the_inputs_it_considered.sql diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index 20c5f9220..4753c7019 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -80,7 +80,9 @@ struct ManifestDataDir { /// whose `schema_version` is greater than this (forward-incompatible) and /// warns when older. Kept as a constant — bumping is a deliberate decision, /// not a side effect of a code change. -const CURRENT_SCHEMA_VERSION: u32 = 70; +// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` +// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 +const CURRENT_SCHEMA_VERSION: u32 = 71; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-extract/src/phrase_align.rs b/crates/utopia-extract/src/phrase_align.rs index 7c1578071..57cab6e3a 100644 --- a/crates/utopia-extract/src/phrase_align.rs +++ b/crates/utopia-extract/src/phrase_align.rs @@ -32,6 +32,10 @@ pub struct PropertyCandidate<'a> { /// 定义域、值域的类键;空表示没声明 pub domains: Vec<&'a str>, pub ranges: Vec<&'a str>, + /// 这条候选是经继承命中的:声明在祖先上,签名的类是它的子类。把依据写给模型看, + /// 不然它对着一条 domain 是 legal_entity 的属性和一个 organization 的主语会答 null + /// (#807:只在代码里放宽候选是不够的,模型得看见继承的依据) + pub via: Vec, } /// 一条待绑定的签名:短语、两端的类、例句与引文、候选属性 @@ -83,7 +87,8 @@ the object is a figure, a title or a status; a few statements with that signatur sentence it was taken from; and the candidate properties, each with its key, its label, its \ definition, its kind (a relation between two things, or an attribute whose object is a value), \ its domain and its range. A class written as \"?\" means the documents' kind word for that side \ -is bound to no class yet.\n\ +is bound to no class yet. A candidate marked \"fits by inheritance\" declares its domain or range on \ +an ancestor of the item's class; that is a fit, not a mismatch.\n\ For each item, answer with the key of the one property that every statement of this signature \ states by that property's definition, and the direction: \"forward\" when the statement's \ subject is the property's subject, \"reverse\" when the statement's object is; or null.\n\ @@ -116,6 +121,9 @@ fn candidate_line(c: &PropertyCandidate<'_>) -> String { if !c.ranges.is_empty() { line.push_str(&format!(" · range: {}", c.ranges.join(", "))); } + if !c.via.is_empty() { + line.push_str(&format!(" · fits by inheritance: {}", c.via.join("; "))); + } line.push_str(&format!(" · {}", c.description)); line } @@ -244,6 +252,7 @@ mod tests { kind: "relation", domains: vec!["organization"], ranges: vec!["place"], + via: Vec::new(), }, PropertyCandidate { key: "subsidiary_of", @@ -252,6 +261,7 @@ mod tests { kind: "relation", domains: vec!["organization"], ranges: vec!["organization"], + via: Vec::new(), }, PropertyCandidate { key: "revenue", @@ -260,6 +270,7 @@ mod tests { kind: "attribute", domains: vec!["organization"], ranges: vec![], + via: Vec::new(), }, ] } diff --git a/crates/utopia-server/src/api/review_routes.rs b/crates/utopia-server/src/api/review_routes.rs index 5de8adb18..d3fe8ba8b 100644 --- a/crates/utopia-server/src/api/review_routes.rs +++ b/crates/utopia-server/src/api/review_routes.rs @@ -1281,6 +1281,7 @@ pub async fn decide_alignment_phrase( status: if property.is_some() { "bound" } else { "none" }, votes: &votes, decided_by: "person", + basis: None, }, ) .await? diff --git a/crates/utopia-server/src/api/review_routes_phrase_tests.rs b/crates/utopia-server/src/api/review_routes_phrase_tests.rs index a6cfb1a88..4f81073cb 100644 --- a/crates/utopia-server/src/api/review_routes_phrase_tests.rs +++ b/crates/utopia-server/src/api/review_routes_phrase_tests.rs @@ -74,6 +74,7 @@ impl Fx { status: "undecided", votes: &json!({}), decided_by: "agent", + basis: None, }, ) .await?; diff --git a/crates/utopia-server/src/phrase_alignment.rs b/crates/utopia-server/src/phrase_alignment.rs index e81ece695..e5d30186e 100644 --- a/crates/utopia-server/src/phrase_alignment.rs +++ b/crates/utopia-server/src/phrase_alignment.rs @@ -33,20 +33,127 @@ const CANDIDATE_LIMIT: usize = 60; /// 一票:这条签名选了哪个属性、哪个方向(None = 没有属性对得上)。 type Vote = Option<(String, Direction)>; -/// 签名两端的类落在属性声明的域/值域里(没声明的不限,没绑到类的一端只被没声明的 -/// 一端接受);正反两个方向都算。 -fn fits(p: &RelationTypeView, sig: &PhraseSignature) -> bool { - let within = |declared: &[Uuid], class: Option| -> bool { - declared.is_empty() || class.is_some_and(|c| declared.contains(&c)) - }; +/// 一个类连同它的全部祖先。候选按它命中:属性的定义域声明在 legal_entity 上, +/// organization 是它的子类,这条属性对 organization 的签名就是候选(#807 第一条)。 +type Closure = HashMap>; + +fn closures<'a>(classes: impl IntoIterator) -> Closure { + let classes: Vec<(Uuid, &[Uuid])> = classes.into_iter().collect(); + let parents: HashMap = classes.iter().copied().collect(); + classes + .iter() + .map(|(id, direct)| { + let mut seen: Vec = vec![*id]; + let mut stack: Vec = direct.to_vec(); + // 多继承与菱形:UNION 语义,同一个祖先只进一次;环不会有(编辑器不允许) + while let Some(p) = stack.pop() { + if seen.contains(&p) { + continue; + } + seen.push(p); + if let Some(pp) = parents.get(&p) { + stack.extend_from_slice(pp); + } + } + seen.sort(); + (*id, seen) + }) + .collect() +} + +/// 一条候选怎么命中的:`via` 是经继承命中的依据(空 = 直接命中或没声明)。 +struct Fit { + via: Vec<(Uuid, Uuid)>, +} + +/// 声明的类里有没有一个是这一端的类或其祖先。没声明不限;这一端没绑到类时只被没声明 +/// 的接受。返回命中的 (声明的类, 这一端的类) 当它不是直接命中时 +fn within( + declared: &[Uuid], + class: Option, + closure: &Closure, +) -> Option> { + if declared.is_empty() { + return Some(None); + } + let c = class?; + if declared.contains(&c) { + return Some(None); + } + let up = closure.get(&c)?; + declared + .iter() + .find(|d| up.contains(d)) + .map(|d| Some((*d, c))) +} + +/// 签名两端的类落在属性声明的域/值域里,经继承也算;正反两个方向都算。 +fn fits(p: &RelationTypeView, sig: &PhraseSignature, closure: &Closure) -> Option { + let mut via = Vec::new(); if sig.object_is_value { - p.kind == "attribute" && within(&p.domains, sig.subject_type_id) - } else { - p.kind == "relation" - && ((within(&p.domains, sig.subject_type_id) && within(&p.ranges, sig.object_type_id)) - || (within(&p.domains, sig.object_type_id) - && within(&p.ranges, sig.subject_type_id))) + if p.kind != "attribute" { + return None; + } + via.extend(within(&p.domains, sig.subject_type_id, closure)?); + return Some(Fit { via }); } + if p.kind != "relation" { + return None; + } + let forward = within(&p.domains, sig.subject_type_id, closure).zip(within( + &p.ranges, + sig.object_type_id, + closure, + )); + let reverse = within(&p.domains, sig.object_type_id, closure).zip(within( + &p.ranges, + sig.subject_type_id, + closure, + )); + let (a, b) = forward.or(reverse)?; + via.extend(a); + via.extend(b); + Some(Fit { via }) +} + +/// 签名的键:短语 + 两端的类 + 宾语是不是字面值(与 `PhraseSignature::key` 同形) +type SignatureKey = (String, Option, Option, bool); +/// 每条签名此刻的候选与指纹 +type Considered<'a> = HashMap, String)>; + +/// 每条活着的签名此刻的候选(经继承命中)与指纹(0053)。开跑时算一次决定要判谁, +/// 收尾时用重新加载的输入再算一次决定要不要再排——两次之间世界可能变了 +fn consider<'a>( + sigs: &[PhraseSignature], + props: &'a [RelationTypeView], + closure: &Closure, + versions: &HashMap>, +) -> Considered<'a> { + let empty: Vec = Vec::new(); + sigs.iter() + .map(|s| { + let fitting: Vec<&RelationTypeView> = props + .iter() + .filter(|p| fits(p, s, closure).is_some()) + .collect(); + let cands: Vec<(Uuid, chrono::DateTime)> = fitting + .iter() + .filter_map(|p| versions.get(&p.id).map(|at| (p.id, *at))) + .collect(); + let up = |c: Option| -> &[Uuid] { + c.and_then(|c| closure.get(&c)) + .map(Vec::as_slice) + .unwrap_or(&empty) + }; + let basis = phrase_bindings::basis_of( + up(s.subject_type_id), + up(s.object_type_id), + s.object_is_value, + &cands, + ); + (s.key(), (fitting, basis)) + }) + .collect() } /// 对一个库跑一遍:新出现的和过期的签名各判一次。 @@ -91,28 +198,32 @@ async fn align_phrases_locked( let class_key: HashMap = classes.iter().map(|c| (c.id, c.key.as_str())).collect(); let by_key: HashMap<&str, &RelationTypeView> = props.iter().map(|p| (p.key.as_str(), p)).collect(); + let closure = closures(classes.iter().map(|c| (c.id, c.parents.as_slice()))); + let versions = phrase_bindings::property_versions(pool, kb_id).await?; let sigs = phrase_bindings::signatures(pool, kb_id).await?; let existing: HashMap<_, _> = phrase_bindings::bindings(pool, kb_id) .await? .into_iter() .map(|b| (b.key(), b)) .collect(); - let stale: HashSet<_> = phrase_bindings::stale(pool, kb_id) - .await? - .into_iter() - .map(|b| b.key()) - .collect(); + // 每条活着的签名此刻的候选与指纹。候选按继承命中;指纹是判定看到的全部输入(0053) + let considered = consider(&sigs, &props, &closure, &versions); + // 过期 = 存下的指纹和此刻的不一样(没有指纹的是这一列出现前判的,各重判一次)。 + // 不再按时间戳:父边的增删、请求途中的编辑(#795)时间戳看不见。人的判定不重判 let todo: Vec<&PhraseSignature> = sigs .iter() .filter(|s| match existing.get(&s.key()) { None => true, - Some(b) => b.decided_by != "person" && stale.contains(&s.key()), + Some(b) => { + b.decided_by != "person" + && b.basis.as_deref() != Some(considered[&s.key()].1.as_str()) + } }) .collect(); let attempted: HashSet<_> = todo.iter().map(|s| s.key()).collect(); tracing::info!(%kb_id, signatures = sigs.len(), to_decide = todo.len(), properties = props.len(), "短语对齐开始"); - // 没有属性可绑:每条都是「没有」;属性出现后 `stale` 会把它们再交回来 + // 没有属性可绑:每条都是「没有」;属性出现后指纹变了,它们会再交回来 if props.is_empty() { for s in &todo { phrase_bindings::decide( @@ -123,8 +234,9 @@ async fn align_phrases_locked( relation_type_id: None, direction: None, status: "none", - votes: &serde_json::json!({ "reason": "no properties" }), + votes: &serde_json::json!({ "reason": "no_properties" }), decided_by: "agent", + basis: Some(&considered[&s.key()].1), }, ) .await?; @@ -141,14 +253,16 @@ async fn align_phrases_locked( // 调用或解析失败的批次:这轮跳过,结束时自己再排一次 let mut failed = 0usize; for batch in todo.chunks(BATCH) { + // 候选超过上限的不问模型:记成 undecided 交给人,指纹照记——属性少下去指纹就变, + // 到时再问。从前超限和无候选一样静默跳过,签名永远排着又永远不可执行(#807) let cands: Vec> = batch .iter() .map(|s| { - let fitting: Vec<&RelationTypeView> = props.iter().filter(|p| fits(p, s)).collect(); + let fitting = &considered[&s.key()].0; if fitting.len() > CANDIDATE_LIMIT { Vec::new() } else { - fitting + fitting.clone() } }) .collect(); @@ -182,6 +296,20 @@ async fn align_phrases_locked( kind: &p.kind, domains: keys_of(&p.domains), ranges: keys_of(&p.ranges), + via: fits(p, s, &closure) + .map(|f| { + f.via + .iter() + .map(|(declared, class)| { + format!( + "{} is a subclass of {}", + class_key.get(class).copied().unwrap_or("?"), + class_key.get(declared).copied().unwrap_or("?"), + ) + }) + .collect() + }) + .unwrap_or_default(), }) .collect(), } @@ -227,7 +355,42 @@ async fn align_phrases_locked( } } for (i, s) in batch.iter().enumerate() { + let basis = considered[&s.key()].1.as_str(); if cands[i].is_empty() { + let fitting = considered[&s.key()].0.len(); + // 两种「没问模型」各自落库,投影才退得掉、队列才收得住: + // 没有一条属性对得上 → none(绑过的签名失去支撑,类型化行随物化作废); + // 对得上的太多 → undecided 交给人,不再每轮重排 + let (status, votes) = if fitting == 0 { + ("none", serde_json::json!({ "reason": "no_candidates" })) + } else { + ( + "undecided", + serde_json::json!({ "first": null, "second": null, + "reason": "too_many_candidates", "candidates": fitting }), + ) + }; + if phrase_bindings::decide( + pool, + kb_id, + s, + Decision { + relation_type_id: None, + direction: None, + status, + votes: &votes, + decided_by: "agent", + basis: Some(basis), + }, + ) + .await? + { + if status == "none" { + none += 1; + } else { + undecided += 1; + } + } skipped += 1; continue; } @@ -254,6 +417,7 @@ async fn align_phrases_locked( status: "undecided", votes: &record, decided_by: "agent", + basis: Some(basis), }, ) .await?; @@ -275,6 +439,7 @@ async fn align_phrases_locked( status: "bound", votes: &record, decided_by: "agent", + basis: Some(basis), }, ) .await? @@ -293,6 +458,7 @@ async fn align_phrases_locked( status: "none", votes: &record, decided_by: "agent", + basis: Some(basis), }, ) .await? @@ -310,20 +476,33 @@ async fn align_phrases_locked( if typed.added > 0 || typed.merged > 0 || typed.retired > 0 { state.emit_graph(kb_id); } - // 这一轮跑着的时候世界没停:新文档带来新签名,改了的属性让刚判的绑定过期,本轮没排上 - // 的触发也都落在这里。有失败的批次、有没试过的新签名、有本轮判完又过期的绑定,就再排 - // 一次 - // 「过期」不限本轮判的:跑着的时候有人建了属性,判过 none 的老绑定也该再判一次 - // ——那正是批量建本体时唯一的触发(建的时候有一份在跑,就不再排了) - let again = failed > 0 - || phrase_bindings::signatures(pool, kb_id) - .await? - .iter() - .any(|s| !attempted.contains(&s.key()) && !existing.contains_key(&s.key())) - || phrase_bindings::stale(pool, kb_id) + // 这一轮跑着的时候世界没停:新文档带来新签名,改了的属性、动了的父边让刚判的绑定 + // 过期,本轮没排上的触发也都落在这里。有失败的批次、有没试过的新签名、有本轮判完 + // 指纹又变了的绑定(请求途中的编辑,#795),就再排一次。 + // 只看**活着的**签名:端点的类换了,旧签名的行没有陈述可判,它永远「过期」却永远 + // 不可执行——从前 `stale` 把这种孤儿每轮交回来,一条孤儿排一次 job,三轮三次(#807) + let again = failed > 0 || { + // **重新加载**,不是拿开跑时的快照比:快照就是判定写下的那份指纹,跟它比永远 + // 相等。模型答着的时候改了定义(#795)、加了父边、来了新文档,只有再读一遍才看得见 + let props = utopia_store::ontology::relation_type_views(pool, kb_id).await?; + let classes = utopia_store::graph::entity_types(pool, kb_id).await?; + let closure = closures(classes.iter().map(|c| (c.id, c.parents.as_slice()))); + let versions = phrase_bindings::property_versions(pool, kb_id).await?; + let sigs = phrase_bindings::signatures(pool, kb_id).await?; + let now_considered = consider(&sigs, &props, &closure, &versions); + let now: HashMap<_, _> = phrase_bindings::bindings(pool, kb_id) .await? - .iter() - .any(|b| b.decided_by != "person"); + .into_iter() + .map(|b| (b.key(), b)) + .collect(); + sigs.iter().any(|s| match now.get(&s.key()) { + None => !attempted.contains(&s.key()), + Some(b) => { + b.decided_by != "person" + && b.basis.as_deref() != Some(now_considered[&s.key()].1.as_str()) + } + }) + }; if again { utopia_store::jobs::enqueue_unless_queued( pool, @@ -335,6 +514,10 @@ async fn align_phrases_locked( Ok(()) } +#[cfg(test)] +#[path = "phrase_alignment_tests.rs"] +mod lifecycle_tests; + #[cfg(test)] mod tests { use super::*; @@ -383,31 +566,88 @@ mod tests { #[test] fn a_property_fits_a_signature_by_its_declared_ends_in_either_direction() { let (org, place, person) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + // 没有父边时闭包为空:每个类只等于它自己,行为与从前一样 + let ok = |p: &RelationTypeView, s: &PhraseSignature| fits(p, s, &Closure::new()).is_some(); let hq = view("relation", vec![org], vec![place]); - assert!(fits(&hq, &sig(Some(org), Some(place), false))); - assert!(fits(&hq, &sig(Some(place), Some(org), false)), "反向也算"); - assert!(!fits(&hq, &sig(Some(person), Some(place), false))); + assert!(ok(&hq, &sig(Some(org), Some(place), false))); + assert!(ok(&hq, &sig(Some(place), Some(org), false)), "反向也算"); + assert!(!ok(&hq, &sig(Some(person), Some(place), false))); assert!( - !fits(&hq, &sig(None, Some(place), false)), + !ok(&hq, &sig(None, Some(place), false)), "没绑到类的一端不算落在声明的域里" ); let any_to_place = view("relation", vec![], vec![place]); assert!( - fits(&any_to_place, &sig(None, Some(place), false)), + ok(&any_to_place, &sig(None, Some(place), false)), "没声明的一端接受没绑到类的" ); - assert!(!fits(&hq, &sig(Some(org), None, true)), "关系不接字面值"); + assert!(!ok(&hq, &sig(Some(org), None, true)), "关系不接字面值"); let open = view("relation", vec![], vec![]); assert!( - fits(&open, &sig(Some(person), Some(person), false)), + ok(&open, &sig(Some(person), Some(person), false)), "没声明就不限" ); let revenue = view("attribute", vec![org], vec![]); - assert!(fits(&revenue, &sig(Some(org), None, true))); - assert!(!fits(&revenue, &sig(Some(person), None, true))); + assert!(ok(&revenue, &sig(Some(org), None, true))); + assert!(!ok(&revenue, &sig(Some(person), None, true))); assert!( - !fits(&revenue, &sig(Some(org), Some(place), false)), + !ok(&revenue, &sig(Some(org), Some(place), false)), "属性只接字面值" ); } + /// 声明在祖先上的属性经继承命中子类的签名(#807 第一条);依据要能说给模型听 + #[test] + fn a_property_declared_on_an_ancestor_fits_a_subclass_by_inheritance() { + let (legal_entity, org, place) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let mut closure = Closure::new(); + closure.insert(org, { + let mut v = vec![org, legal_entity]; + v.sort(); + v + }); + closure.insert(legal_entity, vec![legal_entity]); + closure.insert(place, vec![place]); + let hq = view("relation", vec![legal_entity], vec![place]); + let fit = + fits(&hq, &sig(Some(org), Some(place), false), &closure).expect("fits via parent"); + assert_eq!( + fit.via, + vec![(legal_entity, org)], + "the basis names the declared ancestor and the class" + ); + let direct = + fits(&hq, &sig(Some(legal_entity), Some(place), false), &closure).expect("direct"); + assert!(direct.via.is_empty(), "a direct hit needs no explanation"); + assert!( + fits(&hq, &sig(Some(place), Some(org), false), &closure).is_some(), + "reverse direction walks the hierarchy too" + ); + assert!( + fits(&hq, &sig(Some(org), Some(place), false), &Closure::new()).is_none(), + "without the parent edge the property is not a candidate" + ); + } + + /// 闭包:多继承与菱形,每个祖先只出现一次,且含自己 + #[test] + fn closures_walk_the_hierarchy_once_per_ancestor() { + let (thing, agent, legal, org) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + // org ⊂ agent ⊂ thing 且 org ⊂ legal ⊂ thing:菱形 + let (a, l, o) = ([thing], [thing], [agent, legal]); + let c = closures([ + (thing, &[][..]), + (agent, &a[..]), + (legal, &l[..]), + (org, &o[..]), + ]); + let mut expect = vec![org, agent, legal, thing]; + expect.sort(); + assert_eq!(c[&org], expect); + assert_eq!(c[&thing], vec![thing]); + } } diff --git a/crates/utopia-server/src/phrase_alignment_tests.rs b/crates/utopia-server/src/phrase_alignment_tests.rs new file mode 100644 index 000000000..eee0b3bd5 --- /dev/null +++ b/crates/utopia-server/src/phrase_alignment_tests.rs @@ -0,0 +1,488 @@ +//! 一条短语判定的生命周期(0053,#807,#795):候选经继承命中、父边增删让判定过期、 +//! 无候选与超限各自落库、端点类换了旧行不再循环、请求途中的编辑留下可见的过期、 +//! 人的判定不被覆盖。模型是脚本化的 HTTP 端点,库是真的 PostgreSQL。 +use super::*; +use axum::{extract::State, response::IntoResponse, routing::post, Json, Router}; +use serde_json::{json, Value}; +use std::sync::{Arc, Mutex}; +use utopia_store::{materialize, phrase_bindings}; + +#[derive(Clone)] +struct Model { + replies: Arc>>, + requests: Arc>>, + hold: Arc, + entered: Arc, + release: Arc, +} +async fn reply(State(m): State, Json(body): Json) -> impl IntoResponse { + let n = { + let mut seen = m.requests.lock().unwrap(); + seen.push(body); + seen.len() - 1 + }; + if n == 0 && m.hold.load(std::sync::atomic::Ordering::SeqCst) { + m.entered.notify_one(); + m.release.notified().await; + } + let text = { + let mut replies = m.replies.lock().unwrap(); + if replies.is_empty() { + panic!("unexpected model request #{n}"); + } + replies.remove(0).to_string() + }; + let frame = json!({"choices":[{"delta":{"content":text}}]}); + ( + [("content-type", "text/event-stream")], + format!("data: {frame}\n\ndata: [DONE]\n\n"), + ) +} + +struct Fx { + pool: sqlx::PgPool, + state: AppState, + org: Uuid, + kb: Uuid, + legal_entity: Uuid, + organization: Uuid, + acme: Uuid, + based_in: Uuid, + model: Model, + server: tokio::task::JoinHandle<()>, + dir: tempfile::TempDir, +} + +impl Fx { + /// 一个库:legal_entity ⊃ organization,place;属性 based_in 声明在 legal_entity → place; + /// Acme(organization)—based in→ London(place)一条开放陈述 + async fn new() -> anyhow::Result> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(None); + }; + let pool = sqlx::PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let (org, ws, kb, legal_entity, organization, place, acme, london, based_in, statement) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + sqlx::raw_sql(&format!( + "INSERT INTO organizations(id,name) VALUES ('{org}','phrase-lifecycle'); + INSERT INTO workspaces(id,org_id,name) VALUES ('{ws}','{org}','phrase-lifecycle'); + INSERT INTO knowledge_bases(id,workspace_id,name) VALUES ('{kb}','{ws}','phrase-lifecycle'); + INSERT INTO entity_types(id,kb_id,key,label,color,shape) VALUES + ('{legal_entity}','{kb}','legal_entity','Legal entity','#000','circle'), + ('{organization}','{kb}','organization','Organization','#000','circle'), + ('{place}','{kb}','place','Place','#000','circle'); + INSERT INTO entity_type_parents(child_id,parent_id,is_primary) VALUES + ('{organization}','{legal_entity}',true); + INSERT INTO relation_types(id,kb_id,key,label,kind,temporal,description) VALUES + ('{based_in}','{kb}','based_in','based in','relation','state','where an entity is based'); + INSERT INTO relation_type_domains(relation_type_id,entity_type_id) VALUES ('{based_in}','{legal_entity}'); + INSERT INTO relation_type_ranges(relation_type_id,entity_type_id) VALUES ('{based_in}','{place}'); + INSERT INTO entities(id,kb_id,canonical_name,type_id) VALUES + ('{acme}','{kb}','Acme','{organization}'), ('{london}','{kb}','London','{place}'); + INSERT INTO facts(id,kb_id,subject_id,object_id,layer,phrase) VALUES + ('{statement}','{kb}','{acme}','{london}','open','based in');" + )) + .execute(&pool) + .await?; + let model = Model { + replies: Arc::new(Mutex::new(Vec::new())), + requests: Arc::new(Mutex::new(Vec::new())), + hold: Arc::new(std::sync::atomic::AtomicBool::new(false)), + entered: Arc::new(tokio::sync::Notify::new()), + release: Arc::new(tokio::sync::Notify::new()), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let endpoint = format!("http://{}", listener.local_addr()?); + let router = Router::new() + .route("/chat/completions", post(reply)) + .with_state(model.clone()); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + utopia_store::settings::upsert( + &pool, + ws, + Some(&endpoint), + None, + Some("scripted"), + None, + None, + None, + None, + ) + .await?; + let dir = tempfile::tempdir()?; + let cfg = utopia_core::config::AppConfig { + data_dir: dir.path().to_string_lossy().into_owned(), + ..Default::default() + }; + let search = Arc::new(utopia_search::SearchIndex::open( + &dir.path().join("search"), + )?); + let state = AppState::new(pool.clone(), &cfg, search, "test-only".into()); + Ok(Some(Self { + pool, + state, + org, + kb, + legal_entity, + organization, + acme, + based_in, + model, + server, + dir, + })) + } + fn script(&self, replies: Vec) { + *self.model.replies.lock().unwrap() = replies; + } + async fn run(&self) -> anyhow::Result<()> { + align_phrases(&self.state, self.kb).await + } + fn requests(&self) -> Vec { + self.model.requests.lock().unwrap().clone() + } + fn prompt_of(&self, n: usize) -> String { + self.requests()[n]["messages"][1]["content"] + .as_str() + .unwrap_or("") + .to_string() + } + async fn binding(&self) -> anyhow::Result { + let mut all = phrase_bindings::bindings(&self.pool, self.kb).await?; + anyhow::ensure!(!all.is_empty(), "no binding"); + Ok(all.remove(0)) + } + /// 这条签名落库时记的原因(`votes.reason`):结构性结果不问模型,原因写在票里 + async fn reason(&self) -> anyhow::Result> { + Ok(sqlx::query_scalar("SELECT votes->>'reason' FROM phrase_bindings WHERE kb_id=$1 ORDER BY decided_at DESC LIMIT 1") + .bind(self.kb) + .fetch_one(&self.pool) + .await?) + } + async fn typed(&self) -> anyhow::Result { + Ok(materialize::count(&self.pool, self.kb).await?) + } + /// 这一轮结束后有没有再排一次对齐:收敛的判据 + async fn requeued(&self) -> anyhow::Result { + let n: i64 = sqlx::query_scalar( + "SELECT count(*) FROM jobs WHERE kind='align_phrases' AND status='queued' AND payload->>'kb_id'=$1", + ) + .bind(self.kb.to_string()) + .fetch_one(&self.pool) + .await?; + Ok(n > 0) + } + async fn clear_jobs(&self) -> anyhow::Result<()> { + sqlx::query("DELETE FROM jobs WHERE payload->>'kb_id'=$1") + .bind(self.kb.to_string()) + .execute(&self.pool) + .await?; + Ok(()) + } + async fn cleanup(self) -> anyhow::Result<()> { + self.server.abort(); + self.clear_jobs().await?; + sqlx::query("DELETE FROM organizations WHERE id=$1") + .bind(self.org) + .execute(&self.pool) + .await?; + drop(self.state); + self.dir.close()?; + Ok(()) + } +} + +fn vote(key: Option<&str>, dir: Option<&str>) -> Value { + json!({"b":[[0, key, dir]]}) +} +fn bound() -> Vec { + vec![ + vote(Some("based_in"), Some("forward")), + vote(Some("based_in"), Some("forward")), + ] +} +fn none() -> Vec { + vec![vote(None, None), vote(None, None)] +} + +#[tokio::test] +async fn a_property_declared_on_an_ancestor_is_offered_with_its_basis_and_bound( +) -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(bound()); + f.run().await?; + assert_eq!(f.requests().len(), 2, "two votes"); + let prompt = f.prompt_of(0); + assert!(prompt.contains("based_in"), "{prompt}"); + assert!( + prompt.contains("fits by inheritance: organization is a subclass of legal_entity"), + "the model is told why the candidate fits: {prompt}" + ); + let b = f.binding().await?; + assert_eq!( + (b.status.as_str(), b.decided_by.as_str()), + ("bound", "agent") + ); + assert!(b.basis.is_some(), "an agent decision records its basis"); + assert_eq!(f.typed().await?, 1, "the projection follows"); + assert!( + !f.requeued().await?, + "unchanged inputs leave no queued work" + ); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +#[tokio::test] +async fn removing_the_parent_edge_retires_the_projection_without_a_model_call() -> anyhow::Result<()> +{ + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(bound()); + f.run().await?; + assert_eq!(f.typed().await?, 1); + sqlx::query("DELETE FROM entity_type_parents WHERE child_id=$1") + .bind(f.organization) + .execute(&f.pool) + .await?; + f.clear_jobs().await?; + f.run().await?; + assert_eq!(f.requests().len(), 2, "no candidate, nothing to ask"); + let b = f.binding().await?; + assert_eq!(b.status, "none"); + assert_eq!(f.reason().await?.as_deref(), Some("no_candidates")); + assert_eq!(f.typed().await?, 0, "the unsupported projection is retired"); + assert!(!f.requeued().await?); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +#[tokio::test] +async fn adding_a_parent_edge_reopens_a_structural_none() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + sqlx::query("DELETE FROM entity_type_parents WHERE child_id=$1") + .bind(f.organization) + .execute(&f.pool) + .await?; + f.run().await?; + assert_eq!(f.requests().len(), 0); + assert_eq!(f.binding().await?.status, "none"); + assert!(!f.requeued().await?); + sqlx::query( + "INSERT INTO entity_type_parents(child_id,parent_id,is_primary) VALUES($1,$2,true)", + ) + .bind(f.organization) + .bind(f.legal_entity) + .execute(&f.pool) + .await?; + f.script(bound()); + f.run().await?; + assert_eq!( + f.requests().len(), + 2, + "the edge changed the basis, so it is asked again" + ); + assert_eq!(f.binding().await?.status, "bound"); + assert_eq!(f.typed().await?, 1); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +#[tokio::test] +async fn overflow_is_recorded_for_a_person_and_recovers_when_candidates_shrink( +) -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + // 61 条不声明域/值域的关系:哪一端都接受,加上 based_in 共 62 > 60 + for i in 0..61 { + sqlx::query("INSERT INTO relation_types(id,kb_id,key,label,kind,temporal) VALUES($1,$2,$3,$3,'relation','state')") + .bind(Uuid::now_v7()).bind(f.kb).bind(format!("filler_{i}")).execute(&f.pool).await?; + } + f.run().await?; + assert_eq!(f.requests().len(), 0, "too many to ask"); + let b = f.binding().await?; + assert_eq!(b.status, "undecided"); + assert_eq!(f.reason().await?.as_deref(), Some("too_many_candidates")); + assert!(!f.requeued().await?, "overflow must not queue a run it cannot execute"); + sqlx::query("DELETE FROM relation_types WHERE kb_id=$1 AND key LIKE 'filler_%'") + .bind(f.kb) + .execute(&f.pool) + .await?; + f.script(bound()); + f.run().await?; + assert_eq!(f.requests().len(), 2); + assert_eq!(f.binding().await?.status, "bound"); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +#[tokio::test] +async fn an_endpoint_class_change_moves_the_signature_and_the_old_row_stops_looping( +) -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + // Acme 还没有类:签名 (based in, ?, place),声明了域的属性不接受空的一端 + sqlx::query("UPDATE entities SET type_id=NULL WHERE id=$1") + .bind(f.acme) + .execute(&f.pool) + .await?; + f.run().await?; + assert_eq!(f.requests().len(), 0); + let old = f.binding().await?; + assert_eq!((old.status.as_str(), old.subject_type_id), ("none", None)); + // 类别词绑上了:签名换成 (based in, organization, place),旧行成了孤儿 + sqlx::query("UPDATE entities SET type_id=$2 WHERE id=$1") + .bind(f.acme) + .bind(f.organization) + .execute(&f.pool) + .await?; + f.script(bound()); + f.run().await?; + assert_eq!(f.requests().len(), 2); + let all = phrase_bindings::bindings(&f.pool, f.kb).await?; + assert_eq!(all.len(), 2, "the orphan stays as a cached decision"); + assert!(all + .iter() + .any(|b| b.subject_type_id == Some(f.organization) && b.status == "bound")); + assert!( + !f.requeued().await?, + "an orphan must not queue work it cannot execute" + ); + f.run().await?; + assert_eq!( + f.requests().len(), + 2, + "a second run asks nothing and queues nothing" + ); + assert!(!f.requeued().await?); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +#[tokio::test] +async fn an_edit_during_the_model_request_leaves_the_decision_stale() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.model.hold.store(true, std::sync::atomic::Ordering::SeqCst); + f.script(none()); + let state = f.state.clone(); + let kb = f.kb; + let worker = tokio::spawn(async move { align_phrases(&state, kb).await }); + tokio::time::timeout(std::time::Duration::from_secs(10), f.model.entered.notified()).await?; + // 模型还在答,定义改了:两票读的都是旧定义 + sqlx::query("UPDATE relation_types SET description='NEW definition', updated_at=clock_timestamp() WHERE id=$1") + .bind(f.based_in) + .execute(&f.pool) + .await?; + f.model.release.notify_one(); + worker.await??; + let b = f.binding().await?; + assert_eq!(b.status, "none"); + assert!( + f.requeued().await?, + "the run noticed its own basis is already stale and queued another" + ); + f.clear_jobs().await?; + f.script(bound()); + f.run().await?; + assert_eq!(f.requests().len(), 4, "asked again because the basis differs, not the clock"); + assert_eq!(f.binding().await?.status, "bound"); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +#[tokio::test] +async fn a_person_decision_made_during_the_request_is_not_overwritten() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.model + .hold + .store(true, std::sync::atomic::Ordering::SeqCst); + f.script(none()); + let state = f.state.clone(); + let kb = f.kb; + let worker = tokio::spawn(async move { align_phrases(&state, kb).await }); + tokio::time::timeout( + std::time::Duration::from_secs(10), + f.model.entered.notified(), + ) + .await?; + let sig = phrase_bindings::signatures(&f.pool, f.kb).await?.remove(0); + phrase_bindings::decide( + &f.pool, + f.kb, + &sig, + phrase_bindings::Decision { + relation_type_id: Some(f.based_in), + direction: Some("forward"), + status: "bound", + votes: &json!({}), + decided_by: "person", + basis: None, + }, + ) + .await?; + f.model.release.notify_one(); + worker.await??; + let b = f.binding().await?; + assert_eq!( + (b.status.as_str(), b.decided_by.as_str()), + ("bound", "person") + ); + assert!( + !f.requeued().await?, + "a person's decision is never re-evaluated" + ); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} diff --git a/crates/utopia-store/src/materialize_delivery_tests.rs b/crates/utopia-store/src/materialize_delivery_tests.rs index 16f964e9f..a4b7c598c 100644 --- a/crates/utopia-store/src/materialize_delivery_tests.rs +++ b/crates/utopia-store/src/materialize_delivery_tests.rs @@ -51,6 +51,7 @@ async fn accept( status: if property.is_some() { "bound" } else { "none" }, votes: &json!({}), decided_by: "person", + basis: None, } ) .await? diff --git a/crates/utopia-store/src/phrase_bindings.rs b/crates/utopia-store/src/phrase_bindings.rs index 5e06a7060..c4e7f2d39 100644 --- a/crates/utopia-store/src/phrase_bindings.rs +++ b/crates/utopia-store/src/phrase_bindings.rs @@ -10,6 +10,7 @@ use chrono::{DateTime, Utc}; use sqlx::PgPool; +use std::collections::HashMap; use utopia_core::{AppError, AppResult}; use uuid::Uuid; @@ -146,6 +147,8 @@ pub struct Binding { pub decided_at: DateTime, /// agent / person pub decided_by: String, + /// 判定时输入的指纹;NULL = 这一列出现之前的判定 + pub basis: Option, } impl Binding { @@ -175,7 +178,7 @@ impl PhraseSignature { pub async fn bindings(pool: &PgPool, kb_id: Uuid) -> AppResult> { Ok(sqlx::query_as( "SELECT phrase, subject_type_id, object_type_id, object_is_value, - relation_type_id, direction, status, decided_at, decided_by + relation_type_id, direction, status, decided_at, decided_by, basis FROM phrase_bindings WHERE kb_id = $1 ORDER BY phrase", ) .bind(kb_id) @@ -183,13 +186,64 @@ pub async fn bindings(pool: &PgPool, kb_id: Uuid) -> AppResult> { .await?) } +/// 一条判定看到的输入的指纹(0053):两端类的祖先闭包(含自己)、宾语是不是字面值、 +/// 按继承命中的候选属性与各自的 `updated_at`。worker 每轮对活着的签名重算,与存下的 +/// 不一致就是过期——比的是**现在的输入**,不是时刻,于是父边的增删、模型请求途中的 +/// 编辑(#795)都看得见,时间戳看不见。 +/// +/// 只是缓存失效的键,不是安全用途:FNV-1a 64 位够用,也不用为它拉一个哈希依赖。 +pub fn basis_of( + subject_closure: &[Uuid], + object_closure: &[Uuid], + object_is_value: bool, + candidates: &[(Uuid, DateTime)], +) -> String { + let sorted = |ids: &[Uuid]| { + let mut v: Vec = ids.iter().map(|u| u.to_string()).collect(); + v.sort(); + v.join(",") + }; + let mut cands: Vec = candidates + .iter() + .map(|(id, at)| format!("{id}@{}", at.to_rfc3339())) + .collect(); + cands.sort(); + let text = format!( + "s={};o={};v={};c={}", + sorted(subject_closure), + sorted(object_closure), + object_is_value, + cands.join(",") + ); + let mut h: u64 = 0xcbf29ce484222325; + for b in text.as_bytes() { + h ^= u64::from(*b); + h = h.wrapping_mul(0x100000001b3); + } + format!("{h:016x}") +} + +/// 库里每条属性最后一次改动的时刻,给指纹用。一轮读一次,不进视图——视图是给页面的, +/// 页面不需要这个数 +pub async fn property_versions( + pool: &PgPool, + kb_id: Uuid, +) -> AppResult>> { + let rows: Vec<(Uuid, DateTime)> = + sqlx::query_as("SELECT id, updated_at FROM relation_types WHERE kb_id = $1") + .bind(kb_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().collect()) +} + /// 不再成立的绑定:绑到的属性在判定之后改过;或判成 none / undecided 之后库里有属性 /// 新建或修改。负向判定没有选中的属性,已有属性的新定义也可能让它对得上。 /// 属性或类被删了的,行已随级联消失。 pub async fn stale(pool: &PgPool, kb_id: Uuid) -> AppResult> { Ok(sqlx::query_as( "SELECT b.phrase, b.subject_type_id, b.object_type_id, b.object_is_value, - b.relation_type_id, b.direction, b.status, b.decided_at, b.decided_by + b.relation_type_id, b.direction, b.status, b.decided_at, b.decided_by, b.basis FROM phrase_bindings b LEFT JOIN relation_types r ON r.id = b.relation_type_id WHERE b.kb_id = $1 @@ -214,6 +268,8 @@ pub struct Decision<'a> { pub votes: &'a serde_json::Value, /// agent / person pub decided_by: &'a str, + /// 判定时输入的指纹([`basis_of`]):代理的判定必带,人的判定不带——人不按指纹重判 + pub basis: Option<&'a str>, } /// 记下一条签名的判定(有则改)。返回是否写入了。 @@ -321,8 +377,8 @@ pub async fn decide_on( "INSERT INTO phrase_bindings (id, kb_id, phrase, subject_type_id, object_type_id, object_is_value, relation_type_id, direction, status, votes, statement_count, examples, - decided_at, decided_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13) + decided_at, decided_by, basis) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13, $14) ON CONFLICT (kb_id, phrase, subject_type_id, object_type_id, object_is_value) DO UPDATE SET relation_type_id = EXCLUDED.relation_type_id, direction = EXCLUDED.direction, @@ -331,7 +387,8 @@ pub async fn decide_on( statement_count = EXCLUDED.statement_count, examples = EXCLUDED.examples, decided_at = now(), - decided_by = EXCLUDED.decided_by + decided_by = EXCLUDED.decided_by, + basis = EXCLUDED.basis WHERE NOT (phrase_bindings.decided_by = 'person' AND EXCLUDED.decided_by = 'agent')", ) .bind(Uuid::now_v7()) @@ -351,6 +408,7 @@ pub async fn decide_on( .bind(i32::try_from(sig.count).unwrap_or(i32::MAX)) .bind(&sig.examples) .bind(d.decided_by) + .bind(d.basis) .execute(connection) .await?; Ok(res.rows_affected() > 0) diff --git a/crates/utopia-store/src/phrase_bindings_delivery_tests.rs b/crates/utopia-store/src/phrase_bindings_delivery_tests.rs index 0aabc0153..78fde08e9 100644 --- a/crates/utopia-store/src/phrase_bindings_delivery_tests.rs +++ b/crates/utopia-store/src/phrase_bindings_delivery_tests.rs @@ -83,6 +83,7 @@ fn bound(property: Uuid) -> Decision<'static> { status: "bound", votes: &serde_json::Value::Null, decided_by: "person", + basis: None, } } @@ -93,6 +94,7 @@ fn none() -> Decision<'static> { status: "none", votes: &serde_json::Value::Null, decided_by: "person", + basis: None, } } diff --git a/crates/utopia-store/tests/human_phrase_materialization_delivery.rs b/crates/utopia-store/tests/human_phrase_materialization_delivery.rs index c1059866c..7e35ae326 100644 --- a/crates/utopia-store/tests/human_phrase_materialization_delivery.rs +++ b/crates/utopia-store/tests/human_phrase_materialization_delivery.rs @@ -24,6 +24,7 @@ async fn accept( status: if property.is_some() { "bound" } else { "none" }, votes: &json!({}), decided_by: "person", + basis: None, } ) .await? @@ -231,6 +232,7 @@ fn crash_child() { status: "bound", votes: &json!({}), decided_by: "person", + basis: None, }, ) .await diff --git a/crates/utopia-store/tests/materialization_is_serial.rs b/crates/utopia-store/tests/materialization_is_serial.rs index 89c42cec5..120e1c1cc 100644 --- a/crates/utopia-store/tests/materialization_is_serial.rs +++ b/crates/utopia-store/tests/materialization_is_serial.rs @@ -80,6 +80,7 @@ async fn overlapping_materializations_create_one_typed_fact() -> anyhow::Result< status: "bound", votes: &serde_json::json!({}), decided_by: "agent", + basis: None, }, ) .await?; diff --git a/crates/utopia-store/tests/negative_binding_definition_edit.rs b/crates/utopia-store/tests/negative_binding_definition_edit.rs index 6a76d1fb9..9741c7fc1 100644 --- a/crates/utopia-store/tests/negative_binding_definition_edit.rs +++ b/crates/utopia-store/tests/negative_binding_definition_edit.rs @@ -63,6 +63,7 @@ impl BindingKind { status, votes: &votes, decided_by: actor, + basis: None, }, ) .await? diff --git a/docs/decisions/0044-the-ontology-is-a-view-over-what-documents-say.md b/docs/decisions/0044-the-ontology-is-a-view-over-what-documents-say.md index baf99da98..5432cdbc9 100644 --- a/docs/decisions/0044-the-ontology-is-a-view-over-what-documents-say.md +++ b/docs/decisions/0044-the-ontology-is-a-view-over-what-documents-say.md @@ -68,6 +68,8 @@ This is how the facts a reader draws without the text stating them (a place's co When the ontology changes, only facts under changed signatures and rules are recomputed. A signature with no property stays in the open graph, loses nothing, and counts toward the workbench's suggestions. +**Revision 2026-09-23:** [0053](0053-a-phrase-decision-records-the-inputs-it-considered.md) replaces "a binding goes stale by timestamp" with a recorded basis: candidates are admitted through the class hierarchy, a decision stores a fingerprint of the closures and candidates it saw, and it is stale when the current fingerprint differs. No-candidate and overflow become recorded outcomes; the requeue condition reads live signatures only (#807, #795). + **Revision proposed 2026-09-21:** [0051](0051-a-human-phrase-decision-carries-its-materialization-work.md) addresses delivery after a human binding commits beyond an older materializer’s final read. It proposes a decision and its own durable job in one transaction, while retaining the current projection semantics. The asynchronous HTTP/job/UI contract remains unimplemented. diff --git a/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md b/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md new file mode 100644 index 000000000..3b5a508e8 --- /dev/null +++ b/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md @@ -0,0 +1,94 @@ +# 0053 · A phrase decision records the inputs it considered + +- **Status**: implemented 2026-09-23 on `feat/alignment-cut2` (PR number added at merge) · `phrase_bindings.basis` (migration 0072), candidates admitted through the class hierarchy and shown to the model as such, structural outcomes recorded instead of skipped, the requeue condition reads live signatures only · closes the lifecycle half of #807 and the phrase half of #795; the kind-word aligner keeps its timestamp staleness for now +- **Written**: 2026-09-23 +- **Related**: [0044](0044-the-ontology-is-a-view-over-what-documents-say.md) decision 3; [0051](0051-a-human-phrase-decision-carries-its-materialization-work.md); #807, #795, #801 (withdrawn), #773, #754 + +## Problem + +A phrase signature is decided once and cached; the cache is only right while the inputs that +produced it hold. Until now "the inputs" were identified by timestamps: a bound signature went +stale when its property was updated after the decision, a negative one when any property in the +base was added or updated. #807 and #795 showed four things timestamps cannot see: + +1. **Inheritance.** Candidates were properties whose declared domain and range contained the + endpoint class itself. A property declared on `legal_entity` was never a candidate for an + `organization` signature, so a correct binding was structurally impossible, and no edit to the + property would ever make it stale, because the property was never considered. +2. **Parent edges.** Adding `organization ⊂ legal_entity` can turn "no candidate" into a + candidate; removing it can take the support from a bound signature. `entity_type_parents` + carries no timestamp and no decision was tied to it. +3. **Edits during the request.** A definition changed while the model was answering commits before + the decision does, so the decision's `decided_at` is later than the edit and nothing is stale, + although both votes read the old definition (#795, reproduced with a scripted model). +4. **Two silences.** A signature with no candidate was skipped, never recorded: a previously bound + signature whose property stopped fitting kept its typed projection. A signature with more than + `CANDIDATE_LIMIT` candidates was also skipped, and since `stale` kept returning it, it queued a + run every time without ever becoming executable. Worse, a signature whose endpoint class changed + left an orphan row behind that `stale` returned forever (three rounds, one job each, in the + review of #801). + +#801 fixed the first and part of the fourth locally and was withdrawn by its author: the +interactions between signature identity, cached decisions, ontology changes and scheduling needed a +design, not another patch. + +## Decision + +**A decision stores a fingerprint of what it considered, and staleness is "the fingerprint of the +current inputs differs".** The fingerprint (`basis`) covers the ancestor closure of both endpoint +classes, whether the object is a value, and the set of candidate properties admitted through that +closure with each one's `updated_at`. The worker recomputes it for every live signature on every +run and compares it with the stored one. Nothing is compared to a clock. + +This answers the four gaps at once. Inheritance changes the closure. A parent edge changes the +closure. An edit during the request changes a candidate's `updated_at`, so the stored fingerprint, +computed before the model was called, no longer matches at the next run: the decision remains +detectably stale exactly as #795 asked. And the two silences become recorded outcomes with their +own reasons, so they participate in staleness like any other decision. + +**Candidates are admitted through the class hierarchy, and the model is told why.** `fits` walks +the ancestor closure; when a property fits only through an ancestor, the candidate line says +`fits by inheritance: organization is a subclass of legal_entity` and the system prompt says that +this is a fit. Widening the candidates in code without showing the basis made the model answer +null (#801's finding). + +**Structural outcomes are decisions.** No admissible property: `none` with +`votes.reason = "no_candidates"`, which lets materialisation retire a projection whose support is +gone. More than the limit: `undecided` with `reason = "too_many_candidates"` and the count, which +puts it in the alignment queue for a person and stops it from requeueing. Both carry the +fingerprint, so a property added or removed reopens them like any other negative. + +**The requeue condition reads live signatures only.** A run queues another run when a batch failed, +when a live signature has no decision and was not attempted, or when a live agent decision's +fingerprint no longer matches. An orphaned row (its signature moved because an endpoint class +changed) has no live signature and is never consulted; its typed rows retire through the ordinary +materialisation rule that a statement's current signature must be bound. Unchanged inputs +therefore leave no queued work. + +**A person's decision is not fingerprinted.** It is never re-evaluated by the agent, so it carries +no basis; the human-precedence rule in `decide_on` is unchanged. A person-bound signature whose +property stops fitting keeps its projection: the person said so. + +## Not doing + +- Fingerprinting the kind-word aligner. #795's reproduction is on `align_types`; the same design + applies and is the obvious next cut, but its inputs (kind words, class definitions, the + hierarchy) are a different set and this record does not claim them. +- A revision table of decisions. The old decision is overwritten in place as before; the audit + ledger keeps the person's decisions and the projection changes. #807 asked what records are + retained: the answer here is the current decision plus its basis, nothing historical. +- A separate job per signature. One run per base, batched, as before. + +## Measurement + +Regression coverage, each with a scripted model and a real PostgreSQL: inheritance admits a +property declared on an ancestor and the model sees the basis; removing the parent edge retires the +bound signature's projection without a model call; adding the edge reopens a structural `none`; +overflow is recorded as `undecided` with no model call and no requeue, and shrinking the candidate +set makes it executable again; an endpoint class change orphans the old row without an endless +requeue and decides the new signature; an edit during the model request leaves the decision stale +for the next run; a person's decision made during a request is not overwritten. + +The cost is one fingerprint per live signature per run, computed from data the run already loads, +plus one query for property versions. Rows decided before this record have no basis and are +re-decided once. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 045f60dda..3ce0ed034 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -77,6 +77,8 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | Proposed · declaration policy pending; opt-in web draft only | | 0050 | [An action attempt keeps its identity and uncertain outcome](0050-an-action-attempt-keeps-its-identity-and-uncertain-outcome.md) | Proposed · durable execution identity and uncertain outcomes; no sender | | 0051 | [A human phrase decision carries its materialization work](0051-a-human-phrase-decision-carries-its-materialization-work.md) | Proposed · decision and materialization delivery; shared refactors and real regressions only | +| 0052 | [Document content is a read contract over the retained ledger](0052-document-content-is-a-read-contract.md) | Proposed 2026-09-21 · implemented in #860 · two Viewer-level reads serve the retained originals the export already names by digest: `/documents/{id}/content[?version=N]` and `/documents/{id}/versions`; the handler locks the document and its ledger row through the blob read, purge answers 410, a ledger-referenced missing blob is a 500 invariant failure, a session or a scoped PAT may read, ingest tokens may not +| 0053 | [A phrase decision records the inputs it considered](0053-a-phrase-decision-records-the-inputs-it-considered.md) | Implemented 2026-09-23 · a decision stores a fingerprint of the ancestor closures and admitted candidates it saw; stale means the fingerprint of the current inputs differs, which is what timestamps could not see (#807, #795): inheritance, parent edges, edits during the request; no-candidate and overflow become recorded outcomes; requeue reads live signatures only, so orphaned rows stop looping | | Record | Domain | Status | |---|---|---|---| @@ -131,6 +133,8 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | rules | proposed | | 0050 | [An action attempt keeps its identity and uncertain outcome](0050-an-action-attempt-keeps-its-identity-and-uncertain-outcome.md) | lakehouse-and-actions | proposed | | 0051 | [A human phrase decision carries its materialization work](0051-a-human-phrase-decision-carries-its-materialization-work.md) | ontology | proposed | +| 0052 | [Document content is a read contract over the retained ledger](0052-document-content-is-a-read-contract.md) | sources | current | +| 0053 | [A phrase decision records the inputs it considered](0053-a-phrase-decision-records-the-inputs-it-considered.md) | ontology | current | The status word is whether a later record has overtaken this one; what is built is in the record's own status line. Domains are the files of [../design/](../design/README.md), where every record is dated and the status words are defined. diff --git a/docs/design/ontology.md b/docs/design/ontology.md index 4ca77e424..93b868f36 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -66,11 +66,15 @@ candidates in opposite orders must agree on the property and the direction (forw statement's subject is the property's subject, reverse when its object is) for the signature to bind. A signature the votes disagree on is `undecided` for the alignment queue of #725; one with no fitting property is `none`, its statements stay in the open graph and it counts toward the -workbench's suggestions. Bindings live in `phrase_bindings`: a bound result goes stale when its -selected property changes; `none` and `undecided` go stale when any property in the base is added -or updated, since an existing property's revised definition may now fit [#773]. Kind-word bindings -use the same rule for classes. Both use `updated_at`, so cosmetic edits can also trigger -reevaluation. Even one edit can reopen all older automatic negative bindings on that side of the +workbench's suggestions. Bindings live in `phrase_bindings`. Candidates are the properties whose +declared domain and range admit the endpoint classes **or an ancestor of them**, and a candidate +that fits only by inheritance says so to the model. A decision stores a fingerprint of what it +considered (both ancestor closures, the admitted candidates with their `updated_at`); it is stale +when the fingerprint of the current inputs differs, which is what timestamps could not see: a +parent edge added or removed, an edit committed while the model was answering [0053, #807, #795]. +A signature with no admissible property is recorded as `none` (its projection retires); one with +more candidates than the limit is `undecided` for the queue, not silently skipped. Kind-word +bindings still use `updated_at`, so cosmetic edits can also trigger their reevaluation. Even one edit can reopen all older automatic negative bindings on that side of the base, requiring two votes per eligible item through batched model requests; debouncing reduces the number of runs, not the items reconsidered. A burst of ontology edits debounces into one run rather than one run each [#757]; diff --git a/migrations/0072_a_phrase_decision_records_the_inputs_it_considered.sql b/migrations/0072_a_phrase_decision_records_the_inputs_it_considered.sql new file mode 100644 index 000000000..f4d2f010a --- /dev/null +++ b/migrations/0072_a_phrase_decision_records_the_inputs_it_considered.sql @@ -0,0 +1,11 @@ +-- 一条短语判定记下它当时看到的输入(0053,#807,#795)。 +-- +-- 过期从前按时间戳判:绑到的属性在判定之后改过,或判成 none 之后有属性新建。时间戳看不见 +-- 两件事:类的父边(属性的定义域声明在祖先上,子类经继承命中——加一条父边能让一个判成 +-- 「没有候选」的签名有了候选,去一条能让绑上的失去支撑),以及模型请求途中的编辑(判定 +-- 写入晚于编辑,时间戳说它是新的,可两票看到的都是旧定义)。 +-- +-- `basis` 是判定时输入的指纹:两端类的祖先闭包、按继承命中的候选属性集合与各自的 +-- `updated_at`。worker 每轮对每条活着的签名重算指纹,不一致就是过期——比的是**现在的** +-- 输入而不是时刻。NULL = 这一列出现之前的判定,各重判一次。 +ALTER TABLE phrase_bindings ADD COLUMN basis TEXT; diff --git a/web/src/api.ts b/web/src/api.ts index 7a7577b33..2df340926 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -719,7 +719,13 @@ export type AlignmentItem = object_is_value: boolean; statement_count: number; examples: string[]; - votes: { first?: AlignmentVote | null; second?: AlignmentVote | null } | null; + /** 两票;候选多到没问模型时两票为空、`reason` 说明(0053) */ + votes: { + first?: AlignmentVote | null; + second?: AlignmentVote | null; + reason?: string; + candidates?: number; + } | null; decided_at: string; } | { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 94c43df5d..054c12218 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1953,6 +1953,7 @@ export const en = { alignmentStatements: (n: number) => (n === 1 ? "1 statement" : `${n} statements`), alignmentEntities: (n: number) => (n === 1 ? "1 thing" : `${n} things`), alignmentVotes: (first: string, second: string) => `Votes: ${first} · ${second}`, + alignmentTooMany: (n: number) => `${n} properties could apply; too many to ask the model. Pick one or leave it open.`, alignmentConflict: "This decision conflicts with the current state. Refresh and review it before trying again.", alignmentKindWordBusy: "This kind word is being updated by another operation. Please try again shortly.", alignmentAccepted: "Decision saved. The typed graph is being recomputed and will refresh here when it is done.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 4c6403b19..838e45b94 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1712,6 +1712,7 @@ export const zh: Strings = { alignmentStatements: (n: number) => `${n} 条陈述`, alignmentEntities: (n: number) => `${n} 样东西`, alignmentVotes: (first: string, second: string) => `两票:${first} · ${second}`, + alignmentTooMany: (n: number) => `有 ${n} 条属性都可能对得上,多到没法问模型。请选一条或留在开放图谱。`, alignmentConflict: "此决定与当前状态冲突。请刷新并核对后再试。", alignmentKindWordBusy: "这个类别词正在被其他操作更新,请稍后重试。", alignmentAccepted: "已保存。类型化图谱正在后台重算,算完会在这里自动刷新。", diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index c90e43f5e..51440376d 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -747,6 +747,12 @@ function AlignmentPhraseRow({
{S.review.alignmentVotes(voteText(first), voteText(second))}
+ {/* 候选多到没问模型的签名(0053):说清是这个原因,不是两票都投了空 */} + {item.votes?.reason === "too_many_candidates" && ( +
+ {S.review.alignmentTooMany(item.votes?.candidates ?? 0)} +
+ )}
Date: Wed, 23 Sep 2026 16:41:47 +0800 Subject: [PATCH 2/2] Name the PR that implements 0053 in its status line Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com> --- .../0053-a-phrase-decision-records-the-inputs-it-considered.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md b/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md index 3b5a508e8..48c432c5f 100644 --- a/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md +++ b/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md @@ -1,6 +1,6 @@ # 0053 · A phrase decision records the inputs it considered -- **Status**: implemented 2026-09-23 on `feat/alignment-cut2` (PR number added at merge) · `phrase_bindings.basis` (migration 0072), candidates admitted through the class hierarchy and shown to the model as such, structural outcomes recorded instead of skipped, the requeue condition reads live signatures only · closes the lifecycle half of #807 and the phrase half of #795; the kind-word aligner keeps its timestamp staleness for now +- **Status**: implemented 2026-09-23 in PR #878 · `phrase_bindings.basis` (migration 0072), candidates admitted through the class hierarchy and shown to the model as such, structural outcomes recorded instead of skipped, the requeue condition reads live signatures only · closes the lifecycle half of #807 and the phrase half of #795; the kind-word aligner keeps its timestamp staleness for now - **Written**: 2026-09-23 - **Related**: [0044](0044-the-ontology-is-a-view-over-what-documents-say.md) decision 3; [0051](0051-a-human-phrase-decision-carries-its-materialization-work.md); #807, #795, #801 (withdrawn), #773, #754