diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index aefc6a185..e31c0f6f4 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -627,7 +627,7 @@ pub(crate) async fn insert_fact_on( { return Ok((stated, true)); } - attest_earlier(&mut *conn, *ended, validity.attested_at).await?; + attest_earlier(&mut *conn, *ended, validity.attested_at, true).await?; return Ok((*ended, false)); } } @@ -651,7 +651,7 @@ pub(crate) async fn insert_fact_on( { return Ok((stated, true)); } - attest_earlier(&mut *conn, *ended, validity.attested_at).await?; + attest_earlier(&mut *conn, *ended, validity.attested_at, true).await?; return Ok((*ended, false)); } let open = same @@ -707,7 +707,13 @@ pub(crate) async fn insert_fact_on( return Ok((stated, true)); } } - attest_earlier(&mut *conn, *existing, validity.attested_at).await?; + attest_earlier( + &mut *conn, + *existing, + validity.attested_at, + validity.has_ended(), + ) + .await?; return Ok((*existing, false)); } // 弱化陈述:新观察无时间,同断言已有开放行 → 并入(取起点最新的开放行)。 @@ -719,7 +725,7 @@ pub(crate) async fn insert_fact_on( .filter(|(_, _, vt, _)| vt.is_none() || temporal == Temporal::Event) .max_by_key(|(_, vf, _, _)| *vf) { - attest_earlier(&mut *conn, *existing, validity.attested_at).await?; + attest_earlier(&mut *conn, *existing, validity.attested_at, false).await?; return Ok((*existing, false)); } // 没有开放行,但这次观察的文档日期落在某条**已关上**的行里:说的是那一段,不是 @@ -731,7 +737,7 @@ pub(crate) async fn insert_fact_on( .iter() .find(|(_, vf, vt, _)| vt.is_some_and(|t| at <= t) && vf.is_none_or(|f| f <= at)) { - attest_earlier(&mut *conn, *existing, validity.attested_at).await?; + attest_earlier(&mut *conn, *existing, validity.attested_at, false).await?; return Ok((*existing, false)); } } @@ -846,24 +852,29 @@ pub(crate) async fn insert_fact_on( /// 同一断言又被观察到一次:锚点只往早挪(0022)。更早的文档是更早的证据; /// 更晚的什么也不改——一条事实从有证据的那一刻起成立,之后再被提到不会把它 /// 往后推。`None`(此刻)也不动它:此刻不会早于任何已有的证据。 +/// +/// `ended`:这次观察说的是「它结束了」。只有它是结束得更早的证据,终点锚才跟着挪; +/// 说它成立的观察只挪起点锚。从前两个一起挪,一条晚到的、日期更早的「成立」并进一行 +/// 「结束了,不知哪天」,终点锚就挪到了它自己身上,区间缩成空的(#875 的回放) async fn attest_earlier<'e>( pool: impl sqlx::Executor<'e, Database = sqlx::Postgres>, fact_id: Uuid, at: Option>, + ended: bool, ) -> AppResult<()> { if let Some(at) = at { - // 两个锚点都只往早挪:更早的文档既是它成立的更早证据,若它说的是结束,也是 - // 结束得更早的证据。attested_to 只在结束未知的行上有,NULL 的留 NULL + // attested_to 只在结束未知的行上有,NULL 的留 NULL sqlx::query( // LEAST 会跳过 NULL——开放行的 attested_to 是 NULL,直接 least 会给它凭空长出一个 // 终点锚,撞上 CHECK。NULL 的留 NULL "UPDATE facts SET attested_from = least(attested_from, $2), - attested_to = CASE WHEN attested_to IS NULL THEN NULL + attested_to = CASE WHEN attested_to IS NULL OR NOT $3 THEN attested_to ELSE least(attested_to, $2) END WHERE id = $1", ) .bind(fact_id) .bind(at) + .bind(ended) .execute(pool) .await?; } @@ -953,7 +964,9 @@ pub async fn insert_open_statement( }; let same: Option = q.fetch_optional(pool).await?; if let Some(existing) = same { - attest_earlier(pool, existing, attested_at).await?; + // 开放陈述落库时还不知道这次提及说的是成立还是结束(时间词在 0045 的任务里才读), + // 这里照旧两个锚点一起挪 + attest_earlier(pool, existing, attested_at, true).await?; return Ok((existing, false)); } diff --git a/crates/utopia-store/tests/store/an_earlier_mention_keeps_the_stated_end.rs b/crates/utopia-store/tests/store/an_earlier_mention_keeps_the_stated_end.rs new file mode 100644 index 000000000..501adb4e8 --- /dev/null +++ b/crates/utopia-store/tests/store/an_earlier_mention_keeps_the_stated_end.rs @@ -0,0 +1,146 @@ +//! 一条「结束了,不知哪天」的行(0022 / #393):终点锚在说出结束的那份文档的日期上。之后才到、 +//! 日期更早、说它**成立**的一次观察并进这一行时,起点锚往早挪是对的——那是它成立的更早证据; +//! 终点锚不该跟着挪——那份文档说的是成立,不是结束。两个锚点一起挪,区间缩成 [t0, t0): +//! 读出来这件事从来没成立过,挂在它上面的派生也跟着没了。 +//! +//! #875 的回放里撞上的:物品先在桌上,对账把桌面那一段关在搬走那份观察的日期上(锚点), +//! 一条更早的「在桌上」晚到,桌面那一段就读成了空的。 +//! +//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 + +use chrono::{DateTime, Utc}; +use serde_json::json; +use sqlx::PgPool; +use utopia_store::graph::{self, Validity}; +use uuid::Uuid; + +#[tokio::test] +async fn an_earlier_mention_that_it_held_keeps_the_stated_end() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (class, location, cup) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'stated-end-test')") + .bind(org) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'stated-end-test')") + .bind(ws) + .bind(org) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'stated-end-test')", + ) + .bind(kb) + .bind(ws) + .execute(&pool) + .await?; + + let run = async { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'cup', 'Cup')") + .bind(class) + .bind(kb) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind, datatype, temporal) + VALUES ($1, $2, 'location', 'location', 'attribute', 'text', 'state')", + ) + .bind(location) + .bind(kb) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, 'cup-7')", + ) + .bind(cup) + .bind(kb) + .bind(class) + .execute(&pool) + .await?; + let t = |s: &str| s.parse::>(); + let (t0, t1, t2) = ( + t("2026-09-23T07:50:00Z")?, + t("2026-09-23T08:00:00Z")?, + t("2026-09-23T08:10:00Z")?, + ); + let desk = json!({ "value": "desk" }); + // t1:在桌上。没有起点,从这份证据起成立 + graph::insert_value_fact( + &pool, + kb, + cup, + Some(location), + &desk, + Validity::default().attested(Some(t1)), + 1.0, + ) + .await?; + // t2:它结束了,不知哪天——终点锚在这份文档上 + let (closed, _) = graph::insert_value_fact( + &pool, + kb, + cup, + Some(location), + &desk, + Validity::default().attested(Some(t2)).ended_when_unknown(), + 1.0, + ) + .await?; + // t0 < t1:一次更早的「在桌上」晚到。同一断言并进已经关上的那一行 + let merged = graph::insert_value_fact( + &pool, + kb, + cup, + Some(location), + &desk, + Validity::default().attested(Some(t0)), + 1.0, + ) + .await?; + assert_eq!(merged, (closed, false)); + let (from, to, precision): (DateTime, Option>, Option) = + sqlx::query_as( + "SELECT attested_from, attested_to, valid_to_precision FROM facts WHERE id = $1", + ) + .bind(closed) + .fetch_one(&pool) + .await?; + assert_eq!(precision.as_deref(), Some(graph::ENDED_UNKNOWN)); + assert_eq!(from, t0, "the earlier mention is earlier evidence that it held"); + assert_eq!( + to, + Some(t2), + "a mention that it held is no evidence that it ended earlier" + ); + // 另一条路径仍要前移:这份更早的证据确实说它已结束,而不是只说它成立。 + let ended = graph::insert_value_fact( + &pool, + kb, + cup, + Some(location), + &desk, + Validity::default().attested(Some(t1)).ended_when_unknown(), + 1.0, + ) + .await?; + assert_eq!(ended, (closed, false)); + let anchors: (DateTime, Option>) = + sqlx::query_as("SELECT attested_from, attested_to FROM facts WHERE id = $1") + .bind(closed) + .fetch_one(&pool) + .await?; + assert_eq!(anchors, (t0, Some(t1)), "earlier ended evidence moves only the end here"); + 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 314fa7835..dfbde230f 100644 --- a/crates/utopia-store/tests/store/main.rs +++ b/crates/utopia-store/tests/store/main.rs @@ -90,6 +90,7 @@ mod adopting_an_iri_adopts_the_shape; mod an_agent_can_record; mod an_amount_outlives_adoption; mod an_automatic_merge_is_gated_by_what_it_can_undo; +mod an_earlier_mention_keeps_the_stated_end; mod an_end_date_closes_the_open_span; mod an_event_holds_at_the_moment_it_names; mod an_exploration_says_what_it_covered;