Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tape> [source.eigs]` opens a recorded trace tape
(from `--trace`, `EIGS_TRACE`, or a `--test --trace-on-fail` failure) in
Expand Down
21 changes: 13 additions & 8 deletions docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
21 changes: 21 additions & 0 deletions docs/COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
65 changes: 44 additions & 21 deletions docs/OBSERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand All @@ -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")

Expand Down
34 changes: 34 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading
Loading