From 787a6a271b96a43d7bf93e7604b9a65fc0c426dd Mon Sep 17 00:00:00 2001 From: Bogdan Szabo Date: Sun, 25 Oct 2026 15:10:27 +0100 Subject: [PATCH] A role word is recorded against the chunk that says it, so a re-push can say something new Signed-off-by: Bogdan Szabo --- crates/utopia-cli/src/main.rs | 2 +- crates/utopia-server/src/api/mcp_tests.rs | 5 +- crates/utopia-server/src/extraction_open.rs | 1 + .../src/export_provenance_integrity.sql | 8 + crates/utopia-store/src/graph.rs | 16 +- crates/utopia-store/src/materialize.rs | 24 +- crates/utopia-store/src/pending.rs | 3 +- .../a_bound_statement_becomes_a_typed_fact.rs | 40 ++- ...ole_word_moves_onto_its_evidence_chunks.rs | 289 ++++++++++++++++++ ...pen_statement_keeps_the_documents_words.rs | 37 ++- .../exported_references_never_cross_a_kb.rs | 31 +- crates/utopia-store/tests/store/main.rs | 1 + ...gration_0070_runs_under_any_search_path.rs | 16 +- ...ferences-stay-inside-the-knowledge-base.md | 8 +- ...ole-word-speaks-while-its-chunk-is-live.md | 4 +- docs/decisions/README.md | 2 +- docs/design/ledger.md | 4 +- ...ole_word_is_recorded_against_its_chunk.sql | 125 ++++++++ 18 files changed, 551 insertions(+), 65 deletions(-) create mode 100644 crates/utopia-store/tests/store/a_role_word_moves_onto_its_evidence_chunks.rs create mode 100644 migrations/0121_a_role_word_is_recorded_against_its_chunk.sql diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index 617df6e55..817a0e137 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 111; +const CURRENT_SCHEMA_VERSION: u32 = 112; #[tokio::main] async fn main() -> anyhow::Result<()> { diff --git a/crates/utopia-server/src/api/mcp_tests.rs b/crates/utopia-server/src/api/mcp_tests.rs index ca6f3e280..d92916da3 100644 --- a/crates/utopia-server/src/api/mcp_tests.rs +++ b/crates/utopia-server/src/api/mcp_tests.rs @@ -1008,10 +1008,11 @@ async fn entity_facts_reads_back_a_statements_role_words() -> anyhow::Result<()> return Ok(()); }; sqlx::query( - "INSERT INTO statement_qualifiers(fact_id,role,value) - VALUES ($1,'lines','{\"value\":\"10-42\"}'::jsonb)", + "INSERT INTO statement_qualifiers(fact_id,role,value,chunk_id) + VALUES ($1,'lines','{\"value\":\"10-42\"}'::jsonb,$2)", ) .bind(f.corrected) + .bind(f.chunk) .execute(&f.state.pool) .await?; let result = f diff --git a/crates/utopia-server/src/extraction_open.rs b/crates/utopia-server/src/extraction_open.rs index b328583b6..e9cf5cb84 100644 --- a/crates/utopia-server/src/extraction_open.rs +++ b/crates/utopia-server/src/extraction_open.rs @@ -665,6 +665,7 @@ async fn apply_open_extraction( utopia_store::graph::add_statement_qualifier( pool, fact_id, + chunk.id, role, value.as_ref(), *entity, diff --git a/crates/utopia-store/src/export_provenance_integrity.sql b/crates/utopia-store/src/export_provenance_integrity.sql index c4c894832..3132ece48 100644 --- a/crates/utopia-store/src/export_provenance_integrity.sql +++ b/crates/utopia-store/src/export_provenance_integrity.sql @@ -266,6 +266,14 @@ SELECT edge, kind, COUNT(*) AS rows FROM ( LEFT JOIN entities e ON e.id = q.entity_id WHERE f.kb_id = $1 AND q.entity_id IS NOT NULL UNION ALL + -- @edge statement_qualifiers.chunk_id -> chunks.id + -- 角色词记在哪一块上:归属同样按所属 fact 的库判 + SELECT 'squalifier.chunk', 'cross_kb', c.kb_id IS DISTINCT FROM f.kb_id + FROM statement_qualifiers q + JOIN facts f ON f.id = q.fact_id + LEFT JOIN chunks c ON c.id = q.chunk_id + WHERE f.kb_id = $1 + UNION ALL -- @edge time_mentions.fact_id -> facts.id -- 时间提及以行自己的 kb 归属:指的事实与段落都必须同库 SELECT 'timemention.fact', 'cross_kb', f.kb_id IS DISTINCT FROM t.kb_id diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index a68aef9fd..02632074f 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -199,12 +199,12 @@ pub async fn fact_qualifiers_for( Ok(out) } -/// 往一条开放陈述上写一个按角色词记的属性。**先写者留着**:同一条陈述同一个角色词 -/// 再听到一次不覆盖(与 [`upsert_fact_qualifier`] 的 Conflict 一条规矩——两次观察 -/// 不一致该另立一行加一条冲突,这一刀还没走到那一步)。值与实体必须恰好给一个 +/// 往一条开放陈述上写一个按角色词记的属性,记在说它的那一块上(0070 决定 1)。 +/// 同一块再说一次不写新行;另一块说的另记一行,旧行不动。值与实体必须恰好给一个 pub async fn add_statement_qualifier( pool: &PgPool, fact_id: Uuid, + chunk_id: Uuid, role: &str, value: Option<&serde_json::Value>, entity_id: Option, @@ -223,19 +223,21 @@ pub async fn add_statement_qualifier( )); } sqlx::query( - "INSERT INTO statement_qualifiers (fact_id, role, value, entity_id) - VALUES ($1, $2, $3, $4) ON CONFLICT (fact_id, role) DO NOTHING", + "INSERT INTO statement_qualifiers (fact_id, role, value, entity_id, chunk_id) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (fact_id, role, chunk_id) DO NOTHING", ) .bind(fact_id) .bind(role) .bind(value) .bind(entity_id) + .bind(chunk_id) .execute(pool) .await?; Ok(()) } -/// 一批开放陈述各自的角色词属性,按事实 id 取回;实体那一格连上显示名 +/// 一批开放陈述各自的角色词属性,按事实 id 取回;实体那一格连上显示名。 +/// 同一个值记在几块上只返回一次 pub async fn statement_qualifiers_for( pool: &PgPool, fact_ids: &[Uuid], @@ -253,7 +255,7 @@ pub async fn statement_qualifiers_for( entity_name: Option, } let rows: Vec = sqlx::query_as( - "SELECT q.fact_id, q.role, q.value, q.entity_id, e.canonical_name AS entity_name + "SELECT DISTINCT q.fact_id, q.role, q.value, q.entity_id, e.canonical_name AS entity_name FROM statement_qualifiers q LEFT JOIN entities e ON e.id = q.entity_id WHERE q.fact_id = ANY($1) diff --git a/crates/utopia-store/src/materialize.rs b/crates/utopia-store/src/materialize.rs index c2d263cee..c8eab1256 100644 --- a/crates/utopia-store/src/materialize.rs +++ b/crates/utopia-store/src/materialize.rs @@ -410,7 +410,7 @@ async fn materialize_in_tx( .bind(d.statement) .execute(&mut **tx) .await?; - // 证据与限定各抄一份:证据是同一段原文的同一处引文;限定照角色词原样带过去 + // 证据每次都抄:证据是同一段原文的同一处引文 sqlx::query( "INSERT INTO fact_evidence (fact_id, chunk_id, quote, document_id, doc_version, proposed_predicate, quote_start, quote_end) @@ -423,15 +423,19 @@ async fn materialize_in_tx( .bind(d.statement) .execute(&mut **tx) .await?; - sqlx::query( - "INSERT INTO statement_qualifiers (fact_id, role, value, entity_id) - SELECT $1, role, value, entity_id FROM statement_qualifiers WHERE fact_id = $2 - ON CONFLICT DO NOTHING", - ) - .bind(fact) - .bind(d.statement) - .execute(&mut **tx) - .await?; + if new { + // 角色词只在建行时抄(0070 决定 1 的修订):并进已有行的陈述可能说了别的值, + // 抄过来这一行就同时读到两个值;别的值由决定 4 带过来 + sqlx::query( + "INSERT INTO statement_qualifiers (fact_id, role, value, entity_id, chunk_id) + SELECT $1, role, value, entity_id, chunk_id FROM statement_qualifiers WHERE fact_id = $2 + ON CONFLICT DO NOTHING", + ) + .bind(fact) + .bind(d.statement) + .execute(&mut **tx) + .await?; + } } // 3a. 已批准的规则算隐含行(0044 决定 3 第五片)。读数只查缓存:缓存里没有的这一轮 // 不算,`read_phrases` 填上之后再来。短语规则按陈述触发,类别词规则按实体触发 diff --git a/crates/utopia-store/src/pending.rs b/crates/utopia-store/src/pending.rs index 8d581902d..03f402c41 100644 --- a/crates/utopia-store/src/pending.rs +++ b/crates/utopia-store/src/pending.rs @@ -499,7 +499,8 @@ async fn confirm_open( } else { q.get("value") }; - crate::graph::add_statement_qualifier(pool, fact_id, role, value, entity_id).await?; + crate::graph::add_statement_qualifier(pool, fact_id, v.chunk_id, role, value, entity_id) + .await?; } for t in json_items(v.time_words.as_ref()) { let (Some(text), Some(char_start)) = ( diff --git a/crates/utopia-store/tests/store/a_bound_statement_becomes_a_typed_fact.rs b/crates/utopia-store/tests/store/a_bound_statement_becomes_a_typed_fact.rs index c53a3dd55..a99529620 100644 --- a/crates/utopia-store/tests/store/a_bound_statement_becomes_a_typed_fact.rs +++ b/crates/utopia-store/tests/store/a_bound_statement_becomes_a_typed_fact.rs @@ -4,7 +4,7 @@ //! 一条陈述「Harbor Bakery —is based in→ Port Ellen」,签名 (is based in, organization, //! place) 绑到 headquartered_in:算出一条类型化行,谓词是它,证据与限定抄过来, //! `from_statement_id` 指回陈述;再跑一遍什么都不动;带 mood 的陈述不算;另一份文档 -//! 的同一句并进同一行(两条来源、两条证据);作废其中一条陈述,行还在、来源少一条; +//! 的同一句并进同一行(两条来源、两条证据),它说的另一个 since 不跟进来,行只读建行那条的;作废其中一条陈述,行还在、来源少一条; //! 绑定翻成 reverse,旧行作废、新行主宾对调;绑定改成 none,行作废、不再补。 //! //! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 @@ -133,21 +133,29 @@ async fn a_bound_statement_becomes_a_typed_fact() -> anyhow::Result<()> { .bind(doc) .execute(&pool) .await?; - anyhow::Ok(()) + anyhow::Ok(chunk) } }; - evidence_of(stated, "bakery.txt").await?; - evidence_of(again, "bakery-2.txt").await?; - sqlx::query( - "INSERT INTO statement_qualifiers (fact_id, role, value) VALUES ($1, 'since', '\"2019\"'::jsonb)", - ) - .bind(stated) - .execute(&pool) - .await?; + let stated_chunk = evidence_of(stated, "bakery.txt").await?; + let again_chunk = evidence_of(again, "bakery-2.txt").await?; + let planned_chunk = evidence_of(planned, "bakery-plan.txt").await?; + for (statement, chunk, since) in [(stated, stated_chunk, "2019"), (again, again_chunk, "2021")] { + sqlx::query( + "INSERT INTO statement_qualifiers (fact_id, role, value, chunk_id) + VALUES ($1, 'since', to_jsonb($3::text), $2)", + ) + .bind(statement) + .bind(chunk) + .bind(since) + .execute(&pool) + .await?; + } sqlx::query( - "INSERT INTO statement_qualifiers (fact_id, role, value) VALUES ($1, 'mood', '\"will\"'::jsonb)", + "INSERT INTO statement_qualifiers (fact_id, role, value, chunk_id) + VALUES ($1, 'mood', '\"will\"'::jsonb, $2)", ) .bind(planned) + .bind(planned_chunk) .execute(&pool) .await?; @@ -207,13 +215,17 @@ async fn a_bound_statement_becomes_a_typed_fact() -> anyhow::Result<()> { .bind(typed_id) .fetch_one(&pool) .await?; - let qualifiers: Vec = sqlx::query_scalar( - "SELECT role FROM statement_qualifiers WHERE fact_id = $1 ORDER BY role", + // 并进来的那条说 since 2021:不抄,行只读建行那条的 2019 + let qualifiers: Vec<(String, String, Uuid)> = sqlx::query_as( + "SELECT role, value #>> '{}', chunk_id FROM statement_qualifiers WHERE fact_id = $1", ) .bind(typed_id) .fetch_all(&pool) .await?; - assert_eq!((sources, evidence, qualifiers), (3, 2, vec!["since".to_string()])); + assert_eq!( + (sources, evidence, qualifiers), + (3, 2, vec![("since".to_string(), "2019".to_string(), stated_chunk)]) + ); // 再跑一遍:什么都不动 assert_eq!(materialize(&pool, kb).await?, Outcome::default()); diff --git a/crates/utopia-store/tests/store/a_role_word_moves_onto_its_evidence_chunks.rs b/crates/utopia-store/tests/store/a_role_word_moves_onto_its_evidence_chunks.rs new file mode 100644 index 000000000..ce8188f40 --- /dev/null +++ b/crates/utopia-store/tests/store/a_role_word_moves_onto_its_evidence_chunks.rs @@ -0,0 +1,289 @@ +//! 0121:角色词搬到它事实的每一块证据上(0070 决定 1,#1267)。 +//! +//! 开一个隔离库迁到 0120,种下旧形状的行,再跑 0121: +//! - 每块证据(被取代的也算)都拿到陈述的角色词,读出来仍是一个值; +//! - 类型化行只在说过这个词、且值与它相同的来源陈述的块上拿到它; +//! - 已失效、没有证据的事实(purge 留下的)的角色词被删掉; +//! - 活着的事实有角色词却没有证据,迁移整份中止。 +//! +//! 建库失败(角色没权限的环境)与没有 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")) +} + +/// 按文件顺序跑 `from..=through` 的迁移,各自一个事务(与 sqlx::migrate 同一形状) +async fn migrate(pool: &PgPool, from: i64, through: i64) -> Result<(), sqlx::Error> { + let migrator = sqlx::migrate!("../../migrations"); + let mut conn = pool.acquire().await?; + for m in migrator + .iter() + .filter(|m| (from..=through).contains(&m.version)) + { + let mut tx = conn.begin().await?; + sqlx::raw_sql(&m.sql).execute(&mut *tx).await?; + tx.commit().await?; + } + Ok(()) +} + +/// 隔离库:建 → 迁到 0120 → 返回(库名, 连接池)。失败就地跳过 +async fn scratch(suffix: &str) -> Option<(String, PgPool)> { + let admin_pool = PgPool::connect(&admin_url()?).await.ok()?; + let name = format!("sq121_{}_{}", suffix, Uuid::now_v7().simple()); + let created = sqlx::query(&format!("CREATE DATABASE {name}")) + .execute(&admin_pool) + .await; + admin_pool.close().await; + if created.is_err() { + eprintln!("跳过:建不了隔离库(角色没有 CREATEDB)"); + 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(&pool, 0, 120).await { + eprintln!("跳过:迁到 0120 失败(迁移链自身的问题): {e}"); + drop_scratch(&name).await; + return None; + } + Some((name, pool)) +} + +async fn drop_scratch(name: &str) { + let Some(admin) = admin_url() else { return }; + 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; + } +} + +struct Ledger { + kb: Uuid, + entity: Uuid, + superseded_chunk: Uuid, + live_chunk: Uuid, +} + +/// 一个库、一个实体、一份文档的两块:旧的那块已被取代 +async fn seed_ledger(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb, entity, doc) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + let (superseded_chunk, live_chunk) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'm121-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'm121-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'm121-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + sqlx::query("INSERT INTO entities (id, kb_id, canonical_name) VALUES ($1, $2, 'parse.rs')") + .bind(entity) + .bind(kb) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO documents (id, kb_id, filename, sha256, status) + VALUES ($1, $2, 'ci.json', 'm121', 'ready')", + ) + .bind(doc) + .bind(kb) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO chunks (id, kb_id, document_id, seq, text, superseded_at) + VALUES ($1, $3, $4, 0, 'lines 40-52', now()), ($2, $3, $4, 1, 'lines 58-70', NULL)", + ) + .bind(superseded_chunk) + .bind(live_chunk) + .bind(kb) + .bind(doc) + .execute(pool) + .await?; + Ok(Ledger { + kb, + entity, + superseded_chunk, + live_chunk, + }) +} + +/// 一条事实;`open` 是开放陈述,否则是类型化行 +async fn fact(pool: &PgPool, ledger: &Ledger, open: bool, live: bool) -> anyhow::Result { + let fact = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, object_id, confidence, invalidated_at, layer, phrase) + VALUES ($1, $2, $3, $3, 0.9, CASE WHEN $4 THEN NULL ELSE now() END, + CASE WHEN $5 THEN 'open' ELSE 'typed' END, CASE WHEN $5 THEN 'covers' END)", + ) + .bind(fact) + .bind(ledger.kb) + .bind(ledger.entity) + .bind(live) + .bind(open) + .execute(pool) + .await?; + Ok(fact) +} + +/// 按 0120 的形状写一个 `lines` 角色词(没有 chunk_id) +async fn says_lines(pool: &PgPool, fact: Uuid) -> anyhow::Result<()> { + says_lines_as(pool, fact, "40-52").await +} + +async fn says_lines_as(pool: &PgPool, fact: Uuid, lines: &str) -> anyhow::Result<()> { + sqlx::query("INSERT INTO statement_qualifiers (fact_id, role, value) VALUES ($1, 'lines', $2)") + .bind(fact) + .bind(serde_json::json!(lines)) + .execute(pool) + .await?; + Ok(()) +} + +async fn evidence(pool: &PgPool, fact: Uuid, chunks: &[Uuid]) -> anyhow::Result<()> { + for chunk in chunks { + sqlx::query("INSERT INTO fact_evidence (fact_id, chunk_id) VALUES ($1, $2)") + .bind(fact) + .bind(chunk) + .execute(pool) + .await?; + } + Ok(()) +} + +async fn role_word_chunks(pool: &PgPool, facts: Vec) -> anyhow::Result> { + let rows = sqlx::query_as( + "SELECT fact_id, chunk_id FROM statement_qualifiers + WHERE fact_id = ANY($1) ORDER BY fact_id, chunk_id", + ) + .bind(facts) + .fetch_all(pool) + .await?; + Ok(rows) +} + +#[tokio::test] +async fn role_words_move_onto_every_evidence_chunk_and_leave_a_purged_fact() -> anyhow::Result<()> { + let Some((name, pool)) = scratch("chunks").await else { + return Ok(()); + }; + let ledger = seed_ledger(&pool).await?; + let stated = fact(&pool, &ledger, true, true).await?; + says_lines(&pool, stated).await?; + evidence(&pool, stated, &[ledger.superseded_chunk, ledger.live_chunk]).await?; + let purged = fact(&pool, &ledger, true, false).await?; + says_lines(&pool, purged).await?; + + migrate(&pool, 121, 121).await?; + + let mut expected = vec![ + (stated, ledger.superseded_chunk), + (stated, ledger.live_chunk), + ]; + expected.sort(); + assert_eq!( + role_word_chunks(&pool, vec![stated, purged]).await?, + expected, + "每块证据一行,没有证据的已失效事实一行不留" + ); + let read = utopia_store::graph::statement_qualifiers_for(&pool, &[stated]).await?; + assert_eq!( + serde_json::to_value(&read[&stated])?, + serde_json::json!([{ "role": "lines", "value": "40-52", "entity_id": null, "entity_name": null }]) + ); + pool.close().await; + drop_scratch(&name).await; + Ok(()) +} + +#[tokio::test] +async fn a_typed_row_takes_a_role_word_only_on_the_chunk_of_a_statement_that_said_the_same_value( +) -> anyhow::Result<()> { + let Some((name, pool)) = scratch("typed").await else { + return Ok(()); + }; + let ledger = seed_ledger(&pool).await?; + let (said, silent, disagreed) = ( + fact(&pool, &ledger, true, true).await?, + fact(&pool, &ledger, true, true).await?, + fact(&pool, &ledger, true, true).await?, + ); + says_lines(&pool, said).await?; + evidence(&pool, said, &[ledger.superseded_chunk]).await?; + evidence(&pool, silent, &[ledger.live_chunk]).await?; + // 说了另一个值的来源:它的块不替类型化行说话,类型化行仍只读出原来的值 + says_lines_as(&pool, disagreed, "58-70").await?; + evidence(&pool, disagreed, &[ledger.live_chunk]).await?; + let typed = fact(&pool, &ledger, false, true).await?; + says_lines(&pool, typed).await?; + evidence(&pool, typed, &[ledger.superseded_chunk, ledger.live_chunk]).await?; + sqlx::query("INSERT INTO typed_fact_sources (fact_id, statement_id) VALUES ($1, $2), ($1, $3), ($1, $4)", + ) + .bind(typed) + .bind(said) + .bind(silent) + .bind(disagreed) + .execute(&pool) + .await?; + // 来源已经不在的类型化行:角色词照旧抄到它的证据上,读出来不少 + let sourceless = fact(&pool, &ledger, false, true).await?; + says_lines(&pool, sourceless).await?; + evidence(&pool, sourceless, &[ledger.live_chunk]).await?; + + migrate(&pool, 121, 121).await?; + + let mut expected = vec![ + (typed, ledger.superseded_chunk), + (sourceless, ledger.live_chunk), + ]; + expected.sort(); + assert_eq!( + role_word_chunks(&pool, vec![typed, sourceless]).await?, + expected + ); + pool.close().await; + drop_scratch(&name).await; + Ok(()) +} + +#[tokio::test] +async fn a_live_fact_with_role_words_and_no_evidence_stops_the_migration() -> anyhow::Result<()> { + let Some((name, pool)) = scratch("orphan").await else { + return Ok(()); + }; + let ledger = seed_ledger(&pool).await?; + let orphan = fact(&pool, &ledger, true, true).await?; + says_lines(&pool, orphan).await?; + + let err = migrate(&pool, 121, 121).await.unwrap_err().to_string(); + + assert!( + err.contains("live facts with role words and no evidence (x1)"), + "{err}" + ); + pool.close().await; + drop_scratch(&name).await; + Ok(()) +} diff --git a/crates/utopia-store/tests/store/an_open_statement_keeps_the_documents_words.rs b/crates/utopia-store/tests/store/an_open_statement_keeps_the_documents_words.rs index ddc0b1020..46983265b 100644 --- a/crates/utopia-store/tests/store/an_open_statement_keeps_the_documents_words.rs +++ b/crates/utopia-store/tests/store/an_open_statement_keeps_the_documents_words.rs @@ -147,6 +147,7 @@ async fn an_open_statement_shows_under_its_phrase_and_reuses_its_row() -> anyhow graph::add_statement_qualifier( &pool, fact, + f.chunk_a, "amount", Some(&serde_json::json!({ "value": "$1.2 billion" })), None, @@ -318,27 +319,39 @@ async fn an_open_statement_shows_under_its_phrase_and_reuses_its_row() -> anyhow Some(serde_json::json!({ "value": "$1.2 billion" })) ); assert!(q[0].entity_id.is_none()); - // 先写者留着 - graph::add_statement_qualifier( - &pool, - fact, - "amount", - Some(&serde_json::json!({ "value": "$2 billion" })), - None, + // 角色词记在说它的那一块上:另一块的值另记一行,同一块再说一次不添行 + for (chunk, amount) in [(f.chunk_b, "$2 billion"), (f.chunk_a, "$1.2 billion")] { + graph::add_statement_qualifier( + &pool, + fact, + chunk, + "amount", + Some(&serde_json::json!({ "value": amount })), + None, + ) + .await?; + } + let amounts: Vec<(Uuid, serde_json::Value)> = sqlx::query_as( + "SELECT chunk_id, value FROM statement_qualifiers + WHERE fact_id = $1 AND role = 'amount' ORDER BY chunk_id", ) + .bind(fact) + .fetch_all(&pool) .await?; - let q = &graph::statement_qualifiers_for(&pool, &[fact]).await?[&fact]; assert_eq!( - q[0].value, - Some(serde_json::json!({ "value": "$1.2 billion" })) + amounts, + vec![ + (f.chunk_a, serde_json::json!({ "value": "$1.2 billion" })), + (f.chunk_b, serde_json::json!({ "value": "$2 billion" })), + ] ); // 值与实体必须恰好一个 assert!( - graph::add_statement_qualifier(&pool, fact, "buyer", None, None) + graph::add_statement_qualifier(&pool, fact, f.chunk_a, "buyer", None, None) .await .is_err() ); - graph::add_statement_qualifier(&pool, fact, "buyer", None, Some(f.acme)).await?; + graph::add_statement_qualifier(&pool, fact, f.chunk_a, "buyer", None, Some(f.acme)).await?; let q = &graph::statement_qualifiers_for(&pool, &[fact]).await?[&fact]; let buyer = q.iter().find(|x| x.role == "buyer").unwrap(); assert_eq!(buyer.entity_id, Some(f.acme)); diff --git a/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs b/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs index 281a1c1a9..481523ce5 100644 --- a/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs +++ b/crates/utopia-store/tests/store/exported_references_never_cross_a_kb.rs @@ -379,14 +379,25 @@ async fn new_cross_kb_writes_are_rejected_on_every_exported_edge() -> anyhow::Re // —— 开放陈述的属性(statement_qualifiers,0061):行没有 kb 列,归属按 // 所属 fact 的库判;别库的实体值会被铸进本库 IRI let err = sqlx::query( - "INSERT INTO statement_qualifiers (fact_id, role, entity_id) - VALUES ($1, 'as', $2)", + "INSERT INTO statement_qualifiers (fact_id, role, entity_id, chunk_id) + VALUES ($1, 'as', $2, $3)", ) .bind(f.fact_a) .bind(f.ent_b) + .bind(f.chunk_a) .execute(&pool) .await; assert!(err.is_err(), "squalifier.entity→foreign 必须被拒"); + // 角色词记在别库的块上(0070 决定 1) + let err = sqlx::query( + "INSERT INTO statement_qualifiers (fact_id, role, value, chunk_id) + VALUES ($1, 'lines', '\"1\"'::jsonb, $2)", + ) + .bind(f.fact_a) + .bind(f.chunk_b) + .execute(&pool) + .await; + assert!(err.is_err(), "squalifier.chunk→foreign 必须被拒"); // —— 时间提及(0061/0064):提及自己的 kb 必须与它指的事实、段落同属一库 let err = sqlx::query( @@ -989,13 +1000,22 @@ async fn malformed_rows_fail_every_exported_edge_closed() -> anyhow::Result<()> .bind(f.fact_b) .execute(&mut *tx) .await?; - // 开放陈述的属性值指着别库实体 + // 开放陈述的属性值指着别库实体;另一个角色词记在别库的块上 sqlx::query( - "INSERT INTO statement_qualifiers (fact_id, role, entity_id) - VALUES ($1, 'as', $2)", + "INSERT INTO statement_qualifiers (fact_id, role, entity_id, chunk_id) + VALUES ($1, 'as', $2, $3)", ) .bind(f.fact_a) .bind(f.ent_b) + .bind(f.chunk_a) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO statement_qualifiers (fact_id, role, value, chunk_id) + VALUES ($1, 'lines', '\"1\"'::jsonb, $2)", + ) + .bind(f.fact_a) + .bind(f.chunk_b) .execute(&mut *tx) .await?; // 时间提及两种坏法:归属在 A 却指着 B 的段落(体检扫得见——行按自己的 @@ -1466,6 +1486,7 @@ async fn malformed_rows_fail_every_exported_edge_closed() -> anyhow::Result<()> "condition.predicate", "factsource.statement", "squalifier.entity", + "squalifier.chunk", "timemention.fact", "timemention.chunk", "binding.type", diff --git a/crates/utopia-store/tests/store/main.rs b/crates/utopia-store/tests/store/main.rs index 0f6ef7a36..bc6e39235 100644 --- a/crates/utopia-store/tests/store/main.rs +++ b/crates/utopia-store/tests/store/main.rs @@ -84,6 +84,7 @@ mod a_retired_account; mod a_retraction_leaves_the_graph; mod a_review_has_a_summary; mod a_review_page_is_assembled_in_batches; +mod a_role_word_moves_onto_its_evidence_chunks; mod a_rule_computes_what_it_concludes; mod a_rule_concludes_a_type; mod a_rule_reads_what_a_rule_concluded; diff --git a/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs b/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs index 7949f1145..21cfcc790 100644 --- a/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs +++ b/crates/utopia-store/tests/store/migration_0070_runs_under_any_search_path.rs @@ -428,6 +428,11 @@ const TRIGGER_EDGES: &[(&str, &str, &str)] = &[ "entity_type_id", "relation_type_ranges_same_kb", ), + ( + "statement_qualifiers", + "chunk_id", + "statement_qualifiers_same_kb", + ), ( "statement_qualifiers", "entity_id", @@ -619,7 +624,7 @@ struct Coverage { impl Coverage { /// 完备 = 没有未归类的边、没有腐掉的登记、面与机制互证得上, /// 每条受保护边都进了导出体检、体检里没有腐掉的分支, - /// 且两类已覆盖边的数量与当前迁移链的 44/16 一致。 + /// 且两类已覆盖边的数量与当前迁移链的 44/17 一致。 fn assert_complete(&self) -> anyhow::Result<()> { let mut problems = String::new(); let mut dump = |name: &str, xs: &[String]| { @@ -641,9 +646,10 @@ impl Coverage { self.declarative.len() )); } - if self.trigger_covered.len() != 16 { + // 0121 adds the chunk a role word is recorded against. + if self.trigger_covered.len() != 17 { problems.push_str(&format!( - " TRIGGER_EDGES = {} (expected 16)\n", + " TRIGGER_EDGES = {} (expected 17)\n", self.trigger_covered.len() )); } @@ -1062,8 +1068,8 @@ async fn a_protected_edge_missing_from_preflight_fails_the_guard() -> anyhow::Re #[test] fn the_preflight_check_notices_a_dropped_or_stray_edge() -> anyhow::Result<()> { let surface = preflight_surface()?; - // 同一份文件两个方向各数一次:60 条结构边 + 5 条 merged 过滤检查 - assert_eq!(surface.edges.len(), 60, "preflight 必须覆盖全部保护边"); + // 同一份文件两个方向各数一次:61 条结构边 + 5 条 merged 过滤检查 + assert_eq!(surface.edges.len(), 61, "preflight 必须覆盖全部保护边"); assert_eq!( surface.filters, [ diff --git a/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md b/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md index fc1a24ff4..6496b486e 100644 --- a/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md +++ b/docs/decisions/0048-provenance-references-stay-inside-the-knowledge-base.md @@ -16,7 +16,7 @@ Every reference an export can resolve must join rows that live in the same knowl ## The mechanism, per edge -Thirty-nine reference edges are protected. The split is decided by a single question: **does the referencing row carry the kb authority itself?** +Forty reference edges are protected (thirty-nine at migration 0070; 0121 added `statement_qualifiers.chunk_id`). The split is decided by a single question: **does the referencing row carry the kb authority itself?** | Source | Edge → target | kb authority | Mechanism | |---|---|---|---| @@ -40,11 +40,11 @@ Thirty-nine reference edges are protected. The split is decided by a single ques | `relation_type_qualifiers` | `qualifier_type_id` → `relation_types` | relation's kb | trigger | | `attribute_rule_conditions` | `predicate_id` → `relation_types` | owning rule's kb | trigger | | `typed_fact_sources` | `statement_id` → `facts` | owning fact's kb | trigger | -| `statement_qualifiers` | `entity_id` → `entities` | owning fact's kb | trigger | +| `statement_qualifiers` | `entity_id` → `entities`; `chunk_id` → `chunks` | owning fact's kb | trigger | **26 edges** are declared as `FOREIGN KEY (kb_id, ref) REFERENCES t (kb_id, id)`, replacing the single-column FK that was already there — one kernel-level lookup now proves existence *and* same-base, where before it proved existence and a second check would have had to prove the rest. Eight `UNIQUE (kb_id, id)` constraints on the referenced tables back those keys (`documents`, `chunks`, `entities`, `entity_types`, `relation_types`, `facts`, `rules`, `attribute_rules`). -**13 edges** cannot be expressed that way: their kb authority is a *parent row's* `kb_id`, and the link row has no `kb_id` column at all. A composite key cannot name `fact.kb_id` from a `fact_evidence` row. These keep row-level `BEFORE` triggers — nine functions, ten triggers — that read the parent's base and compare. +**14 edges** cannot be expressed that way: their kb authority is a *parent row's* `kb_id`, and the link row has no `kb_id` column at all. A composite key cannot name `fact.kb_id` from a `fact_evidence` row. These keep row-level `BEFORE` triggers — nine functions, ten triggers — that read the parent's base and compare. **`kb_id` is immutable** on every owned table (one shared function, twelve `BEFORE UPDATE OF kb_id` triggers). Composite keys only guard the rows being pointed *at*; the trigger-covered edges derive their authority from a parent row, and a parent moving base would silently un-anchor every child pointing at it. Making kb reassignment impossible is what lets a point-in-time check stay correct. @@ -54,7 +54,7 @@ Thirty-nine reference edges are protected. The split is decided by a single ques `pg_restore` loads data with `COPY` and creates constraints after, so any shape that survives the migration survives a restore — and the deferred self-references are what let a same-transaction or intra-statement forward chain restore without ordering games. Two sharper edges were designed for explicitly: -- `pg_restore --disable-triggers` and any `session_replication_role = replica` load suppress *user* triggers wholesale. Declarative foreign keys are internal constraint triggers and are **not** suppressed — so the 26 declarative edges hold even in a replica-mode load, which is a second reason to prefer them wherever the schema can say them. The 13 trigger-covered edges admit the gap and are backstopped by the export-side `provenance_integrity` check and the §0 audit query, which doubles as a post-load audit. +- `pg_restore --disable-triggers` and any `session_replication_role = replica` load suppress *user* triggers wholesale. Declarative foreign keys are internal constraint triggers and are **not** suppressed — so the 26 declarative edges hold even in a replica-mode load, which is a second reason to prefer them wherever the schema can say them. The 14 trigger-covered edges admit the gap and are backstopped by the export-side `provenance_integrity` check and the §0 audit query, which doubles as a post-load audit. - The migration pins `search_path = pg_catalog` in every function body and qualifies every identifier `public.*`, because restore empties the session `search_path` and a hostile first schema must not redirect name resolution. `migration_0070_runs_under_any_search_path` installs the whole migration under a normal, an empty, and a decoy-first `search_path`. - The coverage itself is guarded by a catalog-derived regression in the same test file: it enumerates every column-level reference inside the ledger surface from `pg_catalog` and fails when an edge resolves to no declared composite-FK, owner-derived-trigger, or explicit exclusion — so a reference column added later cannot silently slip past the invariant. The same guard also reads the exact preflight scan the export runs (`export_provenance_integrity.sql`) and fails when a protected edge has no scan branch — or a scan branch no longer names a protected edge — so schema protection and export preflight cannot drift apart. diff --git a/docs/decisions/0070-a-role-word-speaks-while-its-chunk-is-live.md b/docs/decisions/0070-a-role-word-speaks-while-its-chunk-is-live.md index 7cd32a6ac..a9674be7e 100644 --- a/docs/decisions/0070-a-role-word-speaks-while-its-chunk-is-live.md +++ b/docs/decisions/0070-a-role-word-speaks-while-its-chunk-is-live.md @@ -1,6 +1,6 @@ # 0070 · A role word speaks while its chunk is live -- **Status**: Proposed · 2026-10-16 · [#1267](https://github.com/deeplethe/utopia/issues/1267) · open: all five decisions +- **Status**: In progress · 2026-10-25 · [#1267](https://github.com/deeplethe/utopia/issues/1267) · decision 1 built (migration 0121) · open: decisions 2 to 4 - **Written**: 2026-10-16 (conventions in the [README](README.md)) - **Related**: [0037](0037-a-relation-carries-its-own-attributes.md) decided that a qualifier is not part of a fact's identity and that the first value stays; [0044](0044-the-ontology-is-a-view-over-what-documents-say.md) owns the open statement and the role words on it; [0054](0054-a-source-may-push-statements-in-the-open-contract.md) decision 7 is the re-push that supersedes a chunk; migration `0061` created `statement_qualifiers`; [0048](0048-provenance-references-stay-inside-the-knowledge-base.md) gets one more edge to keep inside the base; [0019](0019-the-second-clock-can-be-rewound.md) is the record axis `as_of` reads on; [#1267](https://github.com/deeplethe/utopia/issues/1267), [#1272](https://github.com/deeplethe/utopia/issues/1272) @@ -24,6 +24,8 @@ Three facts of the code make it so: The backfill copies each existing `(fact, role)` value onto every evidence chunk of its fact, superseded ones included. Attaching it only to the earliest chunk would hide role words that show today: for a statement already re-pushed under 0054 decision 7, the earliest chunk is superseded, the live chunk carries no row because `DO NOTHING` dropped it, and under decision 2 the role would disappear. With the value on every chunk, every read after the migration returns exactly what it returned before; only the pushes that follow can change a value. +*Revised with migration 0121 (2026-10-30).* As first written this decision put each role word on every evidence chunk of its fact. That holds for open statements. A typed row can have several source statements, and their chunks are all its evidence, so copying a value onto all of them would let a statement speak for a value it never said. A typed row takes a role word only on the chunk of a source statement that said the same value; the migration falls back to every evidence chunk only when no source gave that value. At runtime `materialize` copies role words when it creates a typed row and not when a statement merges into an existing one: the merged statement may say a different value, and the row would read two. Bringing the other values across is decision 4. + `chunk_id` is a new provenance edge, and 0048's invariant covers it: the owner-derived trigger `statement_qualifiers_same_kb` checks that the chunk's document is in the fact's base, and the export preflight scans the edge. **2. A superseded chunk stops speaking for the statement.** diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 564febbf8..10a3051e0 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -75,7 +75,7 @@ By domain; the domains are the files of [../design/](../design/README.md). **Sta | 0010 | [An unnamed relation stays empty](0010-no-relation-is-no-relation.md) | Implemented | partly (by 0044) | | 0037 | [A relation carries its own attributes](0037-a-relation-carries-its-own-attributes.md) | In progress | partly (by 0044) | | 0048 | [Provenance references stay inside the knowledge base](0048-provenance-references-stay-inside-the-knowledge-base.md) | Implemented | | -| 0070 | [A role word speaks while its chunk is live](0070-a-role-word-speaks-while-its-chunk-is-live.md) | Proposed | | +| 0070 | [A role word speaks while its chunk is live](0070-a-role-word-speaks-while-its-chunk-is-live.md) | In progress | | ### [governance](../design/governance.md) diff --git a/docs/design/ledger.md b/docs/design/ledger.md index 34b908222..ab78135d3 100644 --- a/docs/design/ledger.md +++ b/docs/design/ledger.md @@ -30,8 +30,8 @@ by itself: the temporal engine reads the chunk's origin and opens a `described_e instead of closing a correct fact [0040 d4, 0045 cut 3]. Its confidence is also capped below the review threshold, which is what puts it in front of a person. -**Beside the row.** `statement_qualifiers (fact, role, value | entity)` keyed by the document's role -word for open statements; `fact_qualifiers (fact, qualifier_type, value | entity)` keyed by a +**Beside the row.** `statement_qualifiers (fact, role, chunk, value | entity)` keyed by the document's +role word and the chunk that says it, for open statements [0070]; `fact_qualifiers (fact, qualifier_type, value | entity)` keyed by a declared attribute for typed rows, converted by the attribute's datatype, the unit read from the text (`$4 billion` becomes 4000000000 with unit `$`) and the declared unit only when the text carries none; a qualifier is not part of the edge's identity; every superseding writer copies diff --git a/migrations/0121_a_role_word_is_recorded_against_its_chunk.sql b/migrations/0121_a_role_word_is_recorded_against_its_chunk.sql new file mode 100644 index 000000000..8b48c822d --- /dev/null +++ b/migrations/0121_a_role_word_is_recorded_against_its_chunk.sql @@ -0,0 +1,125 @@ +-- 角色词记在说它的那一块上(0070 决定 1,#1267)。 +-- +-- `statement_qualifiers` 从前按 (fact, role) 只留一行、先写者留着:同一个 external_id 重推、 +-- 值变了,新值被 `DO NOTHING` 丢掉。现在每条角色词带上说它的 `chunk_id`,主键变成 +-- (fact, role, chunk),新推的块写新行,旧行不动。 +-- +-- 回填分三步,先陈述、后由陈述推出的行: +-- 1. 开放陈述:每个 (fact, role) 的现值抄到它的每一块证据上,被取代的块也抄。 +-- 2. 类型化行与隐含行:从来源陈述(`typed_fact_sources`、`implied_fact_sources`)第 1 步 +-- 写下的逐块行里,挑出与这一行自己的值和实体都相同的,抄下它们落在这一行证据上的块。 +-- 于是另一条来源陈述的块不会替没说过这个词的陈述说话,说了别的值的来源也不会。 +-- 3. 剩下的(来源已经不在、或没有一条来源给过同样的值)照第 1 步抄到这一行的每一块证据上, +-- 今天读得到的角色词一个不丢。 +-- 每个 (fact, role) 只留下原来那一个值,所以迁移之后读出来的和之前一样。几条来源陈述对同一个 +-- 角色词给了不同的值时,类型化行仍只读它原来的值;别的值由 0070 决定 3 带过来。 +-- +-- 没有证据的事实:`documents::purge` 删掉块和证据、留下已失效的事实和它的角色词。这些行 +-- 没有块可挂,删掉并报数。**活着的**事实有角色词却没有证据,是账本坏了,整份中止。 +-- +-- `chunk_id` 是一条新的出处边(0048):触发器同时查块与事实同库,导出体检扫它。 + +-- §0 前置检查:活着的事实有角色词却没有证据 → 中止 +DO $$ +DECLARE + orphans bigint; +BEGIN + SELECT COUNT(DISTINCT q.fact_id) INTO orphans + FROM public.statement_qualifiers q + JOIN public.facts f ON f.id = q.fact_id + WHERE f.invalidated_at IS NULL + AND NOT EXISTS (SELECT 1 FROM public.fact_evidence e WHERE e.fact_id = q.fact_id); + IF orphans > 0 THEN + RAISE EXCEPTION 'live facts with role words and no evidence (x%) — repair the ledger before role words can move onto chunks', orphans; + END IF; +END; +$$; + +DROP TRIGGER statement_qualifiers_same_kb ON public.statement_qualifiers; + +ALTER TABLE public.statement_qualifiers + ADD COLUMN chunk_id uuid, + DROP CONSTRAINT statement_qualifiers_pkey; + +INSERT INTO public.statement_qualifiers (fact_id, role, value, entity_id, chunk_id) +SELECT q.fact_id, q.role, q.value, q.entity_id, e.chunk_id + FROM public.statement_qualifiers q + JOIN public.facts f ON f.id = q.fact_id AND f.layer = 'open' + JOIN public.fact_evidence e ON e.fact_id = q.fact_id + WHERE q.chunk_id IS NULL; + +-- 两条来源陈述在同一块上说了同一个值时只留一行 +INSERT INTO public.statement_qualifiers (fact_id, role, value, entity_id, chunk_id) +SELECT DISTINCT q.fact_id, q.role, q.value, q.entity_id, s.chunk_id + FROM public.statement_qualifiers q + JOIN (SELECT fact_id, statement_id FROM public.typed_fact_sources + UNION + SELECT fact_id, statement_id FROM public.implied_fact_sources + WHERE statement_id IS NOT NULL) src ON src.fact_id = q.fact_id + JOIN public.statement_qualifiers s + ON s.fact_id = src.statement_id AND s.role = q.role AND s.chunk_id IS NOT NULL + AND s.value IS NOT DISTINCT FROM q.value AND s.entity_id IS NOT DISTINCT FROM q.entity_id + JOIN public.fact_evidence e ON e.fact_id = q.fact_id AND e.chunk_id = s.chunk_id + WHERE q.chunk_id IS NULL; + +INSERT INTO public.statement_qualifiers (fact_id, role, value, entity_id, chunk_id) +SELECT q.fact_id, q.role, q.value, q.entity_id, e.chunk_id + FROM public.statement_qualifiers q + JOIN public.fact_evidence e ON e.fact_id = q.fact_id + WHERE q.chunk_id IS NULL + AND NOT EXISTS (SELECT 1 FROM public.statement_qualifiers n + WHERE n.fact_id = q.fact_id AND n.role = q.role AND n.chunk_id IS NOT NULL); + +DO $$ +DECLARE + dropped bigint; +BEGIN + DELETE FROM public.statement_qualifiers q + WHERE q.chunk_id IS NULL + AND NOT EXISTS (SELECT 1 FROM public.fact_evidence e WHERE e.fact_id = q.fact_id); + GET DIAGNOSTICS dropped = ROW_COUNT; + RAISE NOTICE 'dropped % role words on facts with no evidence', dropped; +END; +$$; + +DELETE FROM public.statement_qualifiers WHERE chunk_id IS NULL; + +ALTER TABLE public.statement_qualifiers + ALTER COLUMN chunk_id SET NOT NULL, + ADD CONSTRAINT statement_qualifiers_chunk_id_fkey + FOREIGN KEY (chunk_id) REFERENCES public.chunks(id) ON DELETE CASCADE, + ADD PRIMARY KEY (fact_id, role, chunk_id); + +-- 删块时级联要按块找 +CREATE INDEX statement_qualifiers_chunk_fk_idx ON public.statement_qualifiers (chunk_id); + +-- 实体值与块都必须与所属事实同库 +CREATE OR REPLACE 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 + 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.chunks WHERE id = NEW.chunk_id; + IF ref_kb IS NOT NULL AND ref_kb <> fact_kb THEN + RAISE EXCEPTION 'statement qualifier chunk % cannot live outside the fact''s knowledge base', + NEW.chunk_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 'statement qualifier entity % cannot live outside the fact''s knowledge base', + NEW.entity_id; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER statement_qualifiers_same_kb + BEFORE INSERT OR UPDATE OF fact_id, entity_id, chunk_id ON public.statement_qualifiers + FOR EACH ROW EXECUTE FUNCTION public.squalifier_stays_inside_its_facts_kb();