Skip to content

feat: sorting algorithms lab — 7 algorithms × 4 datasets racing on one clock - #18

Merged
benjamin-small merged 15 commits into
mainfrom
feat/sorting-lab
Sep 15, 2026
Merged

benjamin-small merged 15 commits into
mainfrom
feat/sorting-lab

Conversation

@benjamin-small

Copy link
Copy Markdown
Owner

Summary

A third lab, #/sorting: a 7×4 matrix of bar-chart panels (Bubble, Insertion, Selection, Shell, Merge, Quick, Heap × Random, Nearly sorted, Reversed, Few unique). Every panel is an independent lane on one engine clock — each tick, every running lane applies one recorded op (compare / swap / write) — so O(n²) vs O(n log n) is visible as a race. Click a cell to run / pause / restart, ▶ on row and column headers, toolbar with Run all / Pause all / Reset / New data / Speed / Size, share links via ?n=<size>.

How it's built

  • Rust: Rule::apply_action + Engine::rule_action (generic hook to mutate live rule state without resetting playback); rules/sorting/{algorithms,datasets} record op traces by running the real algorithms through a Recorder (tests prove replaying the trace reaches the sorted array for every algorithm × dataset × size); SortingRace rule (lanes, per-lane cursor/running, actions, per-frame summary); InstancedQuads renderer; SortingViz draws bars into DOM-supplied cell rects (device px) — Rust does no layout.
  • Engine: frame dt is now clamped to 0.25 s so a hidden tab can't fast-forward a lab by minutes.
  • Web: LabShell gains playback / zoom / overlay props; LabApi.patchVizConfig / ruleAction / readSummary; SortingLab.svelte overlays a CSS grid on the canvas, measures cells with a ResizeObserver, and reads the rule summary every frame for the badges.

Tests

Rust native 116 → 177, browser (wasm) 18 → 24, vitest 55 → 105. Verified live on the dev server (desktop + 375 px mobile), including lab switching with no console errors.

Adds Engine::rule_action so the upcoming sorting lab can mutate live
rule state (toggle a sub-simulation) without hard-resetting playback,
which update_rule_config does. Default apply_action is Ok(false); a
malformed action surfaces as ErasedError::Action / a JS error string.

No sorting code yet — this is the engine-generic hook only.
Batched WebGL2 renderer for filled rects (sorting-lab bars, up to
~8,400 per frame in one instanced draw call). Modeled on
InstancedRings: static unit quad at attrib 0, per-instance min/max/color
at attribs 1-3 with divisor 1, TRIANGLE_STRIP draw_arrays_instanced,
Drop deletes VAO + both buffers. Packing factored into a pure `pack`
fn with native unit tests.
…pi rule-action hooks

Adds `playback`/`zoom` boolean props (both default true, so the two
existing labs are unaffected) and an `overlay` Snippet<[LabApi]> rendered
over the canvas below the info toggle/backdrop — the seams the upcoming
sorting lab needs to swap in its own toolbar and a 7x4 grid of DOM cells.

LabApi gains patchVizConfig/ruleAction/readSummary wrapping the engine's
update_viz_config/rule_action/rule_summary. fakeViz's FakeEngine gains
matching spies (ruleActionSpy, updateVizConfigSpy) and a sortingSummaryFixture
(28 idle lanes, row-major) returned by rule_summary() for lab === 'sorting'.
One lane per (algorithm, dataset) cell, row-major. init records each
lane's op trace once; advance_to applies one op per clock tick to every
running lane, so all 28 cells race on the engine's single clock. Lanes
are started, paused, restarted and reset through Rule::apply_action
(toggle / set_running / reset_all), which never touches playback.

Each column shares one input array so every algorithm in it sorts the
identical data; the column index is mixed into the seed so two columns
of the same dataset still differ.
`#/sorting` parses to the new lab id (with its query intact, e.g. `?n=80`); the nav entry and the lab page follow.
SortingLab.svelte overlays a CSS grid of row/column headers and 28 cell
buttons on the canvas, pushing device-pixel cell rects to the viz via
patchVizConfig and driving per-lane/row/column/global rule actions
(toggle, set_running, reset_all). Speed and array size are controlled
from a bespoke footer toolbar; size is shareable via ?n=.

