The bare predicate words — converged, equilibrium, stable,
improving, diverging, oscillating — and the report of x builtin
classify a value's recent trajectory into one of those bands (report adds
one label the predicates don't have: moving, for a full window in which
none of the six is true — see The report builtin).
Each
predicate also has a named form, converged of x (and so on), that
binds to a specific value rather than the last-observed one — the
preferred form, especially in a loop condition (see
Convergence loops in practice). This file
is the spec: what each one means precisely, the windowed formula it
evaluates, a canonical dH-sequence trace, and the pointwise behavior it
replaces. (docs/OBSERVER.md is the model; this is the
operational target the runtime enforces.)
These are trajectory claims, not snapshot claims. improving means
"the value has been getting more determined over its recent history",
not "the last single step happened to go down." The canonical use is a
loop condition — loop while improving, loop while not stable — which
must be robust to a single noisy tick. That is why every predicate reads
a window of the last N observations rather than the instantaneous
dH. (The original observer semantics were trajectory-based; the early C
runtime simplified five of the six to single-step checks, which flickered
under noise — see "Pointwise behavior replaced" in each section.)
The predicate words and report are routed: a binding whose most
recent observed assignment is numeric answers from the value
channel — the classifier below, over relative steps Δv/(1+|v|); every
other binding (strings, containers) answers from the entropy channel,
the windowed formulas in "The six predicates". report_value of x is the
value-channel classifier by name (identical to the routed words on a
numeric binding — the two surfaces cannot disagree); classify of [t, "entropy"] reaches the entropy classifier by name. The entropy
MEASUREMENT — where, why, how, trajectory snapshots, container
folds, the tape — is unchanged everywhere.
Why: entropy is a function of |x| alone, so any clause on it is a
clause on magnitude. Measured against 27 analytically-known sequences
(tests/test_convergence_oracle.eigs), the entropy channel scored 19/27
— converged fired across the whole region |x| ∈ [77, 1e307] (a
geometric runaway certified at x ≈ 2.9e5), could never fire for limits
in [~0.013, 76] (Newton's method to sqrt(2) was uncertifiable), and
gave the same computation targeting 5000, 5 and 0.005 three different
verdicts. The value channel scores 25/27; the two misses are the
irreducible tolerance floor, not defects (see the honesty bound below).
The numeric definitions (window N = 10 relative steps
rel = Δv/(1+|v|), raw steps Δv kept alongside — #422):
| band | fires when |
|---|---|
converged |
full window, every |rel| < dh_zero, raw guards clean |
stable |
full window, every |rel| < dh_small, no strong consecutive sign flips |
equilibrium |
full window, |mean(rel)| < dh_zero, variance(rel) < dh_zero² |
improving |
≥ 4 samples, monotone raw steps whose mean and max contract to ≤ 0.7× the older half — a summable (geometric-class) tail, genuinely closing on a limit |
diverging |
value at the saturation ceiling (any window fill), or non-vanishing same-sign raw steps (a linear/polynomial runaway whose Δv/|v| → 0 is still unbounded) |
oscillating |
≥ 4 deadband sign-flips of rel; or non-vanishing raw alternation (a perpetual oscillation below the deadband is still an oscillation); or window-scale folding — ≥ 2 direction reversals with net travel ≤ 0.3× path length and motion above the deadband (a sinusoid sampled slower than its half-period) |
The quiescent lattice on this route: converged ⊂ equilibrium and
converged ⊂ stable (all-under-deadband satisfies all three). The rest
bands exclude the raw structure tests; the motion bands are mutually
exclusive. report resolves the canonical priority oscillating → diverging → improving → converged → equilibrium → stable → moving.
The honesty bound. converged is a stopping criterion, not a
proof: it means settled at the deadband — every recent step below the
tolerance — which is the strongest claim a finite window supports.
Vanishing steps do not imply a limit (the harmonic series' steps vanish;
its sum does not), so a slow-enough divergence will eventually read
stable and, at extreme patience, converged; and a slowly-converging
sequence reads converged while still ~1e-2 from its limit (corpus
cases 6 and 8). The deadband is the tolerance knob:
set_observer_thresholds of [dh_zero, dh_small, h_low] — dh_zero is
the settle tolerance, dh_small the small-motion band; the raw
STRUCTURE tests are deliberately threshold-free (a perpetual ±5 swing is
an oscillation at any tolerance). h_low affects only the entropy
route.
Every predicate reads the same observer state on the most recently
assigned top-level value (g_last_observer):
| Field | What it is | Computed by |
|---|---|---|
entropy |
current information content where is x — recomputed from the value present at ask time (#711), so in-place mutation is visible |
compute_entropy_impl via observer_entropy_now |
dH |
change since previous observation why is x — a trajectory of assignments; mutation does not move it, and a query never writes back |
update_observer (new − last) |
prev_dH |
the previous step's dH |
update_observer |
dh_window |
ring buffer of the last OBSERVER_WINDOW_N (=10) dH values |
observer_window_push in update_observer |
obs_age |
number of observations since the value first existed | update_observer |
The ring buffer is allocated lazily on the second observation (the
first push only happens once obs_age >= 1), so a binding
assigned exactly once pays no allocation. Note the gate is the second
observation, not interrogation: a binding nothing ever queries still allocates
its window on its second assignment, because the observe op is emitted
unconditionally (see OBSERVER.md). unobserved: is what
avoids it. Arena values skip the buffer entirely —
they cannot be tracked across resets.
window below means the dh_window contents oldest→newest;
count = window_size(v) is how many real samples it holds (≤ N).
Three numbers from EigsState (defaults shown):
dh_zero = 0.001 |dH| below this is "essentially zero change"
dh_small = 0.01 |dH| below this is "small but nonzero change"
h_low = 0.1 entropy below this is "low information content"
Override with set_observer_thresholds of [dh_zero, dh_small, h_low].
Two derived window constants (functions of N = OBSERVER_WINDOW_N = 10):
VOTE = 0.6 min fraction of genuine same-direction steps for improving/diverging
FLIPS = ceil(N / 3) = 4 min sign-flips in the window for oscillating
If the window does not yet hold enough samples, every predicate returns
false — "we haven't seen enough yet to claim anything." The minimum is
N for the rest bands (converged, stable, equilibrium) on both
routes, 4 for the value route's motion bands (the half-split needs two
samples per half) and 3 for the entropy route's. A two-write program can
never report any predicate true; this is the single most important
difference from the old pointwise rule, which fired on the first step.
One exception: diverging fires at the numeric ceiling regardless of
window fill — see the saturation rule below.
A binding whose CURRENT value is a function or builtin is outside what
the observer measures — a function has no content to sample, so its
entropy is a constant and dH can never move. On such a binding every
predicate returns false and report/report_value/observe's
band answer opaque (a label outside the six-band lattice, like
moving). Rebinding f from one function to another therefore no
longer classifies equilibrium; it names the gap. The entropy constant
itself is unchanged: functions inside containers contribute exactly the
size/average terms they always did, and no numeric trajectory measures
differently. The check reads the binding's current value at ask time —
rebind f to a number and its numeric trajectory (which was recorded
all along) answers normally again.
Overflow saturates at ±1e308 (num_guard, the "finite by construction"
rule), which turns an unbounded trajectory into a fixed point. Once a
runaway pins there, dH is exactly 0 in the entropy channel and the
relative step is exactly 0 in the value channel, and H(1e308) sits far
under h_low — so every clause of converged is legitimately satisfied.
The window really is quiet. The quiet is an artifact of the clamp, formed
after the evidence of divergence was already destroyed.
So a binding whose last observed number has |value| >= 1e308 is treated
as diverging, and in no rest band:
| predicate | at the ceiling |
|---|---|
converged, equilibrium, stable, improving |
forced false — a value at the clamp is not resting or approaching |
diverging |
forced true, including on a partial window |
oscillating |
evaluated normally — a value flipping ±1e308 reads as the oscillation it is, and report's priority resolves it to oscillating |
Since #861 both report and report_value run the same classifier, so
they agree here by construction. (The H(x) ≡ H(−x) blindness that once
made them disagree at the ceiling — #862 — still exists in the entropy
SIGNAL, but no numeric classification reads it anymore; it is visible
only through classify of [t, "entropy"] and for non-numeric bindings,
whose values have no ceiling.)
diverging is claimed before the partial-window guard because the
evidence is the value's position, not the shape of the (flattened)
window — this is the one place a predicate fires on fewer than 4 samples.
A literal ±1e308 that never overflowed reads diverging too. The
runtime cannot distinguish a saturated value from one deliberately
assigned the ceiling (#865: the saturated value compares equal to itself
under further growth, so no in-language predicate separates them). Of the
two possible errors this is the loud one: a diverging solver that reports
converged returns 1e308 as an answer, while a deliberate ceiling
constant reported as diverging is a visible false alarm. Below the
ceiling nothing changes — 1e307 held constant still converges.
As with the opaque rule, the entropy constants are untouched: this is a classification gate, not a change to what entropy measures. It lives with the predicates rather than in the VM (contrast #708, which must read the binding), so the tape, DAP, and step surfaces inherit it.
All six predicates are now windowed (the #202 series is complete):
| Predicate | Windowed? | Tracked by |
|---|---|---|
converged |
✅ shipped (vm.c kind 0) |
#204 (done) |
stable |
✅ shipped (observer_stable, vm.c kind 1 + report) |
#205 (done) |
oscillating |
✅ shipped (observer_oscillating, vm.c kind 3 + report) |
#206 (done) |
improving |
✅ shipped (observer_improving, vm.c kind 2 + report) |
#207 (done) |
diverging |
✅ shipped (observer_diverging, vm.c kind 4 + report) |
#208 (done) |
equilibrium |
✅ shipped (observer_equilibrium, vm.c kind 5 + report) |
#209 (done) |
report of x follows the same windowed helpers (see The report builtin).
The "Pointwise behavior replaced" note under each predicate records the
single-step rule that the windowed version superseded.
Since #861 the kinds dispatch by route: a numeric binding answers from
the value-channel definitions in "Which channel answers" above; the
windowed-entropy formulas in the next section serve non-numeric bindings
and the explicit classify of [t, "entropy"] channel. report_value of x (#294) is the value-channel classifier by name — on a numeric binding
it and the predicate words are one classifier and cannot disagree.
These formulas classify the trajectory of entropy(value). Since #861
they answer only for non-numeric bindings (strings, containers) and the
explicit entropy channel; numeric bindings use the value-channel
definitions above. The traces and design notes are kept because the
mechanics still run — on the signal they were always sound for.
count == N
AND for every dH in window: |dH| < dh_zero
AND entropy < h_low
The strongest band: the value is at rest, has been at rest for a full
window, and sits in a low-information basin. A trajectory that stops for
one step but is information-rich (e.g. an irrational fixed point) is
equilibrium, not converged — the entropy < h_low clause blocks it.
Trace (dh_zero=0.001, N=10):
| step | window (newest last) | entropy | converged |
|---|---|---|---|
| 1–9 | filling (count < 10) | — | false (partial) |
| 10 | [0,0,0,0,0,0,0,0,0,0] |
0.00002 | true |
| 11 | [...,0, 0.5] (one spike) |
0.00002 | false (window not all-quiet) |
Pointwise behavior replaced: |dH| < dh_zero && entropy < h_low —
fired after a single quiet step, a false positive for any iterative
scheme whose first quiet step is followed by more motion (Newton early in
descent, gradient descent crossing a saddle).
count == N
AND |mean(window)| < dh_zero
AND variance(window) < dh_zero^2
The window is centered on zero motion with negligible spread — the value
is sitting still on average, regardless of entropy. converged is the
strict subset of equilibrium that also requires every individual dH
near zero and low entropy; a value can be at equilibrium (zero-mean,
low-variance) while still information-rich.
Trace:
| step | window | mean | var | equilibrium |
|---|---|---|---|---|
| 10 | [0,0,…,0] |
0 | 0 | true |
| 10 | [+0.0008,−0.0007,…] tiny zero-mean |
~0 | < 1e-6 | true |
| 10 | [+0.5,−0.5,+0.5,…] zero-mean, high var |
~0 | 0.25 | false (variance) |
Pointwise behavior replaced: |dH| < dh_zero — a single near-zero
step, with no persistence or spread check.
count == N
AND for every dH in window: |dH| < dh_small
AND entropy >= h_low
AND no consecutive sign flips in window
(no i with window[i]*window[i+1] < 0 and both |.| > dh_zero)
Small but nonzero motion at high information content, holding its direction — the "doing a little, but settled and not bouncing" band. Excludes the oscillation case so the bands stay mutually exclusive in the gray region.
Trace (dh_small=0.01):
| step | window | entropy | stable |
|---|---|---|---|
| 10 | [0.003,0.004,0.003,…] small, same sign |
0.4 | true |
| 10 | [0.003,−0.004,0.003,−0.004,…] flipping |
0.4 | false (sign flips) |
| 10 | [0.02,0.03,…] exceeds dh_small |
0.4 | false (too large) |
| 10 | small same-sign | 0.02 | false (entropy < h_low) |
Pointwise behavior replaced: |dH| < dh_small && entropy >= h_low && !(dH*prev_dH < 0 && |dH| > dh_zero) — a two-point sign check instead of a
full-window one.
count >= 3
AND sum(window) < 0 (NET entropy descent — magnitude-aware)
AND down_fraction >= VOTE (VOTE = 0.6; a "down" step is dH < -dh_small)
where down_fraction = (# steps with dH < -dh_small) / count, tested in
integers as down * 5 >= count * 3.
Information content is falling over the window — the value is becoming more determined. The rule is a hybrid of two independent guards:
sum(window) < 0is the magnitude-aware net test. The window'sdHvalues telescope toentropy_now − entropy_oldest, sosum < 0means the value ends the window more determined than it began. A run that ticks down on most steps but ends with higher entropy (a few large up-ticks outweighing many small descents) is not improving.down_fraction >= 0.6is the proportional vote: a sustained majority of steps must be genuine descents (clearing the gray band atdh_small). This tolerates noisy up-ticks without an absolute cap, and — by usingdh_small, notdh_zero— keeps a steady gray-band descent out ofimproving: such a window hasdown_fraction = 0and readsstable, honoring the #187 mutual-exclusivity contract.
Design note. This is a deliberate hybrid, not a port of an ancestor rule. EigenChat's
TemporalLossState.is_improvingis a magnitude-blind directional ratio vote (it reports "improving" even on a net-worsening run); the legacy language predicate was pointwise (radius decreasing). We take the ratio vote's noise tolerance and add thesum < 0magnitude gate so a net-worsening trajectory is never called improving.
Trace (dh_small = 0.01):
| step | window | net sum | down/count | improving |
|---|---|---|---|---|
| 3+ | steady descent, all dH < −dh_small | < 0 | 1.0 | true |
| 3+ | descent with a couple of up-ticks, still 60%+ down | < 0 | ≥ 0.6 | true |
| 3+ | most steps down but net entropy rose | ≥ 0 | — | false (sum ≥ 0) |
| 3+ | net down but < 60% genuine descents | < 0 | < 0.6 | false (vote) |
| 3+ | steady gray-band descent ( | dH | < dh_small) | < 0 |
| 2 | — | — | — | false (count < 3) |
Pointwise behavior replaced: dH < -dh_small — fired on a single
negative tick and dropped the next frame if entropy bounced; flickered
under noise (#207).
Mirror of improving:
count >= 3
AND sum(window) > 0 (NET entropy ascent — magnitude-aware)
AND up_fraction >= VOTE (a "up" step is dH > +dh_small)
Information content rising over the window — the value becoming less
determined — with the same magnitude gate and proportional vote, sign
reversed. (EigenChat used an asymmetric threshold here — divergence
required stronger evidence, 0.8 vs improving's 0.6, to avoid false alarms
on a temporary setback. The C implementation keeps VOTE = 0.6 for symmetry;
revisit if divergence proves trigger-happy in practice.)
Trace: symmetric to improving with the sign of the net sum and the
vote direction reversed.
Pointwise behavior replaced: dH > dh_small.
count >= 3
AND sign_flip_count(window) >= FLIPS (FLIPS = ceil(N/3) = 4)
The dH sign flips at least FLIPS times across the window — sustained
back-and-forth, not a single reversal. A sign_flip counts adjacent
samples whose product is negative and whose magnitudes both clear
dh_zero (sub-noise wobble does not count).
Trace:
| step | window dH signs | flips | oscillating |
|---|---|---|---|
| 10 | + − + − + − + − + − |
9 | true |
| 10 | + + + − − − + + + |
2 | false (< 4 flips) |
| 10 | one reversal then steady | 1 | false |
Pointwise behavior replaced: dH*prev_dH < 0 && |dH| > dh_zero — a
single adjacent sign flip, indistinguishable from one reversal in an
otherwise monotone descent.
Under the windowed semantics the "active motion" bands —
improving, diverging, oscillating — are mutually exclusive, and each
is exclusive of the "at rest" bands. The three quiescent bands form an
intentional subset lattice rather than disjoint sets; report resolves to
the most specific via its priority order.
improvinganddivergingrequire opposite net trends, so at most one fires.oscillatingrequires ≥FLIPSsign changes, which a window with a monotone net trend (improving/diverging) cannot have. (At the numeric ceilingdivergingis forced true from the value's position rather than the window's trend, so it is the one case where both can hold at once: a trajectory that moves in and out of the ceiling and lands on it has realdHsign changes AND a saturated last value.report's priority order resolves that tooscillating, the more specific claim.)stablerequires every|dH| < dh_small;improving/divergingrequire ≥ 60% of steps to cleardh_smallin one direction, so a uniformly small-motion (gray-band) window isstablewith adown_fraction/up_fractionof 0 — never improving or diverging. This is the #187 contract enforced at the window level. Thestableno-consecutive-flips clause likewise excludesoscillating.- The quiescent lattice (full-window, near-zero motion):
converged ⊂ equilibrium, and a high-entropyequilibrium ⊂ stable. Concretely a full quiet window is exactly one of:- low entropy →
converged(andequilibrium;report→converged); - high entropy →
equilibriumandstable(report→equilibrium). Soequilibriumnever fires alone — it is always accompanied byconverged(low H) orstable(high H). Astablewindow that is not equilibrium is one with steady directional drift (mean|dH| > dh_zero) at high entropy: moving a little, but settled.
- low entropy →
- The bands are not exhaustive. A full window with steady gray-band
drift at low entropy fires nothing: the steps are under
dh_smallsoimproving/divergingare excluded by the #187 rule, the mean is overdh_zerosoequilibrium/convergedare excluded, andstable'sentropy >= h_lowclause excludes it too. That state is real (a value decaying toward zero is in it) and it has no predicate —reportnames itmoving(#735). Do not read "no band fired" as "at rest".
This makes report's priority order load-bearing: oscillating →
diverging → improving → converged → equilibrium → stable returns
the most specific true band. tests/test_predicate_matrix.eigs pins the
overlaps and the report resolution.
report of x (builtins.c:builtin_report) returns the first matching
band, tested in priority order:
oscillatingdivergingimprovingconvergedequilibriumstable
…and, when none of the six is true at a full window, the residual band:
moving
These all use the same windowed helpers as the predicates, so
report of x == "converged" agrees with if converged: on the same value
— at a full window. That agreement is the contract: at a full window
the windowed helpers are the only authority, so report either names a
band whose bare predicate is true, or says moving. It never names a band
the predicates deny. (moving is also the value channel's residual label,
so both channels answer "not settled, not in any named band" the same way.)
For a partial window (count < N), the full-window predicates
(converged/equilibrium/stable) are all false by the partial-window
rule, but report still needs to say something, so it falls back to an
instantaneous best-effort label: equilibrium if the last
|dH| < dh_zero, else stable if |dH| < dh_small at high entropy, else
stable. This is the one place report can disagree with the bare
predicates, and only while observations are still accumulating — by the
time the window fills, the windowed helpers decide and the two agree.
#735: that fallback used to run at any window fill, so a full window
in which no band fired was still labelled from the last dH alone — a
low-entropy gray-band drift (every step under dh_small, so not
improving/diverging; mean over dh_zero, so not equilibrium/converged;
entropy under h_low, so not stable) reported equilibrium while nothing
had settled. A convergence loop written exactly as the settled-plus-hold
recipe below recommends therefore exited early, at rc=0, with a plausible
answer. The fallback is now gated on count < N, and the agreement
invariant is asserted directly in tests/test_predicate_matrix.eigs
rather than stated only here — which is how it survived.
x is 1000000
for i in range of 12:
x is 1000000 # same value 12 times → window fills with zero steps
if converged:
print of "converged" # YES — and the same holds for 5, 42, or 0.005
Before #861, whether a constant could certify depended on its magnitude
(entropy < h_low admitted only |x| > ~76 and |x| < ~0.013). The
value route reads motion, so magnitude is irrelevant.
sqrt(2) settles to 1.41421…; once a full window of relative steps sits
under the deadband it reports converged — the textbook example of
convergence is certifiable. (Before #861 the entropy < h_low clause
blocked every limit in [~0.013, 76], and this section documented the
blindness as intended behavior: "it reports equilibrium, not
converged." See tests/test_windowed_converged.eigs WC4, now pinned to
the certification.)
y is 0
y is 0
if converged:
print of "converged" # NO — count = 2 < N; partial-window rule returns false
A bare predicate (converged, stable, …) has no syntactic subject, so it
classifies whichever binding was observed last in scope. Every
assignment is observed, so a trailing assignment silently repoints it:
loop while not converged:
x is x * rate # the quantity you mean
k is k + 1 # observed last → the bare predicate now reads k
k increments steadily, its entropy flattens at a fixed step count
regardless of rate, and the loop halts on k — not x. (This is exactly
how dynamics' settle_steps returned the same count for every rate.)
Name the subject with the named form:
loop while not (converged of x): # binds to x's slot, every iteration
x is x * rate
k is k + 1
converged of x classifies x's own slot trajectory regardless of what
else is assigned, and a named-predicate loop condition is
self-terminating — it does not arm the global-alias auto-stall that the
bare loop while not converged opts into. The same applies as a plain
expression: report of x and converged of x read x; the bare
converged reads the last-observed binding. Prefer the named form whenever
more than one binding is observed in scope.
loop while not converged reads as the obvious convergence idiom, and
since #861 it is correct for any numeric value that actually settles —
gently, steeply, oscillating on the way in, at any magnitude. What
remains worth knowing: the tolerance semantics (settled ≠ arrived), the
observation-cadence rule, and the divergent-input guard. Every trace
below is a real run.
A predicate classifies the trajectory by its steps against the
deadband. A quantity that is still moving but observed in tiny per-step
increments has every relative step under dh_zero and reads settled —
while still far from its limit:
x is 100.0
for i in range of 20:
x is x * 0.999 # genuine motion, but each step is ~0.1%
report of x # "converged" — settled AT THE DEADBAND; x is ~98, not ~0
This is the tolerance semantics, not a defect: converged means every
recent step is below the tolerance, and per-step motion of 0.1% at the
default dh_zero = 0.001 is exactly the boundary. Tighten the deadband
(set_observer_thresholds) or fix the cadence:
The lesson is about observation cadence: observe at a rate matched to
the dynamics, not once per micro-step. The robust pattern is to advance the
system several substeps unobserved and observe the quantity once per
frame, so each observed dH reflects a meaningful step (dynamics/physics.eigs
runs SUB integration substeps unobserved, then observes once per frame
— without this, a damped oscillator, a diverging one, and a steady
oscillation all read equilibrium alike).
Entropy is the binary entropy of p = 1/(1+|x|) — highest at |x| = 1
(the horizon), falling toward 0 as |x| → 0 or |x| → ∞. On the old
entropy route this made a value shrinking toward 1 read diverging
(rising H) and a runaway growing from 1 read improving (falling H) —
both artifacts of the signal, not the motion. The value route reads the
motion:
x is 100.0
for i in range of 13:
x is x * 0.7 # 100 → ~1: steps contracting toward a limit
report of x # "improving"
x is 1.0
for i in range of 13:
x is x * 1.43 # 1 → ~105: non-vanishing same-sign steps
report of x # "diverging"
The formula and its horizon property are unchanged in the MEASUREMENT
(where is x at 1.0 is still the maximum) and still classify
non-numeric bindings; H(x) ≡ H(1/x) ≡ H(−x) (#862) remains a blind
spot of that signal, reachable via classify of [t, "entropy"].
A residual that decays into rest now reads converged once its window
settles — the pre-#861 behavior this section used to document (Gauss-
Seidel's residual pinned at equilibrium forever, loop while not converged running to the cap on a solved system) was the dead zone.
dynamics/solve.eigs' solvers exit through the predicate itself.
A residual swinging toward its limit (PageRank power iteration) reads
oscillating while the swings dominate and certifies once a full window
sits under the deadband. The pre-#861 flicker — a single spurious
equilibrium at iteration 2, requiring a debounce-and-hold recipe — came
from the entropy signal's instantaneous fallback; the routed classifier
does not produce it.
loop while not (converged of x): # named form: reads x, whatever else is assigned
x is next_step of x
Two cases still deserve a guard:
- Input that may genuinely diverge.
converged(correctly) never fires on a runaway. The bare form (loop while not converged) carries the observer stall backstop — ~100 quiet iterations end the loop with__loop_exit__ == "stalled"— but the named form deliberately does not (it must not false-halt on the global alias), so give it an absolute cap:
it is 0
loop while not (converged of x):
x is next_step of x
it is it + 1
if it >= max_iters:
throw of "did not settle"
- Tolerance tighter than the default.
convergedfires at the deadband (dh_zero, default 0.1% relative). For a tighter answer, lower it first:set_observer_thresholds of [1e-6, 1e-5, 0.1].
The dh_window costs one xcalloc(N * sizeof(double)) (80 bytes at N=10)
per interrogated value, lazily on the second observation. Per assignment
the cost is one buffer write + head advance, gated on the compile-time
observer-tracking flag — values that no predicate or interrogative ever
reads pay nothing. Free is handled in free_value before the VAL_NUM
freelist path so recycled numbers do not leak the buffer.
docs/OBSERVER.md— the model behind the predicatesdocs/SPEC.md— language-level surface (the words as expressions and as bare conditions)tests/test_predicate_matrix.eigs— the predicate-family regression matrix (issue #200)tests/test_windowed_converged.eigs— lock-in tests for windowedconvergedtests/test_report_alignment.eigs— report-predicate agreementInauguralSystems/dynamics— the consumer that surfaced the "Convergence loops in practice" guidance (solve.eigs,physics.eigs; findings F-DYN-2 / F-DYN-6 in itsFINDINGS.md)