From b0fa31d5c576d90bcea75c1ba3f6716ab37823a1 Mon Sep 17 00:00:00 2001 From: dada-yan Date: Sun, 27 Sep 2026 17:26:04 +1000 Subject: [PATCH 1/6] A binding to a state property says what a single date marks, and a state row whose ends are equal is never written Signed-off-by: dada-yan --- crates/utopia-cli/src/main.rs | 2 +- crates/utopia-extract/src/implication.rs | 1 + crates/utopia-extract/src/phrase_align.rs | 225 ++++- crates/utopia-server/src/api/review_routes.rs | 41 +- .../src/api/review_routes_phrase_tests.rs | 94 +++ crates/utopia-server/src/implication.rs | 1 + crates/utopia-server/src/ontology_agent.rs | 1 + .../utopia-server/src/ontology_agent_tests.rs | 1 + crates/utopia-server/src/phrase_alignment.rs | 61 +- .../src/phrase_alignment_tests.rs | 213 ++++- crates/utopia-store/src/alignment_queue.rs | 34 +- crates/utopia-store/src/errata.rs | 23 + crates/utopia-store/src/graph.rs | 105 ++- crates/utopia-store/src/materialize.rs | 86 +- .../src/materialize_delivery_tests.rs | 1 + crates/utopia-store/src/phrase_bindings.rs | 29 +- .../src/phrase_bindings_delivery_tests.rs | 2 + crates/utopia-store/src/review.rs | 3 +- crates/utopia-store/src/review_summary.rs | 5 +- crates/utopia-store/src/temporal.rs | 28 +- .../human_phrase_materialization_delivery.rs | 2 + .../tests/store/a_moment_marks_its_state.rs | 793 ++++++++++++++++++ .../store/a_plan_step_follows_its_premise.rs | 1 + crates/utopia-store/tests/store/main.rs | 1 + .../tests/store/materialization_is_serial.rs | 1 + .../store/negative_binding_definition_edit.rs | 1 + ...cision-records-the-inputs-it-considered.md | 88 +- ..._binding_says_which_end_a_moment_marks.sql | 14 + web/src/api.ts | 12 +- web/src/i18n/en.ts | 16 + web/src/i18n/zh.ts | 13 + web/src/pages/Review.tsx | 89 +- web/src/pages/alignmentPhrase.test.ts | 103 +++ web/src/pages/alignmentPhrase.ts | 25 + 34 files changed, 1993 insertions(+), 122 deletions(-) create mode 100644 crates/utopia-store/tests/store/a_moment_marks_its_state.rs create mode 100644 migrations/0097_a_binding_says_which_end_a_moment_marks.sql create mode 100644 web/src/pages/alignmentPhrase.test.ts create mode 100644 web/src/pages/alignmentPhrase.ts diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index a35a8ab3a..3e341fc43 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 86; +const CURRENT_SCHEMA_VERSION: u32 = 87; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-extract/src/implication.rs b/crates/utopia-extract/src/implication.rs index f624c692e..c4cdab608 100644 --- a/crates/utopia-extract/src/implication.rs +++ b/crates/utopia-extract/src/implication.rs @@ -340,6 +340,7 @@ mod tests { label: key, description: "", kind: "relation", + temporal: "state", domains: vec![], ranges: vec![], via: vec![], diff --git a/crates/utopia-extract/src/phrase_align.rs b/crates/utopia-extract/src/phrase_align.rs index 9cfd17c3b..0177a1963 100644 --- a/crates/utopia-extract/src/phrase_align.rs +++ b/crates/utopia-extract/src/phrase_align.rs @@ -10,9 +10,11 @@ //! 值不是属性量的东西也答 null(股数不是营收)。候选属性的定义、定义域、值域照库里的 //! 写法给,答案里的键照抄。 //! -//! 回复是紧凑 JSON:`{"b": [[id, "key" | null, "forward" | "reverse" | null]]}`。解析同 -//! 类别词那边:坏的一条计数、不毁掉整批;键不在候选里、id 不在批里、绑了却没方向、 -//! 同一个 id 的第二次都算坏;没答到的 id 是「再问」,不是 null。 +//! 回复是紧凑 JSON:`{"b": [[id, "key" | null, "forward" | "reverse" | null, marks]]}`。第四格 +//! 只对状态属性有意义(#966):一条只说了一刻的陈述,那一刻标的是状态的开始(start)、 +//! 结束(end)还是都不是(none);事件、恒常、不绑都答 null,旧的三格写法照样读(marks +//! 缺席)。解析同类别词那边:坏的一条计数、不毁掉整批;键不在候选里、id 不在批里、绑了 +//! 却没方向、marks 写了却认不出、同一个 id 的第二次都算坏;没答到的 id 是「再问」,不是 null。 //! //! **形状也宽容**(同类别词那边的教训):模型(实测 DeepSeek-V3.2)并不总照样例写。它会 //! 把整段答成按 id 作键的对象(`{"0": ["headquartered_in", "forward"], "1": null}`), @@ -37,6 +39,8 @@ pub struct PropertyCandidate<'a> { pub description: &'a str, /// relation(两样东西之间)或 attribute(宾语是值) pub kind: &'a str, + /// state / event / eternal(0031):只有状态有「一刻标哪一端」这个问题(#966) + pub temporal: &'a str, /// 定义域、值域的类键;空表示没声明 pub domains: Vec<&'a str>, pub ranges: Vec<&'a str>, @@ -72,6 +76,38 @@ pub struct PhraseItem<'a> { pub struct PhraseChoice { pub id: i64, pub property: Option<(String, Direction)>, + /// 绑到状态属性时,一条只说了一刻的陈述那一刻标哪一端(#966);没答或不适用是 None + pub marks: Option, +} + +/// 一刻在状态上标的是哪一端(`phrase_bindings.marks`,#966) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Marks { + /// joined、was appointed、took over:状态从这一刻起 + Start, + /// left、resigned from:状态到这一刻为止 + End, + /// 只是那时成立:不算类型化行 + Neither, +} + +impl Marks { + pub fn as_str(self) -> &'static str { + match self { + Marks::Start => "start", + Marks::End => "end", + Marks::Neither => "none", + } + } + + fn parse(written: &str) -> Option { + match written.trim().to_lowercase().as_str() { + "start" => Some(Marks::Start), + "end" => Some(Marks::End), + "none" | "neither" => Some(Marks::Neither), + _ => None, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -97,16 +133,19 @@ it is said of (its subject) and the class of what it points at (its object), or the object is a figure, a title or a status; a few statements with that signature, each with the \ sentence it was taken from; and the keys of its candidate properties. The properties themselves \ are listed once under \"Properties\", each with its key, its label, its kind (a relation between two \ -things, or an attribute whose object is a value), its domain, its range and its definition. A class \ +things, or an attribute whose object is a value), whether it is a state (holds over a span), an event \ +(happens at a moment) or eternal, its domain, its range and its definition. A class \ written as \"?\" means the documents' kind word for that side is bound to no class yet. A candidate \ key 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\ +subject is the property's subject, \"reverse\" when the statement's object is; or null. For a \ +state, also say which end of it a statement dated at a single moment marks (rule 6).\n\ \n\ -Output exactly one JSON object and nothing else, one triple per item:\n\ -{\"b\": [[12, \"headquartered_in\", \"forward\"], [13, null, null]]}\n\ +Output exactly one JSON object and nothing else, one entry per item:\n\ +{\"b\": [[12, \"headquartered_in\", \"forward\", \"none\"], [13, \"member_of\", \"forward\", \"start\"], \ +[14, null, null, null]]}\n\ \n\ 1. Choose a property only when each of the statements under this signature states that \ property, by the definition as given. The property may be broader than the phrase: \"opened a \ @@ -123,10 +162,16 @@ what the attribute measures (a share count is not revenue, a date is not an amou 4. The direction follows the definition: for \"X —is a subsidiary of→ Y\" subsidiary_of is \ forward; for \"X —owns→ Y\", if subsidiary_of is the only fitting candidate, it is reverse.\n\ 5. Never invent a key, never answer with a label, never choose for an item a key that is not \ -among its candidates. One triple per item, every item answered."; +among its candidates. One entry per item, every item answered.\n\ +6. When the property you choose is a state, the fourth value says what a statement of this \ +signature dated at a single moment means for that state: \"start\" when the phrase says the state \ +began then (\"joined\", \"was appointed\", \"took over\"), \"end\" when it says the state ended \ +then (\"left\", \"resigned from\", \"stepped down as\"), \"none\" when the date only says the \ +state held at that moment (\"was chairman in 2019\"). For an event or eternal property, or no \ +property, the fourth value is null."; pub fn candidate_line(c: &PropertyCandidate<'_>) -> String { - let mut line = format!("- {} · {} · {}", c.key, c.label, c.kind); + let mut line = format!("- {} · {} · {} · {}", c.key, c.label, c.kind, c.temporal); if !c.domains.is_empty() { line.push_str(&format!(" · domain: {}", c.domains.join(", "))); } @@ -220,8 +265,8 @@ pub fn parse_phrase_response( let mut choices = Vec::new(); let mut malformed = 0usize; let mut seen = HashSet::new(); - for (id, key, direction) in answers(&value) { - match parse_triple(&id, &key, &direction, &by_id) { + for (id, key, direction, marks) in answers(&value) { + match parse_answer(&id, &key, &direction, &marks, &by_id) { Some(choice) if seen.insert(choice.id) => choices.push(choice), _ => malformed += 1, } @@ -229,7 +274,8 @@ pub fn parse_phrase_response( Ok((choices, malformed)) } -/// 回复里的每一条答案:(id,键,方向)三个原始值,形状还没验。 +/// 回复里的每一条答案:(id,键,方向,marks)四个原始值,形状还没验。marks 在第四格或 +/// `marks` 字段里,缺席就是 null(旧的三格写法照样读)。 /// /// 认这几种写法,说的都是「这个 id 选了这个键、这个方向」:`{"b": [[id, key, dir]]}` /// (样例)、`{"b": {"id": [key, dir]}}`、没有 `b` 的顶层对象 `{"id": [key, dir]}`、 @@ -237,31 +283,32 @@ pub fn parse_phrase_response( /// 按 id 作键时值也可以是 `{"key": .., "direction": ..}`、`null`(不绑)或先抄一遍 id 的 /// `[id, key, dir]`。`b` 在就只看 `b`,顶层别的键是模型的旁白,不是答案。缺了 `b` 又 /// 不是对象或数组的,一条都没有 -fn answers(value: &Value) -> Vec<(Value, Value, Value)> { +fn answers(value: &Value) -> Vec<(Value, Value, Value, Value)> { let listed = value.get("b").unwrap_or(value); match listed { Value::Array(entries) => entries .iter() .map(|entry| match entry { Value::Array(list) if list.len() >= 2 => { - let (key, direction) = key_and_direction(&list[1..]); - (list[0].clone(), key, direction) + let (key, direction, marks) = key_direction_marks(&list[1..]); + (list[0].clone(), key, direction, marks) } Value::Object(map) => { - let (key, direction) = fields(map); + let (key, direction, marks) = fields(map); ( map.get("id").cloned().unwrap_or(Value::Null), key, direction, + marks, ) } - other => (other.clone(), Value::Null, Value::Null), + other => (other.clone(), Value::Null, Value::Null, Value::Null), }) .collect(), Value::Object(map) => map .iter() .map(|(id, v)| { - let (key, direction) = match v { + let (key, direction, marks) = match v { Value::Array(list) => { // 值里先抄一遍 id 再给键:`{"0": [0, "headquartered_in", "forward"]}` let copied = list @@ -269,30 +316,33 @@ fn answers(value: &Value) -> Vec<(Value, Value, Value)> { .and_then(item_id) .is_some_and(|first| id.trim().parse::().ok() == Some(first)); let list = if copied { &list[1..] } else { &list[..] }; - key_and_direction(list) + key_direction_marks(list) } Value::Object(inner) => fields(inner), - other => (other.clone(), Value::Null), + other => (other.clone(), Value::Null, Value::Null), }; - (Value::String(id.clone()), key, direction) + (Value::String(id.clone()), key, direction, marks) }) .collect(), _ => Vec::new(), } } -/// `[key, dir]` 的两格;只有一格就没有方向(null 的答案常只写一格)。空数组是「什么 -/// 都没写」,读成 null 键会把没答到说成不绑——留成对象,调用方算坏 -fn key_and_direction(list: &[Value]) -> (Value, Value) { +/// `[key, dir, marks]` 的三格;只有一格就没有方向(null 的答案常只写一格),没有第三格 +/// 就没有 marks。空数组是「什么都没写」,读成 null 键会把没答到说成不绑——留成对象, +/// 调用方算坏 +fn key_direction_marks(list: &[Value]) -> (Value, Value, Value) { match list { - [] => (Value::Array(Vec::new()), Value::Null), - [key] => (key.clone(), Value::Null), - [key, direction, ..] => (key.clone(), direction.clone()), + [] => (Value::Array(Vec::new()), Value::Null, Value::Null), + [key] => (key.clone(), Value::Null, Value::Null), + [key, direction] => (key.clone(), direction.clone(), Value::Null), + [key, direction, marks, ..] => (key.clone(), direction.clone(), marks.clone()), } } -/// 一条写成对象时的两个字段:键叫 key / property / p,方向叫 direction / dir / d -fn fields(map: &serde_json::Map) -> (Value, Value) { +/// 一条写成对象时的三个字段:键叫 key / property / p,方向叫 direction / dir / d, +/// 一刻标哪一端叫 marks / m +fn fields(map: &serde_json::Map) -> (Value, Value, Value) { let pick = |names: &[&str]| { names .iter() @@ -303,27 +353,30 @@ fn fields(map: &serde_json::Map) -> (Value, Value) { ( pick(&["key", "property", "p"]), pick(&["direction", "dir", "d"]), + pick(&["marks", "m"]), ) } /// 一条答案:id 得是这批里的;键是 null(不绑)或候选里的一个,绑了就得有方向,方向 /// 不认识算坏。键包在数组里的(`["headquartered_in", "forward"]` 塞在第二格)照样读, -/// 方向从数组里取 -fn parse_triple( +/// 方向与 marks 从数组里取。marks 只随绑定读:null 或缺席是没说,写了却认不出算坏 +fn parse_answer( id: &Value, key: &Value, direction: &Value, + marks: &Value, by_id: &HashMap>, ) -> Option { let id = item_id(id)?; let item = by_id.get(&id)?; - let (key, direction) = match key { + let (key, direction, marks) = match key { Value::Array(list) if !list.is_empty() => { - let (k, d) = key_and_direction(list); + let (k, d, m) = key_direction_marks(list); let d = if d.is_null() { direction.clone() } else { d }; - (k, d) + let m = if m.is_null() { marks.clone() } else { m }; + (k, d, m) } - other => (other.clone(), direction.clone()), + other => (other.clone(), direction.clone(), marks.clone()), }; let property = match &key { Value::Null => None, @@ -342,7 +395,16 @@ fn parse_triple( } _ => return None, }; - Some(PhraseChoice { id, property }) + let marks = match (&property, &marks) { + (None, _) | (_, Value::Null) => None, + (Some(_), Value::String(written)) => Some(Marks::parse(written)?), + (Some(_), _) => return None, + }; + Some(PhraseChoice { + id, + property, + marks, + }) } /// 模型写的键对回这一项的候选:先原样,再不分大小写(只在唯一命中时) @@ -400,6 +462,7 @@ mod tests { label: "headquartered in", description: "The organization's principal office is at the place.", kind: "relation", + temporal: "state", domains: vec!["organization"], ranges: vec!["place"], via: Vec::new(), @@ -409,6 +472,7 @@ mod tests { label: "subsidiary of", description: "The organization is owned or controlled by the other organization.", kind: "relation", + temporal: "state", domains: vec!["organization"], ranges: vec!["organization"], via: Vec::new(), @@ -418,6 +482,7 @@ mod tests { label: "revenue", description: "Total income from sales for a period, as an amount of money.", kind: "attribute", + temporal: "state", domains: vec!["organization"], ranges: vec![], via: Vec::new(), @@ -445,7 +510,7 @@ mod tests { let user = &msgs[1].content; assert!(user.contains("Item 3: phrase \"is based in\" · subject class: organization · object: ? · 4 statements"), "{user}"); assert!(user.contains("· Harbor Bakery —is based in→ Port Ellen\n \"Harbor Bakery is based in Port Ellen.\""), "{user}"); - assert!(user.contains("- headquartered_in · headquartered in · relation · domain: organization · range: place · The organization's"), "{user}"); + assert!(user.contains("- headquartered_in · headquartered in · relation · state · domain: organization · range: place · The organization's"), "{user}"); assert!( user.starts_with("Properties:\n- "), "the glossary comes first, once: {user}" @@ -460,7 +525,9 @@ mod tests { "each property is described once: {user}" ); assert!( - user.contains("- revenue · revenue · attribute · domain: organization · Total income"), + user.contains( + "- revenue · revenue · attribute · state · domain: organization · Total income" + ), "{user}" ); assert!(msgs[0] @@ -573,15 +640,18 @@ mod tests { vec![ PhraseChoice { id: 0, - property: Some(("subsidiary_of".into(), Direction::Reverse)) + property: Some(("subsidiary_of".into(), Direction::Reverse)), + marks: None, }, PhraseChoice { id: 1, - property: None + property: None, + marks: None, }, PhraseChoice { id: 4, - property: Some(("revenue".into(), Direction::Forward)) + property: Some(("revenue".into(), Direction::Forward)), + marks: None, }, ] ); @@ -650,11 +720,82 @@ mod tests { PhraseChoice { id, property: Some((key.to_string(), direction)), + marks: None, } } fn unbound(id: i64) -> PhraseChoice { - PhraseChoice { id, property: None } + PhraseChoice { + id, + property: None, + marks: None, + } + } + + fn marked(id: i64, key: &str, direction: Direction, marks: Marks) -> PhraseChoice { + PhraseChoice { + marks: Some(marks), + ..bound(id, key, direction) + } + } + + /// 第四格说一刻标状态的哪一端(#966):四格数组、对象的 marks 字段、按 id 作键的写法都读; + /// 缺席或 null 是没说;不绑的不带;写了却认不出的算坏项 + #[test] + fn the_fourth_value_says_which_end_a_moment_marks() { + let (examples, quotes) = (strings(&[]), strings(&[])); + let items = three_items(&examples, "es); + let raw = r#"{"b": [[0, "subsidiary_of", "reverse", "start"], [1, null, null, "end"], [2, "revenue", "forward", null]]}"#; + let (choices, malformed) = parse_phrase_response(raw, &items).unwrap(); + assert_eq!(malformed, 0); + assert_eq!( + choices, + vec![ + marked(0, "subsidiary_of", Direction::Reverse, Marks::Start), + unbound(1), + bound(2, "revenue", Direction::Forward), + ] + ); + let raw = r#"{"0": ["subsidiary_of", "reverse", "END"], "2": {"key": "revenue", "direction": "forward", "marks": "none"}}"#; + let (choices, malformed) = parse_phrase_response(raw, &items).unwrap(); + assert_eq!(malformed, 0); + assert_eq!( + choices, + vec![ + marked(0, "subsidiary_of", Direction::Reverse, Marks::End), + marked(2, "revenue", Direction::Forward, Marks::Neither), + ] + ); + let raw = r#"{"b": [{"id": 0, "p": "subsidiary_of", "d": "reverse", "m": "start"}, [2, "revenue", "forward", "later"]]}"#; + let (choices, malformed) = parse_phrase_response(raw, &items).unwrap(); + assert_eq!( + choices, + vec![marked(0, "subsidiary_of", Direction::Reverse, Marks::Start)] + ); + assert_eq!(malformed, 1, "an unreadable marks is a bad answer"); + } + + /// 提示词说出每条属性是不是状态,以及第四格怎么答 + #[test] + fn the_prompt_says_which_properties_are_states_and_asks_for_the_fourth_value() { + let (examples, quotes) = (strings(&[]), strings(&[])); + let msgs = build_phrase_messages(&three_items(&examples, "es)); + assert!(msgs[1] + .content + .contains("- subsidiary_of · subsidiary of · relation · state ·")); + let system = &msgs[0].content; + assert!( + system.contains("\"start\" when the phrase says the state began then"), + "{system}" + ); + assert!( + system.contains("\"end\" when it says the state ended then"), + "{system}" + ); + assert!( + system.contains("For an event or eternal property"), + "{system}" + ); } /// 类别词那边实测的写法搬到短语上:整段是按 id 作键的对象,没有 `b`,值是 diff --git a/crates/utopia-server/src/api/review_routes.rs b/crates/utopia-server/src/api/review_routes.rs index 1f44119a1..a80337c6c 100644 --- a/crates/utopia-server/src/api/review_routes.rs +++ b/crates/utopia-server/src/api/review_routes.rs @@ -1225,6 +1225,10 @@ pub struct DecideAlignmentPhraseReq { /// forward | reverse;给了属性就得给 #[serde(default)] pub direction: Option, + /// start | end | none:状态属性下,带着某一刻的陈述那一刻标的是开始、结束,还是都不是 + /// (#966)。不给 = 未知:这样的陈述不算类型化行,签名留在队列里等人补 + #[serde(default)] + pub marks: Option, } /// 人定一条短语签名绑到哪个属性(#725 对齐队列)。写成人的判定,代理此后不再改它; @@ -1293,17 +1297,42 @@ pub async fn decide_alignment_phrase( { None => None, Some(key) => { - let id = utopia_store::ontology::relation_type_views(&state.pool, kb_id) + let found = utopia_store::ontology::relation_type_views(&state.pool, kb_id) .await? .into_iter() .find(|p| p.key == key) - .map(|p| p.id) + .map(|p| (p.id, p.temporal == "state")) .ok_or_else(|| { utopia_core::AppError::invalid("unknown_property", "no property with that key") })?; - Some(id) + Some(found) + } + }; + let marks = match ( + property, + req.marks + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()), + ) { + (_, None) => None, + (Some((_, true)), Some(m @ ("start" | "end" | "none"))) => Some(m), + (Some((_, true)), Some(_)) => { + return Err(utopia_core::AppError::invalid( + "unknown_marks", + "marks is one of start, end or none", + ) + .into()) + } + (_, Some(_)) => { + return Err(utopia_core::AppError::invalid( + "marks_needs_state", + "only a state property says which end a moment marks", + ) + .into()) } }; + let property = property.map(|(id, _)| id); let direction = match (property, req.direction.as_deref()) { (None, _) => None, (Some(_), Some(d @ ("forward" | "reverse"))) => Some(d), @@ -1315,7 +1344,8 @@ pub async fn decide_alignment_phrase( .into()) } }; - let votes = json!({ "person": { "property": req.property, "direction": direction } }); + let votes = json!({ "person": { "property": req.property, "direction": direction, + "marks": marks } }); // 判定和它的重算任务一次提交(0051)。这里**不再**同步重算:等物化锁占的是池里的 // 连接,而正在跑的那次对齐可能已经读完最后一遍,谁也不替这条判定投影。一个 job // 只在判定提交后可见,worker 读的是当前绑定;屏幕上等的是 `review` / `graph` 事件 @@ -1330,6 +1360,7 @@ pub async fn decide_alignment_phrase( votes: &votes, decided_by: "person", basis: None, + marks, }, ) .await? @@ -1342,7 +1373,7 @@ pub async fn decide_alignment_phrase( "phrase_binding", Some(binding_id), json!({ "phrase": sig.phrase, "property": req.property, "direction": direction, - "job_id": job_id }), + "marks": marks, "job_id": job_id }), ) .await; state.emit_review(kb_id); 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 6eee079de..a97a5f67b 100644 --- a/crates/utopia-server/src/api/review_routes_phrase_tests.rs +++ b/crates/utopia-server/src/api/review_routes_phrase_tests.rs @@ -75,6 +75,7 @@ impl Fx { votes: &json!({}), decided_by: "agent", basis: None, + marks: None, }, ) .await?; @@ -246,6 +247,99 @@ async fn a_phrase_decision_is_accepted_with_its_job() -> anyhow::Result<()> { run } +/// 状态属性下,人定签名时说只带一个日期的陈述那个日期标什么(#966)。不说也收下,签名 +/// 留在对齐队列里等人补,属性与方向是现在绑的;说了就写下、出队。认不出的值,或给了不是 +/// 状态的属性(或没有属性),答 422,什么都不写 +#[tokio::test] +async fn a_phrase_decision_under_a_state_says_what_a_single_date_marks() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + let path = format!( + "/api/v1/kbs/{}/review/alignment/phrases/{}", + f.kb, f.binding + ); + let queue = format!( + "/api/v1/kbs/{}/review?queue=alignment&limit=10&offset=0", + f.kb + ); + let (status, body) = f + .call( + &f.editor, + "POST", + &path, + Some(json!({ "property": "based_in", "direction": "forward" })), + ) + .await?; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + let (status, body) = f.call(&f.editor, "GET", &queue, None).await?; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["counts"]["alignment"], 1, "{body}"); + assert_eq!(body["items"][0]["bound_to"], "based_in", "{body}"); + assert_eq!(body["items"][0]["direction"], "forward", "{body}"); + + let (status, body) = f + .call( + &f.editor, + "POST", + &path, + Some(json!({ "property": "based_in", "direction": "forward", "marks": "start" })), + ) + .await?; + assert_eq!(status, StatusCode::ACCEPTED, "{body}"); + let b = phrase_bindings::bindings(&f.pool, f.kb).await?.remove(0); + assert_eq!( + (b.decided_by.as_str(), b.marks.as_deref()), + ("person", Some("start")) + ); + let votes: Value = sqlx::query_scalar("SELECT votes FROM phrase_bindings WHERE id=$1") + .bind(f.binding) + .fetch_one(&f.pool) + .await?; + assert_eq!(votes["person"]["marks"], "start", "{votes}"); + let (_, body) = f.call(&f.editor, "GET", &queue, None).await?; + assert_eq!(body["counts"]["alignment"], 0, "{body}"); + + sqlx::query( + "INSERT INTO relation_types(id,kb_id,key,label,temporal) + VALUES ($1,$2,'acquired','acquired','event')", + ) + .bind(Uuid::now_v7()) + .bind(f.kb) + .execute(&f.pool) + .await?; + for (request, code) in [ + ( + json!({ "property": "based_in", "direction": "forward", "marks": "sometimes" }), + "unknown_marks", + ), + ( + json!({ "property": "acquired", "direction": "forward", "marks": "start" }), + "marks_needs_state", + ), + ( + json!({ "property": null, "marks": "start" }), + "marks_needs_state", + ), + ] { + let (status, body) = f.call(&f.editor, "POST", &path, Some(request)).await?; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["code"], code, "{body}"); + } + let b = phrase_bindings::bindings(&f.pool, f.kb).await?.remove(0); + assert_eq!( + (b.relation_type_id.is_some(), b.marks.as_deref()), + (true, Some("start")), + "a refused request writes nothing" + ); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + #[tokio::test] async fn a_rule_decision_is_accepted_with_its_job() -> anyhow::Result<()> { let Some(f) = Fx::new().await? else { diff --git a/crates/utopia-server/src/implication.rs b/crates/utopia-server/src/implication.rs index be3a536e7..1ac1833a1 100644 --- a/crates/utopia-server/src/implication.rs +++ b/crates/utopia-server/src/implication.rs @@ -72,6 +72,7 @@ pub async fn propose_rules( label: &p.label, description: &p.description, kind: &p.kind, + temporal: &p.temporal, domains: keys_of(&p.domains), ranges: keys_of(&p.ranges), via: Vec::new(), diff --git a/crates/utopia-server/src/ontology_agent.rs b/crates/utopia-server/src/ontology_agent.rs index 526edf3f7..87c1b9987 100644 --- a/crates/utopia-server/src/ontology_agent.rs +++ b/crates/utopia-server/src/ontology_agent.rs @@ -1084,6 +1084,7 @@ async fn adopt_map_to( votes: &votes, decided_by: "person", basis: None, + marks: None, }, ) .await?; diff --git a/crates/utopia-server/src/ontology_agent_tests.rs b/crates/utopia-server/src/ontology_agent_tests.rs index bf3e04c82..f903b4316 100644 --- a/crates/utopia-server/src/ontology_agent_tests.rs +++ b/crates/utopia-server/src/ontology_agent_tests.rs @@ -148,6 +148,7 @@ impl Fx { votes: &json!({ "reason": "no_candidates" }), decided_by: "agent", basis: Some("test"), + marks: None, }, ) .await?; diff --git a/crates/utopia-server/src/phrase_alignment.rs b/crates/utopia-server/src/phrase_alignment.rs index 15724298f..d56777d8b 100644 --- a/crates/utopia-server/src/phrase_alignment.rs +++ b/crates/utopia-server/src/phrase_alignment.rs @@ -20,7 +20,7 @@ use crate::state::AppState; use std::collections::{HashMap, HashSet}; use utopia_core::models::RelationTypeView; use utopia_extract::phrase_align::{ - build_phrase_messages, parse_phrase_response, Direction, PhraseItem, PropertyCandidate, + build_phrase_messages, parse_phrase_response, Direction, Marks, PhraseItem, PropertyCandidate, }; use utopia_store::phrase_bindings::{self, Decision, PhraseSignature}; use uuid::Uuid; @@ -31,7 +31,19 @@ const BATCH: usize = 12; const CANDIDATE_LIMIT: usize = 60; /// 一票:这条签名选了哪个属性、哪个方向(None = 没有属性对得上)。 -type Vote = Option<(String, Direction)>; +/// 一票:属性的键、方向,以及属性是状态时一刻标哪一端(#966) +type Vote = Option<(String, Direction, Option)>; + +/// 代理判过、绑到状态属性、却还没说一刻标哪一端的绑定(#966 之前的判定):即使指纹没变也 +/// 再问一次,让对齐器补上。只进开跑时的 todo,不进收尾的「变了没有」——模型一直不答 +/// marks 时不能每轮都给自己排一次 +fn awaits_marks(b: &phrase_bindings::Binding, props: &[RelationTypeView]) -> bool { + b.status == "bound" + && b.marks.is_none() + && b.relation_type_id + .and_then(|id| props.iter().find(|p| p.id == id)) + .is_some_and(|p| p.temporal == "state") +} /// 一个类连同它的全部祖先。候选按它命中:属性的定义域声明在 legal_entity 上, /// organization 是它的子类,这条属性对 organization 的签名就是候选(#807 第一条)。 @@ -448,14 +460,16 @@ async fn align_phrases_locked( let short = shortlist(state, settings, kb_id, &sigs, &full).await?; let considered = consider(&sigs, &props, &closure, &versions, Some(&short)); // 过期 = 存下的指纹和此刻的不一样(没有指纹的是这一列出现前判的,各重判一次)。 - // 不再按时间戳:父边的增删、请求途中的编辑(#795)时间戳看不见。人的判定不重判 + // 不再按时间戳:父边的增删、请求途中的编辑(#795)时间戳看不见。人的判定不重判。 + // 绑到状态属性却没说一刻标哪一端的代理判定也再问(`awaits_marks`,#966) let todo: Vec<&PhraseSignature> = sigs .iter() .filter(|s| match existing.get(&s.key()) { None => true, Some(b) => { b.decided_by != "person" - && b.basis.as_deref() != Some(considered[&s.key()].1.as_str()) + && (b.basis.as_deref() != Some(considered[&s.key()].1.as_str()) + || awaits_marks(b, &props)) } }) .collect(); @@ -476,6 +490,7 @@ async fn align_phrases_locked( votes: &serde_json::json!({ "reason": "no_properties" }), decided_by: "agent", basis: Some(&considered[&s.key()].1), + marks: None, }, ) .await?; @@ -549,6 +564,7 @@ async fn align_phrases_locked( label: &p.label, description: &p.description, kind: &p.kind, + temporal: &p.temporal, domains: keys_of(&p.domains), ranges: keys_of(&p.ranges), via: fits(p, s, closure) @@ -628,11 +644,12 @@ async fn align_phrases_locked( continue; }; if let Some(slot) = votes.get_mut(i) { + let vote = c.property.map(|(key, direction)| (key, direction, c.marks)); if pass == 0 { - slot.0 = c.property; + slot.0 = vote; answered[i].0 = true; } else { - slot.1 = c.property; + slot.1 = vote; answered[i].1 = true; } } @@ -665,6 +682,7 @@ async fn align_phrases_locked( votes: &votes, decided_by: "agent", basis: Some(basis), + marks: None, }, ) .await? @@ -687,11 +705,31 @@ async fn align_phrases_locked( } let show = |v: &Vote| { v.as_ref() - .map(|(k, d)| serde_json::json!({ "property": k, "direction": d.as_str() })) + .map(|(k, d, m)| { + serde_json::json!({ "property": k, "direction": d.as_str(), + "marks": m.map(Marks::as_str) }) + }) .unwrap_or(serde_json::Value::Null) }; let record = serde_json::json!({ "first": show(a), "second": show(b) }); - if a != b { + // 两票选的属性与方向(或都说没有)是否一致 + let agree = match (a, b) { + (Some((ka, da, _)), Some((kb, db, _))) => ka == kb && da == db, + (None, None) => true, + _ => false, + }; + // 属性是状态时,两票还得说出一刻标哪一端(#966):有一票没说是没答到,下次再问; + // 两票说得不一样和选了不同的属性一样,交给人。事件与恒常不问这一格 + let state = a + .as_ref() + .and_then(|(k, _, _)| by_key.get(k.as_str())) + .is_some_and(|p| p.temporal == "state"); + let marks_of = |v: &Vote| v.as_ref().and_then(|(_, _, m)| *m); + if agree && state && (marks_of(a).is_none() || marks_of(b).is_none()) { + unanswered += 1; + continue; + } + if !agree || (state && marks_of(a) != marks_of(b)) { phrase_bindings::decide( pool, kb_id, @@ -703,6 +741,7 @@ async fn align_phrases_locked( votes: &record, decided_by: "agent", basis: Some(basis), + marks: None, }, ) .await?; @@ -711,9 +750,9 @@ async fn align_phrases_locked( } match a .as_ref() - .and_then(|(k, d)| by_key.get(k.as_str()).map(|p| (p, *d))) + .and_then(|(k, d, m)| by_key.get(k.as_str()).map(|p| (p, *d, *m))) { - Some((p, d)) => { + Some((p, d, m)) => { if phrase_bindings::decide( pool, kb_id, @@ -725,6 +764,7 @@ async fn align_phrases_locked( votes: &record, decided_by: "agent", basis: Some(basis), + marks: if state { m.map(Marks::as_str) } else { None }, }, ) .await? @@ -744,6 +784,7 @@ async fn align_phrases_locked( votes: &record, decided_by: "agent", basis: Some(basis), + marks: None, }, ) .await? diff --git a/crates/utopia-server/src/phrase_alignment_tests.rs b/crates/utopia-server/src/phrase_alignment_tests.rs index fd52f523a..00026a8e1 100644 --- a/crates/utopia-server/src/phrase_alignment_tests.rs +++ b/crates/utopia-server/src/phrase_alignment_tests.rs @@ -303,7 +303,12 @@ impl Fx { } fn vote(key: Option<&str>, dir: Option<&str>) -> Value { - json!({"b":[[0, key, dir]]}) + marked(key, dir, key.map(|_| "none")) +} +/// 一票带第四格:based_in 是状态,绑上它的票要说只带一个日期的陈述那个日期标什么(#966)。 +/// 夹具里的陈述没有日期,平常答 none +fn marked(key: Option<&str>, dir: Option<&str>, marks: Option<&str>) -> Value { + json!({"b":[[0, key, dir, marks]]}) } /// 两票之后对齐还会问一次「这种形状还蕴含什么」(0044 决定 3 第五片):脚本里答「没有」 fn nothing_implied() -> Value { @@ -627,6 +632,7 @@ async fn a_person_decision_made_during_the_request_is_not_overwritten() -> anyho votes: &json!({}), decided_by: "person", basis: None, + marks: None, }, ) .await?; @@ -658,8 +664,8 @@ async fn an_id_keyed_reply_still_binds_the_phrase() -> anyhow::Result<()> { }; let run = async { f.script(vec![ - json!({"0": ["based_in", "forward"]}), - json!({"b": {"0": {"key": "based_in", "direction": "forward"}}}), + json!({"0": ["based_in", "forward", "none"]}), + json!({"b": {"0": {"key": "based_in", "direction": "forward", "marks": "none"}}}), nothing_implied(), ]); f.run().await?; @@ -862,3 +868,204 @@ async fn a_property_created_in_the_editor_has_its_vector_before_phrases_are_alig f.cleanup().await?; run } + +/// 状态属性下,两票还要说只带一个日期的陈述那个日期标什么(#966)。候选行写明属性是 +/// 状态;两票说得一样就绑,marks 跟着写下,两票的记录里也有它 +#[tokio::test] +async fn a_state_binding_carries_what_a_single_date_marks() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(vec![ + marked(Some("based_in"), Some("forward"), Some("start")), + marked(Some("based_in"), Some("forward"), Some("start")), + ]); + f.run().await?; + assert_eq!(f.requests().len(), 2); + let prompt = f.prompt_of(0); + assert!( + prompt.contains("- based_in · based in · relation · state ·"), + "the model is told the property is a state: {prompt}" + ); + let b = f.binding().await?; + assert_eq!( + (b.status.as_str(), b.marks.as_deref()), + ("bound", Some("start")) + ); + let votes: Value = sqlx::query_scalar("SELECT votes FROM phrase_bindings WHERE kb_id=$1") + .bind(f.kb) + .fetch_one(&f.pool) + .await?; + assert_eq!( + (&votes["first"]["marks"], &votes["second"]["marks"]), + (&json!("start"), &json!("start")) + ); + assert!(!f.requeued().await?); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +/// 两票选了同一个属性、同一个方向,却一票说开始、一票说结束:和选了不同的属性一样,交给人 +#[tokio::test] +async fn two_votes_that_read_the_date_differently_go_to_a_person() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(vec![ + marked(Some("based_in"), Some("forward"), Some("start")), + marked(Some("based_in"), Some("forward"), Some("end")), + ]); + f.run().await?; + let b = f.binding().await?; + assert_eq!((b.status.as_str(), b.marks.as_deref()), ("undecided", None)); + let votes: Value = sqlx::query_scalar("SELECT votes FROM phrase_bindings WHERE kb_id=$1") + .bind(f.kb) + .fetch_one(&f.pool) + .await?; + assert_eq!( + (&votes["first"]["marks"], &votes["second"]["marks"]), + (&json!("start"), &json!("end")), + "both readings are kept for the person" + ); + let items = utopia_store::alignment_queue::list(&f.pool, f.kb, 10, 0).await?; + assert!( + matches!( + items.as_slice(), + [utopia_store::alignment_queue::AlignmentItem::Phrase { bound_to: None, .. }] + ), + "{items:?}" + ); + assert!(!f.requeued().await?); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +/// 一票绑了状态却没说那个日期标什么:这一票没答完,不下结论,有限次地再问 +#[tokio::test] +async fn a_state_vote_that_leaves_the_date_unread_is_asked_again() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(vec![ + json!({"b":[[0, "based_in", "forward"]]}), + marked(Some("based_in"), Some("forward"), Some("start")), + ]); + f.run().await?; + assert_eq!(f.requests().len(), 2); + assert!( + phrase_bindings::bindings(&f.pool, f.kb).await?.is_empty(), + "no decision without both readings" + ); + let reasks: Vec> = sqlx::query_scalar( + "SELECT (payload->>'reask')::bigint FROM jobs + WHERE kind='align_phrases' AND status='queued' AND payload->>'kb_id'=$1", + ) + .bind(f.kb.to_string()) + .fetch_all(&f.pool) + .await?; + assert_eq!(reasks, vec![Some(1)], "it queues itself once more"); + f.clear_jobs().await?; + f.script(vec![ + marked(Some("based_in"), Some("forward"), Some("start")), + marked(Some("based_in"), Some("forward"), Some("start")), + ]); + align_phrases_reasking(&f.state, f.kb, 1).await?; + let b = f.binding().await?; + assert_eq!( + (b.status.as_str(), b.marks.as_deref()), + ("bound", Some("start")) + ); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +/// 这一问出现之前判下的绑定没有 marks。代理判的:指纹没变也再问一次,补上就不再问。 +/// 人判的:代理不问,它在对齐队列里等人补,属性与方向是现在绑的 +#[tokio::test] +async fn a_binding_decided_before_the_question_is_asked_once_and_a_persons_waits_in_the_queue( +) -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(bound()); + f.run().await?; + assert_eq!(f.binding().await?.marks.as_deref(), Some("none")); + sqlx::query("UPDATE phrase_bindings SET marks = NULL WHERE kb_id=$1") + .bind(f.kb) + .execute(&f.pool) + .await?; + f.script(vec![ + marked(Some("based_in"), Some("forward"), Some("start")), + marked(Some("based_in"), Some("forward"), Some("start")), + ]); + f.run().await?; + assert_eq!( + f.requests().len(), + 4, + "the fingerprint matches, yet the binding is asked for the value it lacks" + ); + assert_eq!(f.binding().await?.marks.as_deref(), Some("start")); + f.run().await?; + assert_eq!(f.requests().len(), 4, "asked once: it has its value now"); + assert!(!f.requeued().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!({"person": {"property": "based_in", "direction": "forward"}}), + decided_by: "person", + basis: None, + marks: None, + }, + ) + .await?; + f.run().await?; + assert_eq!(f.requests().len(), 4, "a person's binding is not asked"); + let items = utopia_store::alignment_queue::list(&f.pool, f.kb, 10, 0).await?; + assert!( + matches!( + items.as_slice(), + [utopia_store::alignment_queue::AlignmentItem::Phrase { + bound_to: Some(p), + direction: Some(d), + .. + }] if p == "based_in" && d == "forward" + ), + "{items:?}" + ); + assert_eq!( + utopia_store::alignment_queue::waiting(&f.pool, f.kb) + .await? + .0, + 1 + ); + assert_eq!( + utopia_store::review::counts(&f.pool, f.kb).await?.alignment, + 1, + "the badge counts it too" + ); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} diff --git a/crates/utopia-store/src/alignment_queue.rs b/crates/utopia-store/src/alignment_queue.rs index 2241623c6..68fe79b86 100644 --- a/crates/utopia-store/src/alignment_queue.rs +++ b/crates/utopia-store/src/alignment_queue.rs @@ -29,6 +29,10 @@ pub enum AlignmentItem { /// 两票各选了什么:`{"first": {...} | null, "second": {...} | null}` votes: Option, decided_at: DateTime, + /// 人绑过、却没说一刻标哪一端的签名(#966):现在绑到的属性与方向,界面按它预选、 + /// 只等人补上 marks。undecided 的条目两个都是 None + bound_to: Option, + direction: Option, }, KindWord { kind_word: String, @@ -85,8 +89,17 @@ struct PhraseRow { examples: Vec, votes: Option, decided_at: DateTime, + bound_to: Option, + direction: Option, } +/// 等人定的短语签名:两票不一致的,加上人绑到状态属性、却没说一刻标哪一端的(#966)。 +/// 后一种代理不能替人补(人的判定代理不改),不列出来,它名下只说了一刻的陈述就一直不算 +pub(crate) const WAITING_PHRASE: &str = "(b.status = 'undecided' + OR (b.status = 'bound' AND b.decided_by = 'person' AND b.marks IS NULL + AND EXISTS (SELECT 1 FROM relation_types rt + WHERE rt.id = b.relation_type_id AND rt.temporal = 'state')))"; + #[derive(sqlx::FromRow)] struct KindWordRow { kind_word: String, @@ -102,14 +115,17 @@ pub async fn list( limit: i64, offset: i64, ) -> AppResult> { - let phrases: Vec = sqlx::query_as( + let phrases: Vec = sqlx::query_as(&format!( "SELECT b.id, b.phrase, st.key AS subject_class, ot.key AS object_class, - b.object_is_value, b.statement_count, b.examples, b.votes, b.decided_at + b.object_is_value, b.statement_count, b.examples, b.votes, b.decided_at, + CASE WHEN b.status = 'bound' THEN p.key END AS bound_to, + CASE WHEN b.status = 'bound' THEN b.direction END AS direction FROM phrase_bindings b LEFT JOIN entity_types st ON st.id = b.subject_type_id LEFT JOIN entity_types ot ON ot.id = b.object_type_id - WHERE b.kb_id = $1 AND b.status = 'undecided'", - ) + LEFT JOIN relation_types p ON p.id = b.relation_type_id + WHERE b.kb_id = $1 AND {WAITING_PHRASE}" + )) .bind(kb_id) .fetch_all(pool) .await?; @@ -154,6 +170,8 @@ pub async fn list( examples: r.examples, votes: r.votes, decided_at: r.decided_at, + bound_to: r.bound_to, + direction: r.direction, }, ) }) @@ -204,15 +222,15 @@ pub async fn list( /// 等人定的条目数,以及最老的一条从什么时候起在等。 pub async fn waiting(pool: &PgPool, kb_id: Uuid) -> AppResult<(i64, Option>)> { - Ok(sqlx::query_as( + Ok(sqlx::query_as(&format!( "SELECT count(*), min(decided_at) FROM ( - SELECT decided_at FROM phrase_bindings WHERE kb_id = $1 AND status = 'undecided' + SELECT b.decided_at FROM phrase_bindings b WHERE b.kb_id = $1 AND {WAITING_PHRASE} UNION ALL SELECT decided_at FROM type_bindings WHERE kb_id = $1 AND status = 'undecided' UNION ALL SELECT decided_at FROM implication_rules WHERE kb_id = $1 AND status = 'proposed' - ) q", - ) + ) q" + )) .bind(kb_id) .fetch_one(pool) .await?) diff --git a/crates/utopia-store/src/errata.rs b/crates/utopia-store/src/errata.rs index 8d8fd5022..c8ab95af9 100644 --- a/crates/utopia-store/src/errata.rs +++ b/crates/utopia-store/src/errata.rs @@ -436,8 +436,12 @@ pub async fn record(pool: &PgPool, kb_id: Uuid, input: ActionInput<'_>) -> AppRe } None => Ok(old.clone()), }; + let no_moment = empty_state_revision(pool, c.fact_id, predicate_id).await?; match obj { Err(e) => Err(e), + Ok(_) if no_moment => { + Err("a state cannot take a time whose start and end are equal".into()) + } Ok((object_id, object_value)) if predicate_id == c.predicate_id && object_id == old.0 @@ -618,6 +622,9 @@ async fn apply( .bind(fact) .fetch_optional(pool) .await?; + if action == "revise" && empty_state_revision(pool, fact, target.predicate_id).await? { + return Err(crate::graph::empty_state_span()); + } match crate::graph::reject_fact(pool, kb_id, fact).await { Ok(()) | Err(AppError::NotFound) => {} Err(e) => return Err(e), @@ -689,6 +696,22 @@ async fn apply( Ok(Some(new)) } +/// 改是撤旧写新,新行接旧行的时间。旧行两端相等(一刻的事件,或 #966 之前写下的状态), +/// 新行的属性又声明成状态时,接过来的那段任何时刻都不成立,`Validity::under` 不写它: +/// 验证时就拒,执行时先查再撤——不会撤了旧行才发现新行写不进 +async fn empty_state_revision(pool: &PgPool, fact_id: Uuid, predicate_id: Uuid) -> AppResult { + let point: Option = sqlx::query_scalar( + "SELECT valid_from IS NOT NULL AND valid_to IS NOT DISTINCT FROM valid_from + FROM facts WHERE id = $1", + ) + .bind(fact_id) + .fetch_optional(pool) + .await?; + Ok(point == Some(true) + && crate::graph::predicate_temporal(pool, Some(predicate_id)).await? + == crate::graph::Temporal::State) +} + #[derive(Debug, Default, sqlx::FromRow)] struct ValidityRow { valid_from: Option>, diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index d77f7f727..a26dc574e 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -488,10 +488,20 @@ impl<'a> Validity<'a> { /// 不知哪天」对一刻没有意义,一并抹掉。两端同值是这一行自己就能说清的形状: /// 不看谓词的读者顶多把它读成一天的状态,读不成「从那天起一直如此」 /// - 恒常:日期全抹。原文里的日期说的是别的事,不是这条关系何时成立 - /// - 状态:原样 - pub fn under(mut self, temporal: Temporal) -> Self { + /// - 状态:原样——**两端相等的不写**(#966)。世界轴按 `from <= T < to` 读状态,起止 + /// 同值的一段任何时刻都不成立,写下它只会把「那天加入」读成「从没在那儿过」。一刻的 + /// 陈述在状态属性下读成开始、结束还是什么都不算,由绑定说(`phrase_bindings.marks`), + /// 不在这里猜;这里只守住不变量,所以没有哪条写入路径能存下一段不成立的状态 + /// + /// 调用方先截断再归一:截到精度之后才相等的两端(同一天的两个钟点)同样拒绝。 + /// 没有谓词的行按状态读(0010)只是读法,不是声明:调用方不拿它过这一关 + pub fn under(mut self, temporal: Temporal) -> AppResult { match temporal { - Temporal::State => {} + Temporal::State => { + if self.from.is_some() && self.from == self.to { + return Err(empty_state_span()); + } + } Temporal::Eternal => { self.from = None; self.from_precision = None; @@ -513,7 +523,7 @@ impl<'a> Validity<'a> { self.to_precision = p; } } - self + Ok(self) } /// 原文说它结束了,但没说哪天。 @@ -532,6 +542,15 @@ impl<'a> Validity<'a> { } } +/// 一段起止同值的状态(#966):写入、改区间、关上都拒它,说的是同一句话 +pub fn empty_state_span() -> AppError { + AppError::invalid( + "empty_state_span", + "A state that ends where it starts holds at no moment: give an end after the start, \ + or leave it open.", + ) +} + #[allow(clippy::too_many_arguments)] async fn insert_fact_inner( pool: &PgPool, @@ -573,7 +592,13 @@ pub(crate) async fn insert_fact_on( // 按谓词的时间语义归一(0031):事件两端同一刻,恒常无日期。写在这里而不是各个 // 写入者那儿——抽取、点头、人自己写的事实都经过这一个门 let temporal = predicate_temporal(&mut *conn, predicate_id).await?; - let validity = validity.under(temporal).truncated(); + // 声明成状态的属性才守「两端不相等」(#966);没有谓词的行按状态读只是读法,照旧写 + let declared_state = predicate_id.is_some() && temporal == Temporal::State; + let validity = if predicate_id.is_some() { + validity.truncated().under(temporal)? + } else { + validity.truncated() + }; let same_sql = match object { FactObject::Entity(_) => { "SELECT id, valid_from, valid_to, valid_to_precision FROM facts @@ -594,7 +619,13 @@ pub(crate) async fn insert_fact_on( FactObject::Entity(id) => q.bind(id), FactObject::Value(v) => q.bind(v), }; - let same: Vec = q.fetch_all(&mut *conn).await?; + let mut same: Vec = q.fetch_all(&mut *conn).await?; + // 状态属性下起止同值的行(#966 之前写下的)任何时刻都不成立:不关它、也不并进它。 + // 不然「那天加入」与「自那天起」谁先落账,账本就是两个样子——一刻关上了开放的那段, + // 或开放的那段并进了一刻。这样的行由物化作废重算,这里只是不让它牵动别的观察 + if declared_state { + same.retain(|(_, vf, vt, _)| !(vf.is_some() && vf == vt)); + } // 「结束了,不知哪天」的观察撞上同断言的**开放行**(0022 / #393):关上它。 // 不并进去——并进去等于把「它结束了」这唯一带来的信息丢掉(同 valid_from 那条 // 精确重复的路会这么干);也不另立一行——另立一行让两条各说各话,开放的那条 @@ -658,10 +689,13 @@ pub(crate) async fn insert_fact_on( attest_earlier(&mut *conn, *ended, validity.attested_at, true).await?; return Ok((*ended, false)); } + // 声明成状态的开放行不在它自己的起点关上:关上就是一段不成立的状态(#966) let open = same .iter() .filter(|(_, vf, vt, vtp)| { - vt.is_none() && vtp.is_none() && vf.is_none_or(|f| f <= to) + vt.is_none() + && vtp.is_none() + && vf.is_none_or(|f| f < to || (f == to && !declared_state)) }) .max_by_key(|(_, vf, _, _)| *vf); if let Some((open, _, _, _)) = open { @@ -750,10 +784,17 @@ pub(crate) async fn insert_fact_on( // 文档可能先到),本次观察带了起点 → 落库后作废那行并链上。只知道终点的行, // 终点跟着走:这次没说终点就沿用它的,说了就得是同一个 let mut validity = validity; + // 声明成状态的,沿用来的终点若正落在这次的起点上,合起来就是一段不成立的状态(#966): + // 不精化,各自一行 let refine_target = if validity.from.is_some() { same.iter() .find(|(_, vf, vt, _)| { - vf.is_none() && (vt.is_none() || validity.to.is_none() || *vt == validity.to) + vf.is_none() + && (vt.is_none() || validity.to.is_none() || *vt == validity.to) + && !(declared_state + && validity.to.is_none() + && vt.is_some() + && *vt == validity.from) }) .map(|(id, _, vt, vtp)| (*id, *vt, vtp.clone())) } else { @@ -2884,7 +2925,8 @@ mod temporal_shape_tests { attested_at: None, from_grade: None, } - .under(Temporal::Event); + .under(Temporal::Event) + .unwrap(); assert_eq!(span.from, Some(at("2024-03-15T00:00:00Z"))); assert_eq!(span.to, Some(at("2024-03-15T00:00:00Z"))); assert_eq!( @@ -2900,13 +2942,15 @@ mod temporal_shape_tests { attested_at: None, from_grade: None, } - .under(Temporal::Event); + .under(Temporal::Event) + .unwrap(); assert_eq!(end_only.from, Some(at("2024-05-01T00:00:00Z"))); assert_eq!(end_only.from_precision, Some("month")); let unknown = Validity::default() .ended_when_unknown() - .under(Temporal::Event); + .under(Temporal::Event) + .unwrap(); assert_eq!( (unknown.from, unknown.to, unknown.to_precision), (None, None, None) @@ -2919,14 +2963,49 @@ mod temporal_shape_tests { fn an_eternal_fact_keeps_no_dates_and_a_state_keeps_its_own() { let dated = Validity::starting(Some(at("1990-01-01T00:00:00Z")), Some("year")) .attested(Some(at("2024-04-01T00:00:00Z"))); - let eternal = dated.under(Temporal::Eternal); + let eternal = dated.under(Temporal::Eternal).unwrap(); assert_eq!((eternal.from, eternal.from_precision), (None, None)); assert_eq!( eternal.attested_at, Some(at("2024-04-01T00:00:00Z")), "证据日期照记——读出侧不用它,账本仍知道" ); - let state = dated.under(Temporal::State); + let state = dated.under(Temporal::State).unwrap(); assert_eq!(state.from, Some(at("1990-01-01T00:00:00Z"))); } + + /// 状态两端相等就拒绝(#966):起止同值的一段任何时刻都不成立。事件照旧写成一刻; + /// 截到精度之后才相等的两端同样拒绝 + #[test] + fn a_state_that_ends_where_it_starts_is_refused() { + let moment = Validity { + from: Some(at("2023-06-01T00:00:00Z")), + from_precision: Some("day"), + to: Some(at("2023-06-01T00:00:00Z")), + to_precision: Some("day"), + attested_at: None, + from_grade: None, + }; + let refused = moment + .under(Temporal::State) + .expect_err("an empty state span"); + assert!( + format!("{refused:?}").contains("empty_state_span"), + "{refused:?}" + ); + assert!(moment.under(Temporal::Event).is_ok()); + let same_day = Validity { + from: Some(at("2023-06-01T09:00:00Z")), + to: Some(at("2023-06-01T17:00:00Z")), + ..moment + }; + assert!(same_day.truncated().under(Temporal::State).is_err()); + assert!(Validity { + to: None, + to_precision: None, + ..moment + } + .under(Temporal::State) + .is_ok()); + } } diff --git a/crates/utopia-store/src/materialize.rs b/crates/utopia-store/src/materialize.rs index 8f4b4daae..5e928ef8d 100644 --- a/crates/utopia-store/src/materialize.rs +++ b/crates/utopia-store/src/materialize.rs @@ -32,6 +32,18 @@ const MATCH: &str = "b.kb_id = s.kb_id AND b.object_is_value = (s.object_id IS NULL) AND (s.object_id IS NULL OR b.object_type_id IS NOT DISTINCT FROM oe.type_id)"; +/// 一条只说了一刻的陈述(起止同值)绑在一条状态属性上,而绑定没说那一刻是开始还是 +/// 结束(`marks` 是 none 或未知,#966):它不算类型化行,留在开放图谱里带着它的日期。 +/// 照抄起止会写成一段任何时刻都不成立的状态。`s` 是陈述,`b` 是绑定。 +/// +/// 每一项都得是真值、不能是 NULL:它在 `NOT` 后面用,`valid_to = valid_from` 在只有起点的 +/// 陈述上是 NULL,`NOT NULL` 还是 NULL,「自那天起」的陈述就整批不算了 +const MOMENT_UNREAD: &str = + "(s.valid_from IS NOT NULL AND s.valid_to IS NOT DISTINCT FROM s.valid_from + AND EXISTS (SELECT 1 FROM relation_types rt + WHERE rt.id = b.relation_type_id AND rt.temporal = 'state') + AND b.marks IS DISTINCT FROM 'start' AND b.marks IS DISTINCT FROM 'end')"; + /// 一轮重算写了什么。 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct Outcome { @@ -65,6 +77,10 @@ struct Due { valid_to_precision: Option, attested_from: Option>, confidence: f32, + /// 绑定说一刻标的是哪一端(start / end / none,NULL 未知) + marks: Option, + /// 绑到的属性的时间语义 + temporal: Option, } /// 对一个库重算一遍。 @@ -153,7 +169,8 @@ async fn materialize_in_tx( AND ((b.direction = 'forward' AND t.subject_id = s.subject_id) OR (b.direction = 'reverse' AND t.subject_id = s.object_id)) AND NOT EXISTS (SELECT 1 FROM statement_qualifiers q - WHERE q.fact_id = s.id AND q.role = 'mood'))" + WHERE q.fact_id = s.id AND q.role = 'mood') + AND NOT {MOMENT_UNREAD})" )) .bind(kb_id) .execute(&mut **tx) @@ -197,6 +214,24 @@ async fn materialize_in_tx( .await? .rows_affected(); + // 2b. 起止同值的物化状态行(#966 之前照抄一刻写下的)任何时刻都不成立:作废,它的陈述 + // 在第 3 步按绑定说的读法重算——「那天加入」与「自那天起」重新落成一段从那天起的状态。 + // 人写的不碰:不变量从此挡住新的,旧的由人自己改 + let emptied = sqlx::query( + "UPDATE facts t + SET invalidated_at = now() + WHERE t.kb_id = $1 AND t.layer = 'typed' AND t.invalidated_at IS NULL + AND (t.from_statement_id IS NOT NULL OR t.implied) + AND t.valid_from IS NOT NULL AND t.valid_to = t.valid_from + AND EXISTS (SELECT 1 FROM relation_types r + WHERE r.id = t.predicate_id AND r.temporal = 'state')", + ) + .bind(kb_id) + .execute(&mut **tx) + .await? + .rows_affected(); + let retired = retired + emptied; + // 3. 该有而没有的(陈述, 绑定)对:陈述活着、签名绑着、没 mood、还没有活着的行以它为来源 // 且谓词相同。reverse 只对两样东西之间的关系有意义(字面值当不了主语) let due: Vec = sqlx::query_as(&format!( @@ -204,7 +239,9 @@ async fn materialize_in_tx( s.subject_id, s.object_id, s.object_value, s.valid_from, s.valid_from_precision, s.valid_from_grade, s.valid_to, s.valid_to_precision, - s.attested_from, s.confidence + s.attested_from, s.confidence, b.marks, + (SELECT rt.temporal FROM relation_types rt WHERE rt.id = b.relation_type_id) + AS temporal FROM facts s JOIN entities se ON se.id = s.subject_id LEFT JOIN entities oe ON oe.id = s.object_id @@ -212,6 +249,7 @@ async fn materialize_in_tx( WHERE s.kb_id = $1 AND s.layer = 'open' AND s.invalidated_at IS NULL AND b.status = 'bound' AND (b.direction = 'forward' OR s.object_id IS NOT NULL) + AND NOT {MOMENT_UNREAD} AND NOT EXISTS (SELECT 1 FROM statement_qualifiers q WHERE q.fact_id = s.id AND q.role = 'mood') -- 勘误撤过的(陈述, 属性)不再算(0044 决定 7):撤销要站得住 @@ -230,13 +268,38 @@ async fn materialize_in_tx( let (mut added, mut merged) = (0u64, 0u64); for d in &due { let reverse = d.direction == "reverse"; - let validity = Validity { - from: d.valid_from, - from_precision: d.valid_from_precision.as_deref(), - from_grade: d.valid_from_grade.as_deref(), - to: d.valid_to, - to_precision: d.valid_to_precision.as_deref(), - attested_at: d.attested_from, + // 一刻的陈述在状态属性下按绑定说的读(#966):开始写成从那一刻起,结束写成到那一刻 + // 为止(走 `insert_fact_on`「某天结束了」那条路,关上开放的那段)。什么都不算的 + // 在查询里已经排掉了([`MOMENT_UNREAD`]) + let moment = d.temporal.as_deref() == Some("state") + && d.valid_from.is_some() + && d.valid_from == d.valid_to; + let validity = match (moment, d.marks.as_deref()) { + (false, _) => Validity { + from: d.valid_from, + from_precision: d.valid_from_precision.as_deref(), + from_grade: d.valid_from_grade.as_deref(), + to: d.valid_to, + to_precision: d.valid_to_precision.as_deref(), + attested_at: d.attested_from, + }, + (true, Some("start")) => Validity { + from: d.valid_from, + from_precision: d.valid_from_precision.as_deref(), + from_grade: d.valid_from_grade.as_deref(), + to: None, + to_precision: None, + attested_at: d.attested_from, + }, + (true, Some("end")) => Validity { + from: None, + from_precision: None, + from_grade: None, + to: d.valid_from, + to_precision: d.valid_from_precision.as_deref(), + attested_at: d.attested_from, + }, + (true, _) => continue, }; let (fact, new) = match (reverse, d.object_id, &d.object_value) { (true, Some(object), _) => { @@ -397,6 +460,11 @@ async fn imply_in_tx( AND {RULE_MATCH} AND NOT EXISTS (SELECT 1 FROM statement_qualifiers q WHERE q.fact_id = s.id AND q.role = 'mood') AND (r.reading IS NULL OR pr.entity_id IS NOT NULL OR pr.value IS NOT NULL) + -- 规则没有 marks(#966):一刻的陈述蕴含一条状态属性时,读法未知,不算隐含行。 + -- 用 IS NOT DISTINCT FROM:只有起点的陈述上 `=` 是 NULL,NOT 之后照样把它排掉 + AND NOT (s.valid_from IS NOT NULL AND s.valid_to IS NOT DISTINCT FROM s.valid_from + AND EXISTS (SELECT 1 FROM relation_types rt + WHERE rt.id = r.conclude_property_id AND rt.temporal = 'state')) AND NOT EXISTS (SELECT 1 FROM errata_actions ea WHERE ea.statement_id = s.id AND ea.predicate_id = r.conclude_property_id AND ea.status = 'applied' AND ea.action IN ('retract', 'revise')) diff --git a/crates/utopia-store/src/materialize_delivery_tests.rs b/crates/utopia-store/src/materialize_delivery_tests.rs index 5e5d6573a..a404fd86d 100644 --- a/crates/utopia-store/src/materialize_delivery_tests.rs +++ b/crates/utopia-store/src/materialize_delivery_tests.rs @@ -52,6 +52,7 @@ async fn accept( votes: &json!({}), decided_by: "person", basis: None, + marks: None, } ) .await? diff --git a/crates/utopia-store/src/phrase_bindings.rs b/crates/utopia-store/src/phrase_bindings.rs index c4e7f2d39..b44548e98 100644 --- a/crates/utopia-store/src/phrase_bindings.rs +++ b/crates/utopia-store/src/phrase_bindings.rs @@ -149,6 +149,9 @@ pub struct Binding { pub decided_by: String, /// 判定时输入的指纹;NULL = 这一列出现之前的判定 pub basis: Option, + /// 一条只说了一刻的陈述,那一刻标的是状态的哪一端:start / end / none(#966)。 + /// NULL = 未知:这一列之前的判定,或属性不是状态 + pub marks: Option, } impl Binding { @@ -178,7 +181,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, basis + relation_type_id, direction, status, decided_at, decided_by, basis, marks FROM phrase_bindings WHERE kb_id = $1 ORDER BY phrase", ) .bind(kb_id) @@ -243,7 +246,8 @@ pub async fn property_versions( 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.basis + b.relation_type_id, b.direction, b.status, b.decided_at, b.decided_by, b.basis, + b.marks FROM phrase_bindings b LEFT JOIN relation_types r ON r.id = b.relation_type_id WHERE b.kb_id = $1 @@ -270,6 +274,9 @@ pub struct Decision<'a> { pub decided_by: &'a str, /// 判定时输入的指纹([`basis_of`]):代理的判定必带,人的判定不带——人不按指纹重判 pub basis: Option<&'a str>, + /// start / end / none:只说了一刻的陈述在状态属性下怎么读(#966)。只有 bound 带; + /// None = 未知,那样的陈述不算类型化行 + pub marks: Option<&'a str>, } /// 记下一条签名的判定(有则改)。返回是否写入了。 @@ -305,6 +312,16 @@ fn validate_decision(sig: &PhraseSignature, d: &Decision<'_>) -> AppResult 0) diff --git a/crates/utopia-store/src/phrase_bindings_delivery_tests.rs b/crates/utopia-store/src/phrase_bindings_delivery_tests.rs index 78fde08e9..a55207de7 100644 --- a/crates/utopia-store/src/phrase_bindings_delivery_tests.rs +++ b/crates/utopia-store/src/phrase_bindings_delivery_tests.rs @@ -84,6 +84,7 @@ fn bound(property: Uuid) -> Decision<'static> { votes: &serde_json::Value::Null, decided_by: "person", basis: None, + marks: None, } } @@ -95,6 +96,7 @@ fn none() -> Decision<'static> { votes: &serde_json::Value::Null, decided_by: "person", basis: None, + marks: None, } } diff --git a/crates/utopia-store/src/review.rs b/crates/utopia-store/src/review.rs index bcf8953f4..49f7808a8 100644 --- a/crates/utopia-store/src/review.rs +++ b/crates/utopia-store/src/review.rs @@ -68,10 +68,11 @@ pub async fn counts(pool: &PgPool, kb_id: Uuid) -> AppResult { AND NOT EXISTS (SELECT 1 FROM agent_decisions d WHERE d.target_kind = 'review' AND d.target_id = rr.id AND d.status = 'proposed')) AS agent_queue, - (SELECT count(*) FROM (SELECT 1 FROM phrase_bindings WHERE kb_id = $1 AND status = 'undecided' + (SELECT count(*) FROM (SELECT 1 FROM phrase_bindings b WHERE b.kb_id = $1 AND {waiting_phrase} UNION ALL SELECT 1 FROM type_bindings WHERE kb_id = $1 AND status = 'undecided') a) AS alignment, (SELECT count(*) FROM errata_actions WHERE kb_id = $1 AND status = 'held') AS errata", unconfirmed = UNCONFIRMED_FACT, + waiting_phrase = crate::alignment_queue::WAITING_PHRASE, same = crate::resolution::TypeFilter::Same.clause(), conflict = crate::resolution::TypeFilter::Conflict.clause(), ); diff --git a/crates/utopia-store/src/review_summary.rs b/crates/utopia-store/src/review_summary.rs index d10c67fec..ea470d33c 100644 --- a/crates/utopia-store/src/review_summary.rs +++ b/crates/utopia-store/src/review_summary.rs @@ -101,13 +101,14 @@ async fn waiting(pool: &PgPool, kb_id: Uuid) -> AppResult { WHERE kb_id = $1 AND status = 'open') AS defects, (SELECT min(detected_at) FROM ontology_defects WHERE kb_id = $1 AND status = 'open') AS defects_oldest, - (SELECT count(*) FROM (SELECT 1 FROM phrase_bindings WHERE kb_id = $1 AND status = 'undecided' + (SELECT count(*) FROM (SELECT 1 FROM phrase_bindings b WHERE b.kb_id = $1 AND {waiting_phrase} UNION ALL SELECT 1 FROM type_bindings WHERE kb_id = $1 AND status = 'undecided') a) AS alignment, - (SELECT min(decided_at) FROM (SELECT decided_at FROM phrase_bindings WHERE kb_id = $1 AND status = 'undecided' + (SELECT min(decided_at) FROM (SELECT b.decided_at FROM phrase_bindings b WHERE b.kb_id = $1 AND {waiting_phrase} UNION ALL SELECT decided_at FROM type_bindings WHERE kb_id = $1 AND status = 'undecided') a) AS alignment_oldest, (SELECT count(*) FROM errata_actions WHERE kb_id = $1 AND status = 'held') AS errata, (SELECT min(created_at) FROM errata_actions WHERE kb_id = $1 AND status = 'held') AS errata_oldest", unconfirmed = UNCONFIRMED_FACT, + waiting_phrase = crate::alignment_queue::WAITING_PHRASE, ); let r: WaitingRow = sqlx::query_as(&sql) .bind(kb_id) diff --git a/crates/utopia-store/src/temporal.rs b/crates/utopia-store/src/temporal.rs index cf8ff8af9..672dbeb05 100644 --- a/crates/utopia-store/src/temporal.rs +++ b/crates/utopia-store/src/temporal.rs @@ -954,8 +954,8 @@ async fn rewrite_end_tx( derived: bool, require_open: bool, ) -> AppResult> { - let alive: Option<(Uuid,)> = sqlx::query_as( - "SELECT id FROM facts + let alive: Option<(Option>, Option)> = sqlx::query_as( + "SELECT valid_from, predicate_id FROM facts WHERE id = $1 AND invalidated_at IS NULL AND (NOT $2 OR (valid_to IS NULL AND valid_to_precision IS NULL)) FOR UPDATE", @@ -964,8 +964,20 @@ async fn rewrite_end_tx( .bind(require_open) .fetch_optional(&mut **tx) .await?; - if alive.is_none() { + let Some((from, predicate)) = alive else { return Ok(None); + }; + // 声明成状态的行关在它自己的起点,就是一段任何时刻都不成立的状态(#966,与 + // `Validity::under` 同一条不变量)。人关一条事实走这里;引擎只把一行关在严格更晚的 + // 起点上,写入路径也不拿开放行自己的起点来关它 + if let End::At(at, _) = end { + if predicate.is_some() + && from == Some(*at) + && crate::graph::predicate_temporal(&mut **tx, predicate).await? + == crate::graph::Temporal::State + { + return Err(crate::graph::empty_state_span()); + } } let (valid_to, precision, anchor) = match end { End::Open => (None, None, None), @@ -1180,8 +1192,14 @@ pub async fn correct_interval( .bind(fact_id) .fetch_optional(pool) .await?; - let temporal = crate::graph::predicate_temporal(pool, predicate.flatten()).await?; - let validity = validity.under(temporal).truncated(); + let predicate = predicate.flatten(); + let temporal = crate::graph::predicate_temporal(pool, predicate).await?; + // 两端相等的状态只对声明成状态的属性拒(#966):开放陈述与空谓词的行按状态读只是 + // 读法,「那天」照旧写成两端同值 + let validity = match predicate { + Some(_) => validity.truncated().under(temporal)?, + None => validity.truncated(), + }; let mut tx = pool.begin().await?; let corrected = Uuid::now_v7(); let inserted: Option<(Uuid,)> = sqlx::query_as( diff --git a/crates/utopia-store/tests/human_phrase_materialization_delivery.rs b/crates/utopia-store/tests/human_phrase_materialization_delivery.rs index 7e35ae326..12064e131 100644 --- a/crates/utopia-store/tests/human_phrase_materialization_delivery.rs +++ b/crates/utopia-store/tests/human_phrase_materialization_delivery.rs @@ -25,6 +25,7 @@ async fn accept( votes: &json!({}), decided_by: "person", basis: None, + marks: None, } ) .await? @@ -233,6 +234,7 @@ fn crash_child() { votes: &json!({}), decided_by: "person", basis: None, + marks: None, }, ) .await diff --git a/crates/utopia-store/tests/store/a_moment_marks_its_state.rs b/crates/utopia-store/tests/store/a_moment_marks_its_state.rs new file mode 100644 index 000000000..869d8d458 --- /dev/null +++ b/crates/utopia-store/tests/store/a_moment_marks_its_state.rs @@ -0,0 +1,793 @@ +//! 只带一个日期的陈述绑在状态属性上(#966)。「Lin Zhao joined Meridian Systems on 2023-06-01」 +//! 的日期,时间解析照 0031 写事件的办法写在两端;绑到 works_for(状态)之后照抄,就是一段 +//! 2023-06-01 → 2023-06-01 的状态。世界轴按 `from <= T < to` 读状态,这一段任何时刻都不成立。 +//! 绑定现在说那个日期标的是什么(`phrase_bindings.marks`): +//! +//! - start:从那天起。「joined」在「works for … from 2023-06-01」之前、之后,或只有它 +//! (#966 的 A、B、C),都是一段从 2023-06-01 起、至今开着的状态; +//! - end:到那天为止,关上开着的那段; +//! - none 或还没说:不算类型化行,陈述带着日期留在开放图谱。 +//! +//! 之前照抄写下的空段由物化作废重算,人写的不碰;声明成状态的属性下,两端相等的行写入、 +//! 改区间、关在起点都被拒,没有属性的行照旧;已有的空段不并进后来的观察,也不被它关上; +//! 勘误改不了一段不成立的时间;规则没有 marks,只说了一刻的陈述不蕴含状态,只有起点的照旧。 +//! +//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 + +use chrono::{DateTime, Utc}; +use serde_json::json; +use sqlx::PgPool; +use utopia_store::errata::{self, ActionInput, Candidate, Proposed}; +use utopia_store::graph::{self, Validity}; +use utopia_store::materialize::{materialize, Outcome}; +use uuid::Uuid; + +/// 一行类型化事实:起、止,和它是不是规则算的 +type ImpliedRow = (Option>, Option>, bool); + +fn t(s: &str) -> DateTime { + s.parse().expect("fixed timestamp") +} + +/// 2023-06-01,日精度 +fn day() -> DateTime { + t("2023-06-01T00:00:00Z") +} + +/// 两端都是那一天:时间解析写一刻的样子 +fn moment() -> Validity<'static> { + Validity { + from: Some(day()), + from_precision: Some("day"), + to: Some(day()), + to_precision: Some("day"), + attested_at: None, + from_grade: None, + } +} + +/// 只知道终点 +fn ending(at: DateTime) -> Validity<'static> { + Validity { + from: None, + from_precision: None, + to: Some(at), + to_precision: Some("day"), + attested_at: None, + from_grade: None, + } +} + +/// 一个库:person、organization;works_for(状态)、acquired(事件);Lin Zhao、 +/// Meridian Systems、Aster Labs +struct Base { + pool: PgPool, + org: Uuid, + kb: Uuid, + person: Uuid, + company: Uuid, + works_for: Uuid, + acquired: Uuid, + lin: Uuid, + meridian: Uuid, + aster: Uuid, +} + +impl Base { + async fn new(pool: &PgPool) -> anyhow::Result { + let ids: Vec = (0..10).map(|_| Uuid::now_v7()).collect(); + let (org, ws, kb, person, company) = (ids[0], ids[1], ids[2], ids[3], ids[4]); + let (works_for, acquired, lin, meridian, aster) = (ids[5], ids[6], ids[7], ids[8], ids[9]); + // Only locally generated UUIDs are interpolated into fixture SQL. + sqlx::raw_sql(&format!( + "INSERT INTO organizations (id, name) VALUES ('{org}', 'a-moment-marks-its-state'); + INSERT INTO workspaces (id, org_id, name) VALUES ('{ws}', '{org}', 'a-moment-marks-its-state'); + INSERT INTO knowledge_bases (id, workspace_id, name) + VALUES ('{kb}', '{ws}', 'a-moment-marks-its-state'); + INSERT INTO entity_types (id, kb_id, key, label, color, shape) VALUES + ('{person}', '{kb}', 'person', 'person', '#7fd0ff', 'circle'), + ('{company}', '{kb}', 'organization', 'organization', '#7fd0ff', 'circle'); + INSERT INTO relation_types (id, kb_id, key, label, temporal) VALUES + ('{works_for}', '{kb}', 'works_for', 'works for', 'state'), + ('{acquired}', '{kb}', 'acquired', 'acquired', 'event'); + INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES + ('{lin}', '{kb}', '{person}', 'Lin Zhao'), + ('{meridian}', '{kb}', '{company}', 'Meridian Systems'), + ('{aster}', '{kb}', '{company}', 'Aster Labs');" + )) + .execute(pool) + .await?; + Ok(Self { + pool: pool.clone(), + org, + kb, + person, + company, + works_for, + acquired, + lin, + meridian, + aster, + }) + } + + /// 一条开放陈述 Lin Zhao —phrase→ Meridian Systems,两端按时间解析写的形状给:一刻两端 + /// 同值,「自」只有起点,「至」只有终点 + async fn statement( + &self, + phrase: &str, + from: Option>, + to: Option>, + ) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, layer, phrase, confidence, + valid_from, valid_from_precision, valid_from_grade, + valid_to, valid_to_precision) + VALUES ($1, $2, $3, $4, 'open', $5, 0.9, + $6, CASE WHEN $6 IS NULL THEN NULL ELSE 'day' END, + CASE WHEN $6 IS NULL THEN NULL ELSE 'A' END, + $7, CASE WHEN $7 IS NULL THEN NULL ELSE 'day' END)", + ) + .bind(id) + .bind(self.kb) + .bind(self.lin) + .bind(self.meridian) + .bind(phrase) + .bind(from) + .bind(to) + .execute(&self.pool) + .await?; + Ok(id) + } + + /// 签名 (phrase, person, organization) 绑到 works_for,照原文方向,说那个日期标什么 + async fn bind(&self, phrase: &str, marks: Option<&str>) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO phrase_bindings + (id, kb_id, phrase, subject_type_id, object_type_id, object_is_value, + relation_type_id, direction, status, marks) + VALUES ($1, $2, $3, $4, $5, false, $6, 'forward', 'bound', $7) + ON CONFLICT (kb_id, phrase, subject_type_id, object_type_id, object_is_value) + DO UPDATE SET marks = EXCLUDED.marks", + ) + .bind(Uuid::now_v7()) + .bind(self.kb) + .bind(phrase) + .bind(self.person) + .bind(self.company) + .bind(self.works_for) + .bind(marks) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// 一行类型化的 Lin Zhao —works_for→ object,两端都是那一天:#966 之前照抄写下的样子。 + /// `statement` 给了就是物化算的(`from_statement_id`),没给就是人写的 + async fn empty_row(&self, object: Uuid, statement: Option) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, layer, + valid_from, valid_from_precision, valid_to, valid_to_precision, + confidence, from_statement_id) + VALUES ($1, $2, $3, $4, $5, 'typed', $6, 'day', $6, 'day', 0.9, $7)", + ) + .bind(id) + .bind(self.kb) + .bind(self.lin) + .bind(self.works_for) + .bind(object) + .bind(day()) + .bind(statement) + .execute(&self.pool) + .await?; + Ok(id) + } + + /// Lin Zhao 与 Meridian 之间活着的类型化行:(起, 止, 来源数),按起点排 + async fn rows( + &self, + ) -> anyhow::Result>, Option>, i64)>> { + Ok(sqlx::query_as( + "SELECT t.valid_from, t.valid_to, + (SELECT count(*) FROM typed_fact_sources s WHERE s.fact_id = t.id) + FROM facts t + WHERE t.kb_id = $1 AND t.layer = 'typed' AND t.invalidated_at IS NULL + AND t.object_id = $2 + ORDER BY t.valid_from NULLS FIRST, t.valid_to NULLS LAST", + ) + .bind(self.kb) + .bind(self.meridian) + .fetch_all(&self.pool) + .await?) + } + + /// 世界轴上某一刻,谁 works_for Meridian + async fn at_meridian(&self, at: &str) -> anyhow::Result> { + let (nodes, edges) = + graph::neighborhood(&self.pool, self.kb, self.meridian, 1, Some(t(at)), None).await?; + Ok(edges + .iter() + .filter(|e| e.target == self.meridian && e.predicate.as_deref() == Some("works_for")) + .filter_map(|e| nodes.iter().find(|n| n.id == e.source)) + .map(|n| n.name.clone()) + .collect()) + } + + /// 不看世界轴时,Meridian 身上的边:(id, 起, 止) + async fn edges( + &self, + ) -> anyhow::Result>, Option>)>> { + let (_, edges) = + graph::neighborhood(&self.pool, self.kb, self.meridian, 1, None, None).await?; + Ok(edges + .iter() + .filter(|e| e.target == self.meridian) + .map(|e| (e.id, e.valid_from, e.valid_to)) + .collect()) + } + + async fn live(&self, fact: Uuid) -> anyhow::Result<(bool, Option>)> { + Ok( + sqlx::query_as("SELECT invalidated_at IS NULL, valid_to FROM facts WHERE id = $1") + .bind(fact) + .fetch_one(&self.pool) + .await?, + ) + } + + async fn remove(self) -> anyhow::Result<()> { + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(self.org) + .execute(&self.pool) + .await?; + Ok(()) + } +} + +async fn pool() -> anyhow::Result> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(None); + }; + Ok(Some(PgPool::connect(&url).await?)) +} + +fn refused_as_empty(e: &utopia_core::AppError) -> bool { + format!("{e:?}").contains("empty_state_span") +} + +/// #966 的三种顺序:「joined」(一刻)在「works for … from」(自)之前、之后,或只有它。 +/// 绑定说「joined」标的是开始:三种都是一段从 2023-06-01 起、至今开着的状态 +#[tokio::test] +async fn a_date_that_starts_a_state_opens_it_whatever_the_order() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let cases: [(&str, &[&str]); 3] = [ + ("A", &["joined", "works for"]), + ("B", &["works for", "joined"]), + ("C", &["joined"]), + ]; + for (label, phrases) in cases { + let base = Base::new(&pool).await?; + let run = async { + for phrase in phrases { + let point = *phrase == "joined"; + base.statement(phrase, Some(day()), point.then(day)).await?; + base.bind(phrase, point.then_some("start")).await?; + } + materialize(&pool, base.kb).await?; + assert_eq!( + base.rows().await?, + vec![(Some(day()), None, phrases.len() as i64)], + "case {label}: one row from 2023-06-01, every statement its source" + ); + for at in [ + "2023-06-01T12:00:00Z", + "2023-07-01T00:00:00Z", + "2026-01-01T00:00:00Z", + ] { + assert_eq!( + base.at_meridian(at).await?, + vec!["Lin Zhao".to_string()], + "case {label} at {at}" + ); + } + assert_eq!( + materialize(&pool, base.kb).await?, + Outcome::default(), + "case {label}: a second run changes nothing" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run?; + } + Ok(()) +} + +/// 标结束的短语:「left … on 2025-01-31」把开着的那段关在那一天。先读到哪一句都一样 +#[tokio::test] +async fn a_date_that_ends_a_state_closes_it_there() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let left = t("2025-01-31T00:00:00Z"); + for order in [["works for", "left"], ["left", "works for"]] { + let base = Base::new(&pool).await?; + let run = async { + for phrase in order { + if phrase == "left" { + base.statement(phrase, Some(left), Some(left)).await?; + } else { + base.statement(phrase, Some(day()), None).await?; + } + } + base.bind("works for", None).await?; + base.bind("left", Some("end")).await?; + materialize(&pool, base.kb).await?; + assert_eq!( + base.rows().await?, + vec![(Some(day()), Some(left), 2)], + "{order:?}: one span, closed where he left" + ); + assert_eq!( + base.at_meridian("2024-06-01T00:00:00Z").await?, + vec!["Lin Zhao".to_string()], + "{order:?}" + ); + assert!( + base.at_meridian("2025-06-01T00:00:00Z").await?.is_empty(), + "{order:?}: not after he left" + ); + assert_eq!(materialize(&pool, base.kb).await?, Outcome::default()); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run?; + } + Ok(()) +} + +/// 标「都不是」的日期,和绑定还没说标什么的日期:不算类型化行。陈述带着它的日期 +/// 留在开放图谱,画布上还是它 +#[tokio::test] +async fn a_date_that_marks_neither_or_nothing_yet_computes_no_row() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + for marks in [Some("none"), None] { + let base = Base::new(&pool).await?; + let run = async { + let seen = base + .statement("was employed by", Some(day()), Some(day())) + .await?; + base.bind("was employed by", marks).await?; + assert_eq!( + materialize(&pool, base.kb).await?, + Outcome::default(), + "{marks:?}" + ); + assert!(base.rows().await?.is_empty(), "{marks:?}: no typed row"); + assert_eq!( + base.edges().await?, + vec![(seen, Some(day()), Some(day()))], + "{marks:?}: the statement stays in the open graph with its date" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run?; + } + Ok(()) +} + +/// 之前照抄写下的空段(#966 的 A):物化作废它(还在表里),陈述按绑定重算——「works for +/// … from」落成一段从那天起的状态;「joined」的绑定还没说标什么,它留在开放图谱;绑定说了 +/// start,它并进那一段。人写的空段不碰 +#[tokio::test] +async fn a_row_that_held_at_no_moment_is_computed_again() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let joined = base.statement("joined", Some(day()), Some(day())).await?; + let works = base.statement("works for", Some(day()), None).await?; + base.bind("joined", None).await?; + base.bind("works for", None).await?; + let computed = base.empty_row(base.meridian, Some(joined)).await?; + sqlx::query( + "INSERT INTO typed_fact_sources (fact_id, statement_id) VALUES ($1, $2), ($1, $3)", + ) + .bind(computed) + .bind(joined) + .bind(works) + .execute(&pool) + .await?; + let by_hand = base.empty_row(base.aster, None).await?; + assert!( + base.at_meridian("2023-07-01T00:00:00Z").await?.is_empty(), + "the ledger as #966 found it: nobody works for Meridian" + ); + + assert_eq!( + materialize(&pool, base.kb).await?, + Outcome { + retired: 1, + added: 1, + ..Outcome::default() + } + ); + assert_eq!( + base.live(computed).await?, + (false, Some(day())), + "retired, still in the table" + ); + assert_eq!(base.rows().await?, vec![(Some(day()), None, 1)]); + assert_eq!( + base.at_meridian("2023-07-01T00:00:00Z").await?, + vec!["Lin Zhao".to_string()] + ); + let edges = base.edges().await?; + assert!( + edges.iter().any(|(id, _, _)| *id == joined), + "joined has no typed row yet, so it shows as itself: {edges:?}" + ); + assert!(!edges.iter().any(|(id, _, _)| *id == works), "{edges:?}"); + assert_eq!( + base.live(by_hand).await?, + (true, Some(day())), + "a row a person wrote is not touched" + ); + + base.bind("joined", Some("start")).await?; + assert_eq!( + materialize(&pool, base.kb).await?, + Outcome { + merged: 1, + ..Outcome::default() + } + ); + assert_eq!(base.rows().await?, vec![(Some(day()), None, 2)]); + assert_eq!(materialize(&pool, base.kb).await?, Outcome::default()); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} + +/// 不变量:声明成状态的属性下两端相等的行写不进去——写入(截到精度之后才相等的也算)、 +/// 改区间、关在它自己的起点都拒,原来的行不动。事件照旧写成一刻;没有属性的行照旧带着它的日期 +#[tokio::test] +async fn a_state_that_ends_where_it_starts_is_never_written() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let (kb, lin, meridian, aster) = (base.kb, base.lin, base.meridian, base.aster); + let refused = graph::insert_fact( + &pool, + kb, + lin, + Some(base.works_for), + meridian, + moment(), + 0.9, + ) + .await + .expect_err("an empty state span"); + assert!(refused_as_empty(&refused), "{refused:?}"); + let same_day = Validity { + from: Some(t("2023-06-01T09:00:00Z")), + to: Some(t("2023-06-01T17:00:00Z")), + ..moment() + }; + let refused = graph::insert_fact( + &pool, + kb, + lin, + Some(base.works_for), + meridian, + same_day, + 0.9, + ) + .await + .expect_err("equal once truncated to the day"); + assert!(refused_as_empty(&refused), "{refused:?}"); + graph::insert_fact( + &pool, + kb, + meridian, + Some(base.acquired), + aster, + moment(), + 0.9, + ) + .await?; + let (bare, _) = graph::insert_fact(&pool, kb, lin, None, aster, moment(), 0.9).await?; + let (_, to) = base.live(bare).await?; + assert_eq!(to, Some(day()), "a row without a property keeps its date"); + + let (open, _) = graph::insert_fact( + &pool, + kb, + lin, + Some(base.works_for), + meridian, + Validity::starting(Some(day()), Some("day")), + 0.9, + ) + .await?; + let refused = utopia_store::temporal::correct_interval(&pool, open, moment()) + .await + .expect_err("corrected into an empty span"); + assert!(refused_as_empty(&refused), "{refused:?}"); + let refused = utopia_store::temporal::close_superseded(&pool, open, day(), "day") + .await + .expect_err("closed where it starts"); + assert!(refused_as_empty(&refused), "{refused:?}"); + assert_eq!( + base.live(open).await?, + (true, None), + "the open row is untouched" + ); + assert!( + utopia_store::temporal::close_superseded(&pool, open, t("2024-01-01T00:00:00Z"), "day") + .await? + .is_some(), + "closing after the start still works" + ); + let (unnamed, _) = graph::insert_fact( + &pool, + kb, + lin, + None, + meridian, + Validity::starting(Some(day()), Some("day")), + 0.9, + ) + .await?; + assert!( + utopia_store::temporal::correct_interval(&pool, unnamed, moment()) + .await? + .is_some(), + "a row without a property can be given a single date" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} + +/// 护栏:之前写下的空段(这里是人写的,物化不作废它)不把后来「自那天起」的观察并进去, +/// 也不被「到哪天为止」的观察关上。同一天开始又结束的状态,不管先读到哪一句,都不写成空段 +#[tokio::test] +async fn a_row_that_holds_at_no_moment_neither_absorbs_nor_closes() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let (kb, lin, meridian) = (base.kb, base.lin, base.meridian); + let empty = base.empty_row(meridian, None).await?; + let since = Validity::starting(Some(day()), Some("day")); + let (open, created) = + graph::insert_fact(&pool, kb, lin, Some(base.works_for), meridian, since, 0.9).await?; + assert!( + created && open != empty, + "a new row, not merged into the empty one" + ); + let left = t("2025-01-31T00:00:00Z"); + graph::insert_fact( + &pool, + kb, + lin, + Some(base.works_for), + meridian, + ending(left), + 0.9, + ) + .await?; + assert_eq!( + base.rows().await?, + vec![(Some(day()), Some(day()), 0), (Some(day()), Some(left), 0)], + "the open row is the one that closes" + ); + assert_eq!(base.live(empty).await?, (true, Some(day()))); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run?; + + for start_first in [true, false] { + let base = Base::new(&pool).await?; + let run = async { + let (kb, lin, meridian) = (base.kb, base.lin, base.meridian); + let since = Validity::starting(Some(day()), Some("day")); + let writes = if start_first { + [since, ending(day())] + } else { + [ending(day()), since] + }; + for v in writes { + graph::insert_fact(&pool, kb, lin, Some(base.works_for), meridian, v, 0.9).await?; + } + assert_eq!( + base.rows().await?, + vec![(None, Some(day()), 0), (Some(day()), None, 0)], + "start first: {start_first}" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run?; + } + Ok(()) +} + +/// 勘误改一条之前写下的空段:新行接不了一段不成立的时间。验证时就拒;升级前就留给人的 +/// 那一笔,批了也不先撤旧行 +#[tokio::test] +async fn a_revision_cannot_take_a_time_that_holds_at_no_moment() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let (kb, lin, meridian, aster) = (base.kb, base.lin, base.meridian, base.aster); + let text = "Lin Zhao joined Aster Labs on 2023-06-01."; + let (doc, chunk) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256) VALUES ($1, $2, 'offer.txt', 'x')", + ) + .bind(doc) + .bind(kb) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 0, $4)", + ) + .bind(chunk) + .bind(kb) + .bind(doc) + .bind(text) + .execute(&pool) + .await?; + let empty = base.empty_row(meridian, None).await?; + let run_id = errata::start_run(&pool, kb, doc, 1, 0).await?; + let candidate = Candidate { + fact_id: empty, + statement_id: None, + predicate_id: base.works_for, + subject_id: lin, + subject: "Lin Zhao".into(), + subject_class: Some("person".into()), + property: "works_for".into(), + property_label: "works for".into(), + object_id: Some(meridian), + object: "Meridian Systems".into(), + object_class: Some("organization".into()), + flag: Some("name_absent".into()), + quote: None, + }; + let recorded = errata::record( + &pool, + kb, + ActionInput { + run_id, + document_id: doc, + candidate: Some(&candidate), + proposed: Proposed::Revise { + property: None, + object: Some("Aster Labs".into()), + }, + reason: "the document names Aster Labs", + quote: Some("Lin Zhao joined Aster Labs"), + document_text: text, + }, + ) + .await?; + assert_eq!( + (recorded.status, recorded.detail.as_deref()), + ( + "refused", + Some("a state cannot take a time whose start and end are equal") + ) + ); + assert_eq!(base.live(empty).await?, (true, Some(day()))); + + let held = Uuid::now_v7(); + sqlx::query( + "INSERT INTO errata_actions (id, kb_id, run_id, document_id, fact_id, predicate_id, + action, reason, quote, proposed, status) + VALUES ($1, $2, $3, $4, $5, $6, 'revise', 'held before the upgrade', + 'Lin Zhao joined Aster Labs', $7, 'held')", + ) + .bind(held) + .bind(kb) + .bind(run_id) + .bind(doc) + .bind(empty) + .bind(base.works_for) + .bind(json!({ + "subject": "Lin Zhao", "property": "works_for", "object": "Aster Labs", + "subject_id": lin, "predicate_id": base.works_for, "object_id": aster, + "object_value": null, + })) + .execute(&pool) + .await?; + let refused = errata::decide_held(&pool, kb, held, true, Uuid::now_v7()) + .await + .expect_err("the revision cannot be written"); + assert!(refused_as_empty(&refused), "{refused:?}"); + assert_eq!( + base.live(empty).await?, + (true, Some(day())), + "the old row is not retracted first" + ); + let status: String = sqlx::query_scalar("SELECT status FROM errata_actions WHERE id = $1") + .bind(held) + .fetch_one(&pool) + .await?; + assert_eq!(status, "held"); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} + +/// 规则没有 marks:只有起点的陈述照旧蕴含一段从那天起的状态;只说了一刻的陈述蕴含状态时 +/// 读法未知,不算隐含行 +#[tokio::test] +async fn a_rule_reads_a_start_date_but_not_a_single_date() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + base.statement("was hired by", Some(day()), None).await?; + base.statement("signed with", Some(day()), Some(day())) + .await?; + for phrase in ["was hired by", "signed with"] { + sqlx::query( + "INSERT INTO implication_rules + (id, kb_id, trigger, phrase, subject_type_id, object_type_id, object_is_value, + conclude_property_id, status, decided_by) + VALUES ($1, $2, 'phrase', $3, $4, $5, false, $6, 'approved', 'person')", + ) + .bind(Uuid::now_v7()) + .bind(base.kb) + .bind(phrase) + .bind(base.person) + .bind(base.company) + .bind(base.works_for) + .execute(&pool) + .await?; + } + let outcome = materialize(&pool, base.kb).await?; + assert_eq!(outcome.implied, 1, "{outcome:?}"); + let rows: Vec = sqlx::query_as( + "SELECT valid_from, valid_to, implied FROM facts + WHERE kb_id = $1 AND layer = 'typed' AND invalidated_at IS NULL", + ) + .bind(base.kb) + .fetch_all(&pool) + .await?; + assert_eq!(rows, vec![(Some(day()), None, true)]); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} diff --git a/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs b/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs index 3ed965b7d..3554723d2 100644 --- a/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs +++ b/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs @@ -610,6 +610,7 @@ async fn bind_is_on(pool: &PgPool, f: &Fixture) -> anyhow::Result<()> { votes: &json!({ "person": { "property": "location", "direction": "forward" } }), decided_by: "person", basis: None, + marks: None, }, ) .await?; diff --git a/crates/utopia-store/tests/store/main.rs b/crates/utopia-store/tests/store/main.rs index 91142b89e..1adec2374 100644 --- a/crates/utopia-store/tests/store/main.rs +++ b/crates/utopia-store/tests/store/main.rs @@ -52,6 +52,7 @@ mod a_late_value_takes_its_place_in_history; mod a_mapping_is_not_a_fact; mod a_merge_rewinds_with_the_second_clock; mod a_merged_target_stays_out_of_the_export; +mod a_moment_marks_its_state; mod a_name_created_twice_at_once_is_one_entity; mod a_name_is_a_fact; mod a_namesake_tie_goes_to_review_not_a_coin_flip; diff --git a/crates/utopia-store/tests/store/materialization_is_serial.rs b/crates/utopia-store/tests/store/materialization_is_serial.rs index 120e1c1cc..c635b58c4 100644 --- a/crates/utopia-store/tests/store/materialization_is_serial.rs +++ b/crates/utopia-store/tests/store/materialization_is_serial.rs @@ -81,6 +81,7 @@ async fn overlapping_materializations_create_one_typed_fact() -> anyhow::Result< votes: &serde_json::json!({}), decided_by: "agent", basis: None, + marks: None, }, ) .await?; diff --git a/crates/utopia-store/tests/store/negative_binding_definition_edit.rs b/crates/utopia-store/tests/store/negative_binding_definition_edit.rs index 9741c7fc1..d44232207 100644 --- a/crates/utopia-store/tests/store/negative_binding_definition_edit.rs +++ b/crates/utopia-store/tests/store/negative_binding_definition_edit.rs @@ -64,6 +64,7 @@ impl BindingKind { votes: &votes, decided_by: actor, basis: None, + marks: None, }, ) .await? 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 b77056555..9bc645914 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,8 +1,8 @@ # 0053 · A phrase decision records the inputs it considered -- **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 · kind words since 2026-09-26: `type_bindings.basis` (migration 0092), read from one snapshot and compared again when a reply is accepted, see [the revision below](#revision-2026-09-26-kind-words) +- **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 · kind words since 2026-09-26: `type_bindings.basis` (migration 0092), read from one snapshot and compared again when a reply is accepted, see [the revision below](#revision-2026-09-26-kind-words) · what a single date marks since 2026-09-27: `phrase_bindings.marks` (migration 0097), see [the revision below](#revision-2026-09-27-what-a-single-date-marks) - **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 +- **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, #966 ## Problem @@ -143,3 +143,87 @@ whose batch fails takes the bounded re-ask; a failed retrieval falls back and is while it keeps failing; a person's decision carries no basis, is not overwritten, and commits with its job; an acceptance and a class delete wait for each other instead of deadlocking, and a candidate deleted before the check turns the reply away. + +## Revision 2026-09-27 (what a single date marks) + +"Lin Zhao joined Meridian Systems on 2023-06-01" has a single date, which time resolution writes +the way 0031 writes an event: the same instant on both ends. Bound to `works_for`, a state, it was +materialised as 2023-06-01 → 2023-06-01. The world axis reads a state as holding while +`valid_from <= T < valid_to`, so that row held at no moment. It also absorbed or closed the +document's own "works for … from 2023-06-01", depending on which statement was written first +(#966). + +**A binding to a state property says what a single date marks.** `phrase_bindings.marks` +(migration 0097) is `start`, `end` or `none`; NULL is unknown. + +- **The aligner decides it with the binding.** Each candidate line says whether the property is a + state, an event or timeless, and the reply carries a fourth value per item. Both votes must agree + on it, as on the property and the direction: + - two different values put the signature in the alignment queue; + - a vote that binds a state and leaves the value out counts as unanswered and takes the bounded + re-ask; + - events and timeless properties are not asked. +- **What it depends on is in the fingerprint.** Whether the value is asked depends on the property's + time semantics. Every edit to them moves `updated_at`, which the fingerprint covers, so a property + that becomes a state is asked again. The value itself is an output, not an input, and is not + fingerprinted. +- **Decisions made before this** have no value. + - An agent's binding to a state with no value is asked again on the next run, although its + fingerprint matches. Only the run's opening selection does this. The check for work left at the + end of a run ignores it, so a model that never answers cannot queue run after run. + - A person's binding is never re-asked. It is listed in the alignment queue, with its property and + direction preselected, until a person says what the date marks. +- **A person** sets the value in the alignment queue when binding a state. The decision request + carries `marks`, and the API refuses it for anything but a state property. + +**Materialisation reads it.** A statement whose two ends are equal, bound to a state property: + +- `start`: written as [t, open). With "works for … from t" in the same document, both statements + are sources of one row, whichever was written first. +- `end`: written as an end at t, the path a dated ending takes in `insert_fact_on`: it closes the + open row at t. +- `none` or unknown: no typed row. The statement stays in the open graph with its date. + +Rows that hold at no moment because they were materialised before this (a computed state row with +equal ends) are retired in the next run, and their statements are computed again under these rules. +A row a person wrote is not touched. A rule carries no value, so a single-date statement whose rule +concludes a state computes no implied row. + +**The invariant.** A state row with equal ends is never written. `Validity::under` refuses it after +truncation to precision (`empty_state_span`), so an interval correction that would write one gets a +422. Closing a state row at its own start is refused the same way. Rows without a property keep +their dates as before: reading them as a state is how they are read, not a declaration. + +**The guard.** Under a state property, `insert_fact_on` no longer takes a stored row with equal ends +for the same observation. Such a row neither absorbs a later observation nor is closed by one, so the +ledger does not depend on the order of statements. A single date arriving under a state never reaches +that matching: the invariant refuses it first. + +Not doing: + +- **A date that means the whole period.** "Revenue in 2023" or "was chairman in 2019" is `none` and + stays open. Reading such a date as holding through its bucket, as an event is read, would be a + separate decision. +- **A flip between start and end.** A binding whose value changes from `start` to `end`, or back, + keeps the rows already computed from its single-date statements. A change to or from `none`, and a + first value on a binding that had none, are computed again. +- **Reopening.** A row closed by an `end` statement is not reopened when that statement is + retracted, as with any dated ending. + +Regression coverage, with a real PostgreSQL: + +- The three orders from #966 (`joined` before `works for`, after it, and alone) give one row from + 2023-06-01 when the binding says `start`. +- A phrase that marks the end closes the open row at its date. With `none` or no value, a single + date computes no typed row. +- A row that held at no moment is retired and computed again. +- The invariant refuses a write, an interval correction and a closing at the row's own start, and + still writes a single date without a property. +- The guard keeps a stored row with equal ends from absorbing a later observation. + +With a scripted model: + +- a four-value reply binds with the value; +- different values go to the queue, and a missing one is re-asked; +- an agent's binding without a value is asked again and a person's is not; +- the queue lists a person's binding without a value, and a person's decision writes it. diff --git a/migrations/0097_a_binding_says_which_end_a_moment_marks.sql b/migrations/0097_a_binding_says_which_end_a_moment_marks.sql new file mode 100644 index 000000000..dc358c104 --- /dev/null +++ b/migrations/0097_a_binding_says_which_end_a_moment_marks.sql @@ -0,0 +1,14 @@ +-- 绑定说明「这一刻」标的是状态的哪一端(#966,0053 的修订)。 +-- +-- 一条陈述只说了一刻(「2023-06-01 加入 Meridian」,起止同值)而它的签名绑到一条状态属性 +-- (works_for)时,那一刻可能是状态的开始(joined、took over)、结束(left、resigned from), +-- 也可能只是那时观察到它成立。从前照抄起止,写成一段起止相等的状态——世界轴按 +-- `from <= T < to` 读它,任何时刻都不成立,还会吞掉或关上同一份文档里「自那天起」的陈述。 +-- +-- `marks` 由对齐器随绑定一起判,人在对齐队列里可以改: +-- start 这一刻开始了状态:物化成 [t, 开放) +-- end 这一刻结束了状态:在 t 关上开放的那一段 +-- none 只是那时成立:不算类型化行,陈述留在开放图谱里带着它的日期 +-- NULL 是未知(这一列之前的判定、事件与恒常属性):点形状的陈述在状态属性下不算类型化行。 +ALTER TABLE phrase_bindings + ADD COLUMN marks TEXT CHECK (marks IN ('start', 'end', 'none')); diff --git a/web/src/api.ts b/web/src/api.ts index 11ab2d396..35395bd2a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -785,6 +785,10 @@ export type AlignmentItem = candidates?: number; } | null; decided_at: string; + /** 人绑过、还没说单个日期标哪一端的签名(#966):现在绑到的属性与方向。 + * 两票不一致的条目两个都是 null */ + bound_to: string | null; + direction: "forward" | "reverse" | null; } | { kind: "kind_word"; @@ -833,7 +837,11 @@ export interface ErrataItem { export interface AlignmentVote { property: string; direction: "forward" | "reverse"; + /** 状态属性下,只带一个日期的陈述那个日期标的是什么(#966);事件与恒常没有 */ + marks?: AlignmentMarks | null; } +/** 单个日期在状态上标的是开始、结束,还是都不是(`phrase_bindings.marks`,#966) */ +export type AlignmentMarks = "start" | "end" | "none"; export interface OntologyDefect { id: string; @@ -2576,10 +2584,12 @@ export const api = { bindingId: string, property: string | null, direction: "forward" | "reverse", + /** 只在状态属性下给;null = 不说(只带一个日期的陈述留在开放图谱) */ + marks: AlignmentMarks | null, ) => request<{ ok: boolean; job_id: number; status: "accepted" }>( `/api/v1/kbs/${kbId}/review/alignment/phrases/${bindingId}`, - { method: "POST", body: JSON.stringify({ property, direction }) }, + { method: "POST", body: JSON.stringify({ property, direction, marks }) }, ), /** 人批或驳一条蕴含规则:答 202 和 job id,隐含事实在后台算(0044 决定 3 第五片) */ decideAlignmentRule: (kbId: string, ruleId: string, approve: boolean) => diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 37a51ea29..cfc3de4f5 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -137,6 +137,11 @@ export const en = { inference_off: "Materialized inference is off for this knowledge base. Turn it on in Settings.", bad_resolution: "That is not a valid decision.", + empty_state_span: + "A state that ends where it starts holds at no moment. Give an end after the start, or leave it open.", + unknown_marks: "A single date marks the start, the end, or neither.", + marks_needs_state: + "Only a state property says what a single date marks. Events and timeless properties don't.", }, /** 机器给的补充(cron 解析器的原话之类)缀在措辞后面 */ errDetail: (msg: string, detail: string) => `${msg} (${detail})`, @@ -2113,6 +2118,17 @@ 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}`, + // 状态属性下,单个日期标的是什么(#966) + alignmentMarks: "A single date", + alignmentMarksStart: "starts it", + alignmentMarksEnd: "ends it", + alignmentMarksNone: "neither", + alignmentMarksStartHint: "joined, was appointed, took over: it holds from that date", + alignmentMarksEndHint: "left, resigned from, stepped down as: it ends on that date", + alignmentMarksNoneHint: + "only seen to hold then: statements with a single date stay in the open graph", + alignmentAwaitsMarks: (property: string) => + `Bound to ${property} before the aligner asked what a single date marks. Until you say, statements with a single date stay in the open graph.`, alignmentRuleImplies: (property: string) => `also implies ${property}`, alignmentRuleObjectIsStatement: "object: the statement's own object", alignmentRuleReading: (reading: string) => `object: read from the words as ${reading.replace(/_/g, " ")}`, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c8e557298..3ec39dde9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -124,6 +124,9 @@ export const zh: Strings = { concurrency_range: "填一个 1 到 256 之间的数。", inference_off: "这个库没有打开物化推理。到设置里开。", bad_resolution: "这不是一个有效的裁决。", + empty_state_span: "起止相同的状态任何时刻都不成立。给一个晚于起点的终点,或者让它开着。", + unknown_marks: "单个日期标的是开始、结束,或者都不是。", + marks_needs_state: "只有状态属性才说单个日期标的是什么,事件与恒常属性不说。", }, errDetail: (msg: string, detail: string) => `${msg}(${detail})`, toast: { @@ -1836,6 +1839,16 @@ export const zh: Strings = { alignmentStatements: (n: number) => `${n} 条陈述`, alignmentEntities: (n: number) => `${n} 样东西`, alignmentVotes: (first: string, second: string) => `两票:${first} · ${second}`, + // 状态属性下,单个日期标的是什么(#966) + alignmentMarks: "单个日期", + alignmentMarksStart: "是开始", + alignmentMarksEnd: "是结束", + alignmentMarksNone: "都不是", + alignmentMarksStartHint: "加入、被任命、接手:从那天起成立", + alignmentMarksEndHint: "离开、辞去、卸任:到那天为止", + alignmentMarksNoneHint: "只是那时看到它成立:只带一个日期的陈述留在开放图谱", + alignmentAwaitsMarks: (property: string) => + `绑到 ${property} 时对齐器还没问过单个日期标的是什么。你说之前,只带一个日期的陈述留在开放图谱。`, alignmentRuleImplies: (property: string) => `同时蕴含 ${property}`, alignmentRuleObjectIsStatement: "宾语:陈述自己的宾语", alignmentRuleReading: (reading: string) => `宾语:按「${reading.replace(/_/g, " ")}」从字里读出`, diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index 9a29ff49a..515261821 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -1,4 +1,5 @@ import { alignmentErrorMessage } from "./reviewErrors"; +import { asksMarks, phraseStart } from "./alignmentPhrase"; import { useEffect, useState } from "react"; import { LayoutDashboard } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -12,6 +13,7 @@ import { type ReviewTypeFilter, type OntologyDefect, type AlignmentItem, + type AlignmentMarks, type ErrataItem, type EntityTypeView, type RelationTypeView, @@ -701,10 +703,22 @@ function DecisionRow({ e }: { e: ReviewHistoryEvent }) { * 因为人要判断的正是它对不对。概念名与源是身份,unit 是答里必须带的量纲。 */ /** 本体自己的一处自相矛盾。**两个按钮而不是三个**——这一档压根没看数据, * 所以没有「数据错了」这条出路,只能是「我去改了本体」或「先放着」。 */ -function voteText(v: { property: string; direction: string } | string | null | undefined): string { +function voteText( + v: { property: string; direction: string; marks?: AlignmentMarks | null } | string | null | undefined, +): string { if (v === undefined || v === null) return S.review.alignmentNone; if (typeof v === "string") return v; - return `${v.property} · ${v.direction === "reverse" ? S.review.alignmentReverse : S.review.alignmentForward}`; + const side = v.direction === "reverse" ? S.review.alignmentReverse : S.review.alignmentForward; + const marks = v.marks ? ` · ${S.review.alignmentMarks} ${marksText(v.marks)}` : ""; + return `${v.property} · ${side}${marks}`; +} + +function marksText(m: AlignmentMarks): string { + return m === "start" + ? S.review.alignmentMarksStart + : m === "end" + ? S.review.alignmentMarksEnd + : S.review.alignmentMarksNone; } /** 对齐器提的一条蕴含规则(0044 决定 3 第五片):这种形状还蕴含哪条属性、宾语怎么来;人批或驳 */ @@ -791,7 +805,8 @@ function AlignmentRuleRow({ ); } -/** 一条短语签名:短语、两端的类、例句、两票;人选属性与方向,或「没有」 */ +/** 一条短语签名:短语、两端的类、例句、两票;人选属性与方向,或「没有」。 + * 选的是状态属性时,还要说单个日期标的是开始、结束,还是都不是(#966) */ function AlignmentPhraseRow({ item, properties, @@ -801,18 +816,23 @@ function AlignmentPhraseRow({ item: Extract; properties: RelationTypeView[]; busy: boolean; - onDecide: (property: string | null, direction: "forward" | "reverse") => void; + onDecide: ( + property: string | null, + direction: "forward" | "reverse", + marks: AlignmentMarks | null, + ) => void; }) { const first = item.votes?.first ?? null; const second = item.votes?.second ?? null; - const [property, setProperty] = useState(first?.property ?? second?.property ?? ""); - const [direction, setDirection] = useState<"forward" | "reverse">( - first?.direction ?? second?.direction ?? "forward", - ); + const start = phraseStart(item); + const [property, setProperty] = useState(start.property); + const [direction, setDirection] = useState<"forward" | "reverse">(start.direction); + const [marks, setMarks] = useState(start.marks); // 字面值当宾语的签名只配属性(attribute),两样东西之间的只配关系(relation) const fitting = properties.filter((p) => item.object_is_value ? p.kind === "attribute" : p.kind === "relation", ); + const state = asksMarks(fitting, property); return (
@@ -829,9 +849,15 @@ function AlignmentPhraseRow({ ))}
)} -
- {S.review.alignmentVotes(voteText(first), voteText(second))} -
+ {item.bound_to ? ( +
+ {S.review.alignmentAwaitsMarks(item.bound_to)} +
+ ) : ( +
+ {S.review.alignmentVotes(voteText(first), voteText(second))} +
+ )} {/* 候选多到没问模型的签名(0053):说清是这个原因,不是两票都投了空 */} {item.votes?.reason === "too_many_candidates" && (
@@ -860,7 +886,38 @@ function AlignmentPhraseRow({ ]} /> )} -
@@ -1445,11 +1502,13 @@ export function Review() { id, property, direction, + marks, }: { id: string; property: string | null; direction: "forward" | "reverse"; - }) => api.decideAlignmentPhrase(kb!.id, id, property, direction), + marks: AlignmentMarks | null; + }) => api.decideAlignmentPhrase(kb!.id, id, property, direction, marks), // 202:判定收下了,类型化图谱在后台重算;算完 `review` / `graph` 事件会把 // 队列和图刷一遍,这里只告诉人「已保存」,不编一个数字出来 onSuccess: () => toast.success(S.review.alignmentAccepted), @@ -2051,8 +2110,8 @@ export function Review() { alignmentPhraseAction.isPending && alignmentPhraseAction.variables?.id === item.id } - onDecide={(property, direction) => - alignmentPhraseAction.mutate({ id: item.id, property, direction }) + onDecide={(property, direction, marks) => + alignmentPhraseAction.mutate({ id: item.id, property, direction, marks }) } /> ) : item.kind === "rule" ? ( diff --git a/web/src/pages/alignmentPhrase.test.ts b/web/src/pages/alignmentPhrase.test.ts new file mode 100644 index 000000000..7074fd4c8 --- /dev/null +++ b/web/src/pages/alignmentPhrase.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AlignmentItem, RelationTypeView } from "../api"; +import { asksMarks, phraseStart } from "./alignmentPhrase"; + +type PhraseItem = Extract; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +const item = (over: Partial): PhraseItem => ({ + kind: "phrase", + id: "binding", + phrase: "joined", + subject_class: "person", + object_class: "organization", + object_is_value: false, + statement_count: 2, + examples: [], + votes: null, + decided_at: "2026-09-27T00:00:00Z", + bound_to: null, + direction: null, + ...over, +}); +const property = (key: string, temporal: string) => ({ key, temporal }) as RelationTypeView; + +describe("what a single date marks", () => { + it("is asked only under a state property", () => { + const properties = [ + property("works_for", "state"), + property("acquired", "event"), + property("born_in", "eternal"), + ]; + expect(asksMarks(properties, "works_for")).toBe(true); + expect(asksMarks(properties, "acquired")).toBe(false); + expect(asksMarks(properties, "born_in")).toBe(false); + expect(asksMarks(properties, "")).toBe(false); + }); + + it("starts from the first vote, then a person's binding, and reads an unsaid date as neither", () => { + expect( + phraseStart( + item({ + votes: { + first: { property: "works_for", direction: "forward", marks: "start" }, + second: { property: "works_for", direction: "forward", marks: "end" }, + }, + }), + ), + ).toEqual({ property: "works_for", direction: "forward", marks: "start" }); + expect( + phraseStart(item({ votes: { first: null, second: { property: "leads", direction: "reverse" } } })), + ).toEqual({ property: "leads", direction: "reverse", marks: "none" }); + // 人绑过、只差 marks 的:按现在的绑定预选 + expect(phraseStart(item({ bound_to: "works_for", direction: "reverse" }))).toEqual({ + property: "works_for", + direction: "reverse", + marks: "none", + }); + expect(phraseStart(item({}))).toEqual({ property: "", direction: "forward", marks: "none" }); + }); +}); + +for (const lang of ["en", "zh"]) { + describe(`deciding a phrase (${lang})`, () => { + it("sends what a single date marks with the decision", async () => { + vi.stubGlobal("localStorage", { getItem: () => lang }); + const fetch = vi.fn(async () => Response.json({ ok: true, job_id: 1, status: "accepted" }, { status: 202 })); + vi.stubGlobal("fetch", fetch); + const { api } = await import("../api"); + await api.decideAlignmentPhrase("kb", "binding", "works_for", "forward", "start"); + await api.decideAlignmentPhrase("kb", "binding", "acquired", "forward", null); + const bodies = fetch.mock.calls.map((call) => { + const [path, init] = call as unknown as [string, RequestInit]; + expect(path).toBe("/api/v1/kbs/kb/review/alignment/phrases/binding"); + return JSON.parse(String(init.body)); + }); + expect(bodies).toEqual([ + { property: "works_for", direction: "forward", marks: "start" }, + { property: "acquired", direction: "forward", marks: null }, + ]); + }); + + it("words the refusals a single date can meet", async () => { + vi.stubGlobal("localStorage", { getItem: () => lang }); + const { S } = await import("../i18n"); + const { api } = await import("../api"); + const worded = S.err as Record; + for (const code of ["empty_state_span", "unknown_marks", "marks_needs_state"]) { + expect(worded[code], code).toMatch(/\S/); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ error: "server wording", code }, { status: 422 })), + ); + await expect( + api.decideAlignmentPhrase("kb", "binding", "works_for", "forward", "start"), + ).rejects.toThrow(worded[code]); + } + }); + }); +} diff --git a/web/src/pages/alignmentPhrase.ts b/web/src/pages/alignmentPhrase.ts new file mode 100644 index 000000000..7ca01ebd2 --- /dev/null +++ b/web/src/pages/alignmentPhrase.ts @@ -0,0 +1,25 @@ +import type { AlignmentItem, AlignmentMarks, RelationTypeView } from "../api"; + +type PhraseItem = Extract; + +/** 一条短语签名打开时预选什么。人绑过、只差 marks 的按现在的绑定;否则按第一票、第二票。 + * 没有票说过单个日期标什么就取「都不是」:只带一个日期的陈述留在开放图谱,是不猜的 + * 那个读法(#966) */ +export function phraseStart(item: PhraseItem): { + property: string; + direction: "forward" | "reverse"; + marks: AlignmentMarks; +} { + const first = item.votes?.first ?? null; + const second = item.votes?.second ?? null; + return { + property: item.bound_to ?? first?.property ?? second?.property ?? "", + direction: item.direction ?? first?.direction ?? second?.direction ?? "forward", + marks: first?.marks ?? second?.marks ?? "none", + }; +} + +/** 选中的属性是状态时才问单个日期标什么:事件与恒常没有这一问,服务端也不收 */ +export function asksMarks(properties: RelationTypeView[], key: string): boolean { + return properties.find((p) => p.key === key)?.temporal === "state"; +} From c8b7d5960b1afab9b2615d31acf1d9d22d41ee47 Mon Sep 17 00:00:00 2001 From: dada-yan Date: Sun, 27 Sep 2026 18:00:48 +1000 Subject: [PATCH 2/6] The CLI's schema version counts 0096 from dev as well as this branch's 0097 Signed-off-by: dada-yan --- crates/utopia-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index 3e341fc43..47e8fb19c 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 87; +const CURRENT_SCHEMA_VERSION: u32 = 88; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); From fd5a220fc2ba6101be1200fcbb79131dd447a988 Mon Sep 17 00:00:00 2001 From: dada-yan Date: Sun, 27 Sep 2026 17:50:42 +1000 Subject: [PATCH 3/6] The design files say that a state never holds at no moment and what a single date under a state means Signed-off-by: dada-yan --- docs/design/ontology.md | 5 ++++- docs/design/time.md | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/design/ontology.md b/docs/design/ontology.md index cad1702cf..f2fe0807c 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -77,7 +77,10 @@ running, so the order is vectors, then kind words, then phrases. The shortlist i decision's basis, so a changed list re-asks. The candidate properties are described once per batch and each item names only its keys. 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`. Candidates are the properties whose +workbench's suggestions. Bindings live in `phrase_bindings`. A binding to a state property also +says what a statement dated at a single moment marks (`marks`: start, end or none): both votes must +agree on it, a vote without it is asked again, and a person sets it in the alignment queue, where a +person's binding still without it waits [0053 revision, #966]. 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 diff --git a/docs/design/time.md b/docs/design/time.md index c1d19c430..348134c23 100644 --- a/docs/design/time.md +++ b/docs/design/time.md @@ -52,7 +52,16 @@ a conflict; a succession is not, for the consistency check and for the merge gat ending is dropped); an eternal fact has no dates; a state keeps its interval. Reads give an event the bucket its precision names, an undated event no moment, an eternal fact every moment; a fact with no predicate reads as a state [0031]. Rows written before the rule are read correctly and not -rewritten. The `[event]` / `[eternal]` prompt marks of 0031 d4 and the zoned-time sentence of 0024 +rewritten. + +**A state never holds at no moment.** Under a property declared as a state, a row whose two ends +are equal is refused (`empty_state_span`): `Validity::under` refuses the write or the interval +correction, and closing a state at its own start is refused the same way. A row without a property +keeps a single date. A statement dated at a single moment and bound to a state is read by its +binding's `marks`: `start` opens the state there, `end` closes the open row there, and `none` or no +value computes no typed row, so the statement stays in the open graph with its date. Materialized +state rows with equal ends written before this are retired and computed again [0053 revision, +#966]. The `[event]` / `[eternal]` prompt marks of 0031 d4 and the zoned-time sentence of 0024 belonged to the typed contract and are gone with it; the write rule in the store is what remains. **Derived rows** store the interval as read: the intersection of premise read intervals, an anchored From 184ed5d6f904c9c1d7679b44147b6a42549d55ab Mon Sep 17 00:00:00 2001 From: dada-yan Date: Sun, 27 Sep 2026 22:59:57 +1000 Subject: [PATCH 4/6] A binding is asked once what a single date marks and is never reopened by the question, a coarse end closes a row that starts inside its period, and only rows copied from a single date are retired Signed-off-by: dada-yan --- crates/utopia-extract/src/phrase_align.rs | 54 ++- crates/utopia-server/src/api/review_routes.rs | 1 + .../src/api/review_routes_phrase_tests.rs | 1 + crates/utopia-server/src/ontology_agent.rs | 1 + .../utopia-server/src/ontology_agent_tests.rs | 1 + crates/utopia-server/src/phrase_alignment.rs | 123 ++++-- .../src/phrase_alignment_tests.rs | 289 +++++++----- crates/utopia-store/src/alignment_queue.rs | 14 +- crates/utopia-store/src/graph.rs | 71 +-- crates/utopia-store/src/materialize.rs | 43 +- .../src/materialize_delivery_tests.rs | 1 + crates/utopia-store/src/phrase_bindings.rs | 69 ++- .../src/phrase_bindings_delivery_tests.rs | 2 + .../human_phrase_materialization_delivery.rs | 2 + .../tests/store/a_moment_marks_its_state.rs | 418 +++++++++++++++++- .../store/a_plan_step_follows_its_premise.rs | 1 + .../tests/store/materialization_is_serial.rs | 1 + .../store/negative_binding_definition_edit.rs | 1 + ...1-an-event-holds-at-the-moment-it-names.md | 4 +- ...cision-records-the-inputs-it-considered.md | 87 ++-- docs/design/ontology.md | 8 +- docs/design/time.md | 15 +- ..._binding_says_which_end_a_moment_marks.sql | 7 +- web/src/i18n/en.ts | 3 +- web/src/i18n/zh.ts | 3 +- web/src/pages/Review.tsx | 19 +- web/src/pages/alignmentPhrase.test.ts | 27 +- web/src/pages/alignmentPhrase.ts | 10 +- 28 files changed, 1017 insertions(+), 259 deletions(-) diff --git a/crates/utopia-extract/src/phrase_align.rs b/crates/utopia-extract/src/phrase_align.rs index 0177a1963..03ae9b016 100644 --- a/crates/utopia-extract/src/phrase_align.rs +++ b/crates/utopia-extract/src/phrase_align.rs @@ -14,7 +14,8 @@ //! 只对状态属性有意义(#966):一条只说了一刻的陈述,那一刻标的是状态的开始(start)、 //! 结束(end)还是都不是(none);事件、恒常、不绑都答 null,旧的三格写法照样读(marks //! 缺席)。解析同类别词那边:坏的一条计数、不毁掉整批;键不在候选里、id 不在批里、绑了 -//! 却没方向、marks 写了却认不出、同一个 id 的第二次都算坏;没答到的 id 是「再问」,不是 null。 +//! 却没方向、状态属性下 marks 写了却认不出、同一个 id 的第二次都算坏;事件与恒常不读第四格, +//! 写了什么都不算坏。没答到的 id 是「再问」,不是 null。 //! //! **形状也宽容**(同类别词那边的教训):模型(实测 DeepSeek-V3.2)并不总照样例写。它会 //! 把整段答成按 id 作键的对象(`{"0": ["headquartered_in", "forward"], "1": null}`), @@ -262,11 +263,16 @@ pub fn parse_phrase_response( ) -> anyhow::Result<(Vec, usize)> { let value = parse_value(raw)?; let by_id: HashMap> = items.iter().map(|i| (i.id, i)).collect(); + // 批里描述过的属性各是什么时间语义:只有状态读第四格 + let temporal: HashMap<&str, &str> = items + .iter() + .flat_map(|i| i.candidates.iter().map(|c| (c.key.trim(), c.temporal))) + .collect(); let mut choices = Vec::new(); let mut malformed = 0usize; let mut seen = HashSet::new(); for (id, key, direction, marks) in answers(&value) { - match parse_answer(&id, &key, &direction, &marks, &by_id) { + match parse_answer(&id, &key, &direction, &marks, &by_id, &temporal) { Some(choice) if seen.insert(choice.id) => choices.push(choice), _ => malformed += 1, } @@ -359,13 +365,15 @@ fn fields(map: &serde_json::Map) -> (Value, Value, Value) { /// 一条答案:id 得是这批里的;键是 null(不绑)或候选里的一个,绑了就得有方向,方向 /// 不认识算坏。键包在数组里的(`["headquartered_in", "forward"]` 塞在第二格)照样读, -/// 方向与 marks 从数组里取。marks 只随绑定读:null 或缺席是没说,写了却认不出算坏 +/// 方向与 marks 从数组里取。marks 只在绑到状态属性时读:null 或缺席是没说,写了却认不出 +/// 算坏;事件、恒常(或批里没描述过的键)不读这一格,写了什么都忽略 fn parse_answer( id: &Value, key: &Value, direction: &Value, marks: &Value, by_id: &HashMap>, + temporal: &HashMap<&str, &str>, ) -> Option { let id = item_id(id)?; let item = by_id.get(&id)?; @@ -395,10 +403,13 @@ fn parse_answer( } _ => return None, }; - let marks = match (&property, &marks) { - (None, _) | (_, Value::Null) => None, - (Some(_), Value::String(written)) => Some(Marks::parse(written)?), - (Some(_), _) => return None, + let state = property + .as_ref() + .is_some_and(|(key, _)| temporal.get(key.as_str()) == Some(&"state")); + let marks = match (state, &marks) { + (false, _) | (_, Value::Null) => None, + (true, Value::String(written)) => Some(Marks::parse(written)?), + (true, _) => return None, }; Some(PhraseChoice { id, @@ -775,6 +786,35 @@ mod tests { assert_eq!(malformed, 1, "an unreadable marks is a bad answer"); } + /// 事件与恒常不读第四格:写了认不出的值不算坏项,写了认得出的也不带。状态属性下 + /// 认不出照样算坏 + #[test] + fn an_event_property_does_not_read_the_fourth_value() { + let (examples, quotes) = (strings(&[]), strings(&[])); + let mut items = three_items(&examples, "es); + items[0].candidates.push(PropertyCandidate { + key: "acquired", + label: "acquired", + description: "The organization bought the other organization.", + kind: "relation", + temporal: "event", + domains: vec!["organization"], + ranges: vec!["organization"], + via: Vec::new(), + }); + let raw = + r#"{"b": [[0, "acquired", "forward", "later"], [2, "revenue", "forward", "later"]]}"#; + let (choices, malformed) = parse_phrase_response(raw, &items).unwrap(); + assert_eq!(choices, vec![bound(0, "acquired", Direction::Forward)]); + assert_eq!(malformed, 1, "revenue is a state: its fourth value is read"); + let raw = r#"{"b": [[0, "acquired", "forward", "start"]]}"#; + let (choices, malformed) = parse_phrase_response(raw, &items).unwrap(); + assert_eq!( + (choices, malformed), + (vec![bound(0, "acquired", Direction::Forward)], 0) + ); + } + /// 提示词说出每条属性是不是状态,以及第四格怎么答 #[test] fn the_prompt_says_which_properties_are_states_and_asks_for_the_fourth_value() { diff --git a/crates/utopia-server/src/api/review_routes.rs b/crates/utopia-server/src/api/review_routes.rs index a80337c6c..caf8f1f08 100644 --- a/crates/utopia-server/src/api/review_routes.rs +++ b/crates/utopia-server/src/api/review_routes.rs @@ -1361,6 +1361,7 @@ pub async fn decide_alignment_phrase( decided_by: "person", basis: None, marks, + marks_asked: false, }, ) .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 a97a5f67b..cfd690a60 100644 --- a/crates/utopia-server/src/api/review_routes_phrase_tests.rs +++ b/crates/utopia-server/src/api/review_routes_phrase_tests.rs @@ -76,6 +76,7 @@ impl Fx { decided_by: "agent", basis: None, marks: None, + marks_asked: false, }, ) .await?; diff --git a/crates/utopia-server/src/ontology_agent.rs b/crates/utopia-server/src/ontology_agent.rs index 87c1b9987..e00e7d983 100644 --- a/crates/utopia-server/src/ontology_agent.rs +++ b/crates/utopia-server/src/ontology_agent.rs @@ -1085,6 +1085,7 @@ async fn adopt_map_to( decided_by: "person", basis: None, marks: None, + marks_asked: false, }, ) .await?; diff --git a/crates/utopia-server/src/ontology_agent_tests.rs b/crates/utopia-server/src/ontology_agent_tests.rs index f903b4316..fa80992eb 100644 --- a/crates/utopia-server/src/ontology_agent_tests.rs +++ b/crates/utopia-server/src/ontology_agent_tests.rs @@ -149,6 +149,7 @@ impl Fx { decided_by: "agent", basis: Some("test"), marks: None, + marks_asked: false, }, ) .await?; diff --git a/crates/utopia-server/src/phrase_alignment.rs b/crates/utopia-server/src/phrase_alignment.rs index d56777d8b..960f843c9 100644 --- a/crates/utopia-server/src/phrase_alignment.rs +++ b/crates/utopia-server/src/phrase_alignment.rs @@ -30,16 +30,17 @@ const BATCH: usize = 12; /// 筛过定义域/值域之后候选属性最多这么多,再多就不判。 const CANDIDATE_LIMIT: usize = 60; -/// 一票:这条签名选了哪个属性、哪个方向(None = 没有属性对得上)。 -/// 一票:属性的键、方向,以及属性是状态时一刻标哪一端(#966) +/// 一票:这条签名选了哪个属性、哪个方向,属性是状态时还有一刻标哪一端(#966)。 +/// None = 没有属性对得上 type Vote = Option<(String, Direction, Option)>; -/// 代理判过、绑到状态属性、却还没说一刻标哪一端的绑定(#966 之前的判定):即使指纹没变也 -/// 再问一次,让对齐器补上。只进开跑时的 todo,不进收尾的「变了没有」——模型一直不答 -/// marks 时不能每轮都给自己排一次 +/// 代理绑到状态属性、却从没问过一刻标哪一端的绑定(这一列之前的判定,#966):指纹没变也问 +/// 一次,只问这一格,不重判绑定(0053 修订 2026-09-27)。问过的不再问,没得到值的列进对齐 +/// 队列等人。只进开跑时的 todo,不进收尾的「变了没有」 fn awaits_marks(b: &phrase_bindings::Binding, props: &[RelationTypeView]) -> bool { b.status == "bound" && b.marks.is_none() + && b.marks_asked_at.is_none() && b.relation_type_id .and_then(|id| props.iter().find(|p| p.id == id)) .is_some_and(|p| p.temporal == "state") @@ -461,7 +462,7 @@ async fn align_phrases_locked( let considered = consider(&sigs, &props, &closure, &versions, Some(&short)); // 过期 = 存下的指纹和此刻的不一样(没有指纹的是这一列出现前判的,各重判一次)。 // 不再按时间戳:父边的增删、请求途中的编辑(#795)时间戳看不见。人的判定不重判。 - // 绑到状态属性却没说一刻标哪一端的代理判定也再问(`awaits_marks`,#966) + // 绑到状态属性、从没问过一刻标哪一端的代理判定也问(`awaits_marks`,#966) let todo: Vec<&PhraseSignature> = sigs .iter() .filter(|s| match existing.get(&s.key()) { @@ -474,6 +475,16 @@ async fn align_phrases_locked( }) .collect(); let attempted: HashSet<_> = todo.iter().map(|s| s.key()).collect(); + // 其中指纹没变、只为补问 marks 进来的:这一问只能写 marks,绑定原样(0053 修订 2026-09-27)。 + // 从前整条重判,两票没选属性就把绑上的签名判成 none,它的类型化行跟着作废 + let marks_only: HashMap<_, &phrase_bindings::Binding> = todo + .iter() + .filter_map(|s| { + let b = existing.get(&s.key())?; + (b.basis.as_deref() == Some(considered[&s.key()].1.as_str()) && awaits_marks(b, &props)) + .then_some((s.key(), b)) + }) + .collect(); tracing::info!(%kb_id, signatures = sigs.len(), to_decide = todo.len(), properties = props.len(), "短语对齐开始"); // 没有属性可绑:每条都是「没有」;属性出现后指纹变了,它们会再交回来 @@ -491,6 +502,7 @@ async fn align_phrases_locked( decided_by: "agent", basis: Some(&considered[&s.key()].1), marks: None, + marks_asked: false, }, ) .await?; @@ -508,14 +520,22 @@ async fn align_phrases_locked( let mut failed = 0usize; // 问了、模型也答了、却没答到的签名:两票缺一票就不下结论 let mut unanswered = 0usize; + // marks:补问写下了值的;问过却没得到两票同一个值、列进对齐队列的 + let (mut marked, mut unsettled) = (0usize, 0usize); // 批与批并行([`PARALLEL_BATCHES`] 个在飞,模型闸门再限一次):一批两票串行要等模型 // 想两回,串着跑 22 批就是半小时,其中一次卡住的调用能把整轮拖住 18 分钟(bench README, // 2026-09-24)。每批各记各的数,回来再加 { use futures_util::StreamExt; // 只把引用搬进各批的 future - let (considered, full, closure, class_key, by_key) = - (&considered, &full, &closure, &class_key, &by_key); + let (considered, full, closure, class_key, by_key, marks_only) = ( + &considered, + &full, + &closure, + &class_key, + &by_key, + &marks_only, + ); // 先把每批的 future 造出来再排队:直接在 map 里返回 async 块会让借用的生命周期 // 满足不了 tokio::spawn 要的 Send let futures: Vec<_> = todo @@ -523,6 +543,7 @@ async fn align_phrases_locked( .map(|batch| async move { let (mut bound, mut none, mut undecided, mut skipped, mut failed, mut unanswered) = (0usize, 0usize, 0usize, 0usize, 0usize, 0usize); + let (mut marked, mut unsettled) = (0usize, 0usize); // 候选超过上限的不问模型:记成 undecided 交给人,指纹照记——属性少下去指纹就变, // 到时再问。从前超限和无候选一样静默跳过,签名永远排着又永远不可执行(#807) let cands: Vec> = batch @@ -538,6 +559,8 @@ async fn align_phrases_locked( .collect(); let mut votes: Vec<(Vote, Vote)> = vec![(None, None); batch.len()]; let mut answered = vec![(false, false); batch.len()]; + // 读得出回复的遍数:补问 marks 时,两遍的回复都回来了才算问过 + let mut replied = 0usize; for pass in 0..2 { let items: Vec> = batch .iter() @@ -635,6 +658,7 @@ async fn align_phrases_locked( failed += 1; continue; } + replied += 1; if malformed > 0 { // 坏票长什么样得看得见:第一次真跑里一半签名被判坏票,查了一天才知道模型答的是标签 tracing::info!(%kb_id, malformed, reply = %snippet(&reply.text), "短语对齐的回复里有坏票"); @@ -657,6 +681,36 @@ async fn align_phrases_locked( } for (i, s) in batch.iter().enumerate() { let basis = considered[&s.key()].1.as_str(); + if let Some(asked) = marks_only.get(&s.key()) { + // 补问 marks(0053 修订 2026-09-27):两票都认这条绑定的属性与方向、又说了同一个 + // 值,写下它;别的答案——没选属性、选了别的、没说或说得不一样——都让绑定原样。 + // 两种都记下问过:不再问,没有值的列进对齐队列等人。回复没回来不算问过, + // 下次再问(同没答到的签名);问不了的(候选超限)直接交给人 + if !cands[i].is_empty() && replied < 2 { + unanswered += 1; + continue; + } + // 这一票认的是这条绑定,又说了值:认了就是它说的值 + let value_of = |v: &Vote| match v { + Some((k, d, m)) => m.filter(|_| { + by_key.get(k.as_str()).map(|p| p.id) == asked.relation_type_id + && asked.direction.as_deref() == Some(d.as_str()) + }), + None => None, + }; + let (a, b) = &votes[i]; + let value = value_of(a).filter(|m| value_of(b) == Some(*m)); + if phrase_bindings::record_marks(pool, kb_id, asked, value.map(Marks::as_str)) + .await? + { + if value.is_some() { + marked += 1; + } else { + unsettled += 1; + } + } + continue; + } if cands[i].is_empty() { let fitting = considered[&s.key()].0.len(); // 两种「没问模型」各自落库,投影才退得掉、队列才收得住: @@ -683,6 +737,7 @@ async fn align_phrases_locked( decided_by: "agent", basis: Some(basis), marks: None, + marks_asked: false, }, ) .await? @@ -718,18 +773,7 @@ async fn align_phrases_locked( (None, None) => true, _ => false, }; - // 属性是状态时,两票还得说出一刻标哪一端(#966):有一票没说是没答到,下次再问; - // 两票说得不一样和选了不同的属性一样,交给人。事件与恒常不问这一格 - let state = a - .as_ref() - .and_then(|(k, _, _)| by_key.get(k.as_str())) - .is_some_and(|p| p.temporal == "state"); - let marks_of = |v: &Vote| v.as_ref().and_then(|(_, _, m)| *m); - if agree && state && (marks_of(a).is_none() || marks_of(b).is_none()) { - unanswered += 1; - continue; - } - if !agree || (state && marks_of(a) != marks_of(b)) { + if !agree { phrase_bindings::decide( pool, kb_id, @@ -742,17 +786,30 @@ async fn align_phrases_locked( decided_by: "agent", basis: Some(basis), marks: None, + marks_asked: false, }, ) .await?; undecided += 1; continue; } + // 属性是状态时,两票还说一刻标哪一端(#966):说了同一个值才写下它。没说、或说得 + // 不一样,绑定照绑、这一格空着,记下问过——不再问,列进对齐队列等人(0053 修订 + // 2026-09-27)。从前没说的算没答到,模型一直只答三格时每轮重问、永远绑不上。 + // 事件与恒常不问这一格 + let state = a + .as_ref() + .and_then(|(k, _, _)| by_key.get(k.as_str())) + .is_some_and(|p| p.temporal == "state"); + let marks = match (a, b) { + (Some((_, _, ma)), Some((_, _, mb))) if state && ma == mb => *ma, + _ => None, + }; match a .as_ref() - .and_then(|(k, d, m)| by_key.get(k.as_str()).map(|p| (p, *d, *m))) + .and_then(|(k, d, _)| by_key.get(k.as_str()).map(|p| (p, *d))) { - Some((p, d, m)) => { + Some((p, d)) => { if phrase_bindings::decide( pool, kb_id, @@ -764,12 +821,16 @@ async fn align_phrases_locked( votes: &record, decided_by: "agent", basis: Some(basis), - marks: if state { m.map(Marks::as_str) } else { None }, + marks: marks.map(Marks::as_str), + marks_asked: state, }, ) .await? { bound += 1; + if state && marks.is_none() { + unsettled += 1; + } } } None => { @@ -785,6 +846,7 @@ async fn align_phrases_locked( decided_by: "agent", basis: Some(basis), marks: None, + marks_asked: false, }, ) .await? @@ -795,21 +857,26 @@ async fn align_phrases_locked( } } - Ok::<_, anyhow::Error>((bound, none, undecided, skipped, failed, unanswered)) + Ok::<_, anyhow::Error>(( + (bound, none, undecided, skipped, failed, unanswered), + (marked, unsettled), + )) }) .collect(); let mut results = futures_util::stream::iter(futures).buffer_unordered(PARALLEL_BATCHES); while let Some(r) = results.next().await { - let (b, n, u, sk, f, un) = r?; + let ((b, n, u, sk, f, un), (m, us)) = r?; bound += b; none += n; undecided += u; skipped += sk; failed += f; unanswered += un; + marked += m; + unsettled += us; } } - tracing::info!(%kb_id, bound, none, undecided, skipped, failed, unanswered, "短语对齐完成"); + tracing::info!(%kb_id, bound, none, undecided, skipped, failed, unanswered, marked, unsettled, "短语对齐完成"); if unanswered > 0 { tracing::warn!(%kb_id, unanswered, "短语对齐有签名模型没答到,这些签名这轮没有结论"); } @@ -831,6 +898,10 @@ async fn align_phrases_locked( .collect(); let mut asks: Vec> = Vec::new(); for s in &todo { + // 只补问了 marks 的没有重判:指纹没变,它的规则上一轮问过 + if marks_only.contains_key(&s.key()) { + continue; + } let Some(b) = decided_now.get(&s.key()) else { continue; }; diff --git a/crates/utopia-server/src/phrase_alignment_tests.rs b/crates/utopia-server/src/phrase_alignment_tests.rs index 00026a8e1..a63f0a049 100644 --- a/crates/utopia-server/src/phrase_alignment_tests.rs +++ b/crates/utopia-server/src/phrase_alignment_tests.rs @@ -633,6 +633,7 @@ async fn a_person_decision_made_during_the_request_is_not_overwritten() -> anyho decided_by: "person", basis: None, marks: None, + marks_asked: false, }, ) .await?; @@ -909,89 +910,104 @@ async fn a_state_binding_carries_what_a_single_date_marks() -> anyhow::Result<() run } -/// 两票选了同一个属性、同一个方向,却一票说开始、一票说结束:和选了不同的属性一样,交给人 +/// 这条签名在对齐队列里:绑到 based_in、照原文方向,只等人说那个日期标什么 +async fn waits_for_the_date(f: &Fx) -> anyhow::Result { + let items = utopia_store::alignment_queue::list(&f.pool, f.kb, 10, 0).await?; + let listed = matches!( + items.as_slice(), + [utopia_store::alignment_queue::AlignmentItem::Phrase { + bound_to: Some(p), + direction: Some(d), + .. + }] if p == "based_in" && d == "forward" + ); + let waiting = utopia_store::alignment_queue::waiting(&f.pool, f.kb) + .await? + .0; + let badge = utopia_store::review::counts(&f.pool, f.kb).await?.alignment; + Ok(listed && waiting == 1 && badge == 1) +} + +/// 两票选了同一个属性、同一个方向,却一票说开始、一票说结束,或有一票没说(只答三格): +/// 属性两票说定了,照绑;那个日期没说定,marks 空着、记下问过,交给人。不再问,也不给 +/// 自己再排一轮(0053 修订 2026-09-27)。从前没说的算没答到,模型只答三格就永远绑不上 #[tokio::test] -async fn two_votes_that_read_the_date_differently_go_to_a_person() -> anyhow::Result<()> { - let Some(f) = Fx::new().await? else { - return Ok(()); - }; - let run = async { - f.script(vec![ - marked(Some("based_in"), Some("forward"), Some("start")), +async fn two_votes_that_do_not_agree_on_the_date_bind_and_leave_it_to_a_person( +) -> anyhow::Result<()> { + let three_cells = json!({"b":[[0, "based_in", "forward"]]}); + for (label, first) in [ + ( + "end", marked(Some("based_in"), Some("forward"), Some("end")), - ]); - f.run().await?; - let b = f.binding().await?; - assert_eq!((b.status.as_str(), b.marks.as_deref()), ("undecided", None)); - let votes: Value = sqlx::query_scalar("SELECT votes FROM phrase_bindings WHERE kb_id=$1") - .bind(f.kb) - .fetch_one(&f.pool) - .await?; - assert_eq!( - (&votes["first"]["marks"], &votes["second"]["marks"]), - (&json!("start"), &json!("end")), - "both readings are kept for the person" - ); - let items = utopia_store::alignment_queue::list(&f.pool, f.kb, 10, 0).await?; - assert!( - matches!( - items.as_slice(), - [utopia_store::alignment_queue::AlignmentItem::Phrase { bound_to: None, .. }] - ), - "{items:?}" - ); - assert!(!f.requeued().await?); - anyhow::Ok(()) + ), + ("three cells", three_cells), + ] { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(vec![ + first, + marked(Some("based_in"), Some("forward"), Some("start")), + ]); + f.run().await?; + let b = f.binding().await?; + assert_eq!( + (b.status.as_str(), b.relation_type_id, b.marks.as_deref()), + ("bound", Some(f.based_in), None), + "{label}" + ); + assert!(b.marks_asked_at.is_some(), "{label}: asked"); + let votes: Value = + sqlx::query_scalar("SELECT votes FROM phrase_bindings WHERE kb_id=$1") + .bind(f.kb) + .fetch_one(&f.pool) + .await?; + assert_eq!( + &votes["second"]["marks"], + &json!("start"), + "{label}: both readings are kept for the person" + ); + assert_eq!(f.typed().await?, 1, "{label}: the statement is typed"); + assert!(waits_for_the_date(&f).await?, "{label}"); + assert!(!f.requeued().await?, "{label}: no re-ask"); + f.run().await?; + assert_eq!(f.requests().len(), 2, "{label}: asked once"); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run?; } - .await; - f.cleanup().await?; - run + Ok(()) } -/// 一票绑了状态却没说那个日期标什么:这一票没答完,不下结论,有限次地再问 -#[tokio::test] -async fn a_state_vote_that_leaves_the_date_unread_is_asked_again() -> anyhow::Result<()> { - let Some(f) = Fx::new().await? else { - return Ok(()); - }; - let run = async { - f.script(vec![ - json!({"b":[[0, "based_in", "forward"]]}), - marked(Some("based_in"), Some("forward"), Some("start")), - ]); - f.run().await?; - assert_eq!(f.requests().len(), 2); - assert!( - phrase_bindings::bindings(&f.pool, f.kb).await?.is_empty(), - "no decision without both readings" - ); - let reasks: Vec> = sqlx::query_scalar( - "SELECT (payload->>'reask')::bigint FROM jobs - WHERE kind='align_phrases' AND status='queued' AND payload->>'kb_id'=$1", +impl Fx { + /// 把绑定改回这一问出现之前的样子:没有 marks,也没问过 + async fn as_before_the_question(&self) -> anyhow::Result<()> { + sqlx::query( + "UPDATE phrase_bindings SET marks = NULL, marks_asked_at = NULL WHERE kb_id=$1", ) - .bind(f.kb.to_string()) - .fetch_all(&f.pool) + .bind(self.kb) + .execute(&self.pool) .await?; - assert_eq!(reasks, vec![Some(1)], "it queues itself once more"); - f.clear_jobs().await?; - f.script(vec![ - marked(Some("based_in"), Some("forward"), Some("start")), - marked(Some("based_in"), Some("forward"), Some("start")), - ]); - align_phrases_reasking(&f.state, f.kb, 1).await?; - let b = f.binding().await?; - assert_eq!( - (b.status.as_str(), b.marks.as_deref()), - ("bound", Some("start")) - ); - anyhow::Ok(()) + Ok(()) + } + /// 绑定里补问不该动的部分:属性、方向、状态、谁判的、何时判的、指纹、两票 + async fn decision(&self) -> anyhow::Result { + Ok(sqlx::query_scalar( + "SELECT jsonb_build_array(relation_type_id, direction, status, decided_by, + decided_at, basis, votes) + FROM phrase_bindings WHERE kb_id=$1", + ) + .bind(self.kb) + .fetch_one(&self.pool) + .await?) } - .await; - f.cleanup().await?; - run } -/// 这一问出现之前判下的绑定没有 marks。代理判的:指纹没变也再问一次,补上就不再问。 +/// 这一问出现之前判下的绑定没有 marks。代理判的:指纹没变也问一次,只问那个日期标什么, +/// 两票都认这条绑定、又说了同一个值就写下,绑定别的不动;问过就不再问。 /// 人判的:代理不问,它在对齐队列里等人补,属性与方向是现在绑的 #[tokio::test] async fn a_binding_decided_before_the_question_is_asked_once_and_a_persons_waits_in_the_queue( @@ -1003,10 +1019,8 @@ async fn a_binding_decided_before_the_question_is_asked_once_and_a_persons_waits f.script(bound()); f.run().await?; assert_eq!(f.binding().await?.marks.as_deref(), Some("none")); - sqlx::query("UPDATE phrase_bindings SET marks = NULL WHERE kb_id=$1") - .bind(f.kb) - .execute(&f.pool) - .await?; + f.as_before_the_question().await?; + let decided = f.decision().await?; f.script(vec![ marked(Some("based_in"), Some("forward"), Some("start")), marked(Some("based_in"), Some("forward"), Some("start")), @@ -1017,7 +1031,10 @@ async fn a_binding_decided_before_the_question_is_asked_once_and_a_persons_waits 4, "the fingerprint matches, yet the binding is asked for the value it lacks" ); - assert_eq!(f.binding().await?.marks.as_deref(), Some("start")); + assert_eq!(f.decision().await?, decided, "only the value is written"); + let b = f.binding().await?; + assert_eq!(b.marks.as_deref(), Some("start")); + assert!(b.marks_asked_at.is_some()); f.run().await?; assert_eq!(f.requests().len(), 4, "asked once: it has its value now"); assert!(!f.requeued().await?); @@ -1035,34 +1052,108 @@ async fn a_binding_decided_before_the_question_is_asked_once_and_a_persons_waits decided_by: "person", basis: None, marks: None, + marks_asked: false, }, ) .await?; f.run().await?; assert_eq!(f.requests().len(), 4, "a person's binding is not asked"); - let items = utopia_store::alignment_queue::list(&f.pool, f.kb, 10, 0).await?; - assert!( - matches!( - items.as_slice(), - [utopia_store::alignment_queue::AlignmentItem::Phrase { - bound_to: Some(p), - direction: Some(d), - .. - }] if p == "based_in" && d == "forward" - ), - "{items:?}" - ); - assert_eq!( - utopia_store::alignment_queue::waiting(&f.pool, f.kb) - .await? - .0, - 1 - ); + assert!(waits_for_the_date(&f).await?); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +/// 模型从来不给第四格(#975 评审):这一列之前绑上的签名只问一次。绑定原样,它名下的 +/// 类型化行还活着,签名在对齐队列里等人;再跑一轮不再问,也不给自己排下一轮 +#[tokio::test] +async fn a_model_that_never_gives_the_value_is_asked_once() -> 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); + f.as_before_the_question().await?; + let decided = f.decision().await?; + let three_cells = json!({"b":[[0, "based_in", "forward"]]}); + f.script(vec![three_cells.clone(), three_cells]); + f.run().await?; + assert_eq!(f.requests().len(), 4, "one ask: two votes"); + assert_eq!(f.decision().await?, decided, "the binding is unchanged"); + assert_eq!(f.typed().await?, 1, "its typed row is still live"); + f.run().await?; + f.run().await?; + assert_eq!(f.requests().len(), 4, "not asked again"); + let b = f.binding().await?; assert_eq!( - utopia_store::review::counts(&f.pool, f.kb).await?.alignment, - 1, - "the badge counts it too" + (b.marks.as_deref(), b.marks_asked_at.is_some()), + (None, true) ); + assert!(waits_for_the_date(&f).await?); + assert!(!f.requeued().await?, "no re-ask is queued"); + anyhow::Ok(()) + } + .await; + f.cleanup().await?; + run +} + +/// 补问只能写那个日期标什么,重开不了绑定(#975 评审):两票没选属性、选了另一个方向、 +/// 说的值不一样,绑定都原样,类型化行还活着,签名交给人;两票都认这条绑定、说了同一个值, +/// 才写下那个值 +#[tokio::test] +async fn asking_what_a_date_marks_never_reopens_the_binding() -> anyhow::Result<()> { + let Some(f) = Fx::new().await? else { + return Ok(()); + }; + let run = async { + f.script(bound()); + f.run().await?; + let answers = [ + ("no property", vote(None, None), vote(None, None), None), + ( + "the other direction", + marked(Some("based_in"), Some("reverse"), Some("start")), + marked(Some("based_in"), Some("reverse"), Some("start")), + None, + ), + ( + "two readings", + marked(Some("based_in"), Some("forward"), Some("start")), + marked(Some("based_in"), Some("forward"), Some("end")), + None, + ), + ( + "one reading", + marked(Some("based_in"), Some("forward"), Some("end")), + marked(Some("based_in"), Some("forward"), Some("end")), + Some("end"), + ), + ]; + for (label, first, second, value) in answers { + f.as_before_the_question().await?; + let decided = f.decision().await?; + f.script(vec![first, second]); + f.run().await?; + assert_eq!( + f.decision().await?, + decided, + "{label}: the binding is unchanged" + ); + assert_eq!(f.typed().await?, 1, "{label}: its typed row is still live"); + let b = f.binding().await?; + assert_eq!( + (b.marks.as_deref(), b.marks_asked_at.is_some()), + (value, true), + "{label}" + ); + assert_eq!(waits_for_the_date(&f).await?, value.is_none(), "{label}"); + assert!(!f.requeued().await?, "{label}"); + } anyhow::Ok(()) } .await; diff --git a/crates/utopia-store/src/alignment_queue.rs b/crates/utopia-store/src/alignment_queue.rs index 68fe79b86..fe9793bfe 100644 --- a/crates/utopia-store/src/alignment_queue.rs +++ b/crates/utopia-store/src/alignment_queue.rs @@ -29,8 +29,9 @@ pub enum AlignmentItem { /// 两票各选了什么:`{"first": {...} | null, "second": {...} | null}` votes: Option, decided_at: DateTime, - /// 人绑过、却没说一刻标哪一端的签名(#966):现在绑到的属性与方向,界面按它预选、 - /// 只等人补上 marks。undecided 的条目两个都是 None + /// 绑上了、却没说一刻标哪一端的签名(#966):人绑的,或对齐器问过、两票没说出同一个 + /// 值的。现在绑到的属性与方向,界面按它预选、只等人补上 marks。undecided 的条目两个 + /// 都是 None bound_to: Option, direction: Option, }, @@ -93,10 +94,13 @@ struct PhraseRow { direction: Option, } -/// 等人定的短语签名:两票不一致的,加上人绑到状态属性、却没说一刻标哪一端的(#966)。 -/// 后一种代理不能替人补(人的判定代理不改),不列出来,它名下只说了一刻的陈述就一直不算 +/// 等人定的短语签名:两票不一致的,加上绑到状态属性、却没说一刻标哪一端的(#966)——人绑的 +/// (代理不改人的判定),和对齐器问过、两票没说出同一个值的(只问一次,0053 修订)。不列 +/// 出来,它们名下只说了一刻的陈述就一直不算。这一列之前代理绑上、还没问过的不列:对齐器 +/// 下一轮先问 pub(crate) const WAITING_PHRASE: &str = "(b.status = 'undecided' - OR (b.status = 'bound' AND b.decided_by = 'person' AND b.marks IS NULL + OR (b.status = 'bound' AND b.marks IS NULL + AND (b.decided_by = 'person' OR b.marks_asked_at IS NOT NULL) AND EXISTS (SELECT 1 FROM relation_types rt WHERE rt.id = b.relation_type_id AND rt.temporal = 'state')))"; diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index a26dc574e..222f12123 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -670,16 +670,29 @@ pub(crate) async fn insert_fact_on( 另立一行让两条各说各话,开放的那条照旧被读成「至今仍是」——实测「移出失信名单」 「辞去董事职务」各多出一条 `- → 日期`,而原来那条还开着。事件没有开放行 (两端同一刻),所以只有状态走这里。修正走 supersede(作废 + 改写,证据和边上的 - 属性随行),与 #393 关「不知哪天」同一条路;起点比终点晚的开放行不是这一段 */ + 属性随行),与 #393 关「不知哪天」同一条路;起点晚于终点所说时段的开放行不是这一段 */ if temporal == Temporal::State && validity.from.is_none() { if let Some(to) = validity.to { - // 已经关在这一天的:同一件事,复用那一行(引擎推的终点改成写明的,同上) - if let Some((ended, _, _, _)) = same.iter().find(|(_, _, vt, _)| *vt == Some(to)) { - let precision = validity.to_precision.unwrap_or("day"); + let precision = validity.to_precision.unwrap_or("day"); + // 终点说的是一个时段,比的却是时刻:「2024 年离开」存成 2024-01-01。声明成状态的 + // 开放行若从这个时段里开始(2024-03-01 起任职),终点落在时段的尽头 + // (2025-01-01),不另立一行 `- → 2024`、让开放的那段一直开着;在时段之前开始的 + // 照旧关在时段的开头(0053 修订 2026-09-27)。同一天开始又结束的也是这样:关在 + // 那一天的尽头,不关在它自己的起点——那是一段不成立的状态(#966) + let period_end = declared_state.then(|| bucket_end(to, Some(precision))); + let closes_at = |from: Option>| match (from, period_end) { + (Some(f), Some(end)) if to <= f && f < end => end, + _ => to, + }; + // 已经关在那一刻的:同一件事,复用那一行(引擎推的终点改成写明的,同上) + if let Some((ended, from, _, _)) = same + .iter() + .find(|(_, vf, vt, _)| *vt == Some(closes_at(*vf))) + { if let Some(stated) = crate::temporal::state_derived_end( &mut *conn, *ended, - Some((to, precision)), + Some((closes_at(*from), precision)), validity.attested_at, ) .await? @@ -689,21 +702,23 @@ pub(crate) async fn insert_fact_on( attest_earlier(&mut *conn, *ended, validity.attested_at, true).await?; return Ok((*ended, false)); } - // 声明成状态的开放行不在它自己的起点关上:关上就是一段不成立的状态(#966) let open = same .iter() .filter(|(_, vf, vt, vtp)| { vt.is_none() && vtp.is_none() - && vf.is_none_or(|f| f < to || (f == to && !declared_state)) + && vf.is_none_or(|f| match period_end { + Some(end) => f < end, + None => f <= to, + }) }) .max_by_key(|(_, vf, _, _)| *vf); - if let Some((open, _, _, _)) = open { + if let Some((open, from, _, _)) = open { if let Some(closed) = crate::temporal::close_superseded( &mut *conn, *open, - to, - validity.to_precision.unwrap_or("day"), + closes_at(*from), + precision, ) .await? { @@ -784,21 +799,27 @@ pub(crate) async fn insert_fact_on( // 文档可能先到),本次观察带了起点 → 落库后作废那行并链上。只知道终点的行, // 终点跟着走:这次没说终点就沿用它的,说了就得是同一个 let mut validity = validity; - // 声明成状态的,沿用来的终点若正落在这次的起点上,合起来就是一段不成立的状态(#966): - // 不精化,各自一行 - let refine_target = if validity.from.is_some() { - same.iter() - .find(|(_, vf, vt, _)| { - vf.is_none() - && (vt.is_none() || validity.to.is_none() || *vt == validity.to) - && !(declared_state - && validity.to.is_none() - && vt.is_some() - && *vt == validity.from) - }) - .map(|(id, _, vt, vtp)| (*id, *vt, vtp.clone())) - } else { - None + // 声明成状态的,沿用来的终点说的是一个时段(同上):这次的起点落在时段里,终点取时段的 + // 尽头;落在时段之后,那个结束说的是更早的一段,不精化,各自一行(0053 修订 2026-09-27) + let refine_target = match validity.from { + Some(from) => same.iter().find_map(|(id, vf, vt, vtp)| { + if vf.is_some() { + return None; + } + let end = match (validity.to, *vt) { + (None, Some(t)) if declared_state && t <= from => { + let end = bucket_end(t, Some(vtp.as_deref().unwrap_or("day"))); + if from >= end { + return None; + } + Some(end) + } + (Some(to), Some(t)) if to != t => return None, + (_, vt) => vt, + }; + Some((*id, end, vtp.clone())) + }), + None => None, }; if let Some((_, Some(vt), vtp)) = &refine_target { if validity.to.is_none() { diff --git a/crates/utopia-store/src/materialize.rs b/crates/utopia-store/src/materialize.rs index 5e928ef8d..a27b6a3cf 100644 --- a/crates/utopia-store/src/materialize.rs +++ b/crates/utopia-store/src/materialize.rs @@ -150,6 +150,32 @@ async fn materialize_in_tx( ) -> AppResult<(Outcome, Vec)> { // 这一轮写下的行(新建的和并入的),提交后对账 let mut written: Vec = Vec::new(); + // 0. 起止同值的物化状态行(#966 之前照抄一刻写下的)任何时刻都不成立:作废,它的陈述 + // 在第 3 步按绑定说的读法重算——「那天加入」与「自那天起」重新落成一段从那天起的状态。 + // 只认照抄来的:它有一条来源陈述两端正是这一刻(类型化行看 typed_fact_sources,隐含行 + // 看 implied_fact_sources)。人改成起止相等的行也带着 from_statement_id、implied 和 + // 来源链接(#967、#911),看上去和算出来的一样,但它的来源陈述不是这一刻,不碰: + // 不变量从此挡住新的,旧的由人自己改。排在第 1 步之前:那一刻的陈述在绑定说出读法 + // 之前不算来源,第 1 步会先删掉这条链接 + let emptied = sqlx::query( + "UPDATE facts t + SET invalidated_at = now() + WHERE t.kb_id = $1 AND t.layer = 'typed' AND t.invalidated_at IS NULL + AND t.valid_from IS NOT NULL AND t.valid_to = t.valid_from + AND EXISTS (SELECT 1 FROM relation_types r + WHERE r.id = t.predicate_id AND r.temporal = 'state') + AND EXISTS (SELECT 1 FROM facts s + WHERE s.valid_from = t.valid_from AND s.valid_to = t.valid_from + AND (s.id IN (SELECT ts.statement_id FROM typed_fact_sources ts + WHERE ts.fact_id = t.id) + OR s.id IN (SELECT i.statement_id FROM implied_fact_sources i + WHERE i.fact_id = t.id)))", + ) + .bind(kb_id) + .execute(&mut **tx) + .await? + .rows_affected(); + // 1. 删不再成立的来源:陈述死了、行死了、签名没绑着、属性或方向变了、陈述带了 mood sqlx::query(&format!( "DELETE FROM typed_fact_sources src @@ -213,23 +239,6 @@ async fn materialize_in_tx( .execute(&mut **tx) .await? .rows_affected(); - - // 2b. 起止同值的物化状态行(#966 之前照抄一刻写下的)任何时刻都不成立:作废,它的陈述 - // 在第 3 步按绑定说的读法重算——「那天加入」与「自那天起」重新落成一段从那天起的状态。 - // 人写的不碰:不变量从此挡住新的,旧的由人自己改 - let emptied = sqlx::query( - "UPDATE facts t - SET invalidated_at = now() - WHERE t.kb_id = $1 AND t.layer = 'typed' AND t.invalidated_at IS NULL - AND (t.from_statement_id IS NOT NULL OR t.implied) - AND t.valid_from IS NOT NULL AND t.valid_to = t.valid_from - AND EXISTS (SELECT 1 FROM relation_types r - WHERE r.id = t.predicate_id AND r.temporal = 'state')", - ) - .bind(kb_id) - .execute(&mut **tx) - .await? - .rows_affected(); let retired = retired + emptied; // 3. 该有而没有的(陈述, 绑定)对:陈述活着、签名绑着、没 mood、还没有活着的行以它为来源 diff --git a/crates/utopia-store/src/materialize_delivery_tests.rs b/crates/utopia-store/src/materialize_delivery_tests.rs index a404fd86d..97b154a4d 100644 --- a/crates/utopia-store/src/materialize_delivery_tests.rs +++ b/crates/utopia-store/src/materialize_delivery_tests.rs @@ -53,6 +53,7 @@ async fn accept( decided_by: "person", basis: None, marks: None, + marks_asked: false, } ) .await? diff --git a/crates/utopia-store/src/phrase_bindings.rs b/crates/utopia-store/src/phrase_bindings.rs index b44548e98..f658aea02 100644 --- a/crates/utopia-store/src/phrase_bindings.rs +++ b/crates/utopia-store/src/phrase_bindings.rs @@ -152,6 +152,8 @@ pub struct Binding { /// 一条只说了一刻的陈述,那一刻标的是状态的哪一端:start / end / none(#966)。 /// NULL = 未知:这一列之前的判定,或属性不是状态 pub marks: Option, + /// 对齐器问过 marks 的时刻;NULL = 没问过。问过还没有值的不再问,等人(0053 修订) + pub marks_asked_at: Option>, } impl Binding { @@ -181,7 +183,8 @@ 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, basis, marks + relation_type_id, direction, status, decided_at, decided_by, basis, marks, + marks_asked_at FROM phrase_bindings WHERE kb_id = $1 ORDER BY phrase", ) .bind(kb_id) @@ -247,7 +250,7 @@ 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.basis, - b.marks + b.marks, b.marks_asked_at FROM phrase_bindings b LEFT JOIN relation_types r ON r.id = b.relation_type_id WHERE b.kb_id = $1 @@ -277,6 +280,9 @@ pub struct Decision<'a> { /// start / end / none:只说了一刻的陈述在状态属性下怎么读(#966)。只有 bound 带; /// None = 未知,那样的陈述不算类型化行 pub marks: Option<&'a str>, + /// 这次判定问过 marks:对齐器把签名绑到状态属性时随判定一起问(0053 修订)。记下问的 + /// 时刻,没有值的签名就不再问、进对齐队列等人。只有 bound 带 + pub marks_asked: bool, } /// 记下一条签名的判定(有则改)。返回是否写入了。 @@ -312,12 +318,12 @@ fn validate_decision(sig: &PhraseSignature, d: &Decision<'_>) -> AppResult 0) } +/// 给这一列之前绑上的签名补问 marks 的结果(0053 修订 2026-09-27)。这一问只问一刻标哪一端, +/// 不重判绑定:`value` 是两票都认这条绑定的属性与方向、又说了同一个值时的那个值,写下它; +/// 否则是 None,绑定原样。两种都记下问过的时刻——问过的不再问,没有值的列进对齐队列等人。 +/// +/// 只写问的时候那条绑定:还是代理绑的、属性与方向没变、还没有值。途中人判过或重判过的, +/// 这一问的答案不算数,返回 false +pub async fn record_marks( + pool: &PgPool, + kb_id: Uuid, + asked: &Binding, + value: Option<&str>, +) -> AppResult { + if let Some(marks) = value { + if !matches!(marks, "start" | "end" | "none") { + return Err(AppError::Validation(format!("unknown marks {marks:?}"))); + } + } + let res = sqlx::query( + "UPDATE phrase_bindings + SET marks = $8, marks_asked_at = now() + WHERE kb_id = $1 AND phrase = $2 + AND subject_type_id IS NOT DISTINCT FROM $3 + AND object_type_id IS NOT DISTINCT FROM $4 + AND object_is_value = $5 + AND status = 'bound' AND decided_by = 'agent' AND marks IS NULL + AND relation_type_id = $6 AND direction = $7", + ) + .bind(kb_id) + .bind(&asked.phrase) + .bind(asked.subject_type_id) + .bind(asked.object_type_id) + .bind(asked.object_is_value) + .bind(asked.relation_type_id) + .bind(asked.direction.as_deref()) + .bind(value) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) +} + #[cfg(test)] #[path = "phrase_bindings_delivery_tests.rs"] mod delivery_tests; diff --git a/crates/utopia-store/src/phrase_bindings_delivery_tests.rs b/crates/utopia-store/src/phrase_bindings_delivery_tests.rs index a55207de7..39aca70b6 100644 --- a/crates/utopia-store/src/phrase_bindings_delivery_tests.rs +++ b/crates/utopia-store/src/phrase_bindings_delivery_tests.rs @@ -85,6 +85,7 @@ fn bound(property: Uuid) -> Decision<'static> { decided_by: "person", basis: None, marks: None, + marks_asked: false, } } @@ -97,6 +98,7 @@ fn none() -> Decision<'static> { decided_by: "person", basis: None, marks: None, + marks_asked: false, } } diff --git a/crates/utopia-store/tests/human_phrase_materialization_delivery.rs b/crates/utopia-store/tests/human_phrase_materialization_delivery.rs index 12064e131..6f32429d2 100644 --- a/crates/utopia-store/tests/human_phrase_materialization_delivery.rs +++ b/crates/utopia-store/tests/human_phrase_materialization_delivery.rs @@ -26,6 +26,7 @@ async fn accept( decided_by: "person", basis: None, marks: None, + marks_asked: false, } ) .await? @@ -235,6 +236,7 @@ fn crash_child() { decided_by: "person", basis: None, marks: None, + marks_asked: false, }, ) .await diff --git a/crates/utopia-store/tests/store/a_moment_marks_its_state.rs b/crates/utopia-store/tests/store/a_moment_marks_its_state.rs index 869d8d458..a756b6a27 100644 --- a/crates/utopia-store/tests/store/a_moment_marks_its_state.rs +++ b/crates/utopia-store/tests/store/a_moment_marks_its_state.rs @@ -5,12 +5,14 @@ //! //! - start:从那天起。「joined」在「works for … from 2023-06-01」之前、之后,或只有它 //! (#966 的 A、B、C),都是一段从 2023-06-01 起、至今开着的状态; -//! - end:到那天为止,关上开着的那段; +//! - end:到那天为止,关上开着的那段。终点说的是一个时段(「2024 年离开」)而开着的那段从 +//! 时段里开始时,关在时段的尽头; //! - none 或还没说:不算类型化行,陈述带着日期留在开放图谱。 //! -//! 之前照抄写下的空段由物化作废重算,人写的不碰;声明成状态的属性下,两端相等的行写入、 -//! 改区间、关在起点都被拒,没有属性的行照旧;已有的空段不并进后来的观察,也不被它关上; -//! 勘误改不了一段不成立的时间;规则没有 marks,只说了一刻的陈述不蕴含状态,只有起点的照旧。 +//! 之前照抄写下的空段由物化作废重算,人写的、人改成一刻的不碰;声明成状态的属性下,两端 +//! 相等的行写入、改区间、关在起点都被拒(同时开始的冲突也不能关在那一刻),没有属性的行 +//! 照旧;已有的空段不并进后来的观察,也不被它关上;勘误改不了一段不成立的时间,也不能把 +//! 一刻的事件改成状态;规则没有 marks,只说了一刻的陈述不蕴含状态,只有起点的照旧。 //! //! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 @@ -118,6 +120,30 @@ impl Base { phrase: &str, from: Option>, to: Option>, + ) -> anyhow::Result { + self.statement_in(phrase, from, to, "day").await + } + + /// 同 [`Base::statement`],两端是这个精度(「2024 年离开」是年精度的一刻) + async fn statement_in( + &self, + phrase: &str, + from: Option>, + to: Option>, + precision: &str, + ) -> anyhow::Result { + self.statement_to(phrase, self.meridian, from, to, precision) + .await + } + + /// 同 [`Base::statement_in`],宾语是这一个 + async fn statement_to( + &self, + phrase: &str, + object: Uuid, + from: Option>, + to: Option>, + precision: &str, ) -> anyhow::Result { let id = Uuid::now_v7(); sqlx::query( @@ -125,17 +151,18 @@ impl Base { valid_from, valid_from_precision, valid_from_grade, valid_to, valid_to_precision) VALUES ($1, $2, $3, $4, 'open', $5, 0.9, - $6, CASE WHEN $6 IS NULL THEN NULL ELSE 'day' END, + $6, CASE WHEN $6 IS NULL THEN NULL ELSE $8 END, CASE WHEN $6 IS NULL THEN NULL ELSE 'A' END, - $7, CASE WHEN $7 IS NULL THEN NULL ELSE 'day' END)", + $7, CASE WHEN $7 IS NULL THEN NULL ELSE $8 END)", ) .bind(id) .bind(self.kb) .bind(self.lin) - .bind(self.meridian) + .bind(object) .bind(phrase) .bind(from) .bind(to) + .bind(precision) .execute(&self.pool) .await?; Ok(id) @@ -568,7 +595,8 @@ async fn a_state_that_ends_where_it_starts_is_never_written() -> anyhow::Result< } /// 护栏:之前写下的空段(这里是人写的,物化不作废它)不把后来「自那天起」的观察并进去, -/// 也不被「到哪天为止」的观察关上。同一天开始又结束的状态,不管先读到哪一句,都不写成空段 +/// 也不被「到哪天为止」的观察关上。同一天开始又结束的状态,不管先读到哪一句,都不写成空段, +/// 也不让开着的那段一直开着:关在那一天的尽头 #[tokio::test] async fn a_row_that_holds_at_no_moment_neither_absorbs_nor_closes() -> anyhow::Result<()> { let Some(pool) = pool().await? else { @@ -623,8 +651,8 @@ async fn a_row_that_holds_at_no_moment_neither_absorbs_nor_closes() -> anyhow::R } assert_eq!( base.rows().await?, - vec![(None, Some(day()), 0), (Some(day()), None, 0)], - "start first: {start_first}" + vec![(Some(day()), Some(t("2023-06-02T00:00:00Z")), 0)], + "start first: {start_first}: one row, through that day" ); anyhow::Ok(()) } @@ -740,6 +768,55 @@ async fn a_revision_cannot_take_a_time_that_holds_at_no_moment() -> anyhow::Resu .fetch_one(&pool) .await?; assert_eq!(status, "held"); + + // 一刻的事件改成状态属性:新行接过来的也是一段不成立的时间,同样拒 + let (acquisition, _) = graph::insert_fact( + &pool, + kb, + meridian, + Some(base.acquired), + aster, + moment(), + 0.9, + ) + .await?; + let event = Candidate { + fact_id: acquisition, + predicate_id: base.acquired, + subject_id: meridian, + subject: "Meridian Systems".into(), + subject_class: Some("organization".into()), + property: "acquired".into(), + property_label: "acquired".into(), + object_id: Some(aster), + object: "Aster Labs".into(), + ..candidate + }; + let recorded = errata::record( + &pool, + kb, + ActionInput { + run_id, + document_id: doc, + candidate: Some(&event), + proposed: Proposed::Revise { + property: Some("works_for".into()), + object: None, + }, + reason: "the document says an employment", + quote: Some("Lin Zhao joined Aster Labs"), + document_text: text, + }, + ) + .await?; + assert_eq!( + (recorded.status, recorded.detail.as_deref()), + ( + "refused", + Some("a state cannot take a time whose start and end are equal") + ) + ); + assert_eq!(base.live(acquisition).await?, (true, Some(day()))); anyhow::Ok(()) } .await; @@ -791,3 +868,324 @@ async fn a_rule_reads_a_start_date_but_not_a_single_date() -> anyhow::Result<()> base.remove().await?; run } + +/// 人改成起止相等的行(#975 评审):带着 from_statement_id、implied 和来源链接,看上去和照抄 +/// 一刻算出来的一样,可它的来源陈述不是那一刻,物化不作废它。照抄一刻写下的隐含行作废, +/// 人改过的隐含行不碰 +#[tokio::test] +async fn a_row_a_person_set_to_a_single_date_is_not_retired() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + // 「works for … from」算出一行;「was hired by … from」经规则蕴含一行,宾语另是一家, + // 两行不并;「signed with」只说了一刻,#966 之前规则照抄出一行空段 + let works = base.statement("works for", Some(day()), None).await?; + base.bind("works for", None).await?; + let hired = base + .statement_to("was hired by", base.aster, Some(day()), None, "day") + .await?; + let signed = base + .statement("signed with", Some(day()), Some(day())) + .await?; + let mut rules = Vec::new(); + for phrase in ["was hired by", "signed with"] { + let rule = Uuid::now_v7(); + sqlx::query( + "INSERT INTO implication_rules + (id, kb_id, trigger, phrase, subject_type_id, object_type_id, object_is_value, + conclude_property_id, status, decided_by) + VALUES ($1, $2, 'phrase', $3, $4, $5, false, $6, 'approved', 'person')", + ) + .bind(rule) + .bind(base.kb) + .bind(phrase) + .bind(base.person) + .bind(base.company) + .bind(base.works_for) + .execute(&pool) + .await?; + rules.push(rule); + } + materialize(&pool, base.kb).await?; + let row_of = |statement: Uuid, table: &'static str| { + let pool = pool.clone(); + async move { + let id: Uuid = sqlx::query_scalar(&format!( + "SELECT fact_id FROM {table} WHERE statement_id = $1" + )) + .bind(statement) + .fetch_one(&pool) + .await?; + anyhow::Ok(id) + } + }; + let typed = row_of(works, "typed_fact_sources").await?; + let implied = row_of(hired, "implied_fact_sources").await?; + assert_ne!(typed, implied); + // 人把两行都改成那一天:不变量出现之前 `correct_interval` 就这样写,行带着它的来源 + sqlx::query( + "UPDATE facts SET valid_to = valid_from, valid_to_precision = valid_from_precision + WHERE id = ANY($1)", + ) + .bind(vec![typed, implied]) + .execute(&pool) + .await?; + let copied = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, layer, + valid_from, valid_from_precision, valid_to, valid_to_precision, + confidence, implied) + VALUES ($1, $2, $3, $4, $5, 'typed', $6, 'day', $6, 'day', 0.9, true)", + ) + .bind(copied) + .bind(base.kb) + .bind(base.lin) + .bind(base.works_for) + .bind(base.meridian) + .bind(day()) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO implied_fact_sources (fact_id, rule_id, statement_id) VALUES ($1, $2, $3)", + ) + .bind(copied) + .bind(rules[1]) + .bind(signed) + .execute(&pool) + .await?; + + let outcome = materialize(&pool, base.kb).await?; + assert_eq!( + (outcome.retired, outcome.added, outcome.implied), + (1, 0, 0), + "{outcome:?}" + ); + assert_eq!( + base.live(copied).await?, + (false, Some(day())), + "copied: retired" + ); + assert_eq!( + base.live(typed).await?, + (true, Some(day())), + "a typed row a person set to a single date is kept" + ); + assert_eq!( + base.live(implied).await?, + (true, Some(day())), + "an implied row a person set to a single date is kept" + ); + assert_eq!(materialize(&pool, base.kb).await?, Outcome::default()); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} + +/// 终点说的是一个时段,比的却是时刻(#975 评审):「left … in 2024」存成 2024-01-01,年精度。 +/// 「works for … from 2024-03-01」从这个时段里开始,关在时段的尽头 2025-01-01,而不是另立一行 +/// `- → 2024`、让它一直开着到 2025 年以后。先读到哪一句都一样 +#[tokio::test] +async fn a_coarse_end_closes_a_row_that_starts_inside_its_period() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let (march, year, next_year) = ( + t("2024-03-01T00:00:00Z"), + t("2024-01-01T00:00:00Z"), + t("2025-01-01T00:00:00Z"), + ); + for order in [["works for", "left"], ["left", "works for"]] { + let base = Base::new(&pool).await?; + let run = async { + for phrase in order { + if phrase == "left" { + base.statement_in(phrase, Some(year), Some(year), "year") + .await?; + } else { + base.statement(phrase, Some(march), None).await?; + } + } + base.bind("works for", None).await?; + base.bind("left", Some("end")).await?; + materialize(&pool, base.kb).await?; + assert_eq!( + base.rows().await?, + vec![(Some(march), Some(next_year), 2)], + "{order:?}: one span, closed at the end of 2024" + ); + assert_eq!( + base.at_meridian("2024-06-01T00:00:00Z").await?, + vec!["Lin Zhao".to_string()], + "{order:?}" + ); + assert!( + base.at_meridian("2025-06-01T00:00:00Z").await?.is_empty(), + "{order:?}: not still working there in 2025" + ); + assert_eq!(materialize(&pool, base.kb).await?, Outcome::default()); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run?; + } + Ok(()) +} + +/// 同一个时段的终点,对时段之前开始的行照旧关在时段的开头;对时段之后才开始的行不适用—— +/// 那是更早的一段的结束,各自一行,开着的那段照旧开着。先写哪一句都一样 +#[tokio::test] +async fn a_coarse_end_leaves_rows_outside_its_period_as_before() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let year = t("2024-01-01T00:00:00Z"); + let left_in_2024 = Validity { + to_precision: Some("year"), + ..ending(year) + }; + let cases = [ + ( + t("2020-01-01T00:00:00Z"), + vec![(Some(t("2020-01-01T00:00:00Z")), Some(year), 0)], + ), + ( + t("2025-06-01T00:00:00Z"), + vec![ + (None, Some(year), 0), + (Some(t("2025-06-01T00:00:00Z")), None, 0), + ], + ), + ]; + for (start, expected) in cases { + for start_first in [true, false] { + let base = Base::new(&pool).await?; + let run = async { + let since = Validity::starting(Some(start), Some("day")); + let writes = if start_first { + [since, left_in_2024] + } else { + [left_in_2024, since] + }; + for v in writes { + graph::insert_fact( + &pool, + base.kb, + base.lin, + Some(base.works_for), + base.meridian, + v, + 0.9, + ) + .await?; + } + assert_eq!( + base.rows().await?, + expected, + "from {start}, start first: {start_first}" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run?; + } + } + Ok(()) +} + +/// 两个值同一刻开始的冲突(works_for 声明成函数性):人选「关上旧的」却不给日期,旧的就关在 +/// 新的起点——也是它自己的起点,一段不成立的状态,拒绝(`empty_state_span`)。冲突留着等人 +/// 给一个日期 +#[tokio::test] +async fn closing_a_simultaneous_conflict_at_its_own_start_is_refused() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + sqlx::query("UPDATE relation_types SET functional = true WHERE id = $1") + .bind(base.works_for) + .execute(&pool) + .await?; + let since = Validity::starting(Some(day()), Some("day")); + let mut facts = Vec::new(); + for object in [base.meridian, base.aster] { + let (fact, _) = graph::insert_fact( + &pool, + base.kb, + base.lin, + Some(base.works_for), + object, + since, + 0.9, + ) + .await?; + facts.push(fact); + } + let report = utopia_store::temporal::reconcile_new_fact( + &pool, + base.kb, + facts[1], + base.lin, + base.works_for, + Some(base.aster), + None, + utopia_store::temporal::Uniqueness::SubjectSide, + since, + 0.9, + ) + .await?; + assert_eq!(report.conflicts, 1); + let conflicts = utopia_store::temporal::list_conflicts(&pool, base.kb, 10, 0).await?; + let [conflict] = conflicts.as_slice() else { + anyhow::bail!("one conflict: {conflicts:?}"); + }; + assert_eq!(conflict.reason, "simultaneous"); + let refused = utopia_store::temporal::resolve_conflict( + &pool, + base.kb, + conflict.id, + "close", + None, + "day", + ) + .await + .expect_err("closed where it starts"); + assert!(refused_as_empty(&refused), "{refused:?}"); + assert_eq!( + base.live(facts[0]).await?, + (true, None), + "the old row is untouched" + ); + assert_eq!( + utopia_store::temporal::list_conflicts(&pool, base.kb, 10, 0) + .await? + .len(), + 1, + "the conflict stays open" + ); + utopia_store::temporal::resolve_conflict( + &pool, + base.kb, + conflict.id, + "close", + Some(t("2024-01-01T00:00:00Z")), + "day", + ) + .await?; + assert!( + utopia_store::temporal::list_conflicts(&pool, base.kb, 10, 0) + .await? + .is_empty() + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} diff --git a/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs b/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs index 3554723d2..7ad0244fb 100644 --- a/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs +++ b/crates/utopia-store/tests/store/a_plan_step_follows_its_premise.rs @@ -611,6 +611,7 @@ async fn bind_is_on(pool: &PgPool, f: &Fixture) -> anyhow::Result<()> { decided_by: "person", basis: None, marks: None, + marks_asked: false, }, ) .await?; diff --git a/crates/utopia-store/tests/store/materialization_is_serial.rs b/crates/utopia-store/tests/store/materialization_is_serial.rs index c635b58c4..a4e87f6eb 100644 --- a/crates/utopia-store/tests/store/materialization_is_serial.rs +++ b/crates/utopia-store/tests/store/materialization_is_serial.rs @@ -82,6 +82,7 @@ async fn overlapping_materializations_create_one_typed_fact() -> anyhow::Result< decided_by: "agent", basis: None, marks: None, + marks_asked: false, }, ) .await?; diff --git a/crates/utopia-store/tests/store/negative_binding_definition_edit.rs b/crates/utopia-store/tests/store/negative_binding_definition_edit.rs index d44232207..a64c9a9d5 100644 --- a/crates/utopia-store/tests/store/negative_binding_definition_edit.rs +++ b/crates/utopia-store/tests/store/negative_binding_definition_edit.rs @@ -65,6 +65,7 @@ impl BindingKind { decided_by: actor, basis: None, marks: None, + marks_asked: false, }, ) .await? diff --git a/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md b/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md index 51e1b8170..30e823fba 100644 --- a/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md +++ b/docs/decisions/0031-an-event-holds-at-the-moment-it-names.md @@ -25,6 +25,8 @@ So choosing `event` was the same as choosing `state` without a uniqueness axiom, The rule sits in `insert_fact_inner` and `correct_interval`, not in the writers. Extraction, the pending-facts nod (0015), a person's own fact from the API and a person's edit of an interval all pass through those two doors, and nothing outside the store has to know the rule exists. +2026-09-27: under a property declared as a state, the same rule now refuses a row whose two ends are equal, and a single date bound to a state is read by what its binding says it marks. See [0053](0053-a-phrase-decision-records-the-inputs-it-considered.md)'s revision of that date (#966). + Both ends carry the value because that is the one shape the row can explain on its own. A reader that does not consult the predicate — an old client, an export consumer, a hand-written query — sees a fact that held on one day. It cannot see *holds from that day on*, which is what an open end would have told it. The representation fails safe. An undated observation of an event merges into an existing dated one, the mirror of the refinement path that already lets a dated observation supersede a bare row: "acquired in March" followed by "acquired" is one acquisition, not two. @@ -62,7 +64,7 @@ An event row from before this record has a start and an open end. `facts_holds_t - **A point on the panel.** `fmtInterval` prints an event as `2024-03-15 ~ 2024-03-15` and the timeline draws it as a bar of no length. The point rendering, and a line on the ontology page saying what the three values do, are the UI cut. - **The wording the tools give the model.** `time_text` phrases an event's interval the way it phrases a state's. Whether "on 2024-03-15" reads better than "from 2024-03-15 to 2024-03-15" for a timed answer is a prompt question, to be looked at with the tool traces. - **An attribute taken at a moment.** Attributes are created as `state` and the UI does not offer otherwise (0021's readings are states that a later reading closes). A reading that is a measurement *at* a time rather than a value *from* a time is not expressible today; nothing has asked for it. -- **The end convention for states.** Noted above. "Until 2024-07" ends at the start of July under the current read; the bucket reading this record gives events would end it at the start of August. Left alone here. +- **The end convention for states.** Noted above. "Until 2024-07" ends at the start of July under the current read; the bucket reading this record gives events would end it at the start of August. Left alone here. 2026-09-27: one case is settled in [0053](0053-a-phrase-decision-records-the-inputs-it-considered.md)'s revision of that date. An ending that closes an open row starting inside the period it names closes it at the period's end. ## Status history 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 26d58ffda..d3b8f799d 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 @@ -156,24 +156,33 @@ document's own "works for … from 2023-06-01", depending on which statement was **A binding to a state property says what a single date marks.** `phrase_bindings.marks` (migration 0097) is `start`, `end` or `none`; NULL is unknown. -- **The aligner decides it with the binding.** Each candidate line says whether the property is a - state, an event or timeless, and the reply carries a fourth value per item. Both votes must agree - on it, as on the property and the direction: - - two different values put the signature in the alignment queue; - - a vote that binds a state and leaves the value out counts as unanswered and takes the bounded - re-ask; - - events and timeless properties are not asked. +- **The aligner asks it with the binding, once.** Each candidate line says whether the property is a + state, an event or timeless, and the reply carries a fourth value per item. + - When both votes bind the same state property in the same direction, the binding is written. The + value is written with it only when both votes give the same one. + - Otherwise the binding carries no value and records that it was asked (`marks_asked_at`, + migration 0097). It is listed in the alignment queue and is not asked again until its + fingerprint changes. A model that never gives the value costs one decision, not a request per + run. + - Events and timeless properties are not asked. A fourth value under one is ignored, readable or + not. - **What it depends on is in the fingerprint.** Whether the value is asked depends on the property's time semantics. Every edit to them moves `updated_at`, which the fingerprint covers, so a property that becomes a state is asked again. The value itself is an output, not an input, and is not fingerprinted. -- **Decisions made before this** have no value. - - An agent's binding to a state with no value is asked again on the next run, although its - fingerprint matches. Only the run's opening selection does this. The check for work left at the - end of a run ignores it, so a model that never answers cannot queue run after run. - - A person's binding is never re-asked. It is listed in the alignment queue, with its property and +- **Decisions made before this** have no value and were never asked. + - An agent's binding to a state is asked once, although its fingerprint matches. The question can + only write the value. If both votes name the binding's property and direction and give the same + value, the value is written. Any other reply, a vote without a property included, leaves the + binding exactly as it is. + - Either way the binding records that it was asked, so it is not asked again. One still without a + value is listed in the alignment queue. Rules are not proposed again for it. + - A reply that never came back (a failed call) is not an ask. The run re-asks, as for any + signature the model did not answer. + - A person's binding is never asked. It is listed in the alignment queue, with its property and direction preselected, until a person says what the date marks. -- **A person** sets the value in the alignment queue when binding a state. The decision request +- **A person** sets the value in the alignment queue when binding a state. Nothing is preselected, + so a binding to a state goes through only after a person picks a value. The decision request carries `marks`, and the API refuses it for anything but a state property. **Materialisation reads it.** A statement whose two ends are equal, bound to a state property: @@ -181,18 +190,35 @@ document's own "works for … from 2023-06-01", depending on which statement was - `start`: written as [t, open). With "works for … from t" in the same document, both statements are sources of one row, whichever was written first. - `end`: written as an end at t, the path a dated ending takes in `insert_fact_on`: it closes the - open row at t. + open row at t. The date names a period, its bucket at its precision: "left in 2024" is stored as + 2024-01-01 and means all of 2024. + - An open row that starts inside that period is closed at the period's end, 2025-01-01, not left + open beside a row that only ends. + - A row that starts before the period closes at t, as before. The ending says nothing about a row + that starts after the period. + - Both statement orders give the same row. A dated ending takes the same path, so a state that + starts and ends on the same day holds through that day. - `none` or unknown: no typed row. The statement stays in the open graph with its date. Rows that hold at no moment because they were materialised before this (a computed state row with equal ends) are retired in the next run, and their statements are computed again under these rules. -A row a person wrote is not touched. A rule carries no value, so a single-date statement whose rule -concludes a state computes no implied row. +A row is retired only when one of its source statements has the same equal ends: `typed_fact_sources` +for a typed row, `implied_fact_sources` for an implied one. A row a person set to a single date keeps +its statement and source links (#967, #911), but none of those statements has that date, so it is +not touched. A rule carries no value, so a single-date statement whose rule concludes a state +computes no implied row. **The invariant.** A state row with equal ends is never written. `Validity::under` refuses it after truncation to precision (`empty_state_span`), so an interval correction that would write one gets a -422. Closing a state row at its own start is refused the same way. Rows without a property keep -their dates as before: reading them as a state is how they are read, not a declaration. +422. + +- Closing a state row at its own start is refused the same way. So is closing the old side of a + conflict whose two values start at the same instant without giving a date, since the close would + fall on its own start. The conflict stays open until a person gives one. +- An errata revision that would carry a single date onto a state property is refused when it is + recorded, whether the date comes from an old empty span or from an event's moment. +- Rows without a property keep their dates as before: reading them as a state is how they are read, + not a declaration. **The guard.** Under a state property, `insert_fact_on` no longer takes a stored row with equal ends for the same observation. Such a row neither absorbs a later observation nor is closed by one, so the @@ -216,17 +242,28 @@ Regression coverage, with a real PostgreSQL: 2023-06-01 when the binding says `start`. - A phrase that marks the end closes the open row at its date. With `none` or no value, a single date computes no typed row. -- A row that held at no moment is retired and computed again. -- The invariant refuses a write, an interval correction and a closing at the row's own start, and - still writes a single date without a property. +- "Left in 2024" closes a row from 2024-03-01 at 2025-01-01, in both orders. A row from 2020 closes + at 2024-01-01, a row from 2025-06-01 stays open, and a state that starts and ends on one day holds + through it, each in both orders. +- A row that held at no moment is retired and computed again. A typed or implied row a person set to + a single date is not. +- The invariant refuses a write, an interval correction, a closing at the row's own start and a + simultaneous conflict closed without a date. It still writes a single date without a property. + Errata cannot revise an old empty span, or an event's moment, onto a state. - The guard keeps a stored row with equal ends from absorbing a later observation. With a scripted model: -- a four-value reply binds with the value; -- different values go to the queue, and a missing one is re-asked; -- an agent's binding without a value is asked again and a person's is not; -- the queue lists a person's binding without a value, and a person's decision writes it. +- a four-value reply binds with the value, and an unreadable fourth value under an event property is + ignored; +- two different values, or a vote without one, bind without a value, record the ask, list the + binding in the queue and queue no re-ask; +- an agent's binding decided before this is asked once. The value is written when both votes + confirm the binding and agree. A vote without a property, the other direction or two values leave + the binding and its typed row as they were; +- a model that never gives the value is asked once, and the binding waits in the queue with its + typed row live; +- a person's binding is not asked, and the queue lists it until a person's decision writes the value. ## Status history diff --git a/docs/design/ontology.md b/docs/design/ontology.md index f2fe0807c..183235e37 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -78,9 +78,11 @@ decision's basis, so a changed list re-asks. The candidate properties are described once per batch and each item names only its keys. 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 binding to a state property also -says what a statement dated at a single moment marks (`marks`: start, end or none): both votes must -agree on it, a vote without it is asked again, and a person sets it in the alignment queue, where a -person's binding still without it waits [0053 revision, #966]. Candidates are the properties whose +says what a statement dated at a single moment marks (`marks`: start, end or none). The aligner asks +it once, with the binding: the value is written when both votes give the same one, and a binding +without one records the ask (`marks_asked_at`) and waits in the alignment queue, as a person's +binding without one does. A binding made before the question is asked once, and that question only +writes the value [0053 revision, #966]. 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 diff --git a/docs/design/time.md b/docs/design/time.md index 348134c23..b4a0ff6c8 100644 --- a/docs/design/time.md +++ b/docs/design/time.md @@ -56,12 +56,15 @@ rewritten. **A state never holds at no moment.** Under a property declared as a state, a row whose two ends are equal is refused (`empty_state_span`): `Validity::under` refuses the write or the interval -correction, and closing a state at its own start is refused the same way. A row without a property -keeps a single date. A statement dated at a single moment and bound to a state is read by its -binding's `marks`: `start` opens the state there, `end` closes the open row there, and `none` or no -value computes no typed row, so the statement stays in the open graph with its date. Materialized -state rows with equal ends written before this are retired and computed again [0053 revision, -#966]. The `[event]` / `[eternal]` prompt marks of 0031 d4 and the zoned-time sentence of 0024 +correction, and closing a state at its own start is refused the same way, a conflict closed without +a date included. A row without a property keeps a single date. A statement dated at a single moment +and bound to a state is read by its binding's `marks`: `start` opens the state there, `end` closes +the open row there, and `none` or no value computes no typed row, so the statement stays in the open +graph with its date. An end names its bucket: an open row that starts inside it closes at the +bucket's end ("left in 2024" closes a row from March 2024 at 2025-01-01), a row that starts before it +closes at its start. Materialized state rows with equal ends written before this are retired and +computed again when one of their source statements has those ends; a row a person set to a single +date is kept [0053 revision, #966]. The `[event]` / `[eternal]` prompt marks of 0031 d4 and the zoned-time sentence of 0024 belonged to the typed contract and are gone with it; the write rule in the store is what remains. **Derived rows** store the interval as read: the intersection of premise read intervals, an anchored diff --git a/migrations/0097_a_binding_says_which_end_a_moment_marks.sql b/migrations/0097_a_binding_says_which_end_a_moment_marks.sql index dc358c104..72a1a59c9 100644 --- a/migrations/0097_a_binding_says_which_end_a_moment_marks.sql +++ b/migrations/0097_a_binding_says_which_end_a_moment_marks.sql @@ -10,5 +10,10 @@ -- end 这一刻结束了状态:在 t 关上开放的那一段 -- none 只是那时成立:不算类型化行,陈述留在开放图谱里带着它的日期 -- NULL 是未知(这一列之前的判定、事件与恒常属性):点形状的陈述在状态属性下不算类型化行。 +-- +-- `marks_asked_at` 是对齐器问过这一格的时刻:随绑到状态的判定一起问,或给这一列之前绑上的 +-- 签名补问一次。问过、两票没说出同一个值的,不再问,列进对齐队列等人。NULL = 没问过 +-- (这一列之前的判定、人的判定、不是状态的属性)。 ALTER TABLE phrase_bindings - ADD COLUMN marks TEXT CHECK (marks IN ('start', 'end', 'none')); + ADD COLUMN marks TEXT CHECK (marks IN ('start', 'end', 'none')), + ADD COLUMN marks_asked_at TIMESTAMPTZ; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index cfc3de4f5..41f96d32d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2127,8 +2127,9 @@ export const en = { alignmentMarksEndHint: "left, resigned from, stepped down as: it ends on that date", alignmentMarksNoneHint: "only seen to hold then: statements with a single date stay in the open graph", + alignmentMarksRequired: "Say what a single date marks first.", alignmentAwaitsMarks: (property: string) => - `Bound to ${property} before the aligner asked what a single date marks. Until you say, statements with a single date stay in the open graph.`, + `Bound to ${property}, but what a single date marks is not settled. Until you say, statements with a single date stay in the open graph.`, alignmentRuleImplies: (property: string) => `also implies ${property}`, alignmentRuleObjectIsStatement: "object: the statement's own object", alignmentRuleReading: (reading: string) => `object: read from the words as ${reading.replace(/_/g, " ")}`, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3ec39dde9..571ce285f 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1847,8 +1847,9 @@ export const zh: Strings = { alignmentMarksStartHint: "加入、被任命、接手:从那天起成立", alignmentMarksEndHint: "离开、辞去、卸任:到那天为止", alignmentMarksNoneHint: "只是那时看到它成立:只带一个日期的陈述留在开放图谱", + alignmentMarksRequired: "先说单个日期标的是什么。", alignmentAwaitsMarks: (property: string) => - `绑到 ${property} 时对齐器还没问过单个日期标的是什么。你说之前,只带一个日期的陈述留在开放图谱。`, + `已绑到 ${property},单个日期标的是什么还没定。你说之前,只带一个日期的陈述留在开放图谱。`, alignmentRuleImplies: (property: string) => `同时蕴含 ${property}`, alignmentRuleObjectIsStatement: "宾语:陈述自己的宾语", alignmentRuleReading: (reading: string) => `宾语:按「${reading.replace(/_/g, " ")}」从字里读出`, diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index 515261821..86ade8975 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -827,12 +827,14 @@ function AlignmentPhraseRow({ const start = phraseStart(item); const [property, setProperty] = useState(start.property); const [direction, setDirection] = useState<"forward" | "reverse">(start.direction); - const [marks, setMarks] = useState(start.marks); + const [marks, setMarks] = useState(start.marks); // 字面值当宾语的签名只配属性(attribute),两样东西之间的只配关系(relation) const fitting = properties.filter((p) => item.object_is_value ? p.kind === "attribute" : p.kind === "relation", ); const state = asksMarks(fitting, property); + // 状态属性下单个日期标什么没有预选:人选了才能绑 + const unread = state && marks === null; return (
@@ -849,11 +851,13 @@ function AlignmentPhraseRow({ ))}
)} - {item.bound_to ? ( + {item.bound_to && (
{S.review.alignmentAwaitsMarks(item.bound_to)}
- ) : ( + )} + {/* 绑上了的签名只在代理投过票时列两票:它们各说了单个日期标什么 */} + {(!item.bound_to || first || second) && (
{S.review.alignmentVotes(voteText(first), voteText(second))}
@@ -889,10 +893,10 @@ function AlignmentPhraseRow({ {state && ( <> {S.review.alignmentMarks} - size="sm" - value={marks} - onChange={setMarks} + value={marks ?? ""} + onChange={(m) => setMarks(m || null)} options={[ { value: "start", @@ -915,7 +919,8 @@ function AlignmentPhraseRow({ )}