Skip to content

Bifurcation sweep view on lib/ui's chart, behind a memory gate (rung 1, slice 3) - #23

Merged
InauguralPhysicist merged 2 commits into
mainfrom
ui-rung1-bifurcation
Aug 3, 2026
Merged

Bifurcation sweep view on lib/ui's chart, behind a memory gate (rung 1, slice 3)#23
InauguralPhysicist merged 2 commits into
mainfrom
ui-rung1-bifurcation

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Contributor

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-y chart widget 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).

bifurcation view

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-free logistic.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.sh checks the sweep the window actually plots against closed-form algebra. Nothing in it compares against a number this repo chose.

P1_COLS 25        P1_MAXERR 7.771561172376096e-16     # vs 1 - 1/r
P2_COLS 58        P2_MAXERR 7.216449660063518e-16     # vs (r+1 ± sqrt((r-3)(r+1)))/(2r)
P2_BOTH_BRANCHES 1                                    # a collapsed column cannot pass
R1_MEAS 2.999709224700928    R1_ERR 0.0002907752990721768    # r1 = 3 exactly
R2_MEAS 3.449387693405151    R2_ERR 0.00010204937802704706   # r2 = 1+sqrt(6) exactly
R3_MEAS 3.544053444862366    R3_ERR 3.691468955668853e-05    # r3 = 3.5440903...
RATIO 4.750170592661048                               # first Feigenbaum ratio, true 4.7514
P3_R 3.8284271247461903   P3_BELOW 64   P3_ABOVE 3    # period-3 window opens at 1+sqrt(8)
CENSUS_2_8 1  CENSUS_3_2 2  CENSUS_3_5 4  CENSUS_3_55 8  CENSUS_3_9 64

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|^600 is far under 1e-9.

The memory gate

tests/test_bif_mem.sh is 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 a ulimit -v cap — 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:

  static 30 frames:  peak 136448 KB  rc=0  built=1 series=1 points=4000 markers=4
  static 100 frames: peak 136192 KB  rc=0  built=1 series=1 points=4000 markers=4
  static 300 frames: peak 136960 KB  rc=0  built=1 series=1 points=4000 markers=4
  static growth over the frame sweep: 512 KB (limit 25600 KB)
  toggle 30 frames:  peak 136320 KB  rc=0  built=1 series=1 points=4000 markers=4
  toggle 100 frames: peak 136704 KB  rc=0  built=1 series=1 points=4000 markers=4
  toggle 300 frames: peak 136960 KB  rc=0  built=1 series=1 points=4000 markers=4
  toggle growth over the frame sweep: 640 KB (limit 25600 KB)

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 toggle shape 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 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; 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 of turns 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 that chart_marker makes a vline caller invent a y it ignores.

Per the standing rule this is surfaced, not papered over: the window uses the documented record_history of 0 opt-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

  • Mouse oracle covers the new control. The view selector is mouse-driven, so clicking it is verified in pixels on both halves of the window: the control column decodes to '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.
  • Theme layer covers the data surface. orbit_theme.eigs maps plot_bg/plot_grid/plot_axis/plot_border/plot_series onto 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.
  • A lint gate (tests/test_lint.sh). --lint was always available and nothing ran it, which had left three warnings in the tree; all fixed (notably solve.eigs's define dot shadowing the builtin — now vdot). Upstream's lint-walker recursion train means new rules arrive with every runtime bump, so this needs to be mechanical.
  • Pin bump EIGS_REF v0.34.0 → v0.35.0 (the only pin site — SECURITY.md and FINDINGS.md reference 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.
  • Sweep resolution: 200 columns × 20 samples = 4,000 points, 600 transient iterations discarded. Deliberately modest — the widget costs ~24 µs per point per frame, so more columns buy resolution, not physics. tests/test_bifurcation.sh pins these against orbit.eigs so 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:

=== test_lint : rc=0  2s          === test_bifurcation : rc=0  2s
=== test_smoke : rc=0  1s         === test_orbit_oracle : rc=0  16s
=== test_lab : rc=0  1s           === test_orbit_mouse : rc=0  72s
=== test_orbit_hist : rc=0  3s    === test_bif_mem : rc=0  96s

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. --lint clean across all 17 .eigs files. 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

InauguralPhysicist and others added 2 commits August 3, 2026 01:21
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui: phase-portrait / orbit lab window (fleet UI ladder, rung 1)

1 participant