From e114f60f259ed17a3b3ffe5d73db653c1bbb5077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=97=E6=85=B6=E9=BA=9F?= Date: Sun, 20 Sep 2026 18:40:59 +0800 Subject: [PATCH 1/4] Provenance never points across a knowledge base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install a schema-level invariant: every reference an export can resolve must join rows that live in the same knowledge base. A column foreign key proves the target exists, not that it is the same KB's — the exporter would otherwise mint local IRIs naming foreign rows, or silently drop vocabulary references that resolve to nothing. Three layers: a precondition scan that refuses the migration on a dirty ledger, row triggers on every edge (deferred constraint triggers on same-table self-references so COPY and multi-row inserts are judged at commit), and kb-ownership immutability on every owned table. --- .../a_dirty_ledger_stops_the_migration.rs | 205 ++++ ...a_forward_reference_is_judged_at_commit.rs | 348 +++++++ ...gration_0070_runs_under_any_search_path.rs | 230 ++++ ...e_never_points_across_a_knowledge_base.sql | 979 ++++++++++++++++++ 4 files changed, 1762 insertions(+) create mode 100644 crates/utopia-store/tests/a_dirty_ledger_stops_the_migration.rs create mode 100644 crates/utopia-store/tests/a_forward_reference_is_judged_at_commit.rs create mode 100644 crates/utopia-store/tests/migration_0070_runs_under_any_search_path.rs create mode 100644 migrations/0070_provenance_never_points_across_a_knowledge_base.sql diff --git a/crates/utopia-store/tests/a_dirty_ledger_stops_the_migration.rs b/crates/utopia-store/tests/a_dirty_ledger_stops_the_migration.rs new file mode 100644 index 000000000..a10c0fc03 --- /dev/null +++ b/crates/utopia-store/tests/a_dirty_ledger_stops_the_migration.rs @@ -0,0 +1,205 @@ +//! 0070 的 §0 前置检查:往一个**已经有**跨库坏行的库上装 +//! 「出处不许跨库」的不变量,迁移必须确定性中止——报出是哪条边、坏了几行, +//! 而不是装上之后替坏数据背书。 +//! +//! 做法:开一个隔离库,按迁移文件顺序把 ≤0069 的逐个跑掉(每个一个事务, +//! 与 sqlx::migrate 同一语义),手工塞进一条 0070 之前合法、之后非法的行, +//! 再跑 0070 本体: +//! - 干净的 0069 库 → 0070 成功,触发器在场; +//! - 脏的 0069 库 → 0070 报错且报对边名,整个迁移随事务回滚——触发器一个不留。 +//! +//! 建库失败(角色没权限的环境)与没有 UTOPIA_DATABASE_URL 一样处理:跳过。 + +use sqlx::{Acquire, PgPool}; +use uuid::Uuid; + +/// 维护库的地址:`…/utopia` → `…/postgres` +fn admin_url() -> Option { + let url = utopia_store::test_db::url()?; + let (head, _) = url.rsplit_once('/')?; + Some(format!("{head}/postgres")) +} + +/// 按文件顺序跑 ≤ `through` 的迁移,各自一个事务(与 sqlx::migrate 同一形状) +async fn migrate_to(pool: &PgPool, through: i64) -> anyhow::Result<()> { + let migrator = sqlx::migrate!("../../migrations"); + let mut conn = pool.acquire().await?; + for m in migrator.iter().filter(|m| m.version <= through) { + let mut tx = conn.begin().await?; + sqlx::raw_sql(&m.sql).execute(&mut *tx).await?; + tx.commit().await?; + } + Ok(()) +} + +async fn migration_70(pool: &PgPool) -> Result<(), sqlx::Error> { + let migrator = sqlx::migrate!("../../migrations"); + let m = migrator + .iter() + .find(|m| m.version == 70) + .expect("0070 必须在迁移集里"); + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + let r = sqlx::raw_sql(&m.sql).execute(&mut *tx).await; + match r { + Ok(_) => tx.commit().await, + Err(e) => { + let _ = tx.rollback().await; + Err(e) + } + } +} + +/// 隔离库:建 → 迁到 0069 → 返回(库名, 连接池)。失败就地跳过 +async fn scratch(suffix: &str) -> Option<(String, PgPool)> { + let admin = admin_url()?; + let admin_pool = PgPool::connect(&admin).await.ok()?; + let name = format!("xkb70_{}_{}", suffix, Uuid::now_v7().simple()); + let created = sqlx::query(&format!("CREATE DATABASE {name}")) + .execute(&admin_pool) + .await; + if created.is_err() { + eprintln!("跳过:建不了隔离库(角色没有 CREATEDB)"); + admin_pool.close().await; + return None; + } + let url = utopia_store::test_db::url()?; + let (head, _) = url.rsplit_once('/')?; + let pool = PgPool::connect(&format!("{head}/{name}")).await.ok()?; + if let Err(e) = migrate_to(&pool, 69).await { + eprintln!("跳过:迁到 0069 失败(迁移链自身的问题): {e}"); + drop_scratch(&name).await; + return None; + } + Some((name, pool)) +} + +async fn drop_scratch(name: &str) { + if let Some(admin) = admin_url() { + if let Ok(pool) = PgPool::connect(&admin).await { + let _ = sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")) + .execute(&pool) + .await; + pool.close().await; + } + } +} + +/// 0070 之前合法的最小坏账:库 A 的文档+段落+实体+事实,库 B 的实体与段落—— +/// 证据行把 A 的事实配到 B 的段落上 +async fn seed_dirty(pool: &PgPool) -> anyhow::Result<()> { + let (org, ws, a, b) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'm70-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'm70-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + for kb in [a, b] { + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'm70-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + } + let (doc_a, doc_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (chunk_b, ent_a, ent_b) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + for (id, kb) in [(ent_a, a), (ent_b, b)] { + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'e')") + .bind(id) + .bind(kb) + .execute(pool) + .await?; + } + for (id, kb, name) in [(doc_a, a, "a.md"), (doc_b, b, "b.md")] { + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256, status, external_key) + VALUES ($1, $2, $3, $4, 'ready', $5)", + ) + .bind(id) + .bind(kb) + .bind(name) + .bind(format!("sha-{name}")) + .bind(format!("file:///{name}")) + .execute(pool) + .await?; + } + // B 库自己的段落——0070 之前把它配到 A 的事实上,什么都不会拦 + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 0, 'x')", + ) + .bind(chunk_b) + .bind(b) + .bind(doc_b) + .execute(pool) + .await?; + let fact_a = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence) + VALUES ($1, $2, $3, $4, 0.9)", + ) + .bind(fact_a) + .bind(a) + .bind(ent_a) + .bind(ent_a) + .execute(pool) + .await?; + // 坏行:A 的事实配 B 的段落 + sqlx::query("INSERT INTO fact_evidence (fact_id, chunk_id) VALUES ($1, $2)") + .bind(fact_a) + .bind(chunk_b) + .execute(pool) + .await?; + Ok(()) +} + +#[tokio::test] +async fn a_clean_ledger_takes_the_invariant() -> anyhow::Result<()> { + let Some((name, pool)) = scratch("clean").await else { + return Ok(()); + }; + let r = migration_70(&pool).await; + assert!(r.is_ok(), "干净的 0069 库必须装得上 0070: {r:?}"); + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pg_trigger WHERE tgname = 'fact_evidence_same_kb'", + ) + .fetch_one(&pool) + .await?; + assert_eq!(n, 1, "触发器要真的装上"); + pool.close().await; + drop_scratch(&name).await; + Ok(()) +} + +#[tokio::test] +async fn a_dirty_ledger_stops_the_migration_atomically() -> anyhow::Result<()> { + let Some((name, pool)) = scratch("dirty").await else { + return Ok(()); + }; + seed_dirty(&pool).await?; + let r = migration_70(&pool).await; + let msg = format!("{r:?}"); + assert!(r.is_err(), "脏库上跑 0070 必须中止"); + assert!( + msg.contains("cross-KB references already present") || msg.contains("evidence.chunk"), + "中止要报出坏在哪条边: {msg}" + ); + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM pg_trigger WHERE tgname LIKE '%same_kb%'") + .fetch_one(&pool) + .await?; + assert_eq!(n, 0, "中止的迁移不许留下半个不变量"); + pool.close().await; + drop_scratch(&name).await; + Ok(()) +} diff --git a/crates/utopia-store/tests/a_forward_reference_is_judged_at_commit.rs b/crates/utopia-store/tests/a_forward_reference_is_judged_at_commit.rs new file mode 100644 index 000000000..be54e1ff5 --- /dev/null +++ b/crates/utopia-store/tests/a_forward_reference_is_judged_at_commit.rs @@ -0,0 +1,348 @@ +//! 同表自指的前向引用在**提交边界**上判(0070 §1 递延约束触发器)。 +//! +//! `facts.supersedes`、`facts.from_statement_id`、`relation_types.inverse_of`、 +//! `relation_types.sub_property_of` 都指着同表的行——恢复/批量装载时目标可能 +//! 在本语句之后才落盘。行级 BEFORE 触发器在落地那一刻看不见后面的行;递延 +//! 约束触发器(DEFERRABLE INITIALLY DEFERRED)在提交时重估,那时整批都在。 +//! +//! 写入形状分三种,各自的墙不一样: +//! - **多行 INSERT / COPY**:一条语句一批行——FK 在语句末看整批(过), +//! BEFORE 逐行看(看不见后面的目标)。递延约束是这里唯一的闸; +//! - **顺序语句**:`supersedes`/`inverse_of`/`sub_property_of` 的 FK 是 +//! 立即的——目标不存在的顺序写根本进不来,FK 先挡; +//! - **replica 会话**(pg_restore --disable-triggers 的形状):触发器与 +//! FK 全关——那一层没有任何触发器能管,接住它的是导出前的 +//! provenance_integrity 体检。 + +use sqlx::{Acquire, PgPool}; +use uuid::Uuid; + +struct Fixture { + org: Uuid, + a: Uuid, + b: Uuid, + ent_a: Uuid, + ent_b: Uuid, +} + +/// 两个库各一件最小零件:实体——supersedes 的合法写法也要它们 +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, a, b) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'fwdref-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'fwdref-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + for kb in [a, b] { + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'fwdref-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + } + let (ent_a, ent_b) = (Uuid::now_v7(), Uuid::now_v7()); + for (id, kb) in [(ent_a, a), (ent_b, b)] { + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'e')") + .bind(id) + .bind(kb) + .execute(pool) + .await?; + } + Ok(Fixture { + org, + a, + b, + ent_a, + ent_b, + }) +} + +async fn cleanup(pool: &PgPool, f: &Fixture) -> anyhow::Result<()> { + for kb in [f.a, f.b] { + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(kb) + .execute(pool) + .await?; + } + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(pool) + .await?; + Ok(()) +} + +/// 多行 INSERT:引用行在前、目标行在后——FK 在语句末放行,BEFORE 逐行看时 +/// 目标还不在。**提交边界上的递延约束**是抓住它的地方:跨库的过不了, +/// 同库的照样落 +#[tokio::test] +async fn a_multi_row_insert_is_judged_at_commit() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // 引用行在前、别库目标行在后:BEFORE 看不见目标,递延约束看得见整批 + let (newer, older) = (Uuid::now_v7(), Uuid::now_v7()); + let r = sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence, supersedes) + VALUES ($1, $2, $4, $4, 0.9, $3), ($3, $5, $6, $6, 0.9, NULL)", + ) + .bind(newer) + .bind(f.a) + .bind(older) + .bind(f.ent_a) + .bind(f.b) // 目标行落在别库 + .bind(f.ent_b) + .execute(&pool) + .await; + assert!(r.is_err(), "多行 INSERT 里的跨库 supersedes 必须被拒"); + + // 同库的同样写法:两行同库,一条语句——合法 + let (newer2, older2) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence, supersedes) + VALUES ($1, $2, $4, $4, 0.9, $3), ($3, $2, $4, $4, 0.9, NULL)", + ) + .bind(newer2) + .bind(f.a) + .bind(older2) + .bind(f.ent_a) + .execute(&pool) + .await?; + + cleanup(&pool, &f).await +} + +/// COPY 是恢复灌库的形状:先放行后校验只在语句提交边界做一次。别库目标 +/// 排在本批后面也过不了那道闸;同库的前向链照样进得来 +#[tokio::test] +async fn a_copy_batch_is_judged_at_commit() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // 跨库:newer 在前、older 在后,older 属 B 库——提交时被拦 + let (newer, older) = (Uuid::now_v7(), Uuid::now_v7()); + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + let mut copy = tx + .copy_in_raw( + "COPY public.facts (id, kb_id, subject_id, object_id, confidence, supersedes) FROM stdin", + ) + .await?; + copy.send( + format!( + "{newer}\t{a}\t{ent_a}\t{ent_a}\t0.9\t{older}\n{older}\t{b}\t{ent_b}\t{ent_b}\t0.9\t\\N\n", + a = f.a, + b = f.b, + ent_a = f.ent_a, + ent_b = f.ent_b, + ) + .into_bytes(), + ) + .await?; + copy.finish().await?; + let r = tx.commit().await; + assert!(r.is_err(), "COPY 批里的跨库 supersedes 必须在提交时被拦下"); + + // 同库:同样的前向顺序,整条链合法 + let (newer2, older2) = (Uuid::now_v7(), Uuid::now_v7()); + let mut tx = conn.begin().await?; + let mut copy = tx + .copy_in_raw( + "COPY public.facts (id, kb_id, subject_id, object_id, confidence, supersedes) FROM stdin", + ) + .await?; + copy.send( + format!( + "{newer2}\t{a}\t{ent_a}\t{ent_a}\t0.9\t{older2}\n{older2}\t{a}\t{ent_a}\t{ent_a}\t0.9\t\\N\n", + a = f.a, + ent_a = f.ent_a, + ) + .into_bytes(), + ) + .await?; + copy.finish().await?; + tx.commit().await?; + drop(conn); + + cleanup(&pool, &f).await +} + +/// 顺序写装不出前向引用:supersedes 的 FK 是立即的,目标不存在的顺序写 +/// 在第一条语句就被 FK 挡下——递延约束在这条路上是第二道墙 +#[tokio::test] +async fn a_sequential_forward_reference_is_stopped_by_the_fk() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let (newer, older) = (Uuid::now_v7(), Uuid::now_v7()); + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + let r = sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence, supersedes) + VALUES ($1, $2, $3, $3, 0.9, $4)", + ) + .bind(newer) + .bind(f.a) + .bind(f.ent_a) + .bind(older) + .execute(&mut *tx) + .await; + assert!( + r.is_err(), + "目标还不存在的 supersedes 顺序写,FK 在语句末就该报" + ); + + cleanup(&pool, &f).await +} + +/// UPDATE 装上的跨库 supersedes:目标已存在,BEFORE 逐行检查看得见它—— +/// 当场就报;真漏过去的那一层,递延约束在提交时兜底 +#[tokio::test] +async fn an_update_to_a_foreign_supersedes_fails() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let (fa, fb) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence) + VALUES ($1, $2, $3, $3, 0.9), ($4, $5, $6, $6, 0.9)", + ) + .bind(fa) + .bind(f.a) + .bind(f.ent_a) + .bind(fb) + .bind(f.b) + .bind(f.ent_b) + .execute(&pool) + .await?; + + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + let upd = sqlx::query("UPDATE facts SET supersedes = $2 WHERE id = $1") + .bind(fa) + .bind(fb) + .execute(&mut *tx) + .await; + if upd.is_ok() { + let r = tx.commit().await; + assert!(r.is_err(), "UPDATE 装上的跨库 supersedes 最迟在提交时要报"); + } + + cleanup(&pool, &f).await +} + +/// from_statement_id 是同一张表上的第二条自指边:陈述行在批尾、类型化事实 +/// 在批头的跨库写法,提交时一样被拦 +#[tokio::test] +async fn a_from_statement_is_judged_at_commit() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // 类型化事实在前、别库陈述在后:BEFORE 看不见目标,递延约束看得见整批 + let (typed, stmt) = (Uuid::now_v7(), Uuid::now_v7()); + let r = sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence, + layer, phrase, from_statement_id) + VALUES ($1, $2, $4, $4, 0.9, 'typed', NULL, $3), + ($3, $5, $6, $6, 0.9, 'open', 'joined', NULL)", + ) + .bind(typed) + .bind(f.a) + .bind(stmt) + .bind(f.ent_a) + .bind(f.b) // 陈述行落在别库 + .bind(f.ent_b) + .execute(&pool) + .await; + assert!( + r.is_err(), + "多行 INSERT 里的跨库 from_statement_id 必须被拒" + ); + + // 同库的同样写法:类型化事实在前、同库陈述在后——合法 + let (typed2, stmt2) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence, + layer, phrase, from_statement_id) + VALUES ($1, $2, $4, $4, 0.9, 'typed', NULL, $3), + ($3, $2, $4, $4, 0.9, 'open', 'joined', NULL)", + ) + .bind(typed2) + .bind(f.a) + .bind(stmt2) + .bind(f.ent_a) + .execute(&pool) + .await?; + + cleanup(&pool, &f).await +} + +/// 关系的同表自指同一条边界:inverse_of / sub_property_of 引用行在前、 +/// 目标行在后——别库的在提交时被拦,同库的放行 +#[tokio::test] +async fn relation_self_links_are_judged_at_commit() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // inverse_of:一条语句里 A 库的谓词在前、B 库的目标在后——提交时报 + let (inv, tgt) = (Uuid::now_v7(), Uuid::now_v7()); + let r = sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, inverse_of) + VALUES ($1, $2, 'inv', 'inv', $3), ($3, $4, 'tgt', 'tgt', NULL)", + ) + .bind(inv) + .bind(f.a) + .bind(tgt) + .bind(f.b) + .execute(&pool) + .await; + assert!(r.is_err(), "多行 INSERT 里的跨库 inverse_of 必须被拒"); + + // sub_property_of 同库、目标行在后:合法 + let (child, parent) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, sub_property_of) + VALUES ($1, $2, 'child', 'child', $3), ($3, $2, 'parent', 'parent', NULL)", + ) + .bind(child) + .bind(f.a) + .bind(parent) + .execute(&pool) + .await?; + + cleanup(&pool, &f).await +} diff --git a/crates/utopia-store/tests/migration_0070_runs_under_any_search_path.rs b/crates/utopia-store/tests/migration_0070_runs_under_any_search_path.rs new file mode 100644 index 000000000..951991ba0 --- /dev/null +++ b/crates/utopia-store/tests/migration_0070_runs_under_any_search_path.rs @@ -0,0 +1,230 @@ +//! 0070 的 DDL 不许依赖会话的 search_path:`CREATE FUNCTION`、 +//! `CREATE TRIGGER ... ON`、`EXECUTE FUNCTION` 全部限定到 `public.*`—— +//! pg_restore 会把会话 search_path 置空再灌数据,首位被占住的会话也不能 +//! 把函数建到别的 schema 去。落在别处的触发器等于没有触发器。 +//! +//! 三种会话下逐个验证: +//! - 正常 search_path(默认)→ 装上; +//! - `SET LOCAL search_path = ''` → 同样装上,函数落在 public; +//! - `SET LOCAL search_path = 'decoy'`(先在首位摆上同名干扰物)→ +//! 照样装进 public,干扰物一个不被调用。 +//! +//! 没有 `UTOPIA_DATABASE_URL` 时跳过;设了地址却连不上、或建不了库——**失败**, +//! 不是跳过:地址都给了还说「没库」是假话,那个绿色等于这条检查没跑过。 + +use sqlx::{Acquire, PgPool}; +use uuid::Uuid; + +fn admin_url() -> Option { + let url = utopia_store::test_db::url()?; + let (head, _) = url.rsplit_once('/')?; + Some(format!("{head}/postgres")) +} + +/// 按文件顺序跑 ≤ `through` 的迁移,各自一个事务(与 sqlx::migrate 同一形状) +async fn migrate_to(pool: &PgPool, through: i64) -> anyhow::Result<()> { + let migrator = sqlx::migrate!("../../migrations"); + let mut conn = pool.acquire().await?; + for m in migrator.iter().filter(|m| m.version <= through) { + let mut tx = conn.begin().await?; + sqlx::raw_sql(&m.sql).execute(&mut *tx).await?; + tx.commit().await?; + } + Ok(()) +} + +/// 在 `search_path` 为 `path` 的事务里跑 0070 本体 +async fn migration_70_under(pool: &PgPool, path: &str) -> Result<(), sqlx::Error> { + let migrator = sqlx::migrate!("../../migrations"); + let m = migrator + .iter() + .find(|m| m.version == 70) + .expect("0070 必须在迁移集里"); + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + sqlx::query(&format!("SET LOCAL search_path = {path}")) + .execute(&mut *tx) + .await?; + let r = sqlx::raw_sql(&m.sql).execute(&mut *tx).await; + match r { + Ok(_) => tx.commit().await, + Err(e) => { + let _ = tx.rollback().await; + Err(e) + } + } +} + +/// 隔离库:建 → 迁到 0069 → 返回(库名, 连接池)。 +/// 跳过只有一种情形:**根本没设** `UTOPIA_DATABASE_URL`。设了地址连不上、 +/// 建不了库、迁移链跑不动,全都以错误返回——给了地址却拿不到库,这次检查 +/// 就是没有执行过,不该被记成绿色 +async fn scratch(suffix: &str) -> anyhow::Result> { + let Some(admin) = admin_url() else { + return Ok(None); + }; + let admin_pool = PgPool::connect(&admin).await?; + let name = format!("xkb70sp_{}_{}", suffix, Uuid::now_v7().simple()); + sqlx::query(&format!("CREATE DATABASE {name}")) + .execute(&admin_pool) + .await?; + admin_pool.close().await; + let Some(url) = utopia_store::test_db::url() else { + drop_scratch(&name).await; + return Ok(None); + }; + let (head, _) = url.rsplit_once('/').expect("UTOPIA_DATABASE_URL 缺库名段"); + let pool = PgPool::connect(&format!("{head}/{name}")).await?; + if let Err(e) = migrate_to(&pool, 69).await { + pool.close().await; + drop_scratch(&name).await; + return Err(e); + } + Ok(Some((name, pool))) +} + +async fn drop_scratch(name: &str) { + if let Some(admin) = admin_url() { + if let Ok(pool) = PgPool::connect(&admin).await { + let _ = sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")) + .execute(&pool) + .await; + pool.close().await; + } + } +} + +/// 在隔离库上跑 `f`——不管成功、失败还是断言 panic,库都清掉再走: +/// 测失败的证据不许靠运维去捡烂尾库 +async fn with_scratch(suffix: &str, f: impl FnOnce(PgPool) -> Fut) -> anyhow::Result<()> +where + Fut: std::future::Future>, +{ + use futures_util::FutureExt; + use std::panic::AssertUnwindSafe; + let Some((name, pool)) = scratch(suffix).await? else { + return Ok(()); + }; + // catch_unwind:断言 panic 落在 Err 上,清库照常走到再重抛 + let r = AssertUnwindSafe(f(pool.clone())).catch_unwind().await; + pool.close().await; + drop_scratch(&name).await; + match r { + Ok(inner) => inner, + Err(p) => std::panic::resume_unwind(p), + } +} + +/// 装完后要有的两样东西:每条边一个触发器,且所有新函数都在 public schema。 +/// 递延约束触发器在同表自指边上(supersedes / inverse_of / sub_property_of) +async fn assert_installed(pool: &PgPool) -> anyhow::Result<()> { + let fns: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname IN ( + 'fact_evidence_stays_inside_its_kb','chunk_stays_inside_its_kb', + 'derivation_premise_stays_inside_its_kb','qualifier_stays_inside_its_facts_kb', + 'fact_references_stay_inside_the_kb','fact_supersedes_stays_inside_the_kb', + 'derived_references_stay_inside_the_kb','entity_type_stays_inside_its_kb', + 'type_parent_stays_inside_its_kb','disjoint_stays_inside_its_kb', + 'relation_scope_stays_inside_its_kb','relation_links_stay_inside_the_kb', + 'relation_qualifier_stays_inside_the_kb','rule_predicate_stays_inside_the_kb', + 'attribute_rule_refs_stay_inside_the_kb', + 'rule_condition_refs_stay_inside_the_kb','kb_ownership_is_not_reassigned', + 'fact_from_statement_stays_inside_the_kb','typed_source_stays_inside_its_kb', + 'squalifier_stays_inside_its_facts_kb','time_mention_stays_inside_its_kb', + 'type_binding_refs_stay_inside_the_kb','phrase_binding_refs_stay_inside_the_kb')", + ) + .fetch_one(pool) + .await?; + assert_eq!(fns, 23, "全部触发器函数都必须落在 public schema"); + + let trg: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pg_trigger WHERE NOT tgisinternal AND tgname IN ( + 'fact_evidence_same_kb','chunks_same_kb_document','fact_derivations_same_kb', + 'fact_qualifiers_same_kb','facts_references_same_kb','facts_supersedes_same_kb', + 'facts_supersedes_same_kb_deferred','derived_facts_references_same_kb', + 'entities_type_same_kb','entity_type_parents_same_kb', + 'entity_type_disjoint_same_kb','relation_type_domains_same_kb', + 'relation_type_ranges_same_kb','relation_types_links_same_kb', + 'relation_types_links_same_kb_deferred','relation_type_qualifiers_same_kb', + 'rules_predicate_same_kb','attribute_rules_refs_same_kb', + 'facts_keep_their_kb','derived_facts_keep_their_kb','documents_keep_their_kb', + 'entities_keep_their_kb','entity_types_keep_their_kb','relation_types_keep_their_kb', + 'rules_keep_their_kb','attribute_rules_keep_their_kb', + 'entity_type_disjoint_keep_their_kb','attribute_rule_conditions_same_kb', + 'facts_from_statement_same_kb','facts_from_statement_same_kb_deferred', + 'typed_fact_sources_same_kb','statement_qualifiers_same_kb', + 'time_mentions_same_kb','type_bindings_same_kb','phrase_bindings_same_kb', + 'time_mentions_keep_their_kb','type_bindings_keep_their_kb', + 'phrase_bindings_keep_their_kb')", + ) + .fetch_one(pool) + .await?; + assert_eq!(trg, 38, "每条边一个触发器,一条都不能少"); + + // 递延约束触发器真的递延:DEFERRABLE INITIALLY DEFERRED 两个位都立着。 + // pg_trigger 没有 tgisconstraint 这一列——约束触发器的标记是 tgconstraint <> 0 + let deferred: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pg_trigger + WHERE tgconstraint <> 0 AND tgdeferrable AND tginitdeferred + AND tgname IN ('facts_supersedes_same_kb_deferred', + 'facts_from_statement_same_kb_deferred', + 'relation_types_links_same_kb_deferred')", + ) + .fetch_one(pool) + .await?; + assert_eq!(deferred, 3, "同表自指边的提交边界检查必须在场"); + Ok(()) +} + +#[tokio::test] +async fn the_migration_installs_under_an_empty_search_path() -> anyhow::Result<()> { + with_scratch("empty", |pool| async move { + let r = migration_70_under(&pool, "''").await; + assert!(r.is_ok(), "search_path 为空时装得上 0070: {r:?}"); + assert_installed(&pool).await + }) + .await +} + +#[tokio::test] +async fn the_migration_installs_under_a_hostile_search_path() -> anyhow::Result<()> { + with_scratch("evil", |pool| async move { + // 首位摆个干扰 schema:同名的 entities 表与同名函数——裸名解析会先看到它。 + // 限定到 public.* 的 DDL 不该理会它 + sqlx::query("CREATE SCHEMA decoy").execute(&pool).await?; + sqlx::query("CREATE TABLE decoy.entities (id uuid, kb_id uuid, merged_into uuid)") + .execute(&pool) + .await?; + sqlx::query( + "CREATE FUNCTION decoy.kb_ownership_is_not_reassigned() RETURNS trigger + LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END; $$", + ) + .execute(&pool) + .await?; + + let r = migration_70_under(&pool, "decoy, public").await; + assert!(r.is_ok(), "首位被占的 search_path 下也装得上 0070: {r:?}"); + assert_installed(&pool).await?; + // 干扰物原样留着:一次都没被选中 + let decoy_fn: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'decoy' AND p.proname = 'kb_ownership_is_not_reassigned'", + ) + .fetch_one(&pool) + .await?; + assert_eq!(decoy_fn, 1, "decoy 函数不该被覆盖也不该被删掉"); + Ok(()) + }) + .await +} + +#[tokio::test] +async fn the_migration_installs_under_a_normal_search_path() -> anyhow::Result<()> { + with_scratch("norm", |pool| async move { + let r = migration_70_under(&pool, "public").await; + assert!(r.is_ok(), "正常 search_path 下装得上 0070: {r:?}"); + assert_installed(&pool).await + }) + .await +} diff --git a/migrations/0070_provenance_never_points_across_a_knowledge_base.sql b/migrations/0070_provenance_never_points_across_a_knowledge_base.sql new file mode 100644 index 000000000..4fef53055 --- /dev/null +++ b/migrations/0070_provenance_never_points_across_a_knowledge_base.sql @@ -0,0 +1,979 @@ +-- 出处链不许跨库。 +-- +-- 引用完整性只保证「指着的行存在」,不保证「指着的东西在同一个库」。导出把 +-- 引用对象的 id 铸进**本库**的 IRI(urn:utopia:kb:A:fact:{B 库的事实}), +-- 一份看着完整、实则指着不存在之物的文件就这么出去了;而能被导出器解析的 +-- 引用(谓词、属性类型、实体类型、父类、domain/range)落到别库时更安静—— +-- 查表落空,那一截语义**不声不响地消失**。 +-- +-- 所以不变量是一条,不是一条边:**出处与归属语义上的每一个引用,两端必须 +-- 同属一个库。** 下面按所有者逐条列出: +-- +-- 所有者行 引用列 → 被引表 +-- fact_evidence fact_id/chunk_id/document_id → facts/chunks/documents +-- chunks document_id → documents(以 chunk 自己的 kb_id 为准) +-- fact_derivations premise_fact_id → facts · premise_derived_id → derived_facts +-- (以 derived_fact 的 kb 为准) +-- fact_qualifiers qualifier_type_id → relation_types · entity_id → entities +-- (以所属 fact 的 kb 为准) +-- facts subject_id/object_id → entities · predicate_id → relation_types +-- · supersedes / from_statement_id → facts(同表自指) +-- derived_facts subject_id/object_id → entities · predicate_id → relation_types +-- · rule_id → rules · attribute_rule_id → attribute_rules +-- entities type_id → entity_types +-- entity_type_parents parent_id → entity_types(以 child 的 kb 为准) +-- entity_type_disjoint a_id/b_id → entity_types(以行自己的 kb_id 为准) +-- relation_type_domains / _ranges entity_type_id → entity_types +-- (以 relation 的 kb 为准) +-- relation_type_qualifiers qualifier_type_id → relation_types +-- (以 relation 的 kb 为准) +-- relation_types inverse_of / sub_property_of → relation_types(同表自指) +-- rules predicate_id → relation_types +-- attribute_rules subject_type_id / conclude_type_id → entity_types · +-- conclude_predicate_id → relation_types +-- attribute_rule_conditions predicate_id → relation_types +-- (归属按**所属规则**的库判——条件行自己没有 kb 列。 +-- rule_id → attribute_rules 是普通外键:不存在的规则 +-- 装不进来,规则删了条件跟着删) +-- typed_fact_sources statement_id → facts(以所属 fact 的 kb 为准) +-- statement_qualifiers entity_id → entities(以所属 fact 的 kb 为准) +-- time_mentions fact_id → facts · chunk_id → chunks(以行自己的 kb_id 为准) +-- type_bindings type_id → entity_types(以行自己的 kb_id 为准) +-- phrase_bindings subject_type_id / object_type_id → entity_types · +-- relation_type_id → relation_types(以行自己的 kb_id 为准) +-- +-- 例外:attribute_rules.conclude_expr 与算式 operand 里 `attr` 叶子嵌着的 +-- 谓词引用。jsonb 列装不下外键,行级触发器去逐棵 JSON 树拆,等于把求值侧 +-- 的表达式语法再抄一遍——这类引用的执行层是**导出侧校验**(export.rs: +-- 取数时按库挡、序列化时按词汇表解析,越库/悬空/连 uuid 都解析不出的 +-- 一律拒导)。本迁移管的是列级引用。 +-- +-- 三层防线,各管一段: +-- §0 前置检查 —— 迁移本身先数一遍存量:库里已有越界行就**整体中止**, +-- 报出是哪条边、坏了几行。装上不变量却对已违反它的账本报喜, +-- 等于替坏数据背书。不修数据的人不该拿到「迁移成功」。 +-- §1 触发器 —— 挡在一切写入路径的下游(原生 API、手写 SQL、还没写出来 +-- 的那些路径)。列清单同时管住「改引用列」与「改 kb_id 过户」。 +-- §2 kb 不可过户 —— 把已被引用的行挪到别的库,等于把指着它的行一次全变坏行。 +-- +-- §1 的函数体全部**限定到 public schema 且钉死 search_path**:pg_restore 会把 +-- 会话 search_path 置空再灌数据,裸表名在那里解析不到任何东西——数据恢复 +-- 会炸在半截,留下一个说不清的残库。正确性不许依赖环境。 +-- 同一个理由,本文件的 DDL 标识符也一律 `public.*` 限定:迁移在 +-- `SET search_path=''` 或首位被恶意 schema 占住的会话里跑,都不能把函数和 +-- 触发器建错地方——一个落在别处的触发器等于没有触发器。 +-- +-- **同表自指边单独走递延约束触发器**(`CONSTRAINT TRIGGER … DEFERRABLE +-- INITIALLY DEFERRED`):pg_restore 在数据之后建外键,COPY 先放行后校验; +-- 我们的触发器是行级的,A 行先落、B 行后到,BEFORE 检查在 A 落地时看不见 +-- B——一前一后插进同一事务的两行就能把跨库 supersedes 装进去。递延约束在 +-- **提交时**重估,那时整批都在。跨表边不需要:子表永远排在父表之后灌。 +-- +-- 触发器只管落在它之后的写;存量坏行由 §0 挡在迁移门口,由导出侧的体检 +-- (provenance_integrity)与逐页校验拦在序列化之前。 + +-- ===================================================================== +-- §0 前置检查:存量越界行 → 整份中止 +-- ===================================================================== +DO $$ +DECLARE + report text; +BEGIN + SELECT string_agg(edge || ' x' || n, '; ' ORDER BY edge) INTO report + FROM ( + SELECT edge, COUNT(*) AS n FROM ( + SELECT 'evidence.chunk' AS edge, f.kb_id AS owner_kb, c.kb_id AS ref_kb + FROM public.fact_evidence e + JOIN public.facts f ON f.id = e.fact_id + LEFT JOIN public.chunks c ON c.id = e.chunk_id + UNION ALL + SELECT 'evidence.document', f.kb_id, d.kb_id + FROM public.fact_evidence e + JOIN public.facts f ON f.id = e.fact_id + LEFT JOIN public.documents d ON d.id = e.document_id + WHERE e.document_id IS NOT NULL + UNION ALL + SELECT 'chunk.document', c.kb_id, d.kb_id + FROM public.chunks c + LEFT JOIN public.documents d ON d.id = c.document_id + UNION ALL + SELECT 'derivation.premise_fact', d.kb_id, p.kb_id + FROM public.fact_derivations fd + JOIN public.derived_facts d ON d.id = fd.derived_fact_id + LEFT JOIN public.facts p ON p.id = fd.premise_fact_id + WHERE fd.premise_fact_id IS NOT NULL + UNION ALL + SELECT 'derivation.premise_derived', d.kb_id, p.kb_id + FROM public.fact_derivations fd + JOIN public.derived_facts d ON d.id = fd.derived_fact_id + LEFT JOIN public.derived_facts p ON p.id = fd.premise_derived_id + WHERE fd.premise_derived_id IS NOT NULL + UNION ALL + SELECT 'qualifier.type', f.kb_id, r.kb_id + FROM public.fact_qualifiers q + JOIN public.facts f ON f.id = q.fact_id + LEFT JOIN public.relation_types r ON r.id = q.qualifier_type_id + UNION ALL + SELECT 'qualifier.entity', f.kb_id, e.kb_id + FROM public.fact_qualifiers q + JOIN public.facts f ON f.id = q.fact_id + LEFT JOIN public.entities e ON e.id = q.entity_id + WHERE q.entity_id IS NOT NULL + UNION ALL + SELECT 'fact.subject', f.kb_id, s.kb_id + FROM public.facts f + LEFT JOIN public.entities s ON s.id = f.subject_id + UNION ALL + SELECT 'fact.object', f.kb_id, o.kb_id + FROM public.facts f + LEFT JOIN public.entities o ON o.id = f.object_id + WHERE f.object_id IS NOT NULL + UNION ALL + SELECT 'fact.predicate', f.kb_id, r.kb_id + FROM public.facts f + LEFT JOIN public.relation_types r ON r.id = f.predicate_id + WHERE f.predicate_id IS NOT NULL + UNION ALL + SELECT 'fact.supersedes', f.kb_id, s.kb_id + FROM public.facts f + LEFT JOIN public.facts s ON s.id = f.supersedes + WHERE f.supersedes IS NOT NULL + UNION ALL + SELECT 'fact.from_statement', f.kb_id, s.kb_id + FROM public.facts f + LEFT JOIN public.facts s ON s.id = f.from_statement_id + WHERE f.from_statement_id IS NOT NULL + UNION ALL + SELECT 'derived.subject', d.kb_id, s.kb_id + FROM public.derived_facts d + LEFT JOIN public.entities s ON s.id = d.subject_id + UNION ALL + SELECT 'derived.object', d.kb_id, o.kb_id + FROM public.derived_facts d + LEFT JOIN public.entities o ON o.id = d.object_id + WHERE d.object_id IS NOT NULL + UNION ALL + SELECT 'derived.predicate', d.kb_id, r.kb_id + FROM public.derived_facts d + LEFT JOIN public.relation_types r ON r.id = d.predicate_id + UNION ALL + SELECT 'derived.rule', d.kb_id, r.kb_id + FROM public.derived_facts d + LEFT JOIN public.rules r ON r.id = d.rule_id + WHERE d.rule_id IS NOT NULL + UNION ALL + SELECT 'derived.attribute_rule', d.kb_id, r.kb_id + FROM public.derived_facts d + LEFT JOIN public.attribute_rules r ON r.id = d.attribute_rule_id + WHERE d.attribute_rule_id IS NOT NULL + UNION ALL + SELECT 'entity.type', e.kb_id, t.kb_id + FROM public.entities e + LEFT JOIN public.entity_types t ON t.id = e.type_id + WHERE e.type_id IS NOT NULL + UNION ALL + SELECT 'class.parent', c.kb_id, p.kb_id + FROM public.entity_type_parents x + JOIN public.entity_types c ON c.id = x.child_id + LEFT JOIN public.entity_types p ON p.id = x.parent_id + UNION ALL + SELECT 'class.disjoint', dd.kb_id, a.kb_id + FROM public.entity_type_disjoint dd + LEFT JOIN public.entity_types a ON a.id = dd.a_id + UNION ALL + SELECT 'class.disjoint', dd.kb_id, b.kb_id + FROM public.entity_type_disjoint dd + LEFT JOIN public.entity_types b ON b.id = dd.b_id + UNION ALL + SELECT 'relation.domain', r.kb_id, t.kb_id + FROM public.relation_type_domains x + JOIN public.relation_types r ON r.id = x.relation_type_id + LEFT JOIN public.entity_types t ON t.id = x.entity_type_id + UNION ALL + SELECT 'relation.range', r.kb_id, t.kb_id + FROM public.relation_type_ranges x + JOIN public.relation_types r ON r.id = x.relation_type_id + LEFT JOIN public.entity_types t ON t.id = x.entity_type_id + UNION ALL + SELECT 'relation.qualifier', r.kb_id, q.kb_id + FROM public.relation_type_qualifiers x + JOIN public.relation_types r ON r.id = x.relation_type_id + LEFT JOIN public.relation_types q ON q.id = x.qualifier_type_id + UNION ALL + SELECT 'relation.inverse', r.kb_id, t.kb_id + FROM public.relation_types r + LEFT JOIN public.relation_types t ON t.id = r.inverse_of + WHERE r.inverse_of IS NOT NULL + UNION ALL + SELECT 'relation.sub_property', r.kb_id, t.kb_id + FROM public.relation_types r + LEFT JOIN public.relation_types t ON t.id = r.sub_property_of + WHERE r.sub_property_of IS NOT NULL + UNION ALL + SELECT 'rule.predicate', u.kb_id, p.kb_id + FROM public.rules u + LEFT JOIN public.relation_types p ON p.id = u.predicate_id + UNION ALL + SELECT 'arule.subject_type', a.kb_id, t.kb_id + FROM public.attribute_rules a + LEFT JOIN public.entity_types t ON t.id = a.subject_type_id + UNION ALL + SELECT 'arule.conclude_type', a.kb_id, t.kb_id + FROM public.attribute_rules a + LEFT JOIN public.entity_types t ON t.id = a.conclude_type_id + WHERE a.conclude_type_id IS NOT NULL + UNION ALL + SELECT 'arule.conclude_predicate', a.kb_id, p.kb_id + FROM public.attribute_rules a + LEFT JOIN public.relation_types p ON p.id = a.conclude_predicate_id + WHERE a.conclude_predicate_id IS NOT NULL + UNION ALL + -- 条件行自己没有 kb 列:归属按所属规则的库判 + SELECT 'condition.predicate', a.kb_id, p.kb_id + FROM public.attribute_rule_conditions c + JOIN public.attribute_rules a ON a.id = c.rule_id + LEFT JOIN public.relation_types p ON p.id = c.predicate_id + UNION ALL + -- 来源边行自己没有 kb 列:归属按所属 fact 的库判 + SELECT 'factsource.statement', f.kb_id, s.kb_id + FROM public.typed_fact_sources ts + JOIN public.facts f ON f.id = ts.fact_id + LEFT JOIN public.facts s ON s.id = ts.statement_id + UNION ALL + -- 开放陈述的属性行自己没有 kb 列:归属按所属 fact 的库判 + SELECT 'squalifier.entity', f.kb_id, e.kb_id + FROM public.statement_qualifiers q + JOIN public.facts f ON f.id = q.fact_id + LEFT JOIN public.entities e ON e.id = q.entity_id + WHERE q.entity_id IS NOT NULL + UNION ALL + SELECT 'timemention.fact', t.kb_id, f.kb_id + FROM public.time_mentions t + LEFT JOIN public.facts f ON f.id = t.fact_id + UNION ALL + SELECT 'timemention.chunk', t.kb_id, c.kb_id + FROM public.time_mentions t + LEFT JOIN public.chunks c ON c.id = t.chunk_id + UNION ALL + SELECT 'binding.type', b.kb_id, t.kb_id + FROM public.type_bindings b + LEFT JOIN public.entity_types t ON t.id = b.type_id + WHERE b.type_id IS NOT NULL + UNION ALL + SELECT 'pbinding.subject_type', b.kb_id, t.kb_id + FROM public.phrase_bindings b + LEFT JOIN public.entity_types t ON t.id = b.subject_type_id + WHERE b.subject_type_id IS NOT NULL + UNION ALL + SELECT 'pbinding.object_type', b.kb_id, t.kb_id + FROM public.phrase_bindings b + LEFT JOIN public.entity_types t ON t.id = b.object_type_id + WHERE b.object_type_id IS NOT NULL + UNION ALL + SELECT 'pbinding.relation', b.kb_id, r.kb_id + FROM public.phrase_bindings b + LEFT JOIN public.relation_types r ON r.id = b.relation_type_id + WHERE b.relation_type_id IS NOT NULL + ) refs + WHERE ref_kb IS DISTINCT FROM owner_kb + GROUP BY edge + ) bad; + IF report IS NOT NULL THEN + RAISE EXCEPTION 'cross-KB references already present (%) — repair the ledger before this invariant can be installed', report + USING ERRCODE = 'integrity_constraint_violation'; + END IF; +END; +$$; + +-- ===================================================================== +-- §1 引用两端同库:每条被序列化的边一个约束 +-- ===================================================================== + +-- 证据行:事实、段落、冗余文档指针必须同属一个库。 +-- 父行不存在交给外键报错;这里只管「都存在,却不在同一个库」。 +CREATE FUNCTION public.fact_evidence_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + fact_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO fact_kb FROM public.facts WHERE id = NEW.fact_id; + SELECT kb_id INTO ref_kb FROM public.chunks WHERE id = NEW.chunk_id; + IF fact_kb IS NOT NULL AND ref_kb IS NOT NULL AND ref_kb <> fact_kb THEN + RAISE EXCEPTION 'fact_evidence cannot pair fact % with chunk % across knowledge bases', + NEW.fact_id, NEW.chunk_id; + END IF; + IF NEW.document_id IS NOT NULL AND fact_kb IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.documents WHERE id = NEW.document_id; + IF ref_kb IS NOT NULL AND ref_kb <> fact_kb THEN + RAISE EXCEPTION 'fact_evidence cannot point at document % across knowledge bases', + NEW.document_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- `ON CONFLICT DO UPDATE` 只改 quote/proposed_predicate,不在列清单里, +-- 常规的证据合并路径不会唤醒它 +CREATE TRIGGER fact_evidence_same_kb + BEFORE INSERT OR UPDATE OF fact_id, chunk_id, document_id ON public.fact_evidence + FOR EACH ROW EXECUTE FUNCTION public.fact_evidence_stays_inside_its_kb(); + +-- 段落:块的 kb_id 必须与它所属文档的 kb_id 一致。 +CREATE FUNCTION public.chunk_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + doc_kb uuid; +BEGIN + SELECT kb_id INTO doc_kb FROM public.documents WHERE id = NEW.document_id; + IF doc_kb IS NOT NULL AND doc_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'chunk % cannot live in kb % while its document lives in kb %', + NEW.id, NEW.kb_id, doc_kb; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER chunks_same_kb_document + BEFORE INSERT OR UPDATE OF kb_id, document_id ON public.chunks + FOR EACH ROW EXECUTE FUNCTION public.chunk_stays_inside_its_kb(); + +-- 证明树:前提(断言或派生)必须与结论同属一个库。前提在导出里铸成 +-- prov:used → fact:/derived: IRI——别库的前提挂上本库的结论,伪造的就是身份。 +CREATE FUNCTION public.derivation_premise_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + derived_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO derived_kb FROM public.derived_facts WHERE id = NEW.derived_fact_id; + IF derived_kb IS NULL THEN + RETURN NEW; + END IF; + IF NEW.premise_fact_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.facts WHERE id = NEW.premise_fact_id; + ELSE + SELECT kb_id INTO ref_kb FROM public.derived_facts WHERE id = NEW.premise_derived_id; + END IF; + IF ref_kb IS NOT NULL AND ref_kb <> derived_kb THEN + RAISE EXCEPTION 'derivation premise % cannot live outside the derived fact''s knowledge base', + COALESCE(NEW.premise_fact_id, NEW.premise_derived_id); + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER fact_derivations_same_kb + BEFORE INSERT OR UPDATE OF derived_fact_id, premise_fact_id, premise_derived_id + ON public.fact_derivations + FOR EACH ROW EXECUTE FUNCTION public.derivation_premise_stays_inside_its_kb(); + +-- 边上的属性:属性类型必须在本库词汇表里可解析(别库的类型在导出里会被 +-- 静默跳过——坏行不是消失,是当场报错);实体值不许把别库实体铸进本库 IRI。 +CREATE FUNCTION public.qualifier_stays_inside_its_facts_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + fact_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO fact_kb FROM public.facts WHERE id = NEW.fact_id; + IF fact_kb IS NULL THEN + RETURN NEW; + END IF; + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.qualifier_type_id; + IF ref_kb IS NOT NULL AND ref_kb <> fact_kb THEN + RAISE EXCEPTION 'qualifier type % cannot live outside the fact''s knowledge base', + NEW.qualifier_type_id; + END IF; + IF NEW.entity_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entities WHERE id = NEW.entity_id; + IF ref_kb IS NOT NULL AND ref_kb <> fact_kb THEN + RAISE EXCEPTION 'qualifier entity % cannot live outside the fact''s knowledge base', + NEW.entity_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER fact_qualifiers_same_kb + BEFORE INSERT OR UPDATE OF fact_id, qualifier_type_id, entity_id ON public.fact_qualifiers + FOR EACH ROW EXECUTE FUNCTION public.qualifier_stays_inside_its_facts_kb(); + +-- 事实本体:主语、宾语、谓词、supersedes 全部铸成本库 IRI 或进本库词汇表。 +CREATE FUNCTION public.fact_references_stay_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + SELECT kb_id INTO ref_kb FROM public.entities WHERE id = NEW.subject_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'fact % subject % cannot live in another knowledge base', + NEW.id, NEW.subject_id; + END IF; + IF NEW.object_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entities WHERE id = NEW.object_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'fact % object % cannot live in another knowledge base', + NEW.id, NEW.object_id; + END IF; + END IF; + IF NEW.predicate_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.predicate_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'fact % predicate % cannot live in another knowledge base', + NEW.id, NEW.predicate_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +-- supersedes 是**同表自指**:目标行可能还没落(COPY/多行 INSERT/同事务的顺序 +-- 插入),行级 BEFORE 检查在那一刻看不见它——提前报错会冤枉「先插新版、 +-- 再插前身」的合法写法,放过又会放进跨库指向。所以分两半: +-- · BEFORE 只在目标**已存在**时立即报错(行内检查管不到还没到的行); +-- · 提交时再由递延约束重估——那时整批都在,目标存在与否、在哪个库都是定局。 +CREATE FUNCTION public.fact_supersedes_stays_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + IF TG_OP = 'UPDATE' + AND NEW.supersedes IS NOT DISTINCT FROM OLD.supersedes + AND NEW.kb_id IS NOT DISTINCT FROM OLD.kb_id THEN + RETURN NEW; + END IF; + IF NEW.supersedes IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.facts WHERE id = NEW.supersedes; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'fact % supersedes % cannot live in another knowledge base', + NEW.id, NEW.supersedes; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER facts_references_same_kb + BEFORE INSERT OR UPDATE OF kb_id, subject_id, object_id, predicate_id, supersedes + ON public.facts + FOR EACH ROW EXECUTE FUNCTION public.fact_references_stay_inside_the_kb(); + +CREATE TRIGGER facts_supersedes_same_kb + BEFORE INSERT OR UPDATE OF kb_id, supersedes ON public.facts + FOR EACH ROW EXECUTE FUNCTION public.fact_supersedes_stays_inside_the_kb(); + +-- 提交边界上的重估:同表自指在 COPY/多行 INSERT 里可能先于目标落盘。 +-- DEFERRABLE INITIALLY DEFERRED——psql/pg_restore 的一整条 COPY 是一个语句, +-- 递延约束在它的提交点触发;显式事务里的顺序插入则在 COMMIT 触发。两种 +-- 写法的终点都是「整批可见」。目标自始至终不存在的交给外键报,这里只管 +-- 「存在,却在别的库」。 +CREATE CONSTRAINT TRIGGER facts_supersedes_same_kb_deferred + AFTER INSERT OR UPDATE ON public.facts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW WHEN (NEW.supersedes IS NOT NULL) + EXECUTE FUNCTION public.fact_supersedes_stays_inside_the_kb(); + +-- from_statement_id 是另一条同表自指:陈述与类型化事实可能同一批落地, +-- 与 supersedes 同一形状——BEFORE 立即检查 + 提交边界的递延重估。 +CREATE FUNCTION public.fact_from_statement_stays_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + IF TG_OP = 'UPDATE' + AND NEW.from_statement_id IS NOT DISTINCT FROM OLD.from_statement_id + AND NEW.kb_id IS NOT DISTINCT FROM OLD.kb_id THEN + RETURN NEW; + END IF; + IF NEW.from_statement_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.facts WHERE id = NEW.from_statement_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'fact % from_statement % cannot live in another knowledge base', + NEW.id, NEW.from_statement_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER facts_from_statement_same_kb + BEFORE INSERT OR UPDATE OF kb_id, from_statement_id ON public.facts + FOR EACH ROW EXECUTE FUNCTION public.fact_from_statement_stays_inside_the_kb(); + +CREATE CONSTRAINT TRIGGER facts_from_statement_same_kb_deferred + AFTER INSERT OR UPDATE ON public.facts + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW WHEN (NEW.from_statement_id IS NOT NULL) + EXECUTE FUNCTION public.fact_from_statement_stays_inside_the_kb(); + +-- 派生事实:主语、宾语、谓词、公理规则、业务规则。 +CREATE FUNCTION public.derived_references_stay_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + SELECT kb_id INTO ref_kb FROM public.entities WHERE id = NEW.subject_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'derived fact % subject % cannot live in another knowledge base', + NEW.id, NEW.subject_id; + END IF; + IF NEW.object_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entities WHERE id = NEW.object_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'derived fact % object % cannot live in another knowledge base', + NEW.id, NEW.object_id; + END IF; + END IF; + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.predicate_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'derived fact % predicate % cannot live in another knowledge base', + NEW.id, NEW.predicate_id; + END IF; + IF NEW.rule_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.rules WHERE id = NEW.rule_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'derived fact % rule % cannot live in another knowledge base', + NEW.id, NEW.rule_id; + END IF; + END IF; + IF NEW.attribute_rule_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.attribute_rules WHERE id = NEW.attribute_rule_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'derived fact % attribute rule % cannot live in another knowledge base', + NEW.id, NEW.attribute_rule_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER derived_facts_references_same_kb + BEFORE INSERT OR UPDATE OF kb_id, subject_id, object_id, predicate_id, rule_id, attribute_rule_id + ON public.derived_facts + FOR EACH ROW EXECUTE FUNCTION public.derived_references_stay_inside_the_kb(); + +-- 实体类型:导出按 id 进本库词汇表查类,别库的类会被静默丢掉。 +CREATE FUNCTION public.entity_type_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + IF NEW.type_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.type_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'entity % type % cannot live in another knowledge base', + NEW.id, NEW.type_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER entities_type_same_kb + BEFORE INSERT OR UPDATE OF kb_id, type_id ON public.entities + FOR EACH ROW EXECUTE FUNCTION public.entity_type_stays_inside_its_kb(); + +-- 类层级:父类必须与子类同库。 +CREATE FUNCTION public.type_parent_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + child_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO child_kb FROM public.entity_types WHERE id = NEW.child_id; + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.parent_id; + IF child_kb IS NOT NULL AND ref_kb IS NOT NULL AND ref_kb <> child_kb THEN + RAISE EXCEPTION 'class % cannot parent % across knowledge bases', + NEW.parent_id, NEW.child_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER entity_type_parents_same_kb + BEFORE INSERT OR UPDATE OF child_id, parent_id ON public.entity_type_parents + FOR EACH ROW EXECUTE FUNCTION public.type_parent_stays_inside_its_kb(); + +-- 互斥公理:两个类都必须在这行自己的 kb 里。 +CREATE FUNCTION public.disjoint_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.a_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'disjointness cannot name class % outside kb %', NEW.a_id, NEW.kb_id; + END IF; + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.b_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'disjointness cannot name class % outside kb %', NEW.b_id, NEW.kb_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER entity_type_disjoint_same_kb + BEFORE INSERT OR UPDATE OF kb_id, a_id, b_id ON public.entity_type_disjoint + FOR EACH ROW EXECUTE FUNCTION public.disjoint_stays_inside_its_kb(); + +-- 关系的 domain/range:类必须与关系同库。两张表同形,共用一个函数。 +CREATE FUNCTION public.relation_scope_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + rel_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO rel_kb FROM public.relation_types WHERE id = NEW.relation_type_id; + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.entity_type_id; + IF rel_kb IS NOT NULL AND ref_kb IS NOT NULL AND ref_kb <> rel_kb THEN + RAISE EXCEPTION '% cannot name class % outside the relation''s knowledge base', + TG_TABLE_NAME, NEW.entity_type_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER relation_type_domains_same_kb + BEFORE INSERT OR UPDATE OF relation_type_id, entity_type_id ON public.relation_type_domains + FOR EACH ROW EXECUTE FUNCTION public.relation_scope_stays_inside_its_kb(); + +CREATE TRIGGER relation_type_ranges_same_kb + BEFORE INSERT OR UPDATE OF relation_type_id, entity_type_id ON public.relation_type_ranges + FOR EACH ROW EXECUTE FUNCTION public.relation_scope_stays_inside_its_kb(); + +-- 关系自声明的边属性(0037):owl:inverseOf/rdfs:subPropertyOf 进导出后, +-- qualifier 列表也跟着出——三种引用都以「关系自己的库」为准。 +-- inverse_of / sub_property_of 是同表自指:BEFORE 只管目标已存在时立即报错, +-- 提交边界由下面的递延约束重估。 +CREATE FUNCTION public.relation_links_stay_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + IF TG_OP = 'UPDATE' + AND NEW.inverse_of IS NOT DISTINCT FROM OLD.inverse_of + AND NEW.sub_property_of IS NOT DISTINCT FROM OLD.sub_property_of + AND NEW.kb_id IS NOT DISTINCT FROM OLD.kb_id THEN + RETURN NEW; + END IF; + IF NEW.inverse_of IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.inverse_of; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'relation % inverse % cannot live in another knowledge base', + NEW.id, NEW.inverse_of; + END IF; + END IF; + IF NEW.sub_property_of IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.sub_property_of; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'relation % sub_property_of % cannot live in another knowledge base', + NEW.id, NEW.sub_property_of; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER relation_types_links_same_kb + BEFORE INSERT OR UPDATE OF kb_id, inverse_of, sub_property_of ON public.relation_types + FOR EACH ROW EXECUTE FUNCTION public.relation_links_stay_inside_the_kb(); + +CREATE CONSTRAINT TRIGGER relation_types_links_same_kb_deferred + AFTER INSERT OR UPDATE ON public.relation_types + DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW WHEN (NEW.inverse_of IS NOT NULL OR NEW.sub_property_of IS NOT NULL) + EXECUTE FUNCTION public.relation_links_stay_inside_the_kb(); + +-- 关系挂的边属性声明:qualifier 必须是**同一个库**里的行(形态校验—— +-- 必须是 kind='attribute'——在 store 层,这里是归属层)。 +CREATE FUNCTION public.relation_qualifier_stays_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + rel_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO rel_kb FROM public.relation_types WHERE id = NEW.relation_type_id; + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.qualifier_type_id; + IF rel_kb IS NOT NULL AND ref_kb IS NOT NULL AND ref_kb <> rel_kb THEN + RAISE EXCEPTION 'relation qualifier % cannot live outside the relation''s knowledge base', + NEW.qualifier_type_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER relation_type_qualifiers_same_kb + BEFORE INSERT OR UPDATE OF relation_type_id, qualifier_type_id ON public.relation_type_qualifiers + FOR EACH ROW EXECUTE FUNCTION public.relation_qualifier_stays_inside_the_kb(); + +-- 规则编在哪个谓词上:导出把它写成 utopia:onPredicate → 谓词 IRI, +-- 别库的谓词会被词汇表查空——坏行当场报错,不许静默少一条公理。 +CREATE FUNCTION public.rule_predicate_stays_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.predicate_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'rule % predicate % cannot live in another knowledge base', + NEW.id, NEW.predicate_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER rules_predicate_same_kb + BEFORE INSERT OR UPDATE OF kb_id, predicate_id ON public.rules + FOR EACH ROW EXECUTE FUNCTION public.rule_predicate_stays_inside_the_kb(); + +-- 业务规则的主体与结论:类与谓词都以规则自己的库为准。 +CREATE FUNCTION public.attribute_rule_refs_stay_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.subject_type_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'attribute rule % subject type % cannot live in another knowledge base', + NEW.id, NEW.subject_type_id; + END IF; + IF NEW.conclude_type_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.conclude_type_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'attribute rule % conclude type % cannot live in another knowledge base', + NEW.id, NEW.conclude_type_id; + END IF; + END IF; + IF NEW.conclude_predicate_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.conclude_predicate_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'attribute rule % conclude predicate % cannot live in another knowledge base', + NEW.id, NEW.conclude_predicate_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER attribute_rules_refs_same_kb + BEFORE INSERT OR UPDATE OF kb_id, subject_type_id, conclude_type_id, conclude_predicate_id + ON public.attribute_rules + FOR EACH ROW EXECUTE FUNCTION public.attribute_rule_refs_stay_inside_the_kb(); + +-- 规则条件读的谓词:条件行没有自己的 kb 列,归属按所属规则的库判—— +-- 导出把 predicate_id 铸成本库谓词 IRI,别库的谓词会被词汇表查空。 +-- rule_id 是普通外键:不存在的规则装不进来(串行写时点检查就够—— +-- 谓词与规则都必须在 INSERT 时已存在,kb 又都不可过户) +CREATE FUNCTION public.rule_condition_refs_stay_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + rule_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO rule_kb FROM public.attribute_rules WHERE id = NEW.rule_id; + IF rule_kb IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.predicate_id; + IF ref_kb IS NOT NULL AND ref_kb <> rule_kb THEN + RAISE EXCEPTION 'rule condition % predicate % cannot live outside the rule''s knowledge base', + NEW.id, NEW.predicate_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER attribute_rule_conditions_same_kb + BEFORE INSERT OR UPDATE OF rule_id, predicate_id ON public.attribute_rule_conditions + FOR EACH ROW EXECUTE FUNCTION public.rule_condition_refs_stay_inside_the_kb(); + +-- 陈述→类型化事实的来源边:statement 必须与行主(fact)同库。 +-- 两端都是必填外键,写入时必然在场——串行写时点检查就够,kb 又都不可过户。 +CREATE FUNCTION public.typed_source_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + fact_kb uuid; + ref_kb uuid; +BEGIN + SELECT kb_id INTO fact_kb FROM public.facts WHERE id = NEW.fact_id; + SELECT kb_id INTO ref_kb FROM public.facts WHERE id = NEW.statement_id; + IF fact_kb IS NOT NULL AND ref_kb IS NOT NULL AND ref_kb <> fact_kb THEN + RAISE EXCEPTION 'typed fact source % cannot live outside the fact''s knowledge base', + NEW.statement_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER typed_fact_sources_same_kb + BEFORE INSERT OR UPDATE OF fact_id, statement_id ON public.typed_fact_sources + FOR EACH ROW EXECUTE FUNCTION public.typed_source_stays_inside_its_kb(); + +-- 开放陈述的属性:实体值必须与所属事实同库(归属按 fact 的库判—— +-- 属性行自己没有 kb 列)。 +CREATE FUNCTION public.squalifier_stays_inside_its_facts_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + fact_kb uuid; + ref_kb uuid; +BEGIN + IF NEW.entity_id IS NULL THEN + RETURN NEW; + END IF; + SELECT kb_id INTO fact_kb FROM public.facts WHERE id = NEW.fact_id; + SELECT kb_id INTO ref_kb FROM public.entities WHERE id = NEW.entity_id; + IF fact_kb IS NOT NULL AND ref_kb IS NOT NULL AND ref_kb <> fact_kb THEN + RAISE EXCEPTION 'statement qualifier entity % cannot live outside the fact''s knowledge base', + NEW.entity_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER statement_qualifiers_same_kb + BEFORE INSERT OR UPDATE OF fact_id, entity_id ON public.statement_qualifiers + FOR EACH ROW EXECUTE FUNCTION public.squalifier_stays_inside_its_facts_kb(); + +-- 时间提及:提及自己的 kb 必须与它指的事实、段落同属一库。 +CREATE FUNCTION public.time_mention_stays_inside_its_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + SELECT kb_id INTO ref_kb FROM public.facts WHERE id = NEW.fact_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'time mention % cannot point at fact % in another knowledge base', + NEW.id, NEW.fact_id; + END IF; + SELECT kb_id INTO ref_kb FROM public.chunks WHERE id = NEW.chunk_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'time mention % cannot point at chunk % in another knowledge base', + NEW.id, NEW.chunk_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER time_mentions_same_kb + BEFORE INSERT OR UPDATE OF kb_id, fact_id, chunk_id ON public.time_mentions + FOR EACH ROW EXECUTE FUNCTION public.time_mention_stays_inside_its_kb(); + +-- 类型绑定:绑定的类必须与绑定行自己的库同属一库。 +CREATE FUNCTION public.type_binding_refs_stay_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + IF NEW.type_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.type_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'type binding % cannot name class % in another knowledge base', + NEW.id, NEW.type_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER type_bindings_same_kb + BEFORE INSERT OR UPDATE OF kb_id, type_id ON public.type_bindings + FOR EACH ROW EXECUTE FUNCTION public.type_binding_refs_stay_inside_the_kb(); + +-- 短语绑定:主语类、宾语类、关系类型都以绑定行自己的库为准。 +CREATE FUNCTION public.phrase_binding_refs_stay_inside_the_kb() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +DECLARE + ref_kb uuid; +BEGIN + IF NEW.subject_type_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.subject_type_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'phrase binding % subject type % cannot live in another knowledge base', + NEW.id, NEW.subject_type_id; + END IF; + END IF; + IF NEW.object_type_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.entity_types WHERE id = NEW.object_type_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'phrase binding % object type % cannot live in another knowledge base', + NEW.id, NEW.object_type_id; + END IF; + END IF; + IF NEW.relation_type_id IS NOT NULL THEN + SELECT kb_id INTO ref_kb FROM public.relation_types WHERE id = NEW.relation_type_id; + IF ref_kb IS NOT NULL AND ref_kb <> NEW.kb_id THEN + RAISE EXCEPTION 'phrase binding % relation type % cannot live in another knowledge base', + NEW.id, NEW.relation_type_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER phrase_bindings_same_kb + BEFORE INSERT OR UPDATE OF kb_id, subject_type_id, object_type_id, relation_type_id + ON public.phrase_bindings + FOR EACH ROW EXECUTE FUNCTION public.phrase_binding_refs_stay_inside_the_kb(); + +-- ===================================================================== +-- §2 库归属不可过户:把被引用的行挪走,等于把指着它的行一次全变坏行 +-- ===================================================================== +CREATE FUNCTION public.kb_ownership_is_not_reassigned() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog AS $$ +BEGIN + IF NEW.kb_id IS DISTINCT FROM OLD.kb_id THEN + RAISE EXCEPTION 'kb ownership of % is immutable', TG_TABLE_NAME; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER facts_keep_their_kb + BEFORE UPDATE OF kb_id ON public.facts + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER derived_facts_keep_their_kb + BEFORE UPDATE OF kb_id ON public.derived_facts + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER documents_keep_their_kb + BEFORE UPDATE OF kb_id ON public.documents + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER entities_keep_their_kb + BEFORE UPDATE OF kb_id ON public.entities + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER entity_types_keep_their_kb + BEFORE UPDATE OF kb_id ON public.entity_types + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER relation_types_keep_their_kb + BEFORE UPDATE OF kb_id ON public.relation_types + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER rules_keep_their_kb + BEFORE UPDATE OF kb_id ON public.rules + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER attribute_rules_keep_their_kb + BEFORE UPDATE OF kb_id ON public.attribute_rules + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER entity_type_disjoint_keep_their_kb + BEFORE UPDATE OF kb_id ON public.entity_type_disjoint + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER time_mentions_keep_their_kb + BEFORE UPDATE OF kb_id ON public.time_mentions + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER type_bindings_keep_their_kb + BEFORE UPDATE OF kb_id ON public.type_bindings + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); + +CREATE TRIGGER phrase_bindings_keep_their_kb + BEFORE UPDATE OF kb_id ON public.phrase_bindings + FOR EACH ROW EXECUTE FUNCTION public.kb_ownership_is_not_reassigned(); From 4d9d26aa5acd424e4f4723b2acde7b961af918ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=97=E6=85=B6=E9=BA=9F?= Date: Sun, 20 Sep 2026 18:51:27 +0800 Subject: [PATCH 2/4] An export reads one snapshot and vouches for every reference it resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export read model moves from "open a pool connection per page" to a caller-provided transaction: a route can now pin one REPEATABLE READ snapshot across the preflight check, the vocabulary reads, and every page. A mid-stream commit can no longer leak half a rule or a dangling wasGeneratedBy into a finished file. Integrity is checked on the rows that survive, not on a second look. Every page query selects the referenced row's kb atomically with the row itself; a foreign, dangling, or merged-out reference refuses the whole export rather than minting a local IRI that names another KB's row or silently dropping a vocabulary link. The scan covers the provenance chain, derivation premises, edge qualifiers, vocabulary references, and the open-statement / time-mention / binding edges the schema has grown since — the same families migration 0070 guards on the write side. Read-model additions carried by the same pages: statement qualifiers, time mentions, typed-fact sources, fact layer/phrase/validity grade, evidence quote offsets, chunk origin metadata, entity descriptions, document reader/time-context fields, and vocabulary updated_at. --- crates/utopia-server/src/api/export_routes.rs | 29 +- crates/utopia-server/src/api/mcp_tests.rs | 3 +- crates/utopia-store/src/export.rs | 1526 ++++++++++++++++- ...a_merged_target_stays_out_of_the_export.rs | 288 ++++ .../an_export_carries_the_whole_ledger.rs | 48 +- ...pen_statement_keeps_the_documents_words.rs | 5 +- .../tests/cross_kb_provenance_fails_closed.rs | 306 ++++ crates/utopia-store/tests/export_surfaces.rs | 749 ++++++++ .../exported_references_never_cross_a_kb.rs | 849 +++++++++ 9 files changed, 3729 insertions(+), 74 deletions(-) create mode 100644 crates/utopia-store/tests/a_merged_target_stays_out_of_the_export.rs create mode 100644 crates/utopia-store/tests/cross_kb_provenance_fails_closed.rs create mode 100644 crates/utopia-store/tests/export_surfaces.rs create mode 100644 crates/utopia-store/tests/exported_references_never_cross_a_kb.rs diff --git a/crates/utopia-server/src/api/export_routes.rs b/crates/utopia-server/src/api/export_routes.rs index ff853c7cd..db33d5a52 100644 --- a/crates/utopia-server/src/api/export_routes.rs +++ b/crates/utopia-server/src/api/export_routes.rs @@ -4,6 +4,10 @@ //! 一个十万条事实的库正是最需要导出的那种库,也正是「先拼成一个 String」会 //! 把服务打死的那种库。 //! +//! **同一份快照**:体检、词汇表与每一页查询跑在同一条只读 REPEATABLE READ +//! 事务里。若每页各自拿一条连接,词汇表发完之后才提交的规则会在派生页里 +//! 留下一条指向它的 `wasGeneratedBy`——图里就出现没有本体的引用。 +//! //! 中途出错只能截断——HTTP 头早就发出去了。所以错误进日志,而客户端拿到的是 //! 一份短了一截的文件;这比先攒后发要好,那种做法在同样的库上根本发不出来。 @@ -44,6 +48,18 @@ pub async fn export( })?; let names = Names::new(kb_id, q.base.as_deref()).map_err(AppError::Validation)?; + // 整份导出占一条连接上的只读 REPEATABLE READ 事务:下面的预检与流里 + // 每一页查询读同一个快照,事务活到流结束 + let mut tx = state.pool.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + + // 出处链越库一律拒导(0070 挡新行,这里挡存量坏行):宁可不发一个字节, + // 也不能把别库的对象安上本库的 IRI——那份文件看着完整、实则悬空。 + // 在流开始之前拦下:客户端拿到的是确定性的错误,不是一截断掉的文件 + utopia_store::export::provenance_integrity(&mut tx, kb_id).await?; + // 导出是一次「整个库离开这台机器」的动作,台账要记下(0014 的同一条理由) let _ = utopia_store::audit::record( &state.pool, @@ -56,13 +72,12 @@ pub async fn export( ) .await; - let pool = state.pool.clone(); let stream = async_stream::try_stream! { let buf = SharedBuf::default(); let mut sink = Sink::new(format, buf.clone()); - let classes = utopia_store::export::classes(&pool, kb_id).await.map_err(io)?; - let relations = utopia_store::export::relations(&pool, kb_id).await.map_err(io)?; + let classes = utopia_store::export::classes(&mut tx, kb_id).await.map_err(io)?; + let relations = utopia_store::export::relations(&mut tx, kb_id).await.map_err(io)?; let vocab = rdf::vocabulary(&names, &classes, &relations); for c in &classes { rdf::emit_class(&mut sink, &vocab, c)?; @@ -74,7 +89,7 @@ pub async fn export( let mut after = None; loop { - let page = utopia_store::export::documents_page(&pool, kb_id, after).await.map_err(io)?; + let page = utopia_store::export::documents_page(&mut tx, kb_id, after).await.map_err(io)?; let Some(last) = page.last() else { break }; after = Some(last.id); for d in &page { @@ -85,7 +100,7 @@ pub async fn export( let mut after = None; loop { - let page = utopia_store::export::entities_page(&pool, kb_id, after).await.map_err(io)?; + let page = utopia_store::export::entities_page(&mut tx, kb_id, after).await.map_err(io)?; let Some(last) = page.last() else { break }; after = Some(last.id); for e in &page { @@ -99,7 +114,7 @@ pub async fn export( let now = chrono::Utc::now(); let mut after = None; loop { - let page = utopia_store::export::facts_page(&pool, kb_id, after).await.map_err(io)?; + let page = utopia_store::export::facts_page(&mut tx, kb_id, after).await.map_err(io)?; let Some(last) = page.last() else { break }; after = Some(last.id); for f in &page { @@ -110,7 +125,7 @@ pub async fn export( let mut after = None; loop { - let page = utopia_store::export::derived_page(&pool, kb_id, after).await.map_err(io)?; + let page = utopia_store::export::derived_page(&mut tx, kb_id, after).await.map_err(io)?; let Some(last) = page.last() else { break }; after = Some(last.id); for d in &page { diff --git a/crates/utopia-server/src/api/mcp_tests.rs b/crates/utopia-server/src/api/mcp_tests.rs index 5e2f92adb..2e7c49d1f 100644 --- a/crates/utopia-server/src/api/mcp_tests.rs +++ b/crates/utopia-server/src/api/mcp_tests.rs @@ -622,7 +622,8 @@ async fn entity_facts_keeps_identity_values_filters_and_both_clocks() -> anyhow: assert!(derived["rule_id"].is_null()); uuid(&derived["attribute_rule_id"]); // The same UUID is the RDF statement's identity, not a newly minted response ID. - let exported = utopia_store::export::facts_page(&f.state.pool, f.kb, None).await?; + let exported = + utopia_store::export::facts_page(&mut f.state.pool.begin().await?, f.kb, None).await?; assert!(exported .iter() .any(|r| r.id == uuid(&corrected["id"]) && r.documents == vec![f.document])); diff --git a/crates/utopia-store/src/export.rs b/crates/utopia-store/src/export.rs index 33fdc130e..16caf04d1 100644 --- a/crates/utopia-store/src/export.rs +++ b/crates/utopia-store/src/export.rs @@ -6,15 +6,347 @@ //! //! 除本体外一律**按 id 分页**:一个库的事实可以有几十万条,全读进内存再序列化 //! 会在最需要它的那种部署上炸掉。id 是 uuid v7,按它排序即按写入顺序排序。 +//! 第一页没有下界(`$2 IS NULL OR id > $2`):uuid 没有更小的哨兵可垫—— +//! `id > NIL` 会把主键恰为 NIL 的合法行永远挡在导出外,而指向它的 +//! (document_id, version) 定位器照样解析,留下一条没有本体的边。 +//! +//! **逐页校验用的是留下的那几行自己**:每个 page 查询把被引行的 +//! kb_id 与行本体**原子地一并选出**,校验在内存里跑。不能「先取一页、再去库里 +//! 问一次」——第二次问的是另一个时刻的状态,留下的行早已不是它。 use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use utopia_core::AppResult; +use sqlx::{Postgres, Transaction}; +use utopia_core::{AppError, AppResult}; use uuid::Uuid; /// 一次取多少行。够大以免把往返次数拉满,够小以免一页就撑爆内存。 pub const PAGE: i64 = 500; +/// 出处链越界的一类引用。单列外键只认 id、不认库:A 库的 +/// 行可以引用 B 库的对象,schema 什么都不拦。0070 的触发器挡新行;这里拦的是 +/// **存量坏行**与绕过触发器写进来的行。 +/// +/// 处置一律是**整份拒导**:把别库对象的 id 铸进本库 IRI +/// (`urn:utopia:kb:A:document:{B 的文档}`)等于伪造身份——那份文件看着 +/// 完整,实则悬空。被词汇表解析的引用(谓词、属性类型、实体类型)越库则 +/// 静默消失——坏行一样不许放行。少导一行、换个 IRI 都不在选项里 +#[derive(Debug, sqlx::FromRow)] +pub struct CrossKbViolation { + /// 哪条边:chunk.document | evidence.chunk | derivation.premise_fact | … + pub edge: String, + pub rows: i64, +} + +fn cross_kb_error(violations: &[CrossKbViolation]) -> AppError { + let detail = violations + .iter() + .map(|v| format!("{}: {} row(s)", v.edge, v.rows)) + .collect::>() + .join("; "); + AppError::invalid_detail( + "cross_kb_provenance", + "export refused: KB-scoped provenance points outside the knowledge base", + detail, + ) +} + +/// 引用指着的东西**在本库,却不在导出集里**(合并掉的实体是唯一会缺席的 +/// 实体——`entities_page` 滤掉 `merged_into` 非空的行)。同库但缺席的引用 +/// 不能换 IRI、也不能静默省略:整份拒导,与越库同一处置。 +fn unexported_error(violations: &[CrossKbViolation]) -> AppError { + let detail = violations + .iter() + .map(|v| format!("{}: {} row(s)", v.edge, v.rows)) + .collect::>() + .join("; "); + AppError::invalid_detail( + "unexported_target", + "export refused: a reference points at a row that is not in this KB's exported set", + detail, + ) +} + +fn tally(violations: &mut Vec, edge: &str, rows: i64) { + if rows > 0 { + violations.push(CrossKbViolation { + edge: edge.into(), + rows, + }); + } +} + +/// 引用列的判定:**留下的是谁,就查谁**。`ref_kb` 由 page 查询与行本体原子地 +/// 一并选出——别库、悬空(NULL)都不算本库,一律判违规 +fn foreign(ref_kb: Option, kb_id: Uuid) -> bool { + ref_kb != Some(kb_id) +} + +/// 导出前的出处体检。逐类数一遍越界引用,有一行就整份拒导。 +/// 越界有两类,各自一条错:**别库/悬空**(`cross_kb`)与**同库但不在导出集** +/// (`unexported`——合并掉的实体)。只报哪条边坏了、坏了几行——具体哪些行 +/// 坏是库里的事,不进面向导出的报错 +/// +/// **必须在导出用的那条事务里跑**(REPEATABLE READ):体检与每一页查询看的是 +/// 同一个快照,先体检后换连接会在两个时刻之间漏掉刚提交的坏行 +pub async fn provenance_integrity( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, +) -> AppResult<()> { + #[derive(sqlx::FromRow)] + struct ScanViolation { + edge: String, + kind: String, + rows: i64, + } + let violations: Vec = sqlx::query_as( + "SELECT edge, kind, COUNT(*) AS rows FROM ( + SELECT 'chunk.document'::text AS edge, 'cross_kb'::text AS kind, + d.kb_id IS DISTINCT FROM c.kb_id AS bad + FROM chunks c LEFT JOIN documents d ON d.id = c.document_id + WHERE c.kb_id = $1 + UNION ALL + SELECT 'evidence.chunk', 'cross_kb', c.kb_id IS DISTINCT FROM f.kb_id + FROM fact_evidence e + JOIN facts f ON f.id = e.fact_id + LEFT JOIN chunks c ON c.id = e.chunk_id + WHERE f.kb_id = $1 + UNION ALL + SELECT 'evidence.document', 'cross_kb', d.kb_id IS DISTINCT FROM f.kb_id + FROM fact_evidence e + JOIN facts f ON f.id = e.fact_id + LEFT JOIN documents d ON d.id = e.document_id + WHERE f.kb_id = $1 AND e.document_id IS NOT NULL + UNION ALL + SELECT 'derivation.premise_fact', 'cross_kb', p.kb_id IS DISTINCT FROM d.kb_id + FROM fact_derivations fd + JOIN derived_facts d ON d.id = fd.derived_fact_id + LEFT JOIN facts p ON p.id = fd.premise_fact_id + WHERE d.kb_id = $1 AND fd.premise_fact_id IS NOT NULL + UNION ALL + SELECT 'derivation.premise_derived', 'cross_kb', p.kb_id IS DISTINCT FROM d.kb_id + FROM fact_derivations fd + JOIN derived_facts d ON d.id = fd.derived_fact_id + LEFT JOIN derived_facts p ON p.id = fd.premise_derived_id + WHERE d.kb_id = $1 AND fd.premise_derived_id IS NOT NULL + UNION ALL + SELECT 'qualifier.type', 'cross_kb', r.kb_id IS DISTINCT FROM f.kb_id + FROM fact_qualifiers q + JOIN facts f ON f.id = q.fact_id + LEFT JOIN relation_types r ON r.id = q.qualifier_type_id + WHERE f.kb_id = $1 + UNION ALL + SELECT 'qualifier.entity', 'cross_kb', e.kb_id IS DISTINCT FROM f.kb_id + FROM fact_qualifiers q + JOIN facts f ON f.id = q.fact_id + LEFT JOIN entities e ON e.id = q.entity_id + WHERE f.kb_id = $1 AND q.entity_id IS NOT NULL + UNION ALL + SELECT 'qualifier.entity(merged)', 'unexported', TRUE + FROM fact_qualifiers q + JOIN facts f ON f.id = q.fact_id + JOIN entities e ON e.id = q.entity_id AND e.merged_into IS NOT NULL + WHERE f.kb_id = $1 + UNION ALL + SELECT 'fact.subject', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN entities s ON s.id = f.subject_id + WHERE f.kb_id = $1 + UNION ALL + SELECT 'fact.subject(merged)', 'unexported', TRUE + FROM facts f JOIN entities s ON s.id = f.subject_id AND s.merged_into IS NOT NULL + WHERE f.kb_id = $1 + UNION ALL + SELECT 'fact.object', 'cross_kb', o.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN entities o ON o.id = f.object_id + WHERE f.kb_id = $1 AND f.object_id IS NOT NULL + UNION ALL + SELECT 'fact.object(merged)', 'unexported', TRUE + FROM facts f JOIN entities o ON o.id = f.object_id AND o.merged_into IS NOT NULL + WHERE f.kb_id = $1 + UNION ALL + SELECT 'fact.predicate', 'cross_kb', r.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN relation_types r ON r.id = f.predicate_id + WHERE f.kb_id = $1 AND f.predicate_id IS NOT NULL + UNION ALL + SELECT 'fact.supersedes', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN facts s ON s.id = f.supersedes + WHERE f.kb_id = $1 AND f.supersedes IS NOT NULL + UNION ALL + SELECT 'derived.subject', 'cross_kb', s.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN entities s ON s.id = d.subject_id + WHERE d.kb_id = $1 + UNION ALL + SELECT 'derived.subject(merged)', 'unexported', TRUE + FROM derived_facts d JOIN entities s ON s.id = d.subject_id AND s.merged_into IS NOT NULL + WHERE d.kb_id = $1 + UNION ALL + SELECT 'derived.object', 'cross_kb', o.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN entities o ON o.id = d.object_id + WHERE d.kb_id = $1 AND d.object_id IS NOT NULL + UNION ALL + SELECT 'derived.object(merged)', 'unexported', TRUE + FROM derived_facts d JOIN entities o ON o.id = d.object_id AND o.merged_into IS NOT NULL + WHERE d.kb_id = $1 + UNION ALL + SELECT 'derived.predicate', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN relation_types r ON r.id = d.predicate_id + WHERE d.kb_id = $1 + UNION ALL + SELECT 'derived.rule', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN rules r ON r.id = d.rule_id + WHERE d.kb_id = $1 AND d.rule_id IS NOT NULL + UNION ALL + SELECT 'derived.attribute_rule', 'cross_kb', r.kb_id IS DISTINCT FROM d.kb_id + FROM derived_facts d LEFT JOIN attribute_rules r ON r.id = d.attribute_rule_id + WHERE d.kb_id = $1 AND d.attribute_rule_id IS NOT NULL + UNION ALL + SELECT 'entity.type', 'cross_kb', t.kb_id IS DISTINCT FROM e.kb_id + FROM entities e LEFT JOIN entity_types t ON t.id = e.type_id + WHERE e.kb_id = $1 AND e.type_id IS NOT NULL + UNION ALL + SELECT 'class.parent', 'cross_kb', p.kb_id IS DISTINCT FROM c.kb_id + FROM entity_type_parents x + JOIN entity_types c ON c.id = x.child_id + LEFT JOIN entity_types p ON p.id = x.parent_id + WHERE c.kb_id = $1 + UNION ALL + SELECT 'class.disjoint', 'cross_kb', a.kb_id IS DISTINCT FROM dd.kb_id + FROM entity_type_disjoint dd + LEFT JOIN entity_types a ON a.id = dd.a_id + WHERE dd.kb_id = $1 + UNION ALL + SELECT 'class.disjoint', 'cross_kb', b.kb_id IS DISTINCT FROM dd.kb_id + FROM entity_type_disjoint dd + LEFT JOIN entity_types b ON b.id = dd.b_id + WHERE dd.kb_id = $1 + UNION ALL + SELECT 'relation.domain', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_type_domains x + JOIN relation_types r ON r.id = x.relation_type_id + LEFT JOIN entity_types t ON t.id = x.entity_type_id + WHERE r.kb_id = $1 + UNION ALL + SELECT 'relation.range', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_type_ranges x + JOIN relation_types r ON r.id = x.relation_type_id + LEFT JOIN entity_types t ON t.id = x.entity_type_id + WHERE r.kb_id = $1 + UNION ALL + SELECT 'relation.qualifier', 'cross_kb', q.kb_id IS DISTINCT FROM r.kb_id + FROM relation_type_qualifiers x + JOIN relation_types r ON r.id = x.relation_type_id + LEFT JOIN relation_types q ON q.id = x.qualifier_type_id + WHERE r.kb_id = $1 + UNION ALL + SELECT 'relation.inverse', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_types r LEFT JOIN relation_types t ON t.id = r.inverse_of + WHERE r.kb_id = $1 AND r.inverse_of IS NOT NULL + UNION ALL + SELECT 'relation.sub_property', 'cross_kb', t.kb_id IS DISTINCT FROM r.kb_id + FROM relation_types r LEFT JOIN relation_types t ON t.id = r.sub_property_of + WHERE r.kb_id = $1 AND r.sub_property_of IS NOT NULL + UNION ALL + SELECT 'rule.predicate', 'cross_kb', p.kb_id IS DISTINCT FROM u.kb_id + FROM rules u LEFT JOIN relation_types p ON p.id = u.predicate_id + WHERE u.kb_id = $1 + UNION ALL + SELECT 'arule.subject_type', 'cross_kb', t.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rules a LEFT JOIN entity_types t ON t.id = a.subject_type_id + WHERE a.kb_id = $1 + UNION ALL + SELECT 'arule.conclude_type', 'cross_kb', t.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rules a LEFT JOIN entity_types t ON t.id = a.conclude_type_id + WHERE a.kb_id = $1 AND a.conclude_type_id IS NOT NULL + UNION ALL + SELECT 'arule.conclude_predicate', 'cross_kb', p.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rules a LEFT JOIN relation_types p ON p.id = a.conclude_predicate_id + WHERE a.kb_id = $1 AND a.conclude_predicate_id IS NOT NULL + UNION ALL + -- 条件的谓词以**所属规则的库**为准:条件行自己没有 kb 列, + -- rule_id 是普通外键(不存在即外键报错),要管的是「都存在, + -- 却不在同一个库」——别库的谓词进词汇表会被查空 + SELECT 'condition.predicate', 'cross_kb', p.kb_id IS DISTINCT FROM a.kb_id + FROM attribute_rule_conditions c + JOIN attribute_rules a ON a.id = c.rule_id + LEFT JOIN relation_types p ON p.id = c.predicate_id + WHERE a.kb_id = $1 + UNION ALL + SELECT 'fact.from_statement', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM facts f LEFT JOIN facts s ON s.id = f.from_statement_id + WHERE f.kb_id = $1 AND f.from_statement_id IS NOT NULL + UNION ALL + -- 来源边行自己没有 kb 列:归属按所属 fact 的库判 + SELECT 'factsource.statement', 'cross_kb', s.kb_id IS DISTINCT FROM f.kb_id + FROM typed_fact_sources ts + JOIN facts f ON f.id = ts.fact_id + LEFT JOIN facts s ON s.id = ts.statement_id + WHERE f.kb_id = $1 + UNION ALL + -- 开放陈述的属性行自己没有 kb 列:归属按所属 fact 的库判 + SELECT 'squalifier.entity', 'cross_kb', e.kb_id IS DISTINCT FROM f.kb_id + FROM statement_qualifiers q + JOIN facts f ON f.id = q.fact_id + LEFT JOIN entities e ON e.id = q.entity_id + WHERE f.kb_id = $1 AND q.entity_id IS NOT NULL + UNION ALL + SELECT 'squalifier.entity(merged)', 'unexported', TRUE + FROM statement_qualifiers q + JOIN facts f ON f.id = q.fact_id + JOIN entities e ON e.id = q.entity_id AND e.merged_into IS NOT NULL + WHERE f.kb_id = $1 + UNION ALL + SELECT 'timemention.fact', 'cross_kb', f.kb_id IS DISTINCT FROM t.kb_id + FROM time_mentions t LEFT JOIN facts f ON f.id = t.fact_id + WHERE t.kb_id = $1 + UNION ALL + SELECT 'timemention.chunk', 'cross_kb', c.kb_id IS DISTINCT FROM t.kb_id + FROM time_mentions t LEFT JOIN chunks c ON c.id = t.chunk_id + WHERE t.kb_id = $1 + UNION ALL + SELECT 'binding.type', 'cross_kb', t.kb_id IS DISTINCT FROM b.kb_id + FROM type_bindings b LEFT JOIN entity_types t ON t.id = b.type_id + WHERE b.kb_id = $1 AND b.type_id IS NOT NULL + UNION ALL + SELECT 'pbinding.subject_type', 'cross_kb', t.kb_id IS DISTINCT FROM b.kb_id + FROM phrase_bindings b LEFT JOIN entity_types t ON t.id = b.subject_type_id + WHERE b.kb_id = $1 AND b.subject_type_id IS NOT NULL + UNION ALL + SELECT 'pbinding.object_type', 'cross_kb', t.kb_id IS DISTINCT FROM b.kb_id + FROM phrase_bindings b LEFT JOIN entity_types t ON t.id = b.object_type_id + WHERE b.kb_id = $1 AND b.object_type_id IS NOT NULL + UNION ALL + SELECT 'pbinding.relation', 'cross_kb', r.kb_id IS DISTINCT FROM b.kb_id + FROM phrase_bindings b LEFT JOIN relation_types r ON r.id = b.relation_type_id + WHERE b.kb_id = $1 AND b.relation_type_id IS NOT NULL + ) refs WHERE bad GROUP BY edge, kind", + ) + .bind(kb_id) + .fetch_all(&mut **tx) + .await?; + let cross_kb: Vec = violations + .iter() + .filter(|v| v.kind == "cross_kb") + .map(|v| CrossKbViolation { + edge: v.edge.clone(), + rows: v.rows, + }) + .collect(); + let unexported: Vec = violations + .iter() + .filter(|v| v.kind == "unexported") + .map(|v| CrossKbViolation { + edge: v.edge.clone(), + rows: v.rows, + }) + .collect(); + if !cross_kb.is_empty() { + return Err(cross_kb_error(&cross_kb)); + } + if !unexported.is_empty() { + return Err(unexported_error(&unexported)); + } + Ok(()) +} + #[derive(Debug, Clone, sqlx::FromRow)] pub struct ExportClass { pub id: Uuid, @@ -24,8 +356,15 @@ pub struct ExportClass { /// 导入来的类留着它原来的 IRI——schema.org 的 Organization 导出去还是 /// `schema:Organization`,读的人手里的词汇表对得上 pub iri: Option, + /// 内置词表(pack 带来、系统管的)与用户自长的类不是同一种来源—— + /// 审计要知道这个类是「系统声明过」还是「这个库自己长出来的」 + pub builtin: bool, pub parents: Vec, + /// `entity_type_parents.is_primary` 为真的父类:谁是主型是公理的一部分 + pub primary_parents: Vec, pub disjoint: Vec, + /// 最近一次改写(0064 起绑定按它判过期)——修改时刻也是词表语义的一部分 + pub updated_at: DateTime, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -37,6 +376,8 @@ pub struct ExportRelation { pub iri: Option, /// relation | attribute(后者值域是字面值) pub kind: String, + /// 属性的值域声明。字面值按它定型,但「这个属性声明的是什么类型」本身 + /// 也是语义:两个库同名不同型的属性不能读出同一张图 pub datatype: Option, pub unit: Option, pub temporal: String, @@ -46,10 +387,76 @@ pub struct ExportRelation { pub is_symmetric: bool, pub is_asymmetric: bool, pub is_irreflexive: bool, + pub builtin: bool, + /// 同表自指:导出 owl:inverseOf / rdfs:subPropertyOf。存量的合法性在 + /// 迁移的递延约束里管,这里只负责把集合带上(越库/悬空 → 拒导) pub inverse_of: Option, pub sub_property_of: Option, + /// 这条关系声明自己的边能带哪些属性(relation_type_qualifiers,0037) + pub qualifiers: Vec, pub domains: Vec, pub ranges: Vec, + pub updated_at: DateTime, +} + +/// 一条公理规则(rules):推理活动的身份。导出**全部**规则而不只被引用的—— +/// 「撤了公理还留着的那条」照样是这个库当时的推导词表,审计要看得见 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportRule { + pub id: Uuid, + /// transitive | symmetric | inverse | sub_property + pub kind: String, + pub predicate_id: Uuid, + /// predicate_id 指着的谓词的 kb(原子地一并选出) + pub predicate_kb: Option, +} + +/// 一条业务规则(attribute_rules,0021):审计要能从结论走回「凭什么推的」, +/// 所以规则体本身——看什么类、得出什么、按什么条件——也是导出的内容 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportAttributeRule { + pub id: Uuid, + pub name: String, + pub description: String, + /// typing | attribute | computed + pub conclusion: String, + pub subject_type_id: Uuid, + pub conclude_type_id: Option, + pub conclude_predicate_id: Option, + pub conclude_value: Option, + /// 计算结论的算式树(0032,jsonb):`attr` 叶子是 relation_type 引用—— + /// jsonb 装不下外键,越库/悬空的检查在取数时做,序列化时按词汇表解析 + pub conclude_expr: Option, + pub enabled: bool, + /// 前件(attribute_rule_conditions):按 (group_seq, seq) 全序带回, + /// 同组「与」、组间「或」(0039)。规则的判据本体,导出不能少 + #[sqlx(skip)] + pub conditions: Vec, + /// 被引行的 kb(原子地一并选出) + pub subject_type_kb: Option, + pub conclude_type_kb: Option, + pub conclude_predicate_kb: Option, +} + +/// 一条规则条件(attribute_rule_conditions 的一行,0028/0039):业务规则的 +/// 前件。身份是 (rule_id, group_seq, seq) 的全序——`id` 是行级代理键 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportRuleCondition { + pub id: Uuid, + pub rule_id: Uuid, + /// 组序:同组条件用「与」连,组间用「或」连 + pub group_seq: i32, + pub seq: i32, + /// 只能指 kind='attribute' 的谓词(0021)。归属以**所属规则的库**为准—— + /// 条件行自己没有 kb 列 + pub predicate_id: Uuid, + /// gt | gte | lt | lte | between | in | not_in | present + pub op: String, + /// present 为 NULL;gt/lte 的门槛可以是算式树(0032),attr 叶子同样是 + /// relation_type 引用——jsonb 没有外键,越库/悬空由导出侧校验拦下 + pub operand: Option, + /// predicate_id 指着的谓词的 kb(原子地一并选出) + pub predicate_kb: Option, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -57,6 +464,20 @@ pub struct ExportEntity { pub id: Uuid, pub canonical_name: String, pub type_id: Option, + /// type_id 指着的类的 kb(LEFT JOIN 一并选出)。别库/悬空 → 拒导: + /// 序列化按 id 进本库词汇表查类,查不着就是静默丢类型 + pub type_kb: Option, + /// 谁定的型:extracted | inferred | human(类型 provenance,治理要看) + pub type_source: String, + pub type_resolved_at: Option>, + /// 模型想给而本体接不住的词——不记就再也不知道它觉得这是什么 + pub proposed_type: Option, + /// 模型对这个实体自己的「最具体是个什么」的说法 + pub specific_type: Option, + /// 被描述、没有名字的东西的那一段描述(0061)——无名的实体靠它 + /// 在导出里说自己是什么 + pub description: Option, + pub created_at: DateTime, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -72,21 +493,108 @@ pub struct ExportFact { /// 边上的属性(0037),加载后按事实 id 补 #[sqlx(skip)] pub qualifiers: Vec, + /// typed | open(0061):开放陈述与类型化事实同出 facts 表——不标层, + /// 一条谓词为空的陈述在导出里与「弄丢了谓词的类型化事实」分不出来 + pub layer: String, + /// 开放陈述的名字——文档自己的那个关系词(0061) + pub phrase: Option, + /// 这条类型化事实从哪些陈述算出来:typed_fact_sources ∪ + /// from_statement_id(0067/0068)。每条都铸成 utopia:fromStatement 边 + pub source_statements: Vec, + /// source_statements 里是否有别库或悬空的陈述(原子地一并选出) + pub foreign_source: bool, + /// 开放陈述自己的属性(statement_qualifiers,0061),加载后按事实 id 补 + #[sqlx(skip)] + pub statement_qualifiers: Vec, + /// 陈述里的时间词(time_mentions,0061/0064),加载后按事实 id 补 + #[sqlx(skip)] + pub time_mentions: Vec, pub valid_from: Option>, pub valid_from_precision: Option, + /// valid_from 是怎么来的(0069):A 文档写明 / B 按文档锚点算 / C 没算出 + pub valid_from_grade: Option, pub valid_to: Option>, pub valid_to_precision: Option, /// 读出来的区间(0022):「现在仍成立」那条三元组按它判,不再自己解释 NULL pub holds_from: Option>, pub holds_to: Option>, + /// 世界轴的锚点(0034):没起点的事实从最早证据起算——锚点本身是语义的 + /// 一部分,丢了它读的人复算不出 holds_* 投影 + pub attested_from: DateTime, + /// 「结束了,不知哪天」的终点锚 + pub attested_to: Option>, + /// 终点是时态引擎画的(可重算)还是原文/人写明的(0057)——是两种语义 + pub end_derived: bool, + /// 旧规则派生的标记列(无 FK,现写路径不再写它)。断言与派生之分必须 + /// 活下去:导出只留「这条是规则推的」这个标记,不铸规则边 + pub rule_derived: bool, pub recorded_at: DateTime, pub invalidated_at: Option>, pub confidence: f32, pub supersedes: Option, pub documents: Vec, pub quotes: Vec, - /// 证据文字的来源(0040),去重:一条陈述的引文里有没有扫描、转写、看图描述来的 + /// 每条证据所在段的写入来源(chunks.origin,0063):一份导出里混着 + /// 抽取、粘贴、OCR 来的原句时,来源跟着事实走 pub quote_origins: Vec, + /// 以下各列是被引行的 kb,与行本体原子地一并选出。 + /// 别库或悬空(NULL)的引用不许被铸成本库 IRI,也不许静默跳过 + pub subject_kb: Option, + pub object_kb: Option, + pub predicate_kb: Option, + pub supersedes_kb: Option, + /// documents[] 里是否有别库或悬空的文档指针 + pub foreign_document: bool, + /// 主语/宾语指着**已合并**的实体:同库但不在导出集(merged_into IS NOT NULL + /// 的行 entities_page 不导)。与行本体原子地一并选出 + pub subject_merged: bool, + pub object_merged: bool, +} + +/// 开放陈述自己的一条属性(statement_qualifiers,0061):文档的角色词, +/// 不是词汇表里的谓词——role 是原话,value/entity_id 恰有一个在场 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportStatementQualifier { + pub fact_id: Uuid, + pub role: String, + pub value: Option, + pub entity_id: Option, + /// entity_id 指着的实体的 kb(原子地一并选出) + pub entity_kb: Option, + /// entity_id 指着已合并的实体:同库但不在导出集 + pub entity_merged: bool, +} + +/// 一条时间提及(time_mentions,0061/0064):陈述里照抄的时间词与它 +/// 的解算结果——一条陈述的 valid_* 是从这些字算出来的,出处链要走到字上 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportTimeMention { + pub id: Uuid, + /// 提及自己的库——必须与它所属事实的库相同(原子地一并选出) + pub kb_id: Uuid, + pub fact_id: Uuid, + pub chunk_id: Uuid, + /// 来自陈述的哪个槽:when | ended + pub role: String, + /// 照抄的字,永远不是算出来的日期 + pub text: String, + /// 在 chunks.text 里的字符偏移 + pub char_start: i32, + /// 模型的解释:点/区间/截至/时长……(抽取合同的词汇,照存) + pub shape: Option, + /// 照写的绝对值或锚点 + 偏移(jsonb,照存) + pub reference: Option, + pub granularity: Option, + /// A 文档写明 / B 按文档锚点算 / C 没算出 + pub grade: Option, + pub resolved_from: Option>, + pub resolved_from_precision: Option, + pub resolved_to: Option>, + pub resolved_to_precision: Option, + pub resolved_at: Option>, + pub recorded_at: DateTime, + /// chunk_id 指着的段落的 kb(原子地一并选出) + pub chunk_kb: Option, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -107,15 +615,33 @@ pub struct ExportDerived { pub derived_at: DateTime, pub invalidated_at: Option>, pub confidence: f32, - /// transitive | symmetric | inverse | sub_property,或 business - pub rule: String, - /// 业务规则的名字,进 RDF 当这条推理活动的标签 - pub rule_name: Option, - /// 前提事实。审计要顺着它往下走到句子 - pub premises: Vec, - /// 前提里是**另一条派生**的那些(0030)。与上面那列分开,是因为读回来的人 - /// 要知道该去哪张表接着往下走;合成一列的话,一条链在导出里就断了 - pub premises_derived: Vec, + /// 前提按 `fact_derivations.seq` 排:A→B→C→D 的证明读起来要是这个顺序, + /// 人才看得懂链是怎么走的。每条前提带种类(断言 facts / 派生 derived_facts) + /// 与序位 + #[sqlx(skip)] + pub premises: Vec, + /// 被引行的 kb,与行本体原子地一并选出 + pub subject_kb: Option, + pub object_kb: Option, + pub predicate_kb: Option, + pub rule_kb: Option, + pub attribute_rule_kb: Option, + /// premises[] 里是否有别库或悬空的前提(两种前提分开报边) + pub foreign_fact_premise: bool, + pub foreign_derived_premise: bool, + /// 主语/宾语指着已合并的实体:同库但不在导出集 + pub subject_merged: bool, + pub object_merged: bool, +} + +/// 一条前提(fact_derivations 的一行)。`seq` 是全序:断言与派生交错在同一 +/// 个序位序列里,拆成两列就再也看不出原来谁在第几位 +#[derive(Debug, Clone)] +pub struct ExportPremise { + pub seq: i32, + /// 恰有一个非空(表上的 CHECK) + pub fact_id: Option, + pub derived_id: Option, } #[derive(Debug, Clone, sqlx::FromRow)] @@ -123,67 +649,418 @@ pub struct ExportDocument { pub id: Uuid, pub filename: String, pub external_key: Option, + /// 内容完整性:审计要知道导出里这条出处对应的是哪一份字节 + pub sha256: String, + pub mime: String, + pub size_bytes: i64, + /// doc_time 是从哪来的(原文元数据 / 文件时间 / …)——锚的可信度 + pub doc_time_source: String, + pub tags: Vec, pub doc_time: Option>, pub created_at: DateTime, /// 删过的文档留着墓碑(#268)。导出里它仍在,只是记录轴上已经结束 pub deleted_at: Option>, + /// 内容已被清掉(0046):字节不在了,记录还在——审计要知道哪份出处 + /// 只剩骨架 + pub purged_at: Option>, + /// 字节还在等一个没配好的读取器(0063):为什么这份文档一直抽不出 + /// 段落——导出里的「没有 chunk」要靠它解释 + pub reader_needed: Option, + /// 文档自己的日期语境:它定义的期间、历法、叙述锚点(0064)。 + /// 时间提及的 grade B 解算就是照它算的 + pub time_context: Option, + pub time_context_at: Option>, } -pub async fn classes(pool: &PgPool, kb_id: Uuid) -> AppResult> { - Ok(sqlx::query_as( - "SELECT t.id, t.key, t.label, t.description, t.iri, +/// 文档里的一个段落。出处链的最后一环:审计从语句走到证据、从证据走到这里, +/// 才算站到原文上。**text 与 embedding 不导出**——它们常常比整份导出还大, +/// 而定位一段要的是 seq/heading/字符区间与文档版本,不是再复制一遍原文 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportChunk { + pub id: Uuid, + pub document_id: Uuid, + pub seq: i32, + pub heading: Option, + pub char_start: i32, + pub char_end: i32, + /// 这段文本属于文档的第几版(0006)。证据行上也有一份,两边对得上才配成对 + pub doc_version: i32, + /// (document_id, doc_version) 在 document_versions 里有没有对应行——有的话 + /// 序列化时把定位器绑到那一版的节点上(原子地一并选出) + pub version_row: bool, + /// 被新文档版本顶掉的段落。它引着的证据还要往下读,不能抹掉 + pub superseded_at: Option>, + /// 这段是抽取跑过的(0039):审计要能分清「这段还没进过抽取」与 + /// 「这段抽过但没产出」 + pub extracted_at: Option>, + /// 这段文本是谁写进账的(0063):stated(文档自己的话)| pasted | + /// ocr | …——同一句话来源不同,出处的分量不一样 + pub origin: String, + /// 写入来源用的模型(OCR 引擎、模型名——0063) + pub origin_model: Option, + /// 来源自报的锚点(页码、时间码……jsonb,0063):机器给的定位, + /// 不是文档自己的结构 + pub anchor: Option, + pub created_at: DateTime, + /// document_id 指着的文档的 kb(原子地一并选出) + pub document_kb: Option, +} + +/// 一条证据行:**「这句陈述,来自这一段」的配对本身**(fact_evidence 的主键就是 +/// (fact_id, chunk_id))。把它展平成语句上的「文档数组 + quote 数组」会把 +/// quote 与它所属的那段拆开——同一份文档两段都引了一句时,读的人再也对不上 +/// 哪句出自哪段 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportEvidence { + pub fact_id: Uuid, + pub chunk_id: Uuid, + /// 冗余的文档指针:多数行与 chunk.document_id 相同,但版本替换后仍指旧文档的 + /// 行存在——账本记了什么就导什么,不靠「应该一样」重建 + pub document_id: Option, + pub doc_version: Option, + /// (document_id, doc_version) 在 document_versions 里有没有对应行 + /// (原子地一并选出)——有才把版本定位器绑到那一版的节点上 + pub version_row: bool, + /// 模型当时引的原句 + pub quote: Option, + /// quote 在 chunks.text 里的字符区间(0061)——同一句话引在两段里 + /// 各有出处,定位要精确到字 + pub quote_start: Option, + pub quote_end: Option, + /// 模型对谓词的原话(0010)。fact 上那份是本体没接住时的兜底;这份是它 + /// 在**这条证据里**用的词——两处可能不同,所以分开留 + pub proposed_predicate: Option, + /// chunk/document 指着的行的 kb(原子地一并选出) + pub chunk_kb: Option, + pub document_kb: Option, +} + +/// 文档的一个版本(document_versions,0002):每次入库/替换一行, +/// 版本号、内容哈希、字节数、入库时刻。chunks.doc_version 与 +/// fact_evidence.doc_version 这对定位器解析到这里的行——不导它, +/// 「这段出自第几版」就只剩一个没有哈希、没有字节数的号码 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct ExportDocumentVersion { + pub id: Uuid, + pub document_id: Uuid, + pub version: i32, + pub sha256: String, + pub size_bytes: i64, + pub ingested_at: DateTime, + /// document_id 指着的文档的 kb(原子地一并选出):版本行自己没有 kb 列, + /// 它的库就是它所属文档的库 + pub document_kb: Option, +} + +pub async fn classes( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, +) -> AppResult> { + let classes: Vec = sqlx::query_as( + "SELECT t.id, t.key, t.label, t.description, t.iri, t.builtin, COALESCE(ARRAY(SELECT p.parent_id FROM entity_type_parents p WHERE p.child_id = t.id ORDER BY p.parent_id), '{}') AS parents, + COALESCE(ARRAY(SELECT p.parent_id FROM entity_type_parents p + WHERE p.child_id = t.id AND p.is_primary + ORDER BY p.parent_id), '{}') AS primary_parents, COALESCE(ARRAY(SELECT CASE WHEN d.a_id = t.id THEN d.b_id ELSE d.a_id END FROM entity_type_disjoint d WHERE d.kb_id = $1 AND (d.a_id = t.id OR d.b_id = t.id) - ORDER BY 1), '{}') AS disjoint + ORDER BY 1), '{}') AS disjoint, + t.updated_at FROM entity_types t WHERE t.kb_id = $1 ORDER BY t.key", ) .bind(kb_id) - .fetch_all(pool) - .await?) + .fetch_all(&mut **tx) + .await?; + // 父类与互斥类稍后要进本库词汇表按 id 查——查不着就是被静默丢掉。 + // 能解析的就地解析:词汇表全集就在手里,不在集合里的引用就是越库/悬空 + let own: std::collections::HashSet = classes.iter().map(|c| c.id).collect(); + let mut violations = Vec::new(); + for c in &classes { + let bad_parents = c.parents.iter().filter(|p| !own.contains(p)).count() as i64; + let bad_disjoint = c.disjoint.iter().filter(|p| !own.contains(p)).count() as i64; + tally(&mut violations, "class.parent", bad_parents); + tally(&mut violations, "class.disjoint", bad_disjoint); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + Ok(classes) } -pub async fn relations(pool: &PgPool, kb_id: Uuid) -> AppResult> { - Ok(sqlx::query_as( +pub async fn relations( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, +) -> AppResult> { + let relations: Vec = sqlx::query_as( "SELECT r.id, r.key, r.label, r.description, r.iri, r.kind, r.datatype, r.unit, r.temporal, r.functional, r.inverse_functional, r.is_transitive, r.is_symmetric, r.is_asymmetric, r.is_irreflexive, - r.inverse_of, r.sub_property_of, + r.builtin, r.inverse_of, r.sub_property_of, + COALESCE(ARRAY(SELECT q.qualifier_type_id FROM relation_type_qualifiers q + WHERE q.relation_type_id = r.id ORDER BY 1), '{}') AS qualifiers, COALESCE(ARRAY(SELECT d.entity_type_id FROM relation_type_domains d WHERE d.relation_type_id = r.id ORDER BY 1), '{}') AS domains, COALESCE(ARRAY(SELECT g.entity_type_id FROM relation_type_ranges g - WHERE g.relation_type_id = r.id ORDER BY 1), '{}') AS ranges + WHERE g.relation_type_id = r.id ORDER BY 1), '{}') AS ranges, + r.updated_at FROM relation_types r WHERE r.kb_id = $1 ORDER BY r.key", ) .bind(kb_id) - .fetch_all(pool) - .await?) + .fetch_all(&mut **tx) + .await?; + // domain/range 的类 id 与 inverse/sub_property/qualifier 的关系 id 都要进 + // 本库词汇表查——查不着就静默丢公理。**两行全集都在手里**,不在集合里的 + // 引用就是越库/悬空 + let own_types: Vec = sqlx::query_scalar( + "SELECT COALESCE(ARRAY(SELECT id FROM entity_types WHERE kb_id = $1), '{}')", + ) + .bind(kb_id) + .fetch_one(&mut **tx) + .await?; + let own: std::collections::HashSet = own_types.into_iter().collect(); + let own_rel: std::collections::HashSet = relations.iter().map(|r| r.id).collect(); + let mut violations = Vec::new(); + for r in &relations { + let bad_domains = r.domains.iter().filter(|t| !own.contains(t)).count() as i64; + let bad_ranges = r.ranges.iter().filter(|t| !own.contains(t)).count() as i64; + tally(&mut violations, "relation.domain", bad_domains); + tally(&mut violations, "relation.range", bad_ranges); + if let Some(t) = r.inverse_of { + tally( + &mut violations, + "relation.inverse", + (!own_rel.contains(&t)) as i64, + ); + } + if let Some(t) = r.sub_property_of { + tally( + &mut violations, + "relation.sub_property", + (!own_rel.contains(&t)) as i64, + ); + } + let bad_qualifiers = r.qualifiers.iter().filter(|t| !own_rel.contains(t)).count() as i64; + tally(&mut violations, "relation.qualifier", bad_qualifiers); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + Ok(relations) +} + +/// 公理规则全量导出(不只被派生引用着的):「撤了公理还留着的规则」仍是 +/// 这个库的推导词表——审计要知道当时**可能**推什么,不只**推了**什么 +pub async fn rules(tx: &mut Transaction<'_, Postgres>, kb_id: Uuid) -> AppResult> { + let rules: Vec = sqlx::query_as( + "SELECT u.id, u.kind, u.predicate_id, p.kb_id AS predicate_kb + FROM rules u LEFT JOIN relation_types p ON p.id = u.predicate_id + WHERE u.kb_id = $1 ORDER BY u.id", + ) + .bind(kb_id) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + for r in &rules { + tally( + &mut violations, + "rule.predicate", + foreign(r.predicate_kb, kb_id) as i64, + ); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + Ok(rules) +} + +/// 业务规则全量导出,同一理由:被关掉的(enabled=false)规则当时也产出过 +/// 派生——读的人要看得见它的判据,不只是它推出来的结论。 +/// 前件在另一张表(attribute_rule_conditions):按 (rule_id, group_seq, seq) +/// 整序带回——一条规则的「凭什么推」少了前件就只剩结论 +pub async fn attribute_rules( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, +) -> AppResult> { + let mut rules: Vec = sqlx::query_as( + "SELECT a.id, a.name, a.description, a.conclusion, a.subject_type_id, + a.conclude_type_id, a.conclude_predicate_id, a.conclude_value, + a.conclude_expr, a.enabled, + st.kb_id AS subject_type_kb, ct.kb_id AS conclude_type_kb, + cp.kb_id AS conclude_predicate_kb + FROM attribute_rules a + LEFT JOIN entity_types st ON st.id = a.subject_type_id + LEFT JOIN entity_types ct ON ct.id = a.conclude_type_id + LEFT JOIN relation_types cp ON cp.id = a.conclude_predicate_id + WHERE a.kb_id = $1 ORDER BY a.id", + ) + .bind(kb_id) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + for r in &rules { + tally( + &mut violations, + "arule.subject_type", + foreign(r.subject_type_kb, kb_id) as i64, + ); + if r.conclude_type_id.is_some() { + tally( + &mut violations, + "arule.conclude_type", + foreign(r.conclude_type_kb, kb_id) as i64, + ); + } + if r.conclude_predicate_id.is_some() { + tally( + &mut violations, + "arule.conclude_predicate", + foreign(r.conclude_predicate_kb, kb_id) as i64, + ); + } + } + // 前件另一张表:谓词列随行选出被引行的 kb,归属按规则的库判 + let ids: Vec = rules.iter().map(|r| r.id).collect(); + let mut by_rule = rule_conditions_for_export(tx, &ids, kb_id, &mut violations).await?; + // 表达式树里嵌着的谓词引用(conclude_expr 与算式 operand 的 `attr` 叶子)。 + // jsonb 列装不下外键——**导出侧的这次校验就是执行层**:越库、悬空、连 + // uuid 都解析不出来的引用,一律按坏行拒导,不铸死链 + let mut embedded: Vec> = Vec::new(); + for r in &rules { + if let Some(e) = &r.conclude_expr { + expr_predicate_ids(e, &mut embedded); + } + } + for conds in by_rule.values() { + for c in conds { + if let Some(o) = c.operand.as_ref().filter(|o| o.is_object()) { + expr_predicate_ids(o, &mut embedded); + } + } + } + if !embedded.is_empty() { + let ids: Vec = embedded.iter().flatten().copied().collect(); + let rows: Vec<(Uuid, Option)> = + sqlx::query_as("SELECT id, kb_id FROM relation_types WHERE id = ANY($1)") + .bind(&ids) + .fetch_all(&mut **tx) + .await?; + let kbs: std::collections::HashMap> = rows.into_iter().collect(); + // 解析不出 uuid 的叶子也是坏引用:表达式写着「读某个谓词」却连标识都不像 + let bad = embedded + .iter() + .filter(|u| match u { + Some(u) => kbs.get(u).copied().flatten() != Some(kb_id), + None => true, + }) + .count() as i64; + tally(&mut violations, "rule.expr_predicate", bad); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + for r in rules.iter_mut() { + if let Some(c) = by_rule.remove(&r.id) { + r.conditions = c; + } + } + Ok(rules) +} + +/// 规则的前件按 (rule_id, group_seq, seq) 取回——断言与判断交错在同一序位里。 +/// predicate_id 指着的谓词的 kb 原子地一并选出;operand 里表达式树的 attr +/// 引用由调用方连同 conclude_expr 一起校验 +async fn rule_conditions_for_export( + tx: &mut Transaction<'_, Postgres>, + rule_ids: &[Uuid], + kb_id: Uuid, + violations: &mut Vec, +) -> AppResult>> { + let mut out: std::collections::HashMap> = + std::collections::HashMap::new(); + if rule_ids.is_empty() { + return Ok(out); + } + let rows: Vec = sqlx::query_as( + "SELECT c.id, c.rule_id, c.group_seq, c.seq, c.predicate_id, c.op, c.operand, + p.kb_id AS predicate_kb + FROM attribute_rule_conditions c + LEFT JOIN relation_types p ON p.id = c.predicate_id + WHERE c.rule_id = ANY($1) + ORDER BY c.rule_id, c.group_seq, c.seq", + ) + .bind(rule_ids) + .fetch_all(&mut **tx) + .await?; + for r in &rows { + tally( + violations, + "condition.predicate", + foreign(r.predicate_kb, kb_id) as i64, + ); + } + for r in rows { + out.entry(r.rule_id).or_default().push(r); + } + Ok(out) +} + +/// 算式树(0032)里的 `attr` 叶子按出现序收进 `out`:`{"attr":""}` 叶子、 +/// `{"const":n}` 叶子、`{"op":…,"l":…,"r":…}` 节点。叶子值解析不出 uuid 的 +/// 记 None——那是写坏的表达式,调用方按越库/悬空同一处置(拒导) +pub fn expr_predicate_ids(raw: &serde_json::Value, out: &mut Vec>) { + let Some(obj) = raw.as_object() else { return }; + if let Some(a) = obj.get("attr") { + out.push(a.as_str().and_then(|s| Uuid::parse_str(s).ok())); + } else if obj.contains_key("const") { + // 常量叶子,没有引用 + } else if obj.contains_key("op") { + if let Some(l) = obj.get("l") { + expr_predicate_ids(l, out); + } + if let Some(r) = obj.get("r") { + expr_predicate_ids(r, out); + } + } } /// 合并掉的实体不导出:它已经不是一个东西了,它的事实早已搬到留下的那个身上。 pub async fn entities_page( - pool: &PgPool, + tx: &mut Transaction<'_, Postgres>, kb_id: Uuid, after: Option, ) -> AppResult> { - Ok(sqlx::query_as( - "SELECT id, canonical_name, type_id FROM entities - WHERE kb_id = $1 AND merged_into IS NULL AND id > COALESCE($2, '00000000-0000-0000-0000-000000000000'::uuid) - ORDER BY id LIMIT $3", + let page: Vec = sqlx::query_as( + "SELECT e.id, e.canonical_name, e.type_id, t.kb_id AS type_kb, + e.type_source, e.type_resolved_at, e.proposed_type, e.specific_type, + e.description, e.created_at + FROM entities e LEFT JOIN entity_types t ON t.id = e.type_id + WHERE e.kb_id = $1 AND e.merged_into IS NULL + AND ($2 IS NULL OR e.id > $2) + ORDER BY e.id LIMIT $3", ) .bind(kb_id) .bind(after) .bind(PAGE) - .fetch_all(pool) - .await?) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + for e in &page { + if e.type_id.is_some() && foreign(e.type_kb, kb_id) { + tally(&mut violations, "entity.type", 1); + } + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + Ok(page) } /// **不过滤 `invalidated_at`。** 撤回的、被修正顶掉的、区间早已闭合的,全在里面 /// ——它们各自带着两根轴上的时刻,读的人自己判断当时成立不成立(0019、0020)。 pub async fn facts_page( - pool: &PgPool, + tx: &mut Transaction<'_, Postgres>, kb_id: Uuid, after: Option, ) -> AppResult> { @@ -191,9 +1068,12 @@ pub async fn facts_page( "SELECT f.id, f.subject_id, f.predicate_id, fact_surface_predicate(f.id) AS surface_predicate, f.object_id, f.object_value, + f.layer, f.phrase, f.valid_from_grade, f.valid_from, f.valid_from_precision, f.valid_to, f.valid_to_precision, {holds_from} AS holds_from, {holds_to} AS holds_to, f.recorded_at, f.invalidated_at, f.confidence, f.supersedes, + f.attested_from, f.attested_to, f.end_derived, + (f.derived_by_rule IS NOT NULL) AS rule_derived, COALESCE(ARRAY(SELECT DISTINCT e.document_id FROM fact_evidence e WHERE e.fact_id = f.id AND e.document_id IS NOT NULL), '{{}}') AS documents, @@ -202,10 +1082,36 @@ pub async fn facts_page( ORDER BY e.chunk_id), '{{}}') AS quotes, COALESCE(ARRAY(SELECT DISTINCT c.origin FROM fact_evidence e JOIN chunks c ON c.id = e.chunk_id - WHERE e.fact_id = f.id - ORDER BY c.origin), '{{}}') AS quote_origins + WHERE e.fact_id = f.id ORDER BY c.origin), '{{}}') + AS quote_origins, + COALESCE(ARRAY(SELECT x.statement_id FROM ( + SELECT ts.statement_id FROM typed_fact_sources ts + WHERE ts.fact_id = f.id + UNION SELECT f.from_statement_id + WHERE f.from_statement_id IS NOT NULL) x + ORDER BY x.statement_id), '{{}}') + AS source_statements, + s.kb_id AS subject_kb, o.kb_id AS object_kb, + p.kb_id AS predicate_kb, sp.kb_id AS supersedes_kb, + EXISTS(SELECT 1 FROM fact_evidence e + LEFT JOIN documents ed ON ed.id = e.document_id + WHERE e.fact_id = f.id AND e.document_id IS NOT NULL + AND ed.kb_id IS DISTINCT FROM f.kb_id) AS foreign_document, + (EXISTS(SELECT 1 FROM typed_fact_sources ts + LEFT JOIN facts s2 ON s2.id = ts.statement_id + WHERE ts.fact_id = f.id + AND s2.kb_id IS DISTINCT FROM f.kb_id) + OR (f.from_statement_id IS NOT NULL + AND fs.kb_id IS DISTINCT FROM f.kb_id)) AS foreign_source, + (s.merged_into IS NOT NULL) AS subject_merged, + (o.merged_into IS NOT NULL) AS object_merged FROM facts f - WHERE f.kb_id = $1 AND f.id > COALESCE($2, '00000000-0000-0000-0000-000000000000'::uuid) + LEFT JOIN entities s ON s.id = f.subject_id + LEFT JOIN entities o ON o.id = f.object_id + LEFT JOIN relation_types p ON p.id = f.predicate_id + LEFT JOIN facts sp ON sp.id = f.supersedes + LEFT JOIN facts fs ON fs.id = f.from_statement_id + WHERE f.kb_id = $1 AND ($2 IS NULL OR f.id > $2) ORDER BY f.id LIMIT $3", holds_from = crate::world_axis::facts_holds_from("f"), holds_to = crate::world_axis::facts_holds_to("f"), @@ -213,27 +1119,285 @@ pub async fn facts_page( .bind(kb_id) .bind(after) .bind(PAGE) - .fetch_all(pool) + .fetch_all(&mut **tx) .await?; - // 边上的属性另一张表(0037),按事实 id 一次取回补上 + // 留下的行逐个过:被铸成本库 IRI 的引用不许越库,进词汇表的引用不许查空。 + // 检查用的是随行原子选出的 ref_kb——不是再去库里问一次的另一个时刻 + let mut violations = Vec::new(); + let mut unexported = Vec::new(); + for f in &facts { + tally( + &mut violations, + "fact.subject", + foreign(f.subject_kb, kb_id) as i64, + ); + tally( + &mut unexported, + "fact.subject(merged)", + f.subject_merged as i64, + ); + if f.object_id.is_some() { + tally( + &mut violations, + "fact.object", + foreign(f.object_kb, kb_id) as i64, + ); + tally( + &mut unexported, + "fact.object(merged)", + f.object_merged as i64, + ); + } + if f.predicate_id.is_some() { + tally( + &mut violations, + "fact.predicate", + foreign(f.predicate_kb, kb_id) as i64, + ); + } + if f.supersedes.is_some() { + tally( + &mut violations, + "fact.supersedes", + foreign(f.supersedes_kb, kb_id) as i64, + ); + } + tally(&mut violations, "fact.source", f.foreign_source as i64); + tally( + &mut violations, + "evidence.document", + f.foreign_document as i64, + ); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + if !unexported.is_empty() { + return Err(unexported_error(&unexported)); + } + // 边上的属性另一张表(0037),按事实 id 一次取回补上——把属性类型的 kb 与 + // 实体值的 kb 一并选出:别库类型会被词汇表静默跳过,别库实体会被铸进本库 + // IRI,两种行都得在序列化之前拦下来 { let ids: Vec = facts.iter().map(|f| f.id).collect(); - let mut by_fact = crate::graph::fact_qualifiers_for(pool, &ids).await?; + let mut by_fact = qualifiers_for_export(tx, &ids, kb_id).await?; for f in facts.iter_mut() { if let Some(q) = by_fact.remove(&f.id) { f.qualifiers = q; } } } + // 开放陈述自己的属性与时间词(0061/0064):同一形状,按事实 id 取回补上。 + // 别库的实体值/段落与「归属不在本库」的提及一样按坏行拒 + { + let ids: Vec = facts.iter().map(|f| f.id).collect(); + let mut by_fact = statement_qualifiers_for_export(tx, &ids, kb_id).await?; + let mut mentions = time_mentions_for_export(tx, &ids, kb_id).await?; + for f in facts.iter_mut() { + if let Some(q) = by_fact.remove(&f.id) { + f.statement_qualifiers = q; + } + if let Some(m) = mentions.remove(&f.id) { + f.time_mentions = m; + } + } + } Ok(facts) } +#[derive(sqlx::FromRow)] +struct ExportQualifierRow { + fact_id: Uuid, + qualifier_type_id: Uuid, + key: Option, + label: Option, + value: Option, + entity_id: Option, + entity_name: Option, + type_kb: Option, + entity_kb: Option, + entity_merged: bool, +} + +/// 导出专用的属性取数:与 graph::fact_qualifiers_for 同一形状,多选两列 kb。 +/// 别库的属性类型会在序列化时被词汇表查空而**静默消失**——那不叫导出; +/// 别库的实体值会被铸进本库 entity IRI——那更不叫导出。两种都按坏行拒 +async fn qualifiers_for_export( + tx: &mut Transaction<'_, Postgres>, + fact_ids: &[Uuid], + kb_id: Uuid, +) -> AppResult>> { + let mut out: std::collections::HashMap> = + std::collections::HashMap::new(); + if fact_ids.is_empty() { + return Ok(out); + } + let rows: Vec = sqlx::query_as( + "SELECT q.fact_id, q.qualifier_type_id, r.key, r.label, q.value, q.entity_id, + e.canonical_name AS entity_name, + r.kb_id AS type_kb, e.kb_id AS entity_kb, + (e.merged_into IS NOT NULL) AS entity_merged + FROM fact_qualifiers q + LEFT JOIN relation_types r ON r.id = q.qualifier_type_id + LEFT JOIN entities e ON e.id = q.entity_id + WHERE q.fact_id = ANY($1) + ORDER BY q.fact_id, r.key", + ) + .bind(fact_ids) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + let mut unexported = Vec::new(); + for r in &rows { + tally( + &mut violations, + "qualifier.type", + foreign(r.type_kb, kb_id) as i64, + ); + if r.entity_id.is_some() { + tally( + &mut violations, + "qualifier.entity", + foreign(r.entity_kb, kb_id) as i64, + ); + tally( + &mut unexported, + "qualifier.entity(merged)", + r.entity_merged as i64, + ); + } + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + if !unexported.is_empty() { + return Err(unexported_error(&unexported)); + } + for r in rows { + out.entry(r.fact_id) + .or_default() + .push(utopia_core::models::FactQualifier { + qualifier_type_id: r.qualifier_type_id, + // 过了校验 r 必然在:key/label 不会取不到 + key: r.key.unwrap_or_default(), + label: r.label.unwrap_or_default(), + value: r.value, + entity_id: r.entity_id, + entity_name: r.entity_name, + }); + } + Ok(out) +} + +/// 开放陈述的属性(0061)按所属事实取回:role 是文档自己的角色词, +/// value/entity_id 恰有一个在场(表上的 XOR CHECK)。归属按所属 fact 的库判—— +/// 属性行自己没有 kb 列;别库的实体值会被铸进本库 entity IRI,与 +/// fact_qualifiers 同一条处置 +async fn statement_qualifiers_for_export( + tx: &mut Transaction<'_, Postgres>, + fact_ids: &[Uuid], + kb_id: Uuid, +) -> AppResult>> { + let mut out: std::collections::HashMap> = + std::collections::HashMap::new(); + if fact_ids.is_empty() { + return Ok(out); + } + let rows: Vec = sqlx::query_as( + "SELECT q.fact_id, q.role, q.value, q.entity_id, + e.kb_id AS entity_kb, + (e.merged_into IS NOT NULL) AS entity_merged + FROM statement_qualifiers q + LEFT JOIN entities e ON e.id = q.entity_id + WHERE q.fact_id = ANY($1) + ORDER BY q.fact_id, q.role", + ) + .bind(fact_ids) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + let mut unexported = Vec::new(); + for r in &rows { + if r.entity_id.is_some() { + tally( + &mut violations, + "squalifier.entity", + foreign(r.entity_kb, kb_id) as i64, + ); + tally( + &mut unexported, + "squalifier.entity(merged)", + r.entity_merged as i64, + ); + } + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + if !unexported.is_empty() { + return Err(unexported_error(&unexported)); + } + for r in rows { + out.entry(r.fact_id).or_default().push(r); + } + Ok(out) +} + +/// 陈述里的时间词(0061/0064)按所属事实取回:一条陈述的 valid_* 是从 +/// 这些字算出来的,出处链要走到字上。提及自己的 kb 必须与事实的库相同—— +/// 一行归属在 B 却挂在 A 的事实上,正是要拦的那种坏行;它指的段落同理 +async fn time_mentions_for_export( + tx: &mut Transaction<'_, Postgres>, + fact_ids: &[Uuid], + kb_id: Uuid, +) -> AppResult>> { + let mut out: std::collections::HashMap> = + std::collections::HashMap::new(); + if fact_ids.is_empty() { + return Ok(out); + } + let rows: Vec = sqlx::query_as( + "SELECT m.id, m.kb_id, m.fact_id, m.chunk_id, m.role, m.text, m.char_start, + m.shape, m.reference, m.granularity, m.grade, + m.resolved_from, m.resolved_from_precision, + m.resolved_to, m.resolved_to_precision, m.resolved_at, m.recorded_at, + c.kb_id AS chunk_kb + FROM time_mentions m + LEFT JOIN chunks c ON c.id = m.chunk_id + WHERE m.fact_id = ANY($1) + ORDER BY m.fact_id, m.chunk_id, m.char_start, m.role", + ) + .bind(fact_ids) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + for r in &rows { + tally( + &mut violations, + "timemention.fact", + (r.kb_id != kb_id) as i64, + ); + tally( + &mut violations, + "timemention.chunk", + foreign(r.chunk_kb, kb_id) as i64, + ); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + for r in rows { + out.entry(r.fact_id).or_default().push(r); + } + Ok(out) +} + pub async fn derived_page( - pool: &PgPool, + tx: &mut Transaction<'_, Postgres>, kb_id: Uuid, after: Option, ) -> AppResult> { - Ok(sqlx::query_as( + let page: Vec = sqlx::query_as( // **两个 LEFT JOIN。** 表拓宽之后(0021)派生可能没有实体宾语、 // 也可能来自业务规则而不是公理——内连接会把这类结论整条挡在导出之外, // 而 0020 承诺的正是「审计员不靠我们也能读全」 @@ -241,42 +1405,292 @@ pub async fn derived_page( d.rule_id, d.attribute_rule_id, d.valid_from, d.valid_from_precision, d.valid_to, d.valid_to_precision, d.derived_at, d.invalidated_at, d.confidence, - COALESCE(ru.kind, 'business') AS rule, ar.name AS rule_name, - COALESCE(ARRAY(SELECT fd.premise_fact_id FROM fact_derivations fd - WHERE fd.derived_fact_id = d.id - AND fd.premise_fact_id IS NOT NULL - ORDER BY fd.seq), '{}') AS premises, - COALESCE(ARRAY(SELECT fd.premise_derived_id FROM fact_derivations fd - WHERE fd.derived_fact_id = d.id - AND fd.premise_derived_id IS NOT NULL - ORDER BY fd.seq), '{}') AS premises_derived + s.kb_id AS subject_kb, o.kb_id AS object_kb, p.kb_id AS predicate_kb, + ru.kb_id AS rule_kb, ar.kb_id AS attribute_rule_kb, + EXISTS(SELECT 1 FROM fact_derivations fd + LEFT JOIN facts pf ON pf.id = fd.premise_fact_id + WHERE fd.derived_fact_id = d.id AND fd.premise_fact_id IS NOT NULL + AND pf.kb_id IS DISTINCT FROM d.kb_id) AS foreign_fact_premise, + EXISTS(SELECT 1 FROM fact_derivations fd + LEFT JOIN derived_facts pd ON pd.id = fd.premise_derived_id + WHERE fd.derived_fact_id = d.id AND fd.premise_derived_id IS NOT NULL + AND pd.kb_id IS DISTINCT FROM d.kb_id) AS foreign_derived_premise, + (s.merged_into IS NOT NULL) AS subject_merged, + (o.merged_into IS NOT NULL) AS object_merged FROM derived_facts d LEFT JOIN rules ru ON ru.id = d.rule_id LEFT JOIN attribute_rules ar ON ar.id = d.attribute_rule_id - WHERE d.kb_id = $1 AND d.id > COALESCE($2, '00000000-0000-0000-0000-000000000000'::uuid) + LEFT JOIN entities s ON s.id = d.subject_id + LEFT JOIN entities o ON o.id = d.object_id + LEFT JOIN relation_types p ON p.id = d.predicate_id + WHERE d.kb_id = $1 AND ($2 IS NULL OR d.id > $2) ORDER BY d.id LIMIT $3", ) .bind(kb_id) .bind(after) .bind(PAGE) - .fetch_all(pool) - .await?) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + let mut unexported = Vec::new(); + for d in &page { + tally( + &mut violations, + "derived.subject", + foreign(d.subject_kb, kb_id) as i64, + ); + tally( + &mut unexported, + "derived.subject(merged)", + d.subject_merged as i64, + ); + if d.object_id.is_some() { + tally( + &mut violations, + "derived.object", + foreign(d.object_kb, kb_id) as i64, + ); + tally( + &mut unexported, + "derived.object(merged)", + d.object_merged as i64, + ); + } + // 派生的谓词非空(CHECK 保证),NULL 的 ref_kb 一样是越界/悬空 + tally( + &mut violations, + "derived.predicate", + foreign(d.predicate_kb, kb_id) as i64, + ); + if d.rule_id.is_some() { + tally( + &mut violations, + "derived.rule", + foreign(d.rule_kb, kb_id) as i64, + ); + } + if d.attribute_rule_id.is_some() { + tally( + &mut violations, + "derived.attribute_rule", + foreign(d.attribute_rule_kb, kb_id) as i64, + ); + } + tally( + &mut violations, + "derivation.premise_fact", + d.foreign_fact_premise as i64, + ); + tally( + &mut violations, + "derivation.premise_derived", + d.foreign_derived_premise as i64, + ); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + if !unexported.is_empty() { + return Err(unexported_error(&unexported)); + } + // 前提另一张表:按 seq 排序整行带回——断言与派生交错在同一个序位序列里, + // 拆成两列就再也看不出原来谁在第几位(0013) + let mut page = page; + { + let ids: Vec = page.iter().map(|d| d.id).collect(); + let mut by_derived = premises_for_export(tx, &ids).await?; + for d in page.iter_mut() { + if let Some(p) = by_derived.remove(&d.id) { + d.premises = p; + } + } + } + Ok(page) +} + +#[derive(sqlx::FromRow)] +struct ExportPremiseRow { + derived_fact_id: Uuid, + seq: i32, + premise_fact_id: Option, + premise_derived_id: Option, +} + +/// 前提按 (derived_fact_id, seq) 取回。越库/悬空不在这一层拦——页查询里的 +/// foreign_*_premise 与行本体原子地一并选出,那里才是判定点;这里只管把序 +/// 位与种类原样带回去 +async fn premises_for_export( + tx: &mut Transaction<'_, Postgres>, + derived_ids: &[Uuid], +) -> AppResult>> { + let mut out: std::collections::HashMap> = + std::collections::HashMap::new(); + if derived_ids.is_empty() { + return Ok(out); + } + let rows: Vec = sqlx::query_as( + "SELECT fd.derived_fact_id, fd.seq, fd.premise_fact_id, fd.premise_derived_id + FROM fact_derivations fd + WHERE fd.derived_fact_id = ANY($1) + ORDER BY fd.derived_fact_id, fd.seq", + ) + .bind(derived_ids) + .fetch_all(&mut **tx) + .await?; + for r in rows { + out.entry(r.derived_fact_id) + .or_default() + .push(ExportPremise { + seq: r.seq, + fact_id: r.premise_fact_id, + derived_id: r.premise_derived_id, + }); + } + Ok(out) } pub async fn documents_page( - pool: &PgPool, + tx: &mut Transaction<'_, Postgres>, kb_id: Uuid, after: Option, ) -> AppResult> { Ok(sqlx::query_as( - "SELECT id, filename, external_key, doc_time, created_at, deleted_at + "SELECT id, filename, external_key, sha256, mime, size_bytes, + doc_time_source, tags, doc_time, created_at, deleted_at, purged_at, + reader_needed, time_context, time_context_at FROM documents - WHERE kb_id = $1 AND id > COALESCE($2, '00000000-0000-0000-0000-000000000000'::uuid) + WHERE kb_id = $1 AND ($2 IS NULL OR id > $2) ORDER BY id LIMIT $3", ) .bind(kb_id) .bind(after) .bind(PAGE) - .fetch_all(pool) + .fetch_all(&mut **tx) .await?) } + +/// 文档的版本行(document_versions):版本行自己没有 kb 列,它的库就是它 +/// 所属文档的库——按文档过滤。document_id 是普通外键:不存在的文档装不进 +/// 行;挂着别库文档的行只在那个库的导出里出现,不会漏进这份 +pub async fn document_versions_page( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, + after: Option, +) -> AppResult> { + let page: Vec = sqlx::query_as( + "SELECT v.id, v.document_id, v.version, v.sha256, v.size_bytes, v.ingested_at, + d.kb_id AS document_kb + FROM document_versions v JOIN documents d ON d.id = v.document_id + WHERE d.kb_id = $1 AND ($2 IS NULL OR v.id > $2) + ORDER BY v.id LIMIT $3", + ) + .bind(kb_id) + .bind(after) + .bind(PAGE) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + for v in &page { + tally( + &mut violations, + "docversion.document", + foreign(v.document_kb, kb_id) as i64, + ); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + Ok(page) +} + +/// **被顶掉的段落不过滤**:旧版文档的段落仍被它那个版本的证据行指着,滤掉它们 +/// 等于让导出里的证据悬空。`text`/`embedding` 不进导出(见 ExportChunk) +pub async fn chunks_page( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, + after: Option, +) -> AppResult> { + let page: Vec = sqlx::query_as( + "SELECT c.id, c.document_id, c.seq, c.heading, c.char_start, c.char_end, + c.doc_version, c.superseded_at, c.extracted_at, c.created_at, + c.origin, c.origin_model, c.anchor, + d.kb_id AS document_kb, + EXISTS(SELECT 1 FROM document_versions dv + WHERE dv.document_id = c.document_id + AND dv.version = c.doc_version) AS version_row + FROM chunks c LEFT JOIN documents d ON d.id = c.document_id + WHERE c.kb_id = $1 AND ($2 IS NULL OR c.id > $2) + ORDER BY c.id LIMIT $3", + ) + .bind(kb_id) + .bind(after) + .bind(PAGE) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + for c in &page { + tally( + &mut violations, + "chunk.document", + foreign(c.document_kb, kb_id) as i64, + ); + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + Ok(page) +} + +/// 证据行按主键 (fact_id, chunk_id) 复合游标分页——它没有自己的代理 id。 +/// JOIN facts 只为按 kb_id 过滤;一个 chunk 可被多个库共用吗?不行,chunks +/// 本身就带 kb_id,JOIN 只是为了让键序与事实序一致,读的人按语句找证据时顺 +pub async fn evidence_page( + tx: &mut Transaction<'_, Postgres>, + kb_id: Uuid, + after: Option<(Uuid, Uuid)>, +) -> AppResult> { + // 第一页不设下界:复合游标同样不能把 (NIL, NIL) 那行挡在门外 + let after_fact = after.map(|a| a.0); + let after_chunk = after.map(|a| a.1); + let page: Vec = sqlx::query_as( + "SELECT e.fact_id, e.chunk_id, e.document_id, e.doc_version, e.quote, + e.quote_start, e.quote_end, + e.proposed_predicate, c.kb_id AS chunk_kb, d.kb_id AS document_kb, + EXISTS(SELECT 1 FROM document_versions dv + WHERE dv.document_id = e.document_id + AND dv.version = e.doc_version) AS version_row + FROM fact_evidence e + JOIN facts f ON f.id = e.fact_id + LEFT JOIN chunks c ON c.id = e.chunk_id + LEFT JOIN documents d ON d.id = e.document_id + WHERE f.kb_id = $1 + AND ($2 IS NULL OR e.fact_id > $2 + OR (e.fact_id = $2 AND e.chunk_id > $3)) + ORDER BY e.fact_id, e.chunk_id LIMIT $4", + ) + .bind(kb_id) + .bind(after_fact) + .bind(after_chunk) + .bind(PAGE) + .fetch_all(&mut **tx) + .await?; + let mut violations = Vec::new(); + for e in &page { + tally( + &mut violations, + "evidence.chunk", + foreign(e.chunk_kb, kb_id) as i64, + ); + if e.document_id.is_some() { + tally( + &mut violations, + "evidence.document", + foreign(e.document_kb, kb_id) as i64, + ); + } + } + if !violations.is_empty() { + return Err(cross_kb_error(&violations)); + } + Ok(page) +} diff --git a/crates/utopia-store/tests/a_merged_target_stays_out_of_the_export.rs b/crates/utopia-store/tests/a_merged_target_stays_out_of_the_export.rs new file mode 100644 index 000000000..18a1f53fe --- /dev/null +++ b/crates/utopia-store/tests/a_merged_target_stays_out_of_the_export.rs @@ -0,0 +1,288 @@ +//! 同库不等于在导出集里:`entities_page` 滤掉 `merged_into` +//! 非空的行,但 merge 只改写 fact 的主语/宾语——属性里的实体值、派生的 +//! 主语/宾语仍可能指着已合并的行。序列化照铸它的 IRI 就是一条悬空边。 +//! +//! 判法:**同库但不在导出集**也是越界——与越库同一处置,整份拒导。不 +//! 重写指向留下的实体(那是另一个语义动作),也不静默省略。 + +use sqlx::PgPool; +use uuid::Uuid; + +struct Fixture { + org: Uuid, + kb: Uuid, + survivor: Uuid, + merged: Uuid, + rel: Uuid, + attr: Uuid, + rule: Uuid, + fact: Uuid, +} + +/// 一个库、留着的实体与已合并的实体、一条事实、一条指向已合并实体的 +/// 派生——merged 是合法状态(没有触发器拦它),要拦的是导出侧 +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'merged-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'merged-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'merged-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + + let (survivor, merged) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'survivor')") + .bind(survivor) + .bind(kb) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO entities (id, kb_id, canonical_name, merged_into) VALUES ($1, $2, 'gone', $3)", + ) + .bind(merged) + .bind(kb) + .bind(survivor) + .execute(pool) + .await?; + + let (rel, attr) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label) VALUES ($1, $2, 'knows', 'knows')", + ) + .bind(rel) + .bind(kb) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind) VALUES ($1, $2, 'since', 'since', 'attribute')", + ) + .bind(attr) + .bind(kb) + .execute(pool) + .await?; + + let fact = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $3, 0.9)", + ) + .bind(fact) + .bind(kb) + .bind(survivor) + .bind(rel) + .execute(pool) + .await?; + + let rule = Uuid::now_v7(); + sqlx::query( + "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, 'transitive')", + ) + .bind(rule) + .bind(kb) + .bind(rel) + .execute(pool) + .await?; + + Ok(Fixture { + org, + kb, + survivor, + merged, + rel, + attr, + rule, + fact, + }) +} + +async fn cleanup(pool: &PgPool, f: &Fixture) -> anyhow::Result<()> { + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(f.kb) + .execute(pool) + .await?; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(pool) + .await?; + Ok(()) +} + +async fn insert_derived( + pool: &PgPool, + f: &Fixture, + subject: Uuid, + object: Uuid, +) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, rule_id) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(id) + .bind(f.kb) + .bind(subject) + .bind(f.rel) + .bind(object) + .bind(f.rule) + .execute(pool) + .await?; + Ok(id) +} + +/// 已合并的实体不进导出集——它不在 entities 页里出现 +#[tokio::test] +async fn a_merged_entity_is_not_emitted() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let page = utopia_store::export::entities_page(&mut pool.begin().await?, f.kb, None).await?; + assert!(page.iter().any(|e| e.id == f.survivor)); + assert!( + !page.iter().any(|e| e.id == f.merged), + "merged 实体必须不在导出集里" + ); + + cleanup(&pool, &f).await +} + +/// 指着已合并实体的属性值:同库但缺席——体检与事实页都要拦 +#[tokio::test] +async fn a_qualifier_on_a_merged_entity_fails_closed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, entity_id) + VALUES ($1, $2, $3)", + ) + .bind(f.fact) + .bind(f.attr) + .bind(f.merged) + .execute(&pool) + .await?; + + let err = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.kb).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "qualifier→merged 的体检必须拒导"); + assert!( + msg.contains("qualifier.entity(merged)"), + "要报 qualifier.entity(merged): {msg}" + ); + assert!( + utopia_store::export::facts_page(&mut pool.begin().await?, f.kb, None) + .await + .is_err(), + "事实页也要拦下同一条边" + ); + + sqlx::query("DELETE FROM fact_qualifiers WHERE fact_id = $1") + .bind(f.fact) + .execute(&pool) + .await?; + cleanup(&pool, &f).await +} + +/// 派生的主语/宾语指着已合并的实体:同库但缺席 +#[tokio::test] +async fn a_derived_on_a_merged_entity_fails_closed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let d_subj = insert_derived(&pool, &f, f.merged, f.survivor).await?; + let err = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.kb).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "derived.subject→merged 的体检必须拒导"); + assert!( + msg.contains("derived.subject(merged)"), + "要报 derived.subject(merged): {msg}" + ); + sqlx::query("DELETE FROM derived_facts WHERE id = $1") + .bind(d_subj) + .execute(&pool) + .await?; + + let d_obj = insert_derived(&pool, &f, f.survivor, f.merged).await?; + let err = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.kb).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "derived.object→merged 的体检必须拒导"); + assert!( + msg.contains("derived.object(merged)"), + "要报 derived.object(merged): {msg}" + ); + sqlx::query("DELETE FROM derived_facts WHERE id = $1") + .bind(d_obj) + .execute(&pool) + .await?; + + // 干净之后就放行——拒的是缺席的引用,不是库本身 + utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.kb).await?; + cleanup(&pool, &f).await +} + +/// 事实的主语/宾语指着已合并的实体(merge 没走到的旧写或绕过 store 的写): +/// 事实页与体检都要拦 +#[tokio::test] +async fn a_fact_on_a_merged_entity_fails_closed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let bad_fact = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $5, 0.9)", + ) + .bind(bad_fact) + .bind(f.kb) + .bind(f.survivor) + .bind(f.rel) + .bind(f.merged) + .execute(&pool) + .await?; + + let err = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.kb).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "fact.object→merged 的体检必须拒导"); + assert!( + msg.contains("fact.object(merged)"), + "要报 fact.object(merged): {msg}" + ); + assert!( + utopia_store::export::facts_page(&mut pool.begin().await?, f.kb, None) + .await + .is_err(), + "事实页也要拦下同一条边" + ); + + sqlx::query("DELETE FROM facts WHERE id = $1") + .bind(bad_fact) + .execute(&pool) + .await?; + utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.kb).await?; + cleanup(&pool, &f).await +} diff --git a/crates/utopia-store/tests/an_export_carries_the_whole_ledger.rs b/crates/utopia-store/tests/an_export_carries_the_whole_ledger.rs index 908b3e8e1..aaf5680ef 100644 --- a/crates/utopia-store/tests/an_export_carries_the_whole_ledger.rs +++ b/crates/utopia-store/tests/an_export_carries_the_whole_ledger.rs @@ -19,6 +19,8 @@ struct Fixture { bare: Uuid, kept: Uuid, swallowed: Uuid, + doc: Uuid, + chunk: Uuid, deleted_doc: Uuid, derived: Uuid, } @@ -208,6 +210,8 @@ async fn seed(pool: &PgPool) -> anyhow::Result { bare, kept, swallowed, + doc, + chunk, deleted_doc, derived, }) @@ -222,7 +226,7 @@ async fn an_export_reads_the_whole_ledger_not_the_current_view() -> anyhow::Resu let f = seed(&pool).await?; // 1. 事实:撤回的那条**在**。界面把它藏起来是对的,导出把它藏起来就是骗人 - let facts = utopia_store::export::facts_page(&pool, f.kb, None).await?; + let facts = utopia_store::export::facts_page(&mut pool.begin().await?, f.kb, None).await?; let ids: Vec = facts.iter().map(|x| x.id).collect(); assert!(ids.contains(&f.live)); assert!( @@ -244,7 +248,8 @@ async fn an_export_reads_the_whole_ledger_not_the_current_view() -> anyhow::Resu assert_eq!(bare.surface_predicate.as_deref(), Some("advises")); // 4. 实体:合并掉的那个是唯一该消失的东西 - let entities = utopia_store::export::entities_page(&pool, f.kb, None).await?; + let entities = + utopia_store::export::entities_page(&mut pool.begin().await?, f.kb, None).await?; let ids: Vec = entities.iter().map(|e| e.id).collect(); assert!(ids.contains(&f.kept)); assert!( @@ -253,21 +258,24 @@ async fn an_export_reads_the_whole_ledger_not_the_current_view() -> anyhow::Resu ); // 5. 文档:删掉的留着墓碑(#268)。抹掉出处等于抹掉证据链 - let docs = utopia_store::export::documents_page(&pool, f.kb, None).await?; + let docs = utopia_store::export::documents_page(&mut pool.begin().await?, f.kb, None).await?; let deleted = docs.iter().find(|d| d.id == f.deleted_doc).unwrap(); assert!(deleted.deleted_at.is_some()); - // 6. 派生:带着规则和前提,审计顺着它走得到断言 - let derived = utopia_store::export::derived_page(&pool, f.kb, None).await?; + // 6. 派生:带着规则和前提,审计顺着它走得到断言。前提按 seq 整行带回—— + // 断言与派生交错在同一个序位序列里 + let derived = utopia_store::export::derived_page(&mut pool.begin().await?, f.kb, None).await?; let d = derived.iter().find(|d| d.id == f.derived).unwrap(); - assert_eq!(d.rule, "transitive"); - assert_eq!(d.premises, vec![f.live]); + assert!(d.rule_id.is_some(), "公理规则的身份在 rule_id 上"); + assert_eq!(d.premises.len(), 1); + assert_eq!(d.premises[0].seq, 0); + assert_eq!(d.premises[0].fact_id, Some(f.live)); // 7. 词汇表:导入来的类留着原 IRI,公理位照抄 - let classes = utopia_store::export::classes(&pool, f.kb).await?; + let classes = utopia_store::export::classes(&mut pool.begin().await?, f.kb).await?; let person = classes.iter().find(|c| c.key == "person").unwrap(); assert_eq!(person.iri.as_deref(), Some("https://schema.org/Person")); - let relations = utopia_store::export::relations(&pool, f.kb).await?; + let relations = utopia_store::export::relations(&mut pool.begin().await?, f.kb).await?; assert!(relations .iter() .any(|r| r.key == "works_for" && r.functional)); @@ -275,6 +283,28 @@ async fn an_export_reads_the_whole_ledger_not_the_current_view() -> anyhow::Resu .iter() .any(|r| r.key == "part_of" && r.is_transitive)); + // 8. 段落:出处链的最后一环。定位信息(seq/文档/版本)在,text 不进导出 + let chunks = utopia_store::export::chunks_page(&mut pool.begin().await?, f.kb, None).await?; + let c = chunks.iter().find(|c| c.id == f.chunk).unwrap(); + assert_eq!(c.document_id, f.doc); + assert_eq!(c.seq, 0); + assert!(c.superseded_at.is_none()); + + // 9. 证据行:配对本身是主键 (fact_id, chunk_id)。事实上的「文档数组 + + // quote 数组」看不出哪句出自哪段;这一行才是绑定关系本身 + let evidence = + utopia_store::export::evidence_page(&mut pool.begin().await?, f.kb, None).await?; + let live_ev = evidence.iter().find(|e| e.fact_id == f.live).unwrap(); + assert_eq!(live_ev.chunk_id, f.chunk); + assert_eq!(live_ev.document_id, Some(f.doc)); + assert_eq!(live_ev.quote.as_deref(), Some("Lin Zhao works for Acme.")); + let bare_ev = evidence.iter().find(|e| e.fact_id == f.bare).unwrap(); + assert_eq!( + bare_ev.proposed_predicate.as_deref(), + Some("advises"), + "证据行上模型对谓词的原话要跟着走" + ); + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") .bind(f.kb) .execute(&pool) diff --git a/crates/utopia-store/tests/an_open_statement_keeps_the_documents_words.rs b/crates/utopia-store/tests/an_open_statement_keeps_the_documents_words.rs index a7c59e09f..c5b820819 100644 --- a/crates/utopia-store/tests/an_open_statement_keeps_the_documents_words.rs +++ b/crates/utopia-store/tests/an_open_statement_keeps_the_documents_words.rs @@ -226,13 +226,16 @@ async fn an_open_statement_shows_under_its_phrase_and_reuses_its_row() -> anyhow .expect("a path walks the open statement"); assert_eq!(direct.edges[0].predicate.as_deref(), Some("acquired")); - let exported = utopia_store::export::facts_page(&pool, f.kb, None).await?; + let exported = + utopia_store::export::facts_page(&mut pool.begin().await?, f.kb, None).await?; let x = exported .iter() .find(|x| x.id == fact) .expect("export carries it"); assert_eq!(x.surface_predicate.as_deref(), Some("acquired")); assert!(x.predicate_id.is_none()); + assert_eq!(x.layer, "open"); + assert_eq!(x.phrase.as_deref(), Some("acquired")); // 3. 同一句话再听到一次:同一行,证据累积 let (again, created) = graph::insert_open_statement( diff --git a/crates/utopia-store/tests/cross_kb_provenance_fails_closed.rs b/crates/utopia-store/tests/cross_kb_provenance_fails_closed.rs new file mode 100644 index 000000000..1d5d71888 --- /dev/null +++ b/crates/utopia-store/tests/cross_kb_provenance_fails_closed.rs @@ -0,0 +1,306 @@ +//! 出处链不许跨库(0070):新行被触发器挡下,存量坏行让导出整份拒绝。 +//! +//! `fact_evidence`/`chunks` 的外键只认 id 不认库——原生写入路径碰巧全是同库 +//! 构造,但 schema 什么也不拦。两层防: +//! 1. 触发器(0070)挡在一切写入路径下游,包括绕过 store 层的 SQL; +//! 2. 导出侧体检 + 逐页校验——存量坏行与绕过触发器进来的行,宁可整份拒导, +//! 也不能把别库对象的 id 铸进本库 IRI。 +//! +//! 坏行在测试里靠 `SET LOCAL session_replication_role='replica'` 制造:只关 +//! 本事务的触发器,不碰 catalog——`DISABLE TRIGGER` 是全局的,并行测试会把 +//! 对方断言的拒绝窗口撞没。行指着的东西都真实存在,只是不在同一个库—— +//! 正是线上会遇到的形态(比如从 0070 之前的备份恢复进来的旧行)。 + +use sqlx::{Acquire, PgPool}; +use uuid::Uuid; + +struct TwoKbs { + org: Uuid, + a: Uuid, + b: Uuid, + doc_a: Uuid, + doc_b: Uuid, + chunk_a: Uuid, + chunk_b: Uuid, + fact_a: Uuid, + fact_b: Uuid, +} + +/// 两个库、每库一份文档一段一实体一事实——跨库引用需要的合法零件 +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, a, b) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + let (doc_a, doc_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (chunk_a, chunk_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (ent_a, ent_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (fact_a, fact_b) = (Uuid::now_v7(), Uuid::now_v7()); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'crosskb-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'crosskb-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + for kb in [a, b] { + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'crosskb-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + } + for (id, kb, name) in [(doc_a, a, "a.md"), (doc_b, b, "b.md")] { + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256, status, external_key) + VALUES ($1, $2, $3, $4, 'ready', $5)", + ) + .bind(id) + .bind(kb) + .bind(name) + .bind(format!("sha-{id}")) + .bind(format!("file:///{name}")) + .execute(pool) + .await?; + } + for (id, kb, doc) in [(chunk_a, a, doc_a), (chunk_b, b, doc_b)] { + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 0, 'x')", + ) + .bind(id) + .bind(kb) + .bind(doc) + .execute(pool) + .await?; + } + for (id, kb) in [(ent_a, a), (ent_b, b)] { + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'e')") + .bind(id) + .bind(kb) + .execute(pool) + .await?; + } + for (id, kb, subject, object) in [(fact_a, a, ent_a, ent_a), (fact_b, b, ent_b, ent_b)] { + sqlx::query("INSERT INTO facts (id, kb_id, subject_id, object_id, confidence) VALUES ($1, $2, $3, $4, 0.9)") + .bind(id) + .bind(kb) + .bind(subject) + .bind(object) + .execute(pool) + .await?; + } + Ok(TwoKbs { + org, + a, + b, + doc_a, + doc_b, + chunk_a, + chunk_b, + fact_a, + fact_b, + }) +} + +async fn cleanup(pool: &PgPool, f: &TwoKbs) -> anyhow::Result<()> { + for kb in [f.a, f.b] { + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(kb) + .execute(pool) + .await?; + } + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(pool) + .await?; + Ok(()) +} + +#[tokio::test] +async fn new_cross_kb_writes_are_rejected() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + // 触发器是 0070 带来的:测试库可能还没迁移,这里先保证约束在场 + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // 事实引用别库段落:原生路径碰巧不会这么写,但 schema 从前不拦——现在拦 + let err = sqlx::query( + "INSERT INTO fact_evidence (fact_id, chunk_id, quote, document_id, doc_version) + VALUES ($1, $2, 'x', $3, 1)", + ) + .bind(f.fact_a) + .bind(f.chunk_b) + .bind(f.doc_b) + .execute(&pool) + .await; + assert!(err.is_err(), "fact→foreign chunk 必须被拒"); + + // 只坏冗余文档指针那一头:段落同库、文档别库 + let err = sqlx::query( + "INSERT INTO fact_evidence (fact_id, chunk_id, quote, document_id, doc_version) + VALUES ($1, $2, 'x', $3, 1)", + ) + .bind(f.fact_a) + .bind(f.chunk_a) + .bind(f.doc_b) + .execute(&pool) + .await; + assert!(err.is_err(), "evidence.document→foreign 必须被拒"); + + // 段落挂在别库文档下 + let err = sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) + VALUES ($1, $2, $3, 0, 'x')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.doc_b) + .execute(&pool) + .await; + assert!(err.is_err(), "chunk→foreign document 必须被拒"); + + // 权威写入路径同一条约束:add_evidence 走 store API 也一样被拒 + let err = utopia_store::graph::add_evidence(&pool, f.fact_a, f.chunk_b, Some("x"), None).await; + assert!(err.is_err(), "add_evidence 的跨库配对必须被拒"); + + // 过户:把文档挪到别的库,等于把指着它的行一次全变坏行 + let err = sqlx::query("UPDATE documents SET kb_id = $2 WHERE id = $1") + .bind(f.doc_a) + .bind(f.b) + .execute(&pool) + .await; + assert!(err.is_err(), "documents.kb_id 过户必须被拒"); + let err = sqlx::query("UPDATE facts SET kb_id = $2 WHERE id = $1") + .bind(f.fact_a) + .bind(f.b) + .execute(&pool) + .await; + assert!(err.is_err(), "facts.kb_id 过户必须被拒"); + + // 同库的正常写入不受影响(防误伤) + sqlx::query( + "INSERT INTO fact_evidence (fact_id, chunk_id, quote, document_id, doc_version) + VALUES ($1, $2, 'ok', $3, 1)", + ) + .bind(f.fact_a) + .bind(f.chunk_a) + .bind(f.doc_a) + .execute(&pool) + .await?; + utopia_store::graph::add_evidence(&pool, f.fact_b, f.chunk_b, Some("ok"), None).await?; + + cleanup(&pool, &f).await +} + +#[tokio::test] +async fn malformed_existing_rows_fail_the_export_closed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // 存量坏行:触发器只拦落在它之后的写,这种行只能绕过它造——导出侧要接住。 + // SET LOCAL 只在本事务内关触发器,提交即恢复,不会撞掉并行测试的断言窗口 + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + + // 只坏段落那一头:fact_a → chunk_b(文档指针留 NULL,隔离变量) + sqlx::query("INSERT INTO fact_evidence (fact_id, chunk_id) VALUES ($1, $2)") + .bind(f.fact_a) + .bind(f.chunk_b) + .execute(&mut *tx) + .await?; + // 只坏冗余文档指针:fact_b → chunk_b 本身同库,指针却指 a 的文档 + sqlx::query("INSERT INTO fact_evidence (fact_id, chunk_id, document_id) VALUES ($1, $2, $3)") + .bind(f.fact_b) + .bind(f.chunk_b) + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + // 段落挂在别库文档下 + let stray_chunk = Uuid::now_v7(); + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 9, 'stray')", + ) + .bind(stray_chunk) + .bind(f.b) + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + tx.commit().await?; + drop(conn); + + // 体检:库 A 坏在 evidence.chunk,库 B 坏在 evidence.document 与 chunk.document + let err_a = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.a).await; + let msg_a = format!("{err_a:?}"); + assert!(err_a.is_err(), "库 A 的体检必须拒导"); + assert!( + msg_a.contains("evidence.chunk"), + "库 A 该报 evidence.chunk: {msg_a}" + ); + + let err_b = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.b).await; + let msg_b = format!("{err_b:?}"); + assert!(err_b.is_err(), "库 B 的体检必须拒导"); + assert!( + msg_b.contains("evidence.document"), + "库 B 该报 evidence.document: {msg_b}" + ); + assert!( + msg_b.contains("chunk.document"), + "库 B 该报 chunk.document: {msg_b}" + ); + + // 逐页校验同样fail-closed:体检之后的 TOCTOU 坏行也不能漏出伪造 IRI + assert!( + utopia_store::export::evidence_page(&mut pool.begin().await?, f.a, None) + .await + .is_err() + ); + assert!( + utopia_store::export::evidence_page(&mut pool.begin().await?, f.b, None) + .await + .is_err() + ); + assert!( + utopia_store::export::chunks_page(&mut pool.begin().await?, f.b, None) + .await + .is_err() + ); + // 坏文档指针会顺着 facts_page 的 documents[] 出去——那一路也要拦 + assert!( + utopia_store::export::facts_page(&mut pool.begin().await?, f.b, None) + .await + .is_err() + ); + + // 清掉坏行,导出立刻恢复——拒的是坏行,不是库本身 + sqlx::query("DELETE FROM fact_evidence WHERE fact_id IN ($1, $2)") + .bind(f.fact_a) + .bind(f.fact_b) + .execute(&pool) + .await?; + sqlx::query("DELETE FROM chunks WHERE id = $1") + .bind(stray_chunk) + .execute(&pool) + .await?; + utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.a).await?; + utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.b).await?; + + cleanup(&pool, &f).await +} diff --git a/crates/utopia-store/tests/export_surfaces.rs b/crates/utopia-store/tests/export_surfaces.rs new file mode 100644 index 000000000..748ab5d2f --- /dev/null +++ b/crates/utopia-store/tests/export_surfaces.rs @@ -0,0 +1,749 @@ +//! 导出取数面:规则条件、算式谓词引用、文档版本、单事务快照。 +//! +//! 判定在导出取数层:`attribute_rules` / `document_versions_page` / +//! `provenance_integrity` 吃调用方的事务——同一条连接里种行、读页、断言。 +//! 除「中途提交」那条探针外(它要两条连接),所有种子与坏行都在一个 +//! **回滚的事务**里造:快照库上跑这组测试不会留下任何一行。 +//! +//! 坏行靠 `SET LOCAL session_replication_role='replica'` 造:只关本事务的 +//! 触发器(0070 装上的那些也一并关),回滚即恢复——要模拟的正是绕过触发器 +//! 进来的存量坏行。 + +use sqlx::{Acquire, PgPool, Postgres, Transaction}; +use utopia_store::export; +use uuid::Uuid; + +struct Fixture { + a: Uuid, + b: Uuid, + doc_a: Uuid, + attr_a: Uuid, + attr_b: Uuid, + arule_a: Uuid, +} + +/// 两个库;A 库一份文档一段一实体一事实一类一属性一业务规则, +/// B 库只出「别库谓词」这颗坏种子要的合法零件。全部在调用方的事务里 +/// 落——回滚即清场,一个 DELETE 都不用 +async fn seed_tx(tx: &mut Transaction<'_, Postgres>) -> anyhow::Result { + let (org, ws, a, b) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + let (doc_a, doc_b, chunk_a) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (ent_a, ent_b, fact_a) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (class_a, attr_a, attr_b, arule_a) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'export-test')") + .bind(org) + .execute(&mut **tx) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'export-test')") + .bind(ws) + .bind(org) + .execute(&mut **tx) + .await?; + for kb in [a, b] { + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'export-test')", + ) + .bind(kb) + .bind(ws) + .execute(&mut **tx) + .await?; + } + for (id, kb, name) in [(doc_a, a, "a.md"), (doc_b, b, "b.md")] { + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256, status, external_key) + VALUES ($1, $2, $3, $4, 'ready', $5)", + ) + .bind(id) + .bind(kb) + .bind(name) + .bind(format!("sha-{id}")) + .bind(format!("file:///{name}")) + .execute(&mut **tx) + .await?; + } + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text, doc_version) + VALUES ($1, $2, $3, 0, 'x', 1)", + ) + .bind(chunk_a) + .bind(a) + .bind(doc_a) + .execute(&mut **tx) + .await?; + for (id, kb) in [(ent_a, a), (ent_b, b)] { + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'e')") + .bind(id) + .bind(kb) + .execute(&mut **tx) + .await?; + } + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence) + VALUES ($1, $2, $3, $4, 0.9)", + ) + .bind(fact_a) + .bind(a) + .bind(ent_a) + .bind(ent_a) + .execute(&mut **tx) + .await?; + sqlx::query( + "INSERT INTO fact_evidence (fact_id, chunk_id, document_id, doc_version) + VALUES ($1, $2, $3, 1)", + ) + .bind(fact_a) + .bind(chunk_a) + .bind(doc_a) + .execute(&mut **tx) + .await?; + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'well', 'Well')") + .bind(class_a) + .bind(a) + .execute(&mut **tx) + .await?; + for (id, kb, key) in [(attr_a, a, "headcount"), (attr_b, b, "headcount_b")] { + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind, datatype) + VALUES ($1, $2, $3, $4, 'attribute', 'number')", + ) + .bind(id) + .bind(kb) + .bind(key) + .bind(key) + .execute(&mut **tx) + .await?; + } + // computed 结论:CHECK 要求 conclude_predicate_id 与 conclude_expr 同时在场 + sqlx::query( + "INSERT INTO attribute_rules (id, kb_id, name, subject_type_id, conclusion, + conclude_predicate_id, conclude_expr) + VALUES ($1, $2, 'rule A', $3, 'computed', $4, + jsonb_build_object('op','mul','l', + jsonb_build_object('attr', $4::text), + 'r', jsonb_build_object('const', 2)))", + ) + .bind(arule_a) + .bind(a) + .bind(class_a) + .bind(attr_a) + .execute(&mut **tx) + .await?; + Ok(Fixture { + a, + b, + doc_a, + attr_a, + attr_b, + arule_a, + }) +} + +/// 正常面:条件按序随行出、版本行在场把定位器绑上、算式里的 attr 解得出 +#[tokio::test] +async fn conditions_versions_and_expr_refs_export() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + + let mut tx = pool.begin().await?; + let f = seed_tx(&mut tx).await?; + + // 两条条件(group 0 序 1/2)+ 一个版本行——定位器这下有东西可指 + for (seq, op, operand) in [ + (1, "gt", serde_json::json!({"attr": f.attr_a.to_string()})), + (2, "lte", serde_json::json!(100)), + ] { + sqlx::query( + "INSERT INTO attribute_rule_conditions + (id, rule_id, group_seq, seq, predicate_id, op, operand) + VALUES ($1, $2, 0, $3, $4, $5, $6)", + ) + .bind(Uuid::now_v7()) + .bind(f.arule_a) + .bind(seq) + .bind(f.attr_a) + .bind(op) + .bind(operand) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "INSERT INTO document_versions (id, document_id, version, sha256, size_bytes) + VALUES ($1, $2, 1, 'deadbeef', 4096)", + ) + .bind(Uuid::now_v7()) + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + + let rules = export::attribute_rules(&mut tx, f.a).await?; + assert_eq!(rules.len(), 1, "A 库该有一条业务规则"); + let rule = &rules[0]; + assert_eq!(rule.conditions.len(), 2, "两条条件都要随行出来"); + assert_eq!(rule.conditions[0].seq, 1); + assert_eq!(rule.conditions[1].seq, 2); + assert_eq!(rule.conditions[0].predicate_id, f.attr_a); + assert_eq!(rule.conditions[0].predicate_kb, Some(f.a)); + + let versions = export::document_versions_page(&mut tx, f.a, None).await?; + assert_eq!(versions.len(), 1, "版本行要进导出页"); + assert_eq!(versions[0].document_id, f.doc_a); + assert_eq!(versions[0].version, 1); + + let chunks = export::chunks_page(&mut tx, f.a, None).await?; + assert_eq!(chunks.len(), 1); + assert!(chunks[0].version_row, "版本行在场:定位器要绑边"); + let ev = export::evidence_page(&mut tx, f.a, None).await?; + assert_eq!(ev.len(), 1); + assert!(ev[0].version_row, "证据行的定位器同样绑边"); + tx.rollback().await?; + Ok(()) +} + +/// 别库谓词:触发器挡新写(0070),存量坏行靠导出侧接住。 +/// 触发器拒绝会中止当前事务——存点回退后再接着探 +#[tokio::test] +async fn a_condition_on_a_foreign_predicate_fails_closed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + + let mut tx = pool.begin().await?; + let f = seed_tx(&mut tx).await?; + + // 新写:0070 的触发器直接拒(存点圈住这次失败,事务还能接着用) + sqlx::query("SAVEPOINT before_bad_write") + .execute(&mut *tx) + .await?; + let err = sqlx::query( + "INSERT INTO attribute_rule_conditions + (id, rule_id, group_seq, seq, predicate_id, op) + VALUES ($1, $2, 0, 1, $3, 'present')", + ) + .bind(Uuid::now_v7()) + .bind(f.arule_a) + .bind(f.attr_b) + .execute(&mut *tx) + .await; + assert!(err.is_err(), "跨库条件谓词的新写必须被触发器拒"); + sqlx::query("ROLLBACK TO SAVEPOINT before_bad_write") + .execute(&mut *tx) + .await?; + + // 存量坏行:replica 模式绕过触发器造出来,导出侧体检与取数都要拒 + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO attribute_rule_conditions + (id, rule_id, group_seq, seq, predicate_id, op) + VALUES ($1, $2, 0, 1, $3, 'present')", + ) + .bind(Uuid::now_v7()) + .bind(f.arule_a) + .bind(f.attr_b) + .execute(&mut *tx) + .await?; + + let err = export::provenance_integrity(&mut tx, f.a).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "体检必须拒这条跨库条件"); + assert!( + msg.contains("condition.predicate"), + "该报 condition.predicate: {msg}" + ); + assert!( + export::attribute_rules(&mut tx, f.a).await.is_err(), + "取数页同样拒" + ); + + // B 库的导出不受影响——坏行是 A 的(谓词在 B 合法地在场) + export::provenance_integrity(&mut tx, f.b).await?; + tx.rollback().await?; + Ok(()) +} + +/// 悬空 rule_id:条件没了归属就没有 kb,不进任何导出——正确地消失, +/// 不是错误地铸一条 IRI +#[tokio::test] +async fn an_orphan_condition_is_unreachable_and_never_emitted() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + + let mut tx = pool.begin().await?; + let f = seed_tx(&mut tx).await?; + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO attribute_rule_conditions + (id, rule_id, group_seq, seq, predicate_id, op) + VALUES ($1, $2, 0, 1, $3, 'present')", + ) + .bind(Uuid::now_v7()) + .bind(Uuid::now_v7()) // 不存在的规则 + .bind(f.attr_a) + .execute(&mut *tx) + .await?; + + // 体检照样过(孤行不属于任何库),取数页不给它出节点 + export::provenance_integrity(&mut tx, f.a).await?; + let rules = export::attribute_rules(&mut tx, f.a).await?; + assert_eq!(rules.len(), 1); + assert!(rules[0].conditions.is_empty(), "孤儿条件不许出现在导出集"); + tx.rollback().await?; + Ok(()) +} + +/// 算式里的谓词引用(jsonb 没有外键可挂):别库、悬空、连 uuid 都不是的 +/// attr 叶子,取数时一个都不放过 +#[tokio::test] +async fn embedded_predicate_refs_fail_closed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + + let mut tx = pool.begin().await?; + let f = seed_tx(&mut tx).await?; + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + + // 别库谓词进 conclude_expr + sqlx::query( + "UPDATE attribute_rules SET conclude_expr = + jsonb_build_object('op','mul','l', jsonb_build_object('attr', $1::text), + 'r', jsonb_build_object('const', 2)) + WHERE id = $2", + ) + .bind(f.attr_b) + .bind(f.arule_a) + .execute(&mut *tx) + .await?; + let err = export::attribute_rules(&mut tx, f.a).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "conclude_expr 里的别库谓词必须拒导"); + assert!( + msg.contains("rule.expr_predicate"), + "该报 rule.expr_predicate: {msg}" + ); + + // 悬空谓词:uuid 合法但没有这一行 + sqlx::query( + "UPDATE attribute_rules SET conclude_expr = + jsonb_build_object('op','mul','l', jsonb_build_object('attr', $1::text), + 'r', jsonb_build_object('const', 2)) + WHERE id = $2", + ) + .bind(Uuid::now_v7()) + .bind(f.arule_a) + .execute(&mut *tx) + .await?; + assert!( + export::attribute_rules(&mut tx, f.a).await.is_err(), + "悬空谓词必须拒导" + ); + + // 连 uuid 都不是的 attr + sqlx::query( + "UPDATE attribute_rules SET conclude_expr = + jsonb_build_object('attr', 'not-a-uuid') WHERE id = $1", + ) + .bind(f.arule_a) + .execute(&mut *tx) + .await?; + assert!( + export::attribute_rules(&mut tx, f.a).await.is_err(), + "畸形的 attr 必须拒导" + ); + + // 条件 operand 里的算式走同一条检查。computed 的 CHECK 不许 conclude_expr + // 为 NULL——换成没有 attr 叶子的常量树,隔离出 operand 这一个变量 + sqlx::query( + "UPDATE attribute_rules SET conclude_expr = jsonb_build_object('const', 1) + WHERE id = $1", + ) + .bind(f.arule_a) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO attribute_rule_conditions + (id, rule_id, group_seq, seq, predicate_id, op, operand) + VALUES ($1, $2, 0, 1, $3, 'gt', + jsonb_build_object('op','add','l', + jsonb_build_object('attr', $4::text), + 'r', jsonb_build_object('const', 1)))", + ) + .bind(Uuid::now_v7()) + .bind(f.arule_a) + .bind(f.attr_a) + .bind(f.attr_b) + .execute(&mut *tx) + .await?; + let err = export::attribute_rules(&mut tx, f.a).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "operand 里的别库谓词必须拒导"); + assert!( + msg.contains("rule.expr_predicate"), + "该报 rule.expr_predicate: {msg}" + ); + tx.rollback().await?; + Ok(()) +} + +/// 文档过户的极端形态(replica):文档挪到 B,它的版本行跟着归 B—— +/// A 的导出里任何指着旧文档的边都该被它自己的体检拦下 +#[tokio::test] +async fn a_reassigned_document_takes_its_versions_and_breaks_the_edges() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + + let mut tx = pool.begin().await?; + let f = seed_tx(&mut tx).await?; + sqlx::query( + "INSERT INTO document_versions (id, document_id, version, sha256, size_bytes) + VALUES ($1, $2, 1, 'deadbeef', 4096)", + ) + .bind(Uuid::now_v7()) + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE documents SET kb_id = $2 WHERE id = $1") + .bind(f.doc_a) + .bind(f.b) + .execute(&mut *tx) + .await?; + + // 版本行归属跟文档走:A 的页里不该再有它 + let versions = export::document_versions_page(&mut tx, f.a, None).await?; + assert!(versions.is_empty(), "挪走的文档把版本行一并带走"); + + // A 的导出整体拒:chunk.document / evidence.document 还指着那份文档 + let err = export::provenance_integrity(&mut tx, f.a).await; + assert!(err.is_err(), "文档过户后 A 的出处链必须拒导"); + assert!(export::chunks_page(&mut tx, f.a, None).await.is_err()); + assert!(export::evidence_page(&mut tx, f.a, None).await.is_err()); + + // B 收下了文档与版本行:它的导出里版本节点在场、wasRevisionOf 合法 + let vb = export::document_versions_page(&mut tx, f.b, None).await?; + assert_eq!(vb.len(), 1, "版本行在 B 的导出里"); + assert_eq!(vb[0].document_kb, Some(f.b)); + tx.rollback().await?; + Ok(()) +} + +/// 导出中途落下的写进不了这一份。tx 起 REPEATABLE READ 快照后, +/// 另一条连接提交一条新规则+引用它的派生——本事务的规则页与派生页 +/// 都看不见它:没有半个进来的引用,也没有悬空的 wasGeneratedBy。 +/// 这条要两条连接,种子必须提交——清场照常走 +#[tokio::test] +async fn a_mid_stream_commit_stays_outside_the_snapshot() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + + let (org, ws, a) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let ent_a = Uuid::now_v7(); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'export-race')") + .bind(org) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'export-race')") + .bind(ws) + .bind(org) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'export-race')", + ) + .bind(a) + .bind(ws) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'e')") + .bind(ent_a) + .bind(a) + .execute(&pool) + .await?; + + // 先埋一条公理规则和一条引用它的派生,作为「快照内」基线 + let (rule0, pred0) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind) + VALUES ($1, $2, 'p0', 'p0', 'relation')", + ) + .bind(pred0) + .bind(a) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, 'transitive')", + ) + .bind(rule0) + .bind(a) + .bind(pred0) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, rule_id) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(Uuid::now_v7()) + .bind(a) + .bind(ent_a) + .bind(pred0) + .bind(ent_a) + .bind(rule0) + .execute(&pool) + .await?; + + // 导出事务:只读 REPEATABLE READ,快照从第一条语句起钉死 + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut *tx) + .await?; + export::provenance_integrity(&mut tx, a).await?; + let _ = export::entities_page(&mut tx, a, None).await?; + + // 中途:另一条连接提交一条新规则+引用它的派生事实 + let (rule_late, pred_late, derived_late) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind) + VALUES ($1, $2, 'p_late', 'p_late', 'relation')", + ) + .bind(pred_late) + .bind(a) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, 'transitive')", + ) + .bind(rule_late) + .bind(a) + .bind(pred_late) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, rule_id) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(derived_late) + .bind(a) + .bind(ent_a) + .bind(pred_late) + .bind(ent_a) + .bind(rule_late) + .execute(&pool) + .await?; + + // 快照里:规则页、派生页都不该有中途进来的行——一致性是整份的 + let rules = export::rules(&mut tx, a).await?; + assert!( + !rules.iter().any(|r| r.id == rule_late), + "中途提交的规则不许进这份导出" + ); + let derived = export::derived_page(&mut tx, a, None).await?; + assert!( + !derived.iter().any(|d| d.id == derived_late), + "中途提交的派生不许进这份导出——也就不会有悬空的 wasGeneratedBy" + ); + assert_eq!(derived.len(), 1, "快照内的那条还在"); + tx.rollback().await?; + drop(conn); + + // 新事务是新的快照:两条都该在 + let mut tx2 = pool.begin().await?; + let rules = export::rules(&mut tx2, a).await?; + assert!(rules.iter().any(|r| r.id == rule_late)); + let derived = export::derived_page(&mut tx2, a, None).await?; + assert_eq!(derived.len(), 2); + tx2.rollback().await?; + + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(a) + .execute(&pool) + .await?; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + Ok(()) +} + +/// NIL 是合法 uuid——schema 不拦它当主键。按序它排在最前;首页谓词若是 +/// `id > 哨兵`,这一行就永远进不了任何一页。而段落/证据上的 +/// (document_id, doc_version) 定位器不看 id 照样解析过去:节点缺席、 +/// 边在场,导出里就悬一条没有本体的 `ofVersion`。所有按 id 翻页的 +/// 取数口——实体、事实、派生、文档、版本、段落、证据复合键——第一页 +/// 都得把它翻出来 +#[tokio::test] +async fn a_nil_id_row_still_reaches_the_first_page() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + + let mut tx = pool.begin().await?; + let f = seed_tx(&mut tx).await?; + let nil = Uuid::nil(); + + // 每张走 id 游标的表各埋一行 NIL 主键;引用一律指回这些 NIL 行自己, + // 外键不因 NIL 失效——它们跟其他行一样合法 + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'nil-e')") + .bind(nil) + .bind(f.a) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256, status, external_key) + VALUES ($1, $2, 'nil.md', 'nil-sha', 'ready', 'file:///nil.md')", + ) + .bind(nil) + .bind(f.a) + .execute(&mut *tx) + .await?; + // id 为 NIL 的版本行:(doc_a, 1) 正是 chunk_a 指着的定位器 + sqlx::query( + "INSERT INTO document_versions (id, document_id, version, sha256, size_bytes) + VALUES ($1, $2, 1, 'deadbeef', 4096)", + ) + .bind(nil) + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text, doc_version) + VALUES ($1, $2, $3, 1, 'x', 1)", + ) + .bind(nil) + .bind(f.a) + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $3, 0.9)", + ) + .bind(nil) + .bind(f.a) + .bind(nil) + .bind(f.attr_a) + .execute(&mut *tx) + .await?; + let (pred_r, rule_r) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, kind) + VALUES ($1, $2, 'rel_p', 'rel_p', 'relation')", + ) + .bind(pred_r) + .bind(f.a) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, 'transitive')", + ) + .bind(rule_r) + .bind(f.a) + .bind(pred_r) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, rule_id) + VALUES ($1, $2, $3, $4, $3, $5)", + ) + .bind(nil) + .bind(f.a) + .bind(nil) + .bind(pred_r) + .bind(rule_r) + .execute(&mut *tx) + .await?; + // 复合游标的同一角:(NIL, NIL) 的证据行 + sqlx::query( + "INSERT INTO fact_evidence (fact_id, chunk_id, document_id, doc_version) + VALUES ($1, $1, $2, 1)", + ) + .bind(nil) + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + + assert!( + export::entities_page(&mut tx, f.a, None) + .await? + .iter() + .any(|e| e.id == nil), + "entities 首页漏掉 NIL 行" + ); + assert!( + export::documents_page(&mut tx, f.a, None) + .await? + .iter() + .any(|d| d.id == nil), + "documents 首页漏掉 NIL 行" + ); + // 版本节点必须进导出页,ofVersion 才有着落 + let versions = export::document_versions_page(&mut tx, f.a, None).await?; + assert!( + versions.iter().any(|v| v.id == nil), + "document_versions 首页漏掉 NIL 行——它的定位器会悬空" + ); + assert!( + export::chunks_page(&mut tx, f.a, None) + .await? + .iter() + .any(|c| c.id == nil), + "chunks 首页漏掉 NIL 行" + ); + assert!( + export::facts_page(&mut tx, f.a, None) + .await? + .iter() + .any(|x| x.id == nil), + "facts 首页漏掉 NIL 行" + ); + assert!( + export::derived_page(&mut tx, f.a, None) + .await? + .iter() + .any(|d| d.id == nil), + "derived 首页漏掉 NIL 行" + ); + let ev = export::evidence_page(&mut tx, f.a, None).await?; + assert!( + ev.iter().any(|e| e.fact_id == nil && e.chunk_id == nil), + "evidence 首页漏掉 (NIL, NIL) 行" + ); + tx.rollback().await?; + Ok(()) +} diff --git a/crates/utopia-store/tests/exported_references_never_cross_a_kb.rs b/crates/utopia-store/tests/exported_references_never_cross_a_kb.rs new file mode 100644 index 000000000..77dc5fba7 --- /dev/null +++ b/crates/utopia-store/tests/exported_references_never_cross_a_kb.rs @@ -0,0 +1,849 @@ +//! 导出序列化触碰的**每一条**引用都不许跨库(0070 的完整版)。 +//! +//! 出处链的三条边(evidence.chunk / evidence.document / chunk.document)只是 +//! 一族兄弟边里最先长出来的:派生的前提、边上的属性、事实的主宾谓、 +//! supersedes 与 from_statement_id、陈述来源、开放陈述的属性值、时间提及、 +//! 类型与短语绑定、派生的规则、实体的类、类层级与 domain/range——同一条 +//! 不变量下它们都会做出同一种伪造。落到别库的下场分两种: +//! - 铸成本库 IRI 的引用(实体、事实、派生、文档、段落、规则)→ 伪造身份; +//! - 进本库词汇表按 id 查的引用(谓词、属性类型、实体类型、父类)→ 静默消失。 +//! 两种都是坏行,触发器与导出侧一律拒。 +//! +//! 坏行靠 `SET LOCAL session_replication_role='replica'` 造——只关本事务的 +//! 触发器(含 FK 强制),提交即恢复;这同时允许造出「指着的行已不在」的 +//! 悬空引用,正是从 0070 之前的备份恢复进来的形态。 + +use sqlx::{Acquire, PgPool}; +use uuid::Uuid; + +struct Fixture { + org: Uuid, + a: Uuid, + b: Uuid, + doc_a: Uuid, + chunk_a: Uuid, + chunk_b: Uuid, + ent_a: Uuid, + ent_b: Uuid, + fact_a: Uuid, + fact_b: Uuid, + rel_a: Uuid, + rel_b: Uuid, + cls_a: Uuid, + cls_b: Uuid, + rule_a: Uuid, + rule_b: Uuid, + arule_a: Uuid, + arule_b: Uuid, + der_a: Uuid, + der_b: Uuid, +} + +/// 两库,每库一套能被引用的零件:文档/段落/实体/事实/谓词/类/公理规则/业务规则/派生 +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, a, b) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + let (doc_a, doc_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (chunk_a, chunk_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (ent_a, ent_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (fact_a, fact_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (rel_a, rel_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (cls_a, cls_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (rule_a, rule_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (arule_a, arule_b) = (Uuid::now_v7(), Uuid::now_v7()); + let (der_a, der_b) = (Uuid::now_v7(), Uuid::now_v7()); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'xkb-ref-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'xkb-ref-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + for kb in [a, b] { + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'xkb-ref-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + } + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256, status, external_key) + VALUES ($1, $2, 'a.md', 'sha-a', 'ready', 'file:///a.md')", + ) + .bind(doc_a) + .bind(a) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256, status, external_key) + VALUES ($1, $2, 'b.md', 'sha-b', 'ready', 'file:///b.md')", + ) + .bind(doc_b) + .bind(b) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 0, 'x')", + ) + .bind(chunk_a) + .bind(a) + .bind(doc_a) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1, $2, $3, 0, 'x')", + ) + .bind(chunk_b) + .bind(b) + .bind(doc_b) + .execute(pool) + .await?; + for (id, kb) in [(ent_a, a), (ent_b, b)] { + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'e')") + .bind(id) + .bind(kb) + .execute(pool) + .await?; + } + for (id, kb, s, o) in [(fact_a, a, ent_a, ent_a), (fact_b, b, ent_b, ent_b)] { + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence) + VALUES ($1, $2, $3, $4, 0.9)", + ) + .bind(id) + .bind(kb) + .bind(s) + .bind(o) + .execute(pool) + .await?; + } + for (id, kb) in [(rel_a, a), (rel_b, b)] { + sqlx::query("INSERT INTO relation_types (id, kb_id, key, label) VALUES ($1, $2, $3, 'r')") + .bind(id) + .bind(kb) + .bind(format!("r-{id}")) + .execute(pool) + .await?; + } + for (id, kb) in [(cls_a, a), (cls_b, b)] { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, 'c')") + .bind(id) + .bind(kb) + .bind(format!("c-{id}")) + .execute(pool) + .await?; + } + for (id, kb, pred) in [(rule_a, a, rel_a), (rule_b, b, rel_b)] { + sqlx::query( + "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, 'transitive')", + ) + .bind(id) + .bind(kb) + .bind(pred) + .execute(pool) + .await?; + } + for (id, kb, ty, pred) in [(arule_a, a, cls_a, rel_a), (arule_b, b, cls_b, rel_b)] { + sqlx::query( + "INSERT INTO attribute_rules (id, kb_id, name, subject_type_id, conclusion, + conclude_predicate_id, conclude_value) + VALUES ($1, $2, $3, $4, 'attribute', $5, '42'::jsonb)", + ) + .bind(id) + .bind(kb) + .bind(format!("ar-{id}")) + .bind(ty) + .bind(pred) + .execute(pool) + .await?; + } + for (id, kb, s, p, o, r) in [ + (der_a, a, ent_a, rel_a, ent_a, rule_a), + (der_b, b, ent_b, rel_b, ent_b, rule_b), + ] { + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, object_id, rule_id) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(id) + .bind(kb) + .bind(s) + .bind(p) + .bind(o) + .bind(r) + .execute(pool) + .await?; + } + Ok(Fixture { + org, + a, + b, + doc_a, + chunk_a, + chunk_b, + ent_a, + ent_b, + fact_a, + fact_b, + rel_a, + rel_b, + cls_a, + cls_b, + rule_a, + rule_b, + arule_a, + arule_b, + der_a, + der_b, + }) +} + +async fn cleanup(pool: &PgPool, f: &Fixture) -> anyhow::Result<()> { + for kb in [f.a, f.b] { + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(kb) + .execute(pool) + .await?; + } + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(pool) + .await?; + Ok(()) +} + +/// 新的跨库写:每条边都被它自己的触发器当场挡下 +#[tokio::test] +async fn new_cross_kb_writes_are_rejected_on_every_exported_edge() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // —— 派生的前提:两种前提,两种越库形态 + for (premise_fact, premise_derived, what) in [ + (Some(f.fact_b), None, "foreign fact premise"), + (None, Some(f.der_b), "foreign derived premise"), + ] { + let err = sqlx::query( + "INSERT INTO fact_derivations (derived_fact_id, premise_fact_id, premise_derived_id, seq) + VALUES ($1, $2, $3, 0)", + ) + .bind(f.der_a) + .bind(premise_fact) + .bind(premise_derived) + .execute(&pool) + .await; + assert!(err.is_err(), "derivation premise: {what} 必须被拒"); + } + + // —— 边上的属性:别库类型会被词汇表静默跳过,别库实体会被铸进本库 IRI + let err = sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, value) + VALUES ($1, $2, '\"lit\"'::jsonb)", + ) + .bind(f.fact_a) + .bind(f.rel_b) + .execute(&pool) + .await; + assert!(err.is_err(), "qualifier.type→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, entity_id) + VALUES ($1, $2, $3)", + ) + .bind(f.fact_a) + .bind(f.rel_a) + .bind(f.ent_b) + .execute(&pool) + .await; + assert!(err.is_err(), "qualifier.entity→foreign 必须被拒"); + + // —— 事实本体的五个引用列(from_statement_id 是同表自指:immediate 端在 + // BEFORE 触发器上,目标早已在场时当场拒) + for (col, foreign_id, what) in [ + ("subject_id", f.ent_b, "fact.subject"), + ("object_id", f.ent_b, "fact.object"), + ("predicate_id", f.rel_b, "fact.predicate"), + ("supersedes", f.fact_b, "fact.supersedes"), + ("from_statement_id", f.fact_b, "fact.from_statement"), + ] { + let err = sqlx::query(&format!( + "INSERT INTO facts (id, kb_id, subject_id, {col}) VALUES ($1, $2, $3, $4)" + )) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(if col == "subject_id" { + f.ent_b + } else { + f.ent_a + }) + .bind(foreign_id) + .execute(&pool) + .await; + assert!(err.is_err(), "{what}→foreign 必须被拒"); + } + + // —— 陈述来源(typed_fact_sources,0068):行自己没有 kb 列,归属按所属 + // fact 的库判——A 的事实吃 B 的陈述,序列化会铸出指着别库陈述的边 + let err = sqlx::query("INSERT INTO typed_fact_sources (fact_id, statement_id) VALUES ($1, $2)") + .bind(f.fact_a) + .bind(f.fact_b) + .execute(&pool) + .await; + assert!(err.is_err(), "factsource.statement→foreign 必须被拒"); + + // —— 开放陈述的属性(statement_qualifiers,0061):行没有 kb 列,归属按 + // 所属 fact 的库判;别库的实体值会被铸进本库 IRI + let err = sqlx::query( + "INSERT INTO statement_qualifiers (fact_id, role, entity_id) + VALUES ($1, 'as', $2)", + ) + .bind(f.fact_a) + .bind(f.ent_b) + .execute(&pool) + .await; + assert!(err.is_err(), "squalifier.entity→foreign 必须被拒"); + + // —— 时间提及(0061/0064):提及自己的 kb 必须与它指的事实、段落同属一库 + let err = sqlx::query( + "INSERT INTO time_mentions (id, kb_id, fact_id, chunk_id, text, char_start) + VALUES ($1, $2, $3, $4, '去年', 0)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.fact_b) // 事实在别库 + .bind(f.chunk_a) + .execute(&pool) + .await; + assert!(err.is_err(), "timemention.fact→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO time_mentions (id, kb_id, fact_id, chunk_id, text, char_start) + VALUES ($1, $2, $3, $4, '去年', 0)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.fact_a) + .bind(f.chunk_b) // 段落在别库 + .execute(&pool) + .await; + assert!(err.is_err(), "timemention.chunk→foreign 必须被拒"); + + // —— 类型与短语绑定(0065/0066):绑定的类/属性以绑定行自己的库为准 + let err = sqlx::query( + "INSERT INTO type_bindings (id, kb_id, kind_word, status, type_id) + VALUES ($1, $2, 'corp', 'bound', $3)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_b) + .execute(&pool) + .await; + assert!(err.is_err(), "binding.type→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO phrase_bindings (id, kb_id, phrase, subject_type_id, status) + VALUES ($1, $2, 'runs', $3, 'none')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_b) + .execute(&pool) + .await; + assert!(err.is_err(), "pbinding.subject_type→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO phrase_bindings (id, kb_id, phrase, relation_type_id, + direction, status) + VALUES ($1, $2, 'runs', $3, 'forward', 'bound')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.rel_b) + .execute(&pool) + .await; + assert!(err.is_err(), "pbinding.relation→foreign 必须被拒"); + + // —— 派生事实本体的五个引用列 + for (col, foreign_id, what) in [ + ("subject_id", f.ent_b, "derived.subject"), + ("object_id", f.ent_b, "derived.object"), + ("predicate_id", f.rel_b, "derived.predicate"), + ("rule_id", f.rule_b, "derived.rule"), + ("attribute_rule_id", f.arule_b, "derived.attribute_rule"), + ] { + // attribute_rule 与 rule 互斥:测 attribute_rule 那行不带 rule_id + let err = if col == "attribute_rule_id" { + sqlx::query( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, attribute_rule_id) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.ent_a) + .bind(f.rel_a) + .bind(foreign_id) + .execute(&pool) + .await + } else { + sqlx::query(&format!( + "INSERT INTO derived_facts (id, kb_id, subject_id, predicate_id, rule_id, {col}) + VALUES ($1, $2, $3, $4, $5, $6)" + )) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(if col == "subject_id" { + f.ent_b + } else { + f.ent_a + }) + .bind(if col == "predicate_id" { + f.rel_b + } else { + f.rel_a + }) + .bind(if col == "rule_id" { f.rule_b } else { f.rule_a }) + .bind(foreign_id) + .execute(&pool) + .await + }; + assert!(err.is_err(), "{what}→foreign 必须被拒"); + } + + // —— 实体的类、类层级、互斥、domain/range + let err = sqlx::query( + "INSERT INTO entities (id, kb_id, canonical_name, type_id) VALUES ($1, $2, 'e', $3)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_b) + .execute(&pool) + .await; + assert!(err.is_err(), "entity.type→foreign 必须被拒"); + + let err = sqlx::query("INSERT INTO entity_type_parents (child_id, parent_id) VALUES ($1, $2)") + .bind(f.cls_a) + .bind(f.cls_b) + .execute(&pool) + .await; + assert!(err.is_err(), "class.parent→foreign 必须被拒"); + + let err = + sqlx::query("INSERT INTO entity_type_disjoint (kb_id, a_id, b_id) VALUES ($1, $2, $3)") + .bind(f.a) + .bind(f.cls_a) + .bind(f.cls_b) + .execute(&pool) + .await; + assert!(err.is_err(), "class.disjoint→foreign 必须被拒"); + + for (table, what) in [ + ("relation_type_domains", "relation.domain"), + ("relation_type_ranges", "relation.range"), + ] { + let err = sqlx::query(&format!( + "INSERT INTO {table} (relation_type_id, entity_type_id) VALUES ($1, $2)" + )) + .bind(f.rel_a) + .bind(f.cls_b) + .execute(&pool) + .await; + assert!(err.is_err(), "{what}→foreign 必须被拒"); + } + + // —— 关系的同表自指与边属性声明:owl:inverseOf / + // rdfs:subPropertyOf 与 qualifier 列表都是语义,别库的不许进来 + let err = sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, inverse_of) VALUES ($1, $2, 'inv', 'inv', $3)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.rel_b) + .execute(&pool) + .await; + assert!(err.is_err(), "relation.inverse→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, sub_property_of) VALUES ($1, $2, 'sub', 'sub', $3)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.rel_b) + .execute(&pool) + .await; + assert!(err.is_err(), "relation.sub_property→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO relation_type_qualifiers (relation_type_id, qualifier_type_id) VALUES ($1, $2)", + ) + .bind(f.rel_a) + .bind(f.rel_b) + .execute(&pool) + .await; + assert!(err.is_err(), "relation.qualifier→foreign 必须被拒"); + + // —— 规则本体:公理编在哪个谓词上、业务规则看什么类得什么结论, + // 现在都在导出里——它们的引用同样不许跨库 + let err = sqlx::query( + "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, 'transitive')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.rel_b) + .execute(&pool) + .await; + assert!(err.is_err(), "rule.predicate→foreign 必须被拒"); + // 每条结论形状自己的必填列(CHECK 钉死了 typing/attribute/computed 的列组), + // 一个引用列一种合法形状 + let err = sqlx::query( + "INSERT INTO attribute_rules (id, kb_id, name, subject_type_id, conclusion, + conclude_predicate_id, conclude_value) + VALUES ($1, $2, 'ar', $3, 'attribute', $4, '42'::jsonb)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_b) // subject_type 别库 + .bind(f.rel_a) + .execute(&pool) + .await; + assert!(err.is_err(), "arule.subject_type→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO attribute_rules (id, kb_id, name, subject_type_id, conclusion, + conclude_type_id) + VALUES ($1, $2, 'ar', $3, 'typing', $4)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_a) + .bind(f.cls_b) // conclude_type 别库 + .execute(&pool) + .await; + assert!(err.is_err(), "arule.conclude_type→foreign 必须被拒"); + let err = sqlx::query( + "INSERT INTO attribute_rules (id, kb_id, name, subject_type_id, conclusion, + conclude_predicate_id, conclude_value) + VALUES ($1, $2, 'ar', $3, 'attribute', $4, '42'::jsonb)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_a) + .bind(f.rel_b) // conclude_predicate 别库 + .execute(&pool) + .await; + assert!(err.is_err(), "arule.conclude_predicate→foreign 必须被拒"); + + // —— kb 过户:把已被引用的行挪到别的库,等于把指着它的行一次全变坏行 + for (table, id, what) in [ + ("entities", f.ent_a, "entities.kb_id"), + ("entity_types", f.cls_a, "entity_types.kb_id"), + ("relation_types", f.rel_a, "relation_types.kb_id"), + ("derived_facts", f.der_a, "derived_facts.kb_id"), + ("rules", f.rule_a, "rules.kb_id"), + ("attribute_rules", f.arule_a, "attribute_rules.kb_id"), + ] { + let err = sqlx::query(&format!("UPDATE {table} SET kb_id = $2 WHERE id = $1")) + .bind(id) + .bind(f.b) + .execute(&pool) + .await; + assert!(err.is_err(), "{what} 过户必须被拒"); + } + + // —— 防误伤:同库的合法写入照常 + sqlx::query( + "INSERT INTO fact_derivations (derived_fact_id, premise_fact_id, seq) + VALUES ($1, $2, 0)", + ) + .bind(f.der_a) + .bind(f.fact_a) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, entity_id) + VALUES ($1, $2, $3)", + ) + .bind(f.fact_a) + .bind(f.rel_a) + .bind(f.ent_a) + .execute(&pool) + .await?; + + cleanup(&pool, &f).await +} + +/// 存量坏行:体检报出正确的边,对应的页读取同样拒 +#[tokio::test] +async fn malformed_rows_fail_every_exported_edge_closed() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + + // 每条边造一行坏行——全部落在库 A 身上 + sqlx::query( + "INSERT INTO fact_derivations (derived_fact_id, premise_fact_id, seq) + VALUES ($1, $2, 0)", + ) + .bind(f.der_a) + .bind(f.fact_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO fact_derivations (derived_fact_id, premise_derived_id, seq) + VALUES ($1, $2, 1)", + ) + .bind(f.der_a) + .bind(f.der_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, value) + VALUES ($1, $2, '\"lit\"'::jsonb)", + ) + .bind(f.fact_a) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, entity_id) + VALUES ($1, $2, $3)", + ) + .bind(f.fact_a) + .bind(f.rel_a) + .bind(f.ent_b) + .execute(&mut *tx) + .await?; + // 事实的 supersedes 指向别库事实 + sqlx::query("UPDATE facts SET supersedes = $2 WHERE id = $1") + .bind(f.fact_a) + .bind(f.fact_b) + .execute(&mut *tx) + .await?; + // 陈述来源指着别库陈述(typed_fact_sources 与 from_statement_id 两条路) + sqlx::query("INSERT INTO typed_fact_sources (fact_id, statement_id) VALUES ($1, $2)") + .bind(f.fact_a) + .bind(f.fact_b) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE facts SET from_statement_id = $2 WHERE id = $1") + .bind(f.fact_a) + .bind(f.fact_b) + .execute(&mut *tx) + .await?; + // 开放陈述的属性值指着别库实体 + sqlx::query( + "INSERT INTO statement_qualifiers (fact_id, role, entity_id) + VALUES ($1, 'as', $2)", + ) + .bind(f.fact_a) + .bind(f.ent_b) + .execute(&mut *tx) + .await?; + // 时间提及两种坏法:归属在 A 却指着 B 的段落(体检扫得见——行按自己的 + // kb 计数);归属在 B 却挂在 A 的事实上(按事实取回时,页校验拦它) + sqlx::query( + "INSERT INTO time_mentions (id, kb_id, fact_id, chunk_id, text, char_start) + VALUES ($1, $2, $3, $4, '去年', 0)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.fact_a) + .bind(f.chunk_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO time_mentions (id, kb_id, fact_id, chunk_id, text, char_start) + VALUES ($1, $2, $3, $4, '前年', 2)", + ) + .bind(Uuid::now_v7()) + .bind(f.b) + .bind(f.fact_a) + .bind(f.chunk_a) + .execute(&mut *tx) + .await?; + // 类型绑定指着别库的类 + sqlx::query( + "INSERT INTO type_bindings (id, kb_id, kind_word, status, type_id) + VALUES ($1, $2, 'corp', 'bound', $3)", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + // 短语绑定指着别库的属性 + sqlx::query( + "INSERT INTO phrase_bindings (id, kb_id, phrase, relation_type_id, + direction, status) + VALUES ($1, $2, 'runs', $3, 'forward', 'bound')", + ) + .bind(Uuid::now_v7()) + .bind(f.a) + .bind(f.rel_b) + .execute(&mut *tx) + .await?; + // 派生的公理规则换库 + sqlx::query("UPDATE derived_facts SET rule_id = $2 WHERE id = $1") + .bind(f.der_a) + .bind(f.rule_b) + .execute(&mut *tx) + .await?; + // 实体的类换库 + sqlx::query("UPDATE entities SET type_id = $2 WHERE id = $1") + .bind(f.ent_a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + // 类层级与 domain/range + sqlx::query("INSERT INTO entity_type_parents (child_id, parent_id) VALUES ($1, $2)") + .bind(f.cls_a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO relation_type_domains (relation_type_id, entity_type_id) VALUES ($1, $2)", + ) + .bind(f.rel_a) + .bind(f.cls_b) + .execute(&mut *tx) + .await?; + tx.commit().await?; + drop(conn); + + let err = utopia_store::export::provenance_integrity(&mut pool.begin().await?, f.a).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "库 A 的体检必须拒导"); + for edge in [ + "derivation.premise_fact", + "derivation.premise_derived", + "qualifier.type", + "qualifier.entity", + "fact.supersedes", + "fact.from_statement", + "factsource.statement", + "squalifier.entity", + "timemention.chunk", + "binding.type", + "pbinding.relation", + "derived.rule", + "entity.type", + "class.parent", + "relation.domain", + ] { + assert!(msg.contains(edge), "体检该报 {edge}: {msg}"); + } + + // 逐页校验:坏行落在哪页,哪页就拒——不能漏出伪造 IRI + assert!( + utopia_store::export::facts_page(&mut pool.begin().await?, f.a, None) + .await + .is_err() + ); + assert!( + utopia_store::export::derived_page(&mut pool.begin().await?, f.a, None) + .await + .is_err() + ); + assert!( + utopia_store::export::entities_page(&mut pool.begin().await?, f.a, None) + .await + .is_err() + ); + assert!(utopia_store::export::classes(&mut pool.begin().await?, f.a) + .await + .is_err()); + assert!( + utopia_store::export::relations(&mut pool.begin().await?, f.a) + .await + .is_err() + ); + + cleanup(&pool, &f).await +} + +/// 留下的行才参与校验:被引的行在两次读之间消失,判违规的是 +/// **随页原子选出的归属**,不是事后另一个时刻的 JOIN——悬空引用照样拒 +#[tokio::test] +async fn retained_row_validation_survives_dangling_and_late_state() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let f = seed(&pool).await?; + + // 合法证据先行 + sqlx::query("INSERT INTO fact_evidence (fact_id, chunk_id, document_id) VALUES ($1, $2, $3)") + .bind(f.fact_a) + .bind(f.chunk_a) + .bind(f.doc_a) + .execute(&pool) + .await?; + utopia_store::export::evidence_page(&mut pool.begin().await?, f.a, None).await?; + + // 绕过触发器把文档删掉:evidence 行还指着它——悬空不是「不存在所以跳过」 + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM documents WHERE id = $1") + .bind(f.doc_a) + .execute(&mut *tx) + .await?; + tx.commit().await?; + drop(conn); + + let err = utopia_store::export::evidence_page(&mut pool.begin().await?, f.a, None).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "悬空 document 指针必须拒"); + assert!( + msg.contains("evidence.document"), + "该报 evidence.document: {msg}" + ); + + // 同理:前提行被删,derived 的前提数组仍留着死指针——逐页校验要拦 + sqlx::query( + "INSERT INTO fact_derivations (derived_fact_id, premise_fact_id, seq) + VALUES ($1, $2, 0)", + ) + .bind(f.der_a) + .bind(f.fact_a) + .execute(&pool) + .await?; + let mut conn = pool.acquire().await?; + let mut tx = conn.begin().await?; + sqlx::query("SET LOCAL session_replication_role = 'replica'") + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM facts WHERE id = $1") + .bind(f.fact_a) + .execute(&mut *tx) + .await?; + tx.commit().await?; + drop(conn); + + let err = utopia_store::export::derived_page(&mut pool.begin().await?, f.a, None).await; + let msg = format!("{err:?}"); + assert!(err.is_err(), "悬空前提必须拒"); + assert!( + msg.contains("derivation.premise_fact"), + "该报 derivation.premise_fact: {msg}" + ); + + cleanup(&pool, &f).await +} From 77e4d466f1bf2e3d346a8509add61aa3aa1646cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=97=E6=85=B6=E9=BA=9F?= Date: Sun, 20 Sep 2026 21:45:13 +0800 Subject: [PATCH 3/4] The whole ledger reaches the export Rules, attribute rules, document versions, chunks and evidence were read from the same snapshot but never serialized; their facts' prov:wasGeneratedBy, prov:wasDerivedFrom and locator references dangled in the file. Serialize them in snapshot order so every emitted reference resolves. New upstream ledger surfaces serialize too: class/relation updatedAt, entity descriptions, document reader/time-context, chunk origin metadata, evidence quote offsets, open-statement layer/phrase/ qualifiers/time mentions, typed-fact fromStatement provenance, validFromGrade, and evidenceOrigin. --- crates/utopia-server/src/api/export_routes.rs | 43 + crates/utopia-server/src/rdf.rs | 1745 ++++++++++++++++- 2 files changed, 1721 insertions(+), 67 deletions(-) diff --git a/crates/utopia-server/src/api/export_routes.rs b/crates/utopia-server/src/api/export_routes.rs index db33d5a52..fb7ebcb16 100644 --- a/crates/utopia-server/src/api/export_routes.rs +++ b/crates/utopia-server/src/api/export_routes.rs @@ -85,6 +85,13 @@ pub async fn export( for r in &relations { rdf::emit_relation(&mut sink, &vocab, r)?; } + // 公理与业务规则整份进词汇表区:引它们的派生才有 prov:wasGeneratedBy 可指 + for r in &utopia_store::export::rules(&mut tx, kb_id).await.map_err(io)? { + rdf::emit_rule(&mut sink, &names, &vocab, r)?; + } + for r in &utopia_store::export::attribute_rules(&mut tx, kb_id).await.map_err(io)? { + rdf::emit_attribute_rule(&mut sink, &names, &vocab, r)?; + } yield axum::body::Bytes::from(buf.take()); let mut after = None; @@ -98,6 +105,30 @@ pub async fn export( yield axum::body::Bytes::from(buf.take()); } + let mut after = None; + loop { + let page = utopia_store::export::document_versions_page(&mut tx, kb_id, after) + .await + .map_err(io)?; + let Some(last) = page.last() else { break }; + after = Some(last.id); + for v in &page { + rdf::emit_docversion(&mut sink, &names, v)?; + } + yield axum::body::Bytes::from(buf.take()); + } + + let mut after = None; + loop { + let page = utopia_store::export::chunks_page(&mut tx, kb_id, after).await.map_err(io)?; + let Some(last) = page.last() else { break }; + after = Some(last.id); + for c in &page { + rdf::emit_chunk(&mut sink, &names, c)?; + } + yield axum::body::Bytes::from(buf.take()); + } + let mut after = None; loop { let page = utopia_store::export::entities_page(&mut tx, kb_id, after).await.map_err(io)?; @@ -123,6 +154,18 @@ pub async fn export( yield axum::body::Bytes::from(buf.take()); } + // 证据游标是复合的 (fact_id, chunk_id)——与取数页同一键 + let mut after: Option<(Uuid, Uuid)> = None; + loop { + let page = utopia_store::export::evidence_page(&mut tx, kb_id, after).await.map_err(io)?; + let Some(last) = page.last() else { break }; + after = Some((last.fact_id, last.chunk_id)); + for e in &page { + rdf::emit_evidence(&mut sink, &names, e)?; + } + yield axum::body::Bytes::from(buf.take()); + } + let mut after = None; loop { let page = utopia_store::export::derived_page(&mut tx, kb_id, after).await.map_err(io)?; diff --git a/crates/utopia-server/src/rdf.rs b/crates/utopia-server/src/rdf.rs index 0da8f3fc8..d7e3270be 100644 --- a/crates/utopia-server/src/rdf.rs +++ b/crates/utopia-server/src/rdf.rs @@ -20,7 +20,8 @@ use chrono::{DateTime, Utc}; use oxrdf::vocab::{rdf, rdfs, xsd}; use oxrdf::{Literal, NamedNode, NamedNodeRef, Term, TripleRef}; use utopia_store::export::{ - ExportClass, ExportDerived, ExportDocument, ExportEntity, ExportFact, ExportRelation, + ExportAttributeRule, ExportChunk, ExportClass, ExportDerived, ExportDocument, + ExportDocumentVersion, ExportEntity, ExportEvidence, ExportFact, ExportRelation, ExportRule, }; use uuid::Uuid; @@ -59,6 +60,9 @@ fn utopia(term: &str) -> NamedNode { /// 自己对外的地址是什么。按请求的 Host 头去造,等于让身份取决于走了哪个反代。 /// 部署方知道自己发布在哪时,`?base=https://…/` 换成 http IRI。 pub struct Names { + /// 这份导出属于哪个库。序列化器拿它守住最后一条线:随页原子选出的归属 + /// 只要不是它,该 IRI 就不铸——绕过逐页校验递进来的坏行在这就停下 + kb: Uuid, prefix: String, sep: char, } @@ -75,11 +79,13 @@ impl Names { } let trimmed = base.trim_end_matches('/'); Ok(Self { + kb: kb_id, prefix: format!("{trimmed}/kb/{kb_id}/"), sep: '/', }) } None => Ok(Self { + kb: kb_id, prefix: format!("urn:utopia:kb:{kb_id}:"), sep: ':', }), @@ -102,9 +108,42 @@ impl Names { pub fn document(&self, id: Uuid) -> NamedNode { self.mint("document", &id.to_string()) } + pub fn chunk(&self, id: Uuid) -> NamedNode { + self.mint("chunk", &id.to_string()) + } + /// 证据行的 IRI:主键是复合的 (fact_id, chunk_id),IRI 把两截都带上。 + /// 没有它,证据只能摊成语句上两个对不上的数组 + pub fn evidence(&self, fact_id: Uuid, chunk_id: Uuid) -> NamedNode { + self.mint("evidence", &format!("{fact_id}:{chunk_id}")) + } pub fn rule(&self, id: Uuid) -> NamedNode { self.mint("rule", &id.to_string()) } + /// 业务规则(attribute_rules)与公理规则(rules)是两种东西:共用 + /// `rule:` 前缀会让一条派生的 prov:wasGeneratedBy 说不清自己是谁生的 + pub fn attribute_rule(&self, id: Uuid) -> NamedNode { + self.mint("arule", &id.to_string()) + } + /// 一条前提(fact_derivations 的一行):复合键 (derived_fact_id, seq)。 + /// prov:used 只说是前提,看不出先后——序位是证明的一部分,得有自己的节点 + pub fn premise(&self, derived_id: Uuid, seq: i32) -> NamedNode { + self.mint("premise", &format!("{derived_id}:{seq}")) + } + /// 一条规则条件(attribute_rule_conditions):身份是全序 + /// (rule_id, group_seq, seq)——同组「与」、组间「或」,序位是判据的一部分 + pub fn condition(&self, rule_id: Uuid, group_seq: i32, seq: i32) -> NamedNode { + self.mint("condition", &format!("{rule_id}:{group_seq}:{seq}")) + } + /// 文档的一版(document_versions):定位器是 (document_id, version), + /// 段落与证据上的 docVersion 解析到这个节点 + pub fn docversion(&self, document_id: Uuid, version: i32) -> NamedNode { + self.mint("docversion", &format!("{document_id}:{version}")) + } + /// 一条时间提及(time_mentions):陈述里的时间词的出处要走到这个字上, + /// 它得有自己的 IRI + pub fn mention(&self, id: Uuid) -> NamedNode { + self.mint("timemention", &id.to_string()) + } /// 本体自己长出来的类/关系用 **key** 而不是 uuid:key 是这个库内部就在用的 /// 标识(`UNIQUE (kb_id, key)`,抽取提示词和 API 用的都是它),文件因此读得懂。 /// 导入来的一律用原 IRI @@ -273,6 +312,38 @@ fn flag(b: bool) -> Literal { Literal::new_typed_literal(if b { "true" } else { "false" }, xsd::BOOLEAN) } +/// 序列化侧的最后一道闸:随页原子选出的归属 +/// 只要不是这份导出的库——别库、悬空(NULL)都一样——该 IRI 就不铸。 +/// 逐页校验在 store 层已经拦过一遍;这里接住的是绕过它递进来的行 +fn inside(names: &Names, ref_kb: Option, edge: &str) -> std::io::Result<()> { + if ref_kb == Some(names.kb) { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("export refused: {edge} points outside the knowledge base"), + )) + } +} + +/// 同库但**不在导出集里**的目标(合并掉的实体是唯一的缺席类)。page 层已经 +/// 拦过一遍;这里接住的是绕过它递进来的行。不铸 IRI、不换目标、不静默省略 +fn unexported(edge: &str) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("export refused: {edge} points at a row outside this KB's exported set"), + ) +} + +/// 进词汇表按 id 查的引用:查不到在账本上意味着别库或悬空——静默跳过等于 +/// 让一截语义不声不响地消失。查不到就是坏行,报错不省略 +fn resolved(edge: &str) -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("export refused: {edge} cannot be resolved in this KB's vocabulary"), + ) +} + /// 一次导出里要反复查的东西:类与关系的 IRI、属性的值域。 pub struct Vocabulary { pub classes: Vec<(Uuid, NamedNode)>, @@ -327,17 +398,31 @@ pub fn emit_class(sink: &mut Sink, vocab: &Vocabulary, c: &ExportClass) -> std:: &text(c.description.clone()), )?; } + if c.builtin { + sink.l(&iri, &utopia("builtin"), &flag(true))?; + } + // 最近一次改写(0064 起绑定按它判过期)——词表语义的一部分 + sink.l(&iri, &utopia("updatedAt"), &dt(c.updated_at))?; for parent in &c.parents { - if let Some(p) = vocab.class(*parent) { - let p = p.clone(); - sink.r(&iri, &nn(rdfs::SUB_CLASS_OF.as_str()), &p)?; - } + let p = vocab + .class(*parent) + .ok_or_else(|| resolved("class.parent"))?; + let p = p.clone(); + sink.r(&iri, &nn(rdfs::SUB_CLASS_OF.as_str()), &p)?; + } + for parent in &c.primary_parents { + let p = vocab + .class(*parent) + .ok_or_else(|| resolved("class.parent"))?; + let p = p.clone(); + sink.r(&iri, &utopia("primaryType"), &p)?; } for other in &c.disjoint { - if let Some(o) = vocab.class(*other) { - let o = o.clone(); - sink.r(&iri, &owl("disjointWith"), &o)?; - } + let o = vocab + .class(*other) + .ok_or_else(|| resolved("class.disjoint"))?; + let o = o.clone(); + sink.r(&iri, &owl("disjointWith"), &o)?; } Ok(()) } @@ -378,36 +463,203 @@ pub fn emit_relation( sink.r(&iri, &nn(rdf::TYPE.as_str()), &owl(term))?; } } - // 只复制已声明的边;不补反向声明或传递闭包,目标也只在本库词汇表中找。 - for (target, predicate) in [ - (r.inverse_of, owl("inverseOf")), - (r.sub_property_of, nn(rdfs::SUB_PROPERTY_OF.as_str())), - ] { - if let Some(target) = target.and_then(|id| vocab.relation(id)) { - sink.r(&iri, &predicate, target)?; - } - } // 时间语义也照抄(0031):一个 event 谓词的事实两端是同一刻,一个 eternal 谓词的 // 事实没有日期——读的人不看这一条,会把前者读成一天的状态、后者读成从不知何时起。 // 状态是默认,不写 if r.temporal != "state" { sink.l(&iri, &utopia("temporal"), &text(r.temporal.clone()))?; } + if r.builtin { + sink.l(&iri, &utopia("builtin"), &flag(true))?; + } + // 关系之间的公理边(0016):谓词的语义不只在一行公理位上——谁是它的逆、 + // 它细化谁,丢了它们两个语义不同的库会导出同一张图。查不到就是坏行 + if let Some(inv) = r.inverse_of { + let t = vocab + .relation(inv) + .ok_or_else(|| resolved("relation.inverse"))?; + let t = t.clone(); + sink.r(&iri, &owl("inverseOf"), &t)?; + } + if let Some(sub) = r.sub_property_of { + let t = vocab + .relation(sub) + .ok_or_else(|| resolved("relation.sub_property"))?; + let t = t.clone(); + sink.r(&iri, &nn(rdfs::SUB_PROPERTY_OF.as_str()), &t)?; + } + // 这条关系允许自己的边带哪些属性(0037):声明本身也是公理 + for q in &r.qualifiers { + let q = vocab + .relation(*q) + .ok_or_else(|| resolved("relation.qualifier"))?; + let q = q.clone(); + sink.r(&iri, &utopia("allowedQualifier"), &q)?; + } for d in &r.domains { - if let Some(c) = vocab.class(*d) { - let c = c.clone(); - sink.r(&iri, &nn(rdfs::DOMAIN.as_str()), &c)?; - } + let c = vocab.class(*d).ok_or_else(|| resolved("relation.domain"))?; + let c = c.clone(); + sink.r(&iri, &nn(rdfs::DOMAIN.as_str()), &c)?; } for g in &r.ranges { - if let Some(c) = vocab.class(*g) { - let c = c.clone(); - sink.r(&iri, &nn(rdfs::RANGE.as_str()), &c)?; - } + let c = vocab.class(*g).ok_or_else(|| resolved("relation.range"))?; + let c = c.clone(); + sink.r(&iri, &nn(rdfs::RANGE.as_str()), &c)?; + } + // 属性的值域声明:字面值定型靠它,但「这个属性声明的是什么类型」本身 + // 也是语义——两个同名不同型的库不该读出同一张图 + if let Some(dt) = &r.datatype { + sink.l(&iri, &utopia("datatype"), &text(dt.clone()))?; } if let Some(unit) = &r.unit { sink.l(&iri, &utopia("unit"), &text(unit.clone()))?; } + sink.l(&iri, &utopia("updatedAt"), &dt(r.updated_at))?; + Ok(()) +} + +/// 一条公理规则(rules):推理活动的身份。规则在词汇表区**整份**导出—— +/// 撤了公理还留着的规则也是这个库的推导词表。`utopia:onPredicate` 指出 +/// 这条公理编在哪个谓词上,否则审计只看见「transitive」三个字 +pub fn emit_rule( + sink: &mut Sink, + names: &Names, + vocab: &Vocabulary, + r: &ExportRule, +) -> std::io::Result<()> { + inside(names, r.predicate_kb, "rule.predicate")?; + let iri = names.rule(r.id); + sink.r(&iri, &nn(rdf::TYPE.as_str()), &prov("Activity"))?; + sink.l(&iri, &nn(rdfs::LABEL.as_str()), &text(r.kind.clone()))?; + sink.l(&iri, &utopia("ruleKind"), &text(r.kind.clone()))?; + let p = vocab + .relation(r.predicate_id) + .ok_or_else(|| resolved("rule.predicate"))?; + let p = p.clone(); + sink.r(&iri, &utopia("onPredicate"), &p)?; + Ok(()) +} + +/// 算式树(jsonb)里 `attr` 叶子引用的谓词——jsonb 列装不下外键,导出侧的 +/// 校验就是执行层(取数时已按库挡过):每个引用都要能在本库词汇表里解析成 +/// IRI,解析不出就是坏行,拒导而不是让表达式带着一串死 uuid 出去 +fn reads_predicates( + sink: &mut Sink, + vocab: &Vocabulary, + node: &NamedNode, + raw: &serde_json::Value, + edge: &str, +) -> std::io::Result<()> { + let mut ids = Vec::new(); + utopia_store::export::expr_predicate_ids(raw, &mut ids); + let mut seen: Vec = Vec::new(); + for u in ids { + let u = u.ok_or_else(|| resolved(edge))?; + if seen.contains(&u) { + continue; + } + seen.push(u); + let p = vocab.relation(u).ok_or_else(|| resolved(edge))?.clone(); + sink.r(node, &utopia("readsPredicate"), &p)?; + } + Ok(()) +} + +/// 一条业务规则(attribute_rules,0021)。结论要能走回「凭什么推的」: +/// 看什么类、得出什么(归类还是属性值)、按什么条件——规则体本身也是 +/// 导出的内容。前件是一张表上的行:每条条件一个 `utopia:RuleCondition` +/// 节点,(group_seq, seq) 是判据的全序——同组「与」、组间「或」(0039) +pub fn emit_attribute_rule( + sink: &mut Sink, + names: &Names, + vocab: &Vocabulary, + r: &ExportAttributeRule, +) -> std::io::Result<()> { + inside(names, r.subject_type_kb, "arule.subject_type")?; + if r.conclude_type_id.is_some() { + inside(names, r.conclude_type_kb, "arule.conclude_type")?; + } + if r.conclude_predicate_id.is_some() { + inside(names, r.conclude_predicate_kb, "arule.conclude_predicate")?; + } + // 业务规则用自己的名字空间:与公理规则共用 `rule:` 会让 + // prov:wasGeneratedBy 说不清这条结论是哪类规则推的 + let iri = names.attribute_rule(r.id); + sink.r(&iri, &nn(rdf::TYPE.as_str()), &prov("Activity"))?; + sink.l(&iri, &nn(rdfs::LABEL.as_str()), &text(r.name.clone()))?; + sink.l(&iri, &utopia("ruleKind"), &text("business"))?; + if !r.description.is_empty() { + sink.l( + &iri, + &nn(rdfs::COMMENT.as_str()), + &text(r.description.clone()), + )?; + } + sink.l(&iri, &utopia("conclusion"), &text(r.conclusion.clone()))?; + let st = vocab + .class(r.subject_type_id) + .ok_or_else(|| resolved("arule.subject_type"))?; + let st = st.clone(); + sink.r(&iri, &utopia("subjectType"), &st)?; + if let Some(t) = r.conclude_type_id { + let t = vocab + .class(t) + .ok_or_else(|| resolved("arule.conclude_type"))?; + let t = t.clone(); + sink.r(&iri, &utopia("concludesType"), &t)?; + } + if let Some(p) = r.conclude_predicate_id { + let (datatype, _) = vocab.literal_shape(p); + let p = vocab + .relation(p) + .ok_or_else(|| resolved("arule.conclude_predicate"))?; + let p = p.clone(); + sink.r(&iri, &utopia("concludesPredicate"), &p)?; + if let Some(v) = &r.conclude_value { + sink.l(&iri, &utopia("concludesValue"), &literal_value(v, datatype))?; + } + } + // 计算结论的算式树(0032):原文照抄是数据面;树里 `attr` 叶子引用的 + // 谓词另外落成 readsPredicate 边——导出的每个引用都能在词汇表里解出 IRI, + // 不留一串解析不出来的死 uuid + if let Some(e) = &r.conclude_expr { + sink.l(&iri, &utopia("concludeExpr"), &text(e.to_string()))?; + reads_predicates(sink, vocab, &iri, e, "arule.expr_predicate")?; + } + // 前件(0028/0039):按 (group_seq, seq) 全序的条件行,一条一个节点。 + // utopia:condition 从这里起指**条件节点**——不再是 conclude_expr 的别名 + for c in &r.conditions { + inside(names, c.predicate_kb, "condition.predicate")?; + let cn = names.condition(c.rule_id, c.group_seq, c.seq); + sink.r(&iri, &utopia("condition"), &cn)?; + sink.r(&cn, &nn(rdf::TYPE.as_str()), &utopia("RuleCondition"))?; + sink.l(&cn, &utopia("recordId"), &text(c.id.to_string()))?; + sink.l( + &cn, + &utopia("groupSeq"), + &Literal::new_typed_literal(c.group_seq.to_string(), xsd::INTEGER), + )?; + sink.l( + &cn, + &utopia("seq"), + &Literal::new_typed_literal(c.seq.to_string(), xsd::INTEGER), + )?; + let p = vocab + .relation(c.predicate_id) + .ok_or_else(|| resolved("condition.predicate"))?; + let p = p.clone(); + sink.r(&cn, &utopia("onPredicate"), &p)?; + sink.l(&cn, &utopia("op"), &text(c.op.clone()))?; + if let Some(o) = &c.operand { + sink.l(&cn, &utopia("operand"), &text(o.to_string()))?; + if o.is_object() { + reads_predicates(sink, vocab, &cn, o, "condition.expr_predicate")?; + } + } + } + if !r.enabled { + sink.l(&iri, &utopia("disabled"), &flag(true))?; + } Ok(()) } @@ -423,11 +675,31 @@ pub fn emit_entity( &nn(rdfs::LABEL.as_str()), &text(e.canonical_name.clone()), )?; - // 类型可以没有(0009):没判出来就不写,而不是补一个 owl:Thing 充数 - if let Some(t) = e.type_id.and_then(|t| vocab.class(t)) { + // 类型可以没有(0009):没判出来(`type_id = NULL`)就不写,而不是补一个 + // owl:Thing 充数。但 id 在而词汇表里没有——别库或悬空的类——是坏行不是「没类型」 + if let Some(t) = e.type_id { + let t = vocab.class(t).ok_or_else(|| resolved("entity.type"))?; let t = t.clone(); sink.r(&iri, &nn(rdf::TYPE.as_str()), &t)?; } + sink.l(&iri, &prov("generatedAtTime"), &dt(e.created_at))?; + // 类型是谁定的:抽出来的、推出来的、还是人点的——治理与审计都要这层区分 + sink.l(&iri, &utopia("typeSource"), &text(e.type_source.clone()))?; + if let Some(t) = e.type_resolved_at { + sink.l(&iri, &utopia("typeResolvedAt"), &dt(t))?; + } + // 模型想给而本体接不住的词、它自己说的最具体的类——不导就再也不知道 + // 它觉得这是什么(只能整库重抽) + if let Some(t) = &e.proposed_type { + sink.l(&iri, &utopia("proposedType"), &text(t.clone()))?; + } + if let Some(t) = &e.specific_type { + sink.l(&iri, &utopia("specificType"), &text(t.clone()))?; + } + // 被描述、没有名字的东西的那一段(0061):无名的实体靠它说自己是什么 + if let Some(t) = &e.description { + sink.l(&iri, &nn(rdfs::COMMENT.as_str()), &text(t.clone()))?; + } Ok(()) } @@ -435,6 +707,23 @@ pub fn emit_document(sink: &mut Sink, names: &Names, d: &ExportDocument) -> std: let iri = names.document(d.id); sink.r(&iri, &nn(rdf::TYPE.as_str()), &prov("Entity"))?; sink.l(&iri, &nn(rdfs::LABEL.as_str()), &text(d.filename.clone()))?; + // 内容完整性:审计要知道这条出处对应的是哪一份字节,不只是一个文件名 + sink.l(&iri, &utopia("sha256"), &text(d.sha256.clone()))?; + sink.l(&iri, &schema("encodingFormat"), &text(d.mime.clone()))?; + sink.l( + &iri, + &utopia("sizeBytes"), + &Literal::new_typed_literal(d.size_bytes.to_string(), xsd::INTEGER), + )?; + // doc_time 的出处:是原文自己写的日期还是文件系统给的——锚的可信度不一样 + sink.l( + &iri, + &utopia("docTimeSource"), + &text(d.doc_time_source.clone()), + )?; + for t in &d.tags { + sink.l(&iri, &utopia("tag"), &text(t.clone()))?; + } if let Some(key) = &d.external_key { sink.l(&iri, &utopia("externalKey"), &text(key.clone()))?; } @@ -446,6 +735,170 @@ pub fn emit_document(sink: &mut Sink, names: &Names, d: &ExportDocument) -> std: if let Some(t) = d.deleted_at { sink.l(&iri, &prov("invalidatedAtTime"), &dt(t))?; } + // 内容被清掉的文档(0046):记录还在,字节不在了——只剩骨架的出处要看得出来 + if let Some(t) = d.purged_at { + sink.l(&iri, &utopia("purgedAt"), &dt(t))?; + } + // 字节还在等一个没配好的读取器(0063):为什么这份文档一直抽不出段落 + if let Some(r) = &d.reader_needed { + sink.l(&iri, &utopia("readerNeeded"), &text(r.clone()))?; + } + // 文档自己的日期语境(0064):它定义的期间、历法、叙述锚点——时间提及 + // 的 grade B 解算就是照它算的 + if let Some(t) = &d.time_context { + sink.l(&iri, &utopia("timeContext"), &text(t.to_string()))?; + } + if let Some(t) = d.time_context_at { + sink.l(&iri, &utopia("timeContextAt"), &dt(t))?; + } + Ok(()) +} + +/// 一个段落(chunks 表)。导出定位信息而不是原文:seq/heading/字符区间/文档版本 +/// 足够指出「这份文档的哪一处」,把 text 整个塞进导出会让出处链比图还大。 +/// 被顶掉的段落照样写:旧证据行还指着它 +pub fn emit_chunk(sink: &mut Sink, names: &Names, c: &ExportChunk) -> std::io::Result<()> { + inside(names, c.document_kb, "chunk.document")?; + let iri = names.chunk(c.id); + sink.r(&iri, &nn(rdf::TYPE.as_str()), &utopia("Chunk"))?; + let doc = names.document(c.document_id); + sink.r(&iri, &schema("isPartOf"), &doc)?; + sink.l( + &iri, + &schema("position"), + &Literal::new_typed_literal(c.seq.to_string(), xsd::INTEGER), + )?; + if let Some(h) = &c.heading { + sink.l(&iri, &utopia("heading"), &text(h.clone()))?; + } + sink.l( + &iri, + &utopia("charStart"), + &Literal::new_typed_literal(c.char_start.to_string(), xsd::INTEGER), + )?; + sink.l( + &iri, + &utopia("charEnd"), + &Literal::new_typed_literal(c.char_end.to_string(), xsd::INTEGER), + )?; + sink.l( + &iri, + &utopia("docVersion"), + &Literal::new_typed_literal(c.doc_version.to_string(), xsd::INTEGER), + )?; + // 版本行在场就把定位器绑上:(doc, version) 那个节点带着哈希与字节数—— + // 没有行的定位器是「现行版」,文档节点本身就是它 + if c.version_row { + let v = names.docversion(c.document_id, c.doc_version); + sink.r(&iri, &utopia("ofVersion"), &v)?; + } + sink.l(&iri, &prov("generatedAtTime"), &dt(c.created_at))?; + // 这段文本是谁写进账的(0063):文档自己的话、粘贴来的、OCR 出来的—— + // 同一句话来源不同,出处的分量不一样 + sink.l(&iri, &utopia("origin"), &text(c.origin.clone()))?; + if let Some(m) = &c.origin_model { + sink.l(&iri, &utopia("originModel"), &text(m.clone()))?; + } + // 来源自报的锚点(页码、时间码……0063):机器给的定位,不是文档结构 + if let Some(a) = &c.anchor { + sink.l(&iri, &utopia("anchor"), &text(a.to_string()))?; + } + // 抽取跑没跑过这一段(0039):「还没进过抽取」与「抽过但没产出」是两种状态 + if let Some(t) = c.extracted_at { + sink.l(&iri, &utopia("extractedAt"), &dt(t))?; + } + if let Some(t) = c.superseded_at { + sink.l(&iri, &prov("invalidatedAtTime"), &dt(t))?; + } + Ok(()) +} + +/// 文档的一版(document_versions):版本号、内容哈希、字节数、入库时刻。 +/// 没有它,段落与证据上的 docVersion 只是个号码——说不出那一版是哪份字节 +pub fn emit_docversion( + sink: &mut Sink, + names: &Names, + v: &ExportDocumentVersion, +) -> std::io::Result<()> { + inside(names, v.document_kb, "docversion.document")?; + let vn = names.docversion(v.document_id, v.version); + sink.r(&vn, &nn(rdf::TYPE.as_str()), &utopia("DocumentVersion"))?; + sink.l(&vn, &utopia("recordId"), &text(v.id.to_string()))?; + // 这版属于哪份文档:PROV 的 wasRevisionOf 说的正是「是那一版」 + let doc = names.document(v.document_id); + sink.r(&vn, &prov("wasRevisionOf"), &doc)?; + sink.l( + &vn, + &utopia("version"), + &Literal::new_typed_literal(v.version.to_string(), xsd::INTEGER), + )?; + // 这一版对应哪份字节:哈希与字节数是回放的根 + sink.l(&vn, &utopia("sha256"), &text(v.sha256.clone()))?; + sink.l( + &vn, + &utopia("sizeBytes"), + &Literal::new_typed_literal(v.size_bytes.to_string(), xsd::INTEGER), + )?; + sink.l(&vn, &prov("generatedAtTime"), &dt(v.ingested_at))?; + Ok(()) +} + +/// 一条证据行(fact_evidence):**配对就是这一行**。`utopia:onStatement` 指向 +/// 它支撑的具体化语句,`utopia:fromChunk` 指向它引的那段——加上行里记的 +/// quote/文档/版本,审计才能不靠服务端就把「这句陈述凭什么」走回原文。 +/// 方向是 证据→语句:一条事实可以挂多段证据,反过来没有歧义 +pub fn emit_evidence(sink: &mut Sink, names: &Names, e: &ExportEvidence) -> std::io::Result<()> { + inside(names, e.chunk_kb, "evidence.chunk")?; + if e.document_id.is_some() { + inside(names, e.document_kb, "evidence.document")?; + } + let iri = names.evidence(e.fact_id, e.chunk_id); + sink.r(&iri, &nn(rdf::TYPE.as_str()), &utopia("Evidence"))?; + let stmt = names.fact(e.fact_id); + sink.r(&iri, &utopia("onStatement"), &stmt)?; + let chunk = names.chunk(e.chunk_id); + sink.r(&iri, &utopia("fromChunk"), &chunk)?; + if let Some(q) = &e.quote { + sink.l(&iri, &utopia("quote"), &text(q.clone()))?; + } + // quote 在段落原文里的字符区间(0061):同一句话引在两段里各有出处 + if let Some(s) = e.quote_start { + sink.l( + &iri, + &utopia("quoteStart"), + &Literal::new_typed_literal(s.to_string(), xsd::INTEGER), + )?; + } + if let Some(s) = e.quote_end { + sink.l( + &iri, + &utopia("quoteEnd"), + &Literal::new_typed_literal(s.to_string(), xsd::INTEGER), + )?; + } + // 行里记的文档指针照抄:多数时候与 chunk.isPartOf 一致,不一致的行 + // (版本替换后仍指旧文档)正是审计要看见的那种 + if let Some(d) = e.document_id { + let doc = names.document(d); + sink.r(&iri, &prov("wasDerivedFrom"), &doc)?; + } + if let Some(v) = e.doc_version { + sink.l( + &iri, + &utopia("docVersion"), + &Literal::new_typed_literal(v.to_string(), xsd::INTEGER), + )?; + } + // 版本行在场就把 (文档, 版本) 定位器绑到那个版本节点上 + if e.version_row { + if let (Some(d), Some(v)) = (e.document_id, e.doc_version) { + let vn = names.docversion(d, v); + sink.r(&iri, &utopia("ofVersion"), &vn)?; + } + } + if let Some(p) = &e.proposed_predicate { + sink.l(&iri, &utopia("proposedPredicate"), &text(p.clone()))?; + } Ok(()) } @@ -457,9 +910,44 @@ pub fn emit_fact( f: &ExportFact, now: DateTime, ) -> std::io::Result<()> { + inside(names, f.subject_kb, "fact.subject")?; + if f.object_id.is_some() { + inside(names, f.object_kb, "fact.object")?; + } + if f.predicate_id.is_some() { + inside(names, f.predicate_kb, "fact.predicate")?; + } + if f.supersedes.is_some() { + inside(names, f.supersedes_kb, "fact.supersedes")?; + } + // 同库但不在导出集:主语/宾语指着已合并的实体——它的 IRI 在文件里 + // 不存在,铸过去就是一条悬空的边 + if f.subject_merged { + return Err(unexported("fact.subject(merged)")); + } + if f.object_merged { + return Err(unexported("fact.object(merged)")); + } + if f.foreign_document { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "export refused: evidence.document points outside the knowledge base", + )); + } + if f.foreign_source { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "export refused: fact.source points outside the knowledge base", + )); + } let stmt = names.fact(f.id); let subject = names.entity(f.subject_id); let predicate = f.predicate_id.and_then(|p| vocab.relation(p)).cloned(); + // predicate_id 在而词汇表查不到:别库或悬空的谓词——「没谓词」与「谓词消失」 + // 在文件里长得一样,后者是坏行必须报错,不许落到 surface_predicate 那一支去 + if f.predicate_id.is_some() && predicate.is_none() { + return Err(resolved("fact.predicate")); + } let object: Option = match (f.object_id, &f.object_value) { (Some(o), _) => Some(names.entity(o).into()), (None, Some(v)) => { @@ -471,6 +959,22 @@ pub fn emit_fact( }; sink.r(&stmt, &nn(rdf::TYPE.as_str()), &nn(rdf::STATEMENT.as_str()))?; + // 开放陈述与类型化事实同出 facts 表(0061):层不标,一条谓词为空的 + // 陈述在文件里与「弄丢了谓词的类型化事实」分不出来 + sink.l(&stmt, &utopia("statementLayer"), &text(f.layer.clone()))?; + if f.layer == "open" { + sink.r(&stmt, &nn(rdf::TYPE.as_str()), &utopia("OpenStatement"))?; + // 陈述的名字是文档自己的那个关系词,不是词汇表里的谓词 + if let Some(p) = &f.phrase { + sink.l(&stmt, &nn(rdfs::LABEL.as_str()), &text(p.clone()))?; + } + } + // 这条类型化事实从哪些陈述算出来(0067/0068):来源边一个都不许断—— + // 别库/悬空已在上面拒导,到这里的每条都能铸成本库 IRI + for s in &f.source_statements { + let src = names.fact(*s); + sink.r(&stmt, &utopia("fromStatement"), &src)?; + } sink.r(&stmt, &nn(rdf::SUBJECT.as_str()), &subject)?; match (&predicate, &f.surface_predicate) { (Some(p), _) => sink.r(&stmt, &nn(rdf::PREDICATE.as_str()), p)?, @@ -486,6 +990,17 @@ pub fn emit_fact( if f.object_value.as_ref().is_some_and(is_relative) { sink.l(&stmt, &utopia("relativeValue"), &flag(true))?; } + // 这条事实记值时用的单位(`{"value": 65, "unit": "%"}`)。关系节点上的 + // `utopia:unit` 是属性**声明**的单位;同一谓词下不同观测可能各记各的—— + // 只导字面量的话,「65%」和「65kg」读出来是同一个 "65" + if let Some(u) = f + .object_value + .as_ref() + .and_then(|v| v.get("unit")) + .and_then(|u| u.as_str()) + { + sink.l(&stmt, &utopia("unit"), &text(u.to_string()))?; + } emit_validity( sink, &stmt, @@ -494,6 +1009,25 @@ pub fn emit_fact( f.valid_to, f.valid_to_precision.as_deref(), )?; + // valid_from 是怎么来的(0069):原文写明的、按文档锚点算的、没算出来的 + if let Some(g) = &f.valid_from_grade { + sink.l(&stmt, &utopia("validFromGrade"), &text(g.clone()))?; + } + // 世界轴的锚点(0034):holds_* 投影就是按它们算的——锚不进导出,读的人 + // 复算不出「现在仍成立」那条三元组是怎么来的 + sink.l(&stmt, &utopia("attestedFrom"), &dt(f.attested_from))?; + if let Some(t) = f.attested_to { + sink.l(&stmt, &utopia("attestedTo"), &dt(t))?; + } + // 终点是引擎画的还是原文/人写明的(0057):前者会随后段挪动而重算, + // 后者不动——两者写成一个样子,读的人会拿不稳的边界当原文 + if f.end_derived { + sink.l(&stmt, &utopia("endDerived"), &flag(true))?; + } + // 旧派生标记列(无 FK、现写路径不写它):断言与派生之分必须活下去 + if f.rule_derived { + sink.l(&stmt, &utopia("ruleDerived"), &flag(true))?; + } sink.l(&stmt, &prov("generatedAtTime"), &dt(f.recorded_at))?; if let Some(t) = f.invalidated_at { sink.l(&stmt, &prov("invalidatedAtTime"), &dt(t))?; @@ -510,15 +1044,18 @@ pub fn emit_fact( for quote in &f.quotes { sink.l(&stmt, &utopia("quote"), &text(quote.clone()))?; } - // 引文从哪来(0040):审计的人要不问我们就分得清一句原文和一段看图描述(0020) + // 每条证据所在段的写入来源(0063):一份导出里混着抽取、粘贴、OCR + // 来的原句时,来源跟着语句走 for origin in &f.quote_origins { sink.l(&stmt, &utopia("evidenceOrigin"), &text(origin.clone()))?; } // 边上的属性(0037):陈述节点上各多一行,谓词是属性的 IRI,字面量按它的 datatype for q in &f.qualifiers { - let Some(p) = vocab.relation(q.qualifier_type_id) else { - continue; - }; + // 别库的属性类型在这里查不到——静默 continue 等于让这条属性从账本里 + // 不声不响地消失。查不到就是坏行:报错,不省略 + let p = vocab + .relation(q.qualifier_type_id) + .ok_or_else(|| resolved("qualifier.type"))?; if let Some(v) = &q.value { let (datatype, _) = vocab.literal_shape(q.qualifier_type_id); sink.l(&stmt, p, &literal_value(v, datatype))?; @@ -526,6 +1063,102 @@ pub fn emit_fact( sink.r(&stmt, p, &names.entity(e))?; } } + // 开放陈述自己的属性(0061):role 是文档的角色词,不是词汇表里的谓词—— + // 每条属性一个节点,值/实体恰有一个在场(表上的 XOR CHECK) + for (i, q) in f.statement_qualifiers.iter().enumerate() { + let qn = oxrdf::BlankNode::new(format!("sq-{}-{i}", q.fact_id)) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + sink.triple(TripleRef::new( + stmt.as_ref(), + utopia("statementQualifier").as_ref(), + qn.as_ref(), + ))?; + sink.triple(TripleRef::new( + qn.as_ref(), + rdf::TYPE, + utopia("StatementQualifier").as_ref(), + ))?; + sink.triple(TripleRef::new( + qn.as_ref(), + utopia("role").as_ref(), + text(q.role.clone()).as_ref(), + ))?; + if let Some(v) = &q.value { + // 值没有声明的类型(文档自己的数据):原文照抄 jsonb 的形状, + // 数字留着数字、字符串留着引号——压成字符串会把 42 与 "42" 读成一个 + sink.triple(TripleRef::new( + qn.as_ref(), + utopia("qualifierValue").as_ref(), + text(v.to_string()).as_ref(), + ))?; + } else if let Some(e) = q.entity_id { + inside(names, q.entity_kb, "squalifier.entity")?; + if q.entity_merged { + return Err(unexported("squalifier.entity(merged)")); + } + let en = names.entity(e); + sink.triple(TripleRef::new( + qn.as_ref(), + prov("value").as_ref(), + en.as_ref(), + ))?; + } + } + // 陈述里的时间词(0061/0064):valid_* 是从这些字算出来的,出处链要走到字上 + for m in &f.time_mentions { + inside(names, m.chunk_kb, "timemention.chunk")?; + let mn = names.mention(m.id); + sink.r(&stmt, &utopia("timeMention"), &mn)?; + sink.r(&mn, &nn(rdf::TYPE.as_str()), &utopia("TimeMention"))?; + sink.l(&mn, &utopia("role"), &text(m.role.clone()))?; + sink.l(&mn, &utopia("text"), &text(m.text.clone()))?; + sink.l( + &mn, + &utopia("charStart"), + &Literal::new_typed_literal(m.char_start.to_string(), xsd::INTEGER), + )?; + let cn = names.chunk(m.chunk_id); + sink.r(&mn, &utopia("onChunk"), &cn)?; + // 模型的解释(shape/reference/granularity):照存的合同词汇 + if let Some(s) = &m.shape { + sink.l(&mn, &utopia("shape"), &text(s.clone()))?; + } + if let Some(r) = &m.reference { + sink.l(&mn, &utopia("reference"), &text(r.to_string()))?; + } + if let Some(g) = &m.granularity { + sink.l(&mn, &utopia("granularity"), &text(g.clone()))?; + } + // A 原文写明 / B 按文档锚点算 / C 没算出(0069) + if let Some(g) = &m.grade { + sink.l(&mn, &utopia("grade"), &text(g.clone()))?; + } + if let Some(t) = m.resolved_from { + sink.l( + &mn, + &utopia("resolvedFrom"), + &world_time(t, m.resolved_from_precision.as_deref()), + )?; + } + if let Some(p) = &m.resolved_from_precision { + sink.l(&mn, &utopia("resolvedFromPrecision"), &text(p.clone()))?; + } + if let Some(t) = m.resolved_to { + sink.l( + &mn, + &utopia("resolvedTo"), + &world_time(t, m.resolved_to_precision.as_deref()), + )?; + } + // 'unknown' 也是合法精度(结束了,不知哪天)——精度列在场就照抄 + if let Some(p) = &m.resolved_to_precision { + sink.l(&mn, &utopia("resolvedToPrecision"), &text(p.clone()))?; + } + if let Some(t) = m.resolved_at { + sink.l(&mn, &utopia("resolvedAt"), &dt(t))?; + } + sink.l(&mn, &prov("generatedAtTime"), &dt(m.recorded_at))?; + } // 现行三元组:**仍被持有,且现在仍成立**。区间已闭合或已撤回的不写这一条, // 否则一个忽略具体化的消费者会读到「张三现在还管着那个项目」。 @@ -549,14 +1182,45 @@ pub fn emit_derived( vocab: &Vocabulary, d: &ExportDerived, ) -> std::io::Result<()> { + inside(names, d.subject_kb, "derived.subject")?; + if d.object_id.is_some() { + inside(names, d.object_kb, "derived.object")?; + } + inside(names, d.predicate_kb, "derived.predicate")?; + if d.rule_id.is_some() { + inside(names, d.rule_kb, "derived.rule")?; + } + if d.attribute_rule_id.is_some() { + inside(names, d.attribute_rule_kb, "derived.attribute_rule")?; + } + if d.subject_merged { + return Err(unexported("derived.subject(merged)")); + } + if d.object_merged { + return Err(unexported("derived.object(merged)")); + } + // 前提的归属随页原子选出时已一并带回:别库或悬空的前提 + // 不许被铸成本库 prov:used——伪造的就是这条链的身份 + if d.foreign_fact_premise { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "export refused: derivation.premise_fact points outside the knowledge base", + )); + } + if d.foreign_derived_premise { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "export refused: derivation.premise_derived points outside the knowledge base", + )); + } let stmt = names.derived(d.id); - // 推理活动的身份:公理规则有自己的 id,业务规则用它的。两者都要有一个 - // 稳定的 IRI,否则审计读到一条结论却指不出「凭什么」 + // 推理活动的身份:公理规则与业务规则各有自己的名字空间(rule:/arule:), + // 否则这条结论指出的「凭什么」说不清是谁生的 let rule = match (d.rule_id, d.attribute_rule_id) { (Some(r), _) => names.rule(r), - (None, Some(r)) => names.rule(r), - // 库里的 CHECK 保证不会两个都空;真到了这一步宁可跳过整条 - (None, None) => return Ok(()), + (None, Some(r)) => names.attribute_rule(r), + // 库里的 CHECK 保证不会两个都空;真到了这一步不是「没有规则」,是坏行 + (None, None) => return Err(resolved("derived.rule")), }; sink.r(&stmt, &nn(rdf::TYPE.as_str()), &nn(rdf::STATEMENT.as_str()))?; sink.r( @@ -566,6 +1230,9 @@ pub fn emit_derived( )?; if let Some(p) = vocab.relation(d.predicate_id).cloned() { sink.r(&stmt, &nn(rdf::PREDICATE.as_str()), &p)?; + } else { + // 派生的谓词非空:查不到就是别库/悬空的坏行,不是「没有谓词」 + return Err(resolved("derived.predicate")); } // 宾语两条通道,与断言事实同一套:实体走 IRI,字面值结论走字面量(0021) match (d.object_id, &d.object_value) { @@ -581,6 +1248,15 @@ pub fn emit_derived( (None, None) => {} } sink.l(&stmt, &utopia("derived"), &flag(true))?; + // 与断言事实同一理由:字面值里的 "unit" 不进字面量本身,得单独留一行 + if let Some(u) = d + .object_value + .as_ref() + .and_then(|v| v.get("unit")) + .and_then(|u| u.as_str()) + { + sink.l(&stmt, &utopia("unit"), &text(u.to_string()))?; + } emit_validity( sink, &stmt, @@ -595,22 +1271,27 @@ pub fn emit_derived( } sink.l(&stmt, &utopia("confidence"), &confidence(d.confidence))?; sink.r(&stmt, &prov("wasGeneratedBy"), &rule)?; - sink.r(&rule, &nn(rdf::TYPE.as_str()), &prov("Activity"))?; - // 标签用规则自己的名字(业务规则),公理退回它的种类名——审计读到的是 - // 「Gas-bearing well」而不是「business」 - sink.l( - &rule, - &nn(rdfs::LABEL.as_str()), - &text(d.rule_name.clone().unwrap_or_else(|| d.rule.clone())), - )?; - // 前提。审计顺着 prov:used 往下走一步就到断言,再走一步就到句子 - for premise in &d.premises { - let p = names.fact(*premise); - sink.r(&stmt, &prov("used"), &p)?; - } - for premise in &d.premises_derived { - let p = names.derived(*premise); - sink.r(&stmt, &prov("used"), &p)?; + // 规则节点在词汇表区整份导出一次(emit_rule / emit_attribute_rule)—— + // 这里只挂边,不再每条派生重复写一遍 rdf:type/label + for p in &d.premises { + // 序位是证明的一部分(fact_derivations.seq):prov:used 平挂只回答 + // 「结论用了它」,看不出谁在第几位——前提自己成一个节点把 seq 带上。 + // 两条都写:平挂的留着兼容读「用了什么」,premise 节点回答次序 + let target = match (p.fact_id, p.derived_id) { + (Some(f), _) => names.fact(f), + (None, Some(d2)) => names.derived(d2), + (None, None) => return Err(resolved("derivation.premise")), + }; + sink.r(&stmt, &prov("used"), &target)?; + let pn = names.premise(d.id, p.seq); + sink.r(&stmt, &utopia("premise"), &pn)?; + sink.r(&pn, &nn(rdf::TYPE.as_str()), &utopia("Premise"))?; + sink.l( + &pn, + &utopia("seq"), + &Literal::new_typed_literal(p.seq.to_string(), xsd::INTEGER), + )?; + sink.r(&pn, &prov("used"), &target)?; } Ok(()) } @@ -676,6 +1357,10 @@ fn literal_value(v: &serde_json::Value, datatype: Option<&str>) -> Literal { mod tests { use super::*; use oxrdf::Quad; + use utopia_store::export::{ + ExportDocumentVersion, ExportPremise, ExportRuleCondition, ExportStatementQualifier, + ExportTimeMention, + }; fn kb() -> Uuid { Uuid::parse_str("01a06dc4-f40a-7013-b09f-1b499e2e7441").unwrap() @@ -696,8 +1381,11 @@ mod tests { label: key.into(), description: String::new(), iri: iri.map(str::to_string), + builtin: false, parents: vec![], + primary_parents: vec![], disjoint: vec![], + updated_at: at("2026-01-01T00:00:00Z"), } } @@ -718,10 +1406,13 @@ mod tests { is_symmetric: false, is_asymmetric: false, is_irreflexive: false, + builtin: false, inverse_of: None, sub_property_of: None, + qualifiers: vec![], domains: vec![], ranges: vec![], + updated_at: at("2026-01-01T00:00:00Z"), } } @@ -734,6 +1425,13 @@ mod tests { surface_predicate: None, object_id: Some(id(11)), object_value: None, + layer: "typed".into(), + phrase: None, + source_statements: vec![], + foreign_source: false, + statement_qualifiers: vec![], + time_mentions: vec![], + valid_from_grade: None, valid_from: None, valid_from_precision: None, valid_to: None, @@ -741,6 +1439,10 @@ mod tests { // 读出来的区间(0022):没起点就从锚点起,这里让它与记下的时刻同一天 holds_from: Some(at("2026-01-01T00:00:00Z")), holds_to: None, + attested_from: at("2026-01-01T00:00:00Z"), + attested_to: None, + end_derived: false, + rule_derived: false, recorded_at: at("2026-01-01T00:00:00Z"), invalidated_at: None, confidence: 0.9, @@ -748,6 +1450,46 @@ mod tests { documents: vec![], quotes: vec![], quote_origins: vec![], + subject_kb: Some(kb()), + object_kb: Some(kb()), + predicate_kb: Some(kb()), + supersedes_kb: None, + foreign_document: false, + subject_merged: false, + object_merged: false, + } + } + + fn derived(n: u8) -> ExportDerived { + ExportDerived { + id: id(n), + subject_id: id(10), + predicate_id: id(2), + object_id: Some(id(11)), + object_value: None, + rule_id: Some(id(8)), + attribute_rule_id: None, + valid_from: None, + valid_from_precision: None, + valid_to: None, + valid_to_precision: None, + derived_at: at("2026-02-01T00:00:00Z"), + invalidated_at: None, + confidence: 0.8, + premises: vec![ExportPremise { + seq: 1, + fact_id: Some(id(5)), + derived_id: None, + }], + subject_kb: Some(kb()), + object_kb: Some(kb()), + predicate_kb: Some(kb()), + rule_kb: Some(kb()), + attribute_rule_kb: None, + foreign_fact_premise: false, + foreign_derived_premise: false, + subject_merged: false, + object_merged: false, } } @@ -832,6 +1574,7 @@ mod tests { f.documents = vec![id(20)]; f.quotes = vec!["设备 A 待复检".into()]; f.supersedes = Some(id(6)); + f.supersedes_kb = Some(kb()); for retracted in [false, true] { f.invalidated_at = retracted.then(|| at("2026-02-01T00:00:00Z")); let mut sets = Vec::new(); @@ -1160,12 +1903,39 @@ mod tests { derived_at: at("2026-02-01T00:00:00Z"), invalidated_at: None, confidence: 0.9, - rule: "business".into(), - rule_name: Some("Gas-bearing well".into()), - premises: vec![id(5)], - premises_derived: Vec::new(), + premises: vec![ExportPremise { + seq: 1, + fact_id: Some(id(5)), + derived_id: None, + }], + subject_kb: Some(kb()), + object_kb: None, + predicate_kb: Some(kb()), + rule_kb: None, + attribute_rule_kb: Some(kb()), + foreign_fact_premise: false, + foreign_derived_premise: false, + subject_merged: false, + object_merged: false, + }; + let rule = ExportAttributeRule { + id: id(9), + name: "Gas-bearing well".into(), + description: String::new(), + conclusion: "attribute".into(), + subject_type_id: id(1), + conclude_type_id: None, + conclude_predicate_id: Some(id(4)), + conclude_value: Some(serde_json::json!({"value": 0.95})), + conclude_expr: None, + enabled: true, + conditions: vec![], + subject_type_kb: Some(kb()), + conclude_type_kb: None, + conclude_predicate_kb: Some(kb()), }; let quads = export(Format::Turtle, |sink, names, vocab| { + emit_attribute_rule(sink, names, vocab, &rule).unwrap(); emit_derived(sink, names, vocab, &derived).unwrap(); }); let stmt = ""; @@ -1193,9 +1963,10 @@ mod tests { objects(&quads, stmt, "http://www.w3.org/ns/prov#used").len(), 1 ); - // 活动的标签是规则的名字 + // 活动的标签是规则的名字(规则体在词汇表区整份出一次)。 + // 业务规则在自己的名字空间里(arule:),不与公理规则共用 rule: let rule_iri = - ""; + ""; assert_eq!( objects( &quads, @@ -1205,6 +1976,142 @@ mod tests { vec!["\"Gas-bearing well\""], "推理活动要以规则名示人" ); + assert!(has( + &quads, + rule_iri, + "urn:utopia:ns:concludesValue", + "\"0.95\"^^" + )); + } + + /// 规则的前件与算式里的谓词引用(R1/R2):条件一条一个节点, + /// (group_seq, seq) 是判据的全序;表达式树里的 attr 叶子落成 + /// readsPredicate 边——导出的引用一律能在词汇表里解出 IRI + #[test] + fn a_business_rule_exports_its_conditions_and_expr_predicates() { + let headcount = id(4); + let rule = ExportAttributeRule { + id: id(9), + name: "Gas-bearing well".into(), + description: String::new(), + conclusion: "computed".into(), + subject_type_id: id(1), + conclude_type_id: None, + conclude_predicate_id: Some(headcount), + conclude_value: None, + conclude_expr: Some(serde_json::json!({ + "op": "mul", "l": {"attr": headcount.to_string()}, "r": {"const": 2} + })), + enabled: true, + conditions: vec![ExportRuleCondition { + id: id(15), + rule_id: id(9), + group_seq: 0, + seq: 1, + predicate_id: headcount, + op: "gt".into(), + operand: Some(serde_json::json!({"attr": headcount.to_string()})), + predicate_kb: Some(kb()), + }], + subject_type_kb: Some(kb()), + conclude_type_kb: None, + conclude_predicate_kb: Some(kb()), + }; + for format in [Format::Turtle, Format::JsonLd] { + let quads = export(format, |sink, names, vocab| { + emit_attribute_rule(sink, names, vocab, &rule).unwrap(); + }); + let arule = ""; + let cond = ""; + // 条件节点挂在规则上,序位是它自己的身份 + assert!(has( + &quads, + arule, + "urn:utopia:ns:condition", + "urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:condition:09090909-0909-0909-0909-090909090909:0:1" + )); + assert!(has( + &quads, + cond, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:RuleCondition" + )); + assert!(has( + &quads, + cond, + "urn:utopia:ns:seq", + "\"1\"^^" + )); + assert!(has(&quads, cond, "urn:utopia:ns:op", "\"gt\"")); + // 谓词列与算式里的 attr 引用都解成词汇表里的 IRI(本地谓词用 key 铸) + assert!(has( + &quads, + cond, + "urn:utopia:ns:onPredicate", + "urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:relation:headcount" + )); + assert!(has( + &quads, + cond, + "urn:utopia:ns:readsPredicate", + "urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:relation:headcount" + )); + assert!(has( + &quads, + arule, + "urn:utopia:ns:readsPredicate", + "urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:relation:headcount" + )); + assert_eq!( + objects(&quads, arule, "urn:utopia:ns:concludeExpr").len(), + 1 + ); + } + } + + /// 文档的一版(document_versions):(文档, 版本) 是定位器的解析目标, + /// 带着哈希与字节数——「这段出自第几版」到这里才说得出是哪份字节 + #[test] + fn a_document_version_binds_the_doc_version_locator() { + let v = ExportDocumentVersion { + id: id(16), + document_id: id(12), + version: 2, + sha256: "deadbeef".into(), + size_bytes: 4096, + ingested_at: at("2026-03-01T00:00:00Z"), + document_kb: Some(kb()), + }; + for format in [Format::Turtle, Format::JsonLd] { + let quads = export(format, |sink, names, _| { + emit_docversion(sink, names, &v).unwrap(); + let mut c = chunk(13); + c.version_row = true; + emit_chunk(sink, names, &c).unwrap(); + }); + let vn = ""; + assert!(has( + &quads, + vn, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:DocumentVersion" + )); + assert!(has( + &quads, + vn, + "http://www.w3.org/ns/prov#wasRevisionOf", + "urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:document:0c0c0c0c-0c0c-0c0c-0c0c-0c0c0c0c0c0c" + )); + assert!(has(&quads, vn, "urn:utopia:ns:sha256", "\"deadbeef\"")); + // 段落的 docVersion 定位器绑到版本节点上 + let chunk_iri = ""; + assert!(has( + &quads, + chunk_iri, + "urn:utopia:ns:ofVersion", + "urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:docversion:0c0c0c0c-0c0c-0c0c-0c0c-0c0c0c0c0c0c:2" + )); + } } #[test] @@ -1216,10 +2123,7 @@ mod tests { child.sub_property_of = Some(root.id); let mut leaf = relation(24, "leaf", None, "relation"); leaf.sub_property_of = Some(child.id); - let mut missing = relation(25, "unresolved", None, "relation"); - missing.inverse_of = Some(id(98)); - missing.sub_property_of = Some(id(99)); - let mut relations = vec![root, inverse, child, leaf, missing]; + let mut relations = vec![root, inverse, child, leaf]; let mut sets = Vec::new(); for reverse in [false, true] { if reverse { @@ -1285,10 +2189,27 @@ mod tests { derived_at: at("2026-02-01T00:00:00Z"), invalidated_at: None, confidence: 0.8, - rule: "transitive".into(), - rule_name: None, - premises: vec![id(5)], - premises_derived: vec![id(6)], + premises: vec![ + ExportPremise { + seq: 1, + fact_id: Some(id(5)), + derived_id: None, + }, + ExportPremise { + seq: 2, + fact_id: None, + derived_id: Some(id(6)), + }, + ], + subject_kb: Some(kb()), + object_kb: Some(kb()), + predicate_kb: Some(kb()), + rule_kb: Some(kb()), + attribute_rule_kb: None, + foreign_fact_premise: false, + foreign_derived_premise: false, + subject_merged: false, + object_merged: false, }; for format in [Format::Turtle, Format::JsonLd] { let quads = export(format, |sink, names, vocab| { @@ -1308,6 +2229,20 @@ mod tests { Names::new(kb(), None).unwrap().derived(id(6)).to_string() ] ); + // 序位是证明的一部分:每条前提一个节点,seq 就在节点上 + let premise_nodes = objects(&quads, stmt, "urn:utopia:ns:premise"); + assert_eq!(premise_nodes.len(), 2, "每条前提一个节点"); + let p2 = ""; + assert!(has( + &quads, + p2, + "urn:utopia:ns:seq", + "\"2\"^^" + )); + assert_eq!( + objects(&quads, p2, "http://www.w3.org/ns/prov#used"), + vec![Names::new(kb(), None).unwrap().derived(id(6)).to_string()] + ); assert!( !has(&quads, SUBJ, WORKS_FOR, OBJ), "推出来的边不写成平铺三元组:那会让人把引擎的结论当成文档里的话" @@ -1359,4 +2294,680 @@ mod tests { assert!(Names::new(kb(), Some("javascript:alert(1)")).is_err()); assert!(Names::new(kb(), Some("https://acme.example/a b")).is_err()); } + + fn chunk(n: u8) -> ExportChunk { + ExportChunk { + id: id(n), + document_id: id(12), + seq: 3, + heading: Some("Q3 review".into()), + char_start: 120, + char_end: 204, + doc_version: 2, + version_row: false, + superseded_at: None, + extracted_at: None, + origin: "stated".into(), + origin_model: None, + anchor: None, + created_at: at("2026-01-01T00:00:00Z"), + document_kb: Some(kb()), + } + } + + /// 出处链的最后一环:段落得能指出「这份文档的哪一处」。text 不进来—— + /// 定位要的是 seq/区间/版本,不是再复制一遍原文 + #[test] + fn a_chunk_carries_its_locator_back_to_the_document() { + let quads = export(Format::Turtle, |sink, names, _| { + emit_chunk(sink, names, &chunk(13)).unwrap(); + }); + let iri = ""; + assert!(has( + &quads, + iri, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:Chunk" + )); + assert_eq!( + objects(&quads, iri, "https://schema.org/isPartOf"), + vec![""] + ); + assert_eq!( + objects(&quads, iri, "https://schema.org/position"), + vec!["\"3\"^^"] + ); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:charStart"), + vec!["\"120\"^^"] + ); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:docVersion"), + vec!["\"2\"^^"] + ); + // 被顶掉的段落带撤回时刻:旧证据还指着它,读的人要知道它属于哪一版 + let mut old = chunk(14); + old.superseded_at = Some(at("2026-04-01T00:00:00Z")); + let quads = export(Format::Turtle, |sink, names, _| { + emit_chunk(sink, names, &old).unwrap(); + }); + let iri = ""; + assert_eq!( + objects(&quads, iri, "http://www.w3.org/ns/prov#invalidatedAtTime"), + vec!["\"2026-04-01T00:00:00Z\"^^"] + ); + } + + /// 配对就是这一行:语句、段落、引句绑在同一个节点上。摊成语句上两个 + /// 平行数组会把 quote 与它所属的那段拆开,出处链就断了 + #[test] + fn evidence_keeps_the_quote_attached_to_its_chunk() { + let e = ExportEvidence { + fact_id: id(5), + chunk_id: id(13), + document_id: Some(id(12)), + doc_version: Some(2), + version_row: false, + quote: Some("Lin Zhao joined Acme in 2023.".into()), + quote_start: None, + quote_end: None, + proposed_predicate: None, + chunk_kb: Some(kb()), + document_kb: Some(kb()), + }; + for format in [Format::Turtle, Format::JsonLd] { + let quads = export(format, |sink, names, _| { + emit_evidence(sink, names, &e).unwrap(); + }); + let iri = ""; + assert!(has( + &quads, + iri, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:Evidence" + )); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:onStatement"), + vec![STMT.to_string()] + ); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:fromChunk"), + vec![""] + ); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:quote"), + vec!["\"Lin Zhao joined Acme in 2023.\""] + ); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:docVersion"), + vec!["\"2\"^^"] + ); + } + } + + /// 开放陈述(0061):layer 与原文关系词、自己的属性节点、时间词节点、 + /// 类型化事实的来源边——同出 facts 表,层不标就与弄丢谓词的事实分不出来 + #[test] + fn an_open_statement_carries_its_own_ledger() { + let mut open = fact(38); + open.layer = "open".into(); + open.predicate_id = None; + open.predicate_kb = None; + open.phrase = Some("joined".into()); + open.surface_predicate = Some("employs".into()); + open.valid_from_grade = Some("B".into()); + open.statement_qualifiers = vec![ + ExportStatementQualifier { + fact_id: id(38), + role: "since".into(), + value: Some(serde_json::json!("2023-04")), + entity_id: None, + entity_kb: None, + entity_merged: false, + }, + ExportStatementQualifier { + fact_id: id(38), + role: "witness".into(), + value: None, + entity_id: Some(id(11)), + entity_kb: Some(kb()), + entity_merged: false, + }, + ]; + open.time_mentions = vec![ExportTimeMention { + id: id(40), + kb_id: kb(), + fact_id: id(38), + chunk_id: id(13), + chunk_kb: Some(kb()), + role: "when".into(), + text: "去年四月".into(), + char_start: 12, + shape: Some("point".into()), + reference: Some(serde_json::json!({"kind": "month"})), + granularity: Some("month".into()), + resolved_from: Some(at("2023-04-01T00:00:00Z")), + resolved_from_precision: Some("month".into()), + resolved_to: None, + resolved_to_precision: None, + resolved_at: None, + grade: Some("B".into()), + recorded_at: at("2026-02-15T00:00:00Z"), + }]; + let mut typed = fact(39); + typed.source_statements = vec![id(38)]; + let mut f = fact(5); + f.quote_origins = vec!["pasted".into()]; + + let quads = export(Format::Turtle, |sink, names, vocab| { + emit_fact(sink, names, vocab, &open, at("2026-06-01T00:00:00Z")).unwrap(); + emit_fact(sink, names, vocab, &typed, at("2026-06-01T00:00:00Z")).unwrap(); + emit_fact(sink, names, vocab, &f, at("2026-06-01T00:00:00Z")).unwrap(); + }); + let stmt = ""; + assert!(has( + &quads, + stmt, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:OpenStatement" + )); + assert_eq!( + objects(&quads, stmt, "urn:utopia:ns:statementLayer"), + vec!["\"open\""] + ); + // 原文关系词是这条陈述的名字,不是词汇表里的谓词 + assert_eq!( + objects(&quads, stmt, "http://www.w3.org/2000/01/rdf-schema#label"), + vec!["\"joined\""] + ); + assert_eq!( + objects(&quads, stmt, "urn:utopia:ns:proposedPredicate"), + vec!["\"employs\""] + ); + assert_eq!( + objects(&quads, stmt, "urn:utopia:ns:validFromGrade"), + vec!["\"B\""] + ); + // 来源边:类型化事实指回开放陈述 + let typed_iri = ""; + assert_eq!( + objects(&quads, typed_iri, "urn:utopia:ns:fromStatement"), + vec![ + "" + .to_string() + ] + ); + assert_eq!( + objects(&quads, STMT, "urn:utopia:ns:evidenceOrigin"), + vec!["\"pasted\""] + ); + // 属性节点:值与实体各一条,role 是文档自己的词 + let qnodes = objects(&quads, stmt, "urn:utopia:ns:statementQualifier"); + assert_eq!(qnodes.len(), 2, "每条属性一个节点"); + let has_q = |role: &str, pred: &str, obj: &str| { + qnodes.iter().any(|q| { + quads.iter().any(|x| { + x.subject.to_string() == *q + && x.predicate.to_string() == "" + && x.object.to_string() == role + }) && quads.iter().any(|x| { + x.subject.to_string() == *q + && x.predicate.to_string() == format!("<{pred}>") + && x.object.to_string() == obj + }) + }) + }; + assert!(has_q( + "\"since\"", + "urn:utopia:ns:qualifierValue", + "\"\\\"2023-04\\\"\"" + )); + assert!(has_q( + "\"witness\"", + "http://www.w3.org/ns/prov#value", + "" + )); + // 时间词节点:出处链走到字上,resolution 与等级随行 + let mn = ""; + assert_eq!( + objects(&quads, stmt, "urn:utopia:ns:timeMention"), + vec![mn.to_string()] + ); + assert!(has( + &quads, + mn, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:TimeMention" + )); + assert_eq!( + objects(&quads, mn, "urn:utopia:ns:text"), + vec!["\"去年四月\""] + ); + assert_eq!( + objects(&quads, mn, "urn:utopia:ns:onChunk"), + vec![""] + ); + assert_eq!( + objects(&quads, mn, "urn:utopia:ns:resolvedFrom"), + vec!["\"2023-04\"^^"] + ); + assert_eq!(objects(&quads, mn, "urn:utopia:ns:grade"), vec!["\"B\""]); + } + + /// 「65%」和「65kg」不能只导成同一个 "65":关系上的 utopia:unit 是声明的 + /// 单位,语句上这行是这条事实**记下**的单位,同一谓词下两条观测可以不同 + #[test] + fn an_attribute_keeps_the_unit_it_was_recorded_in() { + let mut attr = fact(5); + attr.predicate_id = Some(id(4)); + attr.object_id = None; + attr.object_value = Some(serde_json::json!({ "value": 65, "unit": "%" })); + let quads = export(Format::Turtle, |sink, names, vocab| { + emit_fact(sink, names, vocab, &attr, at("2026-06-01T00:00:00Z")).unwrap(); + }); + assert_eq!(objects(&quads, STMT, "urn:utopia:ns:unit"), vec!["\"%\""]); + // 字面量照旧只是值本身:单位不是它的一部分,是这条陈述的一部分 + assert_eq!( + objects( + &quads, + STMT, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#object" + ), + vec!["\"65\"^^"] + ); + // 没带单位的值不多写一行:语句上的 utopia:unit 永远是记下来的那个 + let mut plain = fact(5); + plain.predicate_id = Some(id(4)); + plain.object_id = None; + plain.object_value = Some(serde_json::json!({ "value": 65 })); + let quads = export(Format::Turtle, |sink, names, vocab| { + emit_fact(sink, names, vocab, &plain, at("2026-06-01T00:00:00Z")).unwrap(); + }); + assert!(objects(&quads, STMT, "urn:utopia:ns:unit").is_empty()); + } + + /// 边上的属性(0037):字面值属性按它类型的 datatype 落字面量,实体属性 + /// 落实体 IRI。两种都要到——一条不在,账本就少了一截 + #[test] + fn qualifiers_arrive_literal_and_entity() { + use utopia_core::models::FactQualifier; + let mut f = fact(5); + f.qualifiers = vec![ + FactQualifier { + qualifier_type_id: id(4), + key: "headcount".into(), + label: "headcount".into(), + value: Some(serde_json::json!({ "value": 42 })), + entity_id: None, + entity_name: None, + }, + FactQualifier { + qualifier_type_id: id(2), + key: "works_for".into(), + label: "works_for".into(), + value: None, + entity_id: Some(id(11)), + entity_name: Some("Acme".into()), + }, + ]; + for format in [Format::Turtle, Format::JsonLd] { + let quads = export(format, |sink, names, vocab| { + emit_fact(sink, names, vocab, &f, at("2026-06-01T00:00:00Z")).unwrap(); + }); + assert_eq!( + objects( + &quads, + STMT, + "urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:relation:headcount" + ), + vec!["\"42\"^^"], + "字面值属性要按类型的 datatype 落字面量" + ); + assert_eq!( + objects(&quads, STMT, WORKS_FOR), + vec![OBJ.to_string()], + "实体属性要落实体 IRI" + ); + } + } + + /// 别库的属性类型在本库词汇表里查不到——曾经静默 continue,属性就这么 + /// 从导出里消失。现在:查不到就是坏行,报错不省略 + #[test] + fn a_qualifier_whose_type_isnt_resolvable_fails_closed() { + use utopia_core::models::FactQualifier; + let names = Names::new(kb(), None).unwrap(); + let relations = vec![relation(2, "works_for", None, "relation")]; + let vocab = vocabulary(&names, &[], &relations); + let buf = SharedBuf::default(); + let mut sink = Sink::new(Format::Turtle, buf); + let mut f = fact(5); + f.qualifiers = vec![FactQualifier { + qualifier_type_id: id(99), + key: "foreign".into(), + label: "foreign".into(), + value: Some(serde_json::json!({ "value": 1 })), + entity_id: None, + entity_name: None, + }]; + let err = emit_fact(&mut sink, &names, &vocab, &f, at("2026-06-01T00:00:00Z")); + assert!(err.is_err(), "查不到的属性类型必须报错,不许静默跳过"); + + // 谓词同理:predicate_id 在而词汇表没有——别库/悬空,不许落到「没谓词」那一支 + let mut f = fact(5); + f.predicate_id = Some(id(99)); + f.predicate_kb = Some(kb()); + let buf = SharedBuf::default(); + let mut sink = Sink::new(Format::Turtle, buf); + let err = emit_fact(&mut sink, &names, &vocab, &f, at("2026-06-01T00:00:00Z")); + assert!(err.is_err(), "查不到的谓词必须报错"); + } + + /// 序列化器自己是最后一道闸:逐页校验被绕过时,归属不对的引用在这就 + /// 停下——IRI 不铸,整份报错(defense-in-depth) + #[test] + fn the_serializer_refuses_to_mint_foreign_iris() { + let names = Names::new(kb(), None).unwrap(); + let relations = vec![relation(2, "works_for", None, "relation")]; + let vocab = vocabulary(&names, &[], &relations); + let foreign = Uuid::now_v7(); + let buf = SharedBuf::default(); + let mut sink = Sink::new(Format::Turtle, buf.clone()); + + let mut f = fact(5); + f.subject_kb = Some(foreign); + assert!(emit_fact(&mut sink, &names, &vocab, &f, at("2026-06-01T00:00:00Z")).is_err()); + + let mut f = fact(5); + f.supersedes = Some(id(6)); + f.supersedes_kb = Some(foreign); + assert!(emit_fact(&mut sink, &names, &vocab, &f, at("2026-06-01T00:00:00Z")).is_err()); + + let mut f = fact(5); + f.foreign_document = true; + assert!(emit_fact(&mut sink, &names, &vocab, &f, at("2026-06-01T00:00:00Z")).is_err()); + + let mut d = derived(7); + d.foreign_fact_premise = true; + assert!(emit_derived(&mut sink, &names, &vocab, &d).is_err()); + let mut d = derived(7); + d.foreign_derived_premise = true; + assert!(emit_derived(&mut sink, &names, &vocab, &d).is_err()); + let mut d = derived(7); + d.rule_kb = Some(foreign); + assert!(emit_derived(&mut sink, &names, &vocab, &d).is_err()); + + let mut c = chunk(13); + c.document_kb = Some(foreign); + assert!(emit_chunk(&mut sink, &names, &c).is_err()); + // 悬空(NULL)同样拒:被指着的行不在,不等于「没有归属」 + c.document_kb = None; + assert!(emit_chunk(&mut sink, &names, &c).is_err()); + + let e = ExportEvidence { + fact_id: id(5), + chunk_id: id(13), + document_id: None, + doc_version: None, + version_row: false, + quote: None, + quote_start: None, + quote_end: None, + proposed_predicate: None, + chunk_kb: Some(foreign), + document_kb: None, + }; + assert!(emit_evidence(&mut sink, &names, &e).is_err()); + let _ = buf.take(); + } + + /// 账本的生命周期记到什么,导出就得带着什么: + /// 撤回的断言、作废的派生、删掉的文档、被顶掉的段落、被修正顶掉的事实—— + /// 少一行,审计读到的就是一份比账本更干净的假账 + #[test] + fn the_ledgers_lifecycle_marks_all_arrive() { + let mut retracted = fact(5); + retracted.invalidated_at = Some(at("2026-03-01T00:00:00Z")); + retracted.supersedes = Some(id(6)); + retracted.supersedes_kb = Some(kb()); + let quads = export(Format::Turtle, |sink, names, vocab| { + emit_fact(sink, names, vocab, &retracted, at("2026-06-01T00:00:00Z")).unwrap(); + }); + assert_eq!( + objects(&quads, STMT, "http://www.w3.org/ns/prov#invalidatedAtTime"), + vec!["\"2026-03-01T00:00:00Z\"^^"], + "撤回时刻必须到" + ); + assert_eq!( + objects(&quads, STMT, "urn:utopia:ns:supersedes"), + vec![""], + "supersedes 链必须到" + ); + + let mut dead = derived(7); + dead.invalidated_at = Some(at("2026-03-02T00:00:00Z")); + let quads = export(Format::Turtle, |sink, names, vocab| { + emit_derived(sink, names, vocab, &dead).unwrap(); + }); + let stmt = ""; + assert_eq!( + objects(&quads, stmt, "http://www.w3.org/ns/prov#invalidatedAtTime"), + vec!["\"2026-03-02T00:00:00Z\"^^"], + "派生作废时刻必须到" + ); + + let doc = ExportDocument { + id: id(12), + filename: "gone.md".into(), + external_key: None, + sha256: "a".repeat(64), + mime: "text/markdown".into(), + size_bytes: 42, + doc_time_source: "document".into(), + tags: vec![], + doc_time: None, + created_at: at("2026-01-01T00:00:00Z"), + deleted_at: Some(at("2026-04-01T00:00:00Z")), + purged_at: None, + reader_needed: None, + time_context: None, + time_context_at: None, + }; + let quads = export(Format::Turtle, |sink, names, _| { + emit_document(sink, names, &doc).unwrap(); + }); + let iri = ""; + assert_eq!( + objects(&quads, iri, "http://www.w3.org/ns/prov#invalidatedAtTime"), + vec!["\"2026-04-01T00:00:00Z\"^^"], + "文档删除时刻必须到" + ); + } + + /// 谓词之间的公理边本身就是语义:谁是它的逆、它细化谁、它允许哪些边属性、 + /// 它声明什么值域。少了任何一条,两个语义不同的库会导出同一张图——而且 + /// inverse_of/sub_property_of 是同表自指,指向 + /// 词汇表之外的行(别库或悬空)必须拒导而不是静默省略 + #[test] + fn a_relations_axioms_reach_the_export() { + let mut spouse = relation(20, "spouse", None, "relation"); + spouse.inverse_of = Some(id(21)); + spouse.sub_property_of = Some(id(22)); + spouse.qualifiers = vec![id(4)]; + spouse.builtin = true; + let partner = relation(21, "partner", None, "relation"); + let kin = relation(22, "kin", None, "relation"); + let mut rate = relation(23, "headcount_rate", None, "attribute"); + rate.datatype = Some("percent".into()); + + for format in [Format::Turtle, Format::JsonLd] { + let names = Names::new(kb(), None).unwrap(); + let relations = vec![ + spouse.clone(), + partner.clone(), + kin.clone(), + rate.clone(), + relation(4, "as_of", None, "attribute"), + ]; + let vocab = vocabulary(&names, &[], &relations); + let buf = SharedBuf::default(); + let mut sink = Sink::new(format, buf.clone()); + for r in &relations { + emit_relation(&mut sink, &vocab, r).unwrap(); + } + sink.finish().unwrap(); + let bytes = buf.take(); + let quads: Vec = oxrdfio::RdfParser::from_format(match format { + Format::Turtle => oxrdfio::RdfFormat::Turtle, + Format::JsonLd => oxrdfio::RdfFormat::JsonLd { + profile: oxrdfio::JsonLdProfileSet::empty(), + }, + }) + .for_slice(&bytes) + .map(|q| q.expect("导出的文件必须解析得回来")) + .collect(); + let s = ""; + let partner_iri = + ""; + let kin_iri = ""; + assert!(has( + &quads, + s, + "http://www.w3.org/2002/07/owl#inverseOf", + partner_iri + )); + assert!(has( + &quads, + s, + "http://www.w3.org/2000/01/rdf-schema#subPropertyOf", + kin_iri + )); + assert_eq!( + objects(&quads, s, "urn:utopia:ns:allowedQualifier"), + vec![Names::new(kb(), None) + .unwrap() + .mint("relation", "as_of") + .to_string()] + ); + assert!(has( + &quads, + s, + "urn:utopia:ns:builtin", + "\"true\"^^" + )); + assert_eq!( + objects( + &quads, + "", + "urn:utopia:ns:datatype" + ), + vec!["\"percent\""], + "datatype 声明本身也是语义" + ); + } + + // 逆关系指向词汇表之外的行(别库/悬空)→ 拒导,不静默省略 + let mut bad = relation(30, "widowed_from", None, "relation"); + bad.inverse_of = Some(id(99)); + let names = Names::new(kb(), None).unwrap(); + let vocab = vocabulary(&names, &[], &[bad.clone()]); + let buf = SharedBuf::default(); + let mut sink = Sink::new(Format::Turtle, buf); + assert!(emit_relation(&mut sink, &vocab, &bad).is_err()); + } + + /// 实体指着已合并的行:同库但不在导出集——不许铸 IRI,不许换目标, + /// 不许静默省略。fact 与 derived 两条边都是这个判法 + #[test] + fn a_reference_to_a_merged_entity_refuses_the_export() { + let mut f = fact(5); + f.subject_merged = true; + let names = Names::new(kb(), None).unwrap(); + let classes = vec![class(1, "person", Some("https://schema.org/Person"))]; + let relations = vec![relation(2, "works_for", None, "relation")]; + let vocab = vocabulary(&names, &classes, &relations); + let buf = SharedBuf::default(); + let mut sink = Sink::new(Format::Turtle, buf); + assert!( + emit_fact(&mut sink, &names, &vocab, &f, at("2026-06-01T00:00:00Z")).is_err(), + "merged subject must refuse the export" + ); + + let mut d = derived(7); + d.object_merged = true; + let buf = SharedBuf::default(); + let mut sink = Sink::new(Format::Turtle, buf); + assert!( + emit_derived(&mut sink, &names, &vocab, &d).is_err(), + "merged object must refuse the export" + ); + } + + /// 断言与派生之分必须活下去:旧派生标记列(无 FK)与引擎画的终点 + /// 都是「这条事实从哪来的」的一部分 + #[test] + fn a_fact_keeps_its_world_anchors_and_origin_flags() { + let mut f = fact(5); + f.attested_to = Some(at("2026-03-15T00:00:00Z")); + f.end_derived = true; + f.rule_derived = true; + for format in [Format::Turtle, Format::JsonLd] { + let quads = export(format, |sink, names, vocab| { + emit_fact(sink, names, vocab, &f, at("2026-06-01T00:00:00Z")).unwrap(); + }); + assert_eq!( + objects(&quads, STMT, "urn:utopia:ns:attestedFrom"), + vec!["\"2026-01-01T00:00:00Z\"^^"] + ); + assert_eq!( + objects(&quads, STMT, "urn:utopia:ns:attestedTo"), + vec!["\"2026-03-15T00:00:00Z\"^^"] + ); + assert!(has( + &quads, + STMT, + "urn:utopia:ns:endDerived", + "\"true\"^^" + )); + assert!(has( + &quads, + STMT, + "urn:utopia:ns:ruleDerived", + "\"true\"^^" + )); + } + } + + /// 公理规则整份进词汇表区:审计要看得见「这个库编了哪些公理」, + /// 包括一条派生都没引用过的。规则只说 kind 不够——onPredicate + /// 指出它编在哪个谓词上 + #[test] + fn a_rule_names_its_kind_and_predicate() { + let r = ExportRule { + id: id(8), + kind: "transitive".into(), + predicate_id: id(2), + predicate_kb: Some(kb()), + }; + for format in [Format::Turtle, Format::JsonLd] { + let quads = export(format, |sink, names, vocab| { + emit_rule(sink, names, vocab, &r).unwrap(); + }); + let iri = ""; + assert!(has( + &quads, + iri, + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/ns/prov#Activity" + )); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:ruleKind"), + vec!["\"transitive\""] + ); + assert_eq!( + objects(&quads, iri, "urn:utopia:ns:onPredicate"), + vec!["".to_string()] + ); + } + } } From 5e6e58c02ef331f07f5274b8e5565c4fdedce50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=97=E6=85=B6=E9=BA=9F?= Date: Sun, 20 Sep 2026 21:45:34 +0800 Subject: [PATCH 4/4] Account for every quad the export serializer emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serializer's unit tests assert individual triples; nothing checks the output as a set. Add an in-repo oracle that independently derives the expected quad set from a synthetic fixture — without calling any emit_* function — and requires exact equality in both formats, all in the default graph, with no duplicates. A declared predicate matrix lists every predicate each node kind may emit, including conditional cells; a coverage test asserts every declared cell is exercised by the fixture in both its present and absent states, and a mutation test asserts unaccounted terms fail. New ledger surfaces added by later migrations must register their predicates in the matrix, keeping the accounting complete as the contract grows. --- crates/utopia-server/src/rdf.rs | 2484 +++++++++++++++++++++++++++++++ 1 file changed, 2484 insertions(+) diff --git a/crates/utopia-server/src/rdf.rs b/crates/utopia-server/src/rdf.rs index d7e3270be..3bf2a9410 100644 --- a/crates/utopia-server/src/rdf.rs +++ b/crates/utopia-server/src/rdf.rs @@ -2970,4 +2970,2488 @@ mod tests { ); } } + + /// 全量核算:期望 **quad** 集从夹具行 + /// **独立**推出——不调任何 `emit_*`,连叶子字面量构造都另写一份; + /// 序列化器只是被比较的一方。`A == E` 一条式子:多一条、少一条、 + /// 换一面(IRI/字面量/bnode/**graph_name**——三元组投影会漏过 + /// named-graph 变种)都算失败。每个声明过的矩阵格——包括实体/事实 + /// 节点上每条词汇表关系 IRI 那一格——都必须登记,哪怕期望集是空的: + /// 漏登记是 oracle 自己的洞,登记了却从没非空过是夹具的洞——`the_fixture_exercises_every_declared_conditional_cell` + /// 按 CONDITIONAL_CELLS 逐格断言真假两支都被打过。 + mod oracle { + use super::super::*; + use super::{at, chunk, class, derived, fact, id, kb, relation}; + use oxrdf::{GraphName, NamedOrBlankNode, Quad, Term}; + use std::collections::{BTreeSet, HashMap, HashSet}; + use utopia_core::models::FactQualifier; + use utopia_store::export::{ + ExportAttributeRule, ExportDocument, ExportDocumentVersion, ExportEntity, + ExportEvidence, ExportPremise, ExportRule, ExportRuleCondition, + ExportStatementQualifier, ExportTimeMention, + }; + + const NOW: &str = "2026-06-01T00:00:00Z"; + + fn now() -> DateTime { + at(NOW) + } + + // ---------- 命名与叶子字面量:合约的独立重述 ---------- + fn mint(kind: &str, ident: &str) -> NamedNode { + NamedNode::new(format!("urn:utopia:kb:{}:{kind}:{ident}", kb())).unwrap() + } + + fn t_dt(t: DateTime) -> Term { + Literal::new_typed_literal( + t.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true), + xsd::DATE_TIME, + ) + .into() + } + + fn t_str(s: impl Into) -> Term { + Literal::new_simple_literal(s.into()).into() + } + + fn t_int(i: i64) -> Term { + Literal::new_typed_literal(i.to_string(), xsd::INTEGER).into() + } + + fn t_dec(c: f32) -> Term { + Literal::new_typed_literal(format!("{c:.2}"), xsd::DECIMAL).into() + } + + fn t_flag() -> Term { + Literal::new_typed_literal("true", xsd::BOOLEAN).into() + } + + /// 世界时间按精度落字面值:year→gYear,month→gYearMonth, + /// 小时以下→截到秒的 dateTime,其余→date + fn t_world(t: DateTime, prec: Option<&str>) -> Term { + let iso = t.format("%Y-%m-%d").to_string(); + match prec { + Some("year") => Literal::new_typed_literal(iso[..4].to_string(), xsd::G_YEAR), + Some("month") => { + Literal::new_typed_literal(iso[..7].to_string(), xsd::G_YEAR_MONTH) + } + Some("hour" | "minute" | "second") => Literal::new_typed_literal( + t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + xsd::DATE_TIME, + ), + _ => Literal::new_typed_literal(iso, xsd::DATE), + } + .into() + } + + /// 属性字面值:{"value":…}/{"summary":…};relative:true → 普通字符串; + /// number→decimal, date→date, bool→boolean, 其余→string + fn t_value(v: &serde_json::Value, datatype: Option<&str>) -> Term { + let raw = v.get("value").unwrap_or(v); + let text = match raw { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Null => v + .get("summary") + .and_then(|s| s.as_str()) + .unwrap_or_default() + .to_string(), + other => other.to_string(), + }; + let ty = if v.get("relative").and_then(|r| r.as_bool()) == Some(true) { + xsd::STRING + } else { + match datatype { + Some("number") => xsd::DECIMAL, + Some("date") => xsd::DATE, + Some("bool") => xsd::BOOLEAN, + _ => xsd::STRING, + } + }; + Literal::new_typed_literal(text, ty).into() + } + + /// 算式树里的 attr 叶子(与取数侧 expr_predicate_ids 同一合约的独立实现) + fn expr_attrs(v: &serde_json::Value) -> BTreeSet { + fn walk(v: &serde_json::Value, out: &mut BTreeSet) { + let Some(o) = v.as_object() else { return }; + if let Some(a) = o.get("attr").and_then(|a| a.as_str()) { + if let Ok(u) = Uuid::parse_str(a) { + out.insert(u); + } + } else if o.contains_key("const") { + } else if o.contains_key("op") { + if let Some(l) = o.get("l") { + walk(l, out); + } + if let Some(r) = o.get("r") { + walk(r, out); + } + } + } + let mut out = BTreeSet::new(); + walk(v, &mut out); + out + } + + // ---------- 矩阵:每种节点声明的谓词格 ---------- + + fn nn2(iri: &str) -> NamedNode { + NamedNode::new(iri).unwrap() + } + + fn fixed_preds(kind: &str) -> &'static [&'static str] { + match kind { + "entity" => &[ + "http://www.w3.org/2000/01/rdf-schema#label", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#comment", + "http://www.w3.org/ns/prov#generatedAtTime", + "urn:utopia:ns:typeSource", + "urn:utopia:ns:typeResolvedAt", + "urn:utopia:ns:proposedType", + "urn:utopia:ns:specificType", + ], + "fact" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#label", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#subject", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#object", + "urn:utopia:ns:statementLayer", + "urn:utopia:ns:fromStatement", + "urn:utopia:ns:supersedes", + "urn:utopia:ns:proposedPredicate", + "urn:utopia:ns:relativeValue", + "urn:utopia:ns:unit", + "https://schema.org/validFrom", + "urn:utopia:ns:validFromPrecision", + "urn:utopia:ns:validFromGrade", + "https://schema.org/validThrough", + "urn:utopia:ns:validThroughPrecision", + "urn:utopia:ns:endedUnknown", + "urn:utopia:ns:attestedFrom", + "urn:utopia:ns:attestedTo", + "urn:utopia:ns:endDerived", + "urn:utopia:ns:ruleDerived", + "http://www.w3.org/ns/prov#generatedAtTime", + "http://www.w3.org/ns/prov#invalidatedAtTime", + "urn:utopia:ns:confidence", + "http://www.w3.org/ns/prov#wasDerivedFrom", + "urn:utopia:ns:quote", + "urn:utopia:ns:evidenceOrigin", + "urn:utopia:ns:statementQualifier", + "urn:utopia:ns:timeMention", + ], + "squalifier" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:role", + "urn:utopia:ns:qualifierValue", + "http://www.w3.org/ns/prov#value", + ], + "timemention" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:role", + "urn:utopia:ns:text", + "urn:utopia:ns:charStart", + "urn:utopia:ns:onChunk", + "urn:utopia:ns:shape", + "urn:utopia:ns:reference", + "urn:utopia:ns:granularity", + "urn:utopia:ns:grade", + "urn:utopia:ns:resolvedFrom", + "urn:utopia:ns:resolvedFromPrecision", + "urn:utopia:ns:resolvedTo", + "urn:utopia:ns:resolvedToPrecision", + "urn:utopia:ns:resolvedAt", + "http://www.w3.org/ns/prov#generatedAtTime", + ], + "derived" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#subject", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#object", + "urn:utopia:ns:derived", + "urn:utopia:ns:unit", + "https://schema.org/validFrom", + "urn:utopia:ns:validFromPrecision", + "https://schema.org/validThrough", + "urn:utopia:ns:validThroughPrecision", + "urn:utopia:ns:endedUnknown", + "http://www.w3.org/ns/prov#generatedAtTime", + "http://www.w3.org/ns/prov#invalidatedAtTime", + "urn:utopia:ns:confidence", + "http://www.w3.org/ns/prov#wasGeneratedBy", + "http://www.w3.org/ns/prov#used", + "urn:utopia:ns:premise", + ], + "premise" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:seq", + "http://www.w3.org/ns/prov#used", + ], + "class" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#label", + "http://www.w3.org/2000/01/rdf-schema#comment", + "urn:utopia:ns:builtin", + "urn:utopia:ns:updatedAt", + "http://www.w3.org/2000/01/rdf-schema#subClassOf", + "urn:utopia:ns:primaryType", + "http://www.w3.org/2002/07/owl#disjointWith", + ], + "relation" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#label", + "http://www.w3.org/2000/01/rdf-schema#comment", + "urn:utopia:ns:temporal", + "urn:utopia:ns:builtin", + "urn:utopia:ns:updatedAt", + "http://www.w3.org/2002/07/owl#inverseOf", + "http://www.w3.org/2000/01/rdf-schema#subPropertyOf", + "urn:utopia:ns:allowedQualifier", + "http://www.w3.org/2000/01/rdf-schema#domain", + "http://www.w3.org/2000/01/rdf-schema#range", + "urn:utopia:ns:datatype", + "urn:utopia:ns:unit", + ], + "rule" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#label", + "urn:utopia:ns:ruleKind", + "urn:utopia:ns:onPredicate", + ], + "arule" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#label", + "urn:utopia:ns:ruleKind", + "http://www.w3.org/2000/01/rdf-schema#comment", + "urn:utopia:ns:conclusion", + "urn:utopia:ns:subjectType", + "urn:utopia:ns:concludesType", + "urn:utopia:ns:concludesPredicate", + "urn:utopia:ns:concludesValue", + "urn:utopia:ns:concludeExpr", + "urn:utopia:ns:readsPredicate", + "urn:utopia:ns:condition", + "urn:utopia:ns:disabled", + ], + "condition" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:recordId", + "urn:utopia:ns:groupSeq", + "urn:utopia:ns:seq", + "urn:utopia:ns:onPredicate", + "urn:utopia:ns:op", + "urn:utopia:ns:operand", + "urn:utopia:ns:readsPredicate", + ], + "document" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#label", + "urn:utopia:ns:sha256", + "https://schema.org/encodingFormat", + "urn:utopia:ns:sizeBytes", + "urn:utopia:ns:docTimeSource", + "urn:utopia:ns:tag", + "urn:utopia:ns:externalKey", + "https://schema.org/datePublished", + "http://www.w3.org/ns/prov#generatedAtTime", + "http://www.w3.org/ns/prov#invalidatedAtTime", + "urn:utopia:ns:purgedAt", + "urn:utopia:ns:readerNeeded", + "urn:utopia:ns:timeContext", + "urn:utopia:ns:timeContextAt", + ], + "chunk" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "https://schema.org/isPartOf", + "https://schema.org/position", + "urn:utopia:ns:heading", + "urn:utopia:ns:charStart", + "urn:utopia:ns:charEnd", + "urn:utopia:ns:docVersion", + "urn:utopia:ns:ofVersion", + "urn:utopia:ns:origin", + "urn:utopia:ns:originModel", + "urn:utopia:ns:anchor", + "http://www.w3.org/ns/prov#generatedAtTime", + "urn:utopia:ns:extractedAt", + "http://www.w3.org/ns/prov#invalidatedAtTime", + ], + "evidence" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:onStatement", + "urn:utopia:ns:fromChunk", + "urn:utopia:ns:quote", + "urn:utopia:ns:quoteStart", + "urn:utopia:ns:quoteEnd", + "http://www.w3.org/ns/prov#wasDerivedFrom", + "urn:utopia:ns:docVersion", + "urn:utopia:ns:ofVersion", + "urn:utopia:ns:proposedPredicate", + ], + "docversion" => &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "urn:utopia:ns:recordId", + "http://www.w3.org/ns/prov#wasRevisionOf", + "urn:utopia:ns:version", + "urn:utopia:ns:sha256", + "urn:utopia:ns:sizeBytes", + "http://www.w3.org/ns/prov#generatedAtTime", + ], + _ => panic!("unknown node kind {kind}"), + } + } + + /// 该种节点声明的全部谓词格。entity/fact 两种节点上,词汇表里每条 + /// 关系 IRI 各占一格(live triple / qualifier)——没登记就是 oracle 的洞 + fn matrix(kind: &str, rel_iris: &[NamedNode]) -> BTreeSet { + let mut set: BTreeSet = fixed_preds(kind).iter().map(|p| nn2(p)).collect(); + if matches!(kind, "entity" | "fact") { + set.extend(rel_iris.iter().cloned()); + } + set + } + + #[derive(Default)] + struct Expected { + triples: HashSet<(NamedOrBlankNode, NamedNode, Term)>, + cells: usize, + /// 分支覆盖观测:(kind, cell_key)。cell_key 是谓词 IRI;entity/fact + /// 上按词汇表关系展开的动态格收敛成一个 "*rel"——条件分支的粒度是 + /// 「这种格的发射支路」,按 IRI 逐格要覆盖只会产生噪音(eternal + /// 谓词结构上不可能有现行边) + non_empty: HashSet<(String, String)>, + empty: HashSet<(String, String)>, + /// 同一格在一节点上登记过 ≥2 个 term(钉「旗标多写一个成员」这类支路) + multi: HashSet<(String, String)>, + } + + impl Expected { + /// 登记一个节点的全部格:谓词集必须恰好等于该种别的声明集—— + /// 少一格或多一格都是 oracle 自己的 bug,不是「没检查到」 + fn node( + &mut self, + kind: &str, + subject: impl Into, + cells: Vec<(NamedNode, Vec)>, + rel_iris: &[NamedNode], + ) { + let subject: NamedOrBlankNode = subject.into(); + let want = matrix(kind, rel_iris); + let got: BTreeSet = cells.iter().map(|c| c.0.clone()).collect(); + assert_eq!( + want, got, + "{kind} {subject}: registered cells must cover the declared matrix" + ); + for (p, terms) in cells { + self.cells += 1; + let key = if matches!(kind, "entity" | "fact") && rel_iris.contains(&p) { + "*rel".to_string() + } else { + p.as_str().to_string() + }; + if terms.is_empty() { + self.empty.insert((kind.to_string(), key)); + } else { + if terms.len() > 1 { + self.multi.insert((kind.to_string(), key.clone())); + } + self.non_empty.insert((kind.to_string(), key)); + } + for o in terms { + assert!( + self.triples.insert((subject.clone(), p.clone(), o)), + "two cells produced the same expected triple ({subject} {p})" + ); + } + } + } + } + + fn rel_iri(r: &ExportRelation) -> NamedNode { + r.iri + .as_deref() + .and_then(|i| NamedNode::new(i).ok()) + .unwrap_or_else(|| mint("relation", &r.key)) + } + + fn class_iri(c: &ExportClass) -> NamedNode { + c.iri + .as_deref() + .and_then(|i| NamedNode::new(i).ok()) + .unwrap_or_else(|| mint("class", &c.key)) + } + + /// 期望集构造:每个节点的每一格都从夹具行推出,条件格一律 + /// `exact_set if cond else {}`——不做存在性等价 + fn expected(fx: &Fx) -> Expected { + let rel: HashMap = + fx.relations.iter().map(|r| (r.id, r)).collect(); + let cls: HashMap = fx.classes.iter().map(|c| (c.id, c)).collect(); + let rel_iris: Vec = fx.relations.iter().map(rel_iri).collect(); + let rel_dt = |id: Uuid| rel.get(&id).and_then(|r| r.datatype.as_deref()); + let dv_keys: HashSet<(Uuid, i32)> = fx + .docversions + .iter() + .map(|v| (v.document_id, v.version)) + .collect(); + let now = now(); + let mut x = Expected::default(); + + // 现行三元组:仍被持有且现在仍成立——holds_from 必须在场且 <= now, + // holds_to 空或 > now;无 holds_from(eternal 投影)不算现行 + let mut live: HashMap<(Uuid, Uuid), HashSet> = HashMap::new(); + for f in &fx.facts { + let held = f.invalidated_at.is_none(); + let holds = + f.holds_from.is_some_and(|t| t <= now) && f.holds_to.is_none_or(|t| t > now); + let obj = match (f.object_id, &f.object_value) { + (Some(o), _) => Some(mint("entity", &o.to_string()).into()), + (None, Some(v)) => Some(t_value(v, f.predicate_id.and_then(|p| rel_dt(p)))), + _ => None, + }; + if held && holds { + if let (Some(p), Some(o)) = (f.predicate_id, obj) { + live.entry((f.subject_id, p)).or_default().insert(o); + } + } + } + + for e in &fx.entities { + let s = mint("entity", &e.id.to_string()); + let mut cells = vec![ + ( + nn2("http://www.w3.org/2000/01/rdf-schema#label"), + vec![t_str(e.canonical_name.clone())], + ), + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + e.type_id + .map(|t| t_iri_of(class_iri(cls[&t]))) + .into_iter() + .collect(), + ), + ( + nn2("http://www.w3.org/ns/prov#generatedAtTime"), + vec![t_dt(e.created_at)], + ), + ( + nn2("urn:utopia:ns:typeSource"), + vec![t_str(e.type_source.clone())], + ), + ( + nn2("urn:utopia:ns:typeResolvedAt"), + e.type_resolved_at.map(t_dt).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:proposedType"), + e.proposed_type.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:specificType"), + e.specific_type.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#comment"), + e.description.as_deref().map(t_str).into_iter().collect(), + ), + ]; + for (rid, r) in &rel { + cells.push(( + rel_iri(r), + live.get(&(e.id, *rid)) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(), + )); + } + x.node("entity", s, cells, &rel_iris); + } + + for f in &fx.facts { + let s = mint("fact", &f.id.to_string()); + let obj: Vec = match (f.object_id, &f.object_value) { + (Some(o), _) => vec![mint("entity", &o.to_string()).into()], + (None, Some(v)) => { + vec![t_value(v, f.predicate_id.and_then(|p| rel_dt(p)))] + } + _ => vec![], + }; + let mut cells = vec![ + (nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), { + let mut ts = vec![t_iri_of(nn2( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#Statement", + ))]; + if f.layer == "open" { + ts.push(t_iri_of(nn2("urn:utopia:ns:OpenStatement"))); + } + ts + }), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#label"), + if f.layer == "open" { + f.phrase.as_deref().map(t_str).into_iter().collect() + } else { + vec![] + }, + ), + ( + nn2("urn:utopia:ns:statementLayer"), + vec![t_str(f.layer.clone())], + ), + ( + nn2("urn:utopia:ns:fromStatement"), + f.source_statements + .iter() + .map(|s| t_iri_of(mint("fact", &s.to_string()))) + .collect(), + ), + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#subject"), + vec![t_iri_of(mint("entity", &f.subject_id.to_string()))], + ), + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate"), + f.predicate_id + .map(|p| t_iri_of(rel_iri(rel[&p]))) + .into_iter() + .collect(), + ), + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#object"), + obj, + ), + ( + nn2("urn:utopia:ns:supersedes"), + f.supersedes + .map(|o| t_iri_of(mint("fact", &o.to_string()))) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:proposedPredicate"), + if f.predicate_id.is_none() { + f.surface_predicate + .as_deref() + .map(t_str) + .into_iter() + .collect() + } else { + vec![] + }, + ), + ( + nn2("urn:utopia:ns:relativeValue"), + if f.object_value + .as_ref() + .and_then(|v| v.get("relative")) + .and_then(|r| r.as_bool()) + == Some(true) + { + vec![t_flag()] + } else { + vec![] + }, + ), + ( + nn2("urn:utopia:ns:unit"), + f.object_value + .as_ref() + .and_then(|v| v.get("unit")) + .and_then(|u| u.as_str()) + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("https://schema.org/validFrom"), + f.valid_from + .map(|t| t_world(t, f.valid_from_precision.as_deref())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:validFromPrecision"), + f.valid_from + .and_then(|_| f.valid_from_precision.as_deref()) + .filter(|p| matches!(*p, "hour" | "minute" | "second")) + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:validFromGrade"), + f.valid_from_grade + .as_deref() + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("https://schema.org/validThrough"), + f.valid_to + .map(|t| t_world(t, f.valid_to_precision.as_deref())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:validThroughPrecision"), + f.valid_to + .and_then(|_| f.valid_to_precision.as_deref()) + .filter(|p| matches!(*p, "hour" | "minute" | "second")) + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:endedUnknown"), + if f.valid_to.is_none() + && f.valid_to_precision.as_deref() == Some("unknown") + { + vec![t_flag()] + } else { + vec![] + }, + ), + ( + nn2("urn:utopia:ns:attestedFrom"), + vec![t_dt(f.attested_from)], + ), + ( + nn2("urn:utopia:ns:attestedTo"), + f.attested_to.map(t_dt).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:endDerived"), + if f.end_derived { + vec![t_flag()] + } else { + vec![] + }, + ), + ( + nn2("urn:utopia:ns:ruleDerived"), + if f.rule_derived { + vec![t_flag()] + } else { + vec![] + }, + ), + ( + nn2("http://www.w3.org/ns/prov#generatedAtTime"), + vec![t_dt(f.recorded_at)], + ), + ( + nn2("http://www.w3.org/ns/prov#invalidatedAtTime"), + f.invalidated_at.map(t_dt).into_iter().collect(), + ), + (nn2("urn:utopia:ns:confidence"), vec![t_dec(f.confidence)]), + ( + nn2("http://www.w3.org/ns/prov#wasDerivedFrom"), + f.documents + .iter() + .map(|d| t_iri_of(mint("document", &d.to_string()))) + .collect(), + ), + ( + nn2("urn:utopia:ns:quote"), + f.quotes.iter().map(|q| t_str(q.clone())).collect(), + ), + ( + nn2("urn:utopia:ns:evidenceOrigin"), + f.quote_origins.iter().map(|o| t_str(o.clone())).collect(), + ), + ( + nn2("urn:utopia:ns:statementQualifier"), + f.statement_qualifiers + .iter() + .enumerate() + .map(|(i, q)| { + oxrdf::BlankNode::new(format!("sq-{}-{i}", q.fact_id)) + .unwrap() + .into() + }) + .collect(), + ), + ( + nn2("urn:utopia:ns:timeMention"), + f.time_mentions + .iter() + .map(|m| t_iri_of(mint("timemention", &m.id.to_string()))) + .collect(), + ), + ]; + // 边上的属性(0037):每条词汇表关系一格;序列化先 value 后 + // entity_id——两列都在时字面量赢 + for (rid, r) in &rel { + let terms: Vec = f + .qualifiers + .iter() + .filter(|q| q.qualifier_type_id == *rid) + .filter_map(|q| { + if let Some(v) = &q.value { + Some(t_value(v, rel_dt(q.qualifier_type_id))) + } else { + q.entity_id.map(|e| mint("entity", &e.to_string()).into()) + } + }) + .collect(); + cells.push((rel_iri(r), terms)); + } + x.node("fact", s, cells, &rel_iris); + // 开放陈述的属性节点(bnode):与序列化同一规则——fact_id + 序位 + for (i, q) in f.statement_qualifiers.iter().enumerate() { + let qn = oxrdf::BlankNode::new(format!("sq-{}-{i}", q.fact_id)).unwrap(); + x.node( + "squalifier", + NamedOrBlankNode::from(qn), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("urn:utopia:ns:StatementQualifier"))], + ), + (nn2("urn:utopia:ns:role"), vec![t_str(q.role.clone())]), + ( + nn2("urn:utopia:ns:qualifierValue"), + q.value + .as_ref() + .map(|v| t_str(v.to_string())) + .into_iter() + .collect(), + ), + ( + nn2("http://www.w3.org/ns/prov#value"), + q.entity_id + .map(|e| t_iri_of(mint("entity", &e.to_string()))) + .into_iter() + .collect(), + ), + ], + &rel_iris, + ); + } + // 陈述里的时间词节点:resolution 与出处一起走 + for m in &f.time_mentions { + x.node( + "timemention", + mint("timemention", &m.id.to_string()), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("urn:utopia:ns:TimeMention"))], + ), + (nn2("urn:utopia:ns:role"), vec![t_str(m.role.clone())]), + (nn2("urn:utopia:ns:text"), vec![t_str(m.text.clone())]), + ( + nn2("urn:utopia:ns:charStart"), + vec![t_int(m.char_start as i64)], + ), + ( + nn2("urn:utopia:ns:onChunk"), + vec![t_iri_of(mint("chunk", &m.chunk_id.to_string()))], + ), + ( + nn2("urn:utopia:ns:shape"), + m.shape.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:reference"), + m.reference + .as_ref() + .map(|r| t_str(r.to_string())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:granularity"), + m.granularity.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:grade"), + m.grade.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:resolvedFrom"), + m.resolved_from + .map(|t| t_world(t, m.resolved_from_precision.as_deref())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:resolvedFromPrecision"), + m.resolved_from_precision + .as_deref() + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:resolvedTo"), + m.resolved_to + .map(|t| t_world(t, m.resolved_to_precision.as_deref())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:resolvedToPrecision"), + m.resolved_to_precision + .as_deref() + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:resolvedAt"), + m.resolved_at.map(t_dt).into_iter().collect(), + ), + ( + nn2("http://www.w3.org/ns/prov#generatedAtTime"), + vec![t_dt(m.recorded_at)], + ), + ], + &rel_iris, + ); + } + } + + for d in &fx.derived { + let s = mint("derived", &d.id.to_string()); + let obj: Vec = match (d.object_id, &d.object_value) { + (Some(o), _) => vec![mint("entity", &o.to_string()).into()], + (None, Some(v)) => vec![t_value(v, rel_dt(d.predicate_id))], + _ => vec![], + }; + let gen = d + .rule_id + .map(|r| mint("rule", &r.to_string())) + .unwrap_or_else(|| mint("arule", &d.attribute_rule_id.unwrap().to_string())); + x.node( + "derived", + s, + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2( + "http://www.w3.org/1999/02/22-rdf-syntax-ns#Statement", + ))], + ), + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#subject"), + vec![t_iri_of(mint("entity", &d.subject_id.to_string()))], + ), + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate"), + vec![t_iri_of(rel_iri(rel[&d.predicate_id]))], + ), + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#object"), + obj, + ), + (nn2("urn:utopia:ns:derived"), vec![t_flag()]), + ( + nn2("urn:utopia:ns:unit"), + d.object_value + .as_ref() + .and_then(|v| v.get("unit")) + .and_then(|u| u.as_str()) + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("https://schema.org/validFrom"), + d.valid_from + .map(|t| t_world(t, d.valid_from_precision.as_deref())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:validFromPrecision"), + d.valid_from + .and_then(|_| d.valid_from_precision.as_deref()) + .filter(|p| matches!(*p, "hour" | "minute" | "second")) + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("https://schema.org/validThrough"), + d.valid_to + .map(|t| t_world(t, d.valid_to_precision.as_deref())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:validThroughPrecision"), + d.valid_to + .and_then(|_| d.valid_to_precision.as_deref()) + .filter(|p| matches!(*p, "hour" | "minute" | "second")) + .map(t_str) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:endedUnknown"), + if d.valid_to.is_none() + && d.valid_to_precision.as_deref() == Some("unknown") + { + vec![t_flag()] + } else { + vec![] + }, + ), + ( + nn2("http://www.w3.org/ns/prov#generatedAtTime"), + vec![t_dt(d.derived_at)], + ), + ( + nn2("http://www.w3.org/ns/prov#invalidatedAtTime"), + d.invalidated_at.map(t_dt).into_iter().collect(), + ), + (nn2("urn:utopia:ns:confidence"), vec![t_dec(d.confidence)]), + ( + nn2("http://www.w3.org/ns/prov#wasGeneratedBy"), + vec![t_iri_of(gen)], + ), + ( + nn2("http://www.w3.org/ns/prov#used"), + d.premises + .iter() + .map(|p| t_iri_of(premise_target(p))) + .collect(), + ), + ( + nn2("urn:utopia:ns:premise"), + d.premises + .iter() + .map(|p| t_iri_of(mint("premise", &format!("{}:{}", d.id, p.seq)))) + .collect(), + ), + ], + &rel_iris, + ); + for p in &d.premises { + x.node( + "premise", + mint("premise", &format!("{}:{}", d.id, p.seq)), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("urn:utopia:ns:Premise"))], + ), + (nn2("urn:utopia:ns:seq"), vec![t_int(p.seq as i64)]), + ( + nn2("http://www.w3.org/ns/prov#used"), + vec![t_iri_of(premise_target(p))], + ), + ], + &rel_iris, + ); + } + } + + for c in &fx.classes { + x.node( + "class", + class_iri(c), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("http://www.w3.org/2002/07/owl#Class"))], + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#label"), + vec![t_str(c.label.clone())], + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#comment"), + if c.description.is_empty() { + vec![] + } else { + vec![t_str(c.description.clone())] + }, + ), + ( + nn2("urn:utopia:ns:builtin"), + if c.builtin { vec![t_flag()] } else { vec![] }, + ), + (nn2("urn:utopia:ns:updatedAt"), vec![t_dt(c.updated_at)]), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#subClassOf"), + c.parents + .iter() + .map(|p| t_iri_of(class_iri(cls[p]))) + .collect(), + ), + ( + nn2("urn:utopia:ns:primaryType"), + c.primary_parents + .iter() + .map(|p| t_iri_of(class_iri(cls[p]))) + .collect(), + ), + ( + nn2("http://www.w3.org/2002/07/owl#disjointWith"), + c.disjoint + .iter() + .map(|p| t_iri_of(class_iri(cls[p]))) + .collect(), + ), + ], + &rel_iris, + ); + } + + for r in &fx.relations { + let mut types = vec![t_iri_of(nn2(if r.kind == "attribute" { + "http://www.w3.org/2002/07/owl#DatatypeProperty" + } else { + "http://www.w3.org/2002/07/owl#ObjectProperty" + }))]; + for (on, t) in [ + (r.functional, "FunctionalProperty"), + (r.inverse_functional, "InverseFunctionalProperty"), + (r.is_transitive, "TransitiveProperty"), + (r.is_symmetric, "SymmetricProperty"), + (r.is_asymmetric, "AsymmetricProperty"), + (r.is_irreflexive, "IrreflexiveProperty"), + ] { + if on { + types.push(t_iri_of(nn2(&format!("http://www.w3.org/2002/07/owl#{t}")))); + } + } + x.node( + "relation", + rel_iri(r), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + types, + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#label"), + vec![t_str(r.label.clone())], + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#comment"), + if r.description.is_empty() { + vec![] + } else { + vec![t_str(r.description.clone())] + }, + ), + ( + nn2("urn:utopia:ns:temporal"), + if r.temporal == "state" { + vec![] + } else { + vec![t_str(r.temporal.clone())] + }, + ), + ( + nn2("urn:utopia:ns:builtin"), + if r.builtin { vec![t_flag()] } else { vec![] }, + ), + (nn2("urn:utopia:ns:updatedAt"), vec![t_dt(r.updated_at)]), + ( + nn2("http://www.w3.org/2002/07/owl#inverseOf"), + r.inverse_of + .map(|i| t_iri_of(rel_iri(rel[&i]))) + .into_iter() + .collect(), + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#subPropertyOf"), + r.sub_property_of + .map(|i| t_iri_of(rel_iri(rel[&i]))) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:allowedQualifier"), + r.qualifiers + .iter() + .map(|q| t_iri_of(rel_iri(rel[q]))) + .collect(), + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#domain"), + r.domains + .iter() + .map(|d| t_iri_of(class_iri(cls[d]))) + .collect(), + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#range"), + r.ranges + .iter() + .map(|d| t_iri_of(class_iri(cls[d]))) + .collect(), + ), + ( + nn2("urn:utopia:ns:datatype"), + r.datatype.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:unit"), + r.unit.as_deref().map(t_str).into_iter().collect(), + ), + ], + &rel_iris, + ); + } + + for r in &fx.rules { + x.node( + "rule", + mint("rule", &r.id.to_string()), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("http://www.w3.org/ns/prov#Activity"))], + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#label"), + vec![t_str(r.kind.clone())], + ), + (nn2("urn:utopia:ns:ruleKind"), vec![t_str(r.kind.clone())]), + ( + nn2("urn:utopia:ns:onPredicate"), + vec![t_iri_of(rel_iri(rel[&r.predicate_id]))], + ), + ], + &rel_iris, + ); + } + + for r in &fx.arules { + let s = mint("arule", &r.id.to_string()); + x.node( + "arule", + s.clone(), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("http://www.w3.org/ns/prov#Activity"))], + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#label"), + vec![t_str(r.name.clone())], + ), + (nn2("urn:utopia:ns:ruleKind"), vec![t_str("business")]), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#comment"), + if r.description.is_empty() { + vec![] + } else { + vec![t_str(r.description.clone())] + }, + ), + ( + nn2("urn:utopia:ns:conclusion"), + vec![t_str(r.conclusion.clone())], + ), + ( + nn2("urn:utopia:ns:subjectType"), + vec![t_iri_of(class_iri(cls[&r.subject_type_id]))], + ), + ( + nn2("urn:utopia:ns:concludesType"), + r.conclude_type_id + .map(|t| t_iri_of(class_iri(cls[&t]))) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:concludesPredicate"), + r.conclude_predicate_id + .map(|p| t_iri_of(rel_iri(rel[&p]))) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:concludesValue"), + r.conclude_predicate_id + .and_then(|p| { + r.conclude_value.as_ref().map(|v| t_value(v, rel_dt(p))) + }) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:concludeExpr"), + r.conclude_expr + .as_ref() + .map(|e| t_str(e.to_string())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:readsPredicate"), + r.conclude_expr + .as_ref() + .map(|e| { + expr_attrs(e) + .iter() + .map(|u| t_iri_of(rel_iri(rel[u]))) + .collect() + }) + .unwrap_or_default(), + ), + ( + nn2("urn:utopia:ns:condition"), + r.conditions + .iter() + .map(|c| { + t_iri_of(mint( + "condition", + &format!("{}:{}:{}", c.rule_id, c.group_seq, c.seq), + )) + }) + .collect(), + ), + ( + nn2("urn:utopia:ns:disabled"), + if r.enabled { vec![] } else { vec![t_flag()] }, + ), + ], + &rel_iris, + ); + for c in &r.conditions { + x.node( + "condition", + mint( + "condition", + &format!("{}:{}:{}", c.rule_id, c.group_seq, c.seq), + ), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("urn:utopia:ns:RuleCondition"))], + ), + (nn2("urn:utopia:ns:recordId"), vec![t_str(c.id.to_string())]), + ( + nn2("urn:utopia:ns:groupSeq"), + vec![t_int(c.group_seq as i64)], + ), + (nn2("urn:utopia:ns:seq"), vec![t_int(c.seq as i64)]), + ( + nn2("urn:utopia:ns:onPredicate"), + vec![t_iri_of(rel_iri(rel[&c.predicate_id]))], + ), + (nn2("urn:utopia:ns:op"), vec![t_str(c.op.clone())]), + ( + nn2("urn:utopia:ns:operand"), + c.operand + .as_ref() + .map(|o| t_str(o.to_string())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:readsPredicate"), + c.operand + .as_ref() + .filter(|o| o.is_object()) + .map(|o| { + expr_attrs(o) + .iter() + .map(|u| t_iri_of(rel_iri(rel[u]))) + .collect() + }) + .unwrap_or_default(), + ), + ], + &rel_iris, + ); + } + } + + for d in &fx.documents { + x.node( + "document", + mint("document", &d.id.to_string()), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("http://www.w3.org/ns/prov#Entity"))], + ), + ( + nn2("http://www.w3.org/2000/01/rdf-schema#label"), + vec![t_str(d.filename.clone())], + ), + (nn2("urn:utopia:ns:sha256"), vec![t_str(d.sha256.clone())]), + ( + nn2("https://schema.org/encodingFormat"), + vec![t_str(d.mime.clone())], + ), + (nn2("urn:utopia:ns:sizeBytes"), vec![t_int(d.size_bytes)]), + ( + nn2("urn:utopia:ns:docTimeSource"), + vec![t_str(d.doc_time_source.clone())], + ), + ( + nn2("urn:utopia:ns:tag"), + d.tags.iter().map(|t| t_str(t.clone())).collect(), + ), + ( + nn2("urn:utopia:ns:externalKey"), + d.external_key.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("https://schema.org/datePublished"), + d.doc_time + .map(|t| t_world(t, Some("day"))) + .into_iter() + .collect(), + ), + ( + nn2("http://www.w3.org/ns/prov#generatedAtTime"), + vec![t_dt(d.created_at)], + ), + ( + nn2("http://www.w3.org/ns/prov#invalidatedAtTime"), + d.deleted_at.map(t_dt).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:purgedAt"), + d.purged_at.map(t_dt).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:readerNeeded"), + d.reader_needed.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:timeContext"), + d.time_context + .as_ref() + .map(|t| t_str(t.to_string())) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:timeContextAt"), + d.time_context_at.map(t_dt).into_iter().collect(), + ), + ], + &rel_iris, + ); + } + + for c in &fx.chunks { + x.node( + "chunk", + mint("chunk", &c.id.to_string()), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("urn:utopia:ns:Chunk"))], + ), + ( + nn2("https://schema.org/isPartOf"), + vec![t_iri_of(mint("document", &c.document_id.to_string()))], + ), + ( + nn2("https://schema.org/position"), + vec![t_int(c.seq as i64)], + ), + ( + nn2("urn:utopia:ns:heading"), + c.heading.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:charStart"), + vec![t_int(c.char_start as i64)], + ), + (nn2("urn:utopia:ns:charEnd"), vec![t_int(c.char_end as i64)]), + ( + nn2("urn:utopia:ns:docVersion"), + vec![t_int(c.doc_version as i64)], + ), + ( + nn2("urn:utopia:ns:ofVersion"), + if dv_keys.contains(&(c.document_id, c.doc_version)) { + vec![t_iri_of(mint( + "docversion", + &format!("{}:{}", c.document_id, c.doc_version), + ))] + } else { + vec![] + }, + ), + (nn2("urn:utopia:ns:origin"), vec![t_str(c.origin.clone())]), + ( + nn2("urn:utopia:ns:originModel"), + c.origin_model.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:anchor"), + c.anchor + .as_ref() + .map(|a| t_str(a.to_string())) + .into_iter() + .collect(), + ), + ( + nn2("http://www.w3.org/ns/prov#generatedAtTime"), + vec![t_dt(c.created_at)], + ), + ( + nn2("urn:utopia:ns:extractedAt"), + c.extracted_at.map(t_dt).into_iter().collect(), + ), + ( + nn2("http://www.w3.org/ns/prov#invalidatedAtTime"), + c.superseded_at.map(t_dt).into_iter().collect(), + ), + ], + &rel_iris, + ); + } + + for e in &fx.evidence { + x.node( + "evidence", + mint("evidence", &format!("{}:{}", e.fact_id, e.chunk_id)), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("urn:utopia:ns:Evidence"))], + ), + ( + nn2("urn:utopia:ns:onStatement"), + vec![t_iri_of(mint("fact", &e.fact_id.to_string()))], + ), + ( + nn2("urn:utopia:ns:fromChunk"), + vec![t_iri_of(mint("chunk", &e.chunk_id.to_string()))], + ), + ( + nn2("urn:utopia:ns:quote"), + e.quote.as_deref().map(t_str).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:quoteStart"), + e.quote_start.map(|s| t_int(s as i64)).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:quoteEnd"), + e.quote_end.map(|s| t_int(s as i64)).into_iter().collect(), + ), + ( + nn2("http://www.w3.org/ns/prov#wasDerivedFrom"), + e.document_id + .map(|d| t_iri_of(mint("document", &d.to_string()))) + .into_iter() + .collect(), + ), + ( + nn2("urn:utopia:ns:docVersion"), + e.doc_version.map(|v| t_int(v as i64)).into_iter().collect(), + ), + ( + nn2("urn:utopia:ns:ofVersion"), + match (e.document_id, e.doc_version) { + (Some(d), Some(v)) if dv_keys.contains(&(d, v)) => { + vec![t_iri_of(mint("docversion", &format!("{d}:{v}")))] + } + _ => vec![], + }, + ), + ( + nn2("urn:utopia:ns:proposedPredicate"), + e.proposed_predicate + .as_deref() + .map(t_str) + .into_iter() + .collect(), + ), + ], + &rel_iris, + ); + } + + for v in &fx.docversions { + x.node( + "docversion", + mint("docversion", &format!("{}:{}", v.document_id, v.version)), + vec![ + ( + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + vec![t_iri_of(nn2("urn:utopia:ns:DocumentVersion"))], + ), + (nn2("urn:utopia:ns:recordId"), vec![t_str(v.id.to_string())]), + ( + nn2("http://www.w3.org/ns/prov#wasRevisionOf"), + vec![t_iri_of(mint("document", &v.document_id.to_string()))], + ), + (nn2("urn:utopia:ns:version"), vec![t_int(v.version as i64)]), + (nn2("urn:utopia:ns:sha256"), vec![t_str(v.sha256.clone())]), + (nn2("urn:utopia:ns:sizeBytes"), vec![t_int(v.size_bytes)]), + ( + nn2("http://www.w3.org/ns/prov#generatedAtTime"), + vec![t_dt(v.ingested_at)], + ), + ], + &rel_iris, + ); + } + x + } + + fn t_iri_of(n: NamedNode) -> Term { + n.into() + } + + fn premise_target(p: &ExportPremise) -> NamedNode { + match (p.fact_id, p.derived_id) { + (Some(f), _) => mint("fact", &f.to_string()), + (None, Some(d)) => mint("derived", &d.to_string()), + _ => unreachable!(), + } + } + + /// 每个矩阵格都能打到的夹具:每类节点至少一行,条件格两侧都有 + struct Fx { + classes: Vec, + relations: Vec, + rules: Vec, + arules: Vec, + entities: Vec, + documents: Vec, + docversions: Vec, + chunks: Vec, + facts: Vec, + evidence: Vec, + derived: Vec, + } + + fn entity(n: u8, name: &str, type_id: Option) -> ExportEntity { + ExportEntity { + id: id(n), + canonical_name: name.into(), + type_id, + type_kb: type_id.map(|_| kb()), + type_source: "extracted".into(), + type_resolved_at: None, + proposed_type: None, + specific_type: None, + description: None, + created_at: at("2026-01-01T00:00:00Z"), + } + } + + impl Fx { + fn full() -> Self { + let mut well = class(30, "well", None); + well.builtin = true; + well.description = "gas well".into(); + well.parents = vec![id(1)]; + well.primary_parents = vec![id(1)]; + well.disjoint = vec![id(3)]; + + let mut spouse = relation(20, "spouse", None, "relation"); + spouse.description = "married to".into(); + spouse.inverse_of = Some(id(21)); + spouse.sub_property_of = Some(id(22)); + spouse.qualifiers = vec![id(4)]; + spouse.domains = vec![id(1)]; + spouse.ranges = vec![id(3)]; + spouse.builtin = true; + spouse.is_transitive = true; + spouse.temporal = "event".into(); + let mut eternal = relation(23, "eternal_bond", None, "relation"); + eternal.temporal = "eternal".into(); + eternal.functional = false; + // 关系节点的声明单位(relation unit 真分支) + let mut headcount = relation(4, "headcount", None, "attribute"); + headcount.unit = Some("%".into()); + + // 规则与业务规则:公理一条、业务两条(一条带算式与条件、 + // 一条 typing 且停用) + let rule = ExportRule { + id: id(8), + kind: "transitive".into(), + predicate_id: id(2), + predicate_kb: Some(kb()), + }; + let arule = ExportAttributeRule { + id: id(9), + name: "Margin rule".into(), + description: "computes".into(), + conclusion: "computed".into(), + subject_type_id: id(1), + conclude_type_id: None, + conclude_predicate_id: Some(id(4)), + conclude_value: Some(serde_json::json!({"value": 0.42})), + conclude_expr: Some(serde_json::json!({ + "op": "mul", "l": {"attr": id(4).to_string()}, "r": {"const": 2} + })), + enabled: true, + conditions: vec![ + ExportRuleCondition { + id: id(15), + rule_id: id(9), + group_seq: 0, + seq: 1, + predicate_id: id(4), + op: "gt".into(), + operand: Some(serde_json::json!({"attr": id(4).to_string()})), + predicate_kb: Some(kb()), + }, + ExportRuleCondition { + id: id(16), + rule_id: id(9), + group_seq: 1, + seq: 1, + predicate_id: id(4), + op: "present".into(), + operand: None, + predicate_kb: Some(kb()), + }, + ], + subject_type_kb: Some(kb()), + conclude_type_kb: None, + conclude_predicate_kb: Some(kb()), + }; + let arule_off = ExportAttributeRule { + id: id(14), + name: "Off".into(), + description: String::new(), + conclusion: "typing".into(), + subject_type_id: id(1), + conclude_type_id: Some(id(3)), + conclude_predicate_id: None, + conclude_value: None, + conclude_expr: None, + enabled: false, + conditions: vec![], + subject_type_kb: Some(kb()), + conclude_type_kb: Some(kb()), + conclude_predicate_kb: None, + }; + + // 实体:全字段的一条 + 光秃秃的一条 + let mut acme = entity(10, "Acme", Some(id(30))); + acme.type_source = "human".into(); + acme.type_resolved_at = Some(at("2026-01-02T00:00:00Z")); + acme.proposed_type = Some("energy major".into()); + acme.specific_type = Some("gas producer".into()); + acme.description = Some("an energy major".into()); + let bob = entity(11, "Bob", None); + let carol = entity(42, "Carol", None); + + // 文档:全字段、删除、清空各一份 + let doc = ExportDocument { + id: id(12), + filename: "annual.md".into(), + external_key: Some("src://acme/annual".into()), + sha256: "a".repeat(64), + mime: "text/markdown".into(), + size_bytes: 42, + doc_time_source: "document".into(), + tags: vec!["filing".into(), "annual".into()], + doc_time: Some(at("2024-03-01T08:00:00Z")), + created_at: at("2026-01-01T00:00:00Z"), + deleted_at: None, + purged_at: None, + reader_needed: Some("pdf".into()), + time_context: Some( + serde_json::json!({"period": "FY2023", "calendar": "fiscal"}), + ), + time_context_at: Some(at("2026-01-05T00:00:00Z")), + }; + let doc_gone = ExportDocument { + id: id(17), + filename: "gone.md".into(), + external_key: None, + sha256: "b".repeat(64), + mime: "text/markdown".into(), + size_bytes: 1, + doc_time_source: "upload".into(), + tags: vec![], + doc_time: None, + created_at: at("2026-01-01T00:00:00Z"), + deleted_at: Some(at("2026-04-01T00:00:00Z")), + purged_at: Some(at("2026-04-02T00:00:00Z")), + reader_needed: None, + time_context: None, + time_context_at: None, + }; + + let version = ExportDocumentVersion { + id: id(18), + document_id: id(12), + version: 2, + sha256: "deadbeef".into(), + size_bytes: 4096, + ingested_at: at("2026-03-01T00:00:00.123456Z"), + document_kb: Some(kb()), + }; + + let mut c1 = chunk(13); + c1.document_id = id(12); + c1.doc_version = 2; + c1.version_row = true; + c1.extracted_at = Some(at("2026-03-02T00:00:00Z")); + c1.origin = "pasted".into(); + c1.origin_model = Some("clip-v2".into()); + c1.anchor = Some(serde_json::json!({"page": 7})); + let mut c2 = chunk(19); + c2.document_id = id(12); + c2.doc_version = 3; + c2.heading = None; + c2.superseded_at = Some(at("2026-04-01T00:00:00Z")); + + // 事实:每个分支一条 + let mut live = fact(5); + live.documents = vec![id(12)]; + live.quotes = vec!["joined in 2023".into()]; + live.quote_origins = vec!["pasted".into()]; + live.qualifiers = vec![ + FactQualifier { + qualifier_type_id: id(4), + key: "headcount".into(), + label: "headcount".into(), + value: Some(serde_json::json!({"value": 42})), + entity_id: None, + entity_name: None, + }, + FactQualifier { + qualifier_type_id: id(2), + key: "works_for".into(), + label: "works_for".into(), + value: None, + entity_id: Some(id(11)), + entity_name: Some("Bob".into()), + }, + // SYNTHETIC-ONLY:真表上 fact_qualifiers 的 XOR CHECK + // ((value IS NOT NULL) <> (entity_id IS NOT NULL))挡住两列 + // 同在的行;这里直接构造 Export* 行打序列化器 + // 「value 在场则 entity_id 让路」的优先支路 + FactQualifier { + qualifier_type_id: id(20), + key: "spouse".into(), + label: "spouse".into(), + value: Some(serde_json::json!({"value": "via introduction"})), + entity_id: Some(id(11)), + entity_name: Some("Bob".into()), + }, + ]; + live.supersedes = Some(id(6)); + live.supersedes_kb = Some(kb()); + live.attested_to = Some(at("2026-03-15T00:00:00Z")); + live.end_derived = true; + live.rule_derived = true; + live.valid_from = Some(at("2023-05-04T10:00:00Z")); + live.valid_from_precision = Some("hour".into()); + + let mut old = fact(6); + old.invalidated_at = Some(at("2026-03-01T00:00:00Z")); + + let mut attr = fact(7); + attr.predicate_id = Some(id(4)); + attr.object_id = None; + attr.object_value = Some(serde_json::json!({"value": 65, "unit": "%"})); + + let mut bare = fact(24); + bare.predicate_id = None; + bare.predicate_kb = None; + bare.surface_predicate = Some("acquired".into()); + + // 谓词没绑定的陈述仍带字面值宾语:缺位的只是 datatype, + // 宾语本身必须到(#821)——datatype 空意味着简单字面量 + let mut bare_val = fact(43); + bare_val.predicate_id = None; + bare_val.predicate_kb = None; + bare_val.object_id = None; + bare_val.object_kb = None; + bare_val.object_value = Some(serde_json::json!({"value": "待复检"})); + bare_val.surface_predicate = Some("状态".into()); + + let mut empty_obj = fact(25); + empty_obj.object_id = None; + empty_obj.object_value = None; + + let mut future = fact(26); + future.holds_from = Some(at("2099-01-01T00:00:00Z")); + + let mut closed = fact(27); + closed.valid_to = Some(at("2024-07-01T00:00:00Z")); + closed.valid_to_precision = Some("month".into()); + closed.holds_to = Some(at("2024-08-01T00:00:00Z")); + + let mut eternal_f = fact(28); + eternal_f.predicate_id = Some(id(23)); + eternal_f.holds_from = None; + + let mut undated_end = fact(29); + undated_end.valid_to_precision = Some("unknown".into()); + undated_end.attested_to = Some(at("2025-03-01T00:00:00Z")); + undated_end.holds_to = Some(at("2025-03-01T00:00:00Z")); + + let mut year_f = fact(31); + year_f.subject_id = id(11); // 另一主语:别与 fact(5) 撞出重复的现行边 + year_f.valid_from = Some(at("2023-01-01T00:00:00Z")); + year_f.valid_from_precision = Some("year".into()); + + // relative:true 的字面值(#681):relativeValue+unit 的真分支, + // 且 datatype 被压成 xsd:string(headcount 声明 number,relative + // 值不是日期/数字)。主语换 bob:避免给 acme 的 headcount 格 + // 再叠一条现行边(不是不行,是让 cell 内容读起来干净) + let mut rel_f = fact(35); + rel_f.subject_id = id(11); + rel_f.predicate_id = Some(id(4)); + rel_f.object_id = None; + rel_f.object_value = Some(serde_json::json!({ + "value": "45 days after signing", "relative": true, "unit": "days" + })); + + // valid_to + 秒级精度:fact 侧 validThroughPrecision 的真分支 + // (year/month/day 都不发 precision 字面值,已各有覆盖) + let mut closed_sec = fact(36); + closed_sec.valid_to = Some(at("2024-07-01T12:34:56Z")); + closed_sec.valid_to_precision = Some("second".into()); + closed_sec.holds_to = Some(at("2024-07-01T12:34:57Z")); + + // 开放陈述(0061):layer/phrase、自己的属性节点(值与实体各一)、 + // 时间提及节点(全字段与光杆各一)、valid_from 的来历等级 + let mut open = fact(38); + open.layer = "open".into(); + open.predicate_id = None; + open.predicate_kb = None; + open.phrase = Some("joined".into()); + open.surface_predicate = Some("employs".into()); + open.valid_from_grade = Some("B".into()); + open.statement_qualifiers = vec![ + ExportStatementQualifier { + fact_id: id(38), + role: "since".into(), + value: Some(serde_json::json!("2023-04")), + entity_id: None, + entity_kb: None, + entity_merged: false, + }, + ExportStatementQualifier { + fact_id: id(38), + role: "witness".into(), + value: None, + entity_id: Some(id(11)), + entity_kb: Some(kb()), + entity_merged: false, + }, + ]; + open.time_mentions = vec![ + ExportTimeMention { + id: id(40), + kb_id: kb(), + fact_id: id(38), + chunk_id: id(13), + chunk_kb: Some(kb()), + role: "when".into(), + text: "去年四月".into(), + char_start: 12, + shape: Some("point".into()), + reference: Some(serde_json::json!({"kind": "month"})), + granularity: Some("month".into()), + resolved_from: Some(at("2023-04-01T00:00:00Z")), + resolved_from_precision: Some("month".into()), + resolved_to: Some(at("2023-05-01T00:00:00Z")), + resolved_to_precision: Some("month".into()), + resolved_at: Some(at("2026-03-01T00:00:00Z")), + grade: Some("B".into()), + recorded_at: at("2026-02-15T00:00:00Z"), + }, + ExportTimeMention { + id: id(41), + kb_id: kb(), + fact_id: id(38), + chunk_id: id(13), + chunk_kb: Some(kb()), + role: "until".into(), + text: "前年".into(), + char_start: 0, + shape: None, + reference: None, + granularity: None, + resolved_from: None, + resolved_from_precision: None, + resolved_to: None, + resolved_to_precision: None, + resolved_at: None, + grade: None, + recorded_at: at("2026-02-15T00:00:00Z"), + }, + ]; + + // 类型化事实的来源边(0067/0068):fromStatement 指回那条开放陈述。 + // 宾语用 carol:bob works_for bob 的现行边已被 year_f 占住 + let mut typed = fact(39); + typed.subject_id = id(11); + typed.object_id = Some(id(42)); + typed.predicate_id = Some(id(2)); + typed.source_statements = vec![id(38)]; + typed.valid_from_grade = Some("A".into()); + + let evidence = vec![ + ExportEvidence { + fact_id: id(5), + chunk_id: id(13), + document_id: Some(id(12)), + doc_version: Some(2), + version_row: true, + quote: Some("joined in 2023".into()), + quote_start: Some(41), + quote_end: Some(54), + proposed_predicate: Some("employs".into()), + chunk_kb: Some(kb()), + document_kb: Some(kb()), + }, + ExportEvidence { + fact_id: id(6), + chunk_id: id(19), + document_id: None, + doc_version: None, + version_row: false, + quote: None, + quote_start: None, + quote_end: None, + proposed_predicate: None, + chunk_kb: Some(kb()), + document_kb: None, + }, + ]; + + // 派生:公理规则推的(前提混断言与派生、小时精度), + // 业务规则推的(字面值结论、分钟精度终点),被作废的 + let mut d1 = derived(32); + d1.rule_id = Some(id(8)); + d1.rule_kb = Some(kb()); + d1.valid_from = Some(at("2024-06-01T10:00:00Z")); + d1.valid_from_precision = Some("hour".into()); + d1.premises = vec![ + ExportPremise { + seq: 0, + fact_id: Some(id(5)), + derived_id: None, + }, + ExportPremise { + seq: 1, + fact_id: None, + derived_id: Some(id(33)), + }, + ]; + let mut d2 = derived(33); + d2.rule_id = None; + d2.rule_kb = None; + d2.attribute_rule_id = Some(id(9)); + d2.attribute_rule_kb = Some(kb()); + d2.object_id = None; + d2.object_kb = None; + d2.object_value = Some(serde_json::json!({"value": 0.95, "unit": "ratio"})); + d2.valid_to = Some(at("2025-01-15T00:00:00Z")); + d2.valid_to_precision = Some("minute".into()); + d2.premises = vec![]; + let mut d3 = derived(34); + d3.invalidated_at = Some(at("2026-03-02T00:00:00Z")); + // SYNTHETIC-ONLY:valid_to NULL + precision 'unknown' 的组合 + // 被真表 CHECK(derived_to_precision_needs_date:precision 非空 + // 则 valid_to 必须非空)挡死——只有合成 ExportDerived 打得到 + // derived endedUnknown 的发射支路。object 两列皆空, + // 顺带覆盖 derived rdf:object 的假支 + let mut d4 = derived(37); + d4.object_id = None; + d4.object_kb = None; + d4.object_value = None; + d4.valid_to = None; + d4.valid_to_precision = Some("unknown".into()); + d4.premises = vec![]; + + Fx { + classes: vec![ + class(1, "person", Some("https://schema.org/Person")), + class(3, "team", None), + well, + ], + relations: vec![ + relation( + 2, + "works_for", + Some("https://schema.org/worksFor"), + "relation", + ), + headcount, + relation(21, "partner", None, "relation"), + relation(22, "kin", None, "relation"), + spouse, + eternal, + ], + rules: vec![rule], + arules: vec![arule, arule_off], + entities: vec![acme, bob, carol], + documents: vec![doc, doc_gone], + docversions: vec![version], + chunks: vec![c1, c2], + facts: vec![ + live, + old, + attr, + bare, + bare_val, + empty_obj, + future, + closed, + eternal_f, + undated_end, + year_f, + rel_f, + closed_sec, + open, + typed, + ], + evidence, + derived: vec![d1, d2, d3, d4], + } + } + + /// 被测的一侧:照常走 emit_*(与导出路由同一顺序) + fn emit(&self, format: Format) -> Vec { + let names = Names::new(kb(), None).unwrap(); + let vocab = vocabulary(&names, &self.classes, &self.relations); + let buf = SharedBuf::default(); + let mut sink = Sink::new(format, buf.clone()); + for c in &self.classes { + emit_class(&mut sink, &vocab, c).unwrap(); + } + for r in &self.relations { + emit_relation(&mut sink, &vocab, r).unwrap(); + } + for r in &self.rules { + emit_rule(&mut sink, &names, &vocab, r).unwrap(); + } + for r in &self.arules { + emit_attribute_rule(&mut sink, &names, &vocab, r).unwrap(); + } + for d in &self.documents { + emit_document(&mut sink, &names, d).unwrap(); + } + for v in &self.docversions { + emit_docversion(&mut sink, &names, v).unwrap(); + } + for c in &self.chunks { + emit_chunk(&mut sink, &names, c).unwrap(); + } + for e in &self.entities { + emit_entity(&mut sink, &names, &vocab, e).unwrap(); + } + for f in &self.facts { + emit_fact(&mut sink, &names, &vocab, f, now()).unwrap(); + } + for e in &self.evidence { + emit_evidence(&mut sink, &names, e).unwrap(); + } + for d in &self.derived { + emit_derived(&mut sink, &names, &vocab, d).unwrap(); + } + sink.finish().unwrap(); + let bytes = buf.take(); + oxrdfio::RdfParser::from_format(match format { + Format::Turtle => oxrdfio::RdfFormat::Turtle, + Format::JsonLd => oxrdfio::RdfFormat::JsonLd { + profile: oxrdfio::JsonLdProfileSet::empty(), + }, + }) + .for_slice(&bytes) + .map(|q| q.expect("导出的文件必须解析得回来")) + .collect() + } + } + + /// quad 全集,四分量一体入账:把三元组搬进命名图 + /// 的变种在三元组投影下与 E 相等,在 quad 核算下必破账。 + /// 序列化器合约是全部写 DEFAULT graph(Sink::triple 固定 + /// in_graph(DefaultGraph)),所以期望侧一律补 DefaultGraph + fn to_set(quads: &[Quad]) -> HashSet { + quads.iter().cloned().collect() + } + + fn expected_quads(expected: &Expected) -> HashSet { + expected + .triples + .iter() + .map(|(s, p, o)| { + Quad::new(s.clone(), p.clone(), o.clone(), GraphName::DefaultGraph) + }) + .collect() + } + + /// 全量核算:A(解析回的全部 quad)== E(独立期望集,全在 + /// DEFAULT graph),emitted == distinct,每个声明格都登记过。 + /// 两种格式同判 + #[test] + fn the_whole_export_is_accounted_for() { + let fx = Fx::full(); + let expected = expected(&fx); + let expected = expected_quads(&expected); + for format in [Format::Turtle, Format::JsonLd] { + let quads = fx.emit(format); + assert!( + quads + .iter() + .all(|q| q.graph_name == GraphName::DefaultGraph), + "{format:?}: every emitted quad must sit in the default graph" + ); + let actual = to_set(&quads); + assert_eq!( + quads.len(), + actual.len(), + "{format:?}: duplicate quads emitted" + ); + let missing: Vec<_> = expected.difference(&actual).collect(); + let unexpected: Vec<_> = actual.difference(&expected).collect(); + assert!( + missing.is_empty() && unexpected.is_empty(), + "{format:?}: missing={missing:?} unexpected={unexpected:?}" + ); + } + } + + /// 核算自身的咬合度:任何没有被期望集背书的 term——换面、加成员、 + /// 空格塞字面量、bnode、未背书边、**搬进命名图**——都必须破账。 + /// 换面、加成员、空格塞字面量、bnode 四类变种打头 + #[test] + fn the_accounting_rejects_unaccounted_terms() { + let fx = Fx::full(); + let expected = expected_quads(&expected(&fx)); + let actual = to_set(&fx.emit(Format::Turtle)); + let fact5 = nn2("urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:fact:05050505-0505-0505-0505-050505050505"); + let derived32 = nn2("urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:derived:20202020-2020-2020-2020-202020202020"); + let entity11 = nn2("urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:entity:0b0b0b0b-0b0b-0b0b-0b0b-0b0b0b0b0b0b"); + let partner = + nn2("urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:relation:partner"); + + let additions: Vec<(NamedNode, NamedNode, Term)> = vec![ + // 字面量 rdf:subject——无期望格背书 + (fact5.clone(), nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#subject"), + Literal::new_simple_literal("not-an-iri").into()), + // 第二个字面量 rdf:object——无期望格背书 + (fact5.clone(), nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#object"), + Literal::new_simple_literal("second-object").into()), + // 限定词边——无 backing 行 + (fact5.clone(), partner.clone(), entity11.clone().into()), + // 派生多一条 *Precision——该行没精度 + (derived32.clone(), nn2("urn:utopia:ns:validThroughPrecision"), + Literal::new_simple_literal("hour").into()), + // 空格塞 "false"(disabled/endDerived/endedUnknown/builtin 假支) + (nn2("urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:arule:09090909-0909-0909-0909-090909090909"), + nn2("urn:utopia:ns:disabled"), + Literal::new_typed_literal("false", xsd::BOOLEAN).into()), + // 空格塞字面量 rdf:object(empty_obj 事实) + (nn2("urn:utopia:kb:01a06dc4-f40a-7013-b09f-1b499e2e7441:fact:19191919-1919-1919-1919-191919191919"), + nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#object"), + Literal::new_simple_literal("phantom").into()), + // bnode 宾语——永远不会被任何期望格背书 + (fact5.clone(), nn2("http://www.w3.org/1999/02/22-rdf-syntax-ns#subject"), + Term::from(oxrdf::BlankNode::new("mutant").unwrap())), + // 无现行事实的现行边 + (entity11, partner, fact5.clone().into()), + ]; + for (s, p, o) in additions { + let mut mutated = actual.clone(); + mutated.insert(Quad::new(s.clone(), p.clone(), o, GraphName::DefaultGraph)); + assert_ne!( + mutated, expected, + "unaccounted term must break the ledger: {s} {p}" + ); + } + // 同一三元组搬进命名图——三元组投影漏它, + // quad 核算下是两处破账(default 少一条、named 多一条) + { + let victim = actual.iter().next().unwrap().clone(); + let mut moved = actual.clone(); + moved.remove(&victim); + let (vs, vp, vo) = (victim.subject, victim.predicate, victim.object); + moved.insert(Quad::new( + vs.clone(), + vp.clone(), + vo.clone(), + nn2("urn:mutant:named-graph"), + )); + assert_ne!(moved, expected, "a named-graph move must break the ledger"); + // 连「复制一条到命名图」(default 没少)也一样破账 + let mut copied = actual.clone(); + copied.insert(Quad::new(vs, vp, vo, nn2("urn:mutant:named-graph"))); + assert_ne!(copied, expected, "a named-graph copy must break the ledger"); + } + // 少一条同样破账 + for victim in actual.iter().take(3) { + let mut mutated = actual.clone(); + mutated.remove(victim); + assert_ne!(mutated, expected, "a dropped quad must break the ledger"); + } + // 发出的 quad 数与去重数必须一致 + assert_eq!(actual.len(), fx.emit(Format::Turtle).len()); + } + + /// 声明面里的全部条件格(kind, cell_key)。登记 ≠ 覆盖 + /// 每个格至少要有一个夹具节点让它非空—— + /// 真分支的发射支路真跑过且账面对上了;也要至少一个节点让它 + /// 空着——假支一样入过账。"*rel" 是 entity/fact 上按词汇表关系 + /// 展开的动态格族(live triple / qualifier)。 + const CONDITIONAL_CELLS: &[(&str, &[&str])] = &[ + ( + "entity", + &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#comment", + "urn:utopia:ns:typeResolvedAt", + "urn:utopia:ns:proposedType", + "urn:utopia:ns:specificType", + "*rel", + ], + ), + ( + "fact", + &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#label", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#object", + "urn:utopia:ns:fromStatement", + "urn:utopia:ns:supersedes", + "urn:utopia:ns:proposedPredicate", + "urn:utopia:ns:relativeValue", + "urn:utopia:ns:unit", + "https://schema.org/validFrom", + "urn:utopia:ns:validFromPrecision", + "urn:utopia:ns:validFromGrade", + "https://schema.org/validThrough", + "urn:utopia:ns:validThroughPrecision", + "urn:utopia:ns:endedUnknown", + "urn:utopia:ns:attestedTo", + "urn:utopia:ns:endDerived", + "urn:utopia:ns:ruleDerived", + "http://www.w3.org/ns/prov#invalidatedAtTime", + "http://www.w3.org/ns/prov#wasDerivedFrom", + "urn:utopia:ns:quote", + "urn:utopia:ns:evidenceOrigin", + "urn:utopia:ns:statementQualifier", + "urn:utopia:ns:timeMention", + "*rel", + ], + ), + ( + "squalifier", + &[ + "urn:utopia:ns:qualifierValue", + "http://www.w3.org/ns/prov#value", + ], + ), + ( + "timemention", + &[ + "urn:utopia:ns:shape", + "urn:utopia:ns:reference", + "urn:utopia:ns:granularity", + "urn:utopia:ns:grade", + "urn:utopia:ns:resolvedFrom", + "urn:utopia:ns:resolvedFromPrecision", + "urn:utopia:ns:resolvedTo", + "urn:utopia:ns:resolvedToPrecision", + "urn:utopia:ns:resolvedAt", + ], + ), + ( + "derived", + &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#object", + "urn:utopia:ns:unit", + "https://schema.org/validFrom", + "urn:utopia:ns:validFromPrecision", + "https://schema.org/validThrough", + "urn:utopia:ns:validThroughPrecision", + "urn:utopia:ns:endedUnknown", + "http://www.w3.org/ns/prov#invalidatedAtTime", + "http://www.w3.org/ns/prov#used", + "urn:utopia:ns:premise", + ], + ), + ( + "class", + &[ + "http://www.w3.org/2000/01/rdf-schema#comment", + "urn:utopia:ns:builtin", + "http://www.w3.org/2000/01/rdf-schema#subClassOf", + "urn:utopia:ns:primaryType", + "http://www.w3.org/2002/07/owl#disjointWith", + ], + ), + ( + "relation", + &[ + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + "http://www.w3.org/2000/01/rdf-schema#comment", + "urn:utopia:ns:temporal", + "urn:utopia:ns:builtin", + "http://www.w3.org/2002/07/owl#inverseOf", + "http://www.w3.org/2000/01/rdf-schema#subPropertyOf", + "urn:utopia:ns:allowedQualifier", + "http://www.w3.org/2000/01/rdf-schema#domain", + "http://www.w3.org/2000/01/rdf-schema#range", + "urn:utopia:ns:datatype", + "urn:utopia:ns:unit", + ], + ), + ( + "arule", + &[ + "http://www.w3.org/2000/01/rdf-schema#comment", + "urn:utopia:ns:concludesType", + "urn:utopia:ns:concludesPredicate", + "urn:utopia:ns:concludesValue", + "urn:utopia:ns:concludeExpr", + "urn:utopia:ns:readsPredicate", + "urn:utopia:ns:condition", + "urn:utopia:ns:disabled", + ], + ), + ( + "condition", + &["urn:utopia:ns:operand", "urn:utopia:ns:readsPredicate"], + ), + ( + "document", + &[ + "urn:utopia:ns:tag", + "urn:utopia:ns:externalKey", + "https://schema.org/datePublished", + "http://www.w3.org/ns/prov#invalidatedAtTime", + "urn:utopia:ns:purgedAt", + ], + ), + ( + "chunk", + &[ + "urn:utopia:ns:heading", + "urn:utopia:ns:ofVersion", + "urn:utopia:ns:extractedAt", + "http://www.w3.org/ns/prov#invalidatedAtTime", + ], + ), + ( + "evidence", + &[ + "urn:utopia:ns:quote", + "http://www.w3.org/ns/prov#wasDerivedFrom", + "urn:utopia:ns:docVersion", + "urn:utopia:ns:ofVersion", + "urn:utopia:ns:proposedPredicate", + ], + ), + ]; + + /// 真表 CHECK 到不了、只能靠合成 Export* 行打到的格——声明在这里 + /// 的意思是:覆盖证据来自夹具行,不是真实数据可达性。 + /// derived/endedUnknown:derived_to_precision_needs_date CHECK + /// 要求 precision 非空 ⇒ valid_to 非空,与该格的发射条件 + /// (valid_to NULL ∧ precision='unknown')互斥。 + /// fact/*rel 里「value 与 entity_id 同列时字面量赢」的行级支路: + /// fact_qualifiers 的 XOR CHECK 挡死两列同在——那不是格级覆盖 + /// 问题,由下面的钉断言单独覆盖(Fx::full 里的合成 FactQualifier)。 + const SYNTHETIC_ONLY_CELLS: &[(&str, &str)] = &[("derived", "urn:utopia:ns:endedUnknown")]; + + /// 结构上不可能空的格:至少一个成员无条件发射(relation 的 + /// rdf:type 永远带 Datatype/ObjectProperty,fact 的永远带 + /// rdf:Statement),假支不存在; + /// 它的条件面是「旗标多写成员」,由 multi 断言钉住 + const EMPTY_IMPOSSIBLE: &[(&str, &str)] = &[ + ( + "relation", + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", + ), + ("fact", "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + ]; + + /// 注册过的格不等于覆盖过的格。每个声明的条件格 + /// 必须在夹具里至少一次非空(真支)且至少一次为空(假支), + /// 否则就是「登记了但没打过」——要么补夹具,要么显式声明不测。 + #[test] + fn the_fixture_exercises_every_declared_conditional_cell() { + let fx = Fx::full(); + let x = expected(&fx); + + let mut uncovered = vec![]; + for (kind, cells) in CONDITIONAL_CELLS { + for cell in *cells { + let key = (kind.to_string(), cell.to_string()); + if !x.non_empty.contains(&key) { + uncovered.push(format!("{kind}/{cell}: true branch never produced")); + } + let exempt_empty = EMPTY_IMPOSSIBLE.iter().any(|e| *e == (*kind, *cell)); + if !x.empty.contains(&key) && !exempt_empty { + uncovered.push(format!("{kind}/{cell}: false branch never produced")); + } + } + } + assert!( + uncovered.is_empty(), + "declared conditional cells not exercised by Fx::full():\n{}", + uncovered.join("\n") + ); + + // relation rdf:type 的假支不存在(见 EMPTY_IMPOSSIBLE), + // 条件面用多成员钉:spouse.is_transitive 必须真多写一个成员 + assert!( + x.multi.contains(&( + "relation".to_string(), + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string() + )), + "relation flag-conditional extra rdf:type member never exercised" + ); + // 同一条钉 fact:layer=open 的陈述必须在 rdf:type 多写一个成员 + assert!( + x.multi.contains(&( + "fact".to_string(), + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string() + )), + "fact layer-conditional extra rdf:type member never exercised" + ); + + // SYNTHETIC-ONLY 格必须真的被夹具打到——不然「声明覆盖」 + // 本身又是空话 + for (kind, cell) in SYNTHETIC_ONLY_CELLS { + assert!( + x.non_empty + .contains(&((*kind).to_string(), (*cell).to_string())), + "synthetic-only cell {kind}/{cell} must be exercised" + ); + } + + // 钉死两条 schema 到不了的支路的语义——合成行的输出 term 要精确: + // 1. derived endedUnknown(valid_to NULL + precision 'unknown') + let d4 = mint("derived", &id(37).to_string()); + assert!( + x.triples + .contains(&(d4.into(), nn2("urn:utopia:ns:endedUnknown"), t_flag())), + "synthetic derived must emit endedUnknown" + ); + // 2. qualifier value 与 entity_id 同列时字面量赢、IRI 不出现 + let f5 = mint("fact", &id(5).to_string()); + let spouse = mint("relation", "spouse"); + let value_lit: Term = + Literal::new_typed_literal("via introduction", xsd::STRING).into(); + let entity_term: Term = mint("entity", &id(11).to_string()).into(); + assert!( + x.triples + .contains(&(f5.clone().into(), spouse.clone(), value_lit)), + "synthetic qualifier: value must win over entity_id" + ); + assert!( + !x.triples.contains(&(f5.into(), spouse, entity_term)), + "synthetic qualifier: entity_id must lose to value" + ); + } + } }