Bifurcation sweep view on lib/ui's chart, behind a memory gate (rung 1, slice 3) - #23
Merged
Merged
Conversation
…1, slice 3)
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) <noreply@anthropic.com>
…sing ruler
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
dynamics#20 rung 1 — the bifurcation sweep view, the last deferred item. Closes #20.
The orbit lab gains a second view (press
b, or the toggle at the foot of the control column) showing the logistic map's bifurcation diagram, built on the x-ychartwidget that landed upstream in EigenScript#819 / v0.35.0. This is that widget's first production consumer, and it needed no consumer-side workarounds — which closes FINDINGS F-DYN-9 (no x-y plot widget) and F-DYN-10 (viz surfaces don't read theme keys).Why a new system, and not the oscillator
The repo's flagship specimen is linear.
x'' = -x - 2ζx'has one attractor for every ζ > 0 and no branching of the attractor set at all; Conway is discrete-state, the solvers are linear fixed-point iterations, and the relaxation primitive is a geometric decay. A bifurcation diagram drawn from any of them would be a picture of a system that has none. So the lab gains the standard system that does bifurcate —x -> r x (1 - x)in a new pure, gfx-freelogistic.eigs— chosen precisely because its cascade is analytically known, which is what turns the picture into something checkable.Why a second view and not a second app
The two views are the same lab asking the same question — "what does this settle into?" — from the two complementary directions: the phase portrait is one parameter over time, the bifurcation diagram is the asymptotics over all parameters. One window keeps one theme application, one
app_loop, one key handler, one (mode-aware) Reset-view button and one EigenOS bundle seam. The window still opens on the phase portrait, so every pre-existing entry point (run,run_auto) and every pre-existing oracle is untouched by construction.The oracle is external, not a golden master
tests/test_bifurcation.shchecks the sweep the window actually plots against closed-form algebra. Nothing in it compares against a number this repo chose.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. Two planted faults validate the checker (the map perturbed; an analytic reference constant moved), and each must be caught by an assertion, not by the program dying.
Where the branch check is skipped it is a statement about physics, not slack: within ~1e-2 of a doubling the multiplier passes through magnitude 1 and 600 iterations genuinely have not settled, so those columns are a real blur. The tolerated margins are where
|multiplier|^600is far under 1e-9.The memory gate
tests/test_bif_mem.shis a deliverable of this PR, 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 aulimit -vcap — while every correctness oracle stayed green.The gate runs the real window at 30 / 100 / 300 frames under a 1.5 GB address-space cap, in two shapes, and fails on a ceiling breach, on RSS that grows with the frame count, or on the structure changing:
Ceiling 200 MB — ~1.45x the 137 MB floor that the merged orbit view and lib/ui's bare chart widget both already sit at (essentially all of it is runtime + SDL; the sweep is noise). Loose enough for a different SDL/driver/font cache on a CI runner, tight enough that the original leak would have been rejected on its first rung. Growth 25 MB against a measured spread under 1 MB, ~25x headroom, while the leak it is aimed at shows +600 MB.
The
toggleshape flips views every frame — 150 switches at 300 frames — and pins that they cost exactly one sweep, one series and four markers. That is the cause, asserted directly; RSS is only the symptom.Validated by two planted faults: one restores the real leak, one removes the build-once guard.
The leak was a runtime bug, not the sweep
Bisected to a single
prev of xinsidephysics.eigs'sframe_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; the chart allocates a coordinate pair per plotted point per frame, so all 4,000 of them per frame were pinned forever.Filed upstream as EigenScript#827 with a gfx-free, lib/ui-free minimal repro — a never-called
prev ofturns a flat 3.4 MB program into 140 MB, linear in the iteration count — and recorded as F-DYN-13. EigenScript#828 (F-DYN-14) records the chart's per-point allocation (~24 µs/point/frame, the amplifier) and thatchart_markermakes avlinecaller invent ayit ignores.Per the standing rule this is surfaced, not papered over: the window uses the documented
record_history of 0opt-out for the lifetime of a session and restores the previous setting on exit, and the gate carries a planted fault that removes that call — so when #827 lands the opt-out can simply be deleted and the gate will still hold the line.Also in this change
'logistic map', the plot area is redrawn wholesale (26,960 px), the diagram is pixel-identical between frames (the visible face of "the bifurcation tick does no per-frame work"), and toggling back restores the phase portrait pixel-for-pixel. A third planted fault (mode— a handler that fires and switches nothing) validates it.orbit_theme.eigsmapsplot_bg/plot_grid/plot_axis/plot_border/plot_seriesonto the same palette entries the phase-portrait canvas reads; both plot surfaces sample(10, 12, 20)in the committed screenshot instead of the toolkit's stock slate.tests/test_lint.sh).--lintwas always available and nothing ran it, which had left three warnings in the tree; all fixed (notablysolve.eigs'sdefine dotshadowing the builtin — nowvdot). Upstream's lint-walker recursion train means new rules arrive with every runtime bump, so this needs to be mechanical.EIGS_REFv0.34.0 → v0.35.0 (the only pin site —SECURITY.mdandFINDINGS.mdreference it, they don't set it). Preflighted: the entire pre-existing suite was run green against a real checkout of the v0.35.0 tag before the bump, not against--version.tests/test_bifurcation.shpins these againstorbit.eigsso the oracle can never end up checking a sweep the window doesn't draw.Verification
Full suite, all eight scripts, against the pinned runtime — 3m 9s, every one
rc=0:Every pre-existing oracle passes unchanged — byte-identical trajectory, byte-identical under pan/zoom on every frame, the interactive history bound, and all the pre-existing mouse/render-decode legs.
--lintclean across all 17.eigsfiles. Screenshot taken by driving the real window on the real display (capped), with the marker colours and both plot backgrounds verified by pixel sampling rather than by eye — which is also what caught the Reset-view button overlapping a caption, a collision no headless test could see.🤖 Generated with Claude Code