diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3009e9a..18b38a2 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -35,6 +35,7 @@ RUN apt-get update \ libsdl2-2.0-0 \ python3 \ python3-pil \ + time \ x11-apps \ xauth \ xdotool \ @@ -43,7 +44,7 @@ RUN apt-get update \ # Full EigenScript source at the pinned tag; build the gfx variant (the # binary hard-links to src/eigenscript, which finds lib/ next to it). -ARG EIGS_REF=v0.34.0 +ARG EIGS_REF=v0.35.0 RUN git clone --depth 1 --branch "${EIGS_REF}" \ https://github.com/InauguralSystems/EigenScript.git /opt/eigenscript \ && make -C /opt/eigenscript gfx CC=gcc diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f2e162..be6f70c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,8 +25,11 @@ jobs: with: runCmd: | set -e + EIGENSCRIPT=eigenscript bash tests/test_lint.sh EIGENSCRIPT=eigenscript bash tests/test_smoke.sh EIGENSCRIPT=eigenscript bash tests/test_lab.sh EIGENSCRIPT=eigenscript bash tests/test_orbit_hist.sh + EIGENSCRIPT=eigenscript bash tests/test_bifurcation.sh EIGENSCRIPT=eigenscript bash tests/test_orbit_oracle.sh EIGENSCRIPT=eigenscript bash tests/test_orbit_mouse.sh + EIGENSCRIPT=eigenscript bash tests/test_bif_mem.sh diff --git a/FINDINGS.md b/FINDINGS.md index 915b110..fd8bd51 100644 --- a/FINDINGS.md +++ b/FINDINGS.md @@ -172,6 +172,15 @@ now forcing the chart/plot widget (with eigen-sheet#26 and EigenMiniSat#76): needed are xy series in data coordinates, axis placement at data zero, markers, and pan/zoom interaction. For `eigenscript-ui-toolkit-engineer`. +**RESOLVED upstream — EigenScript#819 (PR #824), shipped in v0.35.0.** `chart` +was generalised in place into a data-coordinate x-y plot: per-series `x` lists, +`fixed_aspect`, labelled `vline`/`hline`/`point` markers, widget-owned +drag-pan/wheel-zoom, incremental `add_xy`/`chart_trim`, and clipping by +construction. dynamics is its first production consumer — the bifurcation view +(slice 3) is built on it with no consumer-side workarounds. The phase portrait +keeps its `canvas` on purpose: it needs a custom trail renderer, which is what +the canvas escape hatch is for. Residual friction is F-DYN-14. + ## F-DYN-10 — lib/ui gap: viz widget surfaces hardcode their colours instead of reading theme keys `_render_chart` / `_render_bar_chart` / `_render_waveform_view` in @@ -184,6 +193,14 @@ xy chart existed today, it could not have taken the orbit-lab palette. Wants theme keys (e.g. `plot_bg`, `plot_grid`, `plot_border`) read at render time. For `eigenscript-ui-toolkit-engineer`. +**RESOLVED upstream — EigenScript#820 (PR #824), shipped in v0.35.0.** The viz +surfaces now read `plot_bg` / `plot_grid` / `plot_axis` / `plot_border` / +`plot_series` (and `wave_*`) off the active theme, falling back to the built-in +defaults for a theme dict that predates them. `orbit_theme.eigs` maps all five +onto the same palette entries the phase-portrait canvas reads, so chrome and +data surface are themed from the one module — verified in real pixels by the +committed screenshot. + ## F-DYN-11 — lib/ui gap: the wheel event carries no cursor position `ev.x` / `ev.y` on a `wheel` event are the scroll **deltas** (#569), and the @@ -222,6 +239,63 @@ still cannot corrupt the rest of the tree), or say so in the `canvas` doc comment next to the `on_mouse` / `on_wheel` notes. For `eigenscript-ui-toolkit-engineer`. +## F-DYN-13 — the temporal assignment history is unbounded and arms on dead code → upstream EigenScript#827 + +**This one froze the box.** Building the bifurcation view (dynamics#20 rung 1, +slice 3), the new window grew **3.9 MB per rendered frame** — 258 MB at frame +30, 529 MB at frame 100, 859 MB at frame 300, then SIGSEGV against a +`ulimit -v` cap. Every correctness oracle was green throughout. + +Bisected to `load_file of "physics.eigs"`, and inside it to a single token: +`frame_velocity`'s `prev of x`, in a demo helper the window **never calls**. +The compiler arms `g_trace_hist` from a whole-program scan, and the history +table it turns on (`src/trace.c`, `HistoryEntry`) is append-only with **no cap** +and holds a **reference to every assigned value**. `lib/ui`'s chart allocates a +coordinate pair per plotted point per frame (4,000 of them here), so every one +of those was pinned forever. + +Minimal repro, no gfx and no lib/ui — a `prev of` inside a function that is +never called, versus the same program without it: + +``` +prev_off N=100000 PEAK_KB 3456 prev_on N=100000 PEAK_KB 37248 +prev_off N=400000 PEAK_KB 3328 prev_on N=400000 PEAK_KB 140416 +``` + +Flat versus linear in the iteration count. `record_history of 0` restores flat. + +Note this is the *other half* of F-DYN-1: that finding recorded the silent +non-numeric coercion of the same builtin, and its "non-findings" note that +`prev` works "provided `record_history` is never called" is now qualified — +`prev` works, and arms an unbounded table for the whole program while doing it. + +**In this repo:** `orbit.eigs`'s `_run` calls the documented `record_history of 0` +for the lifetime of a window session (a lab window asks no temporal questions) +and restores the previous setting on exit. `tests/test_bif_mem.sh` carries a +planted fault that removes that call and must go red, so when #827 lands the +opt-out can come out and the gate still holds. For +`eigenscript-runtime-engineer` / `eigenscript-trace-tape-engineer`. + +## F-DYN-14 — lib/ui: `chart` allocates a list per plotted point per frame → upstream EigenScript#828 + +Surfaced as `chart`'s first production consumer. `_render_chart`'s per-sample +loop calls `_chart_map`, which returns a fresh 2-element list, for every sample +of every series on every frame — even when the data has not changed. Measured +~24.5 µs/point/frame (4,000 points ≈ 98 ms/frame, 8,800 ≈ 190 ms), so a static +diagram renders at ~10 fps and essentially all of it is allocation, not drawing. + +RSS is flat, so this is not a leak in the widget (verified separately at 4,000 +points over 30/100/300 frames: 130.9 / 131.1 / 131.2 MB, both styles, with and +without markers and fixed bounds) — it is throughput and allocation pressure, +and it is what turned F-DYN-13 from a slow drip into an OOM. + +Also filed there: `chart_marker of ["vline", x, y, label, color]` makes the +caller invent a `y` that a vertical line ignores (and vice versa for `hline`), +which is silently accepted if wrong. + +**Not** a blocker: F-DYN-9's ask (an x-y plot widget) is fully answered — the +API needed no changes to carry this view. For `eigenscript-ui-toolkit-engineer`. + --- ## Non-findings (verified working — recorded to avoid re-investigating) diff --git a/README.md b/README.md index d61ab91..0bdc7fb 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,10 @@ forcing functions), each exercising a different observer sub-surface: actually respond is a separate question, answered by `tests/test_orbit_mouse.sh` — real xdotool input into the real window, verified by decoding the rendered pixels. + The window has a **second view** — press `b` or the toggle at the foot of + the control column — showing the bifurcation diagram of the logistic map + (below). Same window, same theme, same app loop; the phase portrait is what + it opens on, so nothing above changed. Run: `eigenscript orbit_main.eigs` (needs a gfx-capable build: `make gfx` in the EigenScript repo). Palette lives in `orbit_theme.eigs` (DeslanStudio in-place theme-apply pattern). @@ -97,21 +101,64 @@ is untouched): ![orbit lab zoomed](docs/orbit-lab-zoom.png) +- **logistic map / bifurcation view** (`logistic.eigs` + the orbit lab's second + view) — **built** (fleet UI ladder rung 1, slice 3). The damped oscillator is + **linear**: it has no bifurcation to draw, and neither does anything else in + this repo, so rather than fake a diagram the lab gained the standard system + that does — `x -> r x (1 - x)`. It is here because its cascade is *known*, + which makes the picture checkable: `tests/test_bifurcation.sh` compares the + plotted period-1 and period-2 branches against the closed forms `1 - 1/r` and + `(r+1 ± sqrt((r-3)(r+1)))/(2r)` (they agree to **8e-16**), locates the + doublings by bisection and puts them on `r = 3`, `r = 1 + sqrt(6)` and + `r = 3.5440903` to within 3e-4, recovers the first Feigenbaum ratio as + **4.7502** (true 4.7514), and pins the period-3 window opening exactly at + `r = 1 + sqrt(8)`. The four vertical markers on the plot are drawn where that + algebra says they are — the pitchfork lands on the line, it is not fitted to + it. This is the first production consumer of lib/ui's x-y `chart` widget + (EigenScript#819), which closed FINDINGS F-DYN-9/F-DYN-10. + +![bifurcation view](docs/bifurcation.png) + + The sweep is 200 parameter columns x 20 asymptotic samples = 4,000 points, + after discarding 600 transient iterations — deliberately modest, because the + widget costs ~24 µs per point per frame and more columns buy resolution, not + physics. + + **It ships with a memory gate.** `tests/test_bif_mem.sh` runs the real window + at 30 / 100 / 300 frames under a `ulimit -v` cap and fails on a ceiling + breach *or* on RSS that grows with the frame count — in the steady-state + shape and in a toggle-the-view-every-frame shape, which pins that N view + switches cost exactly one sweep, one series and four markers. It is validated + by two planted faults. This is not decoration: the first working build of + this view grew **3.9 MB per frame** and died at 859 MB, and every correctness + oracle stayed green the whole time. The cause was a runtime bug, not the + sweep — see FINDINGS F-DYN-13 / EigenScript#827. + Forcing-function findings (runtime gaps surfaced while building) are logged in [FINDINGS.md](FINDINGS.md) — most have graduated to upstream fixes -(#255/#256/#280/#375); a calling-convention edge remains open. +(#255/#256/#280/#375, and #819/#820 which closed the two lib/ui plot gaps); +a calling-convention edge remains open, and building this rung surfaced two +more: EigenScript#827 (unbounded temporal assignment history — the one that +froze a box) and #828 (chart render allocation). ## Develop locally ```sh eigenscript dynamics.eigs # parse + run the entry point +bash tests/test_lint.sh # --lint clean across every .eigs in the repo bash tests/test_smoke.sh # stage as a consumer would and import bash tests/test_lab.sh # run the standalone lab programs bash tests/test_orbit_hist.sh # trajectory history: bounded live, complete on dump +bash tests/test_bifurcation.sh # bifurcation oracle: plotted data vs closed-form algebra bash tests/test_orbit_oracle.sh # UI oracle: headless vs UI-stepped byte-diff bash tests/test_orbit_mouse.sh # mouse + render-decode oracle (real input, real pixels) +bash tests/test_bif_mem.sh # memory gate: peak RSS capped and flat in frame count ``` +Every one of those that can fail silently is validated with a **planted fault** +— a deliberately broken copy that the checker must reject — so a green run is +evidence the checker still discriminates, not just that nothing threw. + CI builds EigenScript from source on Linux (the gfx variant, under Xvfb) and runs every one of those scripts on every push and PR (see `.github/workflows/test.yml`). diff --git a/docs/bifurcation.png b/docs/bifurcation.png new file mode 100644 index 0000000..a947935 Binary files /dev/null and b/docs/bifurcation.png differ diff --git a/dynamics.eigs b/dynamics.eigs index bb30d63..7b906f7 100644 --- a/dynamics.eigs +++ b/dynamics.eigs @@ -17,7 +17,7 @@ # names stay private. This 0.1.0 seed is the equilibrium-relaxation primitive; # the physics ζ-sweep, Conway, and iterative-solver modules build on it. -VERSION is "0.1.0" +VERSION is "0.1.0" # lint: allow W001 -- exported package surface # Relax a value from `start` toward `target` by geometric steps of factor # `rate` (0 < rate < 1), stopping when the observer reports the trajectory has diff --git a/logistic.eigs b/logistic.eigs new file mode 100644 index 0000000..8432daa --- /dev/null +++ b/logistic.eigs @@ -0,0 +1,208 @@ +# logistic — the iterated-map core: the logistic map and its +# period-doubling cascade (dynamics#20, fleet UI ladder rung 1). +# +# WHY THIS SYSTEM EXISTS IN THIS REPO. The bifurcation view needs a +# system that actually bifurcates, and nothing already here does: the +# repo's flagship specimen is the damped oscillator x'' = -x - 2z x' +# (physics.eigs), which is LINEAR — its only attractor is the origin for +# every z > 0 and its qualitative behaviour changes exactly once, at the +# critically-damped z = 1, with no branching of the attractor set. Conway +# (life.eigs) is discrete-state; the solvers (solve.eigs) are linear +# fixed-point iterations; the relaxation primitive (dynamics.eigs) is a +# geometric decay. A bifurcation diagram drawn from any of them would be +# a picture of a system that has no bifurcations. So the honest move is +# to add the standard one. +# +# x_{n+1} = r * x_n * (1 - x_n), x in [0, 1], r in [0, 4] +# +# and it is chosen precisely because its cascade is ANALYTICALLY KNOWN, +# which makes it an external oracle rather than a picture: +# +# * r < 1 : x -> 0. +# * 1 < r < 3 : one fixed point, x* = 1 - 1/r (EXACT, closed form). +# Its multiplier is f'(x*) = 2 - r, so it is stable +# while |2 - r| < 1 — the first doubling is at r = 3 +# EXACTLY, not approximately. +# * 3 < r < 1+sqrt(6) : a 2-cycle, whose two points are also exact: +# x± = (r + 1 ± sqrt((r-3)(r+1))) / (2r). +# Its multiplier is 4 + 2r - r², so it loses stability +# at r = 1 + sqrt(6) = 3.449489742783178, EXACTLY. +# * r3 = 3.5440903595519228... : 4-cycle -> 8-cycle (a known root of the +# superstability/stability polynomial; quoted, not +# derived here). +# * the doubling points accumulate at r_inf = 3.569945672... with +# (r_{n} - r_{n-1}) / (r_{n+1} - r_{n}) -> Feigenbaum's +# delta = 4.669201609...; the first ratio +# (r2-r1)/(r3-r2) is 4.7514... +# * r = 4 : fully chaotic on [0, 1]. +# +# tests/test_bifurcation.sh checks the numbers this module produces +# against those closed forms — every plotted point on the period-1 and +# period-2 branches against the algebra, and the located doubling points +# against r1, r2, r3 and the first Feigenbaum ratio. +# +# ARCHITECTURE. This is a PURE core: no gfx, no lib/ui, and — unlike +# physics.eigs / life.eigs / solve.eigs, which are runnable specimens +# with a top-level demo — NO top-level side effects at all, so importing +# it costs nothing. The bifurcation window (orbit.eigs) opens it, the +# oracle (tests/bifurcation_oracle.eigs) opens the same functions +# headlessly, and neither pays for a console demo on every launch. The +# same split as the eigen-edit/eigen-sheet fleet pattern: pure core plus +# a front-end that is defined and never called at load time. + +VERSION is "0.1.0" # lint: allow W001 -- exported module surface + +# ---- analytic constants (the oracle's reference values) -------------- + +# First period-doubling: |f'(x*)| = |2 - r| = 1. +R1 is 3.0 # lint: allow W001 -- exported module surface +# Second: the 2-cycle multiplier 4 + 2r - r² = -1 -> r = 1 + sqrt(6). +R2 is 1.0 + (sqrt of 6.0) # lint: allow W001 -- exported module surface +# Third (4-cycle -> 8-cycle): a known root, quoted to double precision. +R3 is 3.5440903595519228 # lint: allow W001 -- exported module surface +# Feigenbaum's delta, the cascade's asymptotic ratio. +FEIGENBAUM is 4.669201609102990 # lint: allow W001 -- exported module surface +# Onset of the period-3 window inside the chaotic band — a tangent +# (saddle-node) bifurcation of the third iterate, at r = 1 + sqrt(8) +# EXACTLY. Not part of the doubling cascade; it is the sharpest +# analytic landmark in the chaotic region, and by Sharkovskii the +# existence of a 3-cycle implies cycles of every period. +R_P3 is 1.0 + (sqrt of 8.0) # lint: allow W001 -- exported module surface + +# Seed for every orbit here. NOT 0.5, and the reason is the point of the +# whole module: a bifurcation diagram shows the attractor of a TYPICAL +# orbit, and x0 = 0.5 is atypical at r = 4 — it is an exact pre-image of +# the repelling fixed point 0 (0.5 -> 1.0 -> 0.0, and 0 is fixed), so the +# r = 4 column would honestly but misleadingly collapse to a single point +# at 0. That is a real property of that one initial condition, not a +# numerical artifact, and the fix is to seed a typical point rather than +# to hide the column. +X0 is 0.4 + +# ---- the map --------------------------------------------------------- + +define logistic_step(x, r) as: + return r * x * (1.0 - x) + +# The period-1 branch of the diagram: the analytic non-zero fixed +# point, valid for r > 1. (Named for the branch rather than `fixed_point` +# so it does not shadow lib/numerics' fixed-point ITERATOR — W021.) +define branch_p1(r) as: + return 1.0 - 1.0 / r + +# The period-2 branch: the analytic 2-cycle, valid for 3 < r < 1+sqrt(6). Returned low +# first. Outside that window the discriminant is negative and sqrt of a +# negative is 0 in this runtime (numbers are finite by construction), so +# callers must range-check rather than lean on a raised error. +define branch_p2(r) as: + local d is sqrt of ((r - 3.0) * (r + 1.0)) + return [(r + 1.0 - d) / (2.0 * r), (r + 1.0 + d) / (2.0 * r)] + +# ---- asymptotic sampling -------------------------------------------- + +# Discard `transient` iterations from X0, then return the next +# `samples` iterates — the attractor as the plot and the oracle see it. +# The iteration runs `unobserved` (this is a hot scalar loop; the +# observer's per-assignment entropy bookkeeping would dominate it and +# has nothing to say about a discarded transient). +define attractor(r, transient, samples) as: + local out is [] + unobserved: + local x is X0 + local i is 0 + loop while i < transient: + x is r * x * (1.0 - x) + i is i + 1 + local j is 0 + loop while j < samples: + x is r * x * (1.0 - x) + append of [out, x] + j is j + 1 + return out + +# How many DISTINCT points the attractor visits, to within `tol`, capped +# at `cap` (a chaotic r would otherwise report `samples`). This is the +# period detector: 1 on the fixed point, 2 on the 2-cycle, 4 on the +# 4-cycle, and `cap` in chaos. +# +# Greedy clustering, not sorting: the window is short (tens of points), +# and a cluster representative is the first member, so two points land +# together exactly when they are within `tol` of that representative. +define distinct_count(r, transient, samples, tol, cap) as: + local pts is attractor of [r, transient, samples] + local reps is [] + local i is 0 + loop while i < (len of pts): + local v is pts[i] + local hit is 0 + local k is 0 + loop while k < (len of reps): + if (abs of (v - reps[k])) <= tol: + hit is 1 + k is len of reps + else: + k is k + 1 + if hit == 0: + append of [reps, v] + if (len of reps) >= cap: + return cap + i is i + 1 + return len of reps + +# Locate a period-doubling parameter by bisection on the distinct-point +# count: the smallest r in [lo, hi] at which the attractor first shows +# `target` distinct points. Requires the count to be monotone across the +# bracket, which holds inside one window of the cascade — the caller +# picks a bracket containing exactly one doubling. +# +# ACCURACY IS BOUNDED BY `transient`, NOT BY `steps`. At a doubling the +# multiplier passes through magnitude 1, so convergence onto the +# attractor is critically slow: just BELOW r_n the dying cycle's residual +# decays only like |mu|^transient, and the detector cannot tell a +# not-yet-decayed residual from a real split. So the located point lands +# slightly BELOW the true one, by however far below r_n the residual is +# still above `tol`, and bisecting past that floor buys nothing. +# Measured with transient = 40000, tol = 1e-7, 20 steps: r1 low by 2.9e-4, +# r2 by 1.0e-4, r3 by 3.7e-5 — the bias shrinks up the cascade because +# the windows narrow faster than the multipliers slow down. +define doubling_point(target, lo, hi, steps, transient, samples, tol) as: + local a is lo + local b is hi + local i is 0 + loop while i < steps: + local m is (a + b) / 2.0 + if (distinct_count of [m, transient, samples, tol, 64]) >= target: + b is m + else: + a is m + i is i + 1 + return (a + b) / 2.0 + +# ---- the sweep (what the chart plots) -------------------------------- + +# Sweep r across `cols` values in [r0, r1] and return the attractor of +# each as two PARALLEL FLAT LISTS — {"r": [...], "x": [...]} — which is +# exactly the shape lib/ui's chart series wants (`chart_series` takes an +# x list and a y list). Building it in the plot's own shape is what lets +# the window hand the result straight to one series and never touch it +# again; nothing downstream reshapes per frame. +# +# Total point count is cols * samples — the cost knob for both the +# render and the sweep itself. See BIF_* in orbit.eigs for the values +# the window ships. +define sweep(r0, r1, cols, transient, samples) as: + local rs is [] + local xs is [] + local c is 0 + loop while c < cols: + local r is r0 + if cols > 1: + r is r0 + (r1 - r0) * c / (cols - 1) + local pts is attractor of [r, transient, samples] + local k is 0 + loop while k < (len of pts): + append of [rs, r] + append of [xs, pts[k]] + k is k + 1 + c is c + 1 + return {"r": rs, "x": xs} diff --git a/orbit.eigs b/orbit.eigs index a9168fa..d9d43e2 100644 --- a/orbit.eigs +++ b/orbit.eigs @@ -36,10 +36,47 @@ # byte-identical oracle depends on. tests/orbit_ui_pan_dump.eigs pins # that: it pans and zooms EVERY frame of an auto run and the dump must # still be byte-identical to the headless reference. +# +# ---- slice 3: the bifurcation sweep, as a SECOND VIEW in this window -- +# +# WHY A SECOND VIEW AND NOT A SECOND APP. The two views are the same +# lab looking at the same question — "what does this system settle +# into?" — from the two complementary directions: the phase portrait is +# ONE parameter over time, the bifurcation diagram is the asymptotics +# over ALL parameters. Keeping them in one window keeps one theme +# application, one app_loop, one key handler, one Reset-view button +# (mode-aware) and one EigenOS bundle seam; a second binary would +# duplicate every one of those to show a second picture of the same +# lab. The view selector is the `b` key or the toggle at the foot of +# the control column; the phase portrait is what the window opens on, +# so every existing entry point (`run`, `run_auto`) and every existing +# oracle is untouched by construction. +# +# WHICH SYSTEM. Not the damped oscillator — it is linear and does not +# bifurcate at all (see logistic.eigs's header for the full survey of +# why nothing already in this repo does). The second view plots the +# standard logistic map, whose period-doubling cascade has closed-form +# landmarks, so the picture is checkable rather than merely plausible: +# tests/test_bifurcation.sh asserts the plotted branches against the +# analytic fixed point and 2-cycle, and the located doubling points +# against r=3, r=1+sqrt(6), r=3.5440903 and the first Feigenbaum ratio. +# +# THE MEMORY DISCIPLINE. A bifurcation sweep is the one thing in this +# window big enough to hurt a 4 GB box, so it is built ONCE, behind +# `_app.sweep_done`, and handed to the chart widget as two flat lists +# that nothing afterwards rewrites. The per-frame tick in bifurcation +# mode does NO work at all: no sweep, no chart_add_series, no +# chart_add_marker, no per-frame string building — the widget re-renders +# the same series object every frame. tests/test_bif_mem.sh is the gate: +# it runs this path at 30/100/300 frames under `ulimit -v`, and fails on +# a ceiling breach OR on RSS that grows with the frame count. The +# toggle-every-frame leg of that gate is what pins the `sweep_done` +# guard specifically — flipping views 300 times must cost one sweep. load_file of "physics.eigs" import ui import orbit_theme +import logistic VERSION is "0.1.0" # lint: allow W001 -- exported module surface @@ -78,6 +115,28 @@ CANVAS_H is 446 # always present. HIST_CAP is TRAIL + 1 +# ---- bifurcation sweep resolution ------------------------------------ +# +# The cost knob for the second view. BIF_COLS * BIF_SAMPLES is the point +# count the chart re-renders every frame, and the widget costs roughly +# 190 ms/frame at 8,800 points on the reference box — so the default is +# deliberately modest at 200 x 20 = 4,000 points (~2.5 s of sweep+render +# work across the whole memory gate, well inside the suite's runtime +# baseline). It is enough resolution to show the period-1 branch, the +# doubling cascade, the chaotic band and the period-3 window; someone +# who wants a poster raises BIF_COLS, pays the render, and gets no new +# physics. The sweep starts at 2.8 rather than 0 because everything +# below the first doubling is a single smooth branch. +BIF_R_LO is 2.8 +BIF_R_HI is 4.0 +BIF_COLS is 200 +BIF_SAMPLES is 20 +# Iterations discarded before sampling. 600 is comfortably past the +# settling time everywhere except within ~1e-3 of a doubling point, +# where critical slowing down blurs the branch by a pixel or two — an +# honest picture of the physics, not an artefact to hide. +BIF_TRANSIENT is 600 + # ---- simulation state (shared by headless and UI paths) -------------- define new_sim(zeta) as: @@ -138,7 +197,30 @@ _app is { "lbl_z": null, "lbl_v": null, "btn_pause": null, - "hook": null + "hook": null, + # ---- second view (bifurcation) ---- + # mode is "orbit" or "bif"; the window always OPENS on "orbit", so + # every pre-existing entry point and oracle sees the old app. + "mode": "orbit", + "chart": null, + # The one-shot guard on the sweep. This flag is the whole memory + # story of this view: the sweep, the chart series and the analytic + # markers are created the first time the user asks for the + # bifurcation view and never again, no matter how many times the + # views are toggled. + "sweep_done": 0, + # Widgets that belong to exactly one view; _apply_mode flips their + # `visible` flag (lib/ui skips an invisible widget in render, layout + # and hit-test alike, so a hidden chart cannot eat the canvas's + # wheel events). + "orbit_only": [], + "bif_only": [], + "btn_mode": null, + # Frame accounting for the headless drivers. ticks counts every + # app_loop tick; bif_frames >= 0 quits after that many (the memory + # gate's budget). auto_frames still drives the trajectory oracle. + "ticks": 0, + "bif_frames": 0 - 1 } # Observer readout bindings: module globals so the observer window @@ -318,6 +400,103 @@ define _on_canvas_wheel(w, ev) as: # lint: allow W002 -- lib/ui's on_wheel sign return zoom_at of [1.0 / ZOOM_STEP, _ptr.x, _ptr.y] return null +# ---- the bifurcation view -------------------------------------------- +# +# The chart is lib/ui's x-y plot widget (EigenScript#819), and this is +# its first production consumer: the sweep is handed over as two flat +# parallel lists in one `chart_series`, the analytic doubling points as +# four `vline` markers, and the widget owns the axes, ticks, clipping, +# drag-pan and wheel-zoom. There is no hand-rolled plotting here — the +# phase portrait above is canvas-drawn because it needs a custom trail +# renderer; this is a plain x-y scatter, which is exactly what the +# widget is for. +# +# EVERYTHING BELOW RUNS AT MOST ONCE PER SESSION. + +define _ensure_sweep() as: + if _app.sweep_done == 1: + return null + # Set the guard BEFORE building, not after: a half-built sweep that + # retried would append a SECOND series and a second set of markers, + # which is precisely the unbounded shape this flag exists to stop. + _app.sweep_done is 1 + local c is orbit_theme.OL_COLORS + local sw is logistic.sweep of [BIF_R_LO, BIF_R_HI, BIF_COLS, BIF_TRANSIENT, BIF_SAMPLES] + local s is ui.chart_series of ["attractor", sw.r, sw.x, c.plot_trace] + # A bifurcation diagram is a scatter, not a curve: joining the + # samples within a column would draw vertical bars across the + # attractor and joining across columns would draw nonsense. + s.style is "points" + s.point_r is 1 + ui.chart_add_series of [_app.chart, s] + # The four analytic landmarks, drawn where the algebra says they + # are — not where the sweep suggests. tests/test_bifurcation.sh + # checks the sweep against these same numbers. + ui.chart_add_marker of [_app.chart, (ui.chart_marker of ["vline", logistic.R1, 0.0, "r1", c.plot_equil])] + ui.chart_add_marker of [_app.chart, (ui.chart_marker of ["vline", logistic.R2, 0.0, "r2", c.plot_equil])] + ui.chart_add_marker of [_app.chart, (ui.chart_marker of ["vline", logistic.R3, 0.0, "r3", c.plot_equil])] + ui.chart_add_marker of [_app.chart, (ui.chart_marker of ["vline", logistic.R_P3, 0.0, "p3", c.plot_head])] + return null + +# What the sweep actually built — the seam tests/test_bif_mem.sh reads +# to prove that N view toggles cost exactly one sweep. +define bif_stats() as: + local st is {"built": _app.sweep_done, "series": 0, "points": 0, "markers": 0} + if _app.chart != null: + st.series is len of _app.chart.series + st.markers is len of _app.chart.markers + if st.series > 0: + st.points is len of _app.chart.series[0].data + return st + +define _set_visible(ws, v) as: + local i is 0 + loop while i < (len of ws): + ws[i].visible is v + i is i + 1 + return null + +define _apply_mode() as: + if _app.mode == "bif": + _set_visible of [_app.orbit_only, 0] + _set_visible of [_app.bif_only, 1] + _app.canvas.visible is 0 + _app.chart.visible is 1 + else: + _set_visible of [_app.orbit_only, 1] + _set_visible of [_app.bif_only, 0] + _app.canvas.visible is 1 + _app.chart.visible is 0 + if _app.btn_mode != null: + local v is 0 + if _app.mode == "bif": + v is 1 + _app.btn_mode.value is v + return null + +define set_mode(m) as: + if m == _app.mode: + return null + _app.mode is m + if m == "bif": + _ensure_sweep of [] + _apply_mode of [] + return null + +define toggle_mode() as: + if _app.mode == "bif": + return set_mode of "orbit" + return set_mode of "bif" + +# Reset whichever view is showing: the phase portrait's own pan/zoom, or +# the chart's (the widget owns its transform). +define reset_current_view() as: + if _app.mode == "bif": + if _app.chart != null: + ui.chart_reset_view of _app.chart + return null + return reset_view of [] + # ---- widget callbacks ------------------------------------------------ define _zeta_label_text() as: @@ -334,14 +513,21 @@ define _on_pause(w) as: return null define _on_reset(w) as: # lint: allow W002 -- lib/ui's on_click signature - return reset_view of [] + return reset_current_view of [] + +define _on_mode(w) as: + if w.value == 1: + return set_mode of "bif" + return set_mode of "orbit" define _on_key(ev) as: if ev.type == "keydown": if ev.key == "escape": return 0 if ev.key == "r": - reset_view of [] + reset_current_view of [] + if ev.key == "b": + toggle_mode of [] if ev.key == "space": if _app.paused == 1: _app.paused is 0 @@ -353,10 +539,24 @@ define _on_key(ev) as: # ---- per-frame tick (the UI-stepped path of the oracle) -------------- define _tick(root) as: # lint: allow W002 -- ui.app_loop's on_tick signature + _app.ticks is _app.ticks + 1 if _app.auto_frames >= 0 and _app.sim.frame >= _app.auto_frames: write_text of [_app.out_path, (traj_text of _app.sim)] ui.request_quit of null return null + # Headless frame budget for the bifurcation drivers (the memory + # gate). Never set on the trajectory-oracle path, which is bounded + # by sim.frame above. + if _app.bif_frames >= 0 and _app.ticks > _app.bif_frames: + ui.request_quit of null + return null + # BIFURCATION MODE DOES NOTHING PER FRAME. Not a sweep, not a series + # append, not an f-string: the chart re-renders the series object it + # was handed once. The only per-frame work is the widget's own draw. + if _app.mode == "bif": + if _app.hook != null: + _app.hook of _app.sim + return null if _app.paused == 0: frame_advance of _app.sim _obs_energy is energy_of of (_app.sim.state) @@ -380,44 +580,102 @@ define set_frame_hook(fn) as: # ---- window construction (theme applied BEFORE widgets) -------------- +# Mount a widget in `side` and record it as belonging to exactly one +# view. `which` is the _app list ("orbit_only" / "bif_only"). +define _mount(side, w, which) as: + ui.add_child of [side, w] + append of [_app[which], w] + return w + define _build(zeta) as: orbit_theme.orbit_theme_apply of [] local root is ui.panel of ["root", 0, 0, WIN_W, WIN_H] + _app.orbit_only is [] + _app.bif_only is [] local cv is ui.canvas of ["phase", CANVAS_X, CANVAS_Y, CANVAS_W, CANVAS_H, _paint_phase, _on_canvas_mouse] cv.on_wheel is _on_canvas_wheel ui.add_child of [root, cv] _app.canvas is cv + # The bifurcation chart shares the canvas's rect — only one of the + # two is ever visible. Bounds are pinned to the swept region rather + # than auto-scaled: the data never changes, so there is nothing for + # auto-scale to discover, and a fixed frame keeps the analytic + # marker positions stable under pan/zoom. hover_enabled is off + # because the widget's nearest-sample scan is one pass over all + # 4,000 plotted points, every frame, to serve a readout this view + # does not show. + local ch is ui.chart of ["bif", CANVAS_X, CANVAS_Y, CANVAS_W, CANVAS_H] + ch.x_label is "r" + ch.y_label is "x" + ch.x_min is BIF_R_LO + ch.x_max is BIF_R_HI + ch.y_min is 0.0 + ch.y_max is 1.0 + ch.legend is 0 + ch.hover_enabled is 0 + ch.ticks_x is 6 + ch.ticks_y is 4 + ch.visible is 0 + ui.add_child of [root, ch] + _app.chart is ch local side is ui.panel of ["side", 460, 12, 208, 446] ui.add_child of [root, side] ui.add_child of [side, (ui.section of ["hdr", 8, 8, 192, "orbit lab"])] - ui.add_child of [side, (ui.label of ["sys", 8, 34, "x''+2z x'+x=0"])] + # -- phase-portrait controls ------------------------------------- + _mount of [side, (ui.label of ["sys", 8, 34, "x''+2z x'+x=0"]), "orbit_only"] local lz is ui.label of ["zl", 8, 66, "zeta"] - ui.add_child of [side, lz] + _mount of [side, lz, "orbit_only"] _app.lbl_z is lz local sl is ui.slider of ["zeta", 8, 88, 192, 0.0, 2.0, zeta, _on_zeta_change] - ui.add_child of [side, sl] + _mount of [side, sl, "orbit_only"] local bp is ui.toggle_button of ["pause", 8, 120, 192, 26, "Pause", _on_pause] - ui.add_child of [side, bp] + _mount of [side, bp, "orbit_only"] _app.btn_pause is bp + # Reset view is mode-aware, so it belongs to neither list. ui.add_child of [side, (ui.button of ["reset", 8, 152, 192, 26, "Reset view", _on_reset])] local lv is ui.label of ["lv", 8, 186, "zoom x1"] - ui.add_child of [side, lv] + _mount of [side, lv, "orbit_only"] _app.lbl_v is lv - ui.add_child of [side, (ui.section of ["obs", 8, 216, 192, "observer forecast"])] + _mount of [side, (ui.section of ["obs", 8, 216, 192, "observer forecast"]), "orbit_only"] local le is ui.label of ["le", 8, 242, "E : -"] - ui.add_child of [side, le] + _mount of [side, le, "orbit_only"] _app.lbl_e is le local lx is ui.label of ["lx", 8, 262, "x : -"] - ui.add_child of [side, lx] + _mount of [side, lx, "orbit_only"] _app.lbl_x is lx - ui.add_child of [side, (ui.label of ["eq", 8, 294, "equilibrium: red"])] - ui.add_child of [side, (ui.label of ["k1", 8, 324, "drag: pan"])] - ui.add_child of [side, (ui.label of ["k2", 8, 344, "wheel: zoom"])] - ui.add_child of [side, (ui.label of ["k3", 8, 364, "r: reset view"])] - ui.add_child of [side, (ui.label of ["k4", 8, 384, "space: pause"])] - ui.add_child of [side, (ui.label of ["k5", 8, 404, "esc: quit"])] + _mount of [side, (ui.label of ["eq", 8, 286, "equilibrium: red"]), "orbit_only"] + # -- bifurcation legend (same column, shown only in that view) ---- + # Every caption is <= 16 characters: lib/ui's default font is scale + # 2 (char_w 12), so 16 glyphs exactly fill the 192 px column and a + # 17th would overflow it (EigenScript#823 / F-DYN-12). + # The column is laid out AROUND the shared Reset-view button (y 152, + # h 26), which belongs to both views: captions run 34-138 above it and + # 186-306 below it. That gap is why the sweep description is two lines + # rather than three — a real-window screenshot caught the third + # overlapping the button, which no headless test could see. + _mount of [side, (ui.section of ["bsec", 8, 34, 192, "bifurcation"]), "bif_only"] + _mount of [side, (ui.label of ["b1", 8, 60, "logistic map"]), "bif_only"] + _mount of [side, (ui.label of ["b2", 8, 80, "x'=r x (1-x)"]), "bif_only"] + _mount of [side, (ui.label of ["b3", 8, 104, "r 2.8 -> 4.0"]), "bif_only"] + _mount of [side, (ui.label of ["b4", 8, 124, "200x20, drop600"]), "bif_only"] + _mount of [side, (ui.section of ["bsec2", 8, 186, 192, "analytic"]), "bif_only"] + _mount of [side, (ui.label of ["b5", 8, 212, "r1 = 3"]), "bif_only"] + _mount of [side, (ui.label of ["b6", 8, 232, "r2 = 1+sqrt6"]), "bif_only"] + _mount of [side, (ui.label of ["b7", 8, 252, "r3 = 3.5441"]), "bif_only"] + _mount of [side, (ui.label of ["b8", 8, 272, "p3 = 1+sqrt8"]), "bif_only"] + _mount of [side, (ui.label of ["b9", 8, 292, "amber: p3"]), "bif_only"] + # -- shared footer ----------------------------------------------- + ui.add_child of [side, (ui.label of ["k1", 8, 310, "drag: pan"])] + ui.add_child of [side, (ui.label of ["k2", 8, 330, "wheel: zoom"])] + ui.add_child of [side, (ui.label of ["k3", 8, 350, "r: reset view"])] + ui.add_child of [side, (ui.label of ["k4", 8, 370, "space: pause"])] + ui.add_child of [side, (ui.label of ["k5", 8, 390, "esc: quit"])] + local bm is ui.toggle_button of ["mode", 8, 412, 192, 26, "b: bifurcation", _on_mode] + ui.add_child of [side, bm] + _app.btn_mode is bm _on_zeta_change of sl _sync_view_label of [] + _apply_mode of [] return root # ---- entry points ---------------------------------------------------- @@ -428,22 +686,61 @@ define _build(zeta) as: # # The history bound is applied ONLY to the interactive path: an auto run # dumps its trajectory, so it keeps every point (cap 0). -define run_session(zeta, frames, out_path) as: +# ---- the window entry point ------------------------------------------ +# +# WHY THIS TURNS OFF ASSIGNMENT HISTORY. `record_history of 0` is the +# runtime's documented control for per-assignment temporal history — the +# append-only table `prev of x` and ` is x at ` read. The +# compiler arms that table for the WHOLE PROGRAM as soon as it sees a +# `prev of` anywhere in the source, including in a function that is +# never called; physics.eigs has exactly one, in the `frame_velocity` +# demo, which this window never invokes. The table is append-only with +# no cap and it holds a REFERENCE to every assigned value, so an +# unbounded loop over it — a 60 fps render — grows without bound: the +# bifurcation view measured 3.9 MB per frame, 859 MB by frame 300, and +# then died on the memory cap. Minimal repro and the upstream issue: +# EigenScript#827 (F-DYN-13). A lab window asks no temporal questions, +# so it opts out for the lifetime of the session and restores the +# previous setting on the way out; the observer predicates the side +# panel shows are live slot reads and are NOT affected. +define _run(zeta, frames, out_path, mode, ticks) as: + local hist_was is record_history of 0 _app.sim is new_sim of zeta if frames < 0: _app.sim.cap is HIST_CAP _app.auto_frames is frames + _app.bif_frames is ticks _app.out_path is out_path _app.paused is 0 + _app.ticks is 0 + _app.mode is "orbit" + _app.sweep_done is 0 reset_view of [] local root is _build of zeta + # The window always OPENS on the phase portrait; a bifurcation + # session switches after the tree exists, through the same public + # set_mode the `b` key and the toggle button use. There is no second + # construction path to keep in sync. + if mode == "bif": + set_mode of "bif" gfx_open of [WIN_W, WIN_H, "dynamics - orbit lab"] ui.app_loop of [root, _on_key, _tick] gfx_close of null + record_history of hist_was return null +define run_session(zeta, frames, out_path) as: + return _run of [zeta, frames, out_path, "orbit", 0 - 1] + define run() as: return run_session of [DEFAULT_ZETA, 0 - 1, ""] define run_auto(zeta, frames, out_path) as: return run_session of [zeta, frames, out_path] + +# Open straight into the bifurcation view and run exactly `ticks` frames +# through the real window and the real chart render, then quit. This is +# the driver tests/test_bif_mem.sh measures: same widget, same tick, same +# draw path an interactive session runs — only the exit condition differs. +define run_bif_auto(ticks) as: + return _run of [DEFAULT_ZETA, 0 - 1, "", "bif", ticks] diff --git a/orbit_theme.eigs b/orbit_theme.eigs index 499a964..e8e07ee 100644 --- a/orbit_theme.eigs +++ b/orbit_theme.eigs @@ -93,7 +93,19 @@ define _orbit_theme_table() as: "tooltip_bg": c.input, "tooltip_text": c.text, "disabled_overlay": c.bg, - "selection_bg": c.accent + "selection_bg": c.accent, + # The lib/ui chart widget's own surface keys (EigenScript#820). + # Without these the bifurcation view would draw on the toolkit's + # stock slate-blue plot surface next to the phase portrait's + # near-black one — two different labs in one window. Mapping + # them onto the SAME palette entries the canvas reads is what + # makes the theme layer cover the data surface, not just the + # chrome. + "plot_bg": c.plot_bg, + "plot_grid": c.plot_grid, + "plot_axis": c.plot_axis, + "plot_border": c.border, + "plot_series": [c.accent, c.accent2, c.red, c.text_dim] } # Apply the orbit-lab palette to lib/ui's ACTIVE theme, in place. diff --git a/predicate_fit.eigs b/predicate_fit.eigs index 65eda97..dd6b2de 100644 --- a/predicate_fit.eigs +++ b/predicate_fit.eigs @@ -22,7 +22,6 @@ HORIZON is 40 EPS is 0.001 -WARM is 220 define claims_settled(status) as: if status == "converged": diff --git a/solve.eigs b/solve.eigs index 9505f43..bdedc8c 100644 --- a/solve.eigs +++ b/solve.eigs @@ -44,7 +44,9 @@ define max_abs_diff(u, v) as: i is i + 1 return m -define dot(u, v) as: +# Named `vdot`, not `dot`: `dot` is a runtime builtin and a same-named +# definition shadows it for the whole file (lint W013). +define vdot(u, v) as: n is len of u s is 0.0 i is 0 @@ -136,9 +138,9 @@ define power_iter(a, maxit) as: y is zeros of n i is 0 loop while i < n: - y[i] is dot of [a[i], x] + y[i] is vdot of [a[i], x] i is i + 1 - nrm is sqrt of (dot of [y, y]) + nrm is sqrt of (vdot of [y, y]) i is 0 loop while i < n: y[i] is y[i] / nrm @@ -175,7 +177,7 @@ define pagerank(m, maxit) as: rn is zeros of n i is 0 loop while i < n: - rn[i] is dot of [m[i], r] + rn[i] is vdot of [m[i], r] i is i + 1 tot is 0.0 i is 0 diff --git a/tests/bif_ui.eigs b/tests/bif_ui.eigs new file mode 100644 index 0000000..317877e --- /dev/null +++ b/tests/bif_ui.eigs @@ -0,0 +1,35 @@ +# Bifurcation-view driver for the MEMORY GATE (dynamics#20 rung 1, +# slice 3). Opens the REAL window straight into the bifurcation view and +# runs exactly BIF_TICKS frames of the real tick + real chart render, +# then quits — the same path an interactive session runs, with a frame +# budget instead of an Escape key. +# +# BIF_MODE: +# static — sit in the bifurcation view for the whole run. This is the +# steady-state shape: the sweep exists, nothing rebuilds it, +# and RSS must be flat in the frame count. +# toggle — flip views on EVERY frame. This is the leak probe for the +# `sweep_done` guard specifically: N toggles must still cost +# exactly one sweep, one series and four markers, which the +# BUILT/SERIES/POINTS/MARKERS report below pins numerically +# on top of the RSS measurement. +# +# The frame hook only calls orbit's own public view-switch; it never +# touches the simulation. +import orbit + +ticks is num of (env_get of "BIF_TICKS") +mode is env_get of "BIF_MODE" + +define flip(sim) as: # lint: allow W002 -- orbit's frame-hook signature + orbit.toggle_mode of [] + return null + +if mode == "toggle": + orbit.set_frame_hook of flip + +orbit.run_bif_auto of ticks + +st is orbit.bif_stats of [] +write_text of [(env_get of "BIF_OUT"), f"BUILT {st.built}\nSERIES {st.series}\nPOINTS {st.points}\nMARKERS {st.markers}\n"] +print of "BIF-UI-OK" diff --git a/tests/bifurcation_oracle.eigs b/tests/bifurcation_oracle.eigs new file mode 100644 index 0000000..6e379df --- /dev/null +++ b/tests/bifurcation_oracle.eigs @@ -0,0 +1,142 @@ +# THE BIFURCATION ORACLE (dynamics#20 rung 1, slice 3) — headless. +# +# The bifurcation view's claim is not "here is a plausible picture", it +# is "here is the logistic map's attractor". That claim is checkable +# against algebra, and this program does the checking; the assertions +# and their thresholds live in tests/test_bifurcation.sh. +# +# It measures FOUR independent things: +# +# 1. THE PLOTTED DATA. Not a re-derivation — the very sweep orbit.eigs +# hands to the chart (same r range, same column count, same +# transient, same sample count). Every point in a column safely +# inside the period-1 window must equal 1 - 1/r, and every point in +# a column safely inside the period-2 window must equal one of +# (r+1 ± sqrt((r-3)(r+1)))/(2r) — with BOTH branch points actually +# visited, so a collapsed column cannot pass. +# +# "Safely inside" is load-bearing and is a statement about physics, +# not a fudge: at a doubling the multiplier passes through magnitude +# 1, so a column within ~1e-2 of r=3 or r=1+sqrt(6) has not settled +# after 600 iterations and genuinely is a blur. The margins below +# (r <= 2.95, 3.05 <= r <= 3.40) are where |multiplier|^600 is far +# under the tolerance. +# +# 2. THE CASCADE LOCATIONS, found by bisection on the measured period +# and compared against r1 = 3, r2 = 1 + sqrt(6), r3 = 3.5440903..., +# plus the first Feigenbaum ratio (r2-r1)/(r3-r2) = 4.7514... +# +# 3. THE PERIOD-3 TANGENT BIFURCATION at r = 1 + sqrt(8): the attractor +# must be chaotic just below it and exactly a 3-cycle just above. +# +# 4. A PERIOD CENSUS at five parameters, one per rung of the cascade. +# +# Output is one FIELD VALUE per line; the shell script asserts. +import logistic + +# The window's own sweep parameters (orbit.eigs BIF_*). Duplicated here +# rather than imported because importing orbit.eigs pulls in lib/ui and +# a gfx front-end for what is a pure numeric check; tests/test_bifurcation.sh +# greps orbit.eigs for these values so the duplication cannot drift. +R_LO is 2.8 +R_HI is 4.0 +COLS is 200 +SAMPLES is 20 +TRANSIENT is 600 + +TOL is 0.000000001 # 1e-9 — the closed-form agreement demanded +DET_TOL is 0.0000001 # 1e-7 — the period detector's "same point" +DET_TRANSIENT is 40000 +DET_SAMPLES is 64 +BISECT_STEPS is 20 + +sw is logistic.sweep of [R_LO, R_HI, COLS, TRANSIENT, SAMPLES] +print of f"SWEEP_N {len of sw.x}" + +# ---- 1. the plotted data against the closed forms -------------------- + +p1_max is 0.0 +p1_cols is 0 +p2_max is 0.0 +p2_cols is 0 +p2_both is 1 + +c is 0 +loop while c < COLS: + base is c * SAMPLES + r is sw.r[base] + if r <= 2.95: + p1_cols is p1_cols + 1 + want is logistic.branch_p1 of r + k is 0 + loop while k < SAMPLES: + d is abs of (sw.x[base + k] - want) + if d > p1_max: + p1_max is d + k is k + 1 + elif r >= 3.05 and r <= 3.40: + p2_cols is p2_cols + 1 + an is logistic.branch_p2 of r + hit_lo is 0 + hit_hi is 0 + k is 0 + loop while k < SAMPLES: + v is sw.x[base + k] + dlo is abs of (v - an[0]) + dhi is abs of (v - an[1]) + d is dlo + if dhi < d: + d is dhi + if dlo < dhi: + hit_lo is 1 + else: + hit_hi is 1 + if d > p2_max: + p2_max is d + k is k + 1 + if hit_lo == 0 or hit_hi == 0: + p2_both is 0 + c is c + 1 + +print of f"P1_COLS {p1_cols}" +print of f"P1_MAXERR {p1_max}" +print of f"P2_COLS {p2_cols}" +print of f"P2_MAXERR {p2_max}" +print of f"P2_BOTH_BRANCHES {p2_both}" +print of f"TOL {TOL}" + +# ---- 2. where the doublings actually are ----------------------------- + +m1 is logistic.doubling_point of [2, 2.9, 3.1, BISECT_STEPS, DET_TRANSIENT, DET_SAMPLES, DET_TOL] +m2 is logistic.doubling_point of [4, 3.4, 3.5, BISECT_STEPS, DET_TRANSIENT, DET_SAMPLES, DET_TOL] +m3 is logistic.doubling_point of [8, 3.53, 3.56, BISECT_STEPS, DET_TRANSIENT, DET_SAMPLES, DET_TOL] + +print of f"R1_MEAS {m1}" +print of f"R1_ERR {abs of (m1 - logistic.R1)}" +print of f"R2_MEAS {m2}" +print of f"R2_ERR {abs of (m2 - logistic.R2)}" +print of f"R3_MEAS {m3}" +print of f"R3_ERR {abs of (m3 - logistic.R3)}" +print of f"RATIO {(m2 - m1) / (m3 - m2)}" +print of f"DELTA {logistic.FEIGENBAUM}" + +# ---- 3. the period-3 tangent bifurcation at 1 + sqrt(8) -------------- +# +# 5e-4 either side of the analytic onset. Below it the attractor is +# chaotic (the detector saturates at its cap); above it, a clean 3-cycle. + +p3_below is logistic.distinct_count of [logistic.R_P3 - 0.0005, DET_TRANSIENT, DET_SAMPLES, DET_TOL, 64] +p3_above is logistic.distinct_count of [logistic.R_P3 + 0.0005, DET_TRANSIENT, DET_SAMPLES, DET_TOL, 64] +print of f"P3_R {logistic.R_P3}" +print of f"P3_BELOW {p3_below}" +print of f"P3_ABOVE {p3_above}" + +# ---- 4. the period census, one per rung ------------------------------ + +print of f"CENSUS_2_8 {logistic.distinct_count of [2.8, DET_TRANSIENT, DET_SAMPLES, DET_TOL, 64]}" +print of f"CENSUS_3_2 {logistic.distinct_count of [3.2, DET_TRANSIENT, DET_SAMPLES, DET_TOL, 64]}" +print of f"CENSUS_3_5 {logistic.distinct_count of [3.5, DET_TRANSIENT, DET_SAMPLES, DET_TOL, 64]}" +print of f"CENSUS_3_55 {logistic.distinct_count of [3.55, DET_TRANSIENT, DET_SAMPLES, DET_TOL, 64]}" +print of f"CENSUS_3_9 {logistic.distinct_count of [3.9, DET_TRANSIENT, DET_SAMPLES, DET_TOL, 64]}" + +print of "BIF-ORACLE-OK" diff --git a/tests/mouse_oracle.py b/tests/mouse_oracle.py index 83a88e6..0fc8de0 100644 --- a/tests/mouse_oracle.py +++ b/tests/mouse_oracle.py @@ -68,6 +68,12 @@ ZETA_LABEL_XY = (468, 78) ZOOM_LABEL_XY = (468, 198) SLIDER = (468, 100, 192) # x, y (centre), width +# Slice 3: the view selector at panel-relative (8, 412, 192, 26), and the +# first caption of the bifurcation control column at panel-relative +# (8, 60) — a slot the phase-portrait column leaves blank, so decoding it +# reads the CURRENT view rather than a label that exists in both. +MODE_BTN = (564, 437) +BIF_LABEL_XY = (468, 72) CHANGED_MIN = 100 # pixels: a redraw of the whole plot # Advancement is measured against a noise floor the run itself pins: the # paused checks below come back at EXACTLY 0 differing pixels, twice, so a @@ -215,6 +221,10 @@ def decode_line(img, atlas, xy, n=22): "pan": (" _drag.active is 1\n", " _drag.active is 0\n"), # the tick advances the simulation even while paused. "pause": (" if _app.paused == 0:\n", " if _app.paused == 0 or 1 == 1:\n"), + # the view selector's handler fires but switches nothing — the class + # of bug where "a handler ran" is mistaken for "the UI changed". + "mode": (' if w.value == 1:\n return set_mode of "bif"\n', + ' if w.value == 1:\n return null\n'), } @@ -222,7 +232,7 @@ def build_tree(tmp, fault): """Copy the app under test into tmp, optionally with a planted fault.""" tree = os.path.join(tmp, "app") os.makedirs(tree) - for name in ("orbit.eigs", "orbit_theme.eigs", "physics.eigs"): + for name in ("orbit.eigs", "orbit_theme.eigs", "physics.eigs", "logistic.eigs"): shutil.copy(os.path.join(REPO, name), os.path.join(tree, name)) if fault: needle, repl = FAULTS[fault] @@ -366,6 +376,42 @@ def key(k): check(zeta_txt.startswith("zeta =") and zeta_txt != base_zeta, "slider: dragging moves zeta %r -> %r" % (base_zeta, zeta_txt)) + # 8. the view selector (slice 3). Clicking it must swap the phase + # portrait for the bifurcation chart — verified in PIXELS on both + # halves of the window: the control column decodes to the + # bifurcation captions, and the plot area is redrawn wholesale. + # Then the diagram must be STATIC: two frames 0.4 s apart + # pixel-identical is the visible face of "the bifurcation tick + # does no per-frame work", which is what the memory gate + # (tests/test_bif_mem.sh) measures in RSS. + before_toggle = grab(wid, tmp) + click_at(*MODE_BTN) + bif = grab(wid, tmp) + bif_txt = decode_line(bif, atlas, BIF_LABEL_XY) + print("decoded bifurcation column: %r" % bif_txt) + check(bif_txt == "logistic map", + "mode: the control column reads %r after the view toggle" % bif_txt) + d = region_diff(before_toggle, bif, CANVAS) + check(d > CHANGED_MIN, + "mode: the plot area is redrawn as the bifurcation chart (%d px differ)" % d) + time.sleep(0.4) + bif_b = grab(wid, tmp) + d = region_diff(bif, bif_b, CANVAS) + check(d == 0, + "mode: the bifurcation diagram is static between frames (%d px differ)" % d) + + # 9. toggling back restores the phase portrait — the pre-existing + # view must survive a round trip through the new one. + click_at(*MODE_BTN) + back = grab(wid, tmp) + back_txt = decode_line(back, atlas, ZETA_LABEL_XY) + print("decoded zeta label after toggling back: %r" % back_txt) + check(back_txt == zeta_txt, + "mode: toggling back restores the phase-portrait column (%r)" % back_txt) + d = region_diff(before_toggle, back, CANVAS) + check(d == 0, + "mode: the phase portrait comes back pixel-for-pixel (%d px differ)" % d) + except Stop: print("(stopping at the first failure)") finally: diff --git a/tests/test_bif_mem.sh b/tests/test_bif_mem.sh new file mode 100755 index 0000000..8de23bb --- /dev/null +++ b/tests/test_bif_mem.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# THE MEMORY GATE (dynamics#20 rung 1, slice 3). +# +# WHY THIS EXISTS. A bifurcation sweep is the largest data structure this +# lab has ever put on screen, and the first attempt at this view grew +# 3.9 MB per frame — 859 MB by frame 300 — and froze a 4 GB box. A +# correctness suite cannot see that: every oracle was green while the +# machine was dying. So the shape of the memory curve is itself a gate. +# +# WHAT IT ASSERTS, at 30 / 100 / 300 frames, in two shapes: +# +# 1. CEILING — peak RSS stays under CEILING_KB. +# 2. FLATNESS — peak RSS at 300 frames is no more than GROWTH_KB above +# peak RSS at 30 frames. This is the real gate: a ceiling alone +# passes a leak that has not had time to bite yet. +# 3. STRUCTURE — the sweep, the chart series and the analytic markers +# are built exactly once, however many times the views are toggled. +# RSS is the symptom; "SERIES 1 after 150 view switches" is the +# cause, asserted directly. +# +# THE NUMBERS, and why they are these numbers. Measured steady state on +# the reference box is 136.6–137.3 MB across all six legs, which is the +# same floor as the merged orbit view (137 MB) and as lib/ui's chart +# widget alone (135 MB) — i.e. essentially all of it is the runtime plus +# SDL, and the sweep itself is noise. CEILING_KB is 200 MB, ~1.45x that +# floor: loose enough to absorb a different SDL/driver/font cache on a CI +# runner, tight enough that the original leak — 258 MB at its FIRST rung, +# 30 frames — would have been caught before the second. GROWTH_KB is +# 25 MB against a measured 30->300 spread of under 1 MB, so ~25x headroom +# on the noise while the leak it is aimed at shows +600 MB. +# +# EVERY RUN IS CAPPED with `ulimit -v`. That is not belt-and-braces: it +# is what turns a runaway into a clean "out of memory" instead of a +# frozen workstation, and it is the reason this gate could be developed +# at all. +# +# The gate is validated with TWO planted faults: one restoring the actual +# leak that was found (the temporal-history opt-out removed), one +# removing the build-once guard. Each must be caught. +# +# Exits 2 (= failure in CI, skip locally) when the runtime has no gfx +# builtins, so it can never be silently dropped. +set -euo pipefail + +EIGS="${EIGENSCRIPT:-eigenscript}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TMP="$(mktemp -d)" +trap "rm -rf '$TMP'" EXIT + +VCAP=1500000 # KB of address space per run — the runaway guard +CEILING_KB=204800 # 200 MB +GROWTH_KB=25600 # 25 MB, 30 frames -> 300 frames + +XVFB="" +if [ -z "${DISPLAY:-}" ]; then + XVFB="xvfb-run -a" +fi + +# The measuring instrument itself is a precondition, and a LOUD one. GNU +# time is a separate package from the shell builtin; without it every run +# exits 127 and a naive gate reports "the process died under the cap" — +# i.e. it blames the thing it is measuring for the absence of the ruler. +# CI caught exactly that. This is a failure, never a skip: a memory gate +# that quietly does not measure is worse than no gate. +if ! command -v /usr/bin/time > /dev/null; then + echo "FAIL: /usr/bin/time (GNU time, package 'time') is not installed — peak RSS cannot be measured, so this gate cannot run" + exit 1 +fi + +echo "--- gfx capability probe ---" +cat > "$TMP/gfxprobe.eigs" <<'EOF' +w is gfx_open of [64, 48, "probe"] +gfx_close of null +print of "GFXOK" +EOF +PROBE_OUT=$( (ulimit -v $VCAP; $XVFB "$EIGS" "$TMP/gfxprobe.eigs" 2>&1) || true) +if ! echo "$PROBE_OUT" | grep -q "GFXOK"; then + if echo "$PROBE_OUT" | grep -q "undefined variable 'gfx_open'"; then + echo "SKIP: runtime is not gfx-capable (build with 'make gfx') — memory gate not run" + exit 2 + fi + echo "FAIL: gfx probe could not open a window — probe output:" + echo "$PROBE_OUT" + exit 1 +fi + +# measure -> sets PEAK (KB) and STATS (file path). +# Runs the real window, the real chart render, under the address-space cap. +measure() { + local tree="$1" mode="$2" frames="$3" + STATS="$TMP/stats.$mode.$frames.txt" + local tf="$TMP/time.$mode.$frames.txt" + rm -f "$STATS" "$tf" + set +e + ( cd "$tree" && ulimit -v $VCAP && BIF_TICKS="$frames" BIF_MODE="$mode" BIF_OUT="$STATS" \ + /usr/bin/time -f "%M" -o "$tf" $XVFB "$EIGS" tests/bif_ui.eigs > /dev/null 2>&1 ) + RC=$? + set -e + PEAK=$(tail -1 "$tf" 2>/dev/null || echo 0) + case "$PEAK" in ''|*[!0-9]*) PEAK=0 ;; esac + # 0 KB is not a measurement. Separated from a real overrun so the + # verdict never blames the program for a missing ruler. + MEASURED=1 + if [ "$PEAK" -eq 0 ]; then MEASURED=0; fi +} + +# stat_field +stat_field() { grep -E "^$2 " "$1" 2>/dev/null | awk '{print $2}'; } + +# run_gate — returns 0 when the tree +# satisfies ceiling + flatness + structure over the given legs. `quiet` +# suppresses per-check output AND stops at the first failure, which is +# what keeps a deliberately-broken tree from being measured for minutes. +run_gate() { + local tree="$1" quiet="$2" modes="$3" frame_list="$4" bad=0 + local mode frames first last growth built series points markers + say() { if [ -z "$quiet" ]; then echo "$1"; fi; } + for mode in $modes; do + first=0; last=0 + for frames in $frame_list; do + measure "$tree" "$mode" "$frames" + built=$(stat_field "$STATS" BUILT) + series=$(stat_field "$STATS" SERIES) + points=$(stat_field "$STATS" POINTS) + markers=$(stat_field "$STATS" MARKERS) + say " $mode $frames frames: peak ${PEAK} KB rc=$RC built=${built:-?} series=${series:-?} points=${points:-?} markers=${markers:-?}" + if [ "$MEASURED" -eq 0 ]; then + say "FAIL: $mode/$frames produced no RSS measurement (exit $RC) — the instrument failed, not necessarily the program" + bad=1 + elif [ "$RC" -ne 0 ]; then + say "FAIL: $mode/$frames exited $RC — a run that dies under the ${VCAP} KB cap is the runaway this gate exists to catch" + bad=1 + fi + if [ "$PEAK" -gt "$CEILING_KB" ]; then + say "FAIL: $mode/$frames peaked at ${PEAK} KB, ceiling ${CEILING_KB} KB" + bad=1 + fi + # Structure: one sweep, one series, one full point set, four + # analytic markers — however many times the views switch. + if [ "${built:-0}" != "1" ]; then + say "FAIL: $mode/$frames reports built=${built:-none}"; bad=1 + fi + if [ "${series:-0}" != "1" ]; then + say "FAIL: $mode/$frames built ${series:-none} chart series — the build-once guard let go"; bad=1 + fi + if [ "${points:-0}" != "4000" ]; then + say "FAIL: $mode/$frames plotted ${points:-none} points, want 4000"; bad=1 + fi + if [ "${markers:-0}" != "4" ]; then + say "FAIL: $mode/$frames has ${markers:-none} markers, want 4"; bad=1 + fi + if [ -n "$quiet" ] && [ "$bad" -ne 0 ]; then + return 1 + fi + if [ "$first" -eq 0 ]; then first=$PEAK; fi + last=$PEAK + done + growth=$((last - first)) + say " $mode growth over the frame sweep: ${growth} KB (limit ${GROWTH_KB} KB)" + if [ "$growth" -gt "$GROWTH_KB" ]; then + say "FAIL: $mode RSS grew ${growth} KB with frame count — that is a leak, not a working set" + bad=1 + if [ -n "$quiet" ]; then return 1; fi + fi + done + return $bad +} + +echo "--- bifurcation view under a ${VCAP} KB address-space cap ---" +if ! run_gate "$ROOT" "" "static toggle" "30 100 300"; then + exit 1 +fi +echo "PASS: peak RSS under ${CEILING_KB} KB and flat in frame count, in both the steady-state and the toggle-every-frame shape" + +# ---- planted faults --------------------------------------------------- +# A memory gate nobody has seen go red is decoration. Each fault below +# recreates a real unbounded shape and is measured by the SAME run_gate +# with the SAME thresholds — on the shorter leg the fault targets, since +# a broken tree is expensive to measure. + +plant() { + local name="$1" needle="$2" repl="$3" + local tree="$TMP/$name" + rm -rf "$tree"; mkdir -p "$tree/tests" + cp "$ROOT"/*.eigs "$tree/" + cp "$ROOT"/tests/*.eigs "$tree/tests/" + python3 - "$tree/orbit.eigs" "$needle" "$repl" <<'PY_END' +import sys +path, needle, repl = sys.argv[1], sys.argv[2], sys.argv[3] +src = open(path).read() +if needle not in src: + raise SystemExit("planted fault no longer matches orbit.eigs: %r" % needle) +open(path, "w").write(src.replace(needle, repl, 1)) +PY_END + echo "$tree" +} + +echo "--- planted fault 1: the temporal-history opt-out removed (the real bug) ---" +# physics.eigs contains one `prev of`, which arms the runtime's +# append-only assignment history for the whole program; the chart's +# per-point allocations are then pinned, at ~3.9 MB per frame. The gate +# must reject this on its FIRST rung. EigenScript#827. +T1=$(plant hist " local hist_was is record_history of 0" " local hist_was is record_history of 1") +if run_gate "$T1" quiet "static" "30 100"; then + echo "FAIL: the leaking build passed the memory gate — the gate can't discriminate" + exit 1 +fi +echo "PASS: planted fault 'hist' is caught (restores the 3.9 MB/frame growth)" + +echo "--- planted fault 2: the build-once sweep guard removed ---" +T2=$(plant guard " if _app.sweep_done == 1: + return null" " if _app.sweep_done == 2: + return null") +if run_gate "$T2" quiet "toggle" "30"; then + echo "FAIL: an unguarded sweep passed the memory gate — the structural checks can't discriminate" + exit 1 +fi +echo "PASS: planted fault 'guard' is caught (every view switch rebuilds the sweep)" + +echo "PASS: memory gate green, both planted faults caught" diff --git a/tests/test_bifurcation.sh b/tests/test_bifurcation.sh new file mode 100755 index 0000000..29a65bd --- /dev/null +++ b/tests/test_bifurcation.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# THE BIFURCATION ORACLE (dynamics#20 rung 1, slice 3). +# +# The bifurcation view plots the logistic map's attractor, and the +# logistic map's cascade is analytically known — so this is a real +# external oracle, not a golden master. Nothing here compares against a +# number this repo chose; every threshold is checked against closed-form +# algebra (1 - 1/r, (r+1 ± sqrt((r-3)(r+1)))/(2r), r = 3, r = 1+sqrt(6), +# r = 1+sqrt(8)) or against a published constant (r3 = 3.5440903..., +# Feigenbaum's delta = 4.6692016...). +# +# Headless: no window, no gfx builtins, so it runs on every build. +# +# The checker is validated with TWO planted faults — one in the system +# under test (the map itself is perturbed) and one in the reference (an +# analytic constant is moved) — and each must be caught. +set -euo pipefail + +EIGS="${EIGENSCRIPT:-eigenscript}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TMP="$(mktemp -d)" +trap "rm -rf '$TMP'" EXIT + +field() { echo "$1" | grep -E "^$2 " | awk '{print $2}'; } +# floating-point comparison without leaving the shell +flt() { awk "BEGIN{exit !($1)}"; } + +# ---- the sweep parameters must match the ones the window ships ------- +# The oracle re-declares orbit.eigs's BIF_* values so it can stay free of +# lib/ui. That duplication is only safe if it cannot drift, so pin it. +echo "--- sweep parameters match the shipped window ---" +for pair in "BIF_R_LO R_LO" "BIF_R_HI R_HI" "BIF_COLS COLS" "BIF_SAMPLES SAMPLES" "BIF_TRANSIENT TRANSIENT"; do + set -- $pair + APP=$(grep -E "^$1 is " orbit.eigs | awk '{print $3}') + ORC=$(grep -E "^$2 is " tests/bifurcation_oracle.eigs | awk '{print $3}') + [ -n "$APP" ] || { echo "FAIL: orbit.eigs has no $1"; exit 1; } + [ "$APP" = "$ORC" ] || { + echo "FAIL: orbit.eigs $1=$APP but the oracle's $2=$ORC — the oracle is checking a sweep the window does not draw" + exit 1 + } + echo " $1 = $APP" +done + +# Returns 0 when every analytic check holds. `quiet` suppresses the +# per-check output (used by the planted faults). +check() { + local out="$1" quiet="${2:-}" bad=0 + local n p1c p1e p2c p2e p2b tol r1e r2e r3e ratio pb pa c28 c32 c35 c355 c39 + n=$(field "$out" SWEEP_N) + p1c=$(field "$out" P1_COLS); p1e=$(field "$out" P1_MAXERR) + p2c=$(field "$out" P2_COLS); p2e=$(field "$out" P2_MAXERR) + p2b=$(field "$out" P2_BOTH_BRANCHES) + tol=$(field "$out" TOL) + r1e=$(field "$out" R1_ERR); r2e=$(field "$out" R2_ERR); r3e=$(field "$out" R3_ERR) + ratio=$(field "$out" RATIO) + pb=$(field "$out" P3_BELOW); pa=$(field "$out" P3_ABOVE) + c28=$(field "$out" CENSUS_2_8); c32=$(field "$out" CENSUS_3_2) + c35=$(field "$out" CENSUS_3_5); c355=$(field "$out" CENSUS_3_55) + c39=$(field "$out" CENSUS_3_9) + say() { [ -n "$quiet" ] || echo "$1"; } + + # The sweep is the one the window plots, and it is non-trivial. + [ "$n" -eq 4000 ] || { say "FAIL: sweep has $n points, want 4000"; bad=1; } + [ "$p1c" -ge 20 ] || { say "FAIL: only $p1c period-1 columns checked"; bad=1; } + [ "$p2c" -ge 40 ] || { say "FAIL: only $p2c period-2 columns checked"; bad=1; } + + # 1. plotted data vs closed form. + flt "$p1e < $tol" || { say "FAIL: period-1 branch is off the analytic 1-1/r by $p1e (tol $tol)"; bad=1; } + flt "$p2e < $tol" || { say "FAIL: period-2 branch is off the analytic 2-cycle by $p2e (tol $tol)"; bad=1; } + [ "$p2b" = "1" ] || { say "FAIL: a period-2 column visited only one branch point — the plotted cycle collapsed"; bad=1; } + + # 2. cascade locations vs r=3, r=1+sqrt(6), r3=3.5440903... + # 1e-3 is the detector's transient-limited floor, not slack: see + # logistic.eigs's doubling_point header. + flt "$r1e < 0.001" || { say "FAIL: measured first doubling is $r1e from r=3"; bad=1; } + flt "$r2e < 0.001" || { say "FAIL: measured second doubling is $r2e from r=1+sqrt(6)"; bad=1; } + flt "$r3e < 0.001" || { say "FAIL: measured third doubling is $r3e from r=3.5440903"; bad=1; } + # First Feigenbaum ratio is 4.7514 (delta itself, 4.66920, is the + # limit of the sequence, not this first term). + flt "$ratio > 4.6 && $ratio < 4.9" || { say "FAIL: first Feigenbaum ratio $ratio outside [4.6, 4.9] (true 4.7514)"; bad=1; } + + # 3. the period-3 tangent bifurcation straddles 1+sqrt(8). + [ "$pb" -gt 8 ] || { say "FAIL: attractor just BELOW 1+sqrt(8) has period $pb — expected chaos"; bad=1; } + [ "$pa" -eq 3 ] || { say "FAIL: attractor just ABOVE 1+sqrt(8) has period $pa — expected exactly 3"; bad=1; } + + # 4. census, one per rung. + [ "$c28" -eq 1 ] || { say "FAIL: period at r=2.8 is $c28, want 1"; bad=1; } + [ "$c32" -eq 2 ] || { say "FAIL: period at r=3.2 is $c32, want 2"; bad=1; } + [ "$c35" -eq 4 ] || { say "FAIL: period at r=3.5 is $c35, want 4"; bad=1; } + [ "$c355" -eq 8 ] || { say "FAIL: period at r=3.55 is $c355, want 8"; bad=1; } + [ "$c39" -gt 8 ] || { say "FAIL: attractor at r=3.9 has period $c39 — expected chaos"; bad=1; } + return $bad +} + +echo "--- the plotted sweep against closed-form algebra ---" +OUT=$("$EIGS" tests/bifurcation_oracle.eigs 2>&1) +echo "$OUT" | grep -q "BIF-ORACLE-OK" || { echo "FAIL: the oracle did not finish"; echo "$OUT"; exit 1; } +echo "$OUT" | grep -vE "^(TOL|DELTA|BIF-ORACLE-OK)" +check "$OUT" || exit 1 +echo "PASS: plotted branches match 1-1/r and the analytic 2-cycle to $(field "$OUT" P1_MAXERR) / $(field "$OUT" P2_MAXERR)" +echo "PASS: doublings land on r=3, r=1+sqrt(6), r=3.5440903; first Feigenbaum ratio $(field "$OUT" RATIO) (true 4.7514)" +echo "PASS: period-3 window opens exactly at r=1+sqrt(8)" + +# ---- planted faults --------------------------------------------------- +# Each runs the SAME checker over a deliberately broken tree, and must +# come back red. Fault 1 breaks the system under test, fault 2 breaks the +# reference it is measured against — a checker that only catches one of +# those is only half a checker. + +plant() { + local name="$1" needle="$2" repl="$3" + local tree="$TMP/$name" + rm -rf "$tree"; mkdir -p "$tree/tests" + cp logistic.eigs "$tree/" + cp tests/bifurcation_oracle.eigs "$tree/tests/" + python3 - "$tree/logistic.eigs" "$needle" "$repl" <<'PY' +import sys +path, needle, repl = sys.argv[1], sys.argv[2], sys.argv[3] +src = open(path).read() +if needle not in src: + raise SystemExit("planted fault no longer matches logistic.eigs: %r" % needle) +open(path, "w").write(src.replace(needle, repl)) +PY + ( cd "$tree" && "$EIGS" tests/bifurcation_oracle.eigs 2>&1 ) || true +} + +# A fault must be caught by an ASSERTION, not by the program dying — +# otherwise "the fault run failed" would also be satisfied by a syntax +# error, and the fault would validate nothing. +faulted() { + local name="$1" out="$2" + echo "$out" | grep -q "BIF-ORACLE-OK" || { + echo "FAIL: planted fault '$name' stopped the oracle instead of failing its checks:" + echo "$out" | tail -5 + exit 1 + } + if check "$out" quiet; then + echo "FAIL: planted fault '$name' passed every analytic check — the oracle can't discriminate" + exit 1 + fi +} + +echo "--- planted fault 1: the map itself is perturbed (r off by 0.1%) ---" +F1=$(plant map "x is r * x * (1.0 - x)" "x is 1.001 * r * x * (1.0 - x)") +faulted map "$F1" +echo "PASS: perturbed map is caught — branches off by $(field "$F1" P2_MAXERR), doublings off by $(field "$F1" R1_ERR) / $(field "$F1" R2_ERR) / $(field "$F1" R3_ERR), and the period-3 straddle collapses (below=$(field "$F1" P3_BELOW))" + +echo "--- planted fault 2: an analytic reference constant is moved ---" +F2=$(plant const "R2 is 1.0 + (sqrt of 6.0)" "R2 is 3.5") +faulted const "$F2" +echo "PASS: moved reference constant (R2 := 3.5) is caught — R2_ERR $(field "$F2" R2_ERR)" + +echo "PASS: bifurcation oracle green, both planted faults caught" diff --git a/tests/test_lint.sh b/tests/test_lint.sh new file mode 100755 index 0000000..0125753 --- /dev/null +++ b/tests/test_lint.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# LINT GATE — every .eigs in the repo must pass `--lint` with no issues. +# +# The suite has always been able to run `--lint`; nothing made it. That let +# three warnings sit in the tree unseen (an unused package constant, a dead +# constant in predicate_fit.eigs, and `define dot` in solve.eigs shadowing +# the builtin of the same name). Upstream's lint-walker recursion train +# (EigenScript #781-#794, v0.35.0) also means new rules — and rules that +# now see inside `unobserved:` blocks and match arms — arrive with every +# runtime bump, so this needs to be mechanical rather than remembered. +# +# A deliberate suppression is a `# lint: allow WNNN -- reason` comment on +# the offending line, which is visible in review; silence is not. +# +# The gate is validated with a planted fault: a copy of the tree with an +# obvious unused variable must be REJECTED. +set -euo pipefail + +EIGS="${EIGENSCRIPT:-eigenscript}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +TMP="$(mktemp -d)" +trap "rm -rf '$TMP'" EXIT + +# lint_tree -> 0 when every .eigs under it is clean; prints offenders. +lint_tree() { + local dir="$1" quiet="${2:-}" bad=0 f out + for f in "$dir"/*.eigs "$dir"/tests/*.eigs; do + [ -e "$f" ] || continue + out=$("$EIGS" --lint "$f" 2>&1) || true + if ! echo "$out" | grep -q "no issues found"; then + [ -n "$quiet" ] || { echo "FAIL: $f"; echo "$out" | sed 's/^/ /'; } + bad=1 + fi + done + return $bad +} + +N=$(ls -1 ./*.eigs tests/*.eigs 2>/dev/null | wc -l) +echo "--- --lint over $N .eigs files ---" +lint_tree "$ROOT" || exit 1 +echo "PASS: all $N .eigs files lint clean" + +# Planted fault: an unused variable must be caught. +echo "--- planted fault: an unused variable ---" +mkdir -p "$TMP/fault/tests" +cp ./*.eigs "$TMP/fault/" +printf 'DEAD_CONSTANT is 42\nprint of "hi"\n' > "$TMP/fault/planted.eigs" +if lint_tree "$TMP/fault" quiet; then + echo "FAIL: an unused variable passed the lint gate — the gate isn't running" + exit 1 +fi +echo "PASS: planted fault (unused variable) is caught" diff --git a/tests/test_orbit_mouse.sh b/tests/test_orbit_mouse.sh index 6506e0c..82037a7 100755 --- a/tests/test_orbit_mouse.sh +++ b/tests/test_orbit_mouse.sh @@ -69,7 +69,7 @@ grep -q "^all mouse + render-decode checks passed$" "$TMP/clean.log" || { exit 1 } -for fault in pause pan; do +for fault in pause pan mode; do echo "--- planted fault: $fault ---" run_oracle "$TMP/fault_$fault.log" --fault "$fault" --stop-on-fail grep -q "mouse-oracle failure" "$TMP/fault_$fault.log" || {