Skip to content

A kind-word decision records the inputs it considered, and a reply whose inputs moved is not accepted - #932

Merged
WaylandYang merged 4 commits into
deeplethe:devfrom
Maya-Kid:fix/kind-word-basis
Sep 26, 2026
Merged

WaylandYang merged 4 commits into
deeplethe:devfrom
Maya-Kid:fix/kind-word-basis

Conversation

@Maya-Kid

Copy link
Copy Markdown
Contributor

Closes #795. The phrase half was #878 (ADR 0053); this is the kind-word half, scoped to type_alignment.rs and type_bindings as discussed on the issue.

What was wrong

type_bindings::stale compared timestamps: a bound kind word went stale when its class was updated after decided_at, a negative one when any class was. decided_at is written with now() after the model answers, so a class edit committed while the model was answering is older than the decision: both votes read the old definition and the result is kept as current. A parent edge does not move updated_at at all.

What this changes

  • One snapshot. align_types reads the classes, their versions and parent edges, the kind-word signatures and the existing decisions in one REPEATABLE READ, READ ONLY transaction, and commits it before the first model call.
  • A basis. type_bindings.basis (migration 0092) stores, for an agent decision, a fingerprint of the candidate classes the model was shown: each one's id, updated_at and ancestor closure (ClassSnapshot::basis). Candidates are retrieved once per run for every live kind word, and a word is decided again when its stored basis differs from the current one. Agent rows without a basis are decided again once; a person's rows carry none and are never re-evaluated.
  • Acceptance compares. decide_and_apply_if_current locks the candidate class rows FOR SHARE before writing the binding row, which is the order a class delete takes before it cascades to the binding. It then recomputes the basis from the rows as they are now and discards the reply if it moved; the run queues another. Updates and deletes of the candidates wait for that transaction. A parent edge or a new class committed in the same window is not blocked, and leaves the decision detectably stale for the next run.
  • Requeue. A stale word the run asked about but could not settle (a failed call, an unreadable reply, a missing vote, no candidates) takes the bounded re-ask instead of the immediate requeue. On dev it requeues with no counter: a variant of the new test, run for three rounds against unmodified dev with a reply that never parses, asked the model six times and queued an immediate align_types job without reask after every round, so an endpoint that always fails keeps the job going indefinitely.
  • Human decisions. decide_and_apply_human enqueues its align_phrases job in the same transaction as the decision and its projection (jobs::enqueue_unless_queued_tx), instead of after the commit. The route's response is unchanged.

A failed retrieval still falls back to the whole class list, as before and as the phrase shortlist does; it is now logged instead of swallowed. Words decided during the failure carry that list in their basis and are asked once more when retrieval recovers. Not falling back would leave a small base untyped for as long as its embedding endpoint is broken.

ADR 0053 gets a dated revision, and docs/design/ontology.md no longer says kind-word bindings use updated_at.

Migration number: 0091 is held for #901, so this takes 0092 and sets the CLI's CURRENT_SCHEMA_VERSION to 79. I'll renumber if you'd rather it had another.

Cost

Retrieval now runs for every live kind word on every run, not only for the words being decided: one embedding request per 64 words and one nearest-class query per word. On a base the size of the typed-graph bench (about 400 kind words) that is 7 embedding requests and 400 queries per run, and extraction queues a run after every document (documents that finish while one is already queued share it). The phrase shortlist likewise embeds its wide signatures (those with more structural candidates than it shows) on every run. If this is too much, a cheap pre-check can skip retrieval when the class layer has not changed since the stored decisions; I left it out to keep this PR to the design agreed on the issue.

Existing agent decisions have no basis and are decided again once after the migration, as #878 did for phrases.

Tests

an_edit_during_the_model_request_is_asked_again is the reproduction from the issue, written in the existing kind-word test fixture: the first request is held, the class definition is edited, and both votes carry OLD definition. On dev it fails:

Error: the edit made during the request was taken as seen: the next run asked 0 more

Here the reply is not accepted, the next run asks with NEW definition, and a third run asks nothing.

Also new:

  • a parent edge alone makes an agent binding stale, with updated_at unchanged;
  • rows without a basis are decided again once;
  • a stale word whose batch fails takes the bounded re-ask;
  • a failed retrieval falls back to the whole class list and is not asked again while it keeps failing;
  • a store test for Moved, Written and KeptPerson, and for the human decision committing with its job;
  • lock order: an acceptance paused between locking the class and writing the binding, with a class delete queued behind it, completes on both sides; a candidate deleted before the check turns the reply away (Moved);
  • unit tests for the basis: order, an edit, a grandparent edge, a deleted candidate, a diamond and a cycle.

Negative controls for the lock, run on a scratch copy and not committed: taking the class lock after the binding write makes the lock-order test fail with deadlock detected; dropping the lock makes the deleted-candidate test accept the reply (Written instead of Moved).

The existing kind-word tests pass unchanged, with one added assertion.

Run on Linux against pgvector/pgvector:pg16 with Rust 1.98.1:

cargo fmt --all --check
cargo clippy --locked --workspace --all-targets -- -D warnings
UTOPIA_TEST_REQUIRE_DB=1 cargo test --locked --workspace -- --test-threads=4

Workspace: 1,144 passed, 0 failed, 5 ignored. The five are the existing opt-in tests that need network access or a dedicated idle database. That includes utopia-server (427 passed), utopia-store (71 unit, 265 store) and the CLI's schema_version_policy_compares_against_current with the constant at 79.

All model calls in these tests go to a scripted local endpoint; no real model was called.

Not in this PR

  • A shared writers' guard for imports and packs. Every statement that changes a class's label or definition moves updated_at, parent edges are in the closure, and the vector refresh changes neither, so no semantic change escapes the basis. The guard would only turn "stale at the next run" into "rejected now" for an edit landing between the acceptance check and its commit.
  • The phrase side. Reading phrase_alignment.rs, it has the same shape at the end of a run: a stale signature whose batch failed still counts as changed and requeues immediately. It is not changed or tested here.

Maya-Kid and others added 4 commits September 26, 2026 10:32
…ile the model answers is asked about again

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

Copy link
Copy Markdown
Contributor

Merged current dev and set CURRENT_SCHEMA_VERSION to 80 as a maintainer edit: #938 landed first with 0091, so with 0092 the count is 80. Nothing else touched.

@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 kind-word half of 0053 done the way the phrase half was: one REPEATABLE READ snapshot before the model call, a basis of candidate ids, updated_at and ancestor closure, acceptance that locks the candidate classes FOR SHARE in the same order a delete takes and recomputes the basis, and the bounded re-ask for words the batch could not settle. The reproduction from #795 fails on dev and passes here; the lock-order and deleted-candidate tests are the ones that would have caught the two easy mistakes. Thank you; merging.

@WaylandYang
WaylandYang merged commit 3b4a97a into deeplethe:dev Sep 26, 2026
7 checks passed
@Maya-Kid
Maya-Kid deleted the fix/kind-word-basis branch September 26, 2026 05:45
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.

An ontology edit during type alignment can be hidden by the response timestamp

2 participants