Skip to content

Keep passage-resolved phrases out of known_as - #862

Open
iuiu-py wants to merge 1 commit into
deeplethe:devfrom
iuiu-py:fix/generic-phrase-not-a-name
Open

iuiu-py wants to merge 1 commit into
deeplethe:devfrom
iuiu-py:fix/generic-phrase-not-a-name

Conversation

@iuiu-py

@iuiu-py iuiu-py commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

What

  • Tighten the OPEN_SYSTEM contract so n accepts only a proper name or fixed term. A role or generic phrase whose referent is resolved by the passage stays as a description or qualifier words, even when it points at a listed or already recorded thing.
  • Add a structural gate in names::record: an entity with a non-null description is described rather than named, so it refuses a late alias. The gate checks the entity state and referent relationship, not a vocabulary blacklist; fixed terms such as free cash flow remain valid names.
  • Add a prompt-contract test, extend the described-entity store test to cover a late alias, and document the rule in docs/design/extraction.md.

Why

Issue #770 shows a passage-resolved role phrase entering known_as. That turns a description into a recall bridge and can pull an unrelated thing back under one identity. The prompt-level rule fixes the contract at extraction time, while the store gate gives the described-entity invariant a structural last check.

Testing

  • Added open::tests::a_passage_resolved_phrase_is_not_another_name; it failed on its first missing phrase assertion before the prompt change and passes afterward.
  • Extended a_described_thing_has_a_description_and_no_name; the new late-alias assertion first observed Some(...) and then passed after the store gate returned None.
  • With UTOPIA_DATABASE_URL=postgres://utopia:utopia@127.0.0.1:15432/utopia and UTOPIA_TEST_REQUIRE_DB=1, cargo test --locked -j 1 -p utopia-store --test a_described_thing_is_an_entity_without_a_name -- --test-threads=1 passes.
  • cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings pass.
  • A local full workspace test was attempted but stopped during compilation by Windows page-file exhaustion (memory allocation failed / os error 1455), not by a test assertion.
  • No live-model extraction benchmark was run.

Fixes #770

Signed-off-by: wangzifei <wangzifei@cit.group.hk>
@WaylandYang

Copy link
Copy Markdown
Contributor

Checked this against real data rather than against the description, because the gate rests on one assumption worth testing: that description IS NOT NULL is a faithful proxy for "described rather than named".

It holds, and I can put a number on it. In a current-schema base here (utopia_open_e2e, 43,446 entities):

  • 18,491 entities carry a description
  • 0 of them have a canonical_name that differs from that description — the name column mirrors the description as a display label
  • so there is no entity in that base that the gate could misfire on by refusing an alias it should have accepted

That was my one real worry — an entity holding both a proper name and a description would silently stop accepting legitimate aliases — and the data says the two are exclusive in practice, not only by intent.

The same query is the argument for the change. The described entities look like this:

headquarters                  || headquarters
city council of Westbrook     || city council of Westbrook
paving contract               || paving contract
vehicles                      || vehicles

headquarters and vehicles as identities is exactly the bridge #770 describes, and there are 18,491 of them in one base. This is not a hypothetical contract violation.

The gate is structural, which is the part that matters here. WHERE id = $1 AND kb_id = $2 AND description IS NOT NULL reads entity state, not words. No blacklist, so free cash flow stays a valid fixed term, and the rule does not rot as vocabulary drifts. Putting the semantic half in the prompt and the structural half in the store is the right split.

One thing I would change.

The refusal is invisible. record returns Ok(None), and both callers in extraction_open.rs are let _ = utopia_store::names::record(...). So when the model violates the n contract and the store catches it, nothing anywhere records that it happened — no signal, no counter, no log line. The only way to learn the prompt is being violated is to notice a name that never appeared.

That runs against how the rest of this codebase treats its own guards: unruled is counted because "别再让它静默消失一次", capped is reported so "没推到" and "不成立" cannot look alike, and extraction already has drop_signal for exactly this shape — a thing the model produced that the system refused. This gate deserves the same, both because a rising count is how you would discover the prompt change regressed, and because the count going to zero is how you would know it worked.

It does not need to block the PR, but it is cheap now and awkward to retrofit once the silence is the established behaviour.

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prompt tightening and the intent here both look right, but the structural gate
is placed in names::record, which is shared with a caller that is not extraction:
graph::update_entity — the human rename endpoint — calls it at graph.rs:1677,
right under the comment "人改的名字也是一条名字事实(0041)".

update_entity has no check excluding described entities and does not clear
description, so after this change a person who renames a described thing gets a
new display name and no name fact at all. The entity then can't be found by name.

Verified against Postgres 16 with the same probe on both branches — create a
described entity, rename it through graph::update_entity, then count known_as
facts:

dev this branch
canonical_name "Acme Shanghai Co." "Acme Shanghai Co."
known_as fact count 1 0
description still set still set

The predicate itself is sound — create_described is the only production writer of
entities.description, and there is no UPDATE ... SET description anywhere, so
description IS NOT NULL really does mean "described, unnamed". It's the layer
that's wrong: what this PR wants to stop is the model writing a role phrase into
n, which is the extraction path (extraction_open.rs:350 and :741). Gating
there would leave the human decision alone.

Minor, separate: the guard adds a round-trip per name recorded on the ingest hot
path. If it stays in names::record, it could fold into the existing statement
rather than being a separate SELECT EXISTS.

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.

A generic phrase becomes a name, and a name is a bridge

2 participants