Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 22 additions & 9 deletions crates/utopia-store/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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));
}
// 弱化陈述:新观察无时间,同断言已有开放行 → 并入(取起点最新的开放行)。
Expand All @@ -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));
}
// 没有开放行,但这次观察的文档日期落在某条**已关上**的行里:说的是那一段,不是
Expand All @@ -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));
}
}
Expand Down Expand Up @@ -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<chrono::DateTime<chrono::Utc>>,
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?;
}
Expand Down Expand Up @@ -953,7 +964,9 @@ pub async fn insert_open_statement(
};
let same: Option<Uuid> = 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));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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::<DateTime<Utc>>();
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<Utc>, Option<DateTime<Utc>>, Option<String>) =
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<Utc>, Option<DateTime<Utc>>) =
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
}
1 change: 1 addition & 0 deletions crates/utopia-store/tests/store/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading