Skip to content

An end that names a period prints as the period after it #994

Open

Description

@Maya-Kid

Raised in the #975 review.

What happens

Since #975, an end that names a period can close an open state row that starts inside that period. The row is closed at the end of the period (graph::insert_fact_on, 0053 revised 2026-09-27).

"Works for Meridian from 2024-03-01", then "left in 2024", is stored as 2024-03-01 → 2025-01-01 at year precision. On the world axis that is right: the person worked there through 2024 and not in 2025.

Every reader prints the stored end at its precision, and that names the next period:

  • time_text::span (chat and MCP) prints 2024-03-01 → 2025. The chat prompt says 2023 means the year, so the model reads "left in 2025".
  • The graph page (fmtInterval) prints 2024-03-01 ~ 2025. The entity history, the document viewer and pending facts print the month, 2025-01.
  • The export writes schema:validThrough "2025"^^xsd:gYear.
  • The correction dialog pre-fills the end with 2025.

The same happens at every precision:

  • A row from 2023-06-01 that "left on 2023-06-01" closes at the end of that day and prints 2023-06-01 → 2023-06-02.
  • "Left in March 2024" on a row from 2024-03-10 prints 2024-03-10 → 2024-04.

The printed end is ambiguous:

  • "Left in 2025" on a row from 2020 is stored at the start of its period, 2025-01-01, and also prints → 2025.
  • A statement "from 2024-03-01 until 2025" is stored with the same values as the "left in 2024" row.

Nothing on the row tells these apart.

What I think is right

Keep the stored instant. The world axis, the timeline and the stored interval are right as they are.

Record which kind of end it is. Add a flag on the fact, for example facts.end_after_period. It says the end is the instant after the period the text named.

  • The paths in insert_fact_on that close a row at closes_at set it when that instant is the end of the period.
  • It lives with the end, as end_derived and the end's mark in corrected_ends do:
    • rehome_tx and graph::adopt carry it;
    • rewrite_end_tx drops it when it writes a new end.

Print the period the text named. That is the stored end minus one unit at its precision: 2024-03-01 → 2024 and 2023-06-01 → 2023-06-01. It is the same form as "left in 2024" on a row that began before 2024.

  • time_text::span steps back one unit when the flag is set; world stays as it is.
  • The web's printers and the export's world_time read the same flag. That covers fmtTime and the month the history, document and pending views print. time_text's module doc asks these copies to agree.
  • The export would then write validThrough "2024"^^xsd:gYear.

The correction dialog and route.

  • If the dialog showed 2024, saving it unchanged would be refused as interval_inverted: 2024-01-01 is before 2024-03-01.
  • So the correction route should take the write path's rule. An end whose period contains the row's start closes at the end of that period, with the flag set.
  • A person who types "2024" for a row from 2024-03-01 then gets what they meant, rather than an error.

Considered and not proposed

I'm raising this rather than claiming it, and am glad to implement it if the direction is right.

