Skip to content

Recall a mention by its name vector so a short form or another script meets its entity - #877

Merged
WaylandYang merged 2 commits into
devfrom
feat/identity-name-vectors
Sep 23, 2026
Merged

WaylandYang merged 2 commits into
devfrom
feat/identity-name-vectors

Conversation

@WaylandYang

@WaylandYang WaylandYang commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

0041 decision 3 names three recall channels; today only the first exists (a literal match of the mention's name against name facts). This lands the second: the mention's name vector against the name vectors of the base, so 海探1 finds 海洋探测器1号 and a name written in another script finds its entity (#709) without either being spelled the same.

What it does, and deliberately does not do

  • Recall proposes; nothing new decides. A near name is never attached. It becomes a name_vector|<cosine> pair for the batch adjudicator, which already judges pairs from two profiles and precedent. Evidence-based deciding is 0041 cut 3 and is not touched. So this change can only add questions, not silent merges.
  • Candidates the literal channel already handled are not re-proposed (same lowercase name → skipped), and a candidate whose type_family is known and differs from the mention's is skipped. An unknown family blocks nothing: when unsure, ask.
  • With no embedding model configured the channel is simply absent and extraction behaves exactly as before.

Pieces

  • migrations/0073_a_name_has_a_vector.sql — name_vectors(fact_id PK, kb_id, entity_id, embedding vector), one row per name fact; composite same-KB foreign keys and the kb_ownership_is_not_reassigned trigger per 0070. A side table rather than a column on facts (every SELECT * … Fact would have to change) or on entities (an entity has as many vectors as it has names).
  • utopia_store::name_vectors — pending, set (registers the HNSW build with vector_index, new Target::NameVectors), nearest (0035's two rules: casts on both sides of <=>, vector_dims predicate; outer re-sort qualified to the CTE).
  • resolution::resolve_mention gains name_vector: Option<&[f32]>; the old body is resolve_by_name unchanged. All callers updated; mapping paths pass None.
  • extraction_open::run_open embeds each chunk's named entities in one batch before resolving them (failure → warn, chunk falls back to literal recall), and after the document tops up name facts that have no vector yet (embed_pending_names, batched, failure → warn, never fails the document).
  • i18n name_vector reason in both packs; design/identity.md and 0041's status line updated.

Verified

Against pgvector/pgvector:pg16 on a fresh database migrated through 0073:

  • cargo test -p utopia-store: 333 passed, 0 failed (4 new in a_similar_name_is_proposed_not_merged.rs: near name → new entity + adjudicator pair; other family → not proposed; far / no vector → nothing; literal hit → not re-proposed)
  • cargo test -p utopia-server: 384 passed, 0 failed
  • cargo clippy --workspace --all-targets -D warnings clean; cargo fmt --check clean
  • web: tsc, pnpm guard (99 files), pnpm test 132 passed

End-to-end, five rounds

Real server from this branch (port 1518) against a fresh Postgres, a deterministic OpenAI-compatible stub standing in for the models (character-hash embeddings, canned open-extraction replies, a batch adjudicator that answers from a per-round alias list), one new knowledge base per round, documents pushed through /kbs/{id}/ingest and jobs left to run:

Round Documents name_vectors Outcome
R1 full name → short form 海洋探测器1号 …, then 海探1 … 4 pair proposed → adjudicated same → merged; survivor holds both name facts
R2 short form → full name reverse order 4 same result, order-independent
R3 Chinese → English (alias known to the stub embedder) 海洋探测器1号 …, then Ocean Probe No. 1 … 5 both cross-script pairs merged (probe and manufacturer)
R4 namesake control 张伟 ×2 in different departments + an unrelated device 6 two 张伟 entities, no channel-2 pair; nothing spurious
R5 no embedding model configured same documents as R1 0 no pairs, extraction unaffected — the controlled negative for R1

The first E2E pass found a bug the unit tests could not: name_vectors::pending ordered by facts.created_at, a column that does not exist, so the end-of-document top-up failed on every document (logged, swallowed) and channel 2 never fired. Fixed to order by id (uuid v7) in the second commit, with a pending → set → pending round-trip test. Every server warning was clean on the final pass.

The harness (stub + driver) is deliberately outside the repository, per the project's rule on harnesses; it is available on request.

Not verified here, and needs a maintainer run

scripts/bench/identity.mjs has not been run: this machine has no embedding endpoint configured (llm_settings.embed_model is null), and the bench needs one. TOP_K = 8 and SIM_FLOOR = 0.60 are provisional values with their reasoning in the comments; the bench should set them. Retiring recall_keys and the generic-suffix lists once channel 2 finds what they found is cut 4, not this PR.

Two small costs worth knowing: names resolved in a chunk are embedded again by the end-of-document top-up (the resolver does not learn the name fact's id; one extra embed call per document), and nearest fetches 4·k rows inside the CTE to leave room for the status filters outside it.

Migration number: 0073. This started as 0072 and collided with #878, which took 0072 on dev while this was open, exactly the case CONTRIBUTING warns about: git merges the two files without complaint and sqlx refuses to run. Renumbered and merged dev back in; the 版本号不得重复 check and _sqlx_migrations on a fresh database (72 rows, max 73) both confirm. 0071 is still held by #861. utopia-cli's CURRENT_SCHEMA_VERSION is pinned by test to the count of files in migrations/, so it is 72 here (70 + #878's 0072 + this 0073); its comment now describes the gap rule without quoting a number that moves.

Full cargo test --workspace against Postgres, the way the backend job now runs it: 1042 passed, 0 failed.

🤖 Generated with Claude Code

@WaylandYang
WaylandYang force-pushed the feat/identity-name-vectors branch 3 times, most recently from 9e39183 to 294f1be Compare September 23, 2026 09:18
@WaylandYang

Copy link
Copy Markdown
Contributor Author

The design is right for cut 2 of 0041 decision 3: channel 2 proposes a pair with its cosine and decides nothing, the literal channel keeps its ground, a family mismatch is a skip and an unknown family is a question. The store module follows 0035's two rules and 0070's same-base keys, and the extraction path degrades to literal recall on every failure rather than failing the document. One thing blocks the merge and one is a question.

Blocking: the migration number collided again. dev now has 0073_a_statement_shape_implies_a_fact.sql (#882, merged after this PR was renumbered), and #885 takes 0074. Git will merge both 0073 files without a word and sqlx will refuse to run, exactly the case the PR body describes. Please renumber to 0080: that is the first number of the range the identity track holds (A holds 0072 to 0079, B 0080 to 0089, per the coordination note), so it cannot collide with anything still open on the alignment side. CURRENT_SCHEMA_VERSION in utopia-cli is the count of files: with 0072, 0073 and 0074 on dev plus 0080 it is 74. The 版本号不得重复 check and a fresh _sqlx_migrations will confirm as before.

Question, not blocking: nearest joins entities … AND e.description IS NULL. The comment says a described thing has no name fact, which is true and makes the predicate redundant for those rows; but if an entity can carry both a name and a description (a named thing the document also describes), the predicate would drop its names from recall. If description is set only for unnamed things, say so in the comment and keep it; otherwise drop the predicate, since the join on a live name fact already excludes the unnamed.

Everything else read cleanly: the break on the floor is sound because nearest orders by distance ascending, the seen set keeps the literal hit and the excluded ids out, and the end-of-document top-up is the right place to pay the one extra embed call.

@WaylandYang
WaylandYang force-pushed the feat/identity-name-vectors branch from 43fafec to 4dd6d6d Compare September 23, 2026 10:12
… meets its entity

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: WaylandYang <the-das302@spoko.pl>
@WaylandYang
WaylandYang force-pushed the feat/identity-name-vectors branch from 4dd6d6d to b9dccec Compare September 23, 2026 10:16
@WaylandYang

Copy link
Copy Markdown
Contributor Author

Both taken, in b9dccec (rebased onto #886's single-binary test layout):

  • Migration is 0080, CURRENT_SCHEMA_VERSION is 74 (0001–0070 + 0072/0073/0074 on dev + 0080). A fresh database migrates to 74 rows with max version 80, the duplicate-number check is empty, and schema_version_policy_compares_against_current passes. Noted the range split — identity track 0080–0089 — so this cannot collide with anything still open on the alignment side.
  • e.description IS NULL is gone from nearest. You are right that it was redundant today and would be wrong tomorrow: the JOIN facts … known_as already excludes a described thing (it has no name fact, 0044), so the predicate filtered nothing — and if a named entity ever also carries a description, its names must still be recallable. The comment on nearest now says that instead.

Also in this revision: the E2E pass (five rounds against the real server, described in the body) found that pending ordered by a column facts does not have, so the end-of-document top-up had been failing silently; fixed to ORDER BY f.id, with a pending → set → pending round-trip test. The new test file lives in tests/store/ and is in main.rs's module list; cargo test -p utopia-store on a fresh database is 345/0 with all five of its cases running inside the store binary.

@WaylandYang
WaylandYang merged commit 1d97c05 into dev Sep 23, 2026
7 checks passed
@WaylandYang
WaylandYang deleted the feat/identity-name-vectors branch September 23, 2026 10:23
WaylandYang added a commit that referenced this pull request Sep 23, 2026
… are still computed

Only the chat client is absent when a chunk is the contract itself; the
embedding model that #877 uses for name vectors comes from the same settings
row and applies to pushed names as much as extracted ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>
WaylandYang added a commit that referenced this pull request Sep 23, 2026
… meets its entity (#877)

Signed-off-by: Wayland Yang <wayland0916@gmail.com>
Co-authored-by: Wayland Yang <wayland0916@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
WaylandYang added a commit that referenced this pull request Sep 23, 2026
… are still computed

Only the chat client is absent when a chunk is the contract itself; the
embedding model that #877 uses for name vectors comes from the same settings
row and applies to pushed names as much as extracted ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>
WaylandYang added a commit that referenced this pull request Sep 24, 2026
* Let a source push statements in the open contract instead of prose a model must read back

A statements source takes the extraction contract itself (e/s/n) on
POST /sources/{id}/statements with the api push's identity, versions and
tombstones; the payload is one chunk and extraction parses it instead of
prompting, so a pushed statement is an open statement and reaches the typed
graph only through alignment. Record 0054.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>

* Keep the embed settings on the pushed-statements path so name vectors are still computed

Only the chat client is absent when a chunk is the contract itself; the
embedding model that #877 uses for name vectors comes from the same settings
row and applies to pushed names as much as extracted ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>

* Locate a pushed statement's time words in the payload itself and refuse an unlisted subject at the door

`locate_time` begins with `quote?`, and the door requires a pushed
statement's quote to be null, so every `when` / `ended` on a pushed
statement fell into the TIME_NOT_IN_QUOTE branch: the fact landed with
only doc_time and a drop signal, and no time mention was ever recorded.
On the pushed path the chunk is the payload, so the words are looked up
in the chunk directly. The DB test now asserts the mention lands; it
fails without this change.

The door now resolves each statement's subject and each name's entity
against `e`, folding whitespace and case as `name_key` does, and answers
422 instead of letting extraction drop the item silently as UNKNOWN_REF.
An object not in `e` still lands as a literal value, as it does for a
model's reply. The route carries a body limit of four payloads so the
2 MiB default is no longer buffered before the token check; the
documented 64 KiB refusal stays a 422 with its message.

Tests added: tombstone marks and a new push revives, a tombstone for an
unknown identity is a no-op, the missing Authorization header, the
64 KiB limit over HTTP, the 200-item limit, an `n` entity not in `e`,
a non-null `n` quote, and the folded subject that must pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>

* Let a statements source's push token be viewed and rotated like an api source's

Found by running the server end to end: the token came back once at
creation, then GET /token and POST /rotate-token answered 404 because
both still gated on kind == "api". One predicate, has_push_token, now
names the two kinds that carry a push token, and creation, view and
rotation use it. A test signs in as the editor, reads the token back,
rotates it, and checks that the old token is refused and the new one
pushes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>

---------

Signed-off-by: Wayland Yang <wayland0916@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant