diff --git a/CHANGELOG.md b/CHANGELOG.md index b0fcd95..e36ffc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to EigenScript are documented here. ## [Unreleased] ### Added +- **The observer pair: value-channel raw-step signals (#422) + trajectory + snapshots across call boundaries (#421).** + - *#422*: relative normalization (`Δv/(1+|v|)`) erased exactly two + trajectory classes, and both misread as *accepting* regimes: an + additive/polynomial runaway (`x → x + c` seeded large) read + `converged`/`stable`, and a perpetual oscillation below the deadband + (`x → -x` seeded at 4e-4, or a fixed absolute swing around a large + offset) read `stable`/`converged`. The `ObserverSlot` value channel now + keeps a parallel window of RAW steps and asks one structural question — + are the steps **non-vanishing** (recent-half mean ≥ half the older-half + mean, above a `4·ε·(1+|v|)` fp-noise floor)? Non-vanishing same-sign + steps classify `diverging` (the value channel's first use of the label; + geometric runaway upgrades from `moving` too); non-vanishing alternating + steps classify `oscillating`. Damped (decaying-step) trajectories still + settle to `converged`. `lib/contract.eigs`: `expect_converging` now + fails FAST on a mid-run `diverging`; `invariant_stable` throws on it; + the documented #422 blind-spot caveats are gone. + - *#421*: observer state is binding-identity, so a value passed into a + function arrived with no history — a contract could not classify what + its caller built. New special form `trajectory of x` snapshots the + binding's observer windows into a plain transparent dict + (`kind`/`rel`/`raw`/`dh`/`entropy`/…), and new builtin `classify of t` + (or `classify of [t, "entropy"]`) classifies it with the SAME + slot machinery `report_value`/`report` use (opcodes + OP_TRAJECTORY_SLOT/NAME, appended per the ABI rule). `classify` of a + non-snapshot raises `type_mismatch` — a bare value never silently + classifies as "no trajectory". `lib/contract.eigs` gains the snapshot + form `expect_regime of [trajectory of x, expected, msg]` alongside the + step-fn contracts; the lint knows `trajectory` as a special form (E003). + - Suite section [50j2] (`tests/test_trajectory.eigs`, 20 checks) pins both + issue repros red→green, the damped/settled/halving no-regressions, the + call-boundary crossing, both channels, the loud error paths, and + `expect_regime`. SPEC.md/COMPARISON.md gain byte-pinned examples; + OBSERVER.md documents the raw-step signal and the snapshot model. - **`--step` — the eigsdap v1 CLI tape-stepper (#418).** `eigenscript --step [source.eigs]` opens a recorded trace tape (from `--trace`, `EIGS_TRACE`, or a `--test --trace-on-fail` failure) in diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 55b5a08..d02b1ec 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -204,14 +204,19 @@ Query a binding's assignment history. Always on for top-level bindings; |------|-----------|-------------| | `report` | `report of value` | Classify change trajectory: "improving", "diverging", "stable", "equilibrium", "oscillating", "converged" | | `observe` | `observe of value` | Return [status, entropy, dH, prev_dH] snapshot | - -**`report`, `report_value`, and `observe` on a plain variable are observer -special forms** (decided in #459): like the predicates and interrogatives, -`report of x` / `report_value of x` / `observe of x` are resolved by the -compiler to the named *binding's* slot trajectory — an operation on the -name, not the value — so a user rebinding of these names does not change -them (`--lint` W013 warns on the shadowing attempt). The non-ident forms -(`report of (x + 0.0)`, `observe of expr`) are ordinary calls to the +| `classify` | `classify of t` or `classify of [t, "entropy"]` | Classify a trajectory snapshot (from `trajectory of x`, #421): value-channel label by default, entropy-channel with `"entropy"`. Raises `type_mismatch` on a non-snapshot — a bare value never silently classifies | + +**`report`, `report_value`, `observe`, and `trajectory` on a plain variable +are observer special forms** (decided in #459): like the predicates and +interrogatives, `report of x` / `report_value of x` / `observe of x` / +`trajectory of x` are resolved by the compiler to the named *binding's* slot +trajectory — an operation on the name, not the value — so a user rebinding of +these names does not change them (`--lint` W013 warns on the shadowing +attempt). `trajectory of x` (#421) snapshots the slot's observer windows into +a plain dict (`kind`/`rel`/`raw`/`dh`/`entropy`/…) that survives a call +boundary, for `classify` to read on the other side — the binding slot itself +is binding-identity and a passed value arrives with no history. The non-ident +forms (`report of (x + 0.0)`, `observe of expr`) are ordinary calls to the value-path builtins. `dispatch` is deliberately NOT in this set — it is a plain builtin and a user rebinding wins (see Lists above). diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 0358f61..951141d 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -373,6 +373,27 @@ loop while not converged: # EigenScript: the runtime watches e e is step of e ``` +And because the measurement is already paid, a trajectory is a first-class +thing you can hand to another function — `trajectory of x` snapshots the +observer's windows into a plain dict, and `classify` reads it on the other +side of the call (no equivalent exists elsewhere; a debugger watch window +is the closest analogue, and it can't cross a function boundary either): + +```eigenscript +define diagnose(t) as: + return classify of t + +r is 1.0 +i is 0 +loop while i < 40: + r is r * 2.0 + i is i + 1 +print of (diagnose of (trajectory of r)) +``` +```output +diverging +``` + ## Before / after: porting checklist Transformations you will apply constantly when porting Python code: diff --git a/docs/OBSERVER.md b/docs/OBSERVER.md index afd2b11..e7b0b51 100644 --- a/docs/OBSERVER.md +++ b/docs/OBSERVER.md @@ -126,11 +126,26 @@ in the flat-entropy plateau around 5 — see #294.) the identical windowed logic and thresholds on the value's relative step `Δv/(1+|x|)` (relative, so the bands mean the same across value scales). On the same oracle it answers `moving`/`oscillating` — correctly never `converged`. -Its vocabulary is `oscillating` (sign of `Δv` keeps flipping), `converged` (a -full window of ~zero relative steps), `stable` (small relative steps, no -flips), `moving` (still changing), and `equilibrium` (no numeric trajectory -yet / non-numeric binding). Use `report` to ask *how determined*; use -`report_value` to ask *has the number stopped moving*. +Its vocabulary is `oscillating` (sign of `Δv` keeps flipping), `diverging` +(non-vanishing same-sign steps — see below), `converged` (a full window of +~zero relative steps), `stable` (small relative steps, no flips), `moving` +(still changing), and `equilibrium` (no numeric trajectory yet / non-numeric +binding). Use `report` to ask *how determined*; use `report_value` to ask +*has the number stopped moving*. + +**The raw-step signal (#422).** Relative normalization erases exactly two +classes, so the value channel also keeps the window of *raw* steps `Δv` and +asks one structural question of it: are the steps **non-vanishing** (the +recent half's mean magnitude at least half the older half's, above an +fp-noise floor of `4·ε·(1+|x|)`)? Non-vanishing **same-sign** steps sum +without bound — an additive or polynomial runaway (`x → x + c` seeded large) +whose `Δv/|x|` vanishes below the deadband is `diverging`, not `converged`. +Non-vanishing **alternating** steps are a perpetual oscillation no matter how +small the deadband-relative amplitude — `x → -x` seeded at `4e-4`, or a fixed +absolute swing around a large offset, is `oscillating`, not `stable`. A +*damped* oscillation has decaying steps, fails non-vanishing, and settles to +`converged` as before. These checks run before the relative verdicts; the +relative step remains the primary contract. ## The manifold: two basins and a horizon @@ -261,17 +276,24 @@ Five surfaces: | `expect_converging of [x0, step_fn, max_obs, msg]` | trajectory settles within budget | value | | `expect_monotone of [x0, step_fn, max_obs, msg]` | no sign-flipping steps | value | | `invariant_stable of [x0, step_fn, max_obs, msg]` | value never begins moving | value | +| `expect_regime of [trajectory of x, expected, msg]` | a caller-built history reads as `expected` | value | Three things the observer's own semantics force on the design — and each is the point, not an accident: -- **The trajectory contracts take a *step function*, not a value.** The - observer's history lives in the binding slot it was written to (binding +- **A trajectory crosses a call boundary as a *snapshot*, never as the value.** + The observer's history lives in the binding slot it was written to (binding identity), so a value handed to a function arrives as a fresh, single-sample - slot — its past does not travel. A contract therefore cannot inspect an - already-built value; it must *drive* the recurrence in its own scope. The - cross-scope gap is filed as [#421](https://github.com/InauguralSystems/EigenScript/issues/421), - not papered over. + slot — its past does not travel. Two contract forms follow: the *drive* form + (a seed + one-arg `step_fn`, applied into the contract's own local slot) when + the contract should control the observation budget, and the *snapshot* form + ([#421](https://github.com/InauguralSystems/EigenScript/issues/421)): + `trajectory of x` captures the binding's observer windows into a plain, + inspectable dict that does survive the call, and `classify of t` (or + `classify of [t, "entropy"]` for the entropy channel) classifies it with the + same machinery `report_value` / `report` use. `classify` of anything that is + not a snapshot raises `type_mismatch` — a bare value silently classifying as + "no trajectory" is exactly the hole the snapshot exists to close. - **Convergence is detected on the value channel, not entropy.** A monotonically *exploding* value has falling-then-flat entropy, so `report` @@ -296,16 +318,17 @@ a `max_obs` too small to fill the observer window. Feed them the scalar residual you actually want to constrain. A worked example lives in `examples/stem/contract_solver.eigs`. -What the contracts inherit and cannot fix — the value channel's resolution floor -([#422](https://github.com/InauguralSystems/EigenScript/issues/422)). Because -it classifies the *relative* step `(v-last)/(1+|v|)` against a `1e-3` deadband, -a runaway whose step-to-magnitude ratio vanishes (additive/polynomial growth, -`x -> x + c`) can read `converged`, and an oscillation below the deadband is -invisible to the flip counter. Exponential divergence and supra-deadband -oscillation *are* caught; for the rest, bound absolute magnitude with an -explicit `ensure of [(abs of x) < LIMIT]`. The contracts document this and file -it rather than hiding the instrument's edge behind an ad-hoc heuristic — the -forcing-function model: a gap surfaces upstream instead of being papered over. +The resolution floor the contracts once inherited — +[#422](https://github.com/InauguralSystems/EigenScript/issues/422), where +additive runaway read `converged` and sub-deadband oscillation read `stable` — +is closed by the raw-step signal +([above](#two-signals-entropy-vs-value-report-vs-report_value)): those now +classify `diverging` and `oscillating`, `expect_converging` fails *fast* on a +mid-run `diverging`, and `invariant_stable` throws on it. What remains true: +a drift whose steps *decay* settles by design, and convergence is still not +correctness — pin the destination with `ensure` on the final residual. (The +gap was filed upstream and fixed in the instrument rather than papered over in +`.eigs` — the forcing-function model working as intended.) ## Settled decisions (formerly "Rough edges") diff --git a/docs/SPEC.md b/docs/SPEC.md index 1f9d65b..1806665 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1069,6 +1069,40 @@ its own condition is false. Both kinds keep an absolute iteration cap plain loop can't be cut short by what its body — or a function it calls — happens to assign to the global observer. +**The value channel** (`report_value of x`) classifies the value's own +trajectory rather than its entropy, over a 10-sample window of relative +steps `Δv/(1+|v|)` — labels `oscillating`, `diverging`, `converged`, +`stable`, `moving`, `equilibrium`. Two raw-step rules (#422) run before the +relative verdicts: non-vanishing same-sign steps are `diverging` (an +additive runaway whose relative step vanishes is still unbounded), and +non-vanishing alternating steps are `oscillating` (a perpetual oscillation +below the relative deadband is still an oscillation); decaying steps settle +as usual. + +**Trajectories cross call boundaries as snapshots** (#421). Observer state +is binding-identity — a value passed to a function arrives with no history — +so `trajectory of x` captures the binding's observer windows into a plain +dict, and `classify of t` (value channel; `classify of [t, "entropy"]` for +the entropy channel) classifies it with the same machinery. `classify` of +anything that is not a snapshot raises a `type_mismatch` error: + +```eigenscript +define judge(t) as: + return classify of t + +x is 400000.0 +i is 0 +loop while i < 40: + x is x + 5000.0 + i is i + 1 +print of (report_value of x) +print of (judge of (trajectory of x)) +``` +```output +diverging +diverging +``` + `unobserved:` blocks (and `loop` bodies inside them) skip observer updates entirely — use them for hot numeric loops: diff --git a/lib/contract.eigs b/lib/contract.eigs index 8897982..582fa1e 100644 --- a/lib/contract.eigs +++ b/lib/contract.eigs @@ -17,17 +17,21 @@ # expect_converging of [x0, step_fn, max_obs, msg] # expect_monotone of [x0, step_fn, max_obs, msg] # invariant_stable of [x0, step_fn, max_obs, msg] +# expect_regime of [trajectory of x, expected, msg] # caller-built history # -# CONTRACT MODEL — WHY THE STEP FUNCTION. +# CONTRACT MODEL — TWO FORMS. # The observer's trajectory lives in the *binding slot* it is written to, not -# on the value (Env::obs is binding identity; #262/#294). A value computed in -# the caller's loop therefore carries NO trajectory across a call boundary — -# `report of x` inside a contract that merely received `x` sees a fresh, -# single-sample slot. So a trajectory contract cannot inspect an already-built -# value; it must DRIVE the trajectory in its own scope. Each contract takes the -# seed `x0` and a one-arg `step_fn` (x -> x'), applies it up to `max_obs` times -# into a single local accumulator, and classifies THAT slot. The cross-scope -# gap is filed upstream (EigenScript #421), not worked around here. +# on the value (Env::obs is binding identity; #262/#294), so a bare value +# carries NO trajectory across a call boundary. The two forms handle that +# two ways: +# - DRIVE (expect_converging / expect_monotone / invariant_stable): the +# contract takes a seed `x0` + one-arg `step_fn` and applies it up to +# `max_obs` times into its own local accumulator, classifying THAT slot. +# Use when the contract should control the observation budget. +# - SNAPSHOT (expect_regime): the caller passes `trajectory of x` — the +# #421 snapshot, a plain dict that DOES survive the call — and the +# contract classifies the history the caller already built. Use when +# the trajectory already happened. # # SCALAR ONLY. The value channel (report_value) only carries a numeric # trajectory. A step_fn that returns a list/dict has no value trajectory, so a @@ -37,20 +41,16 @@ # silently certifying nothing. For a vector solver, have step_fn return the # scalar residual/estimate you actually want to constrain. # -# RESOLUTION FLOOR / KNOWN BLIND SPOTS (inherited from the instrument, filed as -# #422 — do not paper over in .eigs). The value channel classifies the -# *relative* step (v-last)/(1+|v|) against dh_zero=1e-3 / dh_small=1e-2 over a -# 10-sample window. Two consequences the contracts cannot see and therefore do -# not claim to catch: -# 1. A runaway whose step/magnitude ratio VANISHES — additive or polynomial -# growth, e.g. x -> x + c once |x| is large — normalizes below the deadband -# and can read "converged". Exponential/geometric divergence IS caught. -# 2. An oscillation whose relative amplitude sits below dh_zero is invisible -# to the flip counter, so expect_monotone passes a tiny (or, after -# normalization, a large-but-relatively-tiny) perpetual oscillation. -# Guard both with an explicit absolute bound: `ensure of [(abs of x) < LIMIT]`. -# And convergence is not correctness even when caught — pair with `ensure` on -# the final residual to pin the actual target, not merely the fact of settling. +# RESOLUTION FLOOR. The value channel classifies the *relative* step +# (v-last)/(1+|v|) against dh_zero=1e-3 / dh_small=1e-2 over a 10-sample +# window, PLUS (since #422 was fixed upstream) a raw-step structure test: +# non-vanishing same-sign steps classify "diverging" (an additive/polynomial +# runaway no longer hides behind normalization) and non-vanishing alternating +# steps classify "oscillating" even below the deadband. A damped (decaying- +# step) oscillation still settles to "converged" — feed the residual if you +# mean "never rings at all". Convergence is not correctness even when caught — +# pair with `ensure` on the final residual to pin the actual target, not +# merely the fact of settling. # ---- _num_guard: refuse a non-scalar trajectory loudly ---- # Internal. report_value is blind to non-numeric values, so an unguarded @@ -99,9 +99,9 @@ define ensure(n) as: # converging solver from an exploding one: a monotonically growing value has # falling-then-flat entropy, so `report` labels a runaway "converged" (measured: # grow x*1.5 for 40 steps -> report=converged). Only the value channel keeps -# them apart — same run reads report_value=moving. This is the #294 lossy-proxy -# lesson made load-bearing. (For a runaway the value channel itself does not see, -# see the #422 blind spots in the header — bound |x| with `ensure`.) +# them apart — same run reads report_value=moving (or, since #422, +# "diverging" outright). Runaways now FAIL FAST: a mid-run "diverging" +# classification throws immediately instead of burning the whole budget. define expect_converging(n) as: a is n[0] step_fn is n[1] @@ -114,8 +114,11 @@ define expect_converging(n) as: a is step_fn of a steps is steps + 1 _num_guard of [a, "expect_converging"] - if (report_value of a) == "converged": + cstatus is report_value of a + if cstatus == "converged": return a + if cstatus == "diverging": + throw of ("expect_converging failed (diverging at obs " + (str of steps) + "): " + msg) throw of ("expect_converging failed (did not converge within " + (str of max_obs) + " obs; last value regime '" + (report_value of a) + "'): " + msg) # ---- expect_monotone of [x0, step_fn, max_obs, msg] ---- @@ -150,8 +153,11 @@ define expect_monotone(n) as: # deadband — across the window. For quantities that must stay constant (a # conserved energy, a fixed point already reached). Throws on the first "moving" # / "oscillating" classification of the value channel. Returns the final value. -# (A drift SLOWER than the relative deadband reads "stable" and is not caught — -# the #422 blind spot; bound it with `ensure of [(abs of x) < LIMIT]`.) +# Also throws on "diverging" (reachable on the value channel since #422: a +# non-vanishing same-sign drift — additive runaway — is caught even when each +# relative step sits below the deadband). A drift that VANISHES (decaying +# steps) still reads "stable"/"converged"; bound the absolute target with +# `ensure of [(abs of x) < LIMIT]` when the destination matters. define invariant_stable(n) as: a is n[0] step_fn is n[1] @@ -169,4 +175,24 @@ define invariant_stable(n) as: throw of ("invariant_stable violated (moving at obs " + (str of steps) + "): " + msg) if vstatus == "oscillating": throw of ("invariant_stable violated (oscillating at obs " + (str of steps) + "): " + msg) + if vstatus == "diverging": + throw of ("invariant_stable violated (diverging at obs " + (str of steps) + "): " + msg) return a + +# ---- expect_regime of [t, expected, msg] ---- +# The #421 snapshot form: classify a trajectory the CALLER already built. +# `t` is `trajectory of x` — the caller snapshots its own binding's observer +# windows into a dict that survives the call — and `expected` is the regime +# the value channel must report ("converged", "oscillating", "diverging", +# "stable", "moving", "equilibrium"). Throws with both labels on mismatch, +# returns the label on success. classify raises type_mismatch on a non- +# snapshot argument, so a caller passing the bare value fails loudly, not +# as a silent "equilibrium". +define expect_regime(n) as: + t is n[0] + expected is n[1] + msg is n[2] + got is classify of t + if got == expected: + return got + throw of ("expect_regime failed (expected '" + expected + "', trajectory reads '" + got + "'): " + msg) diff --git a/src/builtins.c b/src/builtins.c index 100e2c4..91568d5 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -710,6 +710,48 @@ Value* builtin_observe(Value *arg) { return list; } +Value* builtin_classify(Value *arg) { + /* #421 `classify of t` — classify a trajectory SNAPSHOT (the dict + * `trajectory of x` builds), so a callee can classify what its CALLER + * observed: the snapshot crosses the call boundary; the binding slot + * cannot. One arg → the #294 value-channel label (same classifier as + * `report_value of x`, including the #422 raw-step signals). Two args + * `classify of [t, "entropy"]` → the entropy-channel label (same as + * `report of x`). A non-trajectory argument is a loud EK_TYPE error — + * classifying an arbitrary value would silently mean "no trajectory". */ + Value *t = arg; + const char *channel = "value"; + if (arg && arg->type == VAL_LIST && arg->data.list.count >= 1) { + t = arg->data.list.items[0]; + if (arg->data.list.count >= 2) { + Value *ch = arg->data.list.items[1]; + if (!ch || ch->type != VAL_STR || + (strcmp(ch->data.str, "value") != 0 && + strcmp(ch->data.str, "entropy") != 0)) { + rt_error(EK_VALUE, 0, + "classify: channel must be \"value\" or \"entropy\""); + return make_null(); + } + channel = ch->data.str; + } + } + ObserverSlot s; + if (!observer_slot_from_trajectory(&s, t)) { + rt_error(EK_TYPE, 0, "classify: expected a trajectory snapshot " + "(from `trajectory of x`), got %s", + t ? val_type_name(t->type) : "none"); + return make_null(); + } + const char *label = (strcmp(channel, "entropy") == 0) + ? observer_slot_report(&s) + : observer_slot_report_value(&s); + Value *out = make_str(label ? label : "equilibrium"); + free(s.dh_window); + free(s.v_window); + free(s.vr_window); + return out; +} + Value* builtin_type(Value *arg) { if (!arg) return make_str("none"); switch (arg->type) { @@ -3804,7 +3846,7 @@ static const char *SANDBOX_ALLOW[] = { "type", "coalesce", "num_copy", "secure_equals", /* observer READS — touch only the sandbox's own values, never globals * (set_observer_thresholds / record_history are intentionally NOT here) */ - "observe", "report", "get_observer_thresholds", "state_at", + "observe", "report", "get_observer_thresholds", "state_at", "classify", /* tokenizer / parser introspection (pure over strings) */ "tokenize_ids", "tokenize_with_names", "token_name", "scan_ints", "scan_int_tokens", "scan_tokens", "try_parse", @@ -5870,6 +5912,7 @@ void register_builtins(Env *env) { env_set_local_owned(env, "dict_set", make_builtin(builtin_dict_set)); env_set_local_owned(env, "dict_remove", make_builtin(builtin_dict_remove)); env_set_local_owned(env, "observe", make_builtin(builtin_observe)); + env_set_local_owned(env, "classify", make_builtin(builtin_classify)); env_set_local_owned(env, "type", make_builtin(builtin_type)); env_set_local_owned(env, "json_encode", make_builtin(builtin_json_encode)); env_set_local_owned(env, "json_decode", make_builtin(builtin_json_decode)); diff --git a/src/chunk.c b/src/chunk.c index ff0404a..9fc9b77 100644 --- a/src/chunk.c +++ b/src/chunk.c @@ -287,6 +287,8 @@ const char *op_name(uint8_t op) { [OP_PREDICATE_NAME] = "PREDICATE_NAME", [OP_REPORT_VALUE_SLOT] = "REPORT_VALUE_SLOT", [OP_REPORT_VALUE_NAME] = "REPORT_VALUE_NAME", + [OP_TRAJECTORY_SLOT] = "TRAJECTORY_SLOT", + [OP_TRAJECTORY_NAME] = "TRAJECTORY_NAME", [OP_BREAK] = "BREAK", [OP_CONTINUE] = "CONTINUE", [OP_TRY_BEGIN] = "TRY_BEGIN", [OP_TRY_END] = "TRY_END", [OP_OBSERVE_ASSIGN] = "OBSERVE_ASSIGN", @@ -335,6 +337,7 @@ static int op_has_u16(uint8_t op) { case OP_IMPORT: case OP_MATCH: case OP_LINE: case OP_REPORT_VALUE_SLOT: case OP_REPORT_VALUE_NAME: + case OP_TRAJECTORY_SLOT: case OP_TRAJECTORY_NAME: return 1; case OP_INTERROGATE: case OP_PREDICATE: return 1; /* kind:8 but padded to 16 for uniformity */ @@ -407,7 +410,7 @@ static int op_verify_operands(uint8_t op, VerifyRole roles[3]) { case OP_GET_NAME: case OP_SET_NAME: case OP_SET_NAME_LOCAL: case OP_SET_FN_NAME_LOCAL: case OP_DOT_GET: case OP_DOT_SET: case OP_REPORT_NAME: case OP_OBSERVE_VALUE_NAME: case OP_OBSERVE_NAME_POST: - case OP_REPORT_VALUE_NAME: + case OP_REPORT_VALUE_NAME: case OP_TRAJECTORY_NAME: case OP_IMPORT: roles[0] = VR_NAME; return 1; case OP_CLOSURE: diff --git a/src/compiler.c b/src/compiler.c index baf88bf..92dde37 100644 --- a/src/compiler.c +++ b/src/compiler.c @@ -236,6 +236,7 @@ static int op_stack_effect(uint8_t op) { case OP_PREDICATE: case OP_LISTCOMP_BEGIN: case OP_REPORT_SLOT: case OP_REPORT_NAME: case OP_REPORT_VALUE_SLOT: case OP_REPORT_VALUE_NAME: + case OP_TRAJECTORY_SLOT: case OP_TRAJECTORY_NAME: case OP_OBSERVE_VALUE_SLOT: case OP_OBSERVE_VALUE_NAME: case OP_PREDICATE_SLOT: case OP_PREDICATE_NAME: return 1; @@ -2194,6 +2195,19 @@ static void compile_node_inner(Compiler *c, ASTNode *node) { emit_op_u16(c, OP_REPORT_VALUE_NAME, (uint16_t)rnidx, node->line); break; } + if (fn_node && fn_node->type == AST_IDENT && + strcmp(fn_node->data.ident.name, "trajectory") == 0 && + arg_node && arg_node->type == AST_IDENT) { + /* #421 `trajectory of ` — snapshot the binding's observer + * windows into a dict VALUE that survives a call boundary (the + * slot itself is binding-identity). `classify of t` reads it. */ + uint32_t th = arg_node->name_hash ? arg_node->name_hash : env_hash_name(arg_node->data.ident.name); + int tslot = c->enclosing ? resolve_local(c, arg_node->data.ident.name, th) : -1; + if (tslot >= 0) { emit_op_u16(c, OP_TRAJECTORY_SLOT, (uint16_t)tslot, node->line); break; } + int tnidx = add_string_constant(c, arg_node->data.ident.name); + emit_op_u16(c, OP_TRAJECTORY_NAME, (uint16_t)tnidx, node->line); + break; + } /* #262 Phase-3 D: `observe of ` reads the binding's slot * trajectory, parallel to report — the value path no longer carries * observer state on the Value object. Non-ident operands fall diff --git a/src/eigenscript.c b/src/eigenscript.c index 4aa3b6d..983e98f 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -4,6 +4,7 @@ * Compiles with: gcc -O2 -o eigenscript eigenscript.c -lm -lpthread */ +#include /* DBL_EPSILON — the #422 raw-step fp-noise floor */ #include "eigenscript.h" #include "vm.h" /* EigsChunk layout: the cycle collector traverses * fn -> chunk -> env_cache / functions[] edges */ @@ -344,14 +345,21 @@ static double observer_slot_window_get(const ObserverSlot *s, size_t offset_back } /* #294 value-signal channel: same ring buffer as the entropy window, but the - * pushed quantity is the value's own relative step Δv/(1+|v|). */ -static void observer_slot_v_push(ObserverSlot *s, double rel_delta) { + * pushed quantity is the value's own relative step Δv/(1+|v|). #422 adds a + * parallel RAW-step ring (Δv un-normalized, same head/count): the relative + * step is the deliberate primary contract, but normalization erases exactly + * two classes — additive/polynomial runaway (Δv/|v| → 0 while Δv doesn't) + * and oscillation below the deadband — and both are recoverable from the + * raw step's sign/decay structure alone. */ +static void observer_slot_v_push(ObserverSlot *s, double rel_delta, double raw_delta) { if (!s->v_window) { s->v_window = xcalloc(OBSERVER_WINDOW_N, sizeof(double)); + s->vr_window = xcalloc(OBSERVER_WINDOW_N, sizeof(double)); s->v_window_head = 0; s->v_window_count = 0; } s->v_window[s->v_window_head] = rel_delta; + if (s->vr_window) s->vr_window[s->v_window_head] = raw_delta; s->v_window_head = (uint8_t)((s->v_window_head + 1) % OBSERVER_WINDOW_N); if (s->v_window_count < OBSERVER_WINDOW_N) s->v_window_count++; } @@ -363,14 +371,66 @@ static double observer_slot_v_get(const ObserverSlot *s, size_t offset_back) { return s->v_window[idx]; } +static double observer_slot_vr_get(const ObserverSlot *s, size_t offset_back) { + if (!s->vr_window || offset_back >= s->v_window_count) return 0.0; + int idx = (int)s->v_window_head - 1 - (int)offset_back; + while (idx < 0) idx += OBSERVER_WINDOW_N; + return s->vr_window[idx]; +} + +/* #422 raw-step structure tests. Both require a FULL window and steps above + * the fp-noise floor (a settled double wobbles by ULPs whose signs are + * meaningless — 4·ε·(1+|v|) scales the floor to the value's magnitude), and + * both hinge on the same question the relative channel cannot ask: are the + * raw steps NON-VANISHING (recent-half mean magnitude ≥ half the older-half + * mean)? Non-vanishing same-sign steps sum without bound → diverging, no + * matter how large |v| already is; non-vanishing sign-alternating steps are + * a perpetual oscillation, no matter how small the deadband-relative step. + * A damped (decaying-step) trajectory fails non-vanishing and falls through + * to the relative verdicts — converging approaches stay 'converged'. */ +static int observer_slot_raw_nonvanishing(const ObserverSlot *s) { + if (s->v_window_count < OBSERVER_WINDOW_N) return 0; + double floor_eps = 4.0 * DBL_EPSILON * (1.0 + fabs(s->last_value)); + size_t half = OBSERVER_WINDOW_N / 2; + double recent = 0.0, older = 0.0; + for (size_t i = 0; i < half; i++) recent += fabs(observer_slot_vr_get(s, i)); + for (size_t i = half; i < OBSERVER_WINDOW_N; i++) older += fabs(observer_slot_vr_get(s, i)); + recent /= (double)half; + older /= (double)(OBSERVER_WINDOW_N - half); + if (older <= floor_eps || recent <= floor_eps) return 0; + return recent >= 0.5 * older; +} + +static int observer_slot_raw_diverging(const ObserverSlot *s) { + if (!observer_slot_raw_nonvanishing(s)) return 0; + double first = observer_slot_vr_get(s, 0); + for (size_t i = 1; i < OBSERVER_WINDOW_N; i++) + if (observer_slot_vr_get(s, i) * first <= 0.0) return 0; + return 1; +} + +static int observer_slot_raw_oscillating(const ObserverSlot *s) { + if (!observer_slot_raw_nonvanishing(s)) return 0; + const int FLIPS = (OBSERVER_WINDOW_N + 2) / 3; + double floor_eps = 4.0 * DBL_EPSILON * (1.0 + fabs(s->last_value)); + int flips = 0; + for (size_t i = 0; i + 1 < OBSERVER_WINDOW_N; i++) { + double a = observer_slot_vr_get(s, i); + double b = observer_slot_vr_get(s, i + 1); + if (a * b < 0.0 && fabs(a) > floor_eps && fabs(b) > floor_eps) flips++; + } + return flips >= FLIPS; +} + /* Fold one observed numeric value into the slot's value channel. The stored * step is RELATIVE (Δv/(1+|v|)) so the thresholds carry the same meaning across * value scales: a ±0.6 swing around 5 reads "moving" (~12% steps), the same * swing around 1e6 is effectively settled. First value seeds last_value only. */ void observer_slot_record_value(ObserverSlot *s, double v) { if (s->v_used) { - double rel = (v - s->last_value) / (1.0 + fabs(v)); - observer_slot_v_push(s, rel); + double raw = v - s->last_value; + double rel = raw / (1.0 + fabs(v)); + observer_slot_v_push(s, rel, raw); } s->last_value = v; s->v_used = 1; @@ -424,6 +484,7 @@ void observer_slot_reset(Env *e) { for (int i = 0; i < e->obs_cap; i++) { free(e->obs[i].dh_window); free(e->obs[i].v_window); /* #294 value-signal window */ + free(e->obs[i].vr_window); /* #422 raw-step window */ } free(e->obs); e->obs = NULL; @@ -542,6 +603,14 @@ const char *observer_slot_report_value(const ObserverSlot *s) { } if (flips >= FLIPS) return "oscillating"; } + /* #422: the raw-step structure tests run BEFORE the relative verdicts — + * relative normalization is exactly what hides these two classes. A + * sub-deadband perpetual oscillation (x → -x seeded tiny, or a fixed + * absolute swing around a large offset) is 'oscillating'; non-vanishing + * same-sign steps (a linear/polynomial runaway whose Δv/|v| → 0) are + * 'diverging' — the value channel's first use of that label. */ + if (observer_slot_raw_oscillating(s)) return "oscillating"; + if (observer_slot_raw_diverging(s)) return "diverging"; int all_zero = 1, all_small = 1; for (size_t i = 0; i < cnt; i++) { double w = fabs(observer_slot_v_get(s, i)); @@ -553,6 +622,108 @@ const char *observer_slot_report_value(const ObserverSlot *s) { return "moving"; } +/* ---- #421: trajectory snapshots across call boundaries. ---------------- + * The observer's state is binding-identity (Env::obs[slot]) — passing x + * into a function gives the callee a fresh slot with no history, so a + * contract function cannot classify what its caller built up. `trajectory + * of x` snapshots the slot's windows into a plain dict VALUE, which does + * survive the call; `classify of t` rebuilds a slot from the dict and runs + * the same classifiers. The dict is deliberately transparent (inspectable, + * JSON-able, tape-visible as ) rather than an opaque handle. */ + +Value *observer_slot_trajectory(const ObserverSlot *s) { + Value *out = make_dict(10); + if (!out) return NULL; + dict_set_owned(out, "kind", make_str("trajectory")); + int vcnt = (s && s->v_window) ? s->v_window_count : 0; + int dcnt = (s && s->dh_window) ? s->dh_window_count : 0; + Value *rel = make_list_heap(vcnt > 0 ? vcnt : 1); + Value *raw = make_list_heap(vcnt > 0 ? vcnt : 1); + Value *dh = make_list_heap(dcnt > 0 ? dcnt : 1); + /* windows read newest-first (offset 0 = most recent); emit oldest-first + * so the lists read chronologically, like a history. */ + for (int i = vcnt - 1; i >= 0; i--) { + list_append_owned(rel, make_num(observer_slot_v_get(s, (size_t)i))); + list_append_owned(raw, make_num(observer_slot_vr_get(s, (size_t)i))); + } + for (int i = dcnt - 1; i >= 0; i--) + list_append_owned(dh, make_num(observer_slot_window_get(s, (size_t)i))); + dict_set_owned(out, "rel", rel); + dict_set_owned(out, "raw", raw); + dict_set_owned(out, "dh", dh); + dict_set_owned(out, "entropy", make_num(s ? s->entropy : 0.0)); + dict_set_owned(out, "dH", make_num(s ? s->dH : 0.0)); + dict_set_owned(out, "last_entropy", make_num(s ? s->last_entropy : 0.0)); + dict_set_owned(out, "last_value", make_num((s && s->v_used) ? s->last_value : 0.0)); + dict_set_owned(out, "observed", make_num(s ? (s->used != 0) : 0)); + dict_set_owned(out, "numeric", make_num(s ? (s->v_used != 0) : 0)); + return out; +} + +/* Rebuild a classifiable slot from a snapshot dict. Returns 1 and fills + * *out (windows malloc'd — caller frees dh_window/v_window/vr_window) when + * the dict is a well-formed trajectory; 0 otherwise. Wrong shapes are the + * CALLER's error to raise loudly — no silent tolerance here. */ +int observer_slot_from_trajectory(ObserverSlot *out, Value *dict) { + memset(out, 0, sizeof *out); + if (!dict || dict->type != VAL_DICT) return 0; + Value *kind = dict_get(dict, "kind"); + if (!kind || kind->type != VAL_STR || strcmp(kind->data.str, "trajectory") != 0) + return 0; + Value *rel = dict_get(dict, "rel"); + Value *raw = dict_get(dict, "raw"); + Value *dh = dict_get(dict, "dh"); + if (!rel || rel->type != VAL_LIST || !raw || raw->type != VAL_LIST || + !dh || dh->type != VAL_LIST) + return 0; + /* Only the most recent OBSERVER_WINDOW_N entries matter — a hand-built + * longer list classifies identically to its tail, same ring semantics. */ + int vcnt = rel->data.list.count; + if (raw->data.list.count < vcnt) vcnt = raw->data.list.count; + int vstart = vcnt > OBSERVER_WINDOW_N ? vcnt - OBSERVER_WINDOW_N : 0; + out->v_window = xcalloc(OBSERVER_WINDOW_N, sizeof(double)); + out->vr_window = xcalloc(OBSERVER_WINDOW_N, sizeof(double)); + for (int i = vstart; i < vcnt; i++) { + Value *a = rel->data.list.items[i], *b = raw->data.list.items[i]; + if (!a || a->type != VAL_NUM || !b || b->type != VAL_NUM) { + free(out->v_window); free(out->vr_window); + memset(out, 0, sizeof *out); + return 0; + } + out->v_window[out->v_window_count] = a->data.num; + out->vr_window[out->v_window_count] = b->data.num; + out->v_window_count++; + } + out->v_window_head = (uint8_t)(out->v_window_count % OBSERVER_WINDOW_N); + int dcnt = dh->data.list.count; + int dstart = dcnt > OBSERVER_WINDOW_N ? dcnt - OBSERVER_WINDOW_N : 0; + out->dh_window = xcalloc(OBSERVER_WINDOW_N, sizeof(double)); + for (int i = dstart; i < dcnt; i++) { + Value *a = dh->data.list.items[i]; + if (!a || a->type != VAL_NUM) { + free(out->v_window); free(out->vr_window); free(out->dh_window); + memset(out, 0, sizeof *out); + return 0; + } + out->dh_window[out->dh_window_count++] = a->data.num; + } + out->dh_window_head = (uint8_t)(out->dh_window_count % OBSERVER_WINDOW_N); + Value *v; + if ((v = dict_get(dict, "entropy")) && v->type == VAL_NUM) out->entropy = v->data.num; + if ((v = dict_get(dict, "dH")) && v->type == VAL_NUM) out->dH = v->data.num; + if ((v = dict_get(dict, "last_entropy")) && v->type == VAL_NUM) out->last_entropy = v->data.num; + if ((v = dict_get(dict, "last_value")) && v->type == VAL_NUM) out->last_value = v->data.num; + v = dict_get(dict, "observed"); + out->used = (v && v->type == VAL_NUM) ? (v->data.num != 0.0) : 1; + v = dict_get(dict, "numeric"); + out->v_used = (v && v->type == VAL_NUM) ? (v->data.num != 0.0) + : (out->v_window_count > 0); + /* A full slot re-fed through the classifiers needs obs_age > 0 so the + * partial-window fallbacks behave like a live slot's. */ + out->obs_age = out->dh_window_count + out->v_window_count; + return 1; +} + /* g_last_observer, g_builtin_call_env, g_unobserved_depth are EigsThread * fields; g_obs_dh_zero/small/h_low are EigsState fields. See the macros * in eigenscript.h that bridge the source identifiers to those fields. */ diff --git a/src/eigenscript.h b/src/eigenscript.h index 9929bf5..4af9d52 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -252,6 +252,10 @@ typedef struct ObserverSlot { * as the entropy channel; only the observed signal differs. */ double last_value; /* last observed numeric value (Δv source) */ double *v_window; /* lazily allocated, OBSERVER_WINDOW_N relative-deltas */ + double *vr_window; /* #422 raw deltas (Δv un-normalized), same head/count: + * the non-vanishing-step signal that catches additive + * runaway and sub-deadband oscillation, both of which + * relative normalization erases */ uint8_t v_window_head, v_window_count; uint8_t v_used; /* 1 once a numeric value has been recorded */ } ObserverSlot; @@ -348,6 +352,13 @@ const char *observer_slot_report_value(const struct ObserverSlot *s); * per-binding trajectories from tape A records through the SAME classifier * the language uses — a reimplementation there could silently drift. */ void observer_slot_record_value(struct ObserverSlot *s, double v); +/* #421 trajectory snapshots: capture a slot's observer windows into a plain + * dict Value that survives a call boundary (`trajectory of x`), and rebuild + * a classifiable slot from such a dict (`classify of t`). from_trajectory + * returns 1 + malloc'd windows in *out (caller frees dh/v/vr_window), 0 on + * a non-trajectory dict — the caller raises, never tolerates silently. */ +struct Value *observer_slot_trajectory(const struct ObserverSlot *s); +int observer_slot_from_trajectory(struct ObserverSlot *out, struct Value *dict); /* Returns the dH at offset back from most recent (0 = most recent). * Caller must ensure offset < observer_window_size(v). */ diff --git a/src/lint.c b/src/lint.c index 3fb49fd..3562ba2 100644 --- a/src/lint.c +++ b/src/lint.c @@ -139,6 +139,8 @@ static int is_builtin_name(const char *name) { #undef X if (!env_get(e, "report_value")) env_set_local_owned(e, "report_value", make_null()); + if (!env_get(e, "trajectory")) /* #421 special form, like report_value */ + env_set_local_owned(e, "trajectory", make_null()); g_builtin_name_env = e; } return env_get(g_builtin_name_env, name) != NULL; @@ -1836,9 +1838,11 @@ static void check_undefined_names(ASTNode *ast, const char *path, EIGS_MODEL_BUILTINS(X) #undef X /* Names the compiler resolves itself, so no registrar ever binds them: - * `report_value of x` is a special form (#294), and the observed-loop - * machinery injects __loop_exit__ / __loop_iterations__ bindings. */ + * `report_value of x` is a special form (#294), `trajectory of x` is one + * too (#421), and the observed-loop machinery injects __loop_exit__ / + * __loop_iterations__ bindings. */ e003_bind_name(&e, "report_value"); + e003_bind_name(&e, "trajectory"); e003_bind_name(&e, "__loop_exit__"); e003_bind_name(&e, "__loop_iterations__"); /* load_file resolution anchors at the linted file's directory — diff --git a/src/step.c b/src/step.c index 0841b8c..b2fa96d 100644 --- a/src/step.c +++ b/src/step.c @@ -265,6 +265,7 @@ static const char *classify_at(const NameHist *h, int pos, int *out_numeric) { } const char *label = fed ? observer_slot_report_value(&s) : NULL; free(s.v_window); + free(s.vr_window); free(s.dh_window); if (out_numeric) *out_numeric = fed; return label; @@ -346,6 +347,7 @@ static void show_trajectory(const Tape *t, int pos, const char *name) { printf("\n"); } free(s.v_window); + free(s.vr_window); free(s.dh_window); } diff --git a/src/vm.c b/src/vm.c index c755fe0..cee45e6 100644 --- a/src/vm.c +++ b/src/vm.c @@ -2204,6 +2204,8 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { [OP_REPORT_NAME] = &&lbl_REPORT_NAME, [OP_REPORT_VALUE_SLOT] = &&lbl_REPORT_VALUE_SLOT, [OP_REPORT_VALUE_NAME] = &&lbl_REPORT_VALUE_NAME, + [OP_TRAJECTORY_SLOT] = &&lbl_TRAJECTORY_SLOT, + [OP_TRAJECTORY_NAME] = &&lbl_TRAJECTORY_NAME, [OP_OBSERVE_VALUE_SLOT] = &&lbl_OBSERVE_VALUE_SLOT, [OP_OBSERVE_VALUE_NAME] = &&lbl_OBSERVE_VALUE_NAME, [OP_OBSERVE_NAME_POST] = &&lbl_OBSERVE_NAME_POST, @@ -4172,6 +4174,37 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { DISPATCH(); } + CASE(TRAJECTORY_SLOT): { + /* #421 `trajectory of ` — snapshot the local slot's observer + * windows into a dict value that survives a call boundary. An + * unobserved/out-of-range slot snapshots as an empty trajectory + * (classifies "equilibrium", mirroring report_value's fallback). */ + uint16_t slot = read_u16(ip); ip += 2; + Env *e = frame->fn_env; + const ObserverSlot *s = (e && (int)slot < e->obs_cap) ? &e->obs[slot] : NULL; + vm_push(observer_slot_trajectory(s)); + DISPATCH(); + } + + CASE(TRAJECTORY_NAME): { + /* #421 trajectory of a non-local name — resolve (env,slot), snapshot. + * Undefined name raises like GET_NAME. */ + uint16_t name_idx = read_u16(ip); ip += 2; + const char *name = chunk->const_interns[name_idx]; + uint32_t h = chunk->const_hashes ? chunk->const_hashes[name_idx] : 0; + if (h == 0) { h = env_hash_name(name); if (chunk->const_hashes) chunk->const_hashes[name_idx] = h; } + int oidx = -1, odepth = 0; + Env *oe = env_resolve_chain(frame->env, name, h, &oidx, &odepth); + if (!oe) { + rt_error(EK_UNDEFINED_NAME, current_line, "undefined variable '%s'", name); + vm_push_slot(slot_null()); + DISPATCH(); + } + const ObserverSlot *s = (oidx >= 0 && oidx < oe->obs_cap) ? &oe->obs[oidx] : NULL; + vm_push(observer_slot_trajectory(s)); + DISPATCH(); + } + CASE(OBSERVE_VALUE_SLOT): { /* #262 Phase-3 D: `observe of ` → [status,entropy,dH,prev_dH] * from the local's slot trajectory; no-observation tuple if unpopulated. */ diff --git a/src/vm.h b/src/vm.h index 38cb1ea..0719da3 100644 --- a/src/vm.h +++ b/src/vm.h @@ -172,6 +172,12 @@ typedef enum { * VALUE trajectory (#294), not its entropy. Appended, not mid-list. */ OP_REPORT_VALUE_NAME, /* [name_idx:16] `report_value of ` — resolve the binding's * (env,slot) and classify its value trajectory. */ + OP_TRAJECTORY_SLOT, /* [slot:16] `trajectory of ` (#421) — snapshot the local + * slot's observer windows into a dict VALUE that survives a call + * boundary (the slot itself is binding-identity and cannot). + * Appended, not mid-list. */ + OP_TRAJECTORY_NAME, /* [name_idx:16] `trajectory of ` — resolve the binding's + * (env,slot) and snapshot it. */ OP_COUNT /* sentinel — number of opcodes */ } OpCode; diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 5cf455d..bab44b1 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1813,6 +1813,11 @@ check_eigs_suite "contract: require/ensure + expect_converging/monotone/invarian "test_contract.eigs" "CONTRACT_ALL_PASS" 1 echo "" +echo "[50j2] Observer pair #421/#422: raw-step signals + trajectory snapshots (20 checks)" +check_eigs_suite "value-channel diverging/sub-deadband oscillation, trajectory-of/classify across call boundaries, expect_regime" \ + "test_trajectory.eigs" "TRAJECTORY_ALL_PASS" 1 +echo "" + echo "[50k] UTF-8 codepoints (16 checks)" check_eigs_suite "utf8: decode/len/at/char_at over byte strings + structural validation (published vectors)" \ "test_utf8.eigs" "UTF8_ALL_PASS" 1 diff --git a/tests/test_lint.sh b/tests/test_lint.sh index 65116bf..04006d8 100644 --- a/tests/test_lint.sh +++ b/tests/test_lint.sh @@ -203,7 +203,7 @@ define transform(items, factor) as: total is total + (it * factor) return total -define classify(n) as: +define categorize(n) as: label is "?" if n > 10: label is "big" @@ -227,7 +227,7 @@ nums is [1, 2, 3, 4, 5] squares is [v * v for v in nums] total is transform of [nums, config.scale] piped is total |> doubler -tag is classify of 7 +tag is categorize of 7 dq is safe_div of [10, 2] first_name is config.names[0] deep is config.nested.k diff --git a/tests/test_trajectory.eigs b/tests/test_trajectory.eigs new file mode 100644 index 0000000..177108a --- /dev/null +++ b/tests/test_trajectory.eigs @@ -0,0 +1,149 @@ +# #421 + #422: the observer-pair fixes. +# #422 — value-channel raw-step signals: additive/polynomial runaway is +# 'diverging' (was hidden by relative normalization), sub-deadband +# perpetual oscillation is 'oscillating' (was invisible to the flip +# counter); damped/settled trajectories keep their old labels. +# #421 — `trajectory of x` snapshots a binding's observer windows into a +# dict that survives a call boundary; `classify of t` (and lib/contract's +# expect_regime) classifies it with the SAME classifiers. +# All-pass prints TRAJECTORY_ALL_PASS. +load_file of "lib/contract.eigs" + +fails is 0 +define check(tag, got, want) as: + if got == want: + print of f" PASS: {tag}" + if got != want: + print of f" FAIL: {tag} (got {str of got}, want {str of want})" + fails is fails + 1 + +print of "=== #422: raw-step signals ===" + +# 1. linear runaway (issue repro 1): relative step vanishes, raw step doesn't +a is 400000.0 +s is 0 +loop while s < 40: + a is a + 5000.0 + s is s + 1 +check of ["linear runaway is diverging", report_value of a, "diverging"] + +# 2. sub-deadband perpetual oscillation (issue repro 2) +b is 0.0004 +s2 is 0 +loop while s2 < 40: + b is 0 - b + s2 is s2 + 1 +check of ["sub-deadband oscillation is oscillating", report_value of b, "oscillating"] + +# 3. large-offset absolute swing (relative ~5e-4, below deadband) +c is 1000000.0 +s3 is 0 +loop while s3 < 40: + if c == 1000000.0: + c is 1000500.0 + else: + c is 1000000.0 + s3 is s3 + 1 +check of ["large-offset swing is oscillating", report_value of c, "oscillating"] + +# 4. geometric runaway: fixed relative step, non-vanishing raw +g is 1.0 +s4 is 0 +loop while s4 < 40: + g is g * 2.0 + s4 is s4 + 1 +check of ["geometric runaway is diverging", report_value of g, "diverging"] + +# 5. damped oscillation decays -> settles, must NOT read oscillating +d is 1.0 +s5 is 0 +loop while s5 < 60: + d is (0 - d) * 0.7 + s5 is s5 + 1 +check of ["damped oscillation settles", report_value of d, "converged"] + +# 6. settled constant: zero steps sit below the fp-noise floor +k is 5.0 +s6 is 0 +loop while s6 < 40: + k is k + 0.0 + s6 is s6 + 1 +check of ["settled constant is converged", report_value of k, "converged"] + +# 7. plain halving still converges (no regression) +h is 64.0 +s7 is 0 +loop while s7 < 40: + h is h / 2.0 + s7 is s7 + 1 +check of ["halving still converges", report_value of h, "converged"] + +print of "=== #421: trajectory snapshots ===" + +# 8. the issue repro: a callee classifies what the CALLER built +define classify_elsewhere(t) as: + return classify of t +check of ["snapshot crosses the call boundary", classify_elsewhere of (trajectory of h), "converged"] +check of ["snapshot agrees with report_value", classify_elsewhere of (trajectory of a), "diverging"] + +# 9. entropy channel from the same snapshot +ch is classify of [(trajectory of h), "entropy"] +check of ["entropy channel reachable from snapshot", ch, "converged"] + +# 10. the snapshot is a transparent dict +t is trajectory of h +check of ["snapshot kind field", t["kind"], "trajectory"] +check of ["snapshot rel window is full", len of (t["rel"]), 10] +check of ["snapshot raw window is full", len of (t["raw"]), 10] + +# 11. unobserved parameter snapshots as an empty trajectory +define snap_param(v) as: + return classify of (trajectory of v) +check of ["fresh param slot classifies equilibrium", snap_param of 3.0, "equilibrium"] + +# 12. classify of a non-snapshot raises type_mismatch (never a silent label) +caught is 0 +try: + x is classify of 42 +catch e: + if e["kind"] == "type_mismatch": + caught is 1 +check of ["classify of a bare value raises type_mismatch", caught, 1] + +# 13. classify of a bogus channel raises value_error +caught2 is 0 +try: + _x2 is classify of [(trajectory of h), "bogus"] +catch e2: + if e2["kind"] == "value": + caught2 is 1 +check of ["classify of a bogus channel raises", caught2, 1] + +print of "=== expect_regime (lib/contract.eigs) ===" + +# 14. snapshot-form contract: pass and mismatch paths +got is expect_regime of [(trajectory of h), "converged", "halving converges"] +check of ["expect_regime accepts the built trajectory", got, "converged"] +caught3 is 0 +try: + expect_regime of [(trajectory of a), "converged", "runaway is not converged"] +catch e3: + if e3 != null: + caught3 is 1 +check of ["expect_regime throws on regime mismatch", caught3, 1] + +# 15. expect_converging fails FAST on a runaway (diverging mid-run) +define grow_fast(x) as: + return x * 2.0 +caught4 is 0 +try: + expect_converging of [1.0, grow_fast, 40, "runaway"] +catch e4: + if e4 != null: + caught4 is 1 +check of ["expect_converging throws on a runaway", caught4, 1] + +if fails == 0: + print of "TRAJECTORY_ALL_PASS" +if fails != 0: + print of f"TRAJECTORY_FAILED ({str of fails})"