Skip to content

observer: route numeric predicates to the value channel (#861, #864) - #892

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/861-route-predicates
Aug 5, 2026
Merged

observer: route numeric predicates to the value channel (#861, #864)#892
InauguralPhysicist merged 1 commit into
mainfrom
fix/861-route-predicates

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

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_low made [77, 1e307] a permissive region (a geometric runaway certified at x ≈ 2.9e5; y += 1 exited loop while not converged at 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/--step surfaces already classified from it — live report disagreed with --step about the same trajectory. Routing unifies every surface on one classifier.

The measurement

before after
corpus (27 analytic sequences) 19/27, FP=3, FN=5 25/27, FP=2, FN=0, recall 9/9

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). converged is 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 into improving (→ monotone clause); a 6-step runaway couldn't flag (→ raw tests partial from 4); cos's settled fixed point read oscillating (→ 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: converged ends the loop on settle, stalled after 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)

  • dynamics solvers: all answers correct; results land at the deadband rather than the accidental over-iteration the dead zone forced (3.0018 vs 3.00000000008 — tighter needs the knob, not an accident). This is the visible cost of honest tolerance semantics.
  • dynamics physics: "x OSCILLATES, energy settles" reads correctly again; 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 analyze_stability: two pre-existing defects fixed (artificial 0-seed jump dominating the window; label map missing moving/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

gate result
release suite 3787/3787 ×2 (plus two mid-development passes)
ASan+UBSan detect_leaks=1 3785/3785 — leak tally at its 0 floor
jit-smoke / freestanding-check green (JIT needed no mirror: vm_slot_predicate is the only dispatcher)
observer corpus 14/14
convergence oracle 25/27 pinned, per-case bands pinned

What this deliberately does not do

  • No entropy constant or formula changed — the measurement (where/why/how, folds, tapes) is byte-identical everywhere.
  • No vocabulary rename (convergedsettled remains available as a future call; the word now has defensible semantics).
  • Consumers are unaffected until their next pin bump; the corpus predicts the visible shifts (iteration counts, deadband-tolerance results).

Closes #861
Closes #864

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 19:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_stability to 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 thread lib/simulation.eigs
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 thread src/vm.c
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) {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants