From 235fe61e309cc1b8a4ad3235db0e5b9301858981 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 16:07:45 -0400 Subject: [PATCH 1/2] A same name under two undeclared classes queues a review, so ACME-1 typed as a ticket and a work item is not split in silence Signed-off-by: Rudy Celekli --- crates/utopia-store/src/resolution.rs | 59 ++++--------------- ...declared_disjointness_keeps_names_apart.rs | 58 ++++++++++++++++++ 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/crates/utopia-store/src/resolution.rs b/crates/utopia-store/src/resolution.rs index b7f34250a..2645b9cef 100644 --- a/crates/utopia-store/src/resolution.rs +++ b/crates/utopia-store/src/resolution.rs @@ -176,10 +176,9 @@ pub fn recall_keys(normalized: &str) -> Vec { /// /// **本体说了算,这张表只是没声明时的退路。** 判两个类能不能指同一个东西,先看 /// `owl:disjointWith`(`entity_type_disjoint`,含继承:Person ⟂ Organization 就让 -/// Corporation ⟂ Person)——声明了互斥的一律分开,哪怕它们在类层级上是一家; -/// 没声明的再看类层级(同一支系当易混,#226),最后才是这三个硬 key。 -/// 没装包也没声明的库里这三个 key 不存在,于是所有跨类型同名判 `Disjoint`—— -/// 变严不变松,不会错合(0016 B3) +/// Corporation ⟂ Person)——声明了互斥的一律分开,哪怕它们在类层级上是一家。 +/// 没声明互斥、两个类又都判出来了,同名进审阅,不管是不是一家、在不在这张表里 +/// (0041 d4)。这张表仍把 organization / project / product 标成易混。 pub const CONFUSABLE_TYPE_KEYS: &[&str] = &["organization", "project", "product"]; /// 单次消解最多入队的漂移审核对(防同名大组刷爆审核队列)。 @@ -192,7 +191,7 @@ enum TypeDrift { Recall, /// 两个易混具体类型:新建 + 审核对 Review, - /// 硬互斥(person vs organization 等,含未知自定义类型):完全分开 + /// 两个具体类型不在易混表里。消解里只有本体声明了互斥才按这档分开。 Disjoint, } @@ -1012,7 +1011,7 @@ struct CrossCandidate { canonical_name: String, // None = 这个候选还没判出类型(0009) type_key: Option, - // 同上;`types_are_kin` 要按 id 走类层级 + // 同上;声明的互斥按 id 对 type_id: Option, profile_embedding: Option, profile_n: i32, @@ -1068,40 +1067,6 @@ async fn declared_disjoint_from( Ok(rows.into_iter().map(|(id,)| id).collect()) } -/// 两个类是不是一家的:一方是另一方的祖先,或者两者共有一个**不是根**的祖先。 -/// -/// `CONFUSABLE_TYPE_KEYS` 那张三 key 的硬表是给没装包的库准备的;装了 schema.org -/// 之后同一家公司会被抽成 Organization / Corporation / OnlineBusiness 三种类型, -/// 全都在 Organization 之下,却一对都过不了硬表,于是三个同名实体并存、 -/// 审阅队列里一条都没有,面板还指着 Review 说"去那里合并"(#226)。 -/// -/// 根不算共同祖先:schema.org 里万物皆 Thing,算上它 Person 与 Organization -/// 也成了一家。没有根的词汇表(W3C Org 的 Organization 自己就是顶)靠 -/// 祖先/后代那一半接住 -async fn types_are_kin(pool: &PgPool, a: Uuid, b: Uuid) -> AppResult { - let (kin,): (bool,) = sqlx::query_as( - "WITH RECURSIVE up_a(id) AS ( - SELECT $1::uuid - UNION - SELECT p.parent_id FROM entity_type_parents p JOIN up_a ON p.child_id = up_a.id - ), up_b(id) AS ( - SELECT $2::uuid - UNION - SELECT p.parent_id FROM entity_type_parents p JOIN up_b ON p.child_id = up_b.id - ) - SELECT EXISTS (SELECT 1 FROM up_a WHERE id = $2) - OR EXISTS (SELECT 1 FROM up_b WHERE id = $1) - OR EXISTS (SELECT 1 FROM up_a JOIN up_b USING (id) - WHERE EXISTS (SELECT 1 FROM entity_type_parents p - WHERE p.child_id = up_a.id))", - ) - .bind(a) - .bind(b) - .fetch_one(pool) - .await?; - Ok(kin) -} - fn confusable_reviews( // None = 这一侧还没判出类型(0009) mention_key: Option<&str>, @@ -1161,7 +1126,8 @@ async fn adopt_type( /// 同类型召回为空时的跨类型处置(类型漂移)。 /// concept 兜底型候选跑既有画像分层:高相似直接 ATTACH(漂移的召回修复,concept /// 侧类型升格为具体类型),灰区/无从判别 → 宁分勿合新建 + 审核对;易混具体类型 -/// 一律新建 + 审核对(同名 + 类型摇摆本身就是信号,不设相似度门槛);硬互斥忽略。 +/// 一律新建 + 审核对(同名 + 类型摇摆本身就是信号,不设相似度门槛);本体声明了 +/// 互斥的忽略,其余两个都判出来的类进审阅。 #[allow(clippy::too_many_arguments)] async fn resolve_type_drift( pool: &PgPool, @@ -1215,13 +1181,10 @@ async fn resolve_type_drift( // 本体说这两类互斥:哪怕硬表说易混、类层级说一家,也分开。 // 声明是人写下的判断,启发式只是没声明时的猜测 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; - } - } + } else if drift == TypeDrift::Disjoint && type_id.is_some() && c.type_id.is_some() { + // 硬表判不上、本体也没声明互斥。一家的和两家都不是的都进审阅: + // 只有声明的互斥才排除候选(0041 d4)。一侧还没判出类型的走 Recall。 + drift = TypeDrift::Review; } match drift { TypeDrift::Recall => recall_cands.push(c), diff --git a/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs b/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs index f56f1fe3f..07d5783b3 100644 --- a/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs +++ b/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs @@ -197,6 +197,64 @@ async fn a_declared_disjointness_keeps_names_apart() -> anyhow::Result<()> { run } +/// 0041 d4:只有声明的互斥才排除候选(#1338)。两个类既没声明互斥、也不是一家、 +/// 也不在硬表里,同名照样新建,但要进审阅队列,不能被当成互斥静默分开 +#[tokio::test] +async fn an_undeclared_class_pair_queues_a_drift_review() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + let thing: Uuid = sqlx::query_scalar( + "SELECT id FROM entity_types WHERE kb_id = $1 AND key = 'thing'", + ) + .bind(f.kb) + .fetch_one(&pool) + .await?; + let (ticket, work_item) = (Uuid::now_v7(), Uuid::now_v7()); + for (id, key) in [(ticket, "ticket"), (work_item, "work_item")] { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, $3)") + .bind(id) + .bind(f.kb) + .bind(key) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO entity_type_parents (child_id, parent_id) VALUES ($1, $2)") + .bind(id) + .bind(thing) + .execute(&pool) + .await?; + } + + let acme = entity(&pool, &f, "ACME-1", ticket).await?; + assert_eq!( + drift_reviews(&pool, &f, "ACME-1", work_item).await?, + vec![acme], + "nothing declares ticket disjoint from work_item: the namesake goes to Review" + ); + + ontology::set_disjoint_for(&pool, f.kb, ticket, &[work_item]).await?; + let _acme2 = entity(&pool, &f, "ACME-2", ticket).await?; + assert!( + drift_reviews(&pool, &f, "ACME-2", work_item) + .await? + .is_empty(), + "once declared disjoint, the pair stays apart" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + + let _ = sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(&pool) + .await; + run +} + /// 名字向量那条通道也听声明(#1293):project 认不出大类,从前声明了互斥照样排对 #[tokio::test] async fn a_declared_disjointness_keeps_near_names_apart() -> anyhow::Result<()> { From e8141ad34ba6ddae2187e660bc1c9cb9a12153d5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 25 Oct 2026 16:09:20 -0400 Subject: [PATCH 2/2] Format the undeclared-class review test Signed-off-by: Rudy Celekli --- .../a_declared_disjointness_keeps_names_apart.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs b/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs index 07d5783b3..48ab0878b 100644 --- a/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs +++ b/crates/utopia-store/tests/store/a_declared_disjointness_keeps_names_apart.rs @@ -208,12 +208,11 @@ async fn an_undeclared_class_pair_queues_a_drift_review() -> anyhow::Result<()> let f = seed(&pool).await?; let run = async { - let thing: Uuid = sqlx::query_scalar( - "SELECT id FROM entity_types WHERE kb_id = $1 AND key = 'thing'", - ) - .bind(f.kb) - .fetch_one(&pool) - .await?; + let thing: Uuid = + sqlx::query_scalar("SELECT id FROM entity_types WHERE kb_id = $1 AND key = 'thing'") + .bind(f.kb) + .fetch_one(&pool) + .await?; let (ticket, work_item) = (Uuid::now_v7(), Uuid::now_v7()); for (id, key) in [(ticket, "ticket"), (work_item, "work_item")] { sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, $3)")