Activity

  1. Floating-Y commented on Sep 29, 2026

    @Floating-Y
    Contributor

    I agree with keeping the stored instant and recording whether it represents the end of the period named in the text. That preserves the existing world-axis behavior and lets readers display the intended date.

    After checking the code, I think a few details are worth including in the fix:

    • Both statement orders need to set the flag. Closing an existing open row is one path. Adding a start to an existing end-only row is another; that path can also move the end to the next period.

    • An unchanged end should keep its meaning. Opening the correction dialog and saving without changes should do nothing. Changing only the start should preserve the existing end. Changing the displayed end from 2024 to 2025 must count as a correction even if the stored timestamp stays the same.

    • All readers need the same rule. This includes chat/MCP, the web views, the correction dialog, and export. Some web views currently truncate dates to the month, so passing the flag alone will not be enough.

    • Existing rows need a cautious migration. We cannot infer the flag from the timestamp alone. We should only backfill where the source statements and revision history establish the intended period, and leave ambiguous rows for review.

    For the correction endpoint, we should also make the input contract explicit: readers return the stored boundary, while the form shows the named period. Existing API clients should not silently get a different result when sending a date back.

    Does this cover the cases you were concerned about?

    Since this affects the data model and the correction API, it would be helpful to get a maintainer’s view on the approach before implementation. My suggestion is to record the agreed behavior in a dated revision to ADR 0053, with corresponding updates to ADR 0031 and the time design doc, and then implement it with regression tests.

  2. Maya-Kid commented on Sep 29, 2026

    @Maya-Kid
    ContributorAuthor

    Thanks, @Floating-Y. Yes, that covers them, and two of your points are cases my proposal missed.

    • The other order. When a start arrives for an end-only row, the time refinement in insert_fact_on also moves the inherited end to the end of the period (bucket_end in refine_target). It has to set the flag, as the closing path does.
    • Rows already written. Since A binding to a state property says what a single date marks, and a state row whose ends are equal is never written #975 landed, both paths have stored moved ends without a flag. I agree the values alone cannot tell. I'd backfill only a row whose own statement states the end one unit earlier at the same precision, and leave the rest for review.

    On the other three:

    • Corrections. Agreed. The API keeps reading and accepting the stored boundary, and only the dialog translates between the named period and the boundary. A client that sends back what it read changes nothing. A person who changes the displayed period gets a correction, even when the stored instant stays the same.
    • Readers. Agreed on one rule everywhere, including the views that cut dates to the month.
    • Records. A dated revision to 0053, with 0031 and docs/design/time.md, in the same PR.

    I'll wait for @WaylandYang's view before writing any of it.

  3. Maya-Kid commented on Sep 30, 2026

    @Maya-Kid
    ContributorAuthor

    While reading #991's time resolution, I found the other side of this. I think it belongs in the same change.

    Time resolution stores a named period's end at the start of its last unit. When the text names a span in whole units, resolve_one writes that position as the end:

    • A stated quarter, "2025年第三季度", becomes 2025-07-01 → 2025-09-01 at month precision (a_stated_quarter_is_the_three_months_it_names). "本季度" and "上季度" (quarter_around) are written the same way.
    • A period with both ends goes through period_bounds, which keeps the last day as written. "Three months ended July 26, 2026" ends at 2026-07-26, and "Fiscal 2027" at 2027-01-25.
    • A written range such as "2020年至2023年" ends at 2023, at year precision.

    These ends print as the text says. But the world axis reads the end of a state or an open statement as the instant the fact stops holding, so the last unit is dropped. On dev, with the quarter's span stored on an open statement and on a works_for row:

    07-15 08-15 09-15
    open statement holds holds does not hold
    works_for row holds holds does not hold

    September is part of the third quarter. The same happens to 2027-01-25 in Fiscal 2027, and to 2023 in "2020年至2023年".

    So the ledger has two ways to store an end that names a period:

    • Closing paths (this issue). The end is the instant after the period. It reads right on the world axis, but prints as the next period.
    • Time resolution. The end is the start of the period's last unit. It prints right, but reads one unit short.

    The flag proposed above covers both. Time resolution would store the instant after the last unit and set the flag. For the quarter that is 2025-10-01, which prints as 2025-09. These rows would then read right on the world axis, and every printer would follow one rule.

    That adds these writers to the change:

    • the quarter in resolve_one;
    • quarter_around;
    • the until of absolute spans;
    • period_bounds.

    For rows already written, each statement's time mention keeps the model's reading (reference, shape) and the resolved span. A backfill can use them to tell which ends named a period.

  4. WaylandYang commented on Oct 4, 2026

    @WaylandYang
    Contributor

    Thanks @Maya-Kid and @Floating-Y, and sorry for the wait. The direction is right; please go ahead, Maya-Kid.

    Keep the stored instant, record on the row that the end is the instant after the period the text named, and have every reader print the named period. Floating-Y's points are in: both statement orders set the flag, an unchanged end keeps its meaning through the correction dialog, and the API keeps reading and accepting the stored boundary.

    Two cuts, in this order:

    1. The flag, the closing paths and the refinement path that set it, the writers that carry or drop it, one printing rule for chat, MCP, the web views and the export, and the correction dialog. Backfill only rows whose own statement states the end one unit earlier at the same precision.
    2. Time resolution's side: a named period's end is stored as the instant after its last unit, with the flag. This one changes what existing rows mean on the world axis, so it lands second with its own backfill from the time mentions.

    Since you wrote this, #1054 and #1059 changed rewrite_end_tx and how a start-less row is placed; the new flag has to ride in that same insert. The record revisions you both named go in the same PRs.

  5. Maya-Kid commented on Oct 15, 2026

    @Maya-Kid
    ContributorAuthor

    Thanks, @WaylandYang. I'll do cut 1 first and start cut 2 after it is merged.

    Cut 1

    • The flag. facts.end_after_period, with a check that it only sits on a dated end the timeline did not draw.
    • One end, two wordings. When an open row is closed by both "until 2025" and "left in 2024", it is marked in either order and prints 2024. An end a person corrected in the dialog (corrected_ends) stays as the person set it. A date closed in Review leaves no mark on the row, so cut 1 treats it as a stated end.
    • One printing rule. A flagged end prints one unit before the stored instant, at its precision. It applies in:
      • time_text::span, for chat and MCP;
      • an end_after_period field beside valid_to in MCP's structured results;
      • fmtInterval and the names list;
      • the history, document, pending and review rows, which drop the month cut for the shared rule;
      • the export, which writes validThrough "2024"^^xsd:gYear with utopia:endAfterPeriod true, so the stored instant can still be recovered.
    • Corrections. The API keeps reading and accepting the stored boundary, and only the dialog translates.
      • The request takes an optional end_after_period. Without it, an end equal to the stored one keeps its flag, so a client that sends back what it read changes nothing. Any other end is taken as given, as today, so an end before the start is still refused.
      • The dialog shows the named period and always sends the flag. An untouched end goes back as stored, even when only the start changed. An edited end is translated with the write path's rule, so "2024" on a row from 2024-03-01 is saved as the end of 2024 instead of being refused as interval_inverted.
      • A change of the flag counts as a change of the end, so changing the shown 2024 to 2025 is a correction even though the stored instant stays the same.
    • Backfill. Only live state rows whose start lies in the named period, and whose source statement (typed_fact_sources or implied_fact_sources) states the end one unit earlier at the same precision. Rows with a corrected end, a drawn end or no statement keep printing as they do now.
    • Records. A dated revision to 0053, dated lines in 0031 and docs/design/time.md, plus ledger.md, chat-and-mcp.md and the MCP doc for the new field.

    Cut 2

    • Wherever resolve_one writes a named period's end, it stores the instant after the last unit, with the flag. That covers the stated quarter, quarter_around, a period read through period_bounds or written with only its end, an until against a period anchor, and the until of absolute spans. An as-of at a period's end keeps its last day, so period_bounds itself stays as it is.
    • Materialization carries the flag, and insert_fact_on reads a flagged end's period backwards.
    • Its backfill works from the interpretations kept in time_mentions.
    • I'd also cover combine there. Reading it, a statement that starts in March 2024 with an ending "in 2024" resolves to an end before its start.
    • Its own dated revision to 0053, with 0031 and docs/design/time.md, in the same PR.

    Outside both cuts. Closing a row at a typed date in Review stores the date as given, whether it is an unconfirmed fact's Close, a conflict's close_at or a violation's fact_closed. All three go through close_superseded, and rewrite_end_tx only refuses an end equal to the start, so "2024" on a row from 2024-03-01 writes an end before its start. That isn't about printing, so I'd raise it separately.

    One open point for cut 1. A derived row takes its end from the premise that bounds it. For a state premise that is its stored end. For an event premise it is the end of the event's bucket, which is already the instant after the period the event named. So a derivation from a flagged row, or one bounded by an event, names the next period in rule_matches, in the rule matches on the ontology page, in the export and in MCP's structured results. Should cut 1 also add the flag to derived_facts, set when that end comes from a flagged premise or an event's bucket, or should derived rows wait for a follow-up?

  6. rollroyces commented on Oct 15, 2026

    @rollroyces
    Contributor

    读了一下 crates/utopia-server/src/time_text.rs:22 的 world() 路径,以及 tests::a_span_reads_each_end_by_its_own_rule 里的现有断言 valid_to: 2024-07-01 / month → "2024-07",动手前想先把语义定下来:

    1. 现在的实现把 valid_to: 2025-01-01T00:00:00Z / year 直接渲成 "2025"(%Y)。issue 想要的是 "2024"——也就是 valid_to 落在 period-start 时,显示被关上的那一个 period,不是它存储的原始日期。

    2. 但现有测试 valid_to: 2024-07-01 / month → "2024-07" 表达的是相反的语义:valid_to 落在 month-start,显示就是 month-start 那个名字(2024-07),而不是 2024-06。

    所以 issue 期望的"period 减一"在所有精度上都生效(年→上一年,月→上个月,日→前一日),还是只在年/月的 period-start 上生效(因为日的 period-start 就是上一日,这和现在一致)?

    1. 另外想确认:这一改对那条 valid_to: 2024-07-01 / month → "2024-07" 的测试怎么处理?
      • 改测试期望("2024-06")
      • 改实现保留("2024-07",只对年精度生效) — 看起来与 issue 不符
      • 不动测试 / 不动实现 — 拒绝这条 issue

    两条路我可以开 PR:

    A. 最小改:world 接受 is_end: bool,只在 is_end=true && 精度∈{year,month} && t 落在 period-start 时把字符串变成上一周期。其它路径不变。需要:

    • 改 world(t, precision, is_end: bool) 签名
    • span() 里 valid_to 那条调用加 is_end: true
    • 现有测试里 valid_to: 2024-07-01 / month → "2024-07" 那条断言要改成 "2024-06"
    • 改 rdf::world_time 与 web/src/time.ts::fmtTime 的对应三处

    B. 另一个语义:valid_to 在 day 或更细的精度上维持现行为(显示存储的日期),只在 year / month 精度上"end = end-of-previous-period",让 month-start 仍然是 month-start。改面更小,但和现有 2024-07-01 / month → "2024-07" 测试直接冲突,那条断言如果保留,fix 就要跳过月精度——这就不是 #994 的修法了。

    想先把这条 fix 路径定下来再动,毕竟 time_text.rs 跟 web/src/time.ts::fmtTime 是同一条规则的两份,定义变了三处都要跟。

    如果方便,也告诉我 maintainer 自己的 2024-07 / month → "2024-06" 的看法——也就是"end of June 2024" 还是"during July 2024" 是你们 UI 上现有句子已经形成的语用。这决定 A 路径要不要走到底。

  7. Maya-Kid commented on Oct 16, 2026

    @Maya-Kid
    ContributorAuthor

    @rollroyces thanks for reading into it. WaylandYang gave #994 to me on 10-04, and the plan for cut 1 is in my comment above, so please don't start a parallel PR. Reviews of mine are very welcome.

    On your question: the rule can't be read from the stored instant alone. 2025-01-01 at year precision is the right end both for "until 2025", which holds up to 2025, and for "left in 2024", which holds through 2024. A rule that looks only at the display would print one of them wrong. That is why cut 1 records on the row that the end is the instant after the period the text named (end_after_period).

    • A flagged end prints one unit before the stored instant, at its own precision, whatever the precision is.
    • An unflagged end prints as today. So 2024-07-01 / month with no flag still reads 2024-07, and a_span_reads_each_end_by_its_own_rule keeps that case as it is.
  8. Maya-Kid commented on Oct 17, 2026

    @Maya-Kid
    ContributorAuthor

    Before the first PR, here is how cut 1 will land and where it differs from my plan of 10-15.

    Three PRs, in order. Cut 1 comes to about 1,370 lines with tests, so I'm splitting it the way #725 went:

    • A. The mark. facts.end_after_period with its check, every writer that sets, carries or drops it, the backfill (migration 0114), and the correction route's optional field. After A the data is complete and nothing prints differently yet.
    • B. Server printing. time_text for chat and MCP, end_after_period beside valid_to in MCP's structured results, and the export.
    • C. Web. fmtInterval and the names list, the history, document, pending and review rows, and the time dialog.

    B and C each need only A.

    Where this differs from 10-15

    1. The check also needs a start: NOT end_after_period OR (valid_from IS NOT NULL AND valid_to IS NOT NULL AND NOT end_derived). That makes "a start-less row never gets it" hold for every writer, so a correction that clears the start keeps the stored instant and drops the mark. Cut 2 relaxes this when time resolution writes marked ends.
    2. Two wordings mark only at the same precision. "Left in 2024" marks an open row that "until 2025" already closed, but not one closed by "until 2025-01-01": a marked day end would print 2024-12-31, which no text said. An end a person corrected is left alone, as promised.
    3. "States the end" in the backfill means an end-only statement, or a single date whose binding marks the end. A single date that marks a start does not count: "joined in 2024" with "until 2025" would otherwise print 2024 → 2024.
    4. The route compares the two ends after truncating each to its precision, so an hour end before a minute start in the same hour is refused instead of stored inverted. An explicit end_after_period: true on a row with no start, no dated end, or a predicate that is not a declared state answers 422 bad_end_after_period.

    Three questions

    1. A start moved past an unchanged end. The dialog sends an untouched end back as stored, so a row shown as 2020 → 2024 (stored 2024-01-01) whose start moves to 2024-03-01 is refused as interval_inverted, as today, now with a translated message. Or should the dialog translate the untouched end too, storing 2025-01-01 with the mark, as the write path would?
    2. The review page's entity lines. resolution::entity_fact_lines_many prints both ends as %Y-%m. It feeds the duplicate cards, the adjudicator's prompt and cache key, and governance's facts tool. Printing them by the shared rule in B changes every dated line whose precision isn't a month, so cached adjudications run once more. In B, or a follow-up?
    3. Derived rows, still open from 10-15: should cut 1 add the mark to derived_facts? If yes, the store part would follow A, and the printing would follow B and C.

    I've started on A. The close-by-date routes in Review get their own issue, as I said on 10-15.

  9. WaylandYang commented on Oct 19, 2026

    @WaylandYang
    Contributor

    Thank you for the detailed plan, and for keeping #1295 to the mark; it's in. On the three questions:

    1. A start moved past an untouched end: keep refusing it as interval_inverted, with the translated message. An end the person didn't touch should keep meaning what it meant; translating it silently would make a decision they didn't make.
    2. entity_fact_lines_many: move it to the shared rule in its own PR after B, not inside B. Re-running every cached adjudication once is a change people should be able to see and judge on its own.
    3. Derived rows: leave them out of cut 1. I've opened A derived row's end that names a period does not say so #1317 for marking derived_facts when the end comes from a marked premise or an event's period; it can follow the backfill and B/C.

    And thanks, rollroyces, for reading into it. Maya-Kid owns #994; reviews of the cut 1 PRs are very welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions