From d437056fa0334d5b26fc3261716d6cb1a4accb02 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Mon, 3 Aug 2026 01:21:14 -0500 Subject: [PATCH 1/2] Bifurcation sweep view on lib/ui's chart, behind a memory gate (rung 1, slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last deferred item of dynamics#20: the orbit lab gains a second view showing the logistic map's bifurcation diagram, built on the x-y chart widget that landed upstream in EigenScript#819 (v0.35.0). This is that widget's first production consumer; it needed no consumer-side workarounds, which closes FINDINGS F-DYN-9 and F-DYN-10. WHICH SYSTEM, AND WHY IT IS A NEW ONE. The repo's damped oscillator is linear and does not bifurcate — nor does Conway, nor the linear solvers, nor the relaxation primitive. Drawing a bifurcation diagram from any of them would be a picture of a system that has none, so the lab gains the standard system that does (logistic.eigs, a pure gfx-free core with no top-level side effects). It was chosen because its cascade is analytically known, which makes the picture checkable rather than merely plausible. THE ORACLE (tests/test_bifurcation.sh) is external, not a golden master. Against closed-form algebra, on the very sweep the window plots: * period-1 branch vs 1 - 1/r over 25 columns: max err 7.8e-16 * period-2 branch vs the analytic 2-cycle, 58 cols: max err 7.2e-16 (with both branch points required to be visited, so a collapsed column cannot pass) * doublings located by bisection: r1 3e-4 from 3, r2 1e-4 from 1+sqrt(6), r3 3.7e-5 from 3.5440903 * first Feigenbaum ratio 4.7502 (true 4.7514) * the period-3 window opens exactly across r = 1+sqrt(8) Validated with two planted faults — the map perturbed, and an analytic reference constant moved — each of which must be caught by an assertion rather than by the program dying. THE MEMORY GATE (tests/test_bif_mem.sh) is a deliverable, not a nicety, and it earned its place immediately. The first working build of this view grew 3.9 MB per frame: 258 MB at frame 30, 529 MB at 100, 859 MB at 300, then death against a ulimit cap — while every correctness oracle stayed green. The gate runs the real window at 30/100/300 frames under `ulimit -v`, in a steady-state shape and a toggle-the-view-every-frame shape, and fails on a ceiling breach (200 MB, ~1.45x the 137 MB floor the merged orbit view and the bare chart widget both sit at) or on RSS that grows with frame count (limit 25 MB against a measured spread under 1 MB). It also asserts the structure directly: one sweep, one series, 4,000 points, four markers, no matter how many times the views switch. Measured now: 136.2-137.0 MB across all six legs. Two planted faults validate it. THE CAUSE WAS NOT THE SWEEP. Bisected to a single `prev of x` inside physics.eigs's frame_velocity — a demo helper the window never calls. The compiler arms the runtime's temporal assignment history from a whole-program scan, and that history is append-only with no cap and holds a reference to every assigned value, so the chart's per-point allocations were pinned forever. Filed upstream as EigenScript#827 with a gfx-free minimal repro (a never-called `prev of` turns a flat 3.4 MB program into 140 MB), and recorded as F-DYN-13. The window uses the documented `record_history of 0` opt-out for the lifetime of a session and restores it on exit; the gate carries a planted fault that removes that call, so the workaround can be deleted the day #827 lands and the gate will still hold the line. EigenScript#828 (F-DYN-14) records the chart's per-point allocation and the vline/hline markers' ignored coordinate. Also in this change: * The view selector is a mouse-driven control, so it is covered by the mouse oracle: clicking it is verified in pixels on BOTH halves of the window (the control column decodes to the bifurcation captions, the plot area is redrawn wholesale), the diagram must be pixel-identical between frames — the visible face of "the bifurcation tick does no per-frame work" — and toggling back must restore the phase portrait pixel-for-pixel. A third planted fault ('mode', a handler that fires and switches nothing) validates it. * orbit_theme.eigs maps the chart's plot_bg/plot_grid/plot_axis/ plot_border/plot_series keys onto the same palette entries the phase portrait reads, so both surfaces are (10,12,20) in the committed screenshot rather than the toolkit's stock slate. * A lint gate (tests/test_lint.sh). `--lint` was always available and nothing ran it, which had left three warnings sitting in the tree; all are fixed (solve.eigs's `dot` shadowed the builtin and is now `vdot`). * EIGS_REF v0.34.0 -> v0.35.0, the only pin site. Preflighted: the whole pre-existing suite runs green against a real checkout of the tag before the bump. Every pre-existing oracle passes unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .devcontainer/Dockerfile | 2 +- .github/workflows/test.yml | 3 + FINDINGS.md | 74 ++++++++ README.md | 49 ++++- docs/bifurcation.png | Bin 0 -> 29955 bytes dynamics.eigs | 2 +- logistic.eigs | 208 +++++++++++++++++++++ orbit.eigs | 333 ++++++++++++++++++++++++++++++++-- orbit_theme.eigs | 14 +- predicate_fit.eigs | 1 - solve.eigs | 10 +- tests/bif_ui.eigs | 35 ++++ tests/bifurcation_oracle.eigs | 142 +++++++++++++++ tests/mouse_oracle.py | 48 ++++- tests/test_bif_mem.sh | 204 +++++++++++++++++++++ tests/test_bifurcation.sh | 155 ++++++++++++++++ tests/test_lint.sh | 54 ++++++ tests/test_orbit_mouse.sh | 2 +- 18 files changed, 1307 insertions(+), 29 deletions(-) create mode 100644 docs/bifurcation.png create mode 100644 logistic.eigs create mode 100644 tests/bif_ui.eigs create mode 100644 tests/bifurcation_oracle.eigs create mode 100755 tests/test_bif_mem.sh create mode 100755 tests/test_bifurcation.sh create mode 100755 tests/test_lint.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3009e9a..0f38f0e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -43,7 +43,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 0000000000000000000000000000000000000000..a947935ffae282f199b17fc9f333af8c40dbed71 GIT binary patch literal 29955 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 I5sCjBKmmGQk9 zoU;Wq1O}S$=U>zv2BVtmU9B~9Y4B&Qnrz(i99SX;BRg6hxm}+;-Nk^|z3ND;ukcY67RFBN8!8#31i=_46xpO|jn9QY>5O z#okP*hejJ8ny^La*gE?`s!jqKB4h~NYkE+XB46?N|8!QkAYsz!|m;hBQT^u8livR1i)8B8O4i4-Jwd~(H zu1lRWy?*WO0B+aT7R9&dgTes9`zo-RL0xd)fTCG-UqAfArH=(yP0vxy!y>28Ws%=` zk^O9pm-{zS1go2qcSkD$pm?(B)#X|JgAv%+*tRxBH)qHFJw3ROQgY#%LRVS$p`>h1 zj*j`lku;GoB)wjXw+q;1n9xUHiX=JfX9{~&J^wT_xc!aNP4wi7Bd2OrF*V%)MS7;* z=@cv}8leH5Rw#_d7x^mj+Gz`YIF7dUQ+xaA!JnK4&0B4RKkBt)Z_tO~mwpGkb$I^ybyn}b`@dAj9b?u_+Kt6FH%D)mLb5goslbSgjzRsE zl`~Y~dXA_EbPiJ*78WZqjGXd3LyJOdEjF1lZHeM~biDmz&``^8qwCk3tMiLXnzt_S z*ZD&YvK5t;Tr=2_-D+OrHqB@wWN5-KzlbAd8Rinrvt!x3czx6mS-1yWHC8hf5;#&~ z(z}zzs%qmVIP8@0()@gw2{W5Plg(^Z`Pd{Bc`T({YnMh{s#kw;wzFixWIvYmh8B?T z|KcmQy|kOG?xq)3j5A&^fx9q%2=jGGGidg-o+@tgzOn)suEI3*aKY=DSF3)I_$|5J z#@#JOG()(S4y-d&%iA0IRP$hCLt9z-+xXQxu>KvNow$T;9{9dqe5w_Wy`h=il^?LD zS|yRqkD@mxhkzF@19)mSY;bg-FOe$LJXNGkNGYK7@(CQu?0f5SwRC?s3xm<~^9L9A z-JGA^tVN#Mv^@U!J-&qkq9=MA(eKLId!L$To0e-$K~R5Kws!@!8o)|xMFT{#`;xUV1OIK&72YlkY=ZvhSmvVJHHm<|c1#=iN!Fd|Dq3X8cDf(DfdN zuu~@@|D`P26J9x{yO>7>{fpH~usLK;GiPZ=;>jzjyV3qMzK%;Nn3b2oH_orVkLc;) zhaNrHc>2Sd|8hj@_p$!uSy7D&Le@(m5pw9~AVj^jP_v(P_YI0Odb_>YNGp#sQ+9Gt z?rFKpzOHL86=u{z!@Tvg2at#D_DoN1k+diEk-bSmB&D?-$ve>32DKleWsmI9!f^eU z5VR-Zku5>_Gw@rdPjL&%idU!~sr;AXRzzCfq}P9^yftU?nicG@Q9J*L5^b~>LH~00 z$HY^tmQ%=20%R9f6uzY2T>-jP(IW_2yS+ETRgT|EFVwNIiODqg;?afEg*;lbVJv~j z9QbF{DEuc0_79>*?dwD1yrbgwJeF~=(%dki#(q6K3InCfa^oJR8zOZ>5jm9x!kZ}n znluXT_d?%#n|uA|w=@dy3>hKM1D1Kzzj>$SIe#&z#=np=g1-5P*ZO1n3NnI@)*98u z_Wb3ab*9_1hriIvR-m^}4`|5_aL|5yUFH!eSoD4<|Ais*nvfcFdCfw3{iJLC*1`kC8j3q3tOH?4l&hCN#AzXCPrI*OV)ZzA(de|L zI9F?5R8VleliM+C@e_b0kh0a(#)*lKPIsG)Ix92^C-v(cUH)R;7wb+brBMEgipme~ z@|z_<27P%xrHFz0$Ie+s(_c`r=KcFUrk5|P%F7SNyV;&U=XmFyvOZSYYVZQuGF>AU7q~U&0ajOc&|jeBdBG01Wo(?a@*>3ci$%@By^Qkb8}Au zmfWD$ZhU*x@Ff$6#|eU2t0aZ@l%Mtm6Vr9&FuFqAk*RbKQ&1vgLF9->8Z0gvfU2b2 zu#lByVrZD!)6>k$tF*S}d%IsN3>w}YE$s&9(fv8M5`*USmk1Aml-k;bGxM$ zev1)CSqfCXpye$-+(XpeI72Or*z)n3M4rRHot?}q7YlAn|90$J7t4&qn>P-&v+t+` z$z?DB+;4P~Lf&4|XA|dSWr1olGFl$rdjA8|LmZNUdXOT$k);6F{^l3{xINNtU_jL! znJTH48y+5BUG1W+U5|*@D(wT+zo_a>nlUEyKx{pL;mJaX0S_t!(eUvZhn5LinwUC!p_iE&=XsK5otSeQE?Zbd;DEp=b(8$m!SmFej8*)G1*;fAU-F7ptWv5gxO_gg2IBh^tf5ML;#!p+rfm zYcc*Bu%V7*k-WnEz9#g zTBbhc@Vca)Z@f%XFF)Vq9=>9-SnGm;YQ@ULbhi;pgz*TORpm&NY9}GlIi52pEAz>d zkA`TfbV;w;N?&=T>af-DdT-`bBJ}sl3+SWO^8<>w#LOcr++RGUq0Zyv&ov3R?J2y1 zT5zGic>Q~W>9g}Qon%x>elwXz4Ye4{mSVnuw$K$+?*GNmu?ni0;c`^h>6+UE892F9ccX+Bn55f#FMHv=3t?HT=bnd!Q!$7B?NG=j-wo36lGZ-S)1( zK#`5gjx^*V3&r9Y;vBbC84|)iBRA5%0@;fQhyp=>2-Wion26Ab@J{6&!-f1)jQp(5 zpnG~dBwY570ay0G0@dHp{;2=Ke0rsQW)OuD)Qc9Pj0V=ISQhJie_2zM3a8N*9|v|q zOsHU6pwI{#o_$TBGi&pwhq?1S?PUi3&|>Y%jD`k20PR{R9>~)o&as49+X#z%iL0I6 zgr46puC1&H+0MQPF+GRFq)h%gO}7^qE$CTfp0i z$~3OLI^8KPys@?ez>xc zm)vO+O~3B5!`^f+*bhF(tETVY$7LFGSVg589uhPErg}%fYw_bAG>bKM`b*`R!DAA8 zcG8jLTopGvxy^Xw(~5EIKGS7vj!vn7i;!b6x=enfuzkGyCF)$K%}?6(uQp9N&tJA0 zD88=u?K$1?S~x{*#M3uLZigwkO81hO@d6&m7-p^Eq^o3t$Hc_9Gv)yd@`$%DOo#{R zFfeZ7;yPN9j*d$$G+4BDcKi4=LQfx8oc#G33b&Oe1Ynk4js$wW)7ID57Z|w9&CLyi z!6q`kebayV@GHpfKGz6S6BBfd;X=dKJCLb@@F;A|&0}NEO}2)P_}w1UU0nJ5iIbV%Y`@@7`gLA#evuA-w0H}^9VeaOfw@G1-Jdu3ar71*y-#?xx zg(cqOQgBy+ULL48RJ4KJYaU)+3(biy3_Ncx7FhM0c1UAlV%Q8@%r*z|llk2$s;cz< z6YG1d{E;~4esA3F&)d?n5a8cJ&SCaAb@*_pHFbT=4|31%_7feSnwnA|lHh+M5z|EP z00;KxPYo|`^yD`B-LG}dQo)Ok*IKhu?h$K|cXu~$W3GQrc$te}dm~&N=+L{{%ffu^2Y~3oqePUOeEH8}q!!8Y z`ZZuWD?QIW;hU`>YwRuEXM!YcGgA@3^yBw$hi&C2v@ZZv^!BaQ>fds}E&ik1G(l&L zg);}=ox6nma^``0t(fs@<^5kiklCtq!)?I)L-5k`7`+XtwkeQje zu%LjFa<9{rl9JM(nJLroy(J9vBqR}mKfv&^b8_;VX{&)I4lGITV{)MEn~*T~ZT$Q> zHZTypHGD6DDI?ea&hZ7qJ9iz6ZBK!g95(=AXB~pah@=8jM&-SEmE$!?M4i-gKR+^fSa5y&hub2!MfE9{l}Q%1(6Se>)eVJYG3 zyvWvj;4TUxVhB4w+WrihitNyqYbFq|f2Bu4sv8J@ATRzI7?9t`!=vJR_iCpBP~IjU zUqlEfy#AqCYHF%~dFNv8YLx@kR6|QkN~(JxnZHntaTx4uP_LX^T)sy{5Rx99z~2F^ z2PVz=aM2d@P*M?;M70GV=LDeRl~q)nfWJeLal&`+N_+WHf3i>DL+-_WJrgssJ%u^j zl8y10u&@*CKB?RRzJtX=1{1W{5bpgmjJoP?r)voPx$e+r)tcz%5#Uh zdie2QLQe=eCKUE;xxHum&GRMuHwBfRg&fH4yek-b`6UE+Ta=`t<4Es6G^KtC)4okp z&|9R>t|x!?mRRJH02x?zmiaxEeVL(V$VyEIKN>IJd0u}f=n3)okys$4Yw&$QYD|fM zUwhP`u&sBh%VeUKWd;PIEKi3xr#L;dZYzS0FyyCjc4Lh3&awNKqp+h$5+k>}1HK#Y z9(Ys`GtEZ-2$NAzOjXO&Xwb%KLp=R_WR1A-H$-87#3`tL&R3dHL?7W>Qvjp9ywB!u zbcZ)$ePsVo-&xs?CTv9QN88IG`?SRW*!GQn3+if*?`e*nImN>cQ*(1AA9Kzy-vBIV zP#vK2J zTg#9cWMp{)iYkc^wFOLXg7Y=rYefCgFmW5=^Sx)Sq=zPT!%bQck9{B4-Wi4bkrSLp zT2S)nv7Z|58yv3sZ$HM!>+1U-v~i&mA-hn7<mt3 z(NWb?B`0weS;BjR3ltNpB;04CIL+=y&umv(wc-*J-S)Og z8mztpO(?nXVz*Hxkf6f5Q__d0{l_ze3%2l^k1wk{*{N}LfZ||fwRwdTdvm_8&~vlA zb?k2Fy&`OcSO$Fo(DcH>Z_oD*eN{p|&$juDuMsu2$+EKjLn+Ni{cO2k#nLlQz_6zZ zwcNyd@`i&6#c~wx`PkS}3%ZwlMr& zyMV#%^}XDW??ex7E)Og1ZX=SC1l%t1HD4R5cWxOrxew0HTEY4bFAjx~H#ENd(>&zd zHff=9bBs!k#4$@%9&VYEcpHz-4O^Uj7U#)?gpxs%#Sr*GTl?bpbfLU9f$Ff@3hX)*iP~&bugE9;B0V zP>TMpudfddo@+y)(MRlJpND}7U~8qU_wMW(80ZfCfq_2;+GU!P8NX1{(wR9P0H*4o zYY`&P+>a}^mm{Ti`p0c9_ZvgzYNIi5x1}EXE(tZm;ekAMi!rao)toelg_)Uit)~>p z$RZnDkNI5Sm*vL7_Vd%}85v^>&9^PL!PTdB4nTW?Os7}Agtb3K50Y7j&(G}sx%vP< zuC}hdJh+YboUgI9W@hfk+ZD&86`+p1_WiUXC)N{6Dp70C7`(6JcD!2cb*UB|t%l}r zm2h&#igYnO&Zq}G+k-D3+x(zfCG()fM!gm>JiWci2jP*BN(J=z?%&>}2G`w*>d>iO z9{XLm)jtk)OYkikXdje4yEz~vJ8vS1J9DAN#OWp6z(jC+eGXP#wfphBkdLQ9y<-4@ zQP&!wn7==%kk7TA6>*gG+0qY}#YHO6T)*|1MMy4jBJE^eDtLH;5=4ys)d%Sv{Zq}` zn~zPqL8k!XGk8gp^$-o$a;l^{Jp4RUYUc7V*JXb;A9WXZJaeodn%z0LBhJb=Liv z+8O=|h49T@bWhJ3=Z3ZQZXWWthQ5CEkIzmZDz4#s02_g>R{;z~PF3|Se5&+vBQEMy zKABkh(cNv8eQUD5!NqLWqaz|vLekR?m3CW53%j9}vbp<~mP7+M2nZ6brFi8i+?Brc-5cce#W`7bR`=%4cxm)7=+TVZP7LLI{4Q+>MlL zNB?G_RDeT-MUQp~(UGEsPlITD-o!jszvvStSA4Agyft2L1#&<6K}B2JMIIn~jZ9MtMx+L8Ax#zhTOUg@n)t3V0)?Vbu$6a+}qzRvMZHcXKnR=0ivs zOcnJM0>{)J4b z7DdFP@yg1|e`O1x8B}9)^U_avKxq+er4i{qd*>OI7=5s}n!gtA@6ay;@VuUglANw;J%uBqLCT^suROuhPa(yc@ksQe8M*LCwvhkIA>)=dhbTN%H zyC?a@`72Cl$u2Nzx2~lbO3piM{b4rR$ff)TVx(jYe5mA;Yz_*ZXbqIs^c#JZY$}C( z+$KPxLojX>u<6tckAeceiyS$TKDr2a;r0vC{-Bk_S3k>W2|jxmmS|dru!9n_dIA-yZQ4I zRCh-+hgmt@DYGMm{=tFIor@IxGUuZo=hWeoG=MrT;RI;x}Kl%*5y8ITstF@J9IF z>Z=ez*xw8FhMVZkxefu#b*qDPw=tM<~ntf{vqfsPqUOL|!{Jj4*3=E2l%cs86%>gZB9|`_0kpMSv zIN2uBDH>(}izRTfF0Y#z>P4zuZTnUi78c6Mh9q)Y&MpovEVh3kFtW3|8*guJ7TR0e zTU9+f$(~RZlO9``yS+Q_?I-Giv){KMI*BZ?`eq;TVWPSXv{%>0&W8f2#C+b>St=`9f#`;uU zU(v4b&((^hkJOqU0`FrWLAtWVC(ElySqZx2)y9j;esk_Gg+@YQ|L~OxnvsdOZB68&j9h z^V6a1qL|R}uZSs9g+-@LK`quZhx+^dRU4d8gO+Kc7B!@~jKlHxJf&DqW-!?V$4@yd z+KDs4IdnW#8iBn0eA>XHD%R5w=ol8sy%uLJ204iCAcs6NpZ!uy-Ts( zE$Y;302!eMf~qLd{m+LYzaK4QaJU6yz5h}klwyz7N zR3^v2?=l4hn#WZSzLPH0`|M94o?hrOrsro? zYVJ;@#WxO)!5>DLw=h6`fU&2e(fe6l0aK4 z7PKVjFjzrhdOTN{_f=K*m(5Ri;$D)1a^mWkCmR(XDEy${oDfhuWaXN>x3sbaq^;(L zB;Fz#T~GJNs--;Px6uw#5_E(&gPcLW?#a0K^gUX1_Q=$0LZ0b}Cz<$i-pcpjxDxQU zkomSG%Z501+9#;b_C~n(cco7R=@GNBj4t2Cdb_A9sGb{ZOo-)U)7QQ`r)szT;NIvKLMq z%EH1D9Elt&4%o^Jwk)b4zzvX5xSMKsu;4l<72lwb#V2@~9F<#@HDA4r<7D1NM+Br4 zCpS0UW=}u|3RisC-yY4ZsGz3dd!lh%VsvDVDlXp2#>@=NS|p)~5Y!B@qgHQ~k=!N5 zc(VM(WFP9jtVZegRTEPfs7Bb)_toV*na+|avP8EZ?0*hDZJ!dS|L^VxH)Ne8kG;Cd zaLVl}u#zV|!cLg1cVa|q?Ny~kI60*_@4#;k=?ha#OwDp~Iqk;IDruibN>Xiaj_;nf z9m8P$XA?J(m2a!2G})iz5Zo6jUkiPPhpRTek+q{s+j}6y2PJZDst2qlV1|N&N=gBx z*D^U@-_BtsAJh2qo%`ZKqfCfqRZR``yCY+M{zf3%<cK6 zbe-;u4rxDZOzCO4e+{K88YVzv0J5gnnT7mh=4(0Tep+#KV9B^1_WcbS*fbtY=!md{ zF+}evWMnXVO+%1KzeCgV)ogwA;NAg0{DeRrSPHx@_Ohr;wJVJV6NrzGTYNdVl=1P7?5$H&LUKH|N-ms>-(LFZQOBD9Dvy4(EKdQVZ=-z7*r9}GcHZd481 zsfLUIleW~WWYm}1@)UJMs1bqXwlGeGij_0Gxu4E`DeXxz2VCTX7V}B^$R!NdsPM)Xd5^kiQV3*%ljiNoU9h zkW^94OpGBp9GrtkW&w32gnr!55bewHJpvGMOJTPR^+P6W*Qg_gkW#}~jOxxS6s=_+&v!J6h(h?O- z2UbeJ%>zahIG9@QJfqxqy25p(!k$hxx`AH9cl@(>cck@M%-vCk!RgkptE-)so*w9H zb~=J1vA>G_-)c@c1^=(uShY){pPu?(zp`DQpFR%^qY(}wcxyFL=LhixG(KpENHNg@ ze;7h}(^qiNV!%8)j}D+sJg<4Ic;O~dGu4|zcqwm;3+Ax{0=9s7^VbLmxV&m@=gN$w5P*Co=W%_?#nd*q9}tXRN%A^w zZFf%a<#yqpKe3{seWl#A=(r<;p1(2D-n3*B@f6iZ6dY4$i}cx_V%IDVkl zv+5zdhEq`Ab(L~pLjCSNQnA0>dD;cPRpo!_iN&G8Q?O$9MK<6boii>fIhp11s@}=` zd}ocvdQw0rwG@UfjYoa9yH#s3IyGTzb(EHtb~IZ}5&UQ$P)x{VewkYPC1B?*)~&55 zE0b0lPLuslZ6#9XTZjSa*X}ii6am^7FU}ed#q{)(Yi+cr(nL|=>?qq1e;Pks&S553 zbUYF{SQm~%Ag z$kV?F?M>n}WG>b`*nQ0W>eb+YxBDH^@SkoI_FT%yKOmPD;R3vB&oFYw6y?g_8zzzy zJ#%L7R3gmYU++*n^DYHKvD)vqKg{-{1qP+Yg{3TN2b7Qbd zfH}ADKU9#oR6`L=;EZ*1JNyg|1W0fJz>^Sv{MUD}^}cOvOclu(DHSLdrD^!!pG5VP zdJ?17Wn=@kVfO{4v*{NZM~-rSYElvVxy!)+Xg_8nz%(*e8cG%BXJacZJCaZyaR%}e zXAEEtx2H$JA%KL0-Af>w;8V|YSd91u23pg2FTedS&7zPLP(zT+4x_z%ed5c=3hQ&{ z_tg*oyIdaxyMS*rnhz_&j}y3J0Z%?KT65bk?t#m(zkVSv=IVWJwprOBdms!w?a42m zVGr?-9MAI!8jU#yc&YrFsU(#a9B;gAttwB~tvx^>5U*GmJcv==q$|6x{>=y0bQwVq z4b5VmS}PbIe3;8x^BZYzO9>L`zcX$wCj?j`LE06*=q^+M+#z~@`{Hc|-stJ86gpRG z(Wbb6s^fnH(endGG4OXXuq5mgS{5~C_E(J`{!5X|ocX6X6A{eololGm#^b|-5(h^4 znUIH#vB=_3l*fx$_7Zvq-4THV{JTSBVyq|3>IvDZj8BmbDGENr!*={O{e<-&qk-}1 zwKRU9l0(9_>z0NGr#Hbp0UswQ}BsC#n==10|!Vd7OX6fSRbxhXI;eVfOkaGrYGNizzdo+&|4PPM zw=v9)wg&mtD`{h5%enOoZt-Lfw&w@9v{x z*`dHg>^EiFv602K&v9E7zlz~YL zYy|1)yBu%dnoePZ+(6x;l#TZ$ADHi9U;w3ttutkxIzZyQJyU!Cartj7o8e=zphr}D z8!!hX!m`48^z{R~e+w-l$fGNnVrAPb6rJjRW@bipSOA)|zqU37PC|(Wpcernbw~)1 zVt4FZh6$prlu`r~1l>Zh+r&T{6;dI9Js|^X+HyMYwJBJXDIMFTf#8}!SM5B?2y)M`V-Q2 z3<_h5T=&&J?Y=>kujvy%efAOtz7(SG>jI@+%EHlB^e_+;{l=R3czi5cQZ@sC`iR(O z>z(9WT)13@3;;C9kZ993yS4)#b3v%osAfs*I`=FEHP4uDv82TyN7Ht;s>XVXwT7D; z6n*(P_Q~PWsnXWF9GI2)V1mO&U+St6I6-1-YXgd1u@3PV2p^F{F6R>8XFT9=0H^Ve z;%PN6aKqLvoD%>(#T*@d1Ilw#v!J!Lb*ez`)x{h=7S>7bl6B^6rc@-xLxszWi}llo z`T0B1jKc#y*YeT(|2$`eql9cJp^OsQA=!?Ram<4v zMRp>4&+NUGY&tTs$;fu>y}s9}&*%MFy&sR?uZMq(&U)Ri>%Ok%x&xXWPab%uek~$r z;J(^gNJmkA*Y92nSa{j?eUIrJ3+9S|e2-TIlChP)`$xy zzcJ!>Ajq#P%wdWh|B-9NV2wV%@&g-YG8*SQ@VpMVG8{)*N ztjZg|JeAV~?8}&C7eY#7TjyblVQ~at9tag~3}2so4mRQbw`<3iPi!6!Pd?snhQN8X z%UA@6wYrzSe=^==%glJW5oUm?`omO=i55w5DEzM>`Yiju_r|yOjg5^3To2~)@OOl6 z-r(fq1VM_}rHdqlo)UD$&0X-O*2KT59k4M6*3Y~bR$a?BMY*^<-95rj(t`zEgQUNvX zAuJ_@=x4yU8H06OSy@?Z-$r#{fEP6X2$Db=CKVy4E+L}WT|9hps?)g{v9aynzb8xi zW5F-Ayqr?|`C{q;7Vhb~IvD)uC~jneIyRgDv^%-s9}h;oDkm{93-g0nB_$JI-gJ7A zG5}cv%A5P|ukNjNx`Ubs1VFU>cC1`nDcCQ!E5&JP=R{rhu3x`SOsoxEn-t+WG4x5s zXW#{*6S6Bo?;G5`%fdK;9ZH;-AfOX+G}@xMbp1=eG$7^mT zldxWYKCu)fJdtI0Q&G-(S^1B1J5JG}zhMsbn>l#ti>s4%e`TdS8wT zRy9ip)~Ekon6Waj=3nSZe52x0X8|XIiAfv z`&WGr2~qYSb8>QOiaQWHDB(u-Gm-j}^z{iu2|9qDD)ULDdmZ0PP55|4f-A+dnbHcz z8^sIUpROd&Z28K8JjR9<2$`_3^XJa-T8*h%EfxRAK{VK^;OWI;*`kU~@8?^UJ`&*ttV^frj5~Isr3|as%0*_S>hLrhRQ-bJw3OS zf#>}tUh1EpHw*Vg@2|r$bQX4a-ag)-P*GV4gFibNVSIwC?snlPF}U5=6uvRO+S}MP(S)l{xJj zdmG~zd%c482!Vrx?K@{so=eznCUs?~C}25XaC+^WVEkXVpx9ncc3D|{XTBa9jmP+9 zaBy&_5+~k1+x+N4+VBrFnTEW{fx;39Roc`B_mpaA& zH*iRC8Yr>Bpa?SQ(o&43WZoHh@>V|$1MfQ(tY%m^C!8JY}%E=cQ^7BdA~3VM6Axq@62St+2koBi_Uks^lp=nK&8 zztP6M(l0*XAi1D?Q>I=|W1B~Z>JvenD*w!u=_ju5GcR3on(%~L(jto+?xpG_bSR@C z5I|Fca`8N&P99D~aO?hz{k^K_~`@0}YaNN1U z1-^m#KI}aE8+?|FTAA}VPl_Lj=TNv>>}H7S3d3=Yr7ytqvbcB zDc2GwfS2F9hCZiNo3tPkcyE8Rdc*v9%kygVg}9exW%Z`8P`M{d z3y5+ChQqmU3D;nUfPt)`9CcRgU~3h&lQBn_C6I$9W&g4FL=3FhSDYZP_dCba3_X&V zm>An^Z&Pqe-8vNoMSrjA=FE6#VBk3ro+d{r>1t@u_Q}on>$@32pycYb^Te-C{@;YL(8sqwk=z$+<2j1&LaSp8s-BzcuZe$RY&4PEF5&#Cpv$& zsDLc4%xTkH$j*$OZ$2MvXkH#4*T4dzTRRI#=;XwNA~Wdp{$xP+2ml;11}&)5mv^jT=Fhe@8G_|j+Jnu_p>-PvNEL^W~ zXBg<~(<=S^S?lPn47i;2FW1XgQgdyw<3Ffnz~Bq7N2?nB4IEtTbJx}-LidupQ z(61PE*=hGKXidmE;w7!EmZpA+P=z}0Ze(`FfTp3&H95ubH3J9&Zwm6|7ZxHr>zn%f z1%;i3)*%uB?6%w6$HszS@Cfo1aN0QERv}y(k(!!%!Q;x-O()utd*|UUK*u_6NTQ!;Y6b;Z4t>qig<;8;rVm8#SBlTT1YYGqI(PywuS1Vg93~6cV`*#LVbJ<;nwsP|)%wNa^(#Av2#S zr^rVwudRg%3)Bw|3X2}}q^9b1t%64SDm#0Gfc0#;G9x(PVa07M9=;ipyYr`gLg%qq z&)*xgYhKeZJbolK6%?<@q5SpYq>DaFN~^Kr8&ydq!+|%X)LEST)gR{Xriw_5WB$#T zEkL4jjE_RL$_&JN)64iZVyp}2c?9VELT=fH+jeI?wqtlfTzh~hgV6&q?qx$j2K77a zBFiml?YmqLz4yNR0K_w0exe32bGH-Ugcp5Zb@iK-xPcJwVy^jP zx!k_NXXa)SHh;=e_kva>a>j}ZX1udqF6L|92%KXA@gR73(J-6sqz-}%#)$ye<8jXf zXZbG#b|U%#q(OAEmbv+}cp(bhGm+~RWNAqT8mg*S)$*1)6$l`20+6fX++3h-k}1Jm z{$FmfVb{amAu8>+Odb{JeHLbBBxF=zqJKX0%>4NAJ?H*rje88NHYO%boHDGULUV=@ z--NuVh!4Cu{5(89{$g~Ll#CQaCm3%Gc)#uqiP6?EHtx#^%`PqNV{nZe$D!ZkTNW32@a6744*fUebLf~K9MD&dIP>&0q|CK`u&#gmK}k)GQ4P}`_Y#bt0|Nss z$ePB$+Rjb}zw-hH-{KF!n&!`N$h{EFhFXUBu7! zx`1*#46@2{C@`I|Ei@l${`~o(h{(&G{H!c2#J&U_16MKg;ocAY zP%ydJJE8K5ExwE|6EZ@W1oUF_6J2@e{T*;eD*>#ywWRyrJ`=2rQ6f(A4{5(>T=q!( zXNAm6O?^>yx}tSDf2jZmP49&@jC*<_%N^Hu zVDt0wx$hw{+7=Ny+s%_gDU->%`}X3+z{tz+&hja#aqEj_Y66ySlpU9UUL6 zhkf{9JKcUz?>ivd|Kn%(!xbwlD_l6v>ZG4{(Xo0whRcZdz{np4tRhY%*x1H1ghz98 zFHd1&qC!JeRJSg&8kK>0N7D2D{}w@(Cts#k0b&wCVLuvTR-RPY3qa=rX&64CBy@uI za1+KjUoU`FU!)F-|3}HPZ+(1FC{%=??bBz^DE5nfC83FvNKVbC`3{X^KNj3cKUHR- z{J*gAt%tA){V{Z+!luD*>2R-M=f@|)^l0dOlvR4TjsKKH(SdY7jVlz-?_q|f2ydSY zj)e>cXgD*WKeY>-kFvBFDkG=9d24S9957$}x1A3FGlNQhn{f13$izsQt(W>8u~J}; zHL>4~iu>W4OKi~zz)`RBcOFWq!{ydmGX(=#o|L{)FBUc$4jbHeP+oI5qx~G>QYHd33V)C z7p!5aon^PK2n4FWqmu`KG!b!(49dj<$~Un$St*Qh(`h&G&!3pT{>-ydt0+ofq& zqrt5T;U!S&b5yeF7l)Piw#G3H{tYI3PhOa`t_{IHBMA9jA$tlVZMt&LS48gLCst4( zm5}zQ2fIs))7rE=$LzP-DeMr_tQAl_(3nUaf2)?G^KEJLHefCHL>MS;ASa{`^;;gV zEsEI>m9>GfnO@jFwnH)q(A~&)HbXEy&Ta#N1cUvJ9){`7opX>P3%}=T>G$T(#-1UF2qZ_7iHN|M~4@f3dHmG!xYhs~g~X zg`3=9>eR7%+?6DWI>uug=vrD#^lrCe@Q63joEa(TIoK|Z$H7XKklw_K!n-as>5d2N z40b%|Y{Vecukos%HU&B+P7uKyYI4N!NtozYL9uE2C~ zNs1&zJCRL8V_;!`f4DNj=7)IV?;Iz)#o>scppD74fl4P*XvzI(`Cs+N$4kDuADR^@ z;*NHmQ$(3Hj!8#2Nnp^w!{u=L_H!?%z zySVEFX53ARu!oy*1y^VQd9}YDn~9@+c`2)X>oI@~W=BDfZ9y zb6az(JA3{2Rxz_)L)0S6 z842u&0&=*bw758Q3sHU1Syb%$b+()KJBBf*@?Zu48$Squ48BI*tCseTZ*o}}D9=(~ zAAt+@#&D7xq`?^p33{oWr$F9MMscGJ1|C7dSLoB0T*>??$89Q^doR!I=hwKveFJMaSaiR zHlHDt!WyKYmaj#RoIH0=2=4~2Q}yd`Lfn@-ew3R8DrfuYrlb4XsZzGzr8E$-xT;>z z@?fV;mQ&=Y&QV51Y_ZE{?^Xm|EM z=W>Ol&tOjc?2)Ti|MofLKVdNb-hy^36UOOFUJM)IVy;E`*5zbyi}fU}LK` zQI@Voqdfwa#~a8bfbI#6*nO8i<->LN>j&~1_Diq9hdleicJ8s7hK9@T!kMNxv?TB5 z%<5>puj#>hwgLph!T3VX;3~|wF##MNh%txuCR?}F?=N!TG&^nwGn*@-j>7L5{sYnt zfKVRL6O9Ecym}uR%Fb~fTyxN&U)3lYTOK!q1|xzm6S{6;6!yj6qQ~#!SJ)qa%!PF> z)a43n|A@;b680K!mNR*%YG`aOj#$)r^D5R+bVo)yV>&uMcXnQ4X3muIH~;zbC-m$o zk7uCXL;l&->WG@RzdX>fci>Y#xp^`*$Yk-ZQC^ngZ=6@*Sm`}a@UAAye#miH^@GwR z8WR&1=DcS=4}J9sMiLSdaB)C|1?lOF7dRA{UbHp-io!VP3<1174~_KtR0D%6h-y*W zE6PpRZ}6%-eysmYcx`<_o^WP(XliS@1Ssws$o?4exdV>1`4N76b(>FpH;_R#awYW^HZpBi9vsKmtN>%7iPdtQB?(^+4cZ z0JEQx?||Lx*bzLico z^fa~h2v)>#>s@GdR%WK0T-}iOIzBGcn8o&*E0SRm?ldhJ%})H_cbVu4$K)mG$=bgC7_6o2~?@ z&AhxkaHN`;D%&sAT!g#8(nqN#<6U$3JSFw_##{38fPqhUrWAt-`OO_@72s=hbT>X8 zeUfl#B;Qa>Zo9uQQQAt`@69WB*C3Khs#cY0Fb`^et_Pd z0&N5dDa;=BPEMX4S7+MeDgm1>FV}zuAzBQRa^+W=+$eVVQ+>TUw&-iL$kbF1JGbQo zF4&2J=tHr`-l6V+ZmlS=)q&ufMVr8X1aB+u^|X%@Amn2g`{rVW{$0 z{AdQQ@r=iw6jT5YYYtQ&UY3wnSAP|uvD@ChI8bP+p}xb$iB;0n6qa-N&nUQiD>kC{ zVEmGiNdt9zM^cNj$%&u90m(-RFAmFH^nf**l9=e|VEY9RE%J{vh6#N8)iP=R%xh22 zx*tHQ{n`+b&K+OUjDIFE@SC4~7zHu*WQSMI?%g9-F;~yvUsUk@QH-;Sszd!vs?J4x z)cMO2dh<@VgI)NP$gi|#1j^WgMJ%}T=Pp$$2C*S~p$e~rw>mPsfibkgO=hB}QXrF)Q2K?8& ze`|<*vHEZy3kCCx1n3xogMy0mTl`>xN=>&H9^P8k_M_j&r7z{@MVk$k%?(%XcCCWP znU2Tc9c;8{G+L=EH~6vwJ2K1>dDCvb9g>{T2ivUcQ?E|NL0bVjgR_;2>;1>WYeRMs zH5X*$*B-4K2y2!YXa6pY@=-erP({CfShWRtDP)1hQu>}35Ej`VZ4A_-|1;>C>l<4jhFg8l(N- zL5UOUORbk$S3D@NZhOT;?oH@lEL@r`%%4E&xAm1qWn>UDLd`RQTr{|WIkoNq04aUH zMYY(Rhw;mAycEI%q~+wMR=43Mht?J_j7XVPCFINWY^C5L_g6T3cI@BKWU-VGdi2@{ za!P+ApfKNW{T)a!Kll~oOIK#1yI4S+_CGas-(h3E6i|aJCei7?-hT1#w|`tFn0Dp) zXifE>DKZEpB?M9{az&V)>#Es6cSa8oYyc=;q#%M=1EuU}fx_464-C%oNHm6B3x+H? zvuG1!fJ*P%$q;-?Dbn9eY>uy}bb?yj^n!2ksCP}eFn z`E~jg6@rw%@KAWKrr+YXM{BC z+2sG*Mt=!{s;eAVCvmy6;0)Z|VECw-E2{~c4Rj)WFvOEb8dJW0U7)#P#I09PfkN?ztK1vm zYiB#Tj_>wq{BwI{I$XBF!G5n-=#ea~fsPJfKvKrW#?mDJfT(%|{0;V?IRM%|7~TFe zAoCFh0^*FAC~=Dkh1`7hKnWl%gX4(f44vQM%rSJhND=Ky{ze#tW~1oh?RpPR~M^I1|Fh(Q^putYp}@ZisPgmLSMANsY$muu2S6#GS#Q}NdR-+a z3&QE+e#`g>vUqGHLJr8+Kh{0M+}zyZB4lmaqiPr6jv&dwyf?=^Vt~LS08)|-TEi;z z8nAfyuaLbt`qS+SX^uwbIpsQ^&O;M*>z20fIi5F|Dd6)C#rlm|KP%Jz zS9h26TMi)XJQAuc4>Fz;gi9&SyIVH;Rc|0}7;LPOQ+RwL)nxfCCvemIUhc1aUlOi9hY8o&9XwL;sb z92Gsb7+k^9hnTI9gZkN4;QIkWNDE&7w8j0;z+R{+mvBLjpg}_<%kziM;r$@|wFv7` z1JMBi$~)#qrWp*@amQzo!K98D<_V7mt{?s+OiKCrEuqAmJkCz_5{HLQd)-{gERyXuya_-bP13c@JSl*>Q5R;l%^fCmJ zwmux8JcyS*HTP)(PVW+_ArwoMtK|a0DMDPVeTab9=%5g~L`3_iH8jqvia=cO2*{*! zI##@_W0lEE=jgmJ_{P!fuDy?zPz?59rtV4YAd=R&>p{0n(NU#{K{$G&S3as-wxJUX-1Wvfe4J+UAt5W2?6JyoVIjZf>`2{n-y^oT+@b5ho># zK***o>wVZe0i-9xPx{ZSOlNK728Xl}-%2u9jHzOdl~@8chK*yLK1MraH2O2AlJe72c$+DAcb*LEej#+Tscp!bpB)c&Aw*Q#P!PmhqW%D61lR?Pr$?eHG zg8u2TOGRqGDWo0bTEQKWuIsRCuKc;It&fzDQA23Mkuf!ok7rSTaVvFfz4#ozgU8V# z%X8lIwlbxGfd~yR z@A&co+Z96B_nZ`0TnSG4{PxBjuG0?f0=)aoD4)+MM&G_`44V% zi6!{%Hpr=?52wxPtH*pOIcbNSFLubcNo#9$jeJ=Jn6CU>F;;F=YAzT@!=N-ep*EbhM+1}m>=V)!M zGQv7@FY4};uM`*Z24WaQepc_sd*+wa6y}AxQix@L%xH5~K_^tCTG!UoR8f*B#x=x5 ztb&ht#Fa~Jor#@?M?}&I2}kr*+HYYCx2z26=8`EN^G&X^&6!|ACt|poRBSm4BLW|- z%kPzB+T!2mStYR0Zlbgu%jcn>Vw)&`SWF- znZ;%%6xGW1*|7!-Q`2xY)wF%KoA(RV_EY5Pg=|;C-iJT^*k52p626Lrm89NWNuDcp z=ceq_Eee-p--sLglOrSK642z{4n5S!Mjqu`-|eZ9A0FD6ABJPts5u~RaoaHS!B(}T z2iZl#cs&0AU-~Nk79WEYEso4dV#x_j4CACQb8WBi=itrO!*qU6Z6nPL?Ku#K!0Bsc zKpVR2u@qa|wY53KFH*WE8;N!b6=(0$FeEe-;myJMIz8DIx)(2Vm-BCpoZFx;^1YX_ zEh~sM`M6$Gl&o91GbW~?(I=X2&rv+lnT|T5t2On?H%8_pKAA+gx~hP>Y6Hb&1%=XS zi9s{k?%-ah)Uzk_d7{Y_FQlfPCKBSyaXmN`aU#{&C9t?3QXw(^f_Q8Cwe+1?4_nZd z?wAwi+{GI97e-h#Hp?X(xh%b|Q)B7Un{rs_Q4J$H-EvML!@vr@#;T*%eE3__^-o+} z&r_!ecx}siO;r;dDcLe5$3G3I;D_HVEHu|KePSVzv9W-hrwE}SoO`N0(Q34SooXMf zuZS*3CRk_*o|b4fISSY4)eTdmz5&*xb4jS@YmjLrg;HU0VLo3dcJMK=rxlmAVS(MI zk#lf@7{DyPKt+<|t@b!kpHe?eIIXk-0-xL*DKpV^CRt+4P57_-s#m1pl`7{?! zd{_$9F0);7UgMVY=Q#Sfz}n6wIuv@3wIMRP?aG^&%3BFs8fdex<53)6_0EjWX4-z_ zE>B95D+y(&&f<)luLDiW4NuZZ5oZ+*p6n>Y;GM@%Nah{h-y%#q`{uETeE7)Mxt8}? zN!>3>s01K9$VH)f< z_X8aFehd%whSdh$U!W0S%lrhflvs<8AnrErz@HuE@>W_)Qly#wrK>R2LXD8(i!uPi zp%HL6-~4lR$DAZbe3l0}m^fp2`!2n&p!g|#B^Bb1M~h-#o7+)5?#IT?wSBUtyq6^E z<}R^hsx=lx`vzqWN&Gb|eV8?)E<6e8=N*|7i5iY36h=K&A_EXhkFe8u&HyJ?p1^Hz zu-)SJ2Q=)IxG*UD8;AX%j7F@9&&-=5Bya4k;QrQa_(3IqWDkLC4(}ZtaNbUuK7yM- zuDR;G4si!)j=GezzZ|tX)Azk^y|^=%l*dYv?#KDJ+b{hXw?1vDsj7P-wc$*qOgJ(I zG0niT>Avg~zI!x{6d{g#4MuR5WeV(O_5RyEFn-*{HjR7roH_W8Vz03oB@>-KS<6L> z+nDFtX9zMCXNW;hifHFtHgh@A7`$+o}W=EOs ze)88-LLkCxEv{JN!5ED|$P&UMA!e+YXoLfIdZ$*S zZ1pxel_3gudgmn;5=Y#rp&n##(r4F5Ne%^koYziEiSpJTif&yQ+k%5W5ebYAs)vV& YFI~f1K2t^1@DqfLr2L&M37r@J2f!RIga7~l literal 0 HcmV?d00001 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..b9278e4 --- /dev/null +++ b/tests/test_bif_mem.sh @@ -0,0 +1,204 @@ +#!/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 + +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 +} + +# 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 [ "$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" || { From 6211abf8b3622c1a12163deb947e4d1c8a727448 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Mon, 3 Aug 2026 01:31:49 -0500 Subject: [PATCH 2/2] Memory gate: install GNU time, and stop blaming the program for a missing ruler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed the gate on a clean tree: `/usr/bin/time` is a separate Debian package from the shell builtin and was not in the devcontainer, so every leg exited 127 in 10 ms. That exposed a real weakness in the gate itself — it reported "a run that dies under the cap is the runaway this gate exists to catch", i.e. it blamed the program under test for the absence of the instrument measuring it. A memory gate that cannot tell "leaked" from "could not measure" is not a gate. * `time` added to the devcontainer image. * A hard precondition on /usr/bin/time, failing LOUDLY (exit 1, not the exit-2 skip convention): a memory gate that quietly does not measure is worse than no gate. Validated by running a copy pointed at a nonexistent binary — it exits 1 with the explicit message. * `measure` now distinguishes a zero/unparseable reading (MEASURED=0, "the instrument failed, not necessarily the program") from a real nonzero exit under the cap. Gate re-run locally after the change: 136.2-136.8 MB across all six legs, growth -256 KB / +128 KB, both planted faults still caught. Co-Authored-By: Claude Opus 5 (1M context) --- .devcontainer/Dockerfile | 1 + tests/test_bif_mem.sh | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0f38f0e..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 \ diff --git a/tests/test_bif_mem.sh b/tests/test_bif_mem.sh index b9278e4..8de23bb 100755 --- a/tests/test_bif_mem.sh +++ b/tests/test_bif_mem.sh @@ -58,6 +58,17 @@ 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"] @@ -89,6 +100,10 @@ measure() { 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 @@ -111,7 +126,10 @@ run_gate() { 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 [ "$RC" -ne 0 ]; then + 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