Skip to content

A binding to a state property says what a single date marks, and a state row whose ends are equal is never written - #975

Merged
WaylandYang merged 10 commits into
deeplethe:devfrom
Maya-Kid:fix/binding-marks-a-moment
Sep 28, 2026
Merged

WaylandYang merged 10 commits into
deeplethe:devfrom
Maya-Kid:fix/binding-marks-a-moment

Conversation

@Maya-Kid

@Maya-Kid Maya-Kid commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #966, as decided there: option 2, with option 3's guard and the invariant.

After the review

  • Asked once, and never reopened. A binding made before this is asked only what a single date marks, once. The answer can write the value and nothing else. A binding asked without getting a value records the ask and waits in the alignment queue. A new decision whose votes agree on the property but not on the value binds without one, instead of re-asking or going undecided.
  • Old rows. Only a row copied from a single date is retired. A row a person set to a single date is kept.
  • A coarse end. "Left in 2024" closes a row that starts inside 2024 at 2025-01-01, instead of leaving it open.
  • Smaller.
    • The leftover doc line is gone.
    • A fourth value under an event or timeless property is ignored.
    • The queue preselects no reading.
    • 0031 points to the 0053 revision.
    • The two refusals that follow from the invariant are described below and tested.
  • Migration 0098. Graph fact lines say when an end was derived by the timeline or an interval corrected by a person #976 took 0097 on dev, so this branch's migration is now 0098 and the CLI constant 89.

Problem

  • "Lin Zhao joined Meridian Systems on 2023-06-01" has a single date. Time resolution writes it on both ends, as 0031 writes an event.
  • Bound to works_for, a state, it was materialized as 2023-06-01 → 2023-06-01. The world axis reads a state as holding while valid_from <= T < valid_to, so the row held at no moment.
  • It also absorbed or closed the document's own "works for … from 2023-06-01", depending on which statement was written first (cases A, B and C in A statement dated at one moment, bound to a state property, becomes a state that holds at no moment #966).

Change

The binding. Migration 0098 adds two columns to phrase_bindings, in one plain ALTER:

  • marks: start, end or none, NULL for unknown;
  • marks_asked_at: when the aligner asked for it, NULL if never.

validate_decision accepts either only on a bound decision.

The aligner (phrase_align.rs, phrase_alignment.rs).

  • Each candidate line says whether the property is a state, an event or eternal. A new rule 6 asks, for a state, what a statement dated at a single moment marks.
  • The reply is [id, key, direction, marks].
    • Every reply form the parser reads carries the fourth value: the fourth cell, a marks / m field, id-keyed objects.
    • A three-cell answer still parses, with no value.
    • Under a state, a value the parser cannot read makes the answer malformed. Under an event or eternal property the fourth value is not read, so whatever is written there is ignored.
  • A new decision. Two votes that agree on the property and the direction bind it.
    • Under a state, the value is written only when both votes give the same one.
    • Otherwise the binding carries no value and records that it was asked. It is listed in the alignment queue and not asked again until its fingerprint changes. Both votes are kept in votes, with what each said.
  • The value depends on the property's time semantics. Every edit to them moves updated_at, which the fingerprint already covers, so a property that becomes a state is asked again. No fingerprint changes shape.
  • Bindings decided before this. An agent's binding to a state that was never asked joins the run's opening selection once, although its fingerprint matches.
    • The question can write only the value. If both votes name the binding's own property and direction and give the same value, record_marks writes it. Any other reply leaves the binding exactly as it is: a vote without a property, the other direction, two values, or no value.
    • Either way the ask is recorded, so the binding is not asked again. One still without a value is listed in the queue.
    • Its rules are not proposed again, since the binding was not decided again.
    • A reply that never came back (a failed call) is not an ask, and the run re-asks as for any signature the model did not answer.
    • The end-of-run check for new work ignores these bindings.
    • A person's binding is never asked.

