From 87c44749c23b41fdf6b6c570ce3b8def6a5ae799 Mon Sep 17 00:00:00 2001 From: Maya Kid Date: Sat, 17 Oct 2026 14:11:08 +1100 Subject: [PATCH 1/3] An end moved to the end of its period says so on the row Signed-off-by: Maya Kid --- crates/utopia-cli/src/main.rs | 2 +- crates/utopia-core/src/models.rs | 3 + .../src/api/chat_graph_citation_tests.rs | 2 + crates/utopia-server/src/api/graph_routes.rs | 77 ++++- .../src/api/graph_routes_time_tests.rs | 168 +++++++++++ crates/utopia-server/src/api/tools.rs | 1 + crates/utopia-server/src/api/tools_graph.rs | 1 + crates/utopia-store/src/errata.rs | 5 +- crates/utopia-store/src/graph.rs | 101 +++++-- crates/utopia-store/src/materialize.rs | 7 +- crates/utopia-store/src/pending.rs | 1 + crates/utopia-store/src/temporal.rs | 110 +++++-- .../a_corrected_row_says_which_end_changed.rs | 1 + .../an_end_moved_before_the_mark_gets_it.rs | 154 ++++++++++ .../tests/store/a_fact_awaits_a_nod.rs | 1 + .../tests/store/a_moment_marks_its_state.rs | 274 +++++++++++++++++- .../tests/store/a_path_joins_two_entities.rs | 1 + ...line_is_recomputed_from_the_rows_it_has.rs | 2 + .../store/a_wrong_time_can_be_corrected.rs | 1 + .../store/an_end_date_closes_the_open_span.rs | 3 + .../an_event_holds_at_the_moment_it_names.rs | 3 + .../an_unknown_date_is_not_an_open_one.rs | 1 + crates/utopia-store/tests/store/main.rs | 2 + .../the_engine_redraws_only_what_it_drew.rs | 1 + ...1-an-event-holds-at-the-moment-it-names.md | 2 + ...cision-records-the-inputs-it-considered.md | 143 ++++++++- docs/design/ledger.md | 2 +- docs/design/time.md | 7 +- ...114_an_end_that_names_a_period_says_so.sql | 83 ++++++ 29 files changed, 1097 insertions(+), 62 deletions(-) create mode 100644 crates/utopia-server/src/api/graph_routes_time_tests.rs create mode 100644 crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs create mode 100644 migrations/0114_an_end_that_names_a_period_says_so.sql diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index 7a9ba5011..c88fc1b7c 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 = 101; +const CURRENT_SCHEMA_VERSION: u32 = 102; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 360bef0c5..81ba60c41 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -830,6 +830,9 @@ pub struct EntityFact { /// 终点是时间线推出来的(`facts.end_derived`):后一条事实开始时引擎把它关上,原文没说 /// 这一天(#970) pub end_derived: bool, + /// `valid_to` 仍是存的边界;true 说原文讲的是在它之前结束的那个时段(`facts.end_after_period`, + /// #994):「2024 年离开」存 2025-01-01,原文说的是 2024 + pub end_after_period: bool, /// 区间是人改过的(#970):`corrected_ends` 有值。与 `corrected` 不同,那一位是任何一次 /// 改写:自动接续、审阅裁决、人手改 pub time_corrected: bool, diff --git a/crates/utopia-server/src/api/chat_graph_citation_tests.rs b/crates/utopia-server/src/api/chat_graph_citation_tests.rs index f5c23b284..8b09de749 100644 --- a/crates/utopia-server/src/api/chat_graph_citation_tests.rs +++ b/crates/utopia-server/src/api/chat_graph_citation_tests.rs @@ -696,6 +696,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() to_precision: Some("day"), attested_at: None, from_grade: None, + end_after_period: false, }, ) .await? @@ -820,6 +821,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() to_precision: Some("day"), attested_at: None, from_grade: None, + end_after_period: false, }, ) .await? diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index eed176b99..5d6e4f920 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -10,6 +10,10 @@ use crate::auth::AuthUser; use crate::error::ApiResult; use crate::state::AppState; +#[cfg(test)] +#[path = "graph_routes_time_tests.rs"] +mod time_tests; + pub(super) async fn require_kb( state: &AppState, user: &utopia_core::models::User, @@ -273,7 +277,8 @@ pub async fn update_entity( /// 一条事实的新有效区间。**整体替换,不是部分更新**:区间的两端互相定义, /// 「把结束端清空」与「这次不动结束端」必须能分辨,而缺字段与 null 在 JSON 里 -/// 分不开。界面提交的是表单里的四个值,不是差异。 +/// 分不开。界面提交的是表单里的四个值,不是差异。`end_after_period` 是例外:缺省即 +/// 保留,见那一格。 #[derive(Deserialize)] pub struct FactTimePatch { #[serde(default)] @@ -289,6 +294,13 @@ pub struct FactTimePatch { /// 这里升格成字段 #[serde(default)] pub note: Option, + /// 终点是不是原文所说时段之后的那一刻(`facts.end_after_period`,#994)。**唯一一个 + /// 缺省即保留的字段**:读出来什么就发回什么的客户端(不认识这个字段的也一样)不该 + /// 改掉它。不给时,终点(截到精度之后的值与精度)没变、起点还在,就留着存的标记;清掉 + /// 起点的保留时刻、丢掉标记(没有起点的行第一刀不标);别的终点照给的收下。路由从不 + /// 换算:存的、收的都是边界本身,按时段显示与换算是界面的事 + #[serde(default)] + pub end_after_period: Option, } // 梯子只有一张(0024):与数据库的 CHECK、抽取端、显示端同一列 @@ -301,9 +313,21 @@ struct FactInterval { valid_from_precision: Option, valid_to: Option>, valid_to_precision: Option, + end_after_period: bool, + /// 谓词声明成状态:标记只能落在这样的行上 + declared_state: bool, +} + +/// 显式要标记,却不是一个能带它的终点 +fn bad_end_after_period() -> AppError { + AppError::invalid( + "bad_end_after_period", + "An end that names a period needs a state property, a start and an end date.", + ) } -/// 复刻 `facts` 表的两条 CHECK,外加一条数据库没有的:起点不能晚于终点。 +/// 复刻 `facts` 表的 CHECK:两端的精度,标记要有起点和带日期的终点(属性是不是状态,读到 +/// 行之后再查);外加一条数据库没有的:截到各自精度之后,起点不能晚于终点。 /// /// 在这里挡住是为了让错误说人话。交给数据库挡会得到一句约束名,而这三态 /// (仍在持续 / 结束了不知哪天 / 某日结束)本来就是账本里最容易记混的地方。 @@ -330,8 +354,17 @@ fn check_interval(p: &FactTimePatch) -> Result<(), AppError> { )) } } + // 显式要标记:得有起点、终点得有日期(属性是不是状态,读到行之后再看) + if p.end_after_period == Some(true) && (p.valid_from.is_none() || p.valid_to.is_none()) { + return Err(bad_end_after_period()); + } + // 比截到各自精度之后的两端:存下的就是截过的。比原值时,小时精度的 08:30 终点与分钟精度 + // 的 08:10 起点放得过去,截完成了 08:10 → 08:00,存下一段倒着的区间 + let truncate = utopia_store::graph::truncate_to; if let (Some(from), Some(to)) = (p.valid_from, p.valid_to) { - if from > to { + if truncate(from, p.valid_from_precision.as_deref()) + > truncate(to, p.valid_to_precision.as_deref()) + { return Err(AppError::invalid( "interval_inverted", "The start of the interval is after its end.", @@ -360,9 +393,12 @@ pub async fn update_fact_time( // 归属与状态校验:本 KB 的、未作废的事实才可改。派生事实不在此列—— // 它的区间来自前提,改它等于改一个算出来的值,下一轮推理就会覆盖 let before: Option = sqlx::query_as( - "SELECT valid_from, valid_from_precision, valid_to, valid_to_precision - FROM facts - WHERE id = $1 AND kb_id = $2 AND invalidated_at IS NULL AND derived_by_rule IS NULL", + "SELECT f.valid_from, f.valid_from_precision, f.valid_to, f.valid_to_precision, + f.end_after_period, COALESCE(r.temporal = 'state', false) AS declared_state + FROM facts f + LEFT JOIN relation_types r ON r.id = f.predicate_id + WHERE f.id = $1 AND f.kb_id = $2 AND f.invalidated_at IS NULL + AND f.derived_by_rule IS NULL", ) .bind(fact_id) .bind(kb_id) @@ -372,12 +408,34 @@ pub async fn update_fact_time( let Some(before) = before else { return Err(AppError::NotFound.into()); }; + if req.end_after_period == Some(true) && !before.declared_state { + return Err(bad_end_after_period().into()); + } + // 存下的是截到精度之后的值:比较、判标记都按它 + let truncate = utopia_store::graph::truncate_to; + let from = req + .valid_from + .map(|t| truncate(t, req.valid_from_precision.as_deref())); + let to = req + .valid_to + .map(|t| truncate(t, req.valid_to_precision.as_deref())); + // 不给标记:发回存的终点、还带着起点,就是没动它,留着;清掉起点的丢掉(时刻照旧) + let end_after_period = match req.end_after_period { + Some(mark) => mark, + None => { + before.end_after_period + && from.is_some() + && to == before.valid_to + && req.valid_to_precision == before.valid_to_precision + } + }; // 一字未改就不写一条修正进账本:History 上多一行「改过」而区间没动, // 读者会去找那个不存在的差异 - if before.valid_from == req.valid_from + if before.valid_from == from && before.valid_from_precision == req.valid_from_precision - && before.valid_to == req.valid_to + && before.valid_to == to && before.valid_to_precision == req.valid_to_precision + && before.end_after_period == end_after_period { return Ok(Json(json!({ "ok": true, "unchanged": true }))); } @@ -393,6 +451,7 @@ pub async fn update_fact_time( // 修正行从被替代的行继承锚点(correct_interval 的 SQL 里):改区间是重述 // 同一份证据,不是更新的证据 attested_at: None, + end_after_period, }; let Some(corrected) = utopia_store::temporal::correct_interval(&state.pool, fact_id, validity).await? @@ -417,12 +476,14 @@ pub async fn update_fact_time( "valid_from_precision": before.valid_from_precision, "valid_to": before.valid_to.map(|t| t.to_rfc3339()), "valid_to_precision": before.valid_to_precision, + "end_after_period": before.end_after_period, }); d["to"] = json!({ "valid_from": req.valid_from.map(|t| t.to_rfc3339()), "valid_from_precision": req.valid_from_precision, "valid_to": req.valid_to.map(|t| t.to_rfc3339()), "valid_to_precision": req.valid_to_precision, + "end_after_period": end_after_period, }); if let Some(note) = req.note.as_deref().map(str::trim).filter(|s| !s.is_empty()) { d["note"] = json!(note); diff --git a/crates/utopia-server/src/api/graph_routes_time_tests.rs b/crates/utopia-server/src/api/graph_routes_time_tests.rs new file mode 100644 index 000000000..3ddaa6a33 --- /dev/null +++ b/crates/utopia-server/src/api/graph_routes_time_tests.rs @@ -0,0 +1,168 @@ +//! 改区间的路由收发的是存的边界(#994):`end_after_period` 是唯一一个缺省即保留的字段。 +//! +//! 实体详情读得到标记;不带它的请求,终点(时刻与精度)没动、起点还在就留着,改了终点或清掉 +//! 起点就丢掉(时刻照旧);显式的值照收,只改标记也算改了终点;显式 true 得是状态属性、有起点、 +//! 终点有日期;倒置按截到精度之后的两端判。每一步的结果收进一张表一起比,dev 上哪几步不同一次 +//! 看全。没有 `UTOPIA_DATABASE_URL` 时跳过。 +use axum::body::{to_bytes, Body}; +use axum::http::{Request, StatusCode}; +use axum::Router; +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; +use std::sync::Arc; +use tower::ServiceExt; +use uuid::Uuid; + +const MARCH: &str = "2024-03-01T00:00:00Z"; +const FEB: &str = "2024-02-01T00:00:00Z"; +const END: &str = "2025-01-01T00:00:00Z"; +const LATER: &str = "2026-01-01T00:00:00Z"; + +async fn send( + app: &Router, + token: &str, + method: &str, + uri: String, + body: &Value, +) -> anyhow::Result<(StatusCode, Value)> { + let response = app + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_string()))?, + ) + .await?; + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await?; + Ok((status, serde_json::from_slice(&bytes)?)) +} + +/// 一个请求体:起点日精度、终点年精度,空串是没有;标记给了才带 +fn span(from: &str, to: &str, mark: Option) -> Value { + let from = Some(from).filter(|t| !t.is_empty()); + let to = Some(to).filter(|t| !t.is_empty()); + let mut body = json!({ + "valid_from": from, "valid_from_precision": from.map(|_| "day"), + "valid_to": to, "valid_to_precision": to.map(|_| "year"), + }); + if let Some(mark) = mark { + body["end_after_period"] = json!(mark); + } + body +} + +#[tokio::test] +async fn a_correction_keeps_the_mark_unless_the_request_changes_it() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = sqlx::PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let [org, ws, kb, editor, works_for, lin, meridian, marked, loose] = + std::array::from_fn(|_| Uuid::now_v7()); + // Only locally generated UUIDs and the constants above are interpolated into fixture SQL. + sqlx::raw_sql(&format!( + "INSERT INTO organizations(id,name) VALUES ('{org}','fact-time-route'); + INSERT INTO workspaces(id,org_id,name) VALUES ('{ws}','{org}','fact-time-route'); + INSERT INTO users(id,org_id,email,display_name,password_hash) + VALUES ('{editor}','{org}','{editor}@time.test','editor','unused'); + INSERT INTO knowledge_bases(id,workspace_id,name) VALUES ('{kb}','{ws}','times'); + INSERT INTO kb_members(kb_id,user_id,role) VALUES ('{kb}','{editor}','editor'); + INSERT INTO relation_types(id,kb_id,key,label,temporal) + VALUES ('{works_for}','{kb}','works_for','works for','state'); + INSERT INTO entities(id,kb_id,canonical_name) + VALUES ('{lin}','{kb}','Lin Zhao'), ('{meridian}','{kb}','Meridian Systems'); + -- 「left in 2024」关上的一行,记着说的是 2024;同样的区间、没有谓词的一行 + INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_id,layer,confidence, + valid_from,valid_from_precision,valid_to,valid_to_precision, + end_after_period) VALUES + ('{marked}','{kb}','{lin}','{works_for}','{meridian}','typed',0.9, + '{MARCH}','day','{END}','year',TRUE), + ('{loose}','{kb}','{lin}',NULL,'{meridian}','typed',0.9, + '{MARCH}','day','{END}','year',FALSE);" + )) + .execute(&pool) + .await?; + let result = async { + let dir = tempfile::tempdir()?; + let config = utopia_core::config::AppConfig { + data_dir: dir.path().to_string_lossy().into_owned(), + ..Default::default() + }; + let search = Arc::new(utopia_search::SearchIndex::open(&dir.path().join("search"))?); + let state = crate::state::AppState::new(pool.clone(), &config, search, "test-only".into()); + let token = crate::auth::issue_token(&state, editor)?; + let app = crate::api::router(state, &config); + + let detail = format!("/api/v1/kbs/{kb}/entities/{lin}"); + let (_, detail) = send(&app, &token, "GET", detail, &Value::Null).await?; + let mut read = detail["facts"].as_array().into_iter().flatten(); + let read = read.find(|x| x["id"] == json!(marked)); + let mut seen = vec![("entity detail", json!(read.map(|x| &x["end_after_period"])))]; + let mut wanted = vec![("entity detail", json!(true))]; + // 每一步:改哪一行(None 是上一步写下的那行)、请求体、要的结果——写下的那一行(标记、 + // 人改过哪一端、终点),或拒绝的代码。不带标记的:只改起点留着,改了终点的时刻或精度、 + // 清掉起点都丢掉;显式的照收,只改标记把终点那一端记成人改的;显式 true 缺起点、缺终点、 + // 不是状态的都不收 + let bad = json!([422, "bad_end_after_period"]); + let steps = [ + ("start only", None, span(FEB, END, None), json!([true, "start", END])), + ("mark false", None, span(FEB, END, Some(false)), json!([false, "both", END])), + ("mark true", None, span(FEB, END, Some(true)), json!([true, "both", END])), + ("new end", None, span(FEB, LATER, None), json!([false, "both", LATER])), + ("mark true again", None, span(FEB, END, Some(true)), json!([true, "both", END])), + ( + "precision only", + None, + json!({"valid_from": FEB, "valid_from_precision": "day", + "valid_to": END, "valid_to_precision": "month"}), + json!([false, "both", END]), + ), + ("marked once more", None, span(FEB, END, Some(true)), json!([true, "both", END])), + ("start cleared", None, span("", END, None), json!([false, "both", END])), + ("true, no start", None, span("", END, Some(true)), bad.clone()), + ("true, no end", None, span(FEB, "", Some(true)), bad.clone()), + ("true, no state", Some(loose), span(MARCH, END, Some(true)), bad), + // 小时精度的 08:30 是 08:00,早于分钟精度的起点 08:10 + ( + "inverted once truncated", + Some(loose), + json!({"valid_from": "2026-06-01T08:10:00Z", "valid_from_precision": "minute", + "valid_to": "2026-06-01T08:30:00Z", "valid_to_precision": "hour"}), + json!([422, "interval_inverted"]), + ), + ]; + let mut fact = marked; + for (label, target, body, want) in steps { + let uri = format!("/api/v1/kbs/{kb}/facts/{}", target.unwrap_or(fact)); + let (status, reply) = send(&app, &token, "PATCH", uri, &body).await?; + let got = match reply["fact_id"].as_str() { + Some(id) => { + fact = id.parse()?; + let row: (bool, Option, Option>) = sqlx::query_as( + "SELECT end_after_period, corrected_ends, valid_to FROM facts WHERE id = $1", + ) + .bind(fact) + .fetch_one(&pool) + .await?; + json!(row) + } + None => json!([status.as_u16(), reply["code"]]), + }; + seen.push((label, got)); + wanted.push((label, want)); + } + assert_eq!(seen, wanted); + anyhow::Ok(()) + } + .await; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + result +} diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index 149f68d0a..ec5c6b4e0 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -1532,6 +1532,7 @@ mod tests { stale: false, corrected: false, end_derived: false, + end_after_period: false, time_corrected: false, corrected_ends: None, closed_by_id: None, diff --git a/crates/utopia-server/src/api/tools_graph.rs b/crates/utopia-server/src/api/tools_graph.rs index 0a1beb834..70fa110fa 100644 --- a/crates/utopia-server/src/api/tools_graph.rs +++ b/crates/utopia-server/src/api/tools_graph.rs @@ -1500,6 +1500,7 @@ mod tests { stale: false, corrected: false, end_derived: false, + end_after_period: false, time_corrected: false, corrected_ends: None, closed_by_id: None, diff --git a/crates/utopia-store/src/errata.rs b/crates/utopia-store/src/errata.rs index c8ab95af9..e98c92d5e 100644 --- a/crates/utopia-store/src/errata.rs +++ b/crates/utopia-store/src/errata.rs @@ -617,7 +617,7 @@ async fn apply( let fact = fact_id.ok_or_else(|| AppError::Validation("no fact to retract".into()))?; validity_row = sqlx::query_as( "SELECT valid_from, valid_from_precision, valid_from_grade, valid_to, valid_to_precision, - attested_from FROM facts WHERE id = $1", + end_after_period, attested_from FROM facts WHERE id = $1", ) .bind(fact) .fetch_optional(pool) @@ -647,6 +647,8 @@ async fn apply( to: v.valid_to, to_precision: v.valid_to_precision.as_deref(), attested_at: v.attested_from.or(doc_time), + // 新行接旧行的终点,也接它说的是哪个时段(#994);改到事件、恒常属性上的,写入口丢掉 + end_after_period: v.end_after_period, }; let object = match (target.object_id, &target.object_value) { (Some(o), _) => FactObject::Entity(o), @@ -719,6 +721,7 @@ struct ValidityRow { valid_from_grade: Option, valid_to: Option>, valid_to_precision: Option, + end_after_period: bool, attested_from: Option>, } diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index 1ef3c1a87..96e2c11a8 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -340,6 +340,10 @@ pub struct Validity<'a> { /// `C` 有时间词但锚不到。`None` = 没经过时间解析(人写的、规则算的),按 A 算。 /// 时态引擎读它决定这一行能不能关上前任 pub from_grade: Option<&'a str>, + /// 终点是原文所说时段之后的那一刻(`facts.end_after_period`,#994):「2024 年离开」关上 + /// 2024-03-01 起的那段,存 2025-01-01,原文说的是 2024。只看值分不出它和「到 2025 年」。 + /// 写入口归一(`drop_period_mark_unless`),只留在声明成状态、有起点、终点有日期的行上 + pub end_after_period: bool, } /// `valid_to_precision` 表示「结束了,但不知道是哪天」。 @@ -458,6 +462,7 @@ impl<'a> Validity<'a> { to_precision: None, attested_at: None, from_grade: None, + end_after_period: false, } } @@ -527,6 +532,16 @@ impl<'a> Validity<'a> { self } + /// 「终点说的是它之前那个时段」只对声明成状态、有起点、终点有日期的行有意义:两个写入口 + /// (`insert_fact_on`、`correct_interval`)在 [`Validity::under`] 之后各过一遍,其余的 + /// 标记在这里丢掉。事件归一成一刻、恒常抹掉日期、没有谓词的行不过 `under`,都落在这里。 + /// 没有起点的行第一刀不标(约束 `facts_end_after_period_needs_a_stated_end` 同一句话): + /// 终点只在起点落在那个时段里时才被挪到时段的尽头 + pub(crate) fn drop_period_mark_unless(mut self, declared_state: bool) -> Self { + self.end_after_period &= declared_state && self.from.is_some() && self.to.is_some(); + self + } + /// 这条断言是否已经不再成立——**两种结束都算**。 /// /// 判据写在这里而不是散在各处的 `valid_to.is_some()`:那种写法会把 @@ -592,7 +607,8 @@ pub(crate) async fn insert_fact_on( validity.truncated().under(temporal)? } else { validity.truncated() - }; + } + .drop_period_mark_unless(declared_state); let same_sql = match object { FactObject::Entity(_) => { "SELECT id, valid_from, valid_to, valid_to_precision FROM facts @@ -650,9 +666,14 @@ pub(crate) async fn insert_fact_on( && vtp.as_deref() == Some(ENDED_UNKNOWN) && validity.from.is_none_or(|f| Some(f) == *vf) }) { - if let Some(stated) = - crate::temporal::state_derived_end(&mut *conn, *ended, None, validity.attested_at) - .await? + if let Some(stated) = crate::temporal::state_derived_end( + &mut *conn, + *ended, + None, + false, + validity.attested_at, + ) + .await? { return Ok((stated, true)); } @@ -674,27 +695,43 @@ pub(crate) async fn insert_fact_on( // 照旧关在时段的开头(0053 修订 2026-09-27)。同一天开始又结束的也是这样:关在 // 那一天的尽头,不关在它自己的起点——那是一段不成立的状态(#966) let period_end = declared_state.then(|| bucket_end(to, Some(precision))); + // 关在哪一刻,和终点是不是因此挪到了时段的尽头:挪了的,行上记下终点说的是它之前 + // 那个时段(`end_after_period`,#994),读者才印得出原文说的 2024 let closes_at = |from: Option>| match (from, period_end) { - (Some(f), Some(end)) if to <= f && f < end => end, - _ => to, + (Some(f), Some(end)) if to <= f && f < end => (end, true), + _ => (to, false), }; // 已经关在那一刻的:同一件事,复用那一行(引擎推的终点改成写明的,同上) if let Some((ended, from, _, _)) = same .iter() - .find(|(_, vf, vt, _)| *vt == Some(closes_at(*vf))) + .find(|(_, vf, vt, _)| *vt == Some(closes_at(*vf).0)) { + let (at, moved) = closes_at(*from); if let Some(stated) = crate::temporal::state_derived_end( &mut *conn, *ended, - Some((closes_at(*from), precision)), + Some((at, precision)), + moved, validity.attested_at, ) .await? { return Ok((stated, true)); } - attest_earlier(&mut *conn, *ended, validity.attested_at, true).await?; - return Ok((*ended, false)); + // 写明的终点被另一种说法再说一遍:那一行是「到 2025 年」关上的,这次是「2024 年 + // 离开」,同一个终点。标上它,两句谁先到都一样。上面只比了时刻:人改过的终点、 + // 别的精度写明的终点不改,由 `mark_end_after_period` 锁住那一行再查。 + // 标上了也是并进那一行,不报新行:报成新行,物化会给它记上这句的 + // `from_statement_id`、规则会记上 `implied`,人关上的行就成了算出来的,这句的 + // 陈述一没,它跟着被作废 + let marked = if moved { + crate::temporal::mark_end_after_period(&mut *conn, *ended, precision).await? + } else { + None + }; + let row = marked.unwrap_or(*ended); + attest_earlier(&mut *conn, row, validity.attested_at, true).await?; + return Ok((row, false)); } let open = same .iter() @@ -708,13 +745,9 @@ pub(crate) async fn insert_fact_on( }) .max_by_key(|(_, vf, _, _)| *vf); if let Some((open, from, _, _)) = open { - if let Some(closed) = crate::temporal::close_superseded( - &mut *conn, - *open, - closes_at(*from), - precision, - ) - .await? + let (at, moved) = closes_at(*from); + if let Some(closed) = + crate::temporal::close_stated(&mut *conn, *open, at, precision, moved).await? { return Ok((closed, true)); } @@ -724,13 +757,16 @@ pub(crate) async fn insert_fact_on( // 精确重复:同 valid_from → 复用。同起点、**这次带了终点、那行还开着** → 关上它 // (「自 2020-01-10 起任董事」之后读到「2020-01-10 至 2024-04-30 任董事」) if let Some((existing, _, vt, vtp)) = same.iter().find(|(_, vf, _, _)| *vf == validity.from) { + // 这次带来的终点若说的是它之前那个时段(勘误改出来的行接旧行的标记),关上时一起带上; + // 复用的那行原样留着它自己的终点和标记 if temporal == Temporal::State && vt.is_none() && vtp.is_none() { if let Some(to) = validity.to { - if let Some(closed) = crate::temporal::close_superseded( + if let Some(closed) = crate::temporal::close_stated( &mut *conn, *existing, to, validity.to_precision.unwrap_or("day"), + validity.end_after_period, ) .await? { @@ -747,6 +783,7 @@ pub(crate) async fn insert_fact_on( &mut *conn, *existing, stated_to, + validity.end_after_period, validity.attested_at, ) .await? @@ -800,22 +837,22 @@ pub(crate) async fn insert_fact_on( if vf.is_some() { return None; } - let end = match (validity.to, *vt) { + let (end, moved) = 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(end), true) } (Some(to), Some(t)) if to != t => return None, - (_, vt) => vt, + (_, vt) => (vt, false), }; - Some((*id, end, vtp.clone())) + Some((*id, end, vtp.clone(), moved)) }), None => None, }; - if let Some((_, Some(vt), vtp)) = &refine_target { + if let Some((_, Some(vt), vtp, moved)) = &refine_target { if validity.to.is_none() { validity.to = Some(*vt); validity.to_precision = vtp.as_deref().map(|p| match p { @@ -827,9 +864,11 @@ pub(crate) async fn insert_fact_on( "second" => "second", _ => ENDED_UNKNOWN, }); + // 沿用来的终点挪到了时段的尽头:与收尾那条路同一个标记,两种陈述顺序写下同一行 + validity.end_after_period = *moved; } } - let refine_target = refine_target.map(|(id, _, _)| id); + let refine_target = refine_target.map(|(id, _, _, _)| id); let id = Uuid::now_v7(); let insert_sql = match object { @@ -838,17 +877,17 @@ pub(crate) async fn insert_fact_on( "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, - attested_from, attested_to, valid_from_grade) + attested_from, attested_to, valid_from_grade, end_after_period) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, COALESCE($11, now()), - CASE WHEN $9::text = 'unknown' THEN COALESCE($11, now()) END, $12)" + CASE WHEN $9::text = 'unknown' THEN COALESCE($11, now()) END, $12, $13)" } FactObject::Value(_) => { "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_value, valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, - attested_from, attested_to, valid_from_grade) + attested_from, attested_to, valid_from_grade, end_after_period) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, COALESCE($11, now()), - CASE WHEN $9::text = 'unknown' THEN COALESCE($11, now()) END, $12)" + CASE WHEN $9::text = 'unknown' THEN COALESCE($11, now()) END, $12, $13)" } }; let mut ins = sqlx::query(insert_sql) @@ -867,6 +906,7 @@ pub(crate) async fn insert_fact_on( .bind(confidence) .bind(validity.attested_at) .bind(validity.from_grade) + .bind(validity.end_after_period) .execute(&mut *conn) .await?; @@ -1750,7 +1790,7 @@ pub async fn entity_detail( WHERE fe.fact_id = f.id AND {chunk_live}) ) AS stale, (f.supersedes IS NOT NULL) AS corrected, - f.end_derived, {corrected_ends} AS corrected_ends, + f.end_derived, f.end_after_period, {corrected_ends} AS corrected_ends, {corrected_ends} IS NOT NULL AS time_corrected, closer.closed_by_id, closer.closed_by, closer.closed_by_value, CASE WHEN {corrected_ends} IS NOT NULL THEN {correction_note} END @@ -2543,6 +2583,7 @@ mod temporal_shape_tests { to_precision: Some("day"), attested_at: None, from_grade: None, + end_after_period: false, } .under(Temporal::Event) .unwrap(); @@ -2560,6 +2601,7 @@ mod temporal_shape_tests { to_precision: Some("month"), attested_at: None, from_grade: None, + end_after_period: false, } .under(Temporal::Event) .unwrap(); @@ -2604,6 +2646,7 @@ mod temporal_shape_tests { to_precision: Some("day"), attested_at: None, from_grade: None, + end_after_period: false, }; let refused = moment .under(Temporal::State) diff --git a/crates/utopia-store/src/materialize.rs b/crates/utopia-store/src/materialize.rs index cd81523b5..2fc72d21a 100644 --- a/crates/utopia-store/src/materialize.rs +++ b/crates/utopia-store/src/materialize.rs @@ -289,7 +289,8 @@ async fn materialize_in_tx( let reverse = d.direction == "reverse"; // 一刻的陈述在状态属性下按绑定说的读(#966):开始写成从那一刻起,结束写成到那一刻 // 为止(走 `insert_fact_on`「某天结束了」那条路,关上开放的那段)。什么都不算的 - // 在查询里已经排掉了([`MOMENT_UNREAD`]) + // 在查询里已经排掉了([`MOMENT_UNREAD`])。陈述不带 `end_after_period`:终点挪没挪到 + // 时段的尽头由写入路径定(#994 第一刀) let moment = d.temporal.as_deref() == Some("state") && d.valid_from.is_some() && d.valid_from == d.valid_to; @@ -301,6 +302,7 @@ async fn materialize_in_tx( to: d.valid_to, to_precision: d.valid_to_precision.as_deref(), attested_at: d.attested_from, + end_after_period: false, }, (true, Some("start")) => Validity { from: d.valid_from, @@ -309,6 +311,7 @@ async fn materialize_in_tx( to: None, to_precision: None, attested_at: d.attested_from, + end_after_period: false, }, (true, Some("end")) => Validity { from: None, @@ -317,6 +320,7 @@ async fn materialize_in_tx( to: d.valid_from, to_precision: d.valid_from_precision.as_deref(), attested_at: d.attested_from, + end_after_period: false, }, (true, _) => continue, }; @@ -592,6 +596,7 @@ async fn imply_in_tx( to: d.valid_to, to_precision: d.valid_to_precision.as_deref(), attested_at: d.attested_from, + end_after_period: false, }; let value; let object = match (d.object_id, &d.object_value) { diff --git a/crates/utopia-store/src/pending.rs b/crates/utopia-store/src/pending.rs index 822803515..8d581902d 100644 --- a/crates/utopia-store/src/pending.rs +++ b/crates/utopia-store/src/pending.rs @@ -315,6 +315,7 @@ pub async fn confirm(pool: &PgPool, kb_id: Uuid, id: Uuid) -> AppResult { diff --git a/crates/utopia-store/src/temporal.rs b/crates/utopia-store/src/temporal.rs index 7b21cef75..44e1ef0a7 100644 --- a/crates/utopia-store/src/temporal.rs +++ b/crates/utopia-store/src/temporal.rs @@ -590,7 +590,7 @@ async fn tidy( for (id, end) in diff(&rows, ends) { // 重新打开的行只是开着,终点不是谁推出来的 let derived = end != End::Open; - if let Some(corrected) = rewrite_end_tx(tx, id, &end, derived, false).await? { + if let Some(corrected) = rewrite_end_tx(tx, id, &end, derived, false, false).await? { rewritten.insert(id, corrected); report.corrected.push(corrected); } @@ -880,6 +880,19 @@ pub async fn close_superseded<'a>( fact_id: Uuid, valid_to: DateTime, valid_to_precision: &str, +) -> AppResult> { + // 按日期关上的(审核里关、冲突的 close_at、违规的 fact_closed)给的就是终点那一刻,不标 + close_stated(pool, fact_id, valid_to, valid_to_precision, false).await +} + +/// [`close_superseded`],外加这个终点是不是原文所说时段之后的那一刻(`end_after_period`, +/// #994):写入路径把「2024 年离开」关在 2025-01-01 时标上,读者才印得出 2024 +pub(crate) async fn close_stated<'a>( + pool: impl sqlx::Acquire<'a, Database = Postgres>, + fact_id: Uuid, + valid_to: DateTime, + valid_to_precision: &str, + after_period: bool, ) -> AppResult> { // 闭合点截到它的精度(0024):月精度的闭合就是那个月的 1 日 0 点 let end = End::At( @@ -887,7 +900,7 @@ pub async fn close_superseded<'a>( valid_to_precision.to_string(), ); let mut tx = pool.begin().await?; - let corrected = rewrite_end_tx(&mut tx, fact_id, &end, false, false).await?; + let corrected = rewrite_end_tx(&mut tx, fact_id, &end, false, false, after_period).await?; tx.commit().await?; Ok(corrected) } @@ -903,19 +916,28 @@ pub async fn close_with_unknown_end<'a>( attested_at: Option>, ) -> AppResult> { let mut tx = pool.begin().await?; - let corrected = - rewrite_end_tx(&mut tx, fact_id, &End::Unknown(attested_at), false, true).await?; + let corrected = rewrite_end_tx( + &mut tx, + fact_id, + &End::Unknown(attested_at), + false, + true, + false, + ) + .await?; tx.commit().await?; Ok(corrected) } /// 原文说出了一条**引擎关上**的行的终点:改写成原文说的,此后不再重算(#679 第三轮评审)。 -/// `valid_to` 为 `None` 是「结束了,不知哪天」,锚在 `attested_at`。返回修正行 id; -/// 行已作废,或它的终点本来就是写明的,返回 `None` +/// `valid_to` 为 `None` 是「结束了,不知哪天」,锚在 `attested_at`;`after_period`:原文说的 +/// 是那一刻之前的那个时段(#994)。返回修正行 id;行已作废,或它的终点本来就是写明的, +/// 返回 `None` pub async fn state_derived_end<'a>( pool: impl sqlx::Acquire<'a, Database = Postgres>, fact_id: Uuid, valid_to: Option<(DateTime, &str)>, + after_period: bool, attested_at: Option>, ) -> AppResult> { let end = match valid_to { @@ -933,15 +955,55 @@ pub async fn state_derived_end<'a>( .fetch_optional(&mut *tx) .await?; let corrected = match derived { - Some(true) => rewrite_end_tx(&mut tx, fact_id, &end, false, false).await?, + Some(true) => rewrite_end_tx(&mut tx, fact_id, &end, false, false, after_period).await?, _ => None, }; tx.commit().await?; Ok(corrected) } +/// 一条写明的终点又被另一种说法说了一遍:「until 2025」关上的行又读到「left in 2024」。 +/// 时刻不变,改写成带标记的一行(#994);反过来的顺序在收尾那条路上已经标了,两种顺序 +/// 写下同一行。 +/// +/// 调用方(`insert_fact_on`)只比了时刻,这里锁住那一行再查两件事:精度与这次说的相同—— +/// 日精度写明的 2025-01-01 是那一天,不是 2024 年的尽头;终点不是人改过的 +/// (`corrected_ends` 为空或只有起点)——人把显示的 2024 改成 2025,就是要到 2025 年。 +/// 引擎画的终点、已经标过的、没有起点的不在这里。返回修正行 id;不必标的返回 `None` +pub(crate) async fn mark_end_after_period<'a>( + pool: impl sqlx::Acquire<'a, Database = Postgres>, + fact_id: Uuid, + precision: &str, +) -> AppResult> { + let mut tx = pool.begin().await?; + let stated: Option> = sqlx::query_scalar( + "SELECT valid_to FROM facts + WHERE id = $1 AND invalidated_at IS NULL + AND valid_from IS NOT NULL AND valid_to IS NOT NULL + AND valid_to_precision = $2 + AND NOT end_derived AND NOT end_after_period + AND (corrected_ends IS NULL OR corrected_ends = 'start') + FOR UPDATE", + ) + .bind(fact_id) + .bind(precision) + .fetch_optional(&mut *tx) + .await?; + let marked = match stated { + // 起点那一半人改过的照旧是人的:改写的 CASE 把 'start' 原样带过去 + Some(at) => { + let end = End::At(at, precision.to_string()); + rewrite_end_tx(&mut tx, fact_id, &end, false, false, true).await? + } + None => None, + }; + tx.commit().await?; + Ok(marked) +} + /// 作废 + 改写一行的终点。`derived`:这个终点是引擎按时间线推出来的(0057),之后随 -/// 时间线重算;`require_open`:只改还开着的行。 +/// 时间线重算;`require_open`:只改还开着的行;`after_period`:终点是原文所说时段之后的 +/// 那一刻(#994)。 /// /// 先 `FOR UPDATE` 锁住旧行:并行的两次改写只有一次看得见它还活着,另一次拿到 `None`, /// 不会各插一条修正行;往这一行上添证据的也要等它(`graph::add_evidence`),证据不会落在 @@ -952,6 +1014,7 @@ async fn rewrite_end_tx( end: &End, derived: bool, require_open: bool, + after_period: bool, ) -> AppResult> { let alive: Option<(Option>, Option)> = sqlx::query_as( "SELECT valid_from, predicate_id FROM facts @@ -983,13 +1046,16 @@ async fn rewrite_end_tx( End::At(at, precision) => (Some(*at), Some(precision.as_str()), None), End::Unknown(anchor) => (None, Some(crate::graph::ENDED_UNKNOWN), *anchor), }; + // 标记只落在写明、有日期、有起点的终点上(约束 facts_end_after_period_needs_a_stated_end): + // 时间线画的、不知哪天的、重新打开的,和没有起点的行,一律不带——起点是上面锁住时读的 + let after_period = after_period && !derived && from.is_some() && matches!(end, End::At(..)); let corrected = Uuid::now_v7(); sqlx::query( "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, object_value, valid_from, valid_from_precision, valid_from_grade, valid_to, valid_to_precision, confidence, supersedes, attested_from, attested_by, attested_to, end_derived, - from_statement_id, implied, corrected_ends) + from_statement_id, implied, corrected_ends, end_after_period) SELECT $1, kb_id, subject_id, predicate_id, object_id, object_value, valid_from, valid_from_precision, valid_from_grade, $3, $4, confidence, id, attested_from, attested_by, @@ -998,7 +1064,8 @@ async fn rewrite_end_tx( -- 终点在这里被重新写下(文档说了终点,或时间线推了一个),人改过的终点就不在了; -- 起点那一半照旧 CASE corrected_ends WHEN 'both' THEN 'start' WHEN 'end' THEN NULL - ELSE corrected_ends END + ELSE corrected_ends END, + $7 FROM facts WHERE id = $2", ) .bind(corrected) @@ -1007,6 +1074,7 @@ async fn rewrite_end_tx( .bind(precision) .bind(anchor) .bind(derived) + .bind(after_period) .execute(&mut **tx) .await?; carry_open_conflicts(tx, fact_id, corrected).await?; @@ -1073,11 +1141,11 @@ pub async fn rehome_tx( valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, derived_by_rule, supersedes, attested_from, attested_to, end_derived, - from_statement_id, implied, corrected_ends) + from_statement_id, implied, corrected_ends, end_after_period) SELECT $1, kb_id, COALESCE($3, subject_id), predicate_id, COALESCE($4, object_id), object_value, valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, derived_by_rule, id, attested_from, attested_to, end_derived, - from_statement_id, implied, corrected_ends + from_statement_id, implied, corrected_ends, end_after_period FROM facts WHERE id = $2", ) .bind(moved) @@ -1184,12 +1252,15 @@ async fn copy_evidence( /// 改写同一条规矩([`copy_materialization_links`])。不带过去的话,陈述不再被活着的 /// 类型化行代表,画面上它按原话、原来的区间回来,下一轮物化还照它再算一行。 /// -/// 改了哪一端记在修正行上(`corrected_ends`,#970):值或精度变了的那一端,与旧行已有的 -/// 取并集,与修正同一个事务——事实行据此说「这个日期是人改的」,不再靠事后那条审计。 +/// 改了哪一端记在修正行上(`corrected_ends`,#970):值或精度(终点还有标记)变了的那一端, +/// 与旧行已有的取并集,与修正同一个事务——事实行据此说「这个日期是人改的」,不再靠事后那条审计。 /// 人交来的是整段区间,改完两端都是人的(`end_derived` 清掉,时间线不再重画它):原来 /// 是时间线推出来的终点,人没改它的值也是把它钉住了,一样算人的;原来就是原文说的那一端 /// 没改,仍是原文的 /// +/// 终点是不是原文所说时段之后的那一刻(`end_after_period`,#994)由调用方给,这里只归一: +/// 修正路由按请求定它,从不替人换算 +/// /// 返回修正行 id;`None` 表示这条已被并发改写或作废,本次没有动手。 pub async fn correct_interval( pool: &PgPool, @@ -1209,15 +1280,17 @@ pub async fn correct_interval( let validity = match predicate { Some(_) => validity.truncated().under(temporal)?, None => validity.truncated(), - }; + } + .drop_period_mark_unless(predicate.is_some() && temporal == crate::graph::Temporal::State); let mut tx = pool.begin().await?; let corrected = Uuid::now_v7(); + // 标记变了也算终点变了(#994):人把显示的 2024 改成 2025,存的时刻没动,说的却是另一年 let inserted: Option<(Uuid,)> = sqlx::query_as( "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, object_value, valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, supersedes, attested_from, attested_to, - from_statement_id, implied, corrected_ends) + from_statement_id, implied, corrected_ends, end_after_period) SELECT $1, kb_id, subject_id, predicate_id, object_id, object_value, $3, $4, $5, $6, confidence, id, attested_from, @@ -1225,7 +1298,8 @@ pub async fn correct_interval( from_statement_id, implied, CASE WHEN start_marked AND end_marked THEN 'both' WHEN start_marked THEN 'start' - WHEN end_marked THEN 'end' END + WHEN end_marked THEN 'end' END, + $7 FROM (SELECT f.*, c.start_changed OR COALESCE(f.corrected_ends IN ('start', 'both'), false) AS start_marked, @@ -1238,6 +1312,7 @@ pub async fn correct_interval( AS start_changed, f.valid_to IS DISTINCT FROM $5::timestamptz OR f.valid_to_precision IS DISTINCT FROM $6::text + OR f.end_after_period IS DISTINCT FROM $7::boolean AS end_changed) c WHERE f.id = $2 AND f.invalidated_at IS NULL) old RETURNING id", @@ -1248,6 +1323,7 @@ pub async fn correct_interval( .bind(validity.from_precision) .bind(validity.to) .bind(validity.to_precision) + .bind(validity.end_after_period) .fetch_optional(&mut *tx) .await?; // 已被并发修正过:不重复动手(与 close_superseded 同一防线) diff --git a/crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs b/crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs index 581d46b22..a421e6b72 100644 --- a/crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs +++ b/crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs @@ -36,6 +36,7 @@ fn between(from: &str, to: Option<&str>) -> Validity<'static> { to_precision: to.map(|_| "day"), attested_at: None, from_grade: None, + ..Default::default() } } diff --git a/crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs b/crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs new file mode 100644 index 000000000..49efeb716 --- /dev/null +++ b/crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs @@ -0,0 +1,154 @@ +//! 迁移 0114:这一列之前,#975 的两条路已经把终点挪到了时段的尽头,行上却没记下原文说的是 +//! 哪个时段。照物化的来路造行,再把这一列抹掉——那正是它出现之前的样子。回填的每条规则一行: +//! +//! - 标上,都关在时段的尽头:Lin「works for … from 2024-03-01」再「left in 2024」(收尾那条路); +//! Mei 两句反过来(精化那条路);Ada 规则读的「was on contract with … until 2024」(蕴含行); +//! Zhou「from 2023-06-01」加「left on 2023-06-01」(同一天开始又结束);Tao 同 Lin,起点人挪到 +//! 了 4 月(还在 2024 年里) +//! - 不标:Qian「joined in 2024」(标 start,大小写不同存成了两条)加「until 2025」,到 2025 年 +//! 为止;joined 的绑定翻成 end 之后也不标——起点只来自这两条,同一句话不算另有陈述。Sun 的 +//! 终点人改过;Wu 是人写的,没有来源陈述 +//! +//! 第二遍在绑定翻转之后跑,此外什么都不改。迁移的语句不按 kb 过滤,改的是全库的行,所以这个 +//! 测试在 tests/ 顶层自成一个二进制(与 0099、0101 同理)。没有 `UTOPIA_DATABASE_URL` 时跳过 +//! 而不是失败。自建自拆,绝不碰已有的库。 + +use sqlx::PgPool; +use uuid::Uuid; + +const MIGRATION: &str = + include_str!("../../../migrations/0114_an_end_that_names_a_period_says_so.sql"); + +#[tokio::test] +async fn rows_whose_end_was_moved_before_the_mark_are_marked_once() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let [org, ws, kb, person, company, works_for, meridian, rule] = + std::array::from_fn(|_| Uuid::now_v7()); + let people = + ["Lin", "Mei", "Ada", "Zhou", "Qian", "Sun", "Wu", "Tao"].map(|n| (n, Uuid::now_v7())); + let entities = people.map(|(name, id)| format!("('{id}', '{kb}', '{person}', '{name}')")); + let [lin, mei, ada, zhou, qian, sun, wu, tao] = people.map(|(_, id)| id); + // 开放陈述,两端按时间解析写的形状:「自」只有起点,「至」只有终点,一刻两端同值。物化按 + // id 的先后读,id 照这里的顺序取 + let from = |at: &str, p: &str| format!("'{at}T00:00:00Z', '{p}', NULL, NULL"); + let until = |at: &str, p: &str| format!("NULL, NULL, '{at}T00:00:00Z', '{p}'"); + let on = |at: &str, p: &str| format!("'{at}T00:00:00Z', '{p}', '{at}T00:00:00Z', '{p}'"); + let said = [ + (lin, "works for", from("2024-03-01", "day")), + (lin, "left", on("2024-01-01", "year")), + (mei, "left", on("2024-01-01", "year")), + (mei, "works for", from("2024-03-01", "day")), + (ada, "works for", from("2024-03-01", "day")), + (ada, "was on contract with", until("2024-01-01", "year")), + (zhou, "works for", from("2023-06-01", "day")), + (zhou, "left", on("2023-06-01", "day")), + (qian, "joined", on("2024-01-01", "year")), + (qian, "Joined", on("2024-01-01", "year")), + (qian, "works for", until("2025-01-01", "year")), + (sun, "works for", from("2024-03-01", "day")), + (sun, "left", on("2024-01-01", "year")), + (tao, "works for", from("2024-03-01", "day")), + (tao, "left", on("2024-01-01", "year")), + ] + .map(|(who, phrase, span)| { + let id = Uuid::now_v7(); + format!("('{id}', '{kb}', '{who}', '{meridian}', 'open', '{phrase}', 0.9, {span})") + }); + // Only locally generated UUIDs and the literals above are interpolated into fixture SQL. + sqlx::raw_sql(&format!( + "INSERT INTO organizations (id, name) VALUES ('{org}', 'an-end-before-the-mark'); + INSERT INTO workspaces (id, org_id, name) VALUES ('{ws}', '{org}', 'an-end-before-the-mark'); + INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ('{kb}', '{ws}', 'an-end-before-the-mark'); + 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'); + INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES + ('{meridian}', '{kb}', '{company}', 'Meridian Systems'), {entities}; + INSERT INTO phrase_bindings + (id, kb_id, phrase, subject_type_id, object_type_id, object_is_value, + relation_type_id, direction, status, marks) VALUES + (gen_random_uuid(), '{kb}', 'works for', '{person}', '{company}', false, + '{works_for}', 'forward', 'bound', NULL), + (gen_random_uuid(), '{kb}', 'left', '{person}', '{company}', false, + '{works_for}', 'forward', 'bound', 'end'), + (gen_random_uuid(), '{kb}', 'joined', '{person}', '{company}', false, + '{works_for}', 'forward', 'bound', 'start'); + INSERT INTO implication_rules (id, kb_id, trigger, phrase, subject_type_id, + object_type_id, conclude_property_id, reading, + status, decided_by) + VALUES ('{rule}', '{kb}', 'phrase', 'was on contract with', '{person}', + '{company}', '{works_for}', NULL, 'approved', 'person'); + INSERT INTO facts (id, kb_id, subject_id, object_id, layer, phrase, confidence, + valid_from, valid_from_precision, valid_to, valid_to_precision) + VALUES {said};", + entities = entities.join(", "), + said = said.join(", "), + )) + .execute(&pool) + .await?; + + let run = async { + utopia_store::materialize::materialize(&pool, kb).await?; + // 这一列之前的样子;Sun 的终点人改过,Tao 的起点人改过;Wu 是人写的一行,同样关在 + // 2025-01-01 + sqlx::raw_sql(&format!( + "UPDATE facts SET end_after_period = FALSE WHERE kb_id = '{kb}'; + UPDATE facts SET corrected_ends = 'end' + WHERE subject_id = '{sun}' AND layer = 'typed' AND invalidated_at IS NULL; + UPDATE facts SET corrected_ends = 'start', valid_from = '2024-04-01T00:00:00Z' + WHERE subject_id = '{tao}' AND layer = 'typed' AND invalidated_at IS NULL; + INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, layer, confidence, + valid_from, valid_from_precision, valid_to, valid_to_precision) + VALUES ('{}', '{kb}', '{wu}', '{works_for}', '{meridian}', 'typed', 0.9, + '2024-03-01T00:00:00Z', 'day', '2025-01-01T00:00:00Z', 'year');", + Uuid::now_v7() + )) + .execute(&pool) + .await?; + sqlx::raw_sql(MIGRATION).execute(&pool).await?; + // joined 的绑定后来翻成了 end:已经算出的那行留着(0053),回填读的是绑定现在的值 + sqlx::query( + "UPDATE phrase_bindings SET marks = 'end' WHERE kb_id = $1 AND phrase = 'joined'", + ) + .bind(kb) + .execute(&pool) + .await?; + let again = sqlx::raw_sql(MIGRATION).execute(&pool).await?; + // 每人活着的那一行类型化事实:终点那天、标记 + let rows: Vec<(String, String, bool)> = sqlx::query_as( + "SELECT e.canonical_name, to_char(f.valid_to AT TIME ZONE 'UTC', 'YYYY-MM-DD'), + f.end_after_period + FROM facts f JOIN entities e ON e.id = f.subject_id + WHERE f.kb_id = $1 AND f.layer = 'typed' AND f.invalidated_at IS NULL + ORDER BY 1", + ) + .bind(kb) + .fetch_all(&pool) + .await?; + let expected = [ + ("Ada", "2025-01-01", true), + ("Lin", "2025-01-01", true), + ("Mei", "2025-01-01", true), + ("Qian", "2025-01-01", false), + ("Sun", "2025-01-01", false), + ("Tao", "2025-01-01", true), + ("Wu", "2025-01-01", false), + ("Zhou", "2023-06-02", true), + ] + .map(|(name, end, marked)| (name.to_string(), end.to_string(), marked)); + assert_eq!((rows, again.rows_affected()), (expected.to_vec(), 0)); + anyhow::Ok(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + run +} diff --git a/crates/utopia-store/tests/store/a_fact_awaits_a_nod.rs b/crates/utopia-store/tests/store/a_fact_awaits_a_nod.rs index 0249cc06e..c2a0a0825 100644 --- a/crates/utopia-store/tests/store/a_fact_awaits_a_nod.rs +++ b/crates/utopia-store/tests/store/a_fact_awaits_a_nod.rs @@ -120,6 +120,7 @@ async fn a_remembered_fact_waits_for_a_nod() -> anyhow::Result<()> { to_precision: None, attested_at: None, from_grade: None, + ..Default::default() }, confidence: 0.9, chunk_id: f.chunk, 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 7f2634eb8..d454a9979 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 @@ -14,6 +14,10 @@ //! 照旧;已有的空段不并进后来的观察,也不被它关上;勘误改不了一段不成立的时间,也不能把 //! 一刻的事件改成状态;规则没有 marks,只说了一刻的陈述不蕴含状态,只有起点的照旧。 //! +//! 终点因此挪到时段尽头的行记着原文说的是那个时段(`end_after_period`,#994):收尾、精化 +//! 两条路都记;关在「until 2025」的行再读到「left in 2024」也记(人只改过起点的也记),时间线画 +//! 的终点被它说出来也记;搬主宾、勘误改出来的行带着它,改成事件的不带。 +//! //! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 use chrono::{DateTime, Utc}; @@ -45,6 +49,7 @@ fn moment() -> Validity<'static> { to_precision: Some("day"), attested_at: None, from_grade: None, + ..Default::default() } } @@ -57,6 +62,7 @@ fn ending(at: DateTime) -> Validity<'static> { to_precision: Some("day"), attested_at: None, from_grade: None, + ..Default::default() } } @@ -274,6 +280,21 @@ impl Base { ) } + /// Lin Zhao —works_for→ object,走写入口(抽取、点头、人写的事实同一个门) + async fn write_to(&self, object: Uuid, v: Validity<'_>) -> anyhow::Result { + let (fact, _) = graph::insert_fact( + &self.pool, + self.kb, + self.lin, + Some(self.works_for), + object, + v, + 0.9, + ) + .await?; + Ok(fact) + } + async fn remove(self) -> anyhow::Result<()> { sqlx::query("DELETE FROM organizations WHERE id = $1") .bind(self.org) @@ -997,7 +1018,8 @@ async fn a_row_a_person_set_to_a_single_date_is_not_retired() -> anyhow::Result< /// 终点说的是一个时段,比的却是时刻(#975 评审):「left … in 2024」存成 2024-01-01,年精度。 /// 「works for … from 2024-03-01」从这个时段里开始,关在时段的尽头 2025-01-01,而不是另立一行 -/// `- → 2024`、让它一直开着到 2025 年以后。先读到哪一句都一样 +/// `- → 2024`、让它一直开着到 2025 年以后。先读到哪一句都一样;行上记着终点说的是 2024 +/// (#994:收尾那条路和精化那条路都记) #[tokio::test] async fn a_coarse_end_closes_a_row_that_starts_inside_its_period() -> anyhow::Result<()> { let Some(pool) = pool().await? else { @@ -1008,6 +1030,7 @@ async fn a_coarse_end_closes_a_row_that_starts_inside_its_period() -> anyhow::Re t("2024-01-01T00:00:00Z"), t("2025-01-01T00:00:00Z"), ); + let mut marks: Vec> = Vec::new(); for order in [["works for", "left"], ["left", "works for"]] { let base = Base::new(&pool).await?; let run = async { @@ -1037,12 +1060,22 @@ async fn a_coarse_end_closes_a_row_that_starts_inside_its_period() -> anyhow::Re "{order:?}: not still working there in 2025" ); assert_eq!(materialize(&pool, base.kb).await?, Outcome::default()); + marks.push( + sqlx::query_scalar( + "SELECT end_after_period FROM facts + WHERE kb_id = $1 AND layer = 'typed' AND invalidated_at IS NULL", + ) + .bind(base.kb) + .fetch_all(&pool) + .await?, + ); anyhow::Ok(()) } .await; base.remove().await?; run?; } + assert_eq!(marks, [[true], [true]], "the end names 2024, not 2025"); Ok(()) } @@ -1108,6 +1141,245 @@ async fn a_coarse_end_leaves_rows_outside_its_period_as_before() -> anyhow::Resu Ok(()) } +/// 一个终点,两种说法(#994):人写的 2024-03-01 起的一行关在「until 2025」,文档又说「left in +/// 2024」,同一个终点:标上,并进那一行,它仍是人的(反过来的顺序是收尾那条路);另一份文档再说 +/// 一次,标过的原样留着。人只把起点挪到 4 月的一行(Dune)也标。日精度写明的 2025-01-01(Aster) +/// 是那一天,人改过的终点(Cobalt)是人的,都不改写 +#[tokio::test] +async fn one_end_named_two_ways_is_marked() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let (year, next_year) = (t("2024-01-01T00:00:00Z"), t("2025-01-01T00:00:00Z")); + let since = Validity::starting(Some(t("2024-03-01T00:00:00Z")), Some("day")); + let until_2025 = Validity { + to_precision: Some("year"), + ..ending(next_year) + }; + let (cobalt, dune) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) + VALUES ($1, $2, $3, 'Cobalt'), ($4, $2, $3, 'Dune')", + ) + .bind(cobalt) + .bind(base.kb) + .bind(base.company) + .bind(dune) + .execute(&pool) + .await?; + for (object, end) in [(base.meridian, until_2025), (base.aster, ending(next_year))] { + base.write_to(object, since).await?; + base.write_to(object, end).await?; + } + let by_hand = Validity { + to: Some(next_year), + to_precision: Some("year"), + ..since + }; + let open = base.write_to(cobalt, since).await?; + utopia_store::temporal::correct_interval(&pool, open, by_hand).await?; + let stated = base.write_to(dune, by_hand).await?; + let april = Validity { + from: Some(t("2024-04-01T00:00:00Z")), + ..by_hand + }; + utopia_store::temporal::correct_interval(&pool, stated, april).await?; + for object in [base.meridian, base.meridian, base.aster, cobalt, dune] { + base.statement_to("left", object, Some(year), Some(year), "year") + .await?; + } + base.bind("left", Some("end")).await?; + materialize(&pool, base.kb).await?; + // 每个宾语活着的那一行:标记,仍是人的(不是这句算出来的),那边被取代过几行 + let rows: Vec<(String, bool, bool, i64)> = sqlx::query_as( + "SELECT o.canonical_name, f.end_after_period, f.from_statement_id IS NULL, + (SELECT count(*) FROM facts g WHERE g.layer = 'typed' + AND g.object_id = f.object_id AND g.invalidated_at IS NOT NULL) + FROM facts f JOIN entities o ON o.id = f.object_id + WHERE f.kb_id = $1 AND f.layer = 'typed' AND f.invalidated_at IS NULL + ORDER BY 1", + ) + .bind(base.kb) + .fetch_all(&pool) + .await?; + // Meridian 开着的那行先被「until 2025」关上、再被标上,各取代一次;Dune 改过一次起点 + let expected = [ + ("Aster Labs", false, 1), + ("Cobalt", false, 1), + ("Dune", true, 2), + ("Meridian Systems", true, 2), + ] + .map(|(o, mark, gone)| (o.to_string(), mark, true, gone)); + assert_eq!(rows, expected); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} + +/// 时间线画的终点被原文说出来(#994):works_for 声明成函数性、继任者 2025-01-01 起,时间线在那天 +/// 画上 Meridian 的终点(日精度);「left in 2024」说的是同一刻,改成写明的年精度终点,记着说的是 +/// 2024 +#[tokio::test] +async fn a_drawn_end_the_text_names_as_a_period_is_marked() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let next_year = t("2025-01-01T00:00:00Z"); + sqlx::query("UPDATE relation_types SET functional = true WHERE id = $1") + .bind(base.works_for) + .execute(&pool) + .await?; + let since = Validity::starting(Some(t("2024-03-01T00:00:00Z")), Some("day")); + base.write_to(base.meridian, since).await?; + let next = Validity::starting(Some(next_year), Some("day")); + let next = base.write_to(base.aster, next).await?; + utopia_store::temporal::reconcile_moved_facts(&pool, base.kb, &[next]).await?; + // Meridian 那一行的终点:精度、是不是时间线画的、标记 + let end = || { + sqlx::query_as::<_, (String, bool, bool)>( + "SELECT valid_to_precision, end_derived, end_after_period FROM facts + WHERE object_id = $1 AND invalidated_at IS NULL", + ) + .bind(base.meridian) + .fetch_one(&pool) + }; + let drawn = end().await?; + let left_in_2024 = Validity { + to_precision: Some("year"), + ..ending(t("2024-01-01T00:00:00Z")) + }; + base.write_to(base.meridian, left_in_2024).await?; + assert_eq!( + (drawn, end().await?), + (("day".into(), true, false), ("year".into(), false, true)), + "drawn, then stated as the end of 2024" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} + +/// 标记随行走(#994):撤回合并搬主宾用的 `rehome_tx` 带着它;勘误改出来的行接旧行的标记——改到 +/// 同起点、还开着的一行的宾语上,那一行经同起点那条路关上;改到同起点、终点是时间线画的一行的 +/// 宾语上,那个终点改成写明的——都带着它;属性改成事件的,写入口丢掉它 +#[tokio::test] +async fn a_marked_end_rides_through_a_rehome_and_a_revision() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let kb = base.kb; + let since = Validity::starting(Some(t("2024-03-01T00:00:00Z")), Some("day")); + let closed = Validity { + to: Some(t("2025-01-01T00:00:00Z")), + to_precision: Some("year"), + ..since + }; + let row = base.write_to(base.meridian, closed).await?; + let (cobalt, drawn, doc) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (company, lin, works_for) = (base.company, base.lin, base.works_for); + // Only locally generated UUIDs are interpolated into fixture SQL. + sqlx::raw_sql(&format!( + "UPDATE facts SET end_after_period = TRUE WHERE id = '{row}'; + INSERT INTO entities (id, kb_id, type_id, canonical_name) + VALUES ('{cobalt}', '{kb}', '{company}', 'Cobalt'); + INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, layer, confidence, + valid_from, valid_from_precision, valid_to, valid_to_precision, + end_derived) + VALUES ('{drawn}', '{kb}', '{lin}', '{works_for}', '{cobalt}', 'typed', 0.9, + '2024-03-01T00:00:00Z', 'day', '2025-01-01T00:00:00Z', 'day', TRUE); + INSERT INTO documents (id, kb_id, filename, sha256) + VALUES ('{doc}', '{kb}', 'cv.txt', 'x');" + )) + .execute(&pool) + .await?; + let mut tx = pool.begin().await?; + let moved = utopia_store::temporal::rehome_tx(&mut tx, row, None, Some(base.aster)) + .await? + .expect("the row was live"); + tx.commit().await?; + + // 勘误改三次:宾语改回 Meridian,那边已有一行 2024-03-01 起开着;改到 Cobalt,那边同起点的 + // 一行终点是时间线画的(日精度,上面写下的);属性改成 acquired(事件) + let open = base.write_to(base.meridian, since).await?; + let mut candidate = Candidate { + fact_id: moved, + statement_id: None, + predicate_id: base.works_for, + subject_id: base.lin, + subject: "Lin Zhao".into(), + subject_class: Some("person".into()), + property: "works_for".into(), + property_label: "works for".into(), + object_id: Some(base.aster), + object: "Aster Labs".into(), + object_class: Some("organization".into()), + flag: Some("name_absent".into()), + quote: None, + }; + // 一行的标记,和它取代的是哪一行 + let read = |id: Uuid| { + sqlx::query_as::<_, (bool, Option)>( + "SELECT end_after_period, supersedes FROM facts WHERE id = $1", + ) + .bind(id) + .fetch_one(&pool) + }; + let mut rows = vec![read(moved).await?]; + let run_id = errata::start_run(&pool, kb, doc, 3, 0).await?; + for (property, object) in [ + (None, Some("Meridian Systems")), + (None, Some("Cobalt")), + (Some("acquired"), None), + ] { + let recorded = errata::record( + &pool, + kb, + ActionInput { + run_id, + document_id: doc, + candidate: Some(&candidate), + proposed: Proposed::Revise { + property: property.map(Into::into), + object: object.map(Into::into), + }, + reason: "the document says otherwise", + quote: Some("Lin Zhao left Aster Labs for Meridian Systems"), + document_text: "Lin Zhao left Aster Labs for Meridian Systems in 2024.", + }, + ) + .await?; + candidate.fact_id = recorded + .new_fact_id + .unwrap_or_else(|| panic!("{recorded:?}")); + rows.push(read(candidate.fact_id).await?); + } + assert_eq!( + rows, + [ + (true, Some(row)), + (true, Some(open)), + (true, Some(drawn)), + (false, None) + ], + "rehomed; revised onto an open row and a drawn end; onto an event" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +} + /// 两个值同一刻开始的冲突(works_for 声明成函数性):人选「关上旧的」却不给日期,旧的就关在 /// 新的起点——也是它自己的起点,一段不成立的状态,拒绝(`empty_state_span`)。冲突留着等人 /// 给一个日期 diff --git a/crates/utopia-store/tests/store/a_path_joins_two_entities.rs b/crates/utopia-store/tests/store/a_path_joins_two_entities.rs index 36d516ae6..4ba81a867 100644 --- a/crates/utopia-store/tests/store/a_path_joins_two_entities.rs +++ b/crates/utopia-store/tests/store/a_path_joins_two_entities.rs @@ -109,6 +109,7 @@ async fn seed(pool: &PgPool) -> anyhow::Result { to_precision: to.map(|_| "month"), attested_at: None, from_grade: None, + ..Default::default() }; let mut direct = Uuid::nil(); for (s, p, o, v) in [ diff --git a/crates/utopia-store/tests/store/a_timeline_is_recomputed_from_the_rows_it_has.rs b/crates/utopia-store/tests/store/a_timeline_is_recomputed_from_the_rows_it_has.rs index f6216db57..079f7c750 100644 --- a/crates/utopia-store/tests/store/a_timeline_is_recomputed_from_the_rows_it_has.rs +++ b/crates/utopia-store/tests/store/a_timeline_is_recomputed_from_the_rows_it_has.rs @@ -120,6 +120,7 @@ async fn observe(pool: &PgPool, f: &Fixture, x: Seen<'_>) -> anyhow::Result anyhow::Result<()> to_precision: Some("day"), attested_at: None, from_grade: None, + ..Default::default() }, ) .await? diff --git a/crates/utopia-store/tests/store/a_wrong_time_can_be_corrected.rs b/crates/utopia-store/tests/store/a_wrong_time_can_be_corrected.rs index d06286d1d..c0e85d1f8 100644 --- a/crates/utopia-store/tests/store/a_wrong_time_can_be_corrected.rs +++ b/crates/utopia-store/tests/store/a_wrong_time_can_be_corrected.rs @@ -257,6 +257,7 @@ async fn an_end_that_was_never_there_can_be_taken_back() -> anyhow::Result<()> { to_precision: Some("year"), attested_at: None, from_grade: None, + ..Default::default() }, ) .await? diff --git a/crates/utopia-store/tests/store/an_end_date_closes_the_open_span.rs b/crates/utopia-store/tests/store/an_end_date_closes_the_open_span.rs index 0d2a2cd2a..da6c351ea 100644 --- a/crates/utopia-store/tests/store/an_end_date_closes_the_open_span.rs +++ b/crates/utopia-store/tests/store/an_end_date_closes_the_open_span.rs @@ -26,6 +26,7 @@ fn span( to_precision: to.map(|_| "day"), attested_at: Some(day(attested)), from_grade: None, + ..Default::default() } } @@ -54,6 +55,7 @@ fn ends(to: &str, attested: &str) -> utopia_store::graph::Validity<'static> { to_precision: Some("day"), attested_at: Some(day(attested)), from_grade: None, + ..Default::default() } } @@ -147,6 +149,7 @@ async fn an_end_date_closes_the_open_span() -> anyhow::Result<()> { to_precision: None, attested_at: Some(day("2025-08-01")), from_grade: None, + ..Default::default() }, 0.9, ) diff --git a/crates/utopia-store/tests/store/an_event_holds_at_the_moment_it_names.rs b/crates/utopia-store/tests/store/an_event_holds_at_the_moment_it_names.rs index 53ac19c73..784d50a3a 100644 --- a/crates/utopia-store/tests/store/an_event_holds_at_the_moment_it_names.rs +++ b/crates/utopia-store/tests/store/an_event_holds_at_the_moment_it_names.rs @@ -312,6 +312,7 @@ async fn a_span_an_ending_or_an_unknown_end_given_to_an_event_collapses() -> any to_precision: Some("day"), attested_at: None, from_grade: None, + ..Default::default() }, ) .await?; @@ -338,6 +339,7 @@ async fn a_span_an_ending_or_an_unknown_end_given_to_an_event_collapses() -> any to_precision: Some("month"), attested_at: None, from_grade: None, + ..Default::default() }, ) .await?; @@ -581,6 +583,7 @@ async fn a_person_correcting_an_event_leaves_it_one_moment() -> anyhow::Result<( to_precision: Some("day"), attested_at: None, from_grade: None, + ..Default::default() }, ) .await? diff --git a/crates/utopia-store/tests/store/an_unknown_date_is_not_an_open_one.rs b/crates/utopia-store/tests/store/an_unknown_date_is_not_an_open_one.rs index 302a93db1..010a99b3d 100644 --- a/crates/utopia-store/tests/store/an_unknown_date_is_not_an_open_one.rs +++ b/crates/utopia-store/tests/store/an_unknown_date_is_not_an_open_one.rs @@ -392,6 +392,7 @@ async fn the_anchor_moves_earlier_and_is_inherited() -> anyhow::Result<()> { to_precision: Some("day"), attested_at: None, from_grade: None, + ..Default::default() }, ) .await? diff --git a/crates/utopia-store/tests/store/main.rs b/crates/utopia-store/tests/store/main.rs index 1f61b94b2..f516f69d3 100644 --- a/crates/utopia-store/tests/store/main.rs +++ b/crates/utopia-store/tests/store/main.rs @@ -22,6 +22,8 @@ //! 过滤——修正行、重复行都按全库找,会改到别的测试正在物化、改写的行。 //! - `a_row_a_rule_implied_follows_its_statements`:把迁移 0101 原样跑一遍,同样不按 kb //! 过滤——它按来源重算全库类型化行的见证,会改到别的测试正在物化的行。 +//! - `an_end_moved_before_the_mark_gets_it`:把迁移 0114 原样跑两遍,同样不按 kb 过滤——它给 +//! 全库关在时段尽头的行打标记,会改到别的测试正在关上的行,第二遍也就不再是什么都不改。 //! //! 新加测试默认放这个目录;只有碰上面那类全局状态时才放顶层,并在这里补一行为什么。 mod a_batch_decides_like_a_person; diff --git a/crates/utopia-store/tests/the_engine_redraws_only_what_it_drew.rs b/crates/utopia-store/tests/the_engine_redraws_only_what_it_drew.rs index 8108ce5f3..ad013843c 100644 --- a/crates/utopia-store/tests/the_engine_redraws_only_what_it_drew.rs +++ b/crates/utopia-store/tests/the_engine_redraws_only_what_it_drew.rs @@ -152,6 +152,7 @@ async fn observe(pool: &PgPool, f: &Fixture, x: Seen<'_>) -> anyhow::Result s.id + AND o.valid_from = f.valid_from + AND o.valid_from_precision IS NOT DISTINCT FROM f.valid_from_precision + AND NOT (o.valid_to IS NOT DISTINCT FROM o.valid_from AND EXISTS ( + SELECT 1 FROM phrase_bindings b2 + JOIN entities se2 ON se2.id = o.subject_id + LEFT JOIN entities oe2 ON oe2.id = o.object_id + WHERE b2.kb_id = o.kb_id + AND b2.phrase = lower(btrim(regexp_replace(o.phrase, '\s+', ' ', 'g'))) + AND b2.subject_type_id IS NOT DISTINCT FROM se2.type_id + AND b2.object_is_value = (o.object_id IS NULL) + AND (o.object_id IS NULL OR b2.object_type_id IS NOT DISTINCT FROM oe2.type_id) + AND b2.relation_type_id = f.predicate_id + AND b2.status = 'bound' AND b2.marks = 'end'))))))); From 3f68191d2a81042a05a268d09b7dadeb76afd77f Mon Sep 17 00:00:00 2001 From: Maya Kid Date: Sat, 17 Oct 2026 17:58:13 +1100 Subject: [PATCH 2/3] Name the pull request and the close-by-date issue in the 0053 revision Signed-off-by: Maya Kid --- ...0053-a-phrase-decision-records-the-inputs-it-considered.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 3bbe476aa..5381aae7d 100644 --- a/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md +++ b/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md @@ -1,6 +1,6 @@ # 0053 · A phrase decision records the inputs it considered -- **Status**: Implemented 2026-09-23 (#878, migration 0072) · revised 2026-09-26 (migration 0092) · revised 2026-09-27 (#975, migration 0098) · revised 2026-10-17 (migration 0114) · open: printing the named period (#994 cut 1), derived rows, time resolution's ends (cut 2) +- **Status**: Implemented 2026-09-23 (#878, migration 0072) · revised 2026-09-26 (migration 0092) · revised 2026-09-27 (#975, migration 0098) · revised 2026-10-17 (#1295, migration 0114) · open: printing the named period (#994 cut 1), derived rows, time resolution's ends (cut 2) - **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, #966, #994 @@ -379,7 +379,7 @@ Not doing: - **Cut 2**: time resolution storing a named period's end as the instant after its last unit, a marked end read backwards by `insert_fact_on`, start-less marked rows, `combine`. - **The three closes by date** above: they write the date given, and an end before the start among - them is a separate issue. + them is #1294. - **The merge's duplicate removal**, which compares neither ends nor marks; it predates this. - **History rows**: an invalidated row keeps the stored instant. - **MCP `names[]`**, which carries no precisions, so a mark alone would mean nothing there. From 3211225a73638abe577d64319eb7a021fcded5ca Mon Sep 17 00:00:00 2001 From: Maya Kid Date: Sun, 18 Oct 2026 14:51:41 +1100 Subject: [PATCH 3/3] Leave the backfill to its own pull request and keep the mark's rule in one helper Signed-off-by: Maya Kid --- crates/utopia-server/src/api/graph_routes.rs | 25 ++- crates/utopia-store/src/graph.rs | 30 +++- crates/utopia-store/src/temporal.rs | 51 +++--- .../an_end_moved_before_the_mark_gets_it.rs | 154 ------------------ crates/utopia-store/tests/store/main.rs | 2 - ...cision-records-the-inputs-it-considered.md | 60 +++---- ...114_an_end_that_names_a_period_says_so.sql | 68 +------- 7 files changed, 91 insertions(+), 299 deletions(-) delete mode 100644 crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index 5d6e4f920..728c51f07 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -326,8 +326,8 @@ fn bad_end_after_period() -> AppError { ) } -/// 复刻 `facts` 表的 CHECK:两端的精度,标记要有起点和带日期的终点(属性是不是状态,读到 -/// 行之后再查);外加一条数据库没有的:截到各自精度之后,起点不能晚于终点。 +/// 复刻 `facts` 表的 CHECK:两端的精度,标记要两端带得了它(`period_mark_fits`,属性是不是 +/// 状态读到行之后再查);外加一条数据库没有的:截到各自精度之后,起点不能晚于终点。 /// /// 在这里挡住是为了让错误说人话。交给数据库挡会得到一句约束名,而这三态 /// (仍在持续 / 结束了不知哪天 / 某日结束)本来就是账本里最容易记混的地方。 @@ -354,8 +354,10 @@ fn check_interval(p: &FactTimePatch) -> Result<(), AppError> { )) } } - // 显式要标记:得有起点、终点得有日期(属性是不是状态,读到行之后再看) - if p.end_after_period == Some(true) && (p.valid_from.is_none() || p.valid_to.is_none()) { + // 显式要标记:两端得带得了它。属性是不是状态要读到行才知道,这里先当它是,只看两端 + if p.end_after_period == Some(true) + && !utopia_store::graph::period_mark_fits(true, p.valid_from, p.valid_to, false) + { return Err(bad_end_after_period()); } // 比截到各自精度之后的两端:存下的就是截过的。比原值时,小时精度的 08:30 终点与分钟精度 @@ -408,9 +410,6 @@ pub async fn update_fact_time( let Some(before) = before else { return Err(AppError::NotFound.into()); }; - if req.end_after_period == Some(true) && !before.declared_state { - return Err(bad_end_after_period().into()); - } // 存下的是截到精度之后的值:比较、判标记都按它 let truncate = utopia_store::graph::truncate_to; let from = req @@ -419,14 +418,22 @@ pub async fn update_fact_time( let to = req .valid_to .map(|t| truncate(t, req.valid_to_precision.as_deref())); - // 不给标记:发回存的终点、还带着起点,就是没动它,留着;清掉起点的丢掉(时刻照旧) + // 读到了行,属性是不是状态也知道了:显式要的标记按整条规矩再判一遍 + if req.end_after_period == Some(true) + && !utopia_store::graph::period_mark_fits(before.declared_state, from, to, false) + { + return Err(bad_end_after_period().into()); + } + // 不给标记:发回存的终点(时刻与精度)、这次的两端还带得了它,就是没动它,留着;清掉起点 + // 的带不了,丢掉(时刻照旧)。属性这里不再判:存的标记是按当时的属性写下的,原样发回的 + // 请求不改写这一行 let end_after_period = match req.end_after_period { Some(mark) => mark, None => { before.end_after_period - && from.is_some() && to == before.valid_to && req.valid_to_precision == before.valid_to_precision + && utopia_store::graph::period_mark_fits(true, from, to, false) } }; // 一字未改就不写一条修正进账本:History 上多一行「改过」而区间没动, diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index 96e2c11a8..ad2c802c7 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -342,7 +342,7 @@ pub struct Validity<'a> { pub from_grade: Option<&'a str>, /// 终点是原文所说时段之后的那一刻(`facts.end_after_period`,#994):「2024 年离开」关上 /// 2024-03-01 起的那段,存 2025-01-01,原文说的是 2024。只看值分不出它和「到 2025 年」。 - /// 写入口归一(`drop_period_mark_unless`),只留在声明成状态、有起点、终点有日期的行上 + /// 写入口归一(`drop_period_mark_unless`),只留在带得了它的行上([`period_mark_fits`]) pub end_after_period: bool, } @@ -532,13 +532,12 @@ impl<'a> Validity<'a> { self } - /// 「终点说的是它之前那个时段」只对声明成状态、有起点、终点有日期的行有意义:两个写入口 - /// (`insert_fact_on`、`correct_interval`)在 [`Validity::under`] 之后各过一遍,其余的 - /// 标记在这里丢掉。事件归一成一刻、恒常抹掉日期、没有谓词的行不过 `under`,都落在这里。 - /// 没有起点的行第一刀不标(约束 `facts_end_after_period_needs_a_stated_end` 同一句话): - /// 终点只在起点落在那个时段里时才被挪到时段的尽头 + /// 「终点说的是它之前那个时段」带不了的行([`period_mark_fits`]),标记在这里丢掉:两个 + /// 写入口(`insert_fact_on`、`correct_interval`)在 [`Validity::under`] 之后各过一遍。 + /// 事件归一成一刻、恒常抹掉日期、没有谓词的行不过 `under`,都落在这里。进写入口的终点是 + /// 写明的,不是时间线画的 pub(crate) fn drop_period_mark_unless(mut self, declared_state: bool) -> Self { - self.end_after_period &= declared_state && self.from.is_some() && self.to.is_some(); + self.end_after_period &= period_mark_fits(declared_state, self.from, self.to, false); self } @@ -551,6 +550,23 @@ impl<'a> Validity<'a> { } } +/// 一个终点带不带得了「说的是它之前那个时段」的标记(`end_after_period`,#994):属性声明成 +/// 状态、有起点、终点有日期、不是时间线画的。 +/// +/// 与约束 `facts_end_after_period_needs_a_stated_end` 同一句话(属性是不是状态约束看不到, +/// 由这里查)。Rust 这边判这件事的都调它:写入口的归一(`Validity::drop_period_mark_unless`)、 +/// `temporal::rewrite_end_tx` 落标记前、`temporal::mark_end_after_period` 补标记前、改区间的 +/// 路由。没有起点的行第一刀不标:终点只在起点落在那个时段里时才被挪到时段的尽头。第二刀让 +/// 时间解析自己写这种终点时,起点那一条在这里和约束里一起去掉 +pub fn period_mark_fits( + declared_state: bool, + from: Option>, + to: Option>, + end_derived: bool, +) -> bool { + declared_state && from.is_some() && to.is_some() && !end_derived +} + /// 一段起止同值的状态(#966):写入、改区间、关上都拒它,说的是同一句话 pub fn empty_state_span() -> AppError { AppError::invalid( diff --git a/crates/utopia-store/src/temporal.rs b/crates/utopia-store/src/temporal.rs index 44e1ef0a7..9b68dda3b 100644 --- a/crates/utopia-store/src/temporal.rs +++ b/crates/utopia-store/src/temporal.rs @@ -969,33 +969,38 @@ pub async fn state_derived_end<'a>( /// 调用方(`insert_fact_on`)只比了时刻,这里锁住那一行再查两件事:精度与这次说的相同—— /// 日精度写明的 2025-01-01 是那一天,不是 2024 年的尽头;终点不是人改过的 /// (`corrected_ends` 为空或只有起点)——人把显示的 2024 改成 2025,就是要到 2025 年。 -/// 引擎画的终点、已经标过的、没有起点的不在这里。返回修正行 id;不必标的返回 `None` +/// 已经标过的不再标。那一行带不带得了标记(引擎画的终点、没有起点的行带不了),读出来交给 +/// `graph::period_mark_fits` 判。返回修正行 id;不必标的返回 `None` pub(crate) async fn mark_end_after_period<'a>( pool: impl sqlx::Acquire<'a, Database = Postgres>, fact_id: Uuid, precision: &str, ) -> AppResult> { let mut tx = pool.begin().await?; - let stated: Option> = sqlx::query_scalar( - "SELECT valid_to FROM facts - WHERE id = $1 AND invalidated_at IS NULL - AND valid_from IS NOT NULL AND valid_to IS NOT NULL - AND valid_to_precision = $2 - AND NOT end_derived AND NOT end_after_period - AND (corrected_ends IS NULL OR corrected_ends = 'start') - FOR UPDATE", - ) - .bind(fact_id) - .bind(precision) - .fetch_optional(&mut *tx) - .await?; - let marked = match stated { + #[allow(clippy::type_complexity)] + let locked_row: Option<(Option>, Option>, bool, bool)> = + sqlx::query_as( + "SELECT f.valid_from, f.valid_to, f.end_derived, + COALESCE(r.temporal = 'state', false) AS declared_state + FROM facts f LEFT JOIN relation_types r ON r.id = f.predicate_id + WHERE f.id = $1 AND f.invalidated_at IS NULL + AND f.valid_to_precision = $2 AND NOT f.end_after_period + AND (f.corrected_ends IS NULL OR f.corrected_ends = 'start') + FOR UPDATE OF f", + ) + .bind(fact_id) + .bind(precision) + .fetch_optional(&mut *tx) + .await?; + let marked = match locked_row { // 起点那一半人改过的照旧是人的:改写的 CASE 把 'start' 原样带过去 - Some(at) => { + Some((from, Some(at), end_derived, declared_state)) + if crate::graph::period_mark_fits(declared_state, from, Some(at), end_derived) => + { let end = End::At(at, precision.to_string()); rewrite_end_tx(&mut tx, fact_id, &end, false, false, true).await? } - None => None, + _ => None, }; tx.commit().await?; Ok(marked) @@ -1046,9 +1051,15 @@ async fn rewrite_end_tx( End::At(at, precision) => (Some(*at), Some(precision.as_str()), None), End::Unknown(anchor) => (None, Some(crate::graph::ENDED_UNKNOWN), *anchor), }; - // 标记只落在写明、有日期、有起点的终点上(约束 facts_end_after_period_needs_a_stated_end): - // 时间线画的、不知哪天的、重新打开的,和没有起点的行,一律不带——起点是上面锁住时读的 - let after_period = after_period && !derived && from.is_some() && matches!(end, End::At(..)); + // 标记只落在带得了它的终点上(`graph::period_mark_fits`):时间线画的、不知哪天的、重新 + // 打开的终点,没有起点或属性不是状态的行,一律不带——起点和谓词是上面锁住时读的。要标时 + // 才去查属性:不标的改写(时间线重画、关成不知哪天)不多一次查询 + let after_period = after_period && { + let declared_state = predicate.is_some() + && crate::graph::predicate_temporal(&mut **tx, predicate).await? + == crate::graph::Temporal::State; + crate::graph::period_mark_fits(declared_state, from, valid_to, derived) + }; let corrected = Uuid::now_v7(); sqlx::query( "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, object_value, diff --git a/crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs b/crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs deleted file mode 100644 index 49efeb716..000000000 --- a/crates/utopia-store/tests/an_end_moved_before_the_mark_gets_it.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! 迁移 0114:这一列之前,#975 的两条路已经把终点挪到了时段的尽头,行上却没记下原文说的是 -//! 哪个时段。照物化的来路造行,再把这一列抹掉——那正是它出现之前的样子。回填的每条规则一行: -//! -//! - 标上,都关在时段的尽头:Lin「works for … from 2024-03-01」再「left in 2024」(收尾那条路); -//! Mei 两句反过来(精化那条路);Ada 规则读的「was on contract with … until 2024」(蕴含行); -//! Zhou「from 2023-06-01」加「left on 2023-06-01」(同一天开始又结束);Tao 同 Lin,起点人挪到 -//! 了 4 月(还在 2024 年里) -//! - 不标:Qian「joined in 2024」(标 start,大小写不同存成了两条)加「until 2025」,到 2025 年 -//! 为止;joined 的绑定翻成 end 之后也不标——起点只来自这两条,同一句话不算另有陈述。Sun 的 -//! 终点人改过;Wu 是人写的,没有来源陈述 -//! -//! 第二遍在绑定翻转之后跑,此外什么都不改。迁移的语句不按 kb 过滤,改的是全库的行,所以这个 -//! 测试在 tests/ 顶层自成一个二进制(与 0099、0101 同理)。没有 `UTOPIA_DATABASE_URL` 时跳过 -//! 而不是失败。自建自拆,绝不碰已有的库。 - -use sqlx::PgPool; -use uuid::Uuid; - -const MIGRATION: &str = - include_str!("../../../migrations/0114_an_end_that_names_a_period_says_so.sql"); - -#[tokio::test] -async fn rows_whose_end_was_moved_before_the_mark_are_marked_once() -> anyhow::Result<()> { - let Some(url) = utopia_store::test_db::url() else { - return Ok(()); - }; - let pool = PgPool::connect(&url).await?; - let [org, ws, kb, person, company, works_for, meridian, rule] = - std::array::from_fn(|_| Uuid::now_v7()); - let people = - ["Lin", "Mei", "Ada", "Zhou", "Qian", "Sun", "Wu", "Tao"].map(|n| (n, Uuid::now_v7())); - let entities = people.map(|(name, id)| format!("('{id}', '{kb}', '{person}', '{name}')")); - let [lin, mei, ada, zhou, qian, sun, wu, tao] = people.map(|(_, id)| id); - // 开放陈述,两端按时间解析写的形状:「自」只有起点,「至」只有终点,一刻两端同值。物化按 - // id 的先后读,id 照这里的顺序取 - let from = |at: &str, p: &str| format!("'{at}T00:00:00Z', '{p}', NULL, NULL"); - let until = |at: &str, p: &str| format!("NULL, NULL, '{at}T00:00:00Z', '{p}'"); - let on = |at: &str, p: &str| format!("'{at}T00:00:00Z', '{p}', '{at}T00:00:00Z', '{p}'"); - let said = [ - (lin, "works for", from("2024-03-01", "day")), - (lin, "left", on("2024-01-01", "year")), - (mei, "left", on("2024-01-01", "year")), - (mei, "works for", from("2024-03-01", "day")), - (ada, "works for", from("2024-03-01", "day")), - (ada, "was on contract with", until("2024-01-01", "year")), - (zhou, "works for", from("2023-06-01", "day")), - (zhou, "left", on("2023-06-01", "day")), - (qian, "joined", on("2024-01-01", "year")), - (qian, "Joined", on("2024-01-01", "year")), - (qian, "works for", until("2025-01-01", "year")), - (sun, "works for", from("2024-03-01", "day")), - (sun, "left", on("2024-01-01", "year")), - (tao, "works for", from("2024-03-01", "day")), - (tao, "left", on("2024-01-01", "year")), - ] - .map(|(who, phrase, span)| { - let id = Uuid::now_v7(); - format!("('{id}', '{kb}', '{who}', '{meridian}', 'open', '{phrase}', 0.9, {span})") - }); - // Only locally generated UUIDs and the literals above are interpolated into fixture SQL. - sqlx::raw_sql(&format!( - "INSERT INTO organizations (id, name) VALUES ('{org}', 'an-end-before-the-mark'); - INSERT INTO workspaces (id, org_id, name) VALUES ('{ws}', '{org}', 'an-end-before-the-mark'); - INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ('{kb}', '{ws}', 'an-end-before-the-mark'); - 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'); - INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES - ('{meridian}', '{kb}', '{company}', 'Meridian Systems'), {entities}; - INSERT INTO phrase_bindings - (id, kb_id, phrase, subject_type_id, object_type_id, object_is_value, - relation_type_id, direction, status, marks) VALUES - (gen_random_uuid(), '{kb}', 'works for', '{person}', '{company}', false, - '{works_for}', 'forward', 'bound', NULL), - (gen_random_uuid(), '{kb}', 'left', '{person}', '{company}', false, - '{works_for}', 'forward', 'bound', 'end'), - (gen_random_uuid(), '{kb}', 'joined', '{person}', '{company}', false, - '{works_for}', 'forward', 'bound', 'start'); - INSERT INTO implication_rules (id, kb_id, trigger, phrase, subject_type_id, - object_type_id, conclude_property_id, reading, - status, decided_by) - VALUES ('{rule}', '{kb}', 'phrase', 'was on contract with', '{person}', - '{company}', '{works_for}', NULL, 'approved', 'person'); - INSERT INTO facts (id, kb_id, subject_id, object_id, layer, phrase, confidence, - valid_from, valid_from_precision, valid_to, valid_to_precision) - VALUES {said};", - entities = entities.join(", "), - said = said.join(", "), - )) - .execute(&pool) - .await?; - - let run = async { - utopia_store::materialize::materialize(&pool, kb).await?; - // 这一列之前的样子;Sun 的终点人改过,Tao 的起点人改过;Wu 是人写的一行,同样关在 - // 2025-01-01 - sqlx::raw_sql(&format!( - "UPDATE facts SET end_after_period = FALSE WHERE kb_id = '{kb}'; - UPDATE facts SET corrected_ends = 'end' - WHERE subject_id = '{sun}' AND layer = 'typed' AND invalidated_at IS NULL; - UPDATE facts SET corrected_ends = 'start', valid_from = '2024-04-01T00:00:00Z' - WHERE subject_id = '{tao}' AND layer = 'typed' AND invalidated_at IS NULL; - INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, layer, confidence, - valid_from, valid_from_precision, valid_to, valid_to_precision) - VALUES ('{}', '{kb}', '{wu}', '{works_for}', '{meridian}', 'typed', 0.9, - '2024-03-01T00:00:00Z', 'day', '2025-01-01T00:00:00Z', 'year');", - Uuid::now_v7() - )) - .execute(&pool) - .await?; - sqlx::raw_sql(MIGRATION).execute(&pool).await?; - // joined 的绑定后来翻成了 end:已经算出的那行留着(0053),回填读的是绑定现在的值 - sqlx::query( - "UPDATE phrase_bindings SET marks = 'end' WHERE kb_id = $1 AND phrase = 'joined'", - ) - .bind(kb) - .execute(&pool) - .await?; - let again = sqlx::raw_sql(MIGRATION).execute(&pool).await?; - // 每人活着的那一行类型化事实:终点那天、标记 - let rows: Vec<(String, String, bool)> = sqlx::query_as( - "SELECT e.canonical_name, to_char(f.valid_to AT TIME ZONE 'UTC', 'YYYY-MM-DD'), - f.end_after_period - FROM facts f JOIN entities e ON e.id = f.subject_id - WHERE f.kb_id = $1 AND f.layer = 'typed' AND f.invalidated_at IS NULL - ORDER BY 1", - ) - .bind(kb) - .fetch_all(&pool) - .await?; - let expected = [ - ("Ada", "2025-01-01", true), - ("Lin", "2025-01-01", true), - ("Mei", "2025-01-01", true), - ("Qian", "2025-01-01", false), - ("Sun", "2025-01-01", false), - ("Tao", "2025-01-01", true), - ("Wu", "2025-01-01", false), - ("Zhou", "2023-06-02", true), - ] - .map(|(name, end, marked)| (name.to_string(), end.to_string(), marked)); - assert_eq!((rows, again.rows_affected()), (expected.to_vec(), 0)); - anyhow::Ok(()) - } - .await; - - sqlx::query("DELETE FROM organizations WHERE id = $1") - .bind(org) - .execute(&pool) - .await?; - run -} diff --git a/crates/utopia-store/tests/store/main.rs b/crates/utopia-store/tests/store/main.rs index f516f69d3..1f61b94b2 100644 --- a/crates/utopia-store/tests/store/main.rs +++ b/crates/utopia-store/tests/store/main.rs @@ -22,8 +22,6 @@ //! 过滤——修正行、重复行都按全库找,会改到别的测试正在物化、改写的行。 //! - `a_row_a_rule_implied_follows_its_statements`:把迁移 0101 原样跑一遍,同样不按 kb //! 过滤——它按来源重算全库类型化行的见证,会改到别的测试正在物化的行。 -//! - `an_end_moved_before_the_mark_gets_it`:把迁移 0114 原样跑两遍,同样不按 kb 过滤——它给 -//! 全库关在时段尽头的行打标记,会改到别的测试正在关上的行,第二遍也就不再是什么都不改。 //! //! 新加测试默认放这个目录;只有碰上面那类全局状态时才放顶层,并在这里补一行为什么。 mod a_batch_decides_like_a_person; 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 5381aae7d..272890aa0 100644 --- a/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md +++ b/docs/decisions/0053-a-phrase-decision-records-the-inputs-it-considered.md @@ -1,6 +1,6 @@ # 0053 · A phrase decision records the inputs it considered -- **Status**: Implemented 2026-09-23 (#878, migration 0072) · revised 2026-09-26 (migration 0092) · revised 2026-09-27 (#975, migration 0098) · revised 2026-10-17 (#1295, migration 0114) · open: printing the named period (#994 cut 1), derived rows, time resolution's ends (cut 2) +- **Status**: Implemented 2026-09-23 (#878, migration 0072) · revised 2026-09-26 (migration 0092) · revised 2026-09-27 (#975, migration 0098) · revised 2026-10-17 (#1295, migration 0114) · open: the backfill and printing the named period (#994 cut 1), time resolution's ends (cut 2) - **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, #966, #994 @@ -277,9 +277,12 @@ the graph page `~ 2025`, the history, document and pending rows `2025-01`, the e **The row says so.** The stored instant stays. `facts.end_after_period` (migration 0114) records that the end is the instant after the period the text named, and every reader is to print that period. A CHECK, `facts_end_after_period_needs_a_stated_end`, keeps it on a dated end the timeline -did not draw, on a row with a start. A start-less row never gets it in this cut: an end is moved -only when the row's start lies in the period. Cut 2, when time resolution writes such ends itself, -replaces the constraint by name without the start clause. +did not draw, on a row with a start. On the Rust side that rule, with the state property the CHECK +cannot see, is one function, `graph::period_mark_fits`, which both write entries, `rewrite_end_tx`, +`mark_end_after_period` and the correction route consult. A start-less row never gets it in this +cut: an end is moved only when the row's start lies in the period. Cut 2, when time resolution +writes such ends itself, replaces the constraint by name without the start clause and drops that +clause from the function. **Who sets it.** The two places the revision above moves an end: @@ -297,15 +300,16 @@ stays as the person set it, and an end stated at another precision is not rewrit 2025-01-01 is that day, not the end of 2024). **Who carries or drops it.** `rewrite_end_tx` writes it in the insert #1054 extended and forces it -off for a drawn or unknown end and for a start-less row, so the engine's redraws and -`close_with_unknown_end` never carry it. `rehome_tx` copies it, an errata revision takes it with the -old row's time, and the same-start branch passes it on when it closes the open row it finds or -restates the end the timeline drew there. Both write entries drop it where the row cannot carry it: -an event, an eternal property, no property, no start or no dated end, so a revision onto an event -drops it. Merges move rows in place; an unmerge moves them in place, or through `rehome_tx` for rows -rewritten after the merge; the mark stays either way. `close_superseded` writes none, so a close by -date in Review, a conflict's `close_at` and a violation's `fact_closed` stay stated ends without it; -those three routes stay outside both cuts. +off wherever `period_mark_fits` says the row cannot carry it (a drawn or unknown end, a start-less +row, a property not declared as a state), so the engine's redraws and `close_with_unknown_end` never +carry it. `rehome_tx` copies it, an errata revision takes it with the old row's time, and the +same-start branch passes it on when it closes the open row it finds or restates the end the timeline +drew there. Both write entries drop it where the row cannot carry it: an event, an eternal property, +no property, no start or no dated end, so a revision onto an event drops it. Merges move rows in +place; an unmerge moves them in place, or through `rehome_tx` for rows rewritten after the merge; +the mark stays either way. `close_superseded` writes none, so a close by date in Review, a +conflict's `close_at` and a violation's `fact_closed` stay stated ends without it; those three +routes stay outside both cuts. **Corrections.** `PATCH /api/v1/kbs/{id}/facts/{fact_id}` keeps reading and accepting the stored boundary, and the route never translates. The request takes an optional `end_after_period`, the one @@ -342,38 +346,16 @@ changes, rather than a separate term carrying the named period, as published on recovered: when `endAfterPeriod` is true, it is the end of `validThrough`'s period (`bucket_end`), with `validThroughPrecision` for an end below a day. -**Rows written before.** 0114 marks a live typed state row whose start lies in the named period -and whose source statement states the end one unit earlier at the same precision: an until (a -statement with only an end), or a single date whose bound binding marks `end`, matched the way -materialisation matches. A single date marked `start` does not count: "joined in 2024" with "until -2025" ends in 2025. A single date that also gave the row its start counts only when another source -statement gives that same start, one that is not itself a single date bound as `end` (the same -sentence can be stored twice): a binding flipped from `start` to `end` keeps the rows computed -before (above), while the backfill reads its current value. #994's same-day example ("from -2023-06-01" with "left on 2023-06-01") is still marked. Left unmarked on purpose: rows with no -source statement (a nod's, a person's), an implied row refined by a later start, a corrected end, a -start corrected out of the period, a drawn end, and invalidated rows, so an `as_of` replay prints the -stored instant. They keep printing the next period until a person edits them once in the dialog. A -second run changes nothing. +**Rows written before 0114** stay unmarked until the backfill that follows. **Limits.** - Endings that arrive before the start ("until 2025", then "left in 2024", then "works for … from 2024-03-01") leave the row unmarked on write: the start-less row in between cannot carry the mark, - and the refinement copies its end without it. The backfill marks the same shape written before - 0114. Cut 2's start-less marked rows revisit it. + and the refinement copies its end without it. Cut 2's start-less marked rows revisit it. - "Left in 2024" and "left in 2025" on a row from 2024-03-01 both close it at 2025-01-01, and in either order the row ends up marked: the mark settles the disagreement as 2024. -Open questions on #994, still open: - -- Derived rows, asked on 2026-10-15: "Should cut 1 also add the flag to `derived_facts`, set when - that end comes from a flagged premise or an event's bucket, or should derived rows wait for a - follow-up?" A derivation bounded by a marked premise takes the stored instant, so once the readers - print the mark it will name the next period beside a premise that names the stated one. -- The dialog, asked on 2026-10-17: should a start moved past an untouched end translate that end as - the write path would, instead of being refused? The stored end would then move one period later. - Not doing: - **Cut 2**: time resolution storing a named period's end as the instant after its last unit, a @@ -399,10 +381,6 @@ Regression coverage, with a real PostgreSQL: explicit `false` or `true`, a change of the mark alone counting as a change of the end; refuses an explicit `true` without a start, a dated end or a state property; and refuses an interval inverted only after truncation. -- 0114 marks closing, refinement, rule-until and same-day rows, and one whose start a person moved - within the period. It leaves unmarked a single date marked `start` with an until, still when the - binding flips to `end` with the same sentence stored twice; a corrected end; a person's row. A - second run changes nothing. ## Status history diff --git a/migrations/0114_an_end_that_names_a_period_says_so.sql b/migrations/0114_an_end_that_names_a_period_says_so.sql index 0ab275ea0..ec5435b46 100644 --- a/migrations/0114_an_end_that_names_a_period_says_so.sql +++ b/migrations/0114_an_end_that_names_a_period_says_so.sql @@ -10,74 +10,10 @@ -- 「until 2025」与「left in 2024」两句关上,哪一句先到都设;改写、搬主宾、勘误改出来的行 -- 带着它;时间线画的终点、不知哪天的终点从不带。约束要求有起点:终点只在起点落在那个时段 -- 里时才被挪到时段的尽头,没有起点的行第一刀不标。第二刀让时间解析自己写这种终点时,按 --- 名字换掉这条约束,去掉起点那一条。 +-- 名字换掉这条约束,去掉起点那一条(Rust 一侧同一句话在 `graph::period_mark_fits`)。 -- --- 回填:只标活着的状态行,它的起点落在所说的时段里,而且有一条来源陈述在同一精度上早 --- 一个单位说出了终点——没有起点的「至」,或绑定标为 end 的单个日期(与物化的 MATCH --- 同一个匹配)。标为 start 的单个日期不算:「joined in 2024」加「until 2025」说的是到 --- 2025 年。绑定在 start 与 end 之间翻转时已经算出的行留着(0053),回填读的却是绑定现在 --- 的值,所以单个日期同时给了这一行的起点时,要另有一条来源陈述给出同一个起点才算:不然 --- 「joined」翻成 end 之后,上面那行会被标上;#994 自己的同日例子(「from 2023-06-01」加 --- 「left on 2023-06-01」)照样标。另一条同样标为 end 的单个日期不算数:同一句话可能存成 --- 两条陈述(短语的大小写、空白不同,或合并实体之后主宾相同),翻转之后两条都标 end。 --- 人改过终点的、时间线画的、没有陈述来源的(点头写的、人写的)、被后来的起点精化过的 --- 蕴含行(它的来源留在旧行上)、已作废的都不标,照旧印下一个时段,人在对话框里改一次即可。 --- --- 约束先删再加,整份文件可以重放;约束在回填之前,回填写下的每一行都受它检查。 --- `NOT f.end_after_period` 让再跑一遍什么都不改。时段的开头用 date_trunc(.., 'UTC') 算, --- 与会话的时区无关。 +-- 约束先删再加,整份文件可以重放。 ALTER TABLE facts ADD COLUMN IF NOT EXISTS end_after_period BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE facts DROP CONSTRAINT IF EXISTS facts_end_after_period_needs_a_stated_end; ALTER TABLE facts ADD CONSTRAINT facts_end_after_period_needs_a_stated_end CHECK (NOT end_after_period OR (valid_from IS NOT NULL AND valid_to IS NOT NULL AND NOT end_derived)); - -UPDATE facts f SET end_after_period = TRUE - FROM relation_types r - WHERE r.id = f.predicate_id AND r.temporal = 'state' - AND f.layer = 'typed' AND f.invalidated_at IS NULL - AND NOT f.end_after_period AND NOT f.end_derived - AND (f.corrected_ends IS NULL OR f.corrected_ends = 'start') - AND f.valid_from IS NOT NULL AND f.valid_to IS NOT NULL - AND f.valid_from < f.valid_to - AND date_trunc(f.valid_to_precision, f.valid_to - interval '1 microsecond', 'UTC') <= f.valid_from - AND EXISTS ( - SELECT 1 - FROM (SELECT t.statement_id FROM typed_fact_sources t WHERE t.fact_id = f.id - UNION ALL - SELECT i.statement_id FROM implied_fact_sources i - WHERE i.fact_id = f.id AND i.statement_id IS NOT NULL) src - JOIN facts s ON s.id = src.statement_id - WHERE s.valid_to = date_trunc(f.valid_to_precision, f.valid_to - interval '1 microsecond', 'UTC') - AND s.valid_to_precision = f.valid_to_precision - AND (s.valid_from IS NULL -- 至:只有终点 - OR (s.valid_from = s.valid_to -- 单个日期,绑定标为 end - AND EXISTS (SELECT 1 FROM phrase_bindings b - JOIN entities se ON se.id = s.subject_id - LEFT JOIN entities oe ON oe.id = s.object_id - WHERE b.kb_id = s.kb_id - AND b.phrase = lower(btrim(regexp_replace(s.phrase, '\s+', ' ', 'g'))) - AND b.subject_type_id IS NOT DISTINCT FROM se.type_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) - AND b.relation_type_id = f.predicate_id - AND b.status = 'bound' AND b.marks = 'end') - -- 这个单个日期若也给了这一行的起点,只有另一条来源陈述给出同一个起点时才算; - -- 另一条同样标为 end 的单个日期不算(见文件头) - AND NOT (s.valid_from = f.valid_from - AND s.valid_from_precision IS NOT DISTINCT FROM f.valid_from_precision - AND NOT EXISTS (SELECT 1 FROM typed_fact_sources t2 - JOIN facts o ON o.id = t2.statement_id - WHERE t2.fact_id = f.id AND o.id <> s.id - AND o.valid_from = f.valid_from - AND o.valid_from_precision IS NOT DISTINCT FROM f.valid_from_precision - AND NOT (o.valid_to IS NOT DISTINCT FROM o.valid_from AND EXISTS ( - SELECT 1 FROM phrase_bindings b2 - JOIN entities se2 ON se2.id = o.subject_id - LEFT JOIN entities oe2 ON oe2.id = o.object_id - WHERE b2.kb_id = o.kb_id - AND b2.phrase = lower(btrim(regexp_replace(o.phrase, '\s+', ' ', 'g'))) - AND b2.subject_type_id IS NOT DISTINCT FROM se2.type_id - AND b2.object_is_value = (o.object_id IS NULL) - AND (o.object_id IS NULL OR b2.object_type_id IS NOT DISTINCT FROM oe2.type_id) - AND b2.relation_type_id = f.predicate_id - AND b2.status = 'bound' AND b2.marks = 'end')))))));