Skip to content

fix: pin nail count for the lifetime of a generation run - #6

Merged
baditaflorin merged 1 commit into
mainfrom
fix/mid-run-nail-count-drift
Aug 4, 2026
Merged

fix: pin nail count for the lifetime of a generation run#6
baditaflorin merged 1 commit into
mainfrom
fix/mid-run-nail-count-drift

Conversation

@baditaflorin

Copy link
Copy Markdown
Owner

Context

First-pass TRL audit of string-portrait (no prior trl_assessed_at in the registry). Confirmed the concept by reading source, not assuming: this is a genuine Petros-Vrellis-style computational string-art generator — a greedy solver that repeatedly picks the chord (nail-to-nail line) covering the most remaining "darkness" in a residual buffer, draws it, and subtracts its contribution, producing one continuous thread. src/stringart.ts / src/image.ts / src/svg.ts are pure logic with 27 passing vitest cases; src/main.ts is the DOM/canvas wiring layer (untested by vitest, verified live in a browser for this PR).

What was checked (per the audit's priority order)

  1. Concept verification — read all of src/*.ts. Confirmed it's a real greedy string-art solver, not a stub: nailPositionslinePixels (Bresenham) → lineScore/subtractLine (residual-darkness bookkeeping) → chooseNextNail (greedy pick, -1 sentinel when nothing left worth drawing) → generateStringArt. The residual only ever decreases and is clamped at 0, so the run is provably monotonic — no divergence/oscillation risk by construction.
  2. Privacy — traced every network-shaped call. grep -rniE "fetch\(|XMLHttpRequest|WebSocket|analytics|gtag|sentry" across src/ and index.html: nothing. The one fetch(n.href,i) in the built bundle (docs/assets/*.js) is Vite's standard modulepreload polyfill fetching the app's own local same-origin chunk — not telemetry. Watched the Network tab live through a full generation run (sample image + two uploaded test images): zero requests beyond the local dev server. The README's "100% client-side, nothing leaves your device" claim holds.
  3. Domain-specific correctness — this is where the one real bug was, see below.
  4. Data-loss on refresh — no localStorage/indexedDB/sessionStorage/autosave anywhere in the codebase or docs, and nothing in the UI or README claims persistence. Refresh-loses-work is undocumented-as-otherwise, expected behavior, not a bug.
  5. Security / crash handling — no innerHTML/eval/document.write anywhere (grep confirmed). Fed the upload input a 2 KB file of random bytes with a spoofed image/png MIME type via a synthetic File/DataTransfer (bypassing the accept="image/*" picker hint, which is client-side-only anyway): handled cleanly — createImageBitmap rejects, the Image() fallback's onerror fires, user gets a "Could not read that image." toast, no exception reaches the console, no crash. SVG uploads can't inject script (loaded via <img>/createImageBitmap, not <object>/direct navigation, so embedded <script> in an SVG is inert per spec).
  6. Edge cases — uploaded a real non-square (800×300) test photo; the cover-crop in imageToGray centered and scaled it correctly (verified visually — no stretch, no misalignment). Internal solver resolution is a fixed 500×500 regardless of source photo size, so an oversized upload can't blow up processing cost.

The bug (item 3 — greedy algorithm correctness)

startGeneration() in src/main.ts builds the nail-position array once, from settings.nails at that instant:

nails = nailPositions(settings.nails, radius, RES / 2, RES / 2);

...but the frame loop (step()) re-reads the live settings.nails every frame for the edge-key hash, both for the lookup and for recording:

const next = chooseNextNail(current, nails, residual, { ..., used, count: settings.nails });
...
used.add(edgeKey(current, next, settings.nails));

edgeKey(a, b, count) = a < b ? a*count+b : b*count+a — the hash space depends on count. Nothing disables the "Nails" range slider while a generation is running, so a completely ordinary UI action (drag the slider mid-run) changes settings.nails out from under the in-flight run. That desyncs the used Set's hash space from the one later lookups use: hash collisions can either falsely block a legitimate new chord, or let an already-drawn chord slip back past the "never redraw the same edge" check — which stringart.ts explicitly documents as the anti-oscillation guarantee (ChooseOpts.used, GenerateOpts.uniqueEdges).

Fix: snapshot settings.nails into a local const nailCount once at the top of startGeneration, and use that constant everywhere the loop needs the count for edge encoding, instead of re-reading the live, mutable setting. The slider stays live for shaping the next run — the only behavior the UI actually promises.

Verified live: clicked Generate, then scripted the exact repro (drag Nails 240 → 360 → 120 mid-run) via the DOM. Before the fix this silently corrupted edge-key hashing for the rest of the run; after the fix the in-flight run is unaffected (as intended — the slider only takes effect on the next Generate), confirmed with a clean console throughout.

Verification

  • npm run typecheck — clean
  • npm test — 27/27 pass (unaffected; main.ts isn't vitest-covered, this fix was verified manually)
  • npm run smoke (vitest + vite build + output sanity checks) — passes; docs/ rebuilt, bundle hash changed accordingly (index-DfywjaKV.jsindex-B21gUkMX.js)
  • Live browser session: sample image full run to completion (legible face-like convergence — eyes/mouth read clearly through the thread density, not garbage), non-square photo upload, garbage/invalid file upload, mid-run slider-drag repro — all clean, no console errors

Scope note

npm run fmt:check currently fails against CLAUDE.md and package.json (pre-existing, unrelated to this change, present before this PR). Left untouched — reformatting CLAUDE.md wholesale is out of scope for this fix and would fork the fleet-propagated file against its own header instructions ("if you find a stale copy that differs from this one... don't fork it"). Flagging here rather than silently bundling an unrelated 150+ line reformat into a one-file bugfix PR.

Left PR #5 (postcss Dependabot bump) and #4 (esbuild/vite Dependabot bump) untouched, as instructed.

Suggested registry entry (for a follow-up, not applied here — did not touch services-registry)

  • trl: 6 — real, tested (27 passing unit tests over the pure solver/image/svg logic), correct and verified-live domain algorithm, genuine privacy claim confirmed by source + build-output + live-network audit, graceful handling of malformed uploads, zero dependencies.
  • trl_ceiling: 7 — a browser canvas/JS greedy solver naturally tops out here; going further would need either a materially better approximation algorithm (e.g. simulated annealing / ILP-based chord selection) or algorithmic rigor this genre of tool doesn't call for. Not held back by anything structural in the current implementation.
  • evidence: "First TRL audit (2026-08-04). Verified as a genuine Petros-Vrellis-style greedy string-art solver by reading all of src/*.ts, not assumed from the name. 27/27 vitest pass over the pure algorithm/image/svg logic. Privacy claim ('100% client-side, nothing leaves your device') verified true: no fetch/XHR/WebSocket/analytics anywhere in source or the built bundle (the bundle's one fetch() call is Vite's local-chunk modulepreload polyfill), confirmed live via Network-tab monitoring through full generation runs including uploaded photos. Fixed one real correctness bug (PR fix: pin nail count for the lifetime of a generation run #6): a mid-run edge-key hash-space desync when the live 'Nails' slider is dragged during an in-flight generation, which could silently corrupt the greedy solver's 'never redraw the same chord' invariant — fixed by pinning the nail count for the run's lifetime. Malicious/garbage file uploads (random bytes with spoofed image MIME type) handled gracefully with a user-facing toast, no crash. No autosave claimed anywhere, so refresh data loss is expected/undocumented-otherwise behavior, not a bug."

🤖 Generated with Claude Code

The greedy winding loop in main.ts read the live `settings.nails` value
every frame for both `chooseNextNail`'s edge-key hashing and the
`used.add(edgeKey(...))` call, while the actual nail-position array
(`nails`) is built once at the start of the run from whatever
`settings.nails` was at that moment. Nothing disables the "Nails"
slider while a generation is running, so dragging it mid-run (a normal
UI interaction, not an edge case) changes `settings.nails` out from
under the in-flight run.

Because `edgeKey(a, b, count) = a < b ? a*count+b : b*count+a`, a count
change mid-run desyncs the hash space the `used` Set was built with
from the one later lookups use — collisions can silently block a
legitimate new chord, or let an already-drawn chord slip past the
"never redraw the same edge" check the code explicitly documents as
the anti-oscillation guarantee (see `ChooseOpts.used` / `uniqueEdges`
in stringart.ts). Verified live in a browser: dragging Nails 240 → 360
→ 120 mid-run no longer has any effect on the in-flight run's
edge-key space.

Fix: snapshot `settings.nails` into a local `nailCount` once at the
top of `startGeneration`, and use that everywhere the loop currently
reads `settings.nails` for edge encoding. The slider stays live for
the *next* run, which is the only behavior the UI actually promises.

Pure-logic tests in tests/core.test.ts (stringart.ts, image.ts, svg.ts)
are unaffected — main.ts is the UI wiring layer and isn't covered by
vitest, so this was verified manually: npm run typecheck, npm test
(27/27 still pass), npm run smoke, and a live browser session
(non-square photo upload, garbage-file upload, full generation runs,
mid-run slider drag) with a clean console throughout. `docs/` rebuilt
via `npm run smoke` per repo convention (content-hashed bundle name
changed accordingly).

Also audited: no network calls anywhere in src/ or the built bundle
(confirmed the one `fetch()` in the bundle is Vite's standard
modulepreload polyfill for local same-origin chunks) — the "100%
client-side, nothing leaves your device" privacy claim in README.md
holds. Malicious/garbage file uploads are already handled gracefully
(toast + no crash) via the existing createImageBitmap/Image fallback
try/catch. No XSS surface (no innerHTML/eval in the codebase). No
autosave is claimed anywhere, so refresh data loss is expected,
undocumented-otherwise behavior, not a bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@baditaflorin
baditaflorin merged commit b9536b5 into main Aug 4, 2026
@baditaflorin
baditaflorin deleted the fix/mid-run-nail-count-drift branch August 4, 2026 14:20
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