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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 65 additions & 12 deletions crates/utopia-store/src/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@ pub fn recall_keys(name: &str) -> Vec<String> {
/// 易混具体类型:抽取常在这几类间摇摆(一个团队算组织还是项目?平台算项目还是产品?)。
/// 同名跨这组类型 → 照建实体(宁分勿合),但入队审核对交 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"];

/// 单次消解最多入队的漂移审核对(防同名大组刷爆审核队列)。
Expand Down Expand Up @@ -352,6 +354,9 @@ const CONTAIN_SCAN_LIMIT: i64 = 16;
/// 所以这里靠 `kb_id` 收窄行集并设上限,且只在**新建实体时**跑一次,
/// 不是每条 mention。大库上如果不够,正解是建一张「后缀键」表走等值查,
/// 而不是加模糊索引。
/// 包含扫描的一行:(id, 本名, 类型 key, 类型 id, 画像)。类型 id 用来比本体声明的互斥
type ContainRow = (Uuid, String, Option<String>, Option<Uuid>, Option<Vector>);

async fn containment_reviews(
pool: &PgPool,
kb_id: Uuid,
Expand Down Expand Up @@ -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<String>, Option<Vector>)> = sqlx::query_as(
"SELECT e.id, e.canonical_name, t.key, e.profile_embedding
let rows: Vec<ContainRow> = 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Uuid>,
) -> AppResult<HashSet<Uuid>> {
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
Expand Down Expand Up @@ -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;
Expand Down
198 changes: 198 additions & 0 deletions crates/utopia-store/tests/a_declared_disjointness_keeps_names_apart.rs
Original file line number Diff line number Diff line change
@@ -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<Fx> {
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<Uuid> {
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<Vec<Uuid>> {
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
}
14 changes: 11 additions & 3 deletions docs/decisions/0009-no-type-is-a-type.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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).

Expand Down
Loading
Loading