Pure lane/grouping arithmetic, cell-rect geometry, and rule-summary
parsing live in src/lib/sorting/{lanes,layout,summary}.ts so the
component itself stays under the ~350-line guideline.

Wires the route into App.svelte (nav link + branch), already backed by
the 'sorting' LabId committed in cc68f5e.
…" lab

One InstancedQuads draw per frame fills the cell rectangles the lab
measures in the DOM and pushes through `update_viz_config` as
`cells: [[x, y, w, h], …]` in device pixels, so Rust does no layout.

- Pixel-space y-down projection, matching the DOM's coordinate frame.
- Bar heights scale to each lane's own max; compare/write highlights come
  from `Lane::last_op()`, and a finished lane turns uniformly done-colored.
- Registry: `LAB_IDS` gains "sorting", pairing SortingRace with SortingViz.
- Browser tests for construction, per-lane `rule_action`, its error cases,
  the Ok(false) default on other labs, and a 28-rect cells config.
…rd a lab

frame() previously fed an unbounded now_ms - last_frame_ms gap straight into
dt; a backgrounded/throttled tab could resume with a multi-minute gap and
fast-forward any rule by that much in one tick. Clamp to [0, 0.25]s for the
whole engine.
…alting

- SortingRace::init now clamps cfg.size to [10, 300] defensively (the schema
  already enforces this, but a hand-crafted config could bypass it) instead
  of trusting the raw value.
- Dataset generators mixed a small loop index with a fixed salt via plain
  XOR, which lets a low-bit index and a low-bit salt cancel out. Multiply
  the index by splitmix64's own golden-ratio constant first so consecutive
  indices land on well-spread inputs.
- Pin known-answer vectors for splitmix64(0) and splitmix64(1) so a change
  to the mixing constants is caught directly, not just via downstream
  behavior.
- few_unique_has_at_most_five_distinct_values now also asserts the dataset
  is genuinely repeated and non-trivial (2 <= distinct < n) once n >= 10.
fourier_epicycles's apply_path test used assert_eq!(cfg.path[2].pen, false)
(clippy's bool_assert_comparison territory) — assert!(!...) instead.

Rule::summary's doc claimed it's "never" read per frame, which the sorting
lab's live lane grid already contradicts; reword to allow it while still
asking implementers to keep it cheap.
…l guard

- Grid: switch the inline grid-template to minmax(0, 1fr) tracks and a
  --rowhdr custom property so the 4th dataset column no longer clips at
  375px; give .hdr min-width: 0 so its ellipsis can actually kick in, and
  shrink the row header column + row-header font under the 768px query.
- onSize: re-write the clamped value back into the input and skip the
  rule-config push (and the playback reset it causes) when a repeated
  out-of-range entry clamps to the value already in effect.
- paramsOf now returns the raw, unclamped ?n= so onReady can tell an
  out-of-range link apart from an already-canonical one and rewrite the URL
  to the value that actually loaded.
- runAll always sends running: true — it no longer toggles the whole race
  to paused just because every lane happened to already be running.
- pushCells: skip a frame where the bound cell-button array is short or has
  a hole (a resize callback firing mid-render), instead of measuring bogus
  rects.
- fakeViz: drop the duplicated SortingLane/SortingSummary interfaces in
  favor of importing LaneSummary/SortingSummary from lib/sorting/summary,
  so the test fixture can't drift from the real shape.
- summary.ts: note algorithms.rs::Algorithm::label / datasets.rs::Dataset::label
  as the source of truth this list mirrors.
Rust native (177), WASM browser (24), and vitest (105 across 13 files)
counts, plus a fresh coverage run, were all stale from before the sorting
lab landed.
@benjamin-small
benjamin-small merged commit 1e73a93 into main Sep 15, 2026
6 checks passed
@benjamin-small
benjamin-small deleted the feat/sorting-lab branch September 15, 2026 08:47
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.

1 participant