Skip to content

Commit fbe13bd

Browse files
AschenbathclaudeWaylandYang
authored
A merge keeps the identity evidence of the name it dedupes (#1466)
Signed-off-by: Aschenbath <Aschenbath@users.noreply.github.com> Co-authored-by: Aschenbath <Aschenbath@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com>
1 parent c34dd04 commit fbe13bd

7 files changed

Lines changed: 216 additions & 8 deletions

File tree

‎crates/utopia-cli/src/main.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ struct ManifestDataDir {
8282
/// not a side effect of a code change.
8383
// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current`
8484
// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71
85-
const CURRENT_SCHEMA_VERSION: u32 = 110;
85+
const CURRENT_SCHEMA_VERSION: u32 = 111;
8686

8787
#[tokio::main]
8888
async fn main() -> anyhow::Result<()> {

‎crates/utopia-store/src/name_vectors.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,24 @@ pub async fn drop_for_document(pool: &PgPool, document_id: Uuid) -> AppResult<()
107107
Ok(())
108108
}
109109

110+
/// 合并把重复名字的证据抄到了存活的名字上(#1426):其中有了身份证据的,删掉向量
111+
pub async fn drop_with_identity_evidence(
112+
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
113+
facts: &[Uuid],
114+
) -> AppResult<()> {
115+
if facts.is_empty() {
116+
return Ok(());
117+
}
118+
sqlx::query(concat!(
119+
"DELETE FROM name_vectors v WHERE v.fact_id = ANY($1) AND ",
120+
has_identity_evidence!("v.fact_id")
121+
))
122+
.bind(facts)
123+
.execute(&mut **tx)
124+
.await?;
125+
Ok(())
126+
}
127+
110128
/// 写一批名字向量:`(fact_id, entity_id, embedding)`。同一条事实重写就覆盖(换了模型
111129
/// 重算)。第一次写下这个维度的向量,索引就该排上了(0035)。
112130
/// 写的时候再查一次身份证据:补向量算到一半,来源开了身份名或身份文档抽完删过一轮,

‎crates/utopia-store/src/resolution.rs‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2231,6 +2231,7 @@ pub async fn merge_entities(
22312231
.fetch_all(&mut *tx)
22322232
.await?;
22332233
let mut dup_ids = Vec::new();
2234+
let (mut copied_to, mut copied_chunks) = (Vec::new(), Vec::new());
22342235
for (ids,) in dups {
22352236
// 先给存活行接上来源,否则下一次物化可能作废它,再按原文重建,丢掉人的修正。
22362237
// 旧行的链接暂留:撤回合并还用得上;物化按它原有的规则清理失效链接。
@@ -2243,6 +2244,25 @@ pub async fn merge_entities(
22432244
.bind(&ids[1..])
22442245
.execute(&mut *tx)
22452246
.await?;
2247+
// 证据也抄一份(#1426):身份来源的名字证据要是只留在作废的那条上,存活方就不再是
2248+
// 身份实体,精确名挂靠和合并前的人工把关(0071 决定 2、7)都认不出它。
2249+
// 同一分块存活行已有的不动;抄过去的记进账本,撤回合并时只删这些
2250+
let copied: Vec<Uuid> = sqlx::query_scalar(
2251+
"INSERT INTO fact_evidence (fact_id, chunk_id, quote, document_id, doc_version,
2252+
proposed_predicate, quote_start, quote_end, attested_at, attested_by)
2253+
SELECT DISTINCT ON (chunk_id) $1, chunk_id, quote, document_id, doc_version,
2254+
proposed_predicate, quote_start, quote_end, attested_at, attested_by
2255+
FROM fact_evidence WHERE fact_id = ANY($2)
2256+
ORDER BY chunk_id, array_position($2, fact_id)
2257+
ON CONFLICT DO NOTHING
2258+
RETURNING chunk_id",
2259+
)
2260+
.bind(ids[0])
2261+
.bind(&ids[1..])
2262+
.fetch_all(&mut *tx)
2263+
.await?;
2264+
copied_to.extend(std::iter::repeat_n(ids[0], copied.len()));
2265+
copied_chunks.extend(copied);
22462266
dup_ids.extend_from_slice(&ids[1..]);
22472267
}
22482268
if !dup_ids.is_empty() {
@@ -2252,6 +2272,8 @@ pub async fn merge_entities(
22522272
.await?;
22532273
invalidated.extend(dup_ids);
22542274
}
2275+
// 抄过去的若是身份证据,存活的名字就不该再留向量(0071 决定 5)
2276+
crate::name_vectors::drop_with_identity_evidence(&mut tx, &copied_to).await?;
22552277

22562278
// 画像加权合并
22572279
let (profile, profile_n) = match (&target.profile_embedding, &source.profile_embedding) {
@@ -2356,8 +2378,8 @@ pub async fn merge_entities(
23562378
"INSERT INTO entity_merges (id, kb_id, source_id, target_id,
23572379
moved_subject_facts, moved_object_facts, invalidated_facts,
23582380
target_profile_before, target_profile_n_before, target_type_before,
2359-
merged_by, reason)
2360-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
2381+
merged_by, reason, copied_evidence_facts, copied_evidence_chunks)
2382+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
23612383
)
23622384
.bind(merge_id)
23632385
.bind(kb_id)
@@ -2371,6 +2393,8 @@ pub async fn merge_entities(
23712393
.bind(target.type_id)
23722394
.bind(merged_by)
23732395
.bind(reason)
2396+
.bind(&copied_to)
2397+
.bind(&copied_chunks)
23742398
.execute(&mut *tx)
23752399
.await?;
23762400

@@ -2430,6 +2454,8 @@ struct MergeRow {
24302454
moved_subject_facts: Vec<Uuid>,
24312455
moved_object_facts: Vec<Uuid>,
24322456
invalidated_facts: Vec<Uuid>,
2457+
copied_evidence_facts: Vec<Uuid>,
2458+
copied_evidence_chunks: Vec<Uuid>,
24332459
target_profile_before: Option<Vector>,
24342460
target_profile_n_before: i32,
24352461
target_type_before: Option<Uuid>,
@@ -2470,8 +2496,8 @@ async fn with_rewrites(
24702496
pub async fn revert_merge(pool: &PgPool, kb_id: Uuid, merge_id: Uuid) -> AppResult<()> {
24712497
let m: MergeRow = sqlx::query_as(
24722498
"SELECT source_id, target_id, moved_subject_facts, moved_object_facts,
2473-
invalidated_facts, target_profile_before,
2474-
target_profile_n_before, target_type_before, reverted_at
2499+
invalidated_facts, copied_evidence_facts, copied_evidence_chunks,
2500+
target_profile_before, target_profile_n_before, target_type_before, reverted_at
24752501
FROM entity_merges WHERE id = $1 AND kb_id = $2",
24762502
)
24772503
.bind(merge_id)
@@ -2546,6 +2572,23 @@ pub async fn revert_merge(pool: &PgPool, kb_id: Uuid, merge_id: Uuid) -> AppResu
25462572
crate::temporal::rehome_tx(&mut tx, id, subject, object).await?;
25472573
}
25482574
}
2575+
// 合并时抄到存活行上的证据删掉:重复的那条复活了,证据还在它身上(#1426)。
2576+
// 存活行之后可能被改写过(人改区间、时间线闭合、上面的搬回),证据随行复制到了新行上,
2577+
// 所以顺着 supersedes 删到底,跟 `with_rewrites` 同一条链。
2578+
// 存活的名字因此不再有身份证据的话,它重新等着补向量,跟来源关掉身份名一样(0071 决定 6)
2579+
sqlx::query(
2580+
"WITH RECURSIVE chain(fact_id, chunk_id) AS (
2581+
SELECT * FROM unnest($1::uuid[], $2::uuid[])
2582+
UNION
2583+
SELECT f.id, c.chunk_id FROM facts f JOIN chain c ON f.supersedes = c.fact_id
2584+
)
2585+
DELETE FROM fact_evidence fe USING chain c
2586+
WHERE fe.fact_id = c.fact_id AND fe.chunk_id = c.chunk_id",
2587+
)
2588+
.bind(&m.copied_evidence_facts)
2589+
.bind(&m.copied_evidence_chunks)
2590+
.execute(&mut *tx)
2591+
.await?;
25492592
sqlx::query("UPDATE facts SET invalidated_at = NULL WHERE id = ANY($1)")
25502593
.bind(&m.invalidated_facts)
25512594
.execute(&mut *tx)

‎crates/utopia-store/tests/store/an_identity_name_attaches_by_its_exact_name.rs‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,140 @@ async fn turning_the_flag_on_drops_the_sources_name_vectors_and_off_makes_them_p
272272
})
273273
.await
274274
}
275+
276+
/// `entity` 现行的名字事实。
277+
async fn live_name(pool: &PgPool, entity: Uuid) -> anyhow::Result<Uuid> {
278+
Ok(sqlx::query_scalar(
279+
"SELECT f.id FROM facts f JOIN relation_types r ON r.id = f.predicate_id
280+
WHERE f.subject_id = $1 AND r.key = $2 AND f.invalidated_at IS NULL",
281+
)
282+
.bind(entity)
283+
.bind(utopia_store::names::KNOWN_AS)
284+
.fetch_one(pool)
285+
.await?)
286+
}
287+
288+
/// #1426:人把身份来源的实体并进一个早就从散文里记下同名的实体。存活的名字留住身份证据,
289+
/// 它仍是身份实体;撤回合并只收回抄过去的那些。
290+
#[tokio::test]
291+
async fn a_merge_keeps_the_identity_evidence_of_the_name_it_dedupes() -> anyhow::Result<()> {
292+
use utopia_store::execution_gate::{hold_for_merge, Hold};
293+
use utopia_store::resolution::{merge_entities, revert_merge};
294+
in_kb(|pool, kb, project, _| async move {
295+
let (_, identity) = chunk(&pool, kb, Some(json!({"names": "identity"}))).await?;
296+
let (_, prose) = chunk(&pool, kb, None).await?;
297+
let keeper = holder(&pool, kb, Some(project), "acme/a.ts").await?;
298+
let kept_name = named(&pool, kb, keeper, "acme/a.ts", &[prose]).await?;
299+
name_vectors::set(&pool, kb, &[(kept_name, keeper, vec![1.0, 0.0, 0.0])]).await?;
300+
let pushed = holder(&pool, kb, Some(project), "acme/a.ts").await?;
301+
named(&pool, kb, pushed, "acme/a.ts", &[identity]).await?;
302+
let bystander = holder(&pool, kb, Some(project), "acme/z.ts").await?;
303+
let evidence = |fact: Uuid| {
304+
let pool = pool.clone();
305+
async move {
306+
let mut chunks: Vec<Uuid> =
307+
sqlx::query_scalar("SELECT chunk_id FROM fact_evidence WHERE fact_id = $1")
308+
.bind(fact)
309+
.fetch_all(&pool)
310+
.await?;
311+
chunks.sort();
312+
anyhow::Ok(chunks)
313+
}
314+
};
315+
let mut both = vec![identity, prose];
316+
both.sort();
317+
318+
let merge = merge_entities(&pool, kb, pushed, keeper, None, "test").await?;
319+
assert_eq!(live_name(&pool, keeper).await?, kept_name);
320+
assert_eq!(evidence(kept_name).await?, both);
321+
assert_eq!(
322+
hold_for_merge(&pool, kb, keeper, bystander).await?,
323+
Some(Hold::Identity)
324+
);
325+
assert_eq!(vectors(&pool, kb).await?, Vec::<Uuid>::new());
326+
327+
revert_merge(&pool, kb, merge).await?;
328+
assert_eq!(evidence(kept_name).await?, vec![prose]);
329+
assert_eq!(
330+
evidence(live_name(&pool, pushed).await?).await?,
331+
vec![identity]
332+
);
333+
assert_ne!(
334+
hold_for_merge(&pool, kb, keeper, bystander).await?,
335+
Some(Hold::Identity)
336+
);
337+
assert!(pending_entities(&pool, kb).await?.contains(&keeper));
338+
Ok(())
339+
})
340+
.await
341+
}
342+
343+
/// 存活行原本就有的同一分块证据是它自己的,撤回合并不动。
344+
#[tokio::test]
345+
async fn undoing_a_merge_leaves_the_survivors_own_evidence() -> anyhow::Result<()> {
346+
use utopia_store::resolution::{merge_entities, revert_merge};
347+
in_kb(|pool, kb, project, _| async move {
348+
let (_, prose) = chunk(&pool, kb, None).await?;
349+
let keeper = holder(&pool, kb, Some(project), "acme/a.ts").await?;
350+
let kept_name = named(&pool, kb, keeper, "acme/a.ts", &[prose]).await?;
351+
let other = holder(&pool, kb, Some(project), "acme/a.ts").await?;
352+
named(&pool, kb, other, "acme/a.ts", &[prose]).await?;
353+
let merge = merge_entities(&pool, kb, other, keeper, None, "test").await?;
354+
revert_merge(&pool, kb, merge).await?;
355+
let kept: Vec<Uuid> =
356+
sqlx::query_scalar("SELECT chunk_id FROM fact_evidence WHERE fact_id = $1")
357+
.bind(kept_name)
358+
.fetch_all(&pool)
359+
.await?;
360+
assert_eq!(kept, vec![prose]);
361+
Ok(())
362+
})
363+
.await
364+
}
365+
366+
/// 合并之后存活的名字可能被改写:人改了区间,证据随行到新行上。撤回合并要从现在持有它的
367+
/// 那一行收回,不管存活行原本属于哪一边。
368+
#[tokio::test]
369+
async fn undoing_a_merge_takes_the_copied_evidence_off_a_rewritten_name() -> anyhow::Result<()> {
370+
use utopia_store::graph::Validity;
371+
use utopia_store::resolution::{merge_entities, revert_merge};
372+
in_kb(|pool, kb, project, _| async move {
373+
let (_, identity) = chunk(&pool, kb, Some(json!({"names": "identity"}))).await?;
374+
let (_, prose) = chunk(&pool, kb, None).await?;
375+
// 散文的名字先记,无论往哪边并都是它存活
376+
for prose_moves in [false, true] {
377+
let name = format!("acme/{prose_moves}.ts");
378+
let prose_holder = holder(&pool, kb, Some(project), &name).await?;
379+
let kept_name = named(&pool, kb, prose_holder, &name, &[prose]).await?;
380+
let identity_holder = holder(&pool, kb, Some(project), &name).await?;
381+
named(&pool, kb, identity_holder, &name, &[identity]).await?;
382+
let (source, target) = if prose_moves {
383+
(prose_holder, identity_holder)
384+
} else {
385+
(identity_holder, prose_holder)
386+
};
387+
let merge = merge_entities(&pool, kb, source, target, None, "test").await?;
388+
utopia_store::temporal::correct_interval(
389+
&pool,
390+
kept_name,
391+
Validity {
392+
from: Some(chrono::Utc::now() - chrono::Duration::days(30)),
393+
from_precision: Some("day"),
394+
..Default::default()
395+
},
396+
)
397+
.await?
398+
.expect("the surviving name is live");
399+
revert_merge(&pool, kb, merge).await?;
400+
let live = live_name(&pool, prose_holder).await?;
401+
let chunks: Vec<Uuid> =
402+
sqlx::query_scalar("SELECT chunk_id FROM fact_evidence WHERE fact_id = $1")
403+
.bind(live)
404+
.fetch_all(&pool)
405+
.await?;
406+
assert_eq!(chunks, vec![prose], "prose_moves = {prose_moves}");
407+
}
408+
Ok(())
409+
})
410+
.await
411+
}

‎docs/decisions/0071-a-source-may-declare-its-names-identities.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 0071 · A source may declare its names identities
22

3-
- **Status**: Implemented · 2026-10-25 · #1333, #1390, #1425 · open: none
3+
- **Status**: Implemented · 2026-10-25 · #1333, #1390, #1425, migration 0125 · open: none
44
- **Written**: 2026-10-19 (conventions in the [README](README.md))
55
- **Related**: [0054](0054-a-source-may-push-statements-in-the-open-contract.md) decision 3 sends pushed statements down the same resolution path as prose, and this record narrows that for one kind of source; [0041](0041-a-name-is-a-claim-about-an-entity.md) owns name facts and name-vector recall; [#1248](https://github.com/deeplethe/utopia/issues/1248) meets the same problem from the merge side and is decided here as decision 7; [#1296](https://github.com/deeplethe/utopia/issues/1296) measured what the recall costs; [#1308](https://github.com/deeplethe/utopia/pull/1308) is the disjointness fix that stands on its own
66

@@ -47,6 +47,8 @@ One name fact can carry evidence from both an identity source and a prose docume
4747

4848
Why a person and not a stricter threshold: an identity source has already said what its names are. Decisions 2 to 4 remove the pairs that name vectors and the single-candidate split would raise, so what is left are containment and type-drift pairs and the cross-source duplicate (one thing pushed under two names by two producers). Those are exactly the cases where the model's `same` is a guess about a claim the producer made, and a wrong merge of identities is what #1248 measured: on a small local model, 40 automatic merges with governance off, nearly all wrong (a method into its file, two unrelated types). A person sees few of them because the vectors are off. Raising `AUTO_CONF` for these pairs would still let a confident wrong verdict through, and a per-base switch is a dead end (below).
4949

50+
**A merge keeps the identity evidence of a name it dedupes.** *Revised 2026-10-30 (#1426):* `merge_entities` invalidates a duplicate name fact, and its evidence stayed on the invalidated row. An entity whose name came from an identity source, merged into one that already held that name from prose with an earlier `recorded_at`, stopped being an identity entity, so decisions 2 and 7 no longer reached it. The merge now copies the duplicate's evidence onto the surviving fact, one row per chunk, and deletes the surviving name's vector if the copy makes it an identity name (decision 5). `entity_merges` records the copied pairs. Undoing the merge deletes them, following `supersedes` because a rewrite of the surviving fact carries its evidence forward, and the revived duplicate still has its own. The name is then pending again, as in decision 6. In a chain S→T→C, undoing only S→T leaves the copy on C's name until T→C is undone too, which errs toward a person.
51+
5052
**A NULL-typed holder takes the declared class.** *Revised 2026-10-23 (#1390):* this note said a holder with `type_id IS NULL` keeps its class and left the choice to the next PR. A typed identity mention that attaches to such a holder now gives it the declared class, as the type-drift path does and with the same helper, so a class a person removed stays removed.
5153

5254
## Not doing
@@ -67,3 +69,4 @@ Why a person and not a stricter threshold: an identity source has already said w
6769
- `a_name_with_identity_evidence_gets_no_vector_even_when_prose_names_it_too` (decision 5).
6870
- Turning the flag on deletes the identity names' vectors; turning it off makes them pending again (decision 6).
6971
- `a_same_verdict_on_an_identity_entity_waits_for_a_person`: with governance off and with it on, a `same` verdict at or above `AUTO_CONF` on a pair with one identity side leaves both entities in place and an open review with `escalate_identity`; a `kept` verdict on the same pair still closes it (decision 7).
72+
- `a_merge_keeps_the_identity_evidence_of_the_name_it_dedupes`, `undoing_a_merge_leaves_the_survivors_own_evidence` and `undoing_a_merge_takes_the_copied_evidence_off_a_rewritten_name` (#1426).

‎docs/design/identity.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ is the fallback when nothing is declared [0009, 0016 B3]. Untyped same-name enti
4242
[0009].
4343

4444
**Merges are reversible on both clocks.** `entity_merges` records what moved with `created_at` and
45-
`reverted_at`; `revert_merge` moves facts, name facts and qualifiers back; a read `as_of` a moment
46-
before a merge shows two nodes [0019, 0041]. A merge is a person's action, the adjudicator's at 0.8,
45+
`reverted_at`; `revert_merge` moves facts, name facts and qualifiers back and takes back the evidence
46+
the merge copied from a duplicate onto its survivor; a read `as_of` a moment before a merge shows two
47+
nodes [0019, 0041, 0071]. A merge is a person's action, the adjudicator's at 0.8,
4748
or the governor's through its gate, and an automatic merge is held for a person when it would leave
4849
the graph (a functional contradiction at one moment, a derivation, a cited answer) [0027]. Retypes
4950
and renames are audited with before and after; a name collision on rename is a prompt to merge, not
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- 合并作废重复事实之前,把它的证据抄到存活的那条上(#1426):身份来源的名字证据
2+
-- 否则留在作废行上,存活方不再是身份实体。抄过去的 (fact_id, chunk_id) 按位成对记在这里,
3+
-- 撤回合并只删这些,存活行原有的证据不动
4+
ALTER TABLE entity_merges
5+
ADD COLUMN copied_evidence_facts UUID[] NOT NULL DEFAULT '{}',
6+
ADD COLUMN copied_evidence_chunks UUID[] NOT NULL DEFAULT '{}';

0 commit comments

Comments
 (0)