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
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 110;
const CURRENT_SCHEMA_VERSION: u32 = 111;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
Expand Down
18 changes: 18 additions & 0 deletions crates/utopia-store/src/name_vectors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,24 @@ pub async fn drop_for_document(pool: &PgPool, document_id: Uuid) -> AppResult<()
Ok(())
}

/// 合并把重复名字的证据抄到了存活的名字上(#1426):其中有了身份证据的,删掉向量
pub async fn drop_with_identity_evidence(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
facts: &[Uuid],
) -> AppResult<()> {
if facts.is_empty() {
return Ok(());
}
sqlx::query(concat!(
"DELETE FROM name_vectors v WHERE v.fact_id = ANY($1) AND ",
has_identity_evidence!("v.fact_id")
))
.bind(facts)
.execute(&mut **tx)
.await?;
Ok(())
}

/// 写一批名字向量:`(fact_id, entity_id, embedding)`。同一条事实重写就覆盖(换了模型
/// 重算)。第一次写下这个维度的向量,索引就该排上了(0035)。
/// 写的时候再查一次身份证据:补向量算到一半,来源开了身份名或身份文档抽完删过一轮,
Expand Down
51 changes: 47 additions & 4 deletions crates/utopia-store/src/resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2231,6 +2231,7 @@ pub async fn merge_entities(
.fetch_all(&mut *tx)
.await?;
let mut dup_ids = Vec::new();
let (mut copied_to, mut copied_chunks) = (Vec::new(), Vec::new());
for (ids,) in dups {
// 先给存活行接上来源,否则下一次物化可能作废它,再按原文重建,丢掉人的修正。
// 旧行的链接暂留:撤回合并还用得上;物化按它原有的规则清理失效链接。
Expand All @@ -2243,6 +2244,25 @@ pub async fn merge_entities(
.bind(&ids[1..])
.execute(&mut *tx)
.await?;
// 证据也抄一份(#1426):身份来源的名字证据要是只留在作废的那条上,存活方就不再是
// 身份实体,精确名挂靠和合并前的人工把关(0071 决定 2、7)都认不出它。
// 同一分块存活行已有的不动;抄过去的记进账本,撤回合并时只删这些
let copied: Vec<Uuid> = sqlx::query_scalar(
"INSERT INTO fact_evidence (fact_id, chunk_id, quote, document_id, doc_version,
proposed_predicate, quote_start, quote_end, attested_at, attested_by)
SELECT DISTINCT ON (chunk_id) $1, chunk_id, quote, document_id, doc_version,
proposed_predicate, quote_start, quote_end, attested_at, attested_by
FROM fact_evidence WHERE fact_id = ANY($2)
ORDER BY chunk_id, array_position($2, fact_id)
ON CONFLICT DO NOTHING
RETURNING chunk_id",
)
.bind(ids[0])
.bind(&ids[1..])
.fetch_all(&mut *tx)
.await?;
copied_to.extend(std::iter::repeat_n(ids[0], copied.len()));
copied_chunks.extend(copied);
dup_ids.extend_from_slice(&ids[1..]);
}
if !dup_ids.is_empty() {
Expand All @@ -2252,6 +2272,8 @@ pub async fn merge_entities(
.await?;
invalidated.extend(dup_ids);
}
// 抄过去的若是身份证据,存活的名字就不该再留向量(0071 决定 5)
crate::name_vectors::drop_with_identity_evidence(&mut tx, &copied_to).await?;

// 画像加权合并
let (profile, profile_n) = match (&target.profile_embedding, &source.profile_embedding) {
Expand Down Expand Up @@ -2356,8 +2378,8 @@ pub async fn merge_entities(
"INSERT INTO entity_merges (id, kb_id, source_id, target_id,
moved_subject_facts, moved_object_facts, invalidated_facts,
target_profile_before, target_profile_n_before, target_type_before,
merged_by, reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
merged_by, reason, copied_evidence_facts, copied_evidence_chunks)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
)
.bind(merge_id)
.bind(kb_id)
Expand All @@ -2371,6 +2393,8 @@ pub async fn merge_entities(
.bind(target.type_id)
.bind(merged_by)
.bind(reason)
.bind(&copied_to)
.bind(&copied_chunks)
.execute(&mut *tx)
.await?;

Expand Down Expand Up @@ -2430,6 +2454,8 @@ struct MergeRow {
moved_subject_facts: Vec<Uuid>,
moved_object_facts: Vec<Uuid>,
invalidated_facts: Vec<Uuid>,
copied_evidence_facts: Vec<Uuid>,
copied_evidence_chunks: Vec<Uuid>,
target_profile_before: Option<Vector>,
target_profile_n_before: i32,
target_type_before: Option<Uuid>,
Expand Down Expand Up @@ -2470,8 +2496,8 @@ async fn with_rewrites(
pub async fn revert_merge(pool: &PgPool, kb_id: Uuid, merge_id: Uuid) -> AppResult<()> {
let m: MergeRow = sqlx::query_as(
"SELECT source_id, target_id, moved_subject_facts, moved_object_facts,
invalidated_facts, target_profile_before,
target_profile_n_before, target_type_before, reverted_at
invalidated_facts, copied_evidence_facts, copied_evidence_chunks,
target_profile_before, target_profile_n_before, target_type_before, reverted_at
FROM entity_merges WHERE id = $1 AND kb_id = $2",
)
.bind(merge_id)
Expand Down Expand Up @@ -2546,6 +2572,23 @@ pub async fn revert_merge(pool: &PgPool, kb_id: Uuid, merge_id: Uuid) -> AppResu
crate::temporal::rehome_tx(&mut tx, id, subject, object).await?;
}
}
// 合并时抄到存活行上的证据删掉:重复的那条复活了,证据还在它身上(#1426)。
// 存活行之后可能被改写过(人改区间、时间线闭合、上面的搬回),证据随行复制到了新行上,
// 所以顺着 supersedes 删到底,跟 `with_rewrites` 同一条链。
// 存活的名字因此不再有身份证据的话,它重新等着补向量,跟来源关掉身份名一样(0071 决定 6)
sqlx::query(
"WITH RECURSIVE chain(fact_id, chunk_id) AS (
SELECT * FROM unnest($1::uuid[], $2::uuid[])
UNION
SELECT f.id, c.chunk_id FROM facts f JOIN chain c ON f.supersedes = c.fact_id
)
DELETE FROM fact_evidence fe USING chain c
WHERE fe.fact_id = c.fact_id AND fe.chunk_id = c.chunk_id",
)
.bind(&m.copied_evidence_facts)
.bind(&m.copied_evidence_chunks)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE facts SET invalidated_at = NULL WHERE id = ANY($1)")
.bind(&m.invalidated_facts)
.execute(&mut *tx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,140 @@ async fn turning_the_flag_on_drops_the_sources_name_vectors_and_off_makes_them_p
})
.await
}

/// `entity` 现行的名字事实。
async fn live_name(pool: &PgPool, entity: Uuid) -> anyhow::Result<Uuid> {
Ok(sqlx::query_scalar(
"SELECT f.id FROM facts f JOIN relation_types r ON r.id = f.predicate_id
WHERE f.subject_id = $1 AND r.key = $2 AND f.invalidated_at IS NULL",
)
.bind(entity)
.bind(utopia_store::names::KNOWN_AS)
.fetch_one(pool)
.await?)
}

/// #1426:人把身份来源的实体并进一个早就从散文里记下同名的实体。存活的名字留住身份证据,
/// 它仍是身份实体;撤回合并只收回抄过去的那些。
#[tokio::test]
async fn a_merge_keeps_the_identity_evidence_of_the_name_it_dedupes() -> anyhow::Result<()> {
use utopia_store::execution_gate::{hold_for_merge, Hold};
use utopia_store::resolution::{merge_entities, revert_merge};
in_kb(|pool, kb, project, _| async move {
let (_, identity) = chunk(&pool, kb, Some(json!({"names": "identity"}))).await?;
let (_, prose) = chunk(&pool, kb, None).await?;
let keeper = holder(&pool, kb, Some(project), "acme/a.ts").await?;
let kept_name = named(&pool, kb, keeper, "acme/a.ts", &[prose]).await?;
name_vectors::set(&pool, kb, &[(kept_name, keeper, vec![1.0, 0.0, 0.0])]).await?;
let pushed = holder(&pool, kb, Some(project), "acme/a.ts").await?;
named(&pool, kb, pushed, "acme/a.ts", &[identity]).await?;
let bystander = holder(&pool, kb, Some(project), "acme/z.ts").await?;
let evidence = |fact: Uuid| {
let pool = pool.clone();
async move {
let mut chunks: Vec<Uuid> =
sqlx::query_scalar("SELECT chunk_id FROM fact_evidence WHERE fact_id = $1")
.bind(fact)
.fetch_all(&pool)
.await?;
chunks.sort();
anyhow::Ok(chunks)
}
};
let mut both = vec![identity, prose];
both.sort();

let merge = merge_entities(&pool, kb, pushed, keeper, None, "test").await?;
assert_eq!(live_name(&pool, keeper).await?, kept_name);
assert_eq!(evidence(kept_name).await?, both);
assert_eq!(
hold_for_merge(&pool, kb, keeper, bystander).await?,
Some(Hold::Identity)
);
assert_eq!(vectors(&pool, kb).await?, Vec::<Uuid>::new());

revert_merge(&pool, kb, merge).await?;
assert_eq!(evidence(kept_name).await?, vec![prose]);
assert_eq!(
evidence(live_name(&pool, pushed).await?).await?,
vec![identity]
);
assert_ne!(
hold_for_merge(&pool, kb, keeper, bystander).await?,
Some(Hold::Identity)
);
assert!(pending_entities(&pool, kb).await?.contains(&keeper));
Ok(())
})
.await
}

/// 存活行原本就有的同一分块证据是它自己的,撤回合并不动。
#[tokio::test]
async fn undoing_a_merge_leaves_the_survivors_own_evidence() -> anyhow::Result<()> {
use utopia_store::resolution::{merge_entities, revert_merge};
in_kb(|pool, kb, project, _| async move {
let (_, prose) = chunk(&pool, kb, None).await?;
let keeper = holder(&pool, kb, Some(project), "acme/a.ts").await?;
let kept_name = named(&pool, kb, keeper, "acme/a.ts", &[prose]).await?;
let other = holder(&pool, kb, Some(project), "acme/a.ts").await?;
named(&pool, kb, other, "acme/a.ts", &[prose]).await?;
let merge = merge_entities(&pool, kb, other, keeper, None, "test").await?;
revert_merge(&pool, kb, merge).await?;
let kept: Vec<Uuid> =
sqlx::query_scalar("SELECT chunk_id FROM fact_evidence WHERE fact_id = $1")
.bind(kept_name)
.fetch_all(&pool)
.await?;
assert_eq!(kept, vec![prose]);
Ok(())
})
.await
}

/// 合并之后存活的名字可能被改写:人改了区间,证据随行到新行上。撤回合并要从现在持有它的
/// 那一行收回,不管存活行原本属于哪一边。
#[tokio::test]
async fn undoing_a_merge_takes_the_copied_evidence_off_a_rewritten_name() -> anyhow::Result<()> {
use utopia_store::graph::Validity;
use utopia_store::resolution::{merge_entities, revert_merge};
in_kb(|pool, kb, project, _| async move {
let (_, identity) = chunk(&pool, kb, Some(json!({"names": "identity"}))).await?;
let (_, prose) = chunk(&pool, kb, None).await?;
// 散文的名字先记,无论往哪边并都是它存活
for prose_moves in [false, true] {
let name = format!("acme/{prose_moves}.ts");
let prose_holder = holder(&pool, kb, Some(project), &name).await?;
let kept_name = named(&pool, kb, prose_holder, &name, &[prose]).await?;
let identity_holder = holder(&pool, kb, Some(project), &name).await?;
named(&pool, kb, identity_holder, &name, &[identity]).await?;
let (source, target) = if prose_moves {
(prose_holder, identity_holder)
} else {
(identity_holder, prose_holder)
};
let merge = merge_entities(&pool, kb, source, target, None, "test").await?;
utopia_store::temporal::correct_interval(
&pool,
kept_name,
Validity {
from: Some(chrono::Utc::now() - chrono::Duration::days(30)),
from_precision: Some("day"),
..Default::default()
},
)
.await?
.expect("the surviving name is live");
revert_merge(&pool, kb, merge).await?;
let live = live_name(&pool, prose_holder).await?;
let chunks: Vec<Uuid> =
sqlx::query_scalar("SELECT chunk_id FROM fact_evidence WHERE fact_id = $1")
.bind(live)
.fetch_all(&pool)
.await?;
assert_eq!(chunks, vec![prose], "prose_moves = {prose_moves}");
}
Ok(())
})
.await
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 0071 · A source may declare its names identities

- **Status**: Implemented · 2026-10-25 · #1333, #1390, #1425 · open: none
- **Status**: Implemented · 2026-10-25 · #1333, #1390, #1425, migration 0125 · open: none
- **Written**: 2026-10-19 (conventions in the [README](README.md))
- **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

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

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).

**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.

**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.

## Not doing
Expand All @@ -67,3 +69,4 @@ Why a person and not a stricter threshold: an identity source has already said w
- `a_name_with_identity_evidence_gets_no_vector_even_when_prose_names_it_too` (decision 5).
- Turning the flag on deletes the identity names' vectors; turning it off makes them pending again (decision 6).
- `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).
- `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).
5 changes: 3 additions & 2 deletions docs/design/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ is the fallback when nothing is declared [0009, 0016 B3]. Untyped same-name enti
[0009].

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