Files
NilsBriggenandClaude Fable 5.1 79b80e029c feat(ui): scale-relative fuzzy default, measured (M2-4 B5)
`FUZZY_FRACTION = 1e-7`: every production boolean runs at
`1e-7 × the target's bounding-box diagonal` — 5.2e-6 mm on the 52 mm
block. Both bounds measured before the number was written.

LOWER BOUND — the noise the default must cover. `measure_flush_wall_noise`
(ignored harness, compile/tests/booleans.rs) fuses a 40x30x15 box with a
10x30x15 box authored `gap` past its x = 40 wall, over a (gap x fuzzy)
grid. At fuzzy 0 OCCT's own confusion closes gaps up to 2e-7 mm and
leaves 5e-7 open: twelve faces, two solids. So delta* = 5e-7. A fuzzy
value closes a gap iff it is >= the gap (5e-7 closes 5e-7, 1e-6 closes
1e-6, ...). The spec's rule, fraction * diag >= 2 * delta*, gives
fraction >= 1.9e-8 on diag = sqrt(40^2+30^2+15^2) = 52.2.

UPPER BOUND — the largest fraction keeping every gate green. Swept 1e-4,
1e-5, 1e-6, 1e-7, 1e-8 over vernier-ui, vernier-app and the selftest.
Every analytic 1e-9 gate held at every value — mass properties carry no
tolerance — but one thing does move: OCCT's fuzzy inflates the result's
tolerances and the tolerance-inclusive Bnd_Box grows by ~1.6x the fuzzy
value, and that box IS `bbox_diagonal`, NAMING.md section 5's scale.
Lane A's split-rebind fixture pins D at 1e-6 relative:
  1e-6: 52.20161787 vs 52.20153254 analytic, 1.6e-6 -> red
  1e-7: green (1.6e-7)
So 1e-7 sits five times over the floor and five times under the ceiling.
Not a STOP: the window [1.9e-8, ~6e-7] is open.

WHAT THE FIXTURE HAD TO LEARN. The fuzzy fuse FILLS the gap rather than
snapping the neighbour across it: the volume is 22500.000224999996
against 22500 + 5e-7 * 30 * 15 = 22500.000225 (1.8e-16 relative), and the
bare sum 22500 is off by exactly 1e-8, ten times the gate. The fixtures
assert the filled union. `a_flush_wall_fuses_to_one_face_under_the_default`
(document level, production policy: 1 solid, 6 faces, exact volume) and
`the_same_gap_stays_a_sliver_at_zero_fuzzy` (the SAME two boxes at
fuzzy 0: 12 faces, 2 solids; at the default: 6, 1; and a 1e-7 gap closes
either way, so the default only works where zero fuzzy fails). Selftest
`m2-flush-wall` (29th, appended) records both outcomes:
(94, f9fb78428f4def90).

Also fixed in the bridging fixture from B3: its underside filter caught
the slot's ceiling (normal -Z at z = 5) once fuzzy nudged OCCT's face
order — it now demands z = 0 as well.

Selftest digests, B4 -> B5:
  l1-bike-light-mount (195, 0e0084d3d6e499af) -> (195, 4aff517a7a816113)
  probed: same normal, same volume, same 12 angled faces; the centroid's
  last digits moved at 1.2e-14 relative (10.016404753393848 vs ...973),
  the per-face integration order again. m3-mirror unchanged at 12: the
  bracket's ~1e-9 solved-plane offset is under the confusion and its
  walls still do not unify.

MISTAKES.md's zero-volume entry records the resolution; README's count
27 -> 28 -> 29 across B4/B5 (set to what this branch holds).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:45:33 +02:00

18 KiB
Raw Permalink Blame History

MISTAKES.md

Bugs that cost more than an hour. Read before debugging anything that feels familiar.

Format: symptom → root cause → the wrong hypothesis that wasted the time.

The third field matters most. Knowing the answer helps once; knowing which plausible wrong turn to skip helps every time.