The person (decide_alignment_phrase, the alignment queue, the web).

  • The decision request takes marks. The route refuses an unknown value (unknown_marks), and a value with anything but a state property (marks_needs_state), with 422. The value is recorded in the votes and the audit entry.
  • The queue. It lists a binding to a state without a value when a person made it or the aligner asked for its value. It shows the binding's property and direction, and the votes when an agent voted.
    • The list, waiting, the review badge and the review summary use one condition.
  • The web row. Under a state property it shows a choice: a single date starts it, ends it, or neither.
    • Nothing is preselected. Bind stays disabled until a person picks one, so a batch of bindings arriving on upgrade cannot be settled by one click each.
    • A bound binding preselects its property and direction.
    • Strings are in en and zh, and the new error codes are worded.

Materialization (materialize.rs, graph.rs). For a statement whose ends are equal, bound to a state:

  • start: written as [t, open). With "works for … from t" in the same document, both statements are sources of one row, in either order.
  • end: written as an end at t, the path a dated ending takes in insert_fact_on. The date names a period, its bucket at its precision: "left in 2024" is stored as 2024-01-01 and means all of 2024.
    • An open row that starts inside the period is closed at the period's end, 2025-01-01. Before, the row was compared with the instant 2024-01-01 and left open beside a separate row that only ended.
    • A row that starts before the period closes at t, as before.
    • The ending says nothing about a row that starts after the period. Before, when the ending was written first, such a row took its date and ended before it started: 2025-06-01 → 2024-01-01.
    • Both statement orders give the same rows. Dated endings take the same path, so a state that starts and ends on one day holds through that day.
  • none or unknown: no typed row. The statement stays in the open graph with its date. Step 1 drops a source computed from such a statement.

Also:

  • Old rows. A new step 0 retires a computed state row whose ends are equal, but only when one of its source statements has the same equal ends:
  • Rules. A rule has no value, so a single-date statement whose rule concludes a state computes no implied row.

The invariant and the guard (graph.rs, temporal.rs, errata.rs).

  • The invariant. Validity::under returns AppResult. Under a state property it refuses equal ends after truncation to precision, with empty_state_span.
    • It applies where a property is declared. A row with no property (0010) keeps a single date as before, since reading it as a state is how it is read, not a declaration.
    • Interval corrections get a 422.
    • Closing a state row at its own start is refused the same way. The engine never closes a row there: it closes at a strictly later start.
  • Two refusals that follow from it. Both are right under the invariant.
    • resolve_conflict with close and no close_at closes the old row at the new row's start. On a simultaneous conflict that is the old row's own start, so the call returns empty_state_span and the conflict stays open until a person gives a date.
    • An errata revision that would carry a single date onto a state property is refused at validation ("a state cannot take a time whose start and end are equal"). The date may come from an old empty state row, or from a dated event row revised to a state property. An action held before the upgrade is checked before the old row is retracted, so nothing is left half done.
  • The guard in insert_fact_on. Under a state property, a stored row with equal ends is not taken for the same observation, so it neither absorbs a later observation nor is closed by one.

