observer: route numeric predicates to the value channel (#861, #864) - #892
Merged
Conversation
The entropy channel cannot be a convergence detector for numbers. H is a
function of |x| alone, so every clause on it is a clause on MAGNITUDE:
`entropy < h_low` made all of |x| in [77, 1e307] a permissive region (a
geometric runaway certified `converged` at x ~= 2.9e5, a linear one exited
`loop while not converged` at y = 88) and every limit in [~0.013, 76] a
dead zone (Newton's method to sqrt(2) could never certify). The same
computation targeting 5000, 5 and 0.005 got three different verdicts.
Measured by tests/test_convergence_oracle.eigs: 19/27 against analytic
ground truth, FP=3 FN=5.
The fix: when a binding's most recent observed assignment is numeric
(new ObserverSlot.v_last), the six predicate words and `report` read the
VALUE channel — relative steps Dv/(1+|v|), which is the standard
mixed-tolerance stopping criterion (|Dx| <= atol + rtol|x|, atol = rtol)
with the settle deadband as the tolerance. Non-numeric bindings keep the
entropy classifiers; the entropy MEASUREMENT (where/why/how, snapshots,
container folds, the tape) is untouched everywhere. `report_value` IS the
routed classifier now, so no two surfaces can disagree about one numeric
trajectory — the tape/DAP/step surfaces already classified from the value
channel, so live `report` used to disagree with `--step` about the same
run; it no longer can. `classify of [t, "entropy"]` reaches the entropy
classifier explicitly (builtins.c routes the two channels by name).
Post-routing score: 25/27, FP=2 FN=0, recall 9/9. The two misses are the
irreducible floor of any finite-window detector — sequences settled at
the deadband but ~1e-2 from their limits — and are documented as the
tolerance semantics, with the harmonic series as the proof that vanishing
steps do not imply a limit. `converged` is documented as a stopping
criterion, never a proof.
The numeric band definitions (eigenscript.c, obs_num_*):
- converged: full window, every |rel| < dh_zero, raw guards clean
- stable: full window, every |rel| < dh_small, no strong flips
- equilibrium: full window, |mean| < dh_zero, variance < dh_zero^2
- improving: >=4 samples, MONOTONE raw steps whose mean AND max
contract to <= 0.7x the older half (a summable tail —
genuinely closing on a limit)
- diverging: saturation ceiling (any fill), or #422 raw non-vanishing
same-sign steps
- oscillating: deadband sign-flips; raw perpetual alternation; or
window-scale folding (>= 2 reversals, |net| <= 0.3 x path,
motion above the deadband) — a sinusoid sampled slower
than its half-period folds back without per-sample flips
Quiescent lattice: converged implies equilibrium AND stable. Motion
bands exclusive. Every clause above was forced by a failing test or the
corpus during development: the mean-only contraction called a period-4
cycle improving (alignment artifact -> max clause); non-monotone jitter
flickered a lab sensor into improving (-> monotone clause); the
full-window raw tests let a 6-step runaway answer diverging=false
(-> partial from 4); the bounded-oscillation clause without a settle
guard called cos's settled fixed point oscillating (-> all-under-deadband
guard, caught by corpus case 5 within one run).
Loop halting (vm.c, BOTH copies of the stall check): the stall backstop
drops its `ent >= h_low` clause. That clause compensated for the old
defect — quiet-at-low-entropy used to be where the entropy `converged`
fired — and with the routing it made a bare loop around a runaway
INFINITE (quiet dH at low entropy, predicate correctly refusing, #772
having removed the unconditional cap). New contract: `converged` ends an
observer loop when the value settles; `stalled` ends it after 100 quiet
iterations without certification; __loop_exit__ says which.
Closes #864 as a consequence: the COMPARISON.md showcase now exits via
the predicate in 13 iterations (was 102 via the hidden stall, reported
"stalled"); the recommended named form to a mid-range limit terminates in
19 iterations (used to hang forever); the 15-line settled-plus-hold
recipe PREDICATES.md prescribed is deleted — the honest recipe is the
two-line named loop plus a cap only for genuinely-divergent inputs.
Substantially moots #862 for live classification: the H(x) == H(1/x) ==
H(-x) level sets no longer decide any numeric verdict (the signal keeps
the blind spot; PREDICATES.md now characterizes it, reachable only via
classify-entropy and non-numeric bindings).
Consumer fallout, audited program-by-program in the observer corpus
(goldens re-captured, 14/14):
- dynamics solvers: all answers still correct; iteration counts shift
both ways; results land at the deadband tolerance rather than the
accidental over-iteration the dead zone forced (structural_observer's
3.0018 vs 3.0000000008 — tighter answers now need the knob, not an
accident).
- dynamics physics: "x OSCILLATES, energy settles" reads correctly again
(the window-scale oscillation test); life's population counts certify.
- observer_predicates: a decaying signal reads `improving` all the way
down instead of churning stable->diverging->moving through entropy
magnitude regions.
- lib/simulation.eigs analyze_stability: two pre-existing defects fixed
(an artificial 0->first-element seed jump dominating the window; a
label map missing "moving"/"opaque").
Test re-pins are annotated in place; the inversions are the fix (LE1/LE2
literally swapped: the runaway loop used to exit "normal" via the defect
and the constant loop "stalled" via the dead zone). The corpus gate's
baselines move 19->25 with per-case bands re-pinned — the deliberate,
measured edit the gate exists to force. test_dispatch's counter loops
decay instead of double (the doubling form only terminated through the
defect) with the decaying variable observed last (a bare predicate reads
the last-observed binding).
Docs: SPEC.md (routing, halting contract, report, value-channel
definitions, honesty bound), PREDICATES.md (routing section with the
numeric definitions and tolerance semantics; entropy formulas re-scoped
to the entropy route; canonical examples re-measured — Newton CERTIFIES
now; recipe rewritten), COMPARISON.md (the showcase claim is now
demonstrated by its own example), OBSERVER.md (routing banner; the
convergence-detection bullet), README.md (the improving footnote). Doc
gate 76/76 byte-for-byte.
Gates: release 3787/3787 x2 (plus two mid-development full passes),
ASan+UBSan detect_leaks=1 green with the LeakSanitizer tally at its 0
floor, jit-smoke, freestanding-check, observer corpus 14/14. The JIT
needed no mirror: vm_slot_predicate is the only dispatcher and the JIT
has no predicate involvement.
Closes #861
Closes #864
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Routes the observer predicate vocabulary (converged, stable, improving, etc.) and report for numeric bindings to the value channel (relative-step / mixed-tolerance classifier), keeping entropy classification for non-numeric bindings and the explicit classify … "entropy" path. This aligns runtime surfaces (live report, step/tape/DAP classification, loop-halting semantics) with the numeric convergence/stability intent described in the docs and issues (#861/#864).
Changes:
- Adds routing state (
v_last) and a new numeric predicate/report family in the runtime; updates stall-halting behavior to match the routed semantics. - Re-pins and extends tests (windowed predicate suites, convergence oracle, stepping/loop-exit/dispatch tests) and updates observer corpus golden outputs.
- Updates SPEC/PREDICATES/OBSERVER/COMPARISON/README and stdlib
analyze_stabilityto reflect the new numeric contract.
Reviewed changes
Copilot reviewed 40 out of 40 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/eigenscript.h | Adds ObserverSlot.v_last and declares observer_slot_report_entropy for explicit entropy classification. |
| src/eigenscript.c | Implements numeric routing and the value-channel predicate family; adds observer_slot_report_entropy + routed observer_slot_report. |
| src/vm.c | Updates loop stall checks to be “quiet at any entropy” (both helper and inline opcode path). |
| src/builtins.c | Ensures classify … "entropy" uses the entropy classifier and does not route. |
| lib/simulation.eigs | Seeds analyze_stability from the first sample and expands phase map to include newer labels. |
| README.md | Clarifies numeric vs non-numeric meaning of predicate vocabulary under routing. |
| docs/SPEC.md | Updates spec for numeric routing, loop-halting contract, and report routing semantics. |
| docs/PREDICATES.md | Adds routing section and numeric band definitions; updates examples and rules accordingly. |
| docs/OBSERVER.md | Updates “two signals” section to reflect routing and unified numeric behavior. |
| docs/COMPARISON.md | Updates convergence-loop showcase to reflect routed numeric semantics and loop exit behavior. |
| tests/test_windowed_stable.eigs | Re-pins stable expectations under numeric routing; adds explanatory annotations. |
| tests/test_windowed_oscillating.eigs | Re-pins oscillating expectations under numeric routing; preserves entropy cases via classify. |
| tests/test_windowed_improving.eigs | Re-pins improving expectations under numeric routing; adds explicit entropy-channel coverage case. |
| tests/test_windowed_equilibrium.eigs | Re-pins equilibrium expectations under numeric routing. |
| tests/test_windowed_diverging.eigs | Re-pins diverging expectations under numeric routing; adds explicit entropy-channel coverage case. |
| tests/test_step.sh | Updates stepping assertions to match routed labels (improving vs moving). |
| tests/test_stable_band.eigs | Updates stable-band narrative/output expectations for routed numeric behavior. |
| tests/test_simulation.eigs | Updates analyze_stability assertions to match routed/window-length semantics. |
| tests/test_report_alignment.eigs | Rewrites alignment test to drive value-channel semantics and assert predicate/report agreement. |
| tests/test_predicate_matrix.eigs | Re-pins predicate matrix rows and updates threshold-knob coverage for routed numeric semantics. |
| tests/test_observer_value_signal.eigs | Updates value-signal test to assert report/report_value unification on numerics. |
| tests/test_observer_slots.eigs | Re-pins slot verdict expectations under numeric routing and new lattice relationships. |
| tests/test_observer_coherence.eigs | Updates coherence expectations (e.g., constants at 1.0 certifying converged). |
| tests/test_loop_exit.eigs | Re-pins loop exit reasons for runaway vs constant under routed semantics. |
| tests/test_halting_stall.eigs | Renames/repurposes stall test to settled-constant semantics under routing. |
| tests/test_halting_descent.eigs | Repins runaway-loop halting contract to stall-based termination under routing. |
| tests/test_dispatch.eigs | Adjusts dispatch test loops to avoid routed stall behavior interfering with loop-iteration accounting. |
| tests/test_convergence_oracle.eigs | Updates pinned oracle baselines and expectations to reflect unified routed classifier (25/27). |
| tests/run_all_tests.sh | Updates harness checks/messages for changed labels, loop exit reasons, and iteration ranges. |
| tests/observer_corpus/golden/iLambdaAi__test_stable_band.out | Updates pinned golden output for routed numeric classification. |
| tests/observer_corpus/golden/iLambdaAi__test_report_alignment.out | Updates pinned golden output for routed numeric classification. |
| tests/observer_corpus/golden/iLambdaAi__test_halting_descent.out | Updates pinned golden output for routed halting semantics. |
| tests/observer_corpus/golden/EigenScript__structural_observer.out | Updates pinned golden output for routed numeric classification. |
| tests/observer_corpus/golden/EigenScript__observer.out | Updates pinned golden output for routed numeric classification. |
| tests/observer_corpus/golden/EigenScript__observer_predicates.out | Updates pinned golden output for routed numeric classification and iteration counts. |
| tests/observer_corpus/golden/EigenScript__numerical.out | Updates pinned golden output for routed numeric solver behavior/tolerances. |
| tests/observer_corpus/golden/EigenScript__idioms.out | Updates pinned golden output for routed numeric convergence idioms. |
| tests/observer_corpus/golden/dynamics__solve.out | Updates pinned golden output for routed numeric solver iteration behavior. |
| tests/observer_corpus/golden/dynamics__physics.out | Updates pinned golden output for routed numeric classification in physics example. |
| tests/observer_corpus/golden/dynamics__life.out | Updates pinned golden output for routed numeric classification in life example. |
Suppressed comments (2)
src/eigenscript.c:957
- observer_slot_report_entropy is intended to be an entropy-only classifier (no #861 routing), but it currently calls observer_slot_* helpers that will route to the numeric/value channel when v_last=1. That can make the explicit "entropy" channel accidentally answer from the value classifier for numeric bindings, contradicting the function’s contract and header comment.
const char *observer_slot_report_entropy(const ObserverSlot *s) {
if (!s) return NULL;
if (observer_slot_oscillating(s)) return "oscillating";
if (observer_slot_diverging(s)) return "diverging";
if (observer_slot_improving(s)) return "improving";
src/vm.c:5224
- Same issue as eigs_loop_stall_step: LOOP_STALL_CHECK still keys "quiet" off entropy dH, even though numeric convergence/predicates are now routed to the value channel. For numeric bindings at large magnitude this can stall even while the value trajectory is still moving meaningfully.
double dH, ent;
/* #861: quiet at ANY entropy — mirrors eigs_loop_stall_step
* (see the rationale there). The routed `converged` now owns
* every genuine settle; the stall is the catch-all for quiet
* trajectories the predicate refuses (saturated runaways,
* sub-deadband drift), which live at LOW entropy. */
if (obs_stall_trajectory(&dH, &ent)
&& fabs(dH) < g_obs_dh_zero) {
(void)ent;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
46
to
51
| define analyze_stability(time_series) as: | ||
| tracker is 0 | ||
| # #861: seed from the first element — `tracker is 0` planted an artificial | ||
| # 0 -> first-value jump whose step dominated the whole 10-sample window, | ||
| # so a constant series read "moving" for its entire life. | ||
| tracker is time_series[0] | ||
| statuses is [] |
Comment on lines
1746
to
1750
| if (obs_stall_trajectory(&dH, &ent) | ||
| && fabs(dH) < g_obs_dh_zero && ent >= g_obs_h_low) { | ||
| && fabs(dH) < g_obs_dh_zero) { | ||
| (void)ent; | ||
| g_loop_stall_count++; | ||
| if (g_loop_stall_count >= 100) { |
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 real fix for #861 — per direction: right, not a workaround. Research first, then the routing the corpus argued for.
Why routing, not tuning
The entropy channel cannot be a convergence detector for numbers: H is a function of |x| alone, so every clause on it is a clause on magnitude.
entropy < h_lowmade[77, 1e307]a permissive region (a geometric runaway certified at x ≈ 2.9e5;y += 1exitedloop while not convergedat y = 88) and[~0.013, 76]a dead zone (Newton to √2 uncertifiable). The same computation targeting 5000, 5, 0.005 got three verdicts. No constant fixes a structural property.The value channel's
Δv/(1+|v|)is the textbook mixed-tolerance stopping criterion (|Δx| ≤ atol + rtol·|x|, atol = rtol). And the tape/DAP/--stepsurfaces already classified from it — livereportdisagreed with--stepabout the same trajectory. Routing unifies every surface on one classifier.The measurement
The two misses are the irreducible floor: sequences settled at the deadband but ~1e-2 from their limits. No finite window does better (harmonic: vanishing steps ⇏ limit).
convergedis documented as a stopping criterion — settled at the deadband — never a proof; the deadband is the tolerance knob.The numeric bands
converged / stable / equilibrium: full-window rest tests on relative steps (lattice: converged ⊂ both). improving: monotone steps contracting (mean and max ≤ 0.7× older half — a summable tail). diverging: saturation ceiling or #422 raw non-vanishing same-sign. oscillating: deadband flips, raw perpetual alternation, or window-scale folding (≥2 reversals, net ≤ 0.3× path — a sinusoid sampled slower than its half-period).
Every clause was forced by a failing test during development, usually within minutes: period-4 cycle read
improving(alignment artifact → max clause); lab-sensor jitter flickered intoimproving(→ monotone clause); a 6-step runaway couldn't flag (→ raw tests partial from 4); cos's settled fixed point readoscillating(→ settle guard, caught by corpus case 5 in one run). The externally-anchored corpus caught the one regression I introduced while building the fix for the problem it measures.Loop halting
The stall backstop drops
ent >= h_low(both copies — shared core and interpreter inline). That clause compensated for the old defect; post-routing it made a bare loop around a runaway infinite (#772 removed the cap). New contract:convergedends the loop on settle,stalledafter 100 quiet iterations without certification,__loop_exit__says which.Closes #864 as a consequence
Showcase: 13 iterations, exit via the predicate (was 102 via hidden stall). Named form to 5.0: terminates in 19 (hung forever). The 15-line settled-plus-hold recipe is deleted; the honest recipe is the two-line named loop + a cap for genuinely-divergent inputs. Substantially moots #862 for live classification (level sets no longer decide numeric verdicts; PREDICATES.md characterizes the remaining signal blind spot).
Consumer fallout — audited program-by-program (corpus 14/14 after re-capture)
improvingall the way down instead of churningstable→diverging→movingthrough entropy magnitude regions.analyze_stability: two pre-existing defects fixed (artificial 0-seed jump dominating the window; label map missingmoving/opaque).Test re-pins
~15 files, every inversion annotated in place. LE1/LE2 literally swapped — the runaway loop used to exit "normal" via the defect and the constant loop "stalled" via the dead zone. The corpus gate's pinned baselines move 19→25 with per-case bands re-measured: the deliberate, measured edit the gate exists to force.
Docs
SPEC (routing, halting contract, honesty bound), PREDICATES (routing section + numeric definitions; entropy formulas re-scoped; canonical examples re-measured — Newton certifies; recipe rewritten), COMPARISON (the flagship claim is now demonstrated by its own example), OBSERVER, README. Doc gate 76/76 byte-for-byte.
Gates
detect_leaks=1vm_slot_predicateis the only dispatcher)What this deliberately does not do
where/why/how, folds, tapes) is byte-identical everywhere.converged→settledremains available as a future call; the word now has defensible semantics).Closes #861
Closes #864
🤖 Generated with Claude Code