Skip to content

temporal: address history by assignment ordinal, not source line (#868) - #909

Merged
InauguralPhysicist merged 1 commit into
mainfrom
temporal-when-occurrence-868
Aug 9, 2026
Merged

temporal: address history by assignment ordinal, not source line (#868)#909
InauguralPhysicist merged 1 commit into
mainfrom
temporal-when-occurrence-868

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

The defect

what is x at L keys the past by source line, and a source line 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 assigning one name N times
keeps exactly one live entry:

x is 0
for i in range of 5:
    x is i * 10          # line 3, executed 5 times: 0 10 20 30 40
print of (what is x at 3)   # 40 — only the last is reachable
print of (prev of x)        # 30 — and that is the entire remaining depth

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.eigs
has 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:

print of (what is x when 3)   # 10 — the second iteration
print of (prev of x when 3)   # 0  — the one before it

Every interrogative takes the qualifier (who/when/where/why/how), 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.

Why when and not at [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 space
the 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_WINDOW assignments, 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 spawn all widen the line history to every name; none of them widens
this, 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:

  • an ordinal that has not happened yetnull
  • one that has aged out of the windowraises, naming the retained
    range and the env var

The runtime had the evicted value and dropped it to stay bounded. Reporting
that 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 never-happened.

Error line 8: assignment 5 of 'x' is outside the retained window
(retaining 746..1001; raise EIGS_OCC_WINDOW, currently 256)

A non-number ordinal, a fractional ordinal, and a when on a non-name operand
all 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.c descend into at_exprcollect_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. when
initially had all six unpatched, so:

print of (what is x at nope)     # error[E003]: undefined name 'nope'
print of (what is x when nope)   # no issues found   <-- silently clean

All six now descend, with a regression check in test_lint.sh pinning both
halves 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 x reports. They disagree by the number of
assignments made inside unobserved: blocks — the history records the value
(what is x at L returns it) while env->assign_counts deliberately does not
count 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

Closes #868

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

temporal: what is x at L / state_at of L collapse a loop body to its last execution, so no iteration but the final one is addressable

1 participant