temporal: address history by assignment ordinal, not source line (#868) - #909
Merged
Merged
Conversation
`what is x at L` keyed the past by SOURCE LINE, which is not an injective address for an execution. #827's pruning retains only the strict suffix minima of the line sequence, so a loop body that assigns one name N times keeps exactly ONE live entry: the query answered the last iteration and the other N-1 were unreachable, with `prev of x` reaching one further step back and that being the entire available depth. Since the interesting temporal question is almost always about a particular iteration or call, the addressable set excluded most of what the feature exists for. Line addresses are also edit-brittle — inserting a line above a query silently changes what it means, which is why tests/test_temporal.eigs has to be line-number sensitive. `<kw> is x when <n>` addresses the nth RECORDED ASSIGNMENT instead: 1-based, execution-ordered, injective by construction, unmoved by edits. Every interrogative takes it, and `prev of x when n` is assignment n-1 — the ring still holds the predecessor, so unlike the pruned line history no per-entry prev_value twin is needed. Purely additive: `at` is untouched and its answers are byte-identical. Retention is bounded in the same change, because per-occurrence storage is precisely what #827 was about. Occurrences ride a separate per-name ring — the last EIGS_OCC_WINDOW assignments, default 256 — so peak RSS stays flat across a 16x increase in iteration count (3584 kB at both N=200k and N=1.6M). Arming is the narrowest of the three tiers: a compiled program rings only the names a `when`-qualified query named at compile time, and `state_at` / an open tape / `spawn` cannot widen it the way they widen the line history — that would reintroduce the whole-program over-arming #827 fixed. The one wildcard belongs to the interactive REPL, where the assignments precede the query that would arm them (the line history already does this, same place, same reason); the piped path is untouched, its contract being byte-identical output. The two empty answers are kept distinct rather than collapsed to null. An ordinal that has not happened yet is null. One that has aged out of the window RAISES, naming the retained range and the env var — the runtime had that value and dropped it to stay bounded, so reporting it as null would be indistinguishable from a name that was never assigned: a confident wrong answer instead of a missing one. occ_total counts an assignment before the store can fail for the same reason, so an unstorable ordinal reads as evicted rather than as never-happened. The ordinal space is the history's own recorded-assignment counter, which disagrees with the unqualified `when is x` by the number of assignments made inside `unobserved:` blocks — found while building this and filed as #908. This form has to index what was actually recorded, since that is the only counter that can address a stored entry. A new qualifier field is exactly what the AST walkers silently skip, and it bit here: six separate walkers in lint.c descend into `at_expr`, none of which the compiler flags for missing a case. `when` initially shipped with all six unpatched, so a typo'd ordinal name (`what is x when nope`) linted clean while the `at` twin correctly raised E003. All six now descend, with a regression check pinning both halves. Validation: 3859/3859 release. The bounded-retention gate (#827's test_temporal_memory.sh) gains a when_live row plus window-boundary checks; its planted fault is raising the window to its max, which reproduces #827's signature exactly (104 MB at N=200k -> 535 MB at N=1.6M, ceiling and flatness both red), so the new row is a live instrument. tests/test_temporal_when.eigs adds 28 checks and is deliberately NOT line-number sensitive — that being the point of the feature. Closes #868 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
what is x at Lkeys the past by source line, and a source line is not aninjective address for an execution. #827's pruning retains only the strict
suffix minima of the line sequence, so a loop body assigning one name N times
keeps exactly one live entry:
Since the interesting temporal question is almost always about a particular
iteration or call, the addressable set excluded most of what the reversibility
layer exists for. Line addresses are also edit-brittle: inserting a line above
a query silently changes what it means, which is why
tests/test_temporal.eigshas to be line-number sensitive — the suite was already paying that cost.
The change
<kw> is x when <n>addresses the nth recorded assignment — 1-based,execution-ordered, injective by construction and unmoved by edits:
Every interrogative takes the qualifier (
who/when/where/why/how), andprev of x when nis assignmentn-1— the ring still holds the predecessor,so unlike the pruned line history no per-entry
prev_valuetwin is needed.Purely additive:
atis untouched and its answers are byte-identical.Why
whenand notat [line, occurrence]The issue offered both. The ordinal is strictly better: it is the only one that
fixes problem 2 in the issue (edit-brittleness) as well as problem 1 — a
[line, k]pair still carries a line number. It also reuses an address spacethe runtime already maintains exactly, and reads as the inverse of
when is x,which returns the count.
Retention, decided in the same change
The issue flagged this as the main design constraint, and it is: per-occurrence
storage is precisely what #827 was about. Occurrences ride a separate
per-name ring — the last
EIGS_OCC_WINDOWassignments, default 256.Peak RSS is flat across a 16x increase in iteration count (3584 kB at both
N=200k and N=1.6M, vs a 2944 kB baseline — the ring costs a fixed ~640 kB).
Arming is the narrowest of the three tiers. A compiled program rings only
names a
when-qualified query named at compile time.state_at, an open tape,and
spawnall widen the line history to every name; none of them widensthis, because that would reintroduce exactly the whole-program over-arming
#827 fixed. The one wildcard belongs to the interactive REPL, where the
assignments precede the query that would arm them — the line history already
does this, in the same function, for the same reason. The piped path is
untouched (byte-identical output is its contract).
Missing and evicted are different answers
Kept distinct rather than collapsed to
null:nullrange and the env var
The runtime had the evicted value and dropped it to stay bounded. Reporting
that as
nullwould be indistinguishable from a name that was never assigned —a confident wrong answer instead of a missing one.
occ_totalcounts anassignment before the store can fail for the same reason, so an unstorable
ordinal reads as evicted rather than never-happened.
A non-number ordinal, a fractional ordinal, and a
whenon a non-name operandall fail loudly too (the last at compile time) rather than silently answering
something adjacent.
The walker gap this hit
A new qualifier field is exactly what the AST walkers skip silently, and it bit
here. Six separate walkers in
lint.cdescend intoat_expr—collect_refs,w016_scan,w017_scan, the child-iteration macro,w018_scan,e003_walk—and none of them is a case the compiler can flag for being incomplete.
wheninitially had all six unpatched, so:
All six now descend, with a regression check in
test_lint.shpinning bothhalves side by side.
One thing found on the way
The ordinal space is the history's own recorded-assignment counter, not
what the unqualified
when is xreports. They disagree by the number ofassignments made inside
unobserved:blocks — the history records the value(
what is x at Lreturns it) whileenv->assign_countsdeliberately does notcount it. Filed as #908; this form has to index what was actually recorded,
since that is the only counter that can address a stored entry, and the SPEC
and TRACE docs say so explicitly rather than leaving it as a trap.
Validation
detect_leaks=1, leak tally unchanged at 0.tests/test_temporal_when.eigs— 28 checks, deliberately notline-number sensitive, which is the point of the feature.
tests/test_temporal_memory.sh(Temporal assignment history is unbounded and arms on dead code — a singleprev ofOOMs any long-running program #827's bounded-retention gate) gains awhen_liverow plus the window-boundary checks. Planted-fault validated:raising the window to its maximum reproduces Temporal assignment history is unbounded and arms on dead code — a single
prev ofOOMs any long-running program #827's signature exactly —104 MB at N=200k → 535 MB at N=1.6M — and takes both the ceiling and the
flatness assertion red. The new row is a live instrument, not decoration.
loop,
EIGS_JIT_OFF=1differential).test_doc_examples.pygate).Closes #868