Repository navigation
An end that names a period prints as the period after it #994
Description
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) prints2024-03-01 → 2025. The chat prompt says2023means the year, so the model reads "left in 2025".- The graph page (
fmtInterval) prints2024-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_onthat close a row atcloses_atset it when that instant is the end of the period. - It lives with the end, as
end_derivedand the end's mark incorrected_endsdo:rehome_txandgraph::adoptcarry it;rewrite_end_txdrops 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::spansteps back one unit when the flag is set;worldstays as it is.- The web's printers and the export's
world_timeread the same flag. That coversfmtTimeand 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 asinterval_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
- Store the start of the period (2024-01-01) and have readers add the period. Every reader of
valid_towould change: the world-axis queries, the timeline and every printer. The row would also store an end before its start. - Infer it from the values, as an end whose previous period contains the start. "From 2024-03-01 until 2025" has the same values and means 2025.
- Leave such an end unmaterialized. The open row would keep running, which the A binding to a state property says what a single date marks, and a state row whose ends are equal is never written #975 review asked to avoid.
I'm raising this rather than claiming it, and am glad to implement it if the direction is right.
Activity
Floating-Y commentedon Sep 29, 2026
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
2024to2025must 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.
Maya-Kid commentedon Sep 29, 2026
Thanks, @Floating-Y. Yes, that covers them, and two of your points are cases my proposal missed.
insert_fact_onalso moves the inherited end to the end of the period (bucket_endinrefine_target). It has to set the flag, as the closing path does.On the other three:
docs/design/time.md, in the same PR.I'll wait for @WaylandYang's view before writing any of it.
Maya-Kid commentedon Sep 30, 2026
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_onewrites that position as the end:a_stated_quarter_is_the_three_months_it_names). "本季度" and "上季度" (quarter_around) are written the same way.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.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_forrow:works_forrowSeptember 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:
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:
resolve_one;quarter_around;untilof 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.WaylandYang commentedon Oct 4, 2026
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:
Since you wrote this, #1054 and #1059 changed
rewrite_end_txand 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.Maya-Kid commentedon Oct 15, 2026
Thanks, @WaylandYang. I'll do cut 1 first and start cut 2 after it is merged.
Cut 1
facts.end_after_period, with a check that it only sits on a dated end the timeline did not draw.insert_fact_onsets it in its end-only closing path and inrefine_target, when either moves an end to the end of its period.rehome_txand an errata revision carry it.rewrite_end_txwrites it in the insert Use chapter attestation to order unique state timelines #1054 extended, so an end the timeline draws, or an unknown one, never has it. A start-less row never gets it, however A deleted document's date no longer attests: evidence records when its passage speaks, and attestation is rebuilt from what remains #1059 places the row: an end is moved only when the row's start lies in the period.graph::adoptwas removed in The old ontology growth loop is retired: the agent's proposals are the one way the ontology grows #1129, so nothing is left to carry there.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.time_text::span, for chat and MCP;end_after_periodfield besidevalid_toin MCP's structured results;fmtIntervaland the names list;validThrough "2024"^^xsd:gYearwithutopia:endAfterPeriod true, so the stored instant can still be recovered.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.interval_inverted.2024to2025is a correction even though the stored instant stays the same.typed_fact_sourcesorimplied_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.docs/design/time.md, plusledger.md,chat-and-mcp.mdand the MCP doc for the new field.Cut 2
resolve_onewrites 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 throughperiod_boundsor written with only its end, an until against a period anchor, and theuntilof absolute spans. An as-of at a period's end keeps its last day, soperiod_boundsitself stays as it is.insert_fact_onreads a flagged end's period backwards.time_mentions.combinethere. Reading it, a statement that starts in March 2024 with an ending "in 2024" resolves to an end before its start.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_ator a violation'sfact_closed. All three go throughclose_superseded, andrewrite_end_txonly 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 toderived_facts, set when that end comes from a flagged premise or an event's bucket, or should derived rows wait for a follow-up?rollroyces commentedon Oct 15, 2026
读了一下
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",动手前想先把语义定下来:现在的实现把
valid_to: 2025-01-01T00:00:00Z / year直接渲成"2025"(%Y)。issue 想要的是"2024"——也就是valid_to落在 period-start 时,显示被关上的那一个 period,不是它存储的原始日期。但现有测试
valid_to: 2024-07-01 / month → "2024-07"表达的是相反的语义:valid_to落在 month-start,显示就是 month-start 那个名字(2024-07),而不是2024-06。所以 issue 期望的"period 减一"在所有精度上都生效(年→上一年,月→上个月,日→前一日),还是只在年/月的 period-start 上生效(因为日的 period-start 就是上一日,这和现在一致)?
valid_to: 2024-07-01 / month → "2024-07"的测试怎么处理?"2024-06")"2024-07",只对年精度生效) — 看起来与 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: truevalid_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 路径要不要走到底。Maya-Kid commentedon Oct 16, 2026
@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).2024-07-01 / monthwith no flag still reads2024-07, anda_span_reads_each_end_by_its_own_rulekeeps that case as it is.Maya-Kid commentedon Oct 17, 2026
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:
facts.end_after_periodwith 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.time_textfor chat and MCP,end_after_periodbesidevalid_toin MCP's structured results, and the export.fmtIntervaland 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
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.2024 → 2024.end_after_period: trueon a row with no start, no dated end, or a predicate that is not a declared state answers 422bad_end_after_period.Three questions
2020 → 2024(stored 2024-01-01) whose start moves to 2024-03-01 is refused asinterval_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?resolution::entity_fact_lines_manyprints 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?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.
WaylandYang commentedon Oct 19, 2026
Thank you for the detailed plan, and for keeping #1295 to the mark; it's in. On the three questions:
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.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.derived_factswhen 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.