From 9bd9909da2740dbf16337683819ba8068b7c744b Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Thu, 3 Sep 2026 12:47:16 +0800 Subject: [PATCH] A declared disjointness keeps same names apart (B3) Co-Authored-By: Claude Fable 5.1 --- crates/utopia-store/src/resolution.rs | 77 +++++-- ...declared_disjointness_keeps_names_apart.rs | 198 ++++++++++++++++++ docs/decisions/0009-no-type-is-a-type.md | 14 +- ...-the-open-seams-before-cutting-new-ones.md | 8 +- docs/decisions/README.md | 4 +- 5 files changed, 280 insertions(+), 21 deletions(-) create mode 100644 crates/utopia-store/tests/a_declared_disjointness_keeps_names_apart.rs diff --git a/crates/utopia-store/src/resolution.rs b/crates/utopia-store/src/resolution.rs index f82377d9..9d886a66 100644 --- a/crates/utopia-store/src/resolution.rs +++ b/crates/utopia-store/src/resolution.rs @@ -107,10 +107,12 @@ pub fn recall_keys(name: &str) -> Vec { /// 易混具体类型:抽取常在这几类间摇摆(一个团队算组织还是项目?平台算项目还是产品?)。 /// 同名跨这组类型 → 照建实体(宁分勿合),但入队审核对交 LLM/人工裁决。 /// -/// **这张表该从本体读,今天还没有。** `owl:disjointWith` 已经落库(`entity_type_disjoint`, -/// 有导入有编辑),但消费者只有推理机的本体自检;消解这边仍按这三个硬编码的 key 判。 -/// 没装包的库里这三个 key 不存在,于是这一档永不命中,所有跨类型同名判 `Disjoint`—— -/// 变严不变松,不会错合。改从本体读是 0016 的 B3。 +/// **本体说了算,这张表只是没声明时的退路。** 判两个类能不能指同一个东西,先看 +/// `owl:disjointWith`(`entity_type_disjoint`,含继承:Person ⟂ Organization 就让 +/// Corporation ⟂ Person)——声明了互斥的一律分开,哪怕它们在类层级上是一家; +/// 没声明的再看类层级(同一支系当易混,#226),最后才是这三个硬 key。 +/// 没装包也没声明的库里这三个 key 不存在,于是所有跨类型同名判 `Disjoint`—— +/// 变严不变松,不会错合(0016 B3) pub const CONFUSABLE_TYPE_KEYS: &[&str] = &["organization", "project", "product"]; /// 单次消解最多入队的漂移审核对(防同名大组刷爆审核队列)。 @@ -352,6 +354,9 @@ const CONTAIN_SCAN_LIMIT: i64 = 16; /// 所以这里靠 `kb_id` 收窄行集并设上限,且只在**新建实体时**跑一次, /// 不是每条 mention。大库上如果不够,正解是建一张「后缀键」表走等值查, /// 而不是加模糊索引。 +/// 包含扫描的一行:(id, 本名, 类型 key, 类型 id, 画像)。类型 id 用来比本体声明的互斥 +type ContainRow = (Uuid, String, Option, Option, Option); + async fn containment_reviews( pool: &PgPool, kb_id: Uuid, @@ -379,9 +384,11 @@ async fn containment_reviews( // 启明 X7 加速卡→concept vs 启明 X7 推理加速卡→product), // 按类型相等去查,这两对一个都捞不到。相容性交给下面的 classify_type_drift。 // 多取一些行,因为硬互斥的会在 Rust 侧被筛掉 + // 本体声明了跟这个类互斥的那些类(含继承),一次取出,逐行比 id + let disjoint = declared_disjoint_from(pool, kb_id, type_id).await?; // 第三列可空:未分类实体也要参与包含关系扫描(0009) - let rows: Vec<(Uuid, String, Option, Option)> = sqlx::query_as( - "SELECT e.id, e.canonical_name, t.key, e.profile_embedding + let rows: Vec = sqlx::query_as( + "SELECT e.id, e.canonical_name, t.key, e.type_id, e.profile_embedding 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 e.id <> $2 @@ -419,12 +426,15 @@ async fn containment_reviews( Ok(rows .into_iter() // 哪些类型对可能指同一个东西,既有规则已经想清楚了,别另发明一套: - // person vs organization 永不合并,concept 兜底与谁都可能是一个 - .filter(|(_, _, type_key, _)| { - classify_type_drift(mention_key.as_deref(), type_key.as_deref()) != TypeDrift::Disjoint + // 本体声明互斥的永不合并,person vs organization 永不合并, + // concept 兜底与谁都可能是一个 + .filter(|(_, _, type_key, other_type, _)| { + !other_type.is_some_and(|t| disjoint.contains(&t)) + && classify_type_drift(mention_key.as_deref(), type_key.as_deref()) + != TypeDrift::Disjoint }) .take(MAX_CONTAIN_REVIEWS) - .map(|(id, other_name, _, emb)| { + .map(|(id, other_name, _, _, emb)| { // 分数只是给队列排序用的参考,**不参与是否合并的判断**—— // 那个判断本来就不在这条路上 let score = ctx @@ -466,6 +476,43 @@ fn drift_reason(mention_key: Option<&str>, other_key: Option<&str>, sim: Option< } } +/// 本体声明了跟这个类互斥的全部类(0016 B3)。 +/// +/// **互斥是继承的**:Person ⟂ Organization 一条声明,就让 Person 的每个子类跟 +/// Organization 的每个子类都互斥。所以先沿父链往上收集这个类的祖先,取它们声明的 +/// 互斥对象,再沿子链往下展开。表里两个方向各存一行,问一个方向就够。 +/// +/// 没判出类型(`None`)时没有类可问,回空集:那一侧本来就走召回候选那一档 +async fn declared_disjoint_from( + pool: &PgPool, + kb_id: Uuid, + type_id: Option, +) -> AppResult> { + let Some(type_id) = type_id else { + return Ok(HashSet::new()); + }; + let rows: Vec<(Uuid,)> = sqlx::query_as( + "WITH RECURSIVE up(id) AS ( + SELECT $2::uuid + UNION + SELECT p.parent_id FROM entity_type_parents p JOIN up ON p.child_id = up.id + ), hit(id) AS ( + SELECT d.b_id FROM entity_type_disjoint d JOIN up ON d.a_id = up.id + WHERE d.kb_id = $1 + ), down(id) AS ( + SELECT id FROM hit + UNION + SELECT p.child_id FROM entity_type_parents p JOIN down ON p.parent_id = down.id + ) + SELECT id FROM down", + ) + .bind(kb_id) + .bind(type_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + /// 两个类是不是一家的:一方是另一方的祖先,或者两者共有一个**不是根**的祖先。 /// /// `CONFUSABLE_TYPE_KEYS` 那张三 key 的硬表是给没装包的库准备的;装了 schema.org @@ -561,12 +608,18 @@ async fn resolve_type_drift( .fetch_all(pool) .await?; + // 本体声明了互斥的类,一次取出(含继承)。声明优先于下面所有启发式 + let disjoint = declared_disjoint_from(pool, kb_id, type_id).await?; let mut recall_cands: Vec<&CrossCandidate> = Vec::new(); let mut review_cands: Vec<&CrossCandidate> = Vec::new(); for c in &cross { let mut drift = classify_type_drift(mention_key.as_deref(), c.type_key.as_deref()); - // 硬表判不上的,再看类层级:同一支系下的同名当易混,进审阅队列 - if drift == TypeDrift::Disjoint { + if c.type_id.is_some_and(|t| disjoint.contains(&t)) { + // 本体说这两类互斥:哪怕硬表说易混、类层级说一家,也分开。 + // 声明是人写下的判断,启发式只是没声明时的猜测 + drift = TypeDrift::Disjoint; + } else if drift == TypeDrift::Disjoint { + // 硬表判不上的,再看类层级:同一支系下的同名当易混,进审阅队列 if let (Some(a), Some(b)) = (type_id, c.type_id) { if types_are_kin(pool, a, b).await? { drift = TypeDrift::Review; diff --git a/crates/utopia-store/tests/a_declared_disjointness_keeps_names_apart.rs b/crates/utopia-store/tests/a_declared_disjointness_keeps_names_apart.rs new file mode 100644 index 00000000..112f8323 --- /dev/null +++ b/crates/utopia-store/tests/a_declared_disjointness_keeps_names_apart.rs @@ -0,0 +1,198 @@ +//! 0016 B3:`owl:disjointWith` 进消解——本体声明了互斥的两个类,同名也不进审阅队列。 +//! +//! 消解判「两个类能不能指同一个东西」有三层:硬表 `CONFUSABLE_TYPE_KEYS`、类层级 +//! (同一支系当易混,#226)、本体声明的互斥。这里守的是**声明优先于前两层**: +//! +//! 1. 没声明时行为不变:organization vs project 照硬表进队列,corporation vs +//! federal_agency 照类层级(共有非根祖先 organization)进队列。 +//! 2. 声明 organization ⟂ project 之后,同名的 organization / project 分开,不进队列。 +//! 3. 声明 corporation ⟂ agency 之后,federal_agency(agency 的子类)跟 corporation +//! 也分开——**互斥是继承的**,声明在父类上就够。 +//! +//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 + +use sqlx::PgPool; +use utopia_store::{ontology, resolution}; +use uuid::Uuid; + +struct Fx { + org: Uuid, + kb: Uuid, + organization: Uuid, + project: Uuid, + corporation: Uuid, + agency: Uuid, + federal_agency: Uuid, +} + +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + // 顶上要有一个根:类层级那条规则里「共有的祖先」不算根(schema.org 里万物皆 + // Thing,算上它 Person 与 Organization 也成了一家),所以 organization 得有父类 + let (thing, organization, project, corporation, agency, federal_agency) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'disjoint-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'disjoint-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'disjoint-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + for (id, key) in [ + (thing, "thing"), + (organization, "organization"), + (project, "project"), + (corporation, "corporation"), + (agency, "agency"), + (federal_agency, "federal_agency"), + ] { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, $3)") + .bind(id) + .bind(kb) + .bind(key) + .execute(pool) + .await?; + } + for (child, parent) in [ + (organization, thing), + (project, thing), + (corporation, organization), + (agency, organization), + (federal_agency, agency), + ] { + sqlx::query("INSERT INTO entity_type_parents (child_id, parent_id) VALUES ($1, $2)") + .bind(child) + .bind(parent) + .execute(pool) + .await?; + } + Ok(Fx { + org, + kb, + organization, + project, + corporation, + agency, + federal_agency, + }) +} + +async fn entity(pool: &PgPool, f: &Fx, name: &str, type_id: Uuid) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(f.kb) + .bind(type_id) + .bind(name) + .execute(pool) + .await?; + Ok(id) +} + +/// 消解一条 mention,回它挂上的「类型漂移」审核对指向谁 +async fn drift_reviews( + pool: &PgPool, + f: &Fx, + name: &str, + type_id: Uuid, +) -> anyhow::Result> { + let r = resolution::resolve_mention(pool, f.kb, Some(type_id), name, None).await?; + assert!( + r.created, + "a cross-type same name is a new entity: keep apart, never merge" + ); + Ok(r.reviews + .iter() + .filter(|x| x.reason.starts_with("type_drift|")) + .map(|x| x.other_id) + .collect()) +} + +#[tokio::test] +async fn a_declared_disjointness_keeps_names_apart() -> anyhow::Result<()> { + let Ok(url) = std::env::var("UTOPIA_DATABASE_URL") else { + eprintln!("跳过:未设 UTOPIA_DATABASE_URL"); + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + // 1. 没声明:硬表与类层级照旧 + let orion = entity(&pool, &f, "Orion", f.organization).await?; + assert_eq!( + drift_reviews(&pool, &f, "Orion", f.project).await?, + vec![orion], + "organization vs project is confusable by the hard-coded list" + ); + let acme = entity(&pool, &f, "Acme", f.corporation).await?; + assert_eq!( + drift_reviews(&pool, &f, "Acme", f.federal_agency).await?, + vec![acme], + "corporation vs federal_agency share the ancestor organization: kin, so Review" + ); + + // 2. 声明 organization ⟂ project:硬表说易混,本体说互斥——本体赢 + ontology::set_disjoint_for(&pool, f.kb, f.organization, &[f.project]).await?; + let _vega = entity(&pool, &f, "Vega", f.organization).await?; + assert!( + drift_reviews(&pool, &f, "Vega", f.project) + .await? + .is_empty(), + "a declared disjointness wins over the hard-coded list" + ); + + // 3. 声明 corporation ⟂ agency:federal_agency 是 agency 的子类,互斥继承下来, + // 类层级说一家也不算 + ontology::set_disjoint_for(&pool, f.kb, f.corporation, &[f.agency]).await?; + let _beta = entity(&pool, &f, "Beta", f.corporation).await?; + assert!( + drift_reviews(&pool, &f, "Beta", f.federal_agency) + .await? + .is_empty(), + "a disjointness declared on the parent reaches the child and wins over kinship" + ); + // 反过来问也一样:表里两个方向各一行,继承沿另一头的祖先链走 + let _gamma = entity(&pool, &f, "Gamma", f.federal_agency).await?; + assert!( + drift_reviews(&pool, &f, "Gamma", f.corporation) + .await? + .is_empty(), + "the declaration holds from either side" + ); + + // 4. 取消声明,回到没声明时的行为——编辑必须能撤 + ontology::set_disjoint_for(&pool, f.kb, f.corporation, &[]).await?; + let delta = entity(&pool, &f, "Delta", f.corporation).await?; + assert_eq!( + drift_reviews(&pool, &f, "Delta", f.federal_agency).await?, + vec![delta], + "with the declaration gone, kinship sends the pair to Review again" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + + let _ = sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(&pool) + .await; + run +} diff --git a/docs/decisions/0009-no-type-is-a-type.md b/docs/decisions/0009-no-type-is-a-type.md index 9498662c..38a928d4 100644 --- a/docs/decisions/0009-no-type-is-a-type.md +++ b/docs/decisions/0009-no-type-is-a-type.md @@ -2,9 +2,11 @@ - **Status**: Implemented · `entities.type_id` and `entity_retypes.from_type_id` are nullable; the nine builtin classes and the seeding function left with the seed relations - (#110 / #125 / #128 / [0011](0011-a-mapping-is-not-a-fact.md)) · `owl:disjointWith` is - stored but resolution still reads the hard-coded `CONFUSABLE_TYPE_KEYS`; since #226 same-name - entities of kin classes (ancestor, descendant or a shared non-root ancestor) go to Review · `metric` / + (#110 / #125 / #128 / [0011](0011-a-mapping-is-not-a-fact.md)) · `owl:disjointWith` now + reaches resolution (0016 B3): a declared disjointness, inherited down both hierarchies, keeps + same-name entities apart ahead of every heuristic; since #226 same-name entities of kin classes + (ancestor, descendant or a shared non-root ancestor) go to Review; `CONFUSABLE_TYPE_KEYS` stays + as the fallback when nothing is declared · `metric` / `dimension` are created on demand by mapping exploration (#231); a semantic-layer pack is still planned - **Written**: 2026-08-30 · condensed into English 2026-09-03 @@ -82,6 +84,12 @@ in the ontology as a class, as if someone had decided it. - 2026-09-02: `owl:disjointWith` now has a table (`entity_type_disjoint`), import and an edit endpoint, but its only consumer is R0's ontology self-check; `CONFUSABLE_TYPE_KEYS` is still three hard-coded keys (0016 B3). +- 2026-09-03: resolution reads the table (0016 B3). `declared_disjoint_from` collects the + classes declared disjoint with the mention's class or any ancestor, expanded to their + descendants, in one recursive query; both cross-type paths (type drift and containment) + treat a hit as `Disjoint` before the kinship check and the hard-coded list. Nothing + declared → today's behavior, unchanged. The list is now a fallback, and decision 6 reads + accordingly. - 2026-09-02: `metric` / `dimension` had a visible cost — mapping exploration looked up `entity_types.key IN ('metric','dimension')` and `continue`d when missing, so a KB without those classes silently produced zero mappings. 2026-09-03: exploration now creates both as diff --git a/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md b/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md index b35d5fd5..40a9d6c9 100644 --- a/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md +++ b/docs/decisions/0016-close-the-open-seams-before-cutting-new-ones.md @@ -1,6 +1,6 @@ # 0016 · Close the open seams before cutting new ones -- **Status**: in progress · the first schedule after v0.1.0 · A1, A2, A3 done · B1 done (#227) · B2 done (#238 / #243, [0017](0017-a-contradiction-points-upstream.md)) · B3: the signature half done (#190 / #196), cross-pack signatures and range-aware direction done (#233), `disjointWith` into resolution still open · D2's blank-base problem worked around with builtin `metric` / `dimension` classes (#231), the pack itself still planned · the lakehouse engines landed ahead of D4 (#239, [0018](0018-the-lakehouse-is-one-protocol-away.md)) +- **Status**: in progress · the first schedule after v0.1.0 · A1, A2, A3 done · B1 done (#227) · B2 done (#238 / #243, [0017](0017-a-contradiction-points-upstream.md)) · B3 done: the signature half (#190 / #196), cross-pack signatures and range-aware direction (#233), `disjointWith` into resolution · D2's blank-base problem worked around with builtin `metric` / `dimension` classes (#231), the pack itself still planned · the lakehouse engines landed ahead of D4 (#239, [0018](0018-the-lakehouse-is-one-protocol-away.md)) - **Written**: 2026-09-02 · condensed into English 2026-09-03 - **Related**: written after checking [0001](0001-ontology-import-and-governance.md) through [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) against the code; every item below has its source in those fifteen records, whose 2026-09-02 revision notes are the check's product. This record only orders them and says why this order. @@ -45,9 +45,9 @@ A4 README against code, both languages: promise only what has landed. **B · Finish the reasoning engine**, after A, parallel with C. B1 the R2 proof chain (done, #227; a chain, not a tree — see [0002](0002-reasoning-engine.md)). B2 the derived-vs-asserted contradiction signal, `axiom_violations` kind `derived_contradiction` (done: B2a engine and -queue #238, B2b visibility on graph and panel #243 — 0017). B3 `classify_type_drift` reads `entity_type_disjoint` -instead of the hard-coded list, today's behavior when nothing is declared (class kinship from the -hierarchy landed first, #226). B4 R3 incremental +queue #238, B2b visibility on graph and panel #243 — 0017). B3 resolution reads `entity_type_disjoint` +ahead of the hard-coded list (done: a declared disjointness, inherited, wins over kinship and the +list; nothing declared → today's behavior; class kinship from the hierarchy landed first, #226). B4 R3 incremental maintenance, deferred until a full re-derivation of `ai-timeline-ends` exceeds a threshold (proposal: 10 seconds). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b2e8b981..a4c39010 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -32,14 +32,14 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0006 | [Ontology scale and the extraction prompt](0006-ontology-scale-and-the-prompt.md) | Built · the character budget (24,000) and per-chunk retrieval live, values untested · answer keys still hand-filled | | 0007 | [Counting decides what becomes a relation](0007-who-decides-what-becomes-a-relation.md) | Built · adoption decided by counting (`MIN_DOCS = 2`, `MIN_SIGNALS = 3`), proposals persist (#112) · narrative verbs and `_by` folding still open | | 0008 | [Ontology packs as the cold start](0008-ontology-packs-as-cold-start.md) | Built · five packs embedded, multi-select at creation, schema.org by default · three open questions stay open; Chinese labels got worse | -| 0009 | [An undecided type stays empty](0009-no-type-is-a-type.md) | Implemented · `type_id` nullable, builtin classes gone · kin classes go to Review (#226), `disjointWith` still unread by resolution · `metric` / `dimension` builtin on demand (#231) | +| 0009 | [An undecided type stays empty](0009-no-type-is-a-type.md) | Implemented · `type_id` nullable, builtin classes gone · kin classes go to Review (#226), declared `disjointWith` keeps them apart (0016 B3) · `metric` / `dimension` builtin on demand (#231) | | 0010 | [An unnamed relation stays empty](0010-no-relation-is-no-relation.md) | Implemented · `predicate_id` nullable, `related_to` gone, wording recovered by `fact_surface_predicate` · follow-ups done with 0011 | | 0011 | [A mapping is configuration](0011-a-mapping-is-not-a-fact.md) | Implemented (#126 / #140 / #148) · Review flow and revision history rebuilt · the evidence chain not built | | 0012 | [The ontology is a contract](0012-the-ontology-is-a-contract-not-a-suggestion.md) | Implemented · violation rate 57% → 4%, reversals 39 → 0 · guard extended to adoption and merge (#190 / #196) · reified-shell filter at pack import open | | 0013 | [A source hands over its history](0013-a-source-should-hand-over-its-history.md) | Implemented for GitHub, Jira and Notion (#134 / #135 / #213) · Feishu and Confluence not started · `instant` precision not triggered | | 0014 | [Identity from the person, scope from the token](0014-identity-from-the-person-scope-from-the-token.md) | Implemented (#180) · five read-only MCP tools over Streamable HTTP · tokens page at `/account/tokens` · `can_write` still hard-coded false | | 0015 | [A recorded sentence waits for a nod](0015-recording-a-sentence-is-not-asserting-a-fact.md) | Implemented · memory facts wait in `pending_facts`, nod queue and chat card, `remember` reopened · MCP write is the next cut | -| 0016 | [Close the open seams before cutting new ones](0016-close-the-open-seams-before-cutting-new-ones.md) | In progress · A done · B1 and B2 done, `disjointWith` open · C untouched · D2 worked around (#231); the lakehouse landed ahead of D4 (#239) | +| 0016 | [Close the open seams before cutting new ones](0016-close-the-open-seams-before-cutting-new-ones.md) | In progress · A done · B done (B4 deferred) · C untouched · D2 worked around (#231); the lakehouse landed ahead of D4 (#239) | | 0017 | [A contradiction points at an error upstream](0017-a-contradiction-points-upstream.md) | Implemented · B2a: engine and queue, per-item cap, aggregation by rule pair, cards with clues and repairs (#238) · B2b: contested edges in the alert colour, ghost edges for blocked derivations, the disputed chip and the "did not land" section in the panel (#243) | | 0018 | [The lakehouse is one protocol away](0018-the-lakehouse-is-one-protocol-away.md) | Implemented: Trino (Iceberg / Delta / Hive), Databricks and Snowflake behind the same trait, scheme picks the engine (#239) · replay tests only, real clusters wanted (#240–#242) · MaxCompute waits |