Docs.

  • 0053 gets a dated revision, and its status line reads revised 2026-09-27 (#975, migration 0098).
  • 0031, which owns the write rule, gets a dated sentence pointing to it, and another under its open question on the end convention for states.
  • The design files time and ontology describe the change.

CLI. CURRENT_SCHEMA_VERSION is 89: the 88 files on dev, #976's 0097 among them, plus 0098.

Known limits

  • A date that means the whole period. "Revenue in 2023" or "was chairman in 2019" is none and stays open. Reading such a date as holding through its bucket, as an event is read, would be a separate decision.
  • The end of a coarse period is its latest reading. A row from 2024-03-01 closed by "left in 2024" holds through 2024. The ending is known only to the year, and this is the one reading that neither leaves the row open nor ends it before it starts.
  • A flip between start and end. A binding whose value changes from start to end, or back, keeps the rows already computed from its single-date statements. A change to or from none, and a first value, are computed again.
  • Reopening. A row closed by an end statement is not reopened when that statement is retracted, as with any dated ending.
  • Cost after the upgrade. Each agent binding to a state is asked once, in the aligner's usual batches. A model that never gives the value costs that one ask, and the binding waits for a person.
  • Adoptions. An ontology-agent map_to adoption writes a person's binding. Under a state it waits in the queue for its value like any person's binding.

Verification

Run in containers on a Linux host: Rust 1.98.1 and PostgreSQL 16 with pgvector, and Node 20 for the web.

New tests

Store, tests/store/a_moment_marks_its_state.rs (12 tests):

  • Cases A, B and C with a start binding give one row from 2023-06-01 with every statement as a source. Lin Zhao works for Meridian at 2023-06-01 12:00, 2023-07-01 and 2026-01-01.
  • End. A phrase that marks the end closes the open row at its date, in either order.
  • A coarse end (after the review).
    • "Left in 2024" at year precision closes "works for … from 2024-03-01" at 2025-01-01, in either order. Nobody works for Meridian in 2025.
    • A row from 2020 closes at 2024-01-01, and a row from 2025-06-01 stays open beside the ending. Both are checked in either order.
    • A state that starts and ends on one day holds through that day, in either order.
  • none and no value compute no typed row. The statement stays on the canvas with its date.
  • Old rows.
    • A computed row with equal ends is retired and computed again, and a person's own row is not touched.
    • After the review: a typed row and an implied row that a person set to a single date keep their date, and an implied row copied from a single date is retired.
  • The invariant refuses a write, a write whose ends are equal only once truncated, an interval correction, and a close at the row's own start. An event and a row without a property still take a single date.
  • A simultaneous conflict (after the review): closed without a date, it is refused with empty_state_span. The old row stays open, and the conflict stays in the list until a date is given.
  • The guard. A stored row with equal ends neither absorbs a later observation nor is closed by one.
  • Errata.
    • A revision of an empty row is refused, and so is a revision of a dated event row to a state property (after the review).
    • A held action approved later leaves the old row in place.
  • Rules. A rule computes an implied row from a start-only statement, and none from a single-date one.

Server, phrase_alignment_tests.rs, with a scripted model:

  • Two votes with the same value bind with it.
  • Votes that agree on the property but give two values, or one value and none, bind without a value. The ask is recorded, the binding is in the queue, no re-ask is queued, and the next run asks nothing.
  • An agent's binding from before, asked once:
    • two votes that confirm the binding and agree write the value, and nothing else in the binding changes;
    • a vote without a property, the other direction, or two values leave the binding and its typed row exactly as they were, and put it in the queue.
  • A model that never gives the value (the review's case): one ask of two requests, the binding unchanged, its typed row live, no request on the next two runs, the binding in the queue, no re-ask queued.
  • A person's binding is not asked and waits in the queue: the list, waiting and the badge.

Also:

  • Route, review_routes_phrase_tests.rs: a person's decision writes the value and clears the queue; unknown_marks and marks_needs_state write nothing.
  • Extract. The prompt says which properties are states and asks for the fourth value, and every reply form reads it. Under an event property a fourth value is ignored, readable or not.
  • Web. alignmentPhrase.test.ts (vitest): the choice appears under a state only, nothing is preselected, the request body, and the new error codes in en and zh.

The review's tests on the code before the fix

The control branch is this branch with the aligner, the parser, the queue condition, insert_fact_on, materialization and the web helper as they were at fd5a220. Only the new tests fail, each on the behavior the review describes:

  • Store: 4 of 12 fail.
    • "Left in 2024" leaves (none → 2024-01-01) beside [2024-03-01, open).
    • With the ending first, the row from 2025-06-01 becomes 2025-06-01 → 2024-01-01.
    • The rows a person set to a single date are retired with the copied one, and computed again from their statements.
    • A state that starts and ends on one day leaves an open row beside the ending.
  • Server: 4 of 20 phrase_alignment tests fail.
    • A three-cell model is asked again and again. Over the ask and the two runs after it the model gets 22 requests, against 2 on this branch.
    • A vote without a property turns the bound binding into none.
    • The old binding's re-decision rewrites its votes and decision time.
    • Two values make the signature undecided.
  • Extract: 1 of 14 fails. The event answer with an unreadable fourth value is malformed.
  • Web: 1 of 6 fails. The reading is preselected.

The conflict and errata refusals pass there too: fd5a220 already refused them, and the tests pin it.

This branch

After merging dev (efe20c0):

  • The migration chain runs on a fresh database: 89 files, with Graph fact lines say when an end was derived by the timeline or an interval corrected by a person #976's 0097 and this branch's 0098.
  • cargo test --locked -p utopia-cli passes (13), schema_version_policy_compares_against_current among them.
  • Store suite: 291. Server, phrase_alignment, review_routes and api::: 242.
  • cargo fmt --all --check and cargo clippy --locked --workspace --all-targets -- -D warnings are clean.
  • UTOPIA_TEST_REQUIRE_PDFTOTEXT=1 cargo test --locked --workspace passes: 1274 passed, 0 failed.
  • Web: vitest (211) and pnpm build (tsc and vite) pass.

…ate row whose ends are equal is never written

Signed-off-by: dada-yan <BinjunYann@gmail.com>
Signed-off-by: dada-yan <BinjunYann@gmail.com>
Signed-off-by: dada-yan <BinjunYann@gmail.com>
…s 0097

Signed-off-by: dada-yan <BinjunYann@gmail.com>
… single date under a state means

Signed-off-by: dada-yan <BinjunYann@gmail.com>

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

Thank you. This is the decision on #966 built as decided: the invariant holds at precision level because truncation runs before under, materialization filters point statements in SQL so a refusal never fails a document, cases A, B and C are pinned in both orders, the four queue readers share one condition, and 0053 is revised in the new one-line format. I ran the chain on a fresh database (88 files) and the store and phrase tests; all pass.

Three things before it lands, the first being the one that matters.

1. Asking for marks must not reopen a settled binding, and must happen once.
An agent's binding to a state with no value rejoins the opening selection of every run. When the model leaves out the fourth cell, nothing is written, so the next run asks again: with three-cell replies, three runs in a row each spent two requests on the same binding and queued a re-ask. And because it is a full re-decision, votes that come back without a property turn a bound binding into none, which retires its typed rows. On upgrade every existing base sends all of its state bindings through this.

  • The question put to a binding that is already bound is only what a single date marks. If both votes name the same property and direction as the binding and agree on the value, write the value. In every other case leave the binding exactly as it is.
  • Record that it was asked, durably, on the binding. A binding that was asked and still has no value is not asked again; it is listed in the alignment queue for a person, as a person's binding already is.
  • Please add the test for a model that never gives the value: one ask, the binding unchanged, its typed rows still live, the row in the queue.
  • Then the description and the 0053 revision can say asked once and be right.

2. Step 2b can retire a row a person corrected. correct_interval and rewrite_end_tx carry from_statement_id, implied and the source links (#967, #911), so a computed row a person set to equal ends looks like a computed one. Retire a row only when a source statement has the same equal ends as the row; implied rows need the same test against implied_fact_sources.

3. An end compares instants, not what the date names. "works for from 2024-03-01" with "left in 2024" at year precision and marks end gives (none → 2024-01-01) and [2024-03-01, open), so the person still works there in 2025. A coarse end closes the open row that starts inside the period it names, at the end of that period, or is left unmaterialized if you judge that safer; either way it must not leave the open row running. Dated endings behaved this way before, but marks end now sends many more statements down that path. Please pin it with a test.

Smaller:

  • The leftover doc line above type Vote in phrase_alignment.rs.
  • An unreadable fourth cell makes the whole answer malformed even under an event property, where the value is not read. Ignore it there.
  • resolve_conflict with close and no close_at on a simultaneous conflict now returns empty_state_span, and an errata revision of a dated event row to a state property is refused. Both are right under the invariant; please say so in the description and give each a test.
  • The queue preselects neither, so one click writes none for every binding that arrives on upgrade. Preselect nothing and require a choice.
  • 0031 owns the write rule; a dated sentence there pointing to the 0053 revision would help the next reader.

Migration 0097 and constant 88 are right as of now; #976 may take a number first, so count the files in migrations/ when you rebase. Thanks again; the core of this is sound.

@WaylandYang

Copy link
Copy Markdown
Contributor

A note on numbers: #976 is landing with migration 0097 (a partial index on audit_events), so this one becomes 0098 and CURRENT_SCHEMA_VERSION 89 when you rebase.

…d by the question, a coarse end closes a row that starts inside its period, and only rows copied from a single date are retired

Signed-off-by: dada-yan <BinjunYann@gmail.com>
…ment

Signed-off-by: dada-yan <BinjunYann@gmail.com>
… intervals, and the CLI's schema version counts 89

Signed-off-by: dada-yan <BinjunYann@gmail.com>
@Maya-Kid

Copy link
Copy Markdown
Contributor Author

Thank you. All three are in efe20c0, together with the smaller points. The description now opens with an "After the review" list, and its verification section has the control run.

  1. Asked once. A binding decided before this is asked only what a single date marks, once. The value is written only when both votes name the binding's own property and direction and agree on it. Any other reply, a vote without a property included, leaves the binding as it is. The ask is recorded (marks_asked_at, in the same migration), so it is not asked again, and a binding still without a value is listed in the queue. A new decision whose votes agree on the property but not on the value binds without one, instead of re-asking. a_model_that_never_gives_the_value_is_asked_once is your case.
  2. Old rows. The retirement takes a row only when one of its source statements has the same equal ends, through typed_fact_sources or implied_fact_sources. It now runs before step 1, which would otherwise drop that link first.
  3. A coarse end. An open row that starts inside the period an end names is closed at the period's end: "left in 2024" closes a row from 2024-03-01 at 2025-01-01, in either order. A row that starts before the period still closes at its start, and one that starts after it is left alone. That last case also fixes an inverted row the refinement used to write when the ending came first.

The smaller points: the doc line is gone, a fourth cell under an event is ignored, both refusals are described and tested, the queue preselects nothing, and 0031 has the dated sentence.

#976 took 0097, so this migration is now 0098 and the CLI constant 89.

WaylandYang and others added 2 commits September 28, 2026 12:27
Maintainer edit on deeplethe#975. The retirement of state rows with equal ends lacked the guard the next
step has: a row a person wrote gains a source link when a single-date statement merges into it,
and was then invalidated. It now takes only rows with from_statement_id or implied.

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

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

Thank you. All three are done as asked and I verified each against the code and by running it: the marks-only ask writes only marks and marks_asked_at, its update is a no-op if a person decided in the meantime, a model that never gives the value is asked once, the retirement needs a source statement with the same equal ends, and a coarse end closes the open row in either order. Merged with dev: store 291, server 518, extract 97, web 211, all passing.

I pushed one line. The retirement step lacked the guard the next step has, from_statement_id or implied. A row a person wrote gains a source link when a single-date statement merges into it, and the step then invalidated it; with the guard it stays. A test for that case would be welcome in a follow-up.

One thing to think about, not blocking: closing at the end of the period stores "left in 2024" on a row from 2024-03-01 as 2024-03-01 → 2025-01-01 at year precision, which the line prints as → 2025. The reading on the world axis is what was asked for, but a reader will take it as left in 2025. An end that names a period probably wants to print the period it names. Please open an issue for it with what you think is right.

Landing it. #966 closes with it. Thanks again.

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

Maintainer review: sound, landing.

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 statement dated at one moment, bound to a state property, becomes a state that holds at no moment

2 participants