diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index eb846059d..cc9fbb707 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -173,8 +173,10 @@ pub async fn entity_detail( // 推出来的那些**单独回一个键**,不掺进 `facts`。前端据此给它们自己的一档: // 一条派生边跟一条断言边混在同一个列表里,用户看不出「这条是文档里写的」 // 和「这条是引擎推的」的区别,而那正是推理会污染知识的样子 + // 同一个 as_of(#549):回放中的面板上,派生那一档也是**当时**推出的 let derived = - utopia_store::reasoning::derived_for_entity(&state.pool, kb_id, entity_id, None).await?; + utopia_store::reasoning::derived_for_entity(&state.pool, kb_id, entity_id, None, as_of) + .await?; // 同名的那些**打开面板时就给**,不是等改名之后才回。 // // 从前它只随 `update_entity` 的响应回来,于是「把同名的合并进来」这个动作 diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index 1ebedf86e..4aa2ea402 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -342,10 +342,17 @@ pub async fn entity_facts(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolRe // 规则的结论也是这个实体的一部分(0021)。**不给的话模型会拿那些 // 读数自己再判一遍**——而阈值写在规则里,它看不见,于是两处判断 // 迟早不一致,agent 那次还没有前提链、没有区间、也不进账本 - let derived = - utopia_store::reasoning::derived_for_entity(&ctx.state.pool, ctx.kb_id, id, at) - .await - .unwrap_or_default(); + // 两根轴一起传(#549):as_of 回到三月,派生也回到三月,不然模型 + // 拿到的是「三月的断言 + 今天的结论」,一条前提都不在,结论却在 + let derived = utopia_store::reasoning::derived_for_entity( + &ctx.state.pool, + ctx.kb_id, + id, + at, + as_of, + ) + .await + .unwrap_or_default(); let mut derived: Vec = derived .iter() .map(|d| { diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 2da92b2f5..3a691a911 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -2458,7 +2458,12 @@ pub async fn derived_for_entity( kb_id: Uuid, entity_id: Uuid, at: Option>, + as_of: Option>, ) -> AppResult> { + // 记录轴(0019 / #549):断言那一半早就走 `held_at`,这一半曾写死 + // `invalidated_at IS NULL`——回放到三月的面板上挂着四月才推出的结论, + // 前提一条都不在,结论却在。谓词只在 record_axis 里拼,这里不自己写。 + // // **宾语与规则两侧都是 LEFT JOIN。** 表拓宽之后(0021)一条派生的宾语可能 // 是字面值而不是实体,规则可能是业务规则而不是公理——内连接会把这两种 // 结论**静默地**从面板上抹掉,而它们恰恰是最需要解释的那种。 @@ -2500,15 +2505,17 @@ pub async fn derived_for_entity( LEFT JOIN rules ru ON ru.id = d.rule_id LEFT JOIN attribute_rules ar ON ar.id = d.attribute_rule_id LEFT JOIN entity_types ct ON ct.id = ar.conclude_type_id - WHERE d.kb_id = $1 AND d.invalidated_at IS NULL + WHERE d.kb_id = $1 AND {derived_held} AND (d.subject_id = $2 OR d.object_id = $2) AND {derived_hold} ORDER BY d.derived_at DESC", derived_hold = crate::world_axis::derived_hold_at("d", 3), + derived_held = crate::record_axis::derived_held_at("d", 4), )) .bind(kb_id) .bind(entity_id) .bind(at) + .bind(as_of) .fetch_all(pool) .await?) } diff --git a/crates/utopia-store/tests/a_derivation_follows_the_second_clock.rs b/crates/utopia-store/tests/a_derivation_follows_the_second_clock.rs new file mode 100644 index 000000000..443980c14 --- /dev/null +++ b/crates/utopia-store/tests/a_derivation_follows_the_second_clock.rs @@ -0,0 +1,235 @@ +//! 派生也随第二根钟回退(0019 / #549),打在真库上。 +//! +//! 0019 写了「`derived_facts` 带 `derived_at` 和 `invalidated_at`,派生遵守同一条 +//! 规则」。图总览做到了,实体一级的读取没有:`derived_for_entity` 写死了 +//! `invalidated_at IS NULL`,于是 `as_of` 回到三月的面板上,断言那一半退回去了, +//! 派生那一半还是今天的——一条前提都不在,结论却挂在那儿,两个时刻缝成一个快照。 +//! MCP 的 `entity_facts` 和网页的实体面板走的都是这一个函数。 +//! +//! 两个方向都钉住,因为它们各自会坏: +//! - **推出之前**的时刻:结论不该出现(#549 报的那半句) +//! - **推翻之前**的时刻:结论仍该出现——回放的图上留着当时推出的边(0019 承诺的那半句) + +use sqlx::PgPool; +use utopia_store::reasoning; +use uuid::Uuid; + +fn t(s: &str) -> chrono::DateTime { + s.parse().unwrap() +} + +struct Fixture { + org: Uuid, + kb: Uuid, + atlas: Uuid, + systems: Uuid, + derived: Uuid, +} + +/// 三月十日记下两条断言:Atlas ⊂ Acme Robotics ⊂ Acme Systems。 +/// 三月十一日引擎据传递律推出 Atlas ⊂ Acme Systems。时间戳直接写进行里, +/// 不经物化器——这里测的是读取,不是推导。 +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let etype = Uuid::now_v7(); + let part_of = Uuid::now_v7(); + let (atlas, robotics, systems) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (premise_a, premise_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (rule, derived) = (Uuid::now_v7(), Uuid::now_v7()); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'derived-rewind-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'derived-rewind-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) + VALUES ($1, $2, 'derived-rewind-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'thing', 'Thing')", + ) + .bind(etype) + .bind(kb) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, is_transitive) + VALUES ($1, $2, 'part_of', 'part of', TRUE)", + ) + .bind(part_of) + .bind(kb) + .execute(pool) + .await?; + for (id, name) in [ + (atlas, "Atlas"), + (robotics, "Acme Robotics"), + (systems, "Acme Systems"), + ] { + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(kb) + .bind(etype) + .bind(name) + .execute(pool) + .await?; + } + for (id, s, o) in [(premise_a, atlas, robotics), (premise_b, robotics, systems)] { + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, recorded_at) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(id) + .bind(kb) + .bind(s) + .bind(part_of) + .bind(o) + .bind(t("2026-03-10T00:00:00Z")) + .execute(pool) + .await?; + } + sqlx::query( + "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, 'transitive')", + ) + .bind(rule) + .bind(kb) + .bind(part_of) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, rule_id, + derived_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(derived) + .bind(kb) + .bind(atlas) + .bind(part_of) + .bind(systems) + .bind(rule) + .bind(t("2026-03-11T00:00:00Z")) + .execute(pool) + .await?; + for (seq, premise) in [(0, premise_a), (1, premise_b)] { + sqlx::query( + "INSERT INTO fact_derivations (derived_fact_id, premise_fact_id, seq) + VALUES ($1, $2, $3)", + ) + .bind(derived) + .bind(premise) + .bind(seq) + .execute(pool) + .await?; + } + Ok(Fixture { + org, + kb, + atlas, + systems, + derived, + }) +} + +#[tokio::test] +async fn a_derivation_is_absent_before_it_was_drawn_and_kept_until_it_was_withdrawn( +) -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let derived_of = |entity: Uuid, as_of: Option<&'static str>| { + let pool = pool.clone(); + async move { + let rows = + reasoning::derived_for_entity(&pool, f.kb, entity, None, as_of.map(t)).await?; + Ok::, anyhow::Error>(rows.into_iter().map(|d| d.object).collect()) + } + }; + + let run = async { + // ---- 一、现在:结论在,且是那一条 + assert_eq!( + derived_of(f.atlas, None).await?, + vec!["Acme Systems".to_string()], + "现在:引擎推出的结论要在" + ); + + // ---- 二、三月一日:前提还没记下,结论也不该在——这是 #549 报的那半句。 + // 断言那一半同一时刻也是空的:两半必须说同一个时刻 + let (_, facts) = utopia_store::graph::entity_detail( + &pool, + f.kb, + f.atlas, + None, + Some(t("2026-03-01T00:00:00Z")), + ) + .await?; + assert!(facts.is_empty(), "三月一日:两条前提都还没记下"); + assert!( + derived_of(f.atlas, Some("2026-03-01T00:00:00Z")) + .await? + .is_empty(), + "推出之前的时刻不该有这条结论:它的前提一条都不在" + ); + assert!( + derived_of(f.systems, Some("2026-03-01T00:00:00Z")) + .await? + .is_empty(), + "从宾语那一侧看回去也一样" + ); + + // 前提记下了、结论还没推出的一天:断言在,派生不在 + let (_, facts) = utopia_store::graph::entity_detail( + &pool, + f.kb, + f.atlas, + None, + Some(t("2026-03-10T12:00:00Z")), + ) + .await?; + assert_eq!(facts.len(), 1, "三月十日中午:Atlas 自己的那条前提已经记下"); + assert!( + derived_of(f.atlas, Some("2026-03-10T12:00:00Z")) + .await? + .is_empty(), + "推出是次日的事" + ); + + // ---- 三、四月一日推翻。推出与推翻之间的时刻,结论仍要在——0019 承诺的那半句: + // 回放的图上留着**当时**推出的边,不是今天这套规则的结论 + sqlx::query("UPDATE derived_facts SET invalidated_at = $2 WHERE id = $1") + .bind(f.derived) + .bind(t("2026-04-01T00:00:00Z")) + .execute(&pool) + .await?; + assert_eq!( + derived_of(f.atlas, Some("2026-03-20T00:00:00Z")).await?, + vec!["Acme Systems".to_string()], + "推翻之前的时刻,当时推出的边要留在回放的图上" + ); + assert!( + derived_of(f.atlas, None).await?.is_empty(), + "现在:已推翻的结论不再出现" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(&pool) + .await?; + run +} diff --git a/crates/utopia-store/tests/a_proof_reaches_the_sentence.rs b/crates/utopia-store/tests/a_proof_reaches_the_sentence.rs index f2a780e65..2b36ff0bd 100644 --- a/crates/utopia-store/tests/a_proof_reaches_the_sentence.rs +++ b/crates/utopia-store/tests/a_proof_reaches_the_sentence.rs @@ -186,7 +186,7 @@ async fn a_proof_reaches_the_sentence() -> anyhow::Result<()> { reasoning::materialize(&pool, f.kb).await?; let derived: Vec = - reasoning::derived_for_entity(&pool, f.kb, f.a, None).await?; + reasoning::derived_for_entity(&pool, f.kb, f.a, None, None).await?; let ac = derived .iter() .find(|d| d.subject_id == f.a && d.object_id == Some(f.c)) diff --git a/docs/decisions/0019-the-second-clock-can-be-rewound.md b/docs/decisions/0019-the-second-clock-can-be-rewound.md index f68713070..456d323ab 100644 --- a/docs/decisions/0019-the-second-clock-can-be-rewound.md +++ b/docs/decisions/0019-the-second-clock-can-be-rewound.md @@ -1,6 +1,6 @@ # 0019 · The second clock can be rewound -- **Status**: implemented in three cuts · #317 gave every graph read the `held_at` predicate and `as_of` beside `at`; #337 gave entities their clock by unwinding `entity_merges`; this record's second open question landed with retrieval (superseded chunks keep their vectors, and vector recall and chunk fetch both take `as_of`) · the control on the graph page is still open, and full-text recall is still "now" only +- **Status**: implemented in three cuts · #317 gave every graph read the `held_at` predicate and `as_of` beside `at`; #337 gave entities their clock by unwinding `entity_merges`; this record's second open question landed with retrieval (superseded chunks keep their vectors, and vector recall and chunk fetch both take `as_of`) · the control on the graph page is still open, and full-text recall is still "now" only · #549 found the one derived read that had kept `invalidated_at IS NULL`: `derived_for_entity` now takes `as_of`, so the entity panel and `entity_facts` over MCP rewind derivations with the assertions - **Written**: 2026-09-04 (conventions in the [README](README.md)) - **Related**: [0003](0003-ontology-growth-loop.md) put adoption's rewrites on the same append path as human correction, so the prior state is still on disk; [0002](0002-reasoning-engine.md) built the proof chain on the same rows. #268 (deleting a document) is what made the gap urgent, and is deliberately a separate change @@ -36,4 +36,5 @@ Read-only throughout. No new column, no migration. - **Entities have no clock.** ~~`merged_into` records that a merge happened, not when.~~ Settled in #337: `entity_merges.created_at` / `reverted_at` is the clock, `fact_owner_at` reads the moved-fact arrays back, and a merge made in March leaves two nodes at an `as_of` in February. The target's own type and profile at T are recorded (`target_type_before`, `target_profile_before`) and still unread. - **Retrieval has the same two clocks.** Settled, with one half left standing. `replace_chunks` no longer clears `embedding` on a superseded chunk: the storage that decision saved was the smaller half — the chunk's **text** was already kept — and the price was that history could not be searched at all. Vector recall and chunk fetch now take `as_of`, and a document deleted after T comes back with its chunks (#268 leaves the tombstone). **Full text stays "now"**: Tantivy holds one version of a base, so a timed search returns correct hits and misses the ones only history has. Giving the index versions is a separate piece of work, not a filter. +- **A proof has one version.** `fact_derivations` is rewritten in place when a kept conclusion is reproved ([0030](0030-a-rule-may-read-what-a-rule-concluded.md), cut 2), and `proof` reads it as it stands. So a rewound panel lists the derivations the engine had drawn by T (#549), but expanding one shows today's premises, with the retracted ones marked. Keeping every proof would mean versioning `fact_derivations` the way `facts` is versioned; the row's `derived_at` already says when the conclusion first stood, and nobody has asked for the rest. - **What the control is.** A second slider doubles the surface for a question most people ask rarely; a mode switch on the existing slider is cheaper but risks reading as the same axis, which is the confusion the section above is written to prevent. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 767b1f165..faa5964a4 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -42,7 +42,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0016 | [Close the open seams before cutting new ones](0016-close-the-open-seams-before-cutting-new-ones.md) | In progress · A done · B done (B4 deferred) · C1 done (#289) · C2 done (#297) · C3–C5 open · D2 worked around (#231); the lakehouse landed ahead of D4 (#239) | | 0017 | [A contradiction points at an error upstream](0017-a-contradiction-points-upstream.md) | Implemented · B2a: engine and queue, per-item cap, aggregation by rule pair, cards with clues and repairs (#238) · B2b: contested edges in the alert colour, ghost edges for blocked derivations, the disputed chip and the "did not land" section in the panel (#243) | | 0018 | [The lakehouse is one protocol away](0018-the-lakehouse-is-one-protocol-away.md) | Implemented: Trino (Iceberg / Delta / Hive), Databricks and Snowflake behind the same trait, scheme picks the engine (#239) · Trino verified against a real cluster (#327); Databricks and Snowflake still want one (#241, #242) · MaxCompute waits | -| 0019 | [The second clock can be rewound](0019-the-second-clock-can-be-rewound.md) | Implemented in three cuts · `held_at` and `as_of` on every graph read (#317), entities' own clock by unwinding `entity_merges` (#337), retrieval as of a moment · the control on the graph page is still open (#307), full-text recall is still "now" only | +| 0019 | [The second clock can be rewound](0019-the-second-clock-can-be-rewound.md) | Implemented in three cuts · `held_at` and `as_of` on every graph read (#317), entities' own clock by unwinding `entity_merges` (#337), retrieval as of a moment · the entity panel and `entity_facts` rewind derivations too (#549) · the control on the graph page is still open (#307), full-text recall is still "now" only | | 0020 | [An auditor reads it without us](0020-an-auditor-reads-it-without-us.md) | Implemented · `GET /kbs/{id}/export?format=turtle\|jsonld` streams the base as RDF, `rdf.rs` holds the mapping · SPARQL waits, and the record says why (#308) | | 0021 | [A rule reads attributes and concludes a type](0021-a-rule-reads-attributes-and-concludes-a-type.md) | Implemented (#359) · `derived_facts` widened to match `facts`, rules authored in `attribute_rules` from the ontology page, evaluated in the materialisation job, explained in the entity panel with their premises · read-only over MCP, writing a rule stays out · no canvas marker, and a conclusion is rewritten rather than edited | | 0022 | [An unknown date is not an open one](0022-an-unknown-date-is-not-an-open-one.md) | Implemented in two cuts (#394 and the derived cut) · `world_axis` predicate beside `record_axis`, `facts.attested_at` anchors a missing start or an undated end at the document that attests it, every read and both client filters on the read interval, an undated ending closes the dated row it ends, derived rows intersect premise intervals as read and carry no precision on an anchored bound · two anchors (`attested_from` / `attested_to`), so a bare open row closes too (#393) |