Pre-seeded — known traps, not yet hit

These are documented in advance because they are near-certain and expensive.

OCCT exception crosses the FFI boundary

  • Symptom: silent memory corruption, or a crash far from the actual call, often much later
  • Cause: Standard_Failure unwinding through cxx into Rust is undefined behaviour
  • Wrong hypothesis to skip: "the Rust side has a lifetime bug." It does not. Check that every façade function wraps its OCCT calls in try { } catch (Standard_Failure&).

Boolean produces a valid-looking shape with zero volume

  • Symptom: operation succeeds, render shows nothing or shows garbage
  • Cause: coincident or near-coincident faces below OCCT's tolerance; the fuzzy boolean parameter was not set
  • Wrong hypothesis to skip: "tessellation is broken." Check volume() on the result before blaming anything downstream.
  • Live risk, M1 onward: every extrude and pattern merge in vernier-ui/src/compile/merge.rs passes fuzzy_mm: 0.0. Circle profiles never produced near-coincident faces, so this trap stayed theoretical; a rectangular profile drawn flush with an existing wall is exactly the geometry it describes, and line profiles make that a normal thing to draw. Left at 0.0 deliberately — any other value moves every existing golden number — and open as an M2 item with a scale-relative default (a fraction of the bounding-box diagonal the façade already computes). If a flush-wall fuse ever returns a plausible shape with a wrong volume, start here rather than in the profile code. M3 (mirror) makes the coincident-face fuse routine rather than accidental — the canonical mirror touches its source on one coplanar face by design — and it measured clean: exact union volumes, both contact walls correctly Deleted, no zero-volume trap. A second confirmed-clean data point, and the reason the scale-relative-fuzzy calibration is now more urgent than it was: the geometry that would expose a wrong default is no longer something a user has to go out of their way to draw.
  • Resolved 2026-09-06 with FUZZY_FRACTION = 1e-7 (vernier-ui/src/compile/merge.rs, M2-4): every production boolean now runs at 1e-7 × the target's bounding-box diagonal. Both bounds were measured, not chosen: at zero fuzzy OCCT's own confusion closes a flush gap of 2e-7 mm and leaves 5e-7 open (two solids, twelve faces), and a fuzzy value closes a gap iff it is at least the gap — so the floor is 2·5e-7 / 52.2 mm ≈ 1.9e-8; the ceiling is that OCCT's fuzzy inflates the result's tolerances and the tolerance-inclusive Bnd_Box (bbox_diagonal, §5's scale) grows by ~1.6× the fuzzy value, which the split-rebind fixture's 1e-6 pin on D refuses at 1e-6 and accepts at 1e-7. Fixture: a_flush_wall_fuses_to_one_face_under_the_default with its zero-fuzzy sibling the_same_gap_stays_a_sliver_at_zero_fuzzy (compile/tests/booleans.rs), selftest m2-flush-wall. One thing the fixture had to learn: the fuzzy fuse FILLS the gap rather than snapping the neighbour across it, so the exact volume is V + δ·A_contact, and asserting the bare sum at 1e-9 fails by exactly 1e-8.

Fillet silently moves to a different edge after a sketch edit

  • Symptom: part looks subtly wrong; no error anywhere
  • Cause: topological reference resolved by fingerprint below the confidence threshold and picked the nearest candidate instead of failing
  • Wrong hypothesis to skip: "the fillet radius is wrong." This is invariant #5 being violated. Find where the threshold check was skipped.

Golden test passes locally, fails in CI

  • Symptom: topology hash differs between machines
  • Cause: HashMap iteration order reached the output. Invariant #1.
  • Wrong hypothesis to skip: "floating point differs between CPUs." It usually does not at 1e-9 relative for these operations. Check for unordered iteration first.

Viewport stutters during recompute

  • Symptom: frame drops while a feature rebuilds
  • Cause: kernel work on the render thread, or a lock held across an await/kernel call
  • Wrong hypothesis to skip: "the GPU is the bottleneck." Profile the frame; it is almost always a blocked render thread. Invariant #6.

Actual mistakes

Worker test wedges the whole suite instead of failing (2026-08-08)

  • Symptom: cargo test never finishes; a vernier-ui test binary sits alive for hours with its worker thread parked on a futex. Surfaced after a suspend/resume perturbed a recv_timeout mid-test.
  • Cause: a test gated a worker job on an unbounded mpsc::recv(). Any assertion failure before the gate opened unwound the test thread, which ran Worker::drop, which joins the in-flight job — but the gate's sender lived on the very stack being unwound, below the worker in drop order. Failure path → permanent deadlock, not a red test.
  • Wrong hypothesis to skip: "channel-gated tests are deterministic, therefore safe." Determinism of the happy path says nothing about the unwind path. Any job a Worker runs in a test must have a bounded wait (see bounded_wait in worker.rs tests): with drop semantics that join in-flight work, an unbounded gate converts every assert into a hang.

Lost naming store silently rebinds fillet refs onto different faces (2026-08-09)

  • Symptom: compile the same document against a fresh (lost) naming store: the fillet lands on the bore-floor rim instead of the top outer edge. Zero errors; the wrong part exports a clean STL. Caught by the Phase 3/4 adversarial review's probe, not by any test.
  • Cause: the kernel-side id counter lived in the NamingStore. A fresh store restarted it from zero and re-minted the same small integers for different faces, so stale references in fillet payloads stayed "alive" — alive-by-identity checks pass, resolution never runs, invariants #4/#5 both violated at once. The fix moved the counter into the document allocator (one id space, one owner): with a fresh store, mints start above every stale reference, references die, resolution runs, and the compile breaks loudly.
  • Wrong hypothesis to skip: "the reference machinery must be scored/thresholded better." Resolution was never reached — the bug was upstream, in id allocation. When a stale reference misbehaves, first ask who minted the id and can two minters collide, before touching resolve() at all.

Per-column LM damping reports NoConvergence on satisfiable geometry (2026-08-09)

  • Symptom: dragging the start point of a quarter arc returned NoConvergence after 0 iterations, on geometry that is trivially satisfiable. Looked like a bug in the new arc formulation, since arcs were what surfaced it.
  • Cause: nothing to do with arcs. SOLVER.md §3's λ·max(diag(JᵀJ), 1e-12) had been implemented element-wise (the textbook Marquardt form), so a parameter that a constraint touches with a near-zero partial got damping proportional to that near-zero. LM then asks for a step of g_i/(d_i(1+λ)) in a direction where second-order curvature dominates; all 8 trial steps are rejected and the solve gives up. Reproducible with pre-arc constraints alone — Lock + EqualLength with two points axis-aligned — which is how the regression fixture is written. Fixed by reading the formula as a scalar (one max over the whole diagonal), which is only meaningful because §2 makes every residual commensurable in mm. Perf improved as a side effect: 692 µs → 383 µs at 287 constraints.
  • Wrong hypothesis to skip: "the new entity's Jacobian must be wrong." When new geometry surfaces a convergence failure, first try to reproduce it with only pre-existing constraint kinds. If you can, the new code is the trigger, not the cause, and the fix belongs in the shared numerics — where it needs proportionally harder verification, because everything else rides on it.
  • Second-order lesson: a spec formula whose operands are a vector and a scalar (max(diag (JᵀJ), 1e-12)) is ambiguous between element-wise and reduce. Both readings compile, both converge on easy fixtures, and they diverge only on ill-scaled ones. Write which you mean.

Revolve history is incomplete at exactly 2π, and OCCT says the faces were deleted (2026-08-10)

  • Symptom: every full-turn revolve fails the façade's closure check (kHistoryClosure); every partial sweep is complete and correct, right up to 2π − 1e-9. One input value out of a continuum is wrong, which is the shape of a boundary bug and is not one.
  • Cause: BRepPrimAPI_MakeRevol was constructed with its default Copy=Standard_False. Under that flag two things go wrong at once and only together: Generated() is incomplete at a full turn, and IsDeleted() actively lies — the profile edges that plainly produce the annular end faces report zero images and claim to have been deleted. The lie is the expensive half. NAMING.md §2 takes affirmative deletion as truth (an explicit death must outrank a lookalike), so the gap does not present as a gap; it presents as history asserting a rule. Fixed by passing Copy=Standard_True, which costs one copy of the profile face per revolve and is documented at the call site as load-bearing, not an optimization to remove.
  • Wrong hypothesis to skip: "the cap detection is broken." The faces that go missing are the planar ones, and the angle-dependent cap rule (two caps below a full turn, none at 2π) is the only angle-dependent code anywhere near, so it draws the eye and it is innocent — those annuli are ordinary side faces that happen to be planar, generated by profile edges, not caps. The flag is on the builder's constructor, nowhere near either. When history is missing for some result faces, first ask what the builder was told, and treat an IsDeleted() that is true for an edge whose face you can see in the result as a misconfigured builder rather than as a naming bug.
  • Why it was visible at all: only the closure check (NAMING.md §2, duty 2 — every id in the final delta must map to a sub-shape of the final result, or the operation fails). Without it the call returns kOk with two faces unaccounted for. A copy-of-the-prism implementation would have inherited the default flag with no reason to doubt it and shipped the gap; the prism sweeps a direction and never exercises the path.

Every fixture locked its points, so no test could tell authored from solved (2026-08-10)

  • Symptom: none, for a long time. SetRevolveAngle / SetRevolveProfile / SetRevolveAxis validated the axis relationship against the sketch's authored point coordinates while compile_document clones the sketch and solves it and never writes back. Found by adversarial review, not by a test. It bites two ways: a perfectly valid revolve whose angle can never be edited (the only revolve UI shipped is dead for that document), and an edit accepted into the timeline that then refuses to compile — both stated contracts broken.
  • Cause of the invisibility, which is the interesting half: every revolve fixture in the repo, including the M2 gate's own, locks each point to its own authored coordinates. Authored and solved therefore coincide by construction, so no test could distinguish which one the code read. The fixtures were not thin — they were degenerate in exactly the dimension under test.
  • Wrong hypothesis to skip: "the validation is covered, there are tests either side of it." Coverage of a code path says nothing when the fixture makes two different inputs identical. When a layer transforms data (solve, resolve, normalize), at least one fixture must make the input and the output differ, or every test passes whichever side the code reads.
  • The general shape: ask of any fixture, "what would this test do if the code read the other one?" If the answer is "pass", the fixture is the bug.

OCCT's STEP writer is non-deterministic and talks to stdout (2026-08-11)

  • Symptom, one: exporting the same shape twice produced files of identical length that differed at byte 626. Symptom, two: adding STEP export broke two unrelated CLI integration tests — json_report_file_matches_stdout and selftest_output_is_deterministic.
  • Cause, one: two fields vary. FILE_NAME carries a wall-clock timestamp, which is the obvious one and is fixable through APIHeaderSection_MakeHeader. The non-obvious one is that the PRODUCT entity carries a process-global counter — 'VernierCAD 1', 'VernierCAD 2' — that increments per transfer and that no Interface_Static parameter reaches. write.step.product.name sets the base and not the counter, so pinning it looks like it worked and does not.
  • Cause, two: STEPControl_Writer/Reader narrate to OCCT's default messenger, which prints to stdout. vernier-cli writes its JSON report to stdout, so a STEP call inside a selftest check corrupted the machine-readable output the whole verification path depends on. The fix is a scoped, restoring guard around the STEP calls — global muting would throw away diagnostics everywhere else, and a non-restoring one would leave the process permanently silent after a throw.
  • Wrong hypothesis to skip: "the timestamp is the non-determinism." It is only half, and fixing it makes the remaining half harder to see, because the file then looks pinned. Diff two outputs byte-for-byte and read the first difference rather than reasoning about which fields ought to vary.
  • Second-order lesson: a new kernel capability can break a test that has nothing to do with it, by writing to a stream another contract owns. When adding a library call, ask what it prints and where — not only what it returns.

A bare synchronous call to a lockable UI path can wedge cargo test forever, the same way the Worker one did (2026-09-02)

  • Symptom: clicking the push-pull gizmo once, on a release build, froze the whole app — card e4dd7ddb. All 16 threads in futex_wait, zero CPU, window still on screen looking alive. No existing test caught it before a human did, by hand, in one click.
  • Cause: gizmo_overlay called response.drag_delta() (which reaches Context::input) INSIDE a ui.data_mut closure — both route through egui's single exclusive Context::write, and parking_lot::RwLock is not reentrant. The UI thread asked itself for a lock it already held. Fixed by hoisting the read out before the lock (f2aa364).
  • Wrong hypothesis to skip: "a debug build would have caught this, release builds are the invisible ones." Both build profiles can hide it, just differently. epaint's RwLock is try_write_for(10s) + panic under debug_assertions, and a plain blocking write() without them — so the identical defect panics loud around 10s in debug and hangs forever, silently, in release. The actual gap here was not the build profile; it was that no test drove the drag interaction through the buggy closure AT ALL, in either build. A bare synchronous call to code that can take a shared non-reentrant lock (egui's Context today; any future one reached from application code) still fails visibly in debug via that panic, but in release it hangs the calling thread forever with nothing to bound it — and because cargo test's default harness shares one thread pool across tests, ONE hung test wedges the ENTIRE invocation: no red, no exit code, only an eventual external kill. That is the 2026-08-08 Worker-wedge mistake above, one layer up, on a different lock.
  • The rule, generalized from that entry: any test that deliberately drives UI/interaction code capable of taking a shared non-reentrant lock — egui's Context today, any future one reached from application code — must run the drive on its OWN thread, gated by a bounded recv_timeout, and must distinguish a panic (RecvTimeoutError::Disconnected) from a genuine hang (RecvTimeoutError::Timeout) in its own failure message. See dragging_the_gizmo_does_not_deadlock_the_ui_thread in vernier-ui/src/shell/tests.rs for the pattern. The reason to require this is BOUNDING, not diagnosis. Cybered drove all three cases (genuine re-entrancy, an unrelated panic, a merely slow frame) against that exact split and found the ARM is always right — a panic always lands on Disconnected, a hang always on Timeout — but the MESSAGE can be wrong: a panic and a genuine deadlock both land on Disconnected and both get the re-entrancy sentence, so the split does not by itself tell a real lock cycle from an unrelated crash (card b7531df4, open). What the pattern reliably delivers is that a hang becomes a FINITE, VISIBLE failure instead of wedging the whole test binary — print the elapsed time in the failure message too, so the reader is not left guessing which of the three shapes fired.
  • Known residual, not retroactively fixed by writing this rule: at least six existing vernier-ui tests — shell_frames_run_headlessly, open_asks_before_it_discards_unsaved_work, unsaved_work_is_visible, sketch_mode_paints_itself_and_its_tool, the_value_cards_enter_hint_is_plain_text_not_a_missing_glyph, and the several more that go through the private paint() helper (fn paint in this module) — call ctx.run_ui with show() (or, for one, value_card()) directly, synchronously, on the test's own thread, unbounded. This list is illustrative, not a completed audit — retroactively auditing every such test is explicitly out of scope for this entry (qa/cybered, card 2de3ff7d). They predate this rule and are grandfathered, not fixed here: migrate one to the bounded pattern if you touch it, but do not read their present shape as compliant, and do not read "they have never hung" as proof this shape is safe — they are unhung today only because the one closure that was reentrant (gizmo_overlay) is now fixed, not because a bare synchronous call through show() cannot reach a reentrant one again.