- Fresh repo rather than a fork of the exercise repo, to keep real commit history.
- Sample videos fetched by script, never committed.
- Cloudflare Pages over GitHub Pages: Pages requires Pro for private repos. Static site, no build step, auto-deploys on push to main.
- Hole ROIs act as occupancy detectors, not just geometry. Watching each hole for intrusion is more robust than requiring good nose tracking every frame, and it distinguishes "mouse entered hole" from "tracker lost the mouse".
- Tracker and hole detectors are independent signals. Disagreement between them is surfaced for human review rather than silently resolved.
- Thigmotaxis (edge-hugging) observed in all three clips, test50 especially. Report outer-annulus time separately so it does not read as search.
- Browser-only, no backend. Matches the brief's stated ideal shape for Task 1, means no video ever leaves the user's machine (IACUC-relevant), and removes a deployment. CV via OpenCV.js.
- MCP server, if built, is a local Python process reading exported session files. It does not require the analysis tool to have a backend.
- Frame-accurate seeking is the known risk. Start with requestVideoFrameCallback, fall back to WebCodecs. Check talmolab/vibes video player first.
- Next session: check talmolab/vibes, then walking skeleton with fake data.
- Borrowing frame-accurate seeking from talmolab/vibes video-player (BSD 3-Clause). Attribution in THIRD_PARTY.md and in-file. Their repo is BSD-3, this project is MIT; both permissive, borrowed code keeps its own notice.
- Same player component is used twice: once at load, once in the review panel. Different controls on top, one implementation underneath.
- Playback controls are dropped. Analysis controls replace them: frame step, jump to next flagged event, jump to next failed frame.
- Review UI follows the Trial Data Extractor pattern: list left, detail right, unverified/flagged/verified counters, keyboard verify-and-advance. Verification is a queue to work through, not a screen to stare at.
- Every reported number links to the frame that produced it.
- Mouse mask comes from background subtraction against the median frame, not a global darkness threshold. Holes and shadow are dark in the median too, so they subtract out. Lighting is handled per-pixel rather than globally.
- Eroded mask removes the tail (thin) while keeping the body (thick). Nose is the leading end of the eroded blob by direction of travel.
- Mask/hole-ROI collision generates candidate events, it does not confirm them. Overlap duration, speed, which end overlapped, and occupancy change qualify a candidate into an investigation. All four thresholds are user-visible.
- Two independent per-hole signals: mask overlap, and change in the hole's own pixels vs the median. Agreement means confidence; disagreement is flagged for human review. This is also how "mouse entered the hole" is distinguished from "tracker lost the mouse" without heuristics.
- Correction to 9/1 note: hole detectors are a SECOND signal alongside tracking, not a replacement for it.
- Verified on all three clips: median-frame subtraction isolates the mouse cleanly under three different rigs and lighting setups. Banding from re-encoding is present in the median but cancels out, since subtraction is per-pixel.
- Blob selection needs two guards, found via test51: a spatial gate (reject blobs
outside the detected platform circle) and a selection rule (nearest expected
size / nearest last known position, not simply the largest blob). Without them,
a bolt or knob visible in test51's first 75 frames is tracked as the mouse.
Correction, flagged by an independent review (2026-09-07): "nearest
expected size/last known position" was this entry's own first hypothesis,
not what shipped - superseded a few lines below by the simpler fix that
was actually validated and kept: a size gate plus the spatial gate, with
largest-blob-wins applied only AFTER both gates reject the bolt/knob.
track.js's real selection code (theif (!best || area > best.area)comparison, guarded by the size and platform-radius checks above it) confirms this directly, and matches CLAUDE.md's "already validated" summary and the Mouse tracking "About" text word for word. Left the original line above as-is rather than rewritten, per this file's own practice - this note is the correction, not a silent edit. - test51 reported 0.0% lost frames, which is a red flag rather than a good result: the tracker always found something. Zero lost frames in a Barnes maze trial is implausible, since hole entries should produce losses.
- Open question: the first ~75 frames of test51 may predate the trial. If so, "when does the trial start" is a user-facing definition that changes latency, and needs to be explicit and adjustable rather than assumed to be frame 0.
- Spatial gate (blob must be inside the detected platform circle) plus size gate (150-1500 px) fixes the test51 knob problem. Max blob area drops 3121 -> 777 and lost frames go 0.0% -> 10.1%, which is the tracker becoming honest rather than getting worse.
- Hough circle detection on the median frame finds the platform on all three clips: radius 208, 222, 208 across three different rigs. Reliable enough to build the ROI step on.
- Trial start is derived from the same gate: the first frame containing a valid mouse-sized blob on the platform. Detected at frames 150, 75, 150 - about five seconds into each clip regardless of frame rate, which suggests a consistent setup protocol rather than a coincidence.
- Trial start is a candidate, not an answer. The user confirms or moves the marker, the same "confirmation not entry" pattern as the ROI step. Two people disagreeing about trial start changes latency by seconds, which is exactly the silent inconsistency the facility manager described.
- AREA_MIN of 150 is currently clipping test53 (its minimum observed area is exactly 150). Partial mice mid-poke are being rejected as too small. Size bounds must be user-adjustable, not hardcoded.
- Blob area detects hole ENTRIES but not nose-pokes. test50 shows zero sustained area drops across 5,539 frames while test53 drops to zero at its terminal entry. The holes are shallow openings in a flat board, so investigating one occludes very little of the animal. Area is an entry detector, not an investigation detector.
- This inverts the two-signal plan: per-hole pixel change is the PRIMARY investigation detector, and blob area is the entry/confirmation signal.
- Behavioural read across the three clips, consistent with the learning curve the assay is designed to measure: test50 searches for 3:05 with no entry, test51 is cut off possibly mid-entry, test53 goes almost directly to target and enters at 0:27. test53 is a trained animal; test50 is not.
- Thigmotaxis vs investigation is separable geometrically rather than by a trained classifier: an edge-running mouse changes the hole's outer rim pixels while travelling tangentially, an investigating mouse changes the hole's centre pixels while slowing. No ground truth exists in this dataset, so a learned classifier would only reproduce my own labelling with less transparency.
- Hand-labelled frames will be used as a VALIDATION set to report detector agreement, not as training data.
- Hole detection: Hough circles on the median frame, filtered to a radial band (0.72-1.02 of platform radius) and sorted by angle. Found 20/20 on all three clips with no per-video tuning. The ROI step is therefore confirmation plus one click for the target, not 21 clicks per video.
- Holes are indexed 0-19 in ring order by angle. That ordering is what makes a search-strategy classifier possible: consecutive indices are serial search, scattered indices are random.
- Parking CV exploration here. Everything needed for the pipeline is validated: median background, mouse mask, platform circle, 20 holes, trial start, entry detection. Next work is the browser skeleton, which is what the user actually opens.
- Checked talmolab/vibes video-player before building the skeleton, as planned
9/1. It does NOT use
<video>+ requestVideoFrameCallback. It demuxes the MP4 with mp4box.js and decodes with WebCodecs, reading fps as an exact rational from container metadata (sample count / (duration/timescale)) rather than inferring it from rendered-frame timing. This is a strictly better fit for hard rule #2 than rVFC delta-measurement would have been. - Amended the "no dependencies beyond OpenCV.js" style rule to also allow mp4box.js. Reasoning: exact frame rate from container metadata is worth a second small, permissively-licensed dependency; an rVFC-only workaround would satisfy the old rule's letter while giving a worse (estimated, not exact) answer to the exact problem the rule exists for.
- mp4box.js is BSD-3-Clause (gpac/mp4box.js), same license as vibes itself. Both permissive, compatible with this project's MIT license. Attribution goes in THIRD_PARTY.md per the borrowed-code rule.
- Testing Piece 1 on test50 surfaced a real decode bug: frame 5490 failed
to decode every time. Root cause was a genuine duplicate CTS in the
container (frames 5490/5491 share timestamp 2818560), which broke the
decoder's timestamp-to-frame-index lookup. Fixed by nudging duplicate
timestamps by 1us at sample-extraction time;
ctsitself is untouched. See KNOWN_LIMITATIONS.md. Caught by manual frame-by-frame testing, not by the fps sanity check, which is exactly why per-frame stepping was worth building into Piece 1 instead of trusting the metrics alone. - test50's real fps, read from container metadata: 29.929755 (5539 frames / (2842624/15360 s)), confirming the container-metadata approach also works on the case that ISN'T the flagrant 15000/1001 example.
- Piece 2: OpenCV.js (CDN, pinned to 4.13.0, Apache 2.0) + median background, ported from scratch/median.py (60 evenly-sampled frames, grayscale, per-pixel median). OpenCV.js has no built-in median-across-N- images op, so that step is hand-rolled with a sorted typed array per pixel; grayscale conversion goes through cv.cvtColor for consistency with every CV step after this one.
- Verified on all three clips: median background is a clean empty platform with no mouse, and visually matches the validated Python output in scratch/*_median.png. Compute time ~1.5-1.9s per clip regardless of total video length (60 samples dominate, not total frames) - acceptable on the "2019 laptop, no GPU" target.
- Fixed an unhandled-promise-rejection spam bug found during Piece 2 testing: decodeRange() fires decoder.flush() without capturing its promise. flush() rejects with AbortError whenever a later decode closes the decoder before it settles, which happens constantly when sampling scattered frames. Not user-visible, but 25-58 uncaught rejections per video in the console is bad hygiene and would mask a real error. Fixed by attaching .catch() and logging only non-AbortError cases.
- Re-tested after the flush() fix: frame 3192 (previously also missing alongside 3191) now decodes fine. That was collateral damage from the unhandled flush() rejection, not a separate bug. Only 3191 itself - the actual duplicate-timestamp frame - still fails after its 1us nudge. Narrower and more explainable than first thought, but still not fully understood; left open in KNOWN_LIMITATIONS.md rather than guess-fixed further. Low priority: excluded and reported per hard rule #1, doesn't visibly affect the median with 59/60 samples.
- Piece 4: ROI confirmation. Merged docs/screens.md's Screen 2 (trial metadata dialog) and Screen 3 (ROI confirmation) into one screen instead of two sequential ones. Screen 2 was designed around "use the loading wait to collect info the user knows," but Piece 2 measured background compute at 1.5-1.9s and per-frame decode is on-demand ms-scale - there's no real wait to design around, so splitting into two screens would only add a click-through for nothing. One screen, right after auto-detection finishes, is fewer clicks - which is the actual design pressure on this screen given the brief's own "1,200 clicks and the facility goes back to the stopwatch" framing.
- The detected platform/hole overlay (Piece 3, previously read-only) is now interactive on the same canvas: drag a circle's rim to resize, drag its middle to move, click a hole (no drag, under a small movement threshold) to mark it the target. Hit-testing checks holes before the platform since holes are smaller and visually on top.
- Target hole is distinguished by shape, not just color (accessibility rule: no color-only meaning) - filled circle + dashed outline + a trailing star on its label, versus outline-only red circles for the rest.
- Adopted a two-tier flag system from a prior tool of mine, Trial Data Extractor (found at ../trial_extractor_copy_desktop, not part of this repo): a small per-field flag (purple) for "double-check this one thing" on any check-card, kept visually and structurally distinct from a whole-video flag (red, opens a native with preset reasons + a free-text note). This is the mechanism for micro-auditing later - a reviewer scanning many videos can see a lingering purple mark on one field without the whole video needing a red flag, and jump straight to that field instead of scrubbing frames.
- Confidence chips on each check-card show provenance (auto-detected vs. human-edited), not a numeric confidence score - there's no honest per-field confidence signal available from HoughCircles to show instead, and a fabricated one would violate the same spirit as hard rule #1. "Edited" vs "auto" is something the app actually knows.
- No add/remove-hole controls if detection finds != 20 holes. All three clips validated 20/20 (see Piece 3 above); a mismatch is flagged loudly (chip goes from "ok" to "check") rather than silently accepted, but building UI for a case not yet seen on real data isn't worth the time under this deadline. Logged in KNOWN_LIMITATIONS.md to revisit if a real video needs it.
- Platform diameter (cm) and target hole are required to Accept; trial metadata (number/cohort/group/animal ID) is optional, matching docs/screens.md's table. Diameter is required because without it path length reports in pixels, not cm - not a cosmetic default worth softening.
- Reload persistence (IndexedDB) deliberately NOT part of this piece, despite the brief calling it out ("losing 40 minutes of annotation kills adoption instantly"). It's a genuinely separate piece - session schema, survives-refresh wiring - not something to fold into "make the overlay draggable." Scheduled as the next piece, before tracking.
- Keyboard-only circle adjustment (arrow-key nudge) not built this piece; logged in KNOWN_LIMITATIONS.md. Flags and text inputs on this screen are fully keyboard-operable already. A dedicated accessibility pass (also covering 200% zoom, contrast) is its own later piece, not something to spot-fix per screen as it's built.
- Round-two fixes after clicking through Piece 4 on real hardware (small
screen, shared with this session):
- The resize-vs-move "edge band" around a circle's rim was a flat 8px
regardless of the shape's own radius. On a hole (~9-29px radius) that
ate nearly half the clickable area, so a click aimed at the middle
kept landing in "resize" instead - the exact "finicky" complaint.
Replaced with a band scaled to the shape's own radius
(
Math.min(10, Math.max(3, radius * 0.35))), so small holes get a proportionally larger safe click/move zone while the platform circle keeps a comfortably-sized resize band. Also loosened click-vs-drag tolerance (4px -> 6px canvas space) for a slightly shaky click. - The dashed ring marking the target hole read as fuzzy noise at ~10px radius. Replaced with a plain second concentric ring (still a shape difference from every other hole, not just color) - kept the star.
- Platform circle stroke width 2 -> 4, so it doesn't rely on color alone to read as a different kind of thing from the hole outlines (also 2px).
- Added a "Reset platform & holes to detected" button - geometry only, doesn't touch diameter/trial/flags/target, since those aren't things a bad drag would mess up.
- Added a target-hole
<select>next to the check-card as an alternative to clicking on canvas, synced both ways with canvas clicks. Chosen over enlarging the canvas into a popup (the other idea raised): cheaper, and it doubles as the fix for the keyboard-nav gap logged above, since a<select>is natively keyboard-operable.
- The resize-vs-move "edge band" around a circle's rim was a flat 8px
regardless of the shape's own radius. On a hole (~9-29px radius) that
ate nearly half the clickable area, so a click aimed at the middle
kept landing in "resize" instead - the exact "finicky" complaint.
Replaced with a band scaled to the shape's own radius
(
- Piece 5: reload persistence (IndexedDB). Two object stores, not one -
videoBlobs(the video file, written once on load) andsessions(roiState/confirmedROI, written on every edit). Splitting them means an edited text field never re-writes tens of MB of video. A thirdmetastore just remembers the most recently worked-on video, so a fresh page load knows what to offer resuming. - Only human-authored state is persisted (platform/hole positions as last left by the user, target hole, diameter, trial info, flags, confirmedROI). The median background and raw Hough-circle detection are never stored - deterministic and cheap (~1-2s) to recompute, so a resume re-runs them fresh rather than trusting a cached copy. This keeps the same "automatic vs. human-touched" separation as everything else in this piece (hard rule #5), instead of quietly treating a week-old cached detection as current.
- File objects are stored directly in IndexedDB (structured clone supports File, not just Blob, and preserves name/lastModified) rather than a separate {name, blob} wrapper - one less thing to keep in sync.
- Resume is explicit, never silent: a banner ("Resume test50.mp4?")
appears on load if a stored session exists, rather than auto-loading
into a state the user didn't ask for. Discard clears the stored video
- session entirely, not just hides the banner.
- Resume auto-cascades through Compute background -> Detect -> apply restored roiState, rather than requiring the user to re-click through steps they already did once. computeMedian() and runDetection() were pulled out of their click handlers into standalone functions so the resume flow can call them directly - same logic either way, just not gated behind a simulated click.
- IndexedDB failures (private browsing, storage disabled/full) are caught and logged, never block the app - a session that can't be persisted just isn't resumable after a reload; every other requirement (hard rule #1 in particular) still holds either way.
- Ran an early, cheap validation (scratch/hole_signal_check.py,
scratch/hole_signal_falsepos_check.py - not part of the app, throwaway
diagnostics) of the riskiest, still-unbuilt assumption in the whole
pipeline: that per-hole pixel-change vs. the median can actually tell a
real event from thigmotaxis/nothing, before spending Friday porting the
tracker to the browser around it.
- Positive check: at test53's known real entry (dips.py's terminal area dip, frame 819 = 0:27, matching the entry already noted from watching the video) the hole nearest the disappearance (hole 1) shows a sharp, isolated spike (meanDiff 28, 248px over threshold) while all 19 other holes stay at ~0 in the same window, and hole 1 itself is silent during an unrelated quiet window. Clean separation, one real event.
- Negative-control check: test50 has zero real entries across all 5,539 frames (already validated, see above). Scanning the whole clip found per-hole spikes of similar-to-larger magnitude than test53's real entry (pxCount up to 446, meanDiff up to 57) with nothing there to detect - almost certainly thigmotaxis brushing a hole's rim while running the edge, not investigation.
- Verdict: the approach is not broken, but a naive diff-magnitude-over-threshold signal alone will false-positive at real-entry scale. This isn't a new problem - it's exactly what the rim-vs-center geometric distinction and duration-gating already sketched in the two-signal-detector notes above were meant to solve; this is now evidence that refinement is load-bearing, not optional polish to add if there's time. The Friday tracking piece needs to build hole-signal detection with duration gating (mirroring dips.py's MINLEN=3 pattern - a single-frame threshold-cross is not enough) and the rim/center split from day one, not bolt it on after a naive version ships.
- Also worth noting for the "should I have used SLEAP instead" question this test was really asking: a pose model would replace the upstream tracking signal, not this downstream problem. "Is the mouse investigating this hole" still has to be defined from geometry/dwell time relative to wherever the tracker says the nose is, pose-estimated or not - swapping tracking methods doesn't sidestep the part that just failed the naive test.
- Concrete steps this changes for the Friday tracking piece, in build
order:
- Duration-gate every hole signal to 3+ consecutive frames before it counts as anything, mirroring dips.py's own MINLEN=3 for area dips - a single-frame threshold-cross is exactly what test50's false spikes look like, a real dwell is not.
- Split each hole's ROI into a center region and a rim ring; weight or require the center to dominate. This is the untested half of the original design (see the two-signal-detector notes above) - building it in from the first version, not after a naive threshold ships and needs a rewrite.
- Corroborate with the mouse's own tracked speed near the hole once the blob tracker exists: investigation should coincide with the animal slowing/stopping, thigmotaxis with sustained tangential speed. A third independent check, not required to ship first but cheap to add once tracking data exists.
- Re-run this same test53-positive / test50-negative-control pair against the refined (gated + rim/center-split) detector once built, in-browser, before trusting it in the UI - not just once, naively, and moving on.
- Whatever residual false-positive rate remains after all of the above is exactly what the two-signal-agreement design already exists to catch (agreement = confidence, disagreement = flagged for human review) - the correction UI isn't a fallback for this problem being imperfectly solved, it's the intended answer to a category of problem the brief itself says has no ground truth to perfectly solve against.
- Built the proof-of-concept for step 2 above (rim/center split) rather
than just planning it, using scratch/hole_signal_refined_check.py
against the same test53-entry / test50-false-positive frames. This
surfaced two real mistakes in the model's own exploratory code before
landing on what actually works - logging the wrong turns too, not
just the destination, since that's the point of this exercise.
- Mistake 1: first version compared raw pixel counts between the rim annulus and the center disk. The rim region has ~5x the pixel area of the center at these radii, so it will always out-count the center regardless of what's actually happening - an area-normalization bug, not a finding. Fixed by comparing density (fraction of each region's own pixels that changed) instead of raw counts.
- Mistake 2 (a real negative result, not a bug): with density fixed, EVERY test50 "false-positive" frame also passed the same duration-gated center-dominant threshold that test53's real entry passed - center-vs-rim geometry doesn't separate them. The rim/center hypothesis from the earlier plan is dropped; it was tested and didn't hold up.
- Mistake 3, the more important one: the model had already mislabeled what those test50 frames were false positives for. test50 has zero real ENTRIES (validated), but test50's mouse searches for 3+ minutes without entering - meaning it very plausibly has real POKES (investigation without entry), which is a completely different, unvalidated claim than "these are false." There is no ground truth that those six frames weren't genuine investigation.
- What actually separates real entries from not: whether the mouse's OWN overall blob also vanished at the same time (cross-checked against the already-computed scratch/test50_gated.csv). At all six test50 candidate frames the mouse's overall blob was fully tracked, normal area (613-889px, near the 525px in-trial median from dips.py). At test53's real entry, dips.py already showed the blob area crashes to 0 and stays there. This is exactly the disambiguation CLAUDE.md already specified ("mouse vanished + hole changed = entered") - the fix wasn't a new geometric rule, it was combining a signal that already existed with the hole-pixel-change signal, which the model had been testing in isolation instead.
- Checked mouse speed at the six test50 candidates as a partial second opinion on whether they're real pokes: average speed in each window (1.6-2.8 px/frame) sits at or above test50's own 75th-percentile speed (1.70 px/frame) computed across the whole trial (median 0.70). Suggestive that these are the mouse moving through, not pausing to investigate, but not conclusive - genuinely can't fully resolve this without hand-labeling, which is exactly what the brief means by "no ground truth exists on purpose."
- Revised verdict, replacing the "steps to address" list above:
- ENTRY detection: solid, validated two ways now (real positive + real negative control). Ship it as hole-pixel-change AND concurrent whole-mouse-blob-loss, not hole-pixel-change alone.
- POKE/investigation detection (mouse stays visible) is the genuinely hard, still-open part - simple geometry doesn't solve it, and I don't have ground truth to fully validate against. The right engineering answer isn't a cleverer single classifier, it's leaning harder into the two-signal-agreement design that already exists for exactly this: pixel-change (duration-gated, dropping rim/center) plus speed/dwell as a second, independent corroborating signal, and where they disagree, flag for human review instead of silently picking one. This test is what grounds why that design choice is load-bearing here, not just a hedge against imperfection.
- Followed up on real pre-entry investigation glances noted from actually
watching test53 (not inferred from a trace - watched behavior), rather
than only having one positive example (the entry) to validate poke
detection against. scratch/hole_signal_preentry_scan.py scanned every
frame of test53's pre-entry window (150-819) for duration-gated
hole-signal candidates and found 4: three brief investigations of other
holes (frames 150-236 at hole 0, 555-631 at hole 3, 713-716 at hole 2)
before the mouse settles on the target (774-819 at hole 1, running
straight into the already-confirmed entry) - a real serial-search
pattern, not noise.
- Checked mouse speed during each of these four windows the same way as the test50 false-positive check: all four sit at or below test53's own trial-median speed (0.19-0.71 px/frame vs. median 0.42), several with a minimum of 0 (fully stopped) inside the window. Every test50 false-positive candidate, by contrast, sat above test50's own 75th percentile (1.62-2.83 vs. p75 1.70).
- Pulled and looked at actual frames from two of the four candidates (scratch/frame_193_hole0_look.png, scratch/frame_796_hole1_approach.png) rather than trusting the numbers alone - both show the mouse's body stationary at a hole with its nose oriented into the opening, visually exactly what "investigating a hole" should look like.
- This is a real, grounded threshold candidate now, not a guess: a duration-gated hole-signal window qualifies as a poke only if the mouse's own average speed during that window is at/below its trial's own median speed (relative to its own trial, not a fixed px/frame constant - speed baselines will differ per rig/video, same reasoning as every other threshold in this project being video-relative rather than a magic number). Above that -> thigmotaxis candidate, not a poke, matching what happened in every test50 case.
- Upgrades the poke-detection verdict above from "genuinely open, no validated path" to "a specific rule exists, backed by both numeric and visual evidence across two videos." Still needs: (1) the same check on test51, (2) tuning the exact speed-ratio cutoff once this is built and run against real data rather than eyeballing four examples, (3) treating anything close to the boundary as flagged-for-review rather than a confident auto-call, per the two-signal-agreement design.
- Ran the same scan on test51 (scratch/hole_signal_scan_generic.py, full
trial range 75-740 - no confirmed real entry to anchor on here, since
test51 is the clip that cuts off possibly mid-entry, so this was a
blind scan rather than checking a known answer). Found 5 duration-gated
candidates: hole 12 (147-170), hole 11 (277-357), hole 10 (379-398),
hole 17 (500-524), hole 18 (687-740, running to the end of the scanned
range).
- The hole 18 candidate lines up almost exactly with dips.py's own, independently-computed area-dip for test51 (frames 734-736, position (457,165) vs. hole 18's (460,164)) - two different signals agreeing on where the "cut off possibly mid-entry" moment actually is.
- Applying the trial-relative speed rule (test51 median 0.80 px/frame): hole 12 (0.69), hole 11 (0.70), and hole 18 (0.70, the dips.py-corroborated one) pass; hole 10 (1.54) clearly fails; hole 17 (0.87) fails narrowly, right at the boundary. A plausible, not suspiciously clean, split - this is messier data than test53 (matches the already-known "bolt in the first 75 frames" note about test51 being the noisier rig), and the rule doesn't just wave everything through or reject everything.
- Pulled frames for hole 18 (passes) and hole 10 (fails) to look at. Both single frames show the mouse right at a hole - visually similar despite one passing the rule and one failing it. That's expected, not a problem: a single still can't distinguish "paused to investigate" from "passing through," which is exactly the argument for using speed-over-time instead of a geometric/visual snapshot rule in the first place.
- Net: the speed-relative-to-trial-median rule now has evidence across all three sample clips, not two. Not proof it's exactly right - still a hypothesis, to be checked against real per-frame tracking data once Piece 6 exists, per the plan of using this as a baseline to compare against rather than a final answer.
- Piece 6 (tracking) tested on test50: 5355/5539 tracked (96.7%), 184 failed, trial start candidate frame 150 - matches Python's already- validated frame 150 exactly. The 34-frame gap vs. Python's 150 failed (all of which are pre-trial, zero after) turned out to be the same duplicate-timestamp decode-miss defect from Piece 1, just never surfaced at real scale before now because nothing had sequentially decoded every frame until this piece. Corrected KNOWN_LIMITATIONS.md, which previously said this only affected one frame (3191) - that was an artifact of Piece 1 only ever sampling scattered frames, not a complete picture of the defect.
- Hand-labeled two real head-poke events on test51 by watching Piece 6's
tracking overlay frame-by-frame (not inferred from a signal - watched
and confirmed against the actual video), the validation-set use of
hand labels this project always intended, not training data:
- Event 1: frames 88-123 (36 frames). Searching, leaning in, investigating, then a clean disengage at 123.
- Event 2: frames 196-365 (169 frames). A different technique - stays more around the hole's edges, passes by the center rather than sitting in it; genuine investigation ends at 365 even though the mouse's tail lingers over the hole a little past that (frame 358).
- Real event durations (36, 169 frames) dwarf the MIN_RUN=3 floor from the earlier stress test, confirming that floor was always meant as a noise filter, not a target - no change needed, now backed by real numbers instead of an assumption.
- At frame 115 (mid-event-1), the tracked blob centroid sits on the mouse's rear rather than the hole, even though the animal is still actively investigating (confirmed on neighbors 114/116, which do show it at the hole) - an elongated animal's whole-body centroid can drift toward the tail without the animal disengaging. This is probably a non-issue for the actual hole-signal detector, which reads local pixel change at the hole directly rather than the blob centroid's position - but that's untested until Piece 7 exists, not assumed.
- The more important finding: event 1 (the longer, more involved investigation) showed the blob centroid sitting less squarely in the hole than a shorter event did. Centroid depth-in-hole is not a reliable proxy for investigation quality - direct confirmation (not just a design argument) that duration + hole-local pixel density, not blob-centroid position, is the right basis for classification, matching the architecture already chosen.
- Real per-video calibration data now exists for Piece 7 from three independent sources: the Python stress test (test53/test50/test51), and now hand-labeled ground truth on test51 specifically. Piece 7 should be checked against this labeled set before trusting its output, not just built and assumed correct.
- Piece 6 tested on test53: 684/905 tracked (75.6%), 221 failed, trial start frame 150 (matches Python exactly, again). The much lower tracked percentage here isn't a red flag the way it would be on another clip - cross-checked against scratch/test53_gated.csv, Python independently fails 67 frames after trial start too, and every one of them sits in 822-904: the mouse is actually gone, inside the escape box, which is correct behavior for the platform-circle gate, not a defect. Browser vs. Python gap here is only 4 frames (221 vs. 217) - much tighter than test50's 34, consistent with test53 having far fewer duplicate- timestamp frames to begin with (25 vs. test50's 162, see KNOWN_LIMITATIONS.md). Hand-verified against the tracking overlay: 822-825 and 826-854 failed with the mouse visibly in the hole, matching Python's independently-computed list almost exactly.
- Hand-observed something the automated scan didn't fully capture: the real behavioral engagement with the target hole runs 737-905 (mouse arrives, looks around, then commits and enters as the video ends) - 37 frames earlier than the 774-819 window the earlier Python duration-gate scan found as hole 1's qualifying run. The automated detector is catching the committed, sustained tail of the approach, not the full arc of "arrived and starting to look." Not a bug to chase down before Piece 7 exists, but real calibration information: don't expect the duration-gated window to equal the full behavioral event length, and don't tune thresholds against an assumption that they should match.
- Piece 7: event detection. Split into two stages deliberately - raw
per-hole center/rim density is computed once, folded into track.js's
existing frame loop (reuses the same
diffimage already computed for the mouse mask, so it's not a second full-video decode); classifying that data into events (duration-gating, entry/poke rules) is a separate, cheap function in events.js that runs on the stored numbers, so adjusting a threshold doesn't mean waiting through test50's 60+ second tracking pass again. - Entry classification checks whether the mouse's blob goes invalid in a window right AFTER the qualifying hole-signal run ends, not whether the blob is invalid throughout the run - test53's hand-labeled data showed the qualifying run (774-819) is mostly the approach, with the actual vanish only starting at its very end (dips.py's area-dip begins at 819). A "majority of the run is invalid" rule would have missed this real entry. ENTRY_LOOKAHEAD (10 frames) is a code constant, not a UI threshold - it's an implementation detail of the entry check, not one of the two recipes that were validated frame-by-frame against real data.
- Duration-gating tolerates gaps (default 2 frames) between qualifying frames without splitting a run in two, specifically because of the duplicate-timestamp decode-miss defect found while building Piece 6 - a real event shouldn't fragment because one frame in the middle failed to decode.
- All four UI-adjustable thresholds (center density, min run, gap tolerance, speed ratio) default to exactly the values validated in scratch/ - this is the hypothesis baseline going into its first real test against actual browser-computed tracking data, not a fresh guess.
- First real end-to-end test of Piece 7, on test50 (default thresholds,
no tuning): every detected candidate came back
poke, zeroentry- correctly matching the one video guaranteed to have no real entry anywhere in it. A mix of high/low confidence, not all-or-nothing, which fits test50's own description (a mouse still searching, not yet learned the maze) better than a suspiciously clean sweep would have. Hand-checked several: some show the tracked blob's crosshair not centered on the hole while the mouse's actual head still is, and the poke still gets detected correctly - direct, real confirmation that decoupling the hole-signal from blob-centroid position (the same thing the test51 frame-115 finding pointed at) is working as designed, not just a theory anymore. - Second real end-to-end test, on test53 (the decisive one - this is the
video with a known, hand-confirmed real entry): correctly detected as
entry, high confidence, frames 786-904 (running to the video's last frame, matching the mouse never reappearing), at the target hole - independently marked by hand and matching the app's own ★ target marker. Also found three earlierpokecandidates at that same target hole (343-353, 407-409, 413-419, 750-755) before the final commit, plus candidates at two other holes - a plausible check-around-then-commit pattern, not something predicted going in. Reporting a frame RANGE rather than a single timestamp mattered here in a way worth naming: the first frame of the range (786) doesn't need to look like a fully completed entry on its own for the classification to be honest, since the range itself already shows the mouse partway in by that point (confirmed by hand) - a single-frame stamp would have made this a real ambiguity instead of a non-issue. - Third real end-to-end test, on test51 (the clip already known to cut
off mid-entry): the final candidate - Hole 19 (★ the marked target,
checked three times earlier too: 546-549, 578-614, 638-662), 691-740,
ending on the video's literal last frame - came back
poke, notentry. Correct, not a miss: entry classification needs to observe the blob go invalid in the frames after a run, and frame 740 is the last frame that exists - there is no "after" for the footage to show. The detector can't report a vanish it never had the chance to see, sopokeat the target hole, high confidence, running to the last frame, is the honest ceiling of what this specific footage supports. A human reading the table with the already-documented "test51 cuts off mid-entry" context can correctly close that gap themselves - which is the two-signal design's whole stance (report the evidence honestly, let a human resolve what the evidence alone can't), not a gap to engineer around. - Added a per-event "Human review" dropdown right after the test51 finding above, on purpose - considered a plain purple flag (reusing the ROI screen's per-field flag exactly) first and rejected it: a flag alone says "look at this," not "here's what happened," and the whole point here is that someone reading this six months later shouldn't have to re-derive "oh, it's fine, the video just ended" from scratch. A structured choice (confirmed entry / confirmed poke / video ended, can't confirm) encodes the answer, not just the question. Explicitly scoped as a draft: no persistence (reload or re-detect both clear it - re-detecting can shift frame ranges under a threshold change, so keeping stale overrides around would be actively misleading, not just outdated), no free-text note. This was a deliberate build-now call, not scope creep on Piece 7 - it's needed for the same reason the ROI flags are needed, and the test51 case is a real one, not hypothetical. Logged explicitly in KNOWN_LIMITATIONS.md so it gets picked up and finished by the correction-persistence piece rather than silently staying a draft forever.
OPEN: merge rule for the visit sequence. Not yet built.
Step 1 — find it. A long single-hole candidate somewhere near frames 175-345, clip unknown (test50 most likely: longest clip, pure search, no entry). Re-run detection on each clip and look for a candidate run far longer than the usual 3-40 frames.
Step 2 — plot that hole's center density across the whole span, every frame, not just qualifying ones. Three possible shapes: flat elevated throughout -> one long visit, ~170 frames at one hole sawtooth down to baseline -> several visits; merge rule must be STRICTER than instinct says elevated, never near baseline -> one visit with shallow middles; this is the case the fraction rule is for
Step 3 — set REENGAGE_FRACTION from what the plot shows. Log the shape as the REASON for the value.
Decided already (don't re-litigate):
- Merge rule uses per-hole center density, NOT blob centroid position. Centroid is unreliable near holes during shallow pokes - same finding as test51 frame 115 and the test50 off-centre crosshairs. Reusing it here would import the exact defect the architecture routes around.
- Baseline is per-hole, taken from the median frame. Holes differ in lighting and size; a global baseline compares a shadowed hole to a lit one.
- Threshold is REENGAGE_FRACTION (density must fall to ~0.3 of the qualifying threshold before a re-rise counts as a new visit), default 0.3, UI-adjustable. Deliberately a fraction, not a frame count - a frame count is a number that would need defending and wouldn't survive it.
- Same rule defines "errors" vs "perseverative errors" in the measures. Written once, used twice.
- Strategy classifier: spatial / serial / random, plus MIXED. Mixed is not a fourth verdict - it is no verdict, displaying all three scores and the visit sequence so a researcher can audit and override via the per-event review dropdown.
- Hole adjacency is CIRCULAR: distance(19, 0) = 1. Use min(|a-b|, 20-|a-b|).
- Thigmotaxis/serial-search confound is already handled upstream: the qualifying gate is centre density + slowing, so rim-running frames never become events. This is design, not luck - it is why an index sequence is safe to read as search order at all.
- Sequence window ends at first target arrival. Strategy describes search; what happens after the mouse finds the hole is not search.
- If all three clips come back mixed, the bars are wrong, not the mice. Three trials cannot calibrate a classifier - set bars from the literature's shape and let the scores carry the doubt. Steps 1-3, resolved. Re-ran event detection (default thresholds) live against all three clips in a disposable headless browser (not the app itself - a throwaway Playwright driver in scratch, deleted after use; same real pipeline code, same real video files, no app changes). Found two same-hole candidate pairs close enough in time to be a merge question, not one:
- test51, hole 12 (display numbering): runs 198-256 and 277-357. Combined span 198-357 - close to the guessed 175-345, but test51, not test50 as guessed. The guess about which clip was wrong; worth noting so "most likely" language doesn't get trusted uncritically next time.
- test50, hole 6: runs 313-333 and 340-381.
Pulled raw per-frame center density (every frame, not just qualifying ones) for both, straight from trackResult.holeSignals - the same numbers the app itself computes, not a re-derivation:
- test51 hole 12's dip (frames 257-275) bottoms at 0.000 for five straight frames (271-275) - a genuine return to baseline. Sawtooth shape. Two real, separate visits, not one - the mouse fully disengaged and came back.
- test50 hole 6's dip (frames 334-339) bottoms at 0.257 and never gets close to baseline - density sits between 0.26 and 0.59 across the entire 311-381 span. Elevated-throughout shape, not sawtooth. One continuous ~70-frame investigation, fragmented into two reported events only because it dipped a few frames under the exact 0.30 qualifying cutoff.
Both shapes predicted in Step 2 turned up for real, in different clips, which is better calibration data than either alone - a merge rule tuned against only the sawtooth case could plausibly over-split, and one tuned against only the shallow case could plausibly over-merge.
REENGAGE_FRACTION=0.3 (density must fall to <= 0.3 * 0.30 = 0.09 before a re-rise counts as a new visit) checked against both real minimums: 0.000 <= 0.09 (test51 hole 12 correctly stays split) and 0.257 > 0.09 (test50 hole 6 correctly merges). The already-chosen default holds up against real data on both sides of the question, not just the side it was picked to handle - no change to the constant, but now it is a checked value, not an asserted one.
Next: implement the merge pass itself (group qualifying runs at the same hole where the dip between them never reaches REENGAGE_FRACTION, re-report as one visit) before starting the strategy classifier, which depends on a correct visit sequence.
Built: mergeVisits() in events.js, a separate pass over detectEvents()'s output rather than a change to findQualifyingRuns/classifyRun - those already drive the validated per-event review table and Piece 7's entry/poke classification, and merging is a different question (did the mouse leave and come back?) asked on top of that, not a change to it. A visit's reported type is its LAST merged event's type - how the engagement concluded, not how it opened.
Re-ran live against all three clips with the new "Merged visits" table (new UI, per her call - a visible check, not just plumbing to trust blind):
- test51: the validated hole-12 pair (198-256, 277-357) correctly stays two visits - matches the 0.000 baseline dip found above.
- test50: the validated hole-6 pair (313-333, 340-381) correctly merges cat >> DECISIONS.md <<'EOF'
Built: mergeVisits() in events.js, a separate pass over detectEvents()'s output rather than a change to findQualifyingRuns/classifyRun - those already drive the validated per-event review table and Piece 7's entry/poke classification, and merging is a different question (did the mouse leave and come back?) asked on top of that, not a change to it. A visit's reported type is its LAST merged event's type - how the engagement concluded, not how it opened.
Re-ran live against all three clips with the new "Merged visits" table (new UI, per her call - a visible check, not just plumbing to trust blind):
- test51: the validated hole-12 pair (198-256, 277-357) correctly stays two visits - matches the 0.000 baseline dip found above.
- test50: the validated hole-6 pair (313-333, 340-381) correctly merges into one visit, 313-381 - matches the 0.257 shallow dip found above. Four more same-hole pairs merged the same way elsewhere in test50 (holes 12, 14, 17, 7) - not individually checked frame-by-frame, but consistent with the same rule, not cherry-picked cases.
- test53: an unplanned but sensible third case - the last standalone
poke before the target-hole entry (750-755) merged with the entry
itself (786-904) into one visit reported as
entry, spanning 750-904. This is the same check-then-commit pattern already noted for this clip, now represented as one visit instead of two disconnected events - a real example of what this rule is for, not manufactured to test it.
Piece 8: measures. Primary/total latency and primary/total errors are not invented terminology - they're named and defined in the brief itself ("The Three Asks.pdf": "primary/total latency (time to first reach / actually enter the target), primary/total errors (non-target hole checks)"), matching Barnes CA (1979). Path length, speed, and quadrant time were already on that list; nothing else in it was missing.
- Quadrant time buckets by real measured angle from the platform center, centered on the target hole's own angle - not by hole-index arithmetic assuming even spacing. Simpler and doesn't depend on detect.js's Hough-circle angles being perfectly uniform.
- Path length / speed / quadrant time run over the whole recorded search (trial start to end of trajectory) regardless of whether the target was ever reached - a mouse that never finds the hole still travelled a real path. Latency and error-counting stop at first target arrival (primary) or actual entry (total), or fall back to the whole trial if that arrival/entry never happens - same "report the window we actually have" reasoning as everything else here.
- Built override-aware from the start, not bolted on later: takes the
same eventOverrides map Piece 7's human-review dropdown already
writes to. An override changes a visit's effective type for
latency/error purposes; a "video ended, can't confirm" override
produces an explicit UNDETERMINED total latency rather than silently
collapsing it into "not reached" - those are different honest
answers (a human explicitly flagged ambiguity vs. the detector simply
not finding anything), and collapsing them would be exactly the kind
of silent resolution this project keeps refusing to do. Every measure
touched by an override carries an
auditedflag - not tied to reload-persistence (which doesn't exist yet for overrides), just to this session's current override state, so re-clicking "Compute measures" after changing a dropdown already acts as an informal recalculate. - Path length / speed / quadrant time can never carry the audited flag
- there is no trajectory-point correction yet (see KNOWN_LIMITATIONS.md), only event-classification correction, so nothing about how they're computed can change from an override.
- Validated live against all three clips (throwaway headless-browser
driver again, not the app itself): test53 (real target, hole index
- shows a fast primary/total latency (6.45s/20.04s) and a short path (61.2cm), matching its known "goes almost directly to target" behavior. test50 (arbitrary target, hole index 5, heavily but never successfully poked) correctly shows total latency "not reached" despite 39 total errors and 24 perseverative errors across its long search. test51 (its own real target, hole index 18) shows "not reached" by default; applying a "video ended, can't confirm" override to its final candidate flips total latency to "undetermined" with the audited badge, while total/perseverative error counts stay identical between the two runs - correct, since neither run has a defined total- latency frame to bound the window differently, only the label for why changes.
Real bug, caught by hand-testing Piece 8 on test51: mismarked the target
hole at first, got a correctly-confusing "not reached" everywhere, fixed
the target and re-ran "Detect events" out of habit - which silently
wiped an already-applied human-review override, even though target hole
isn't an input to detectEvents()/mergeVisits() at all and the frame
ranges hadn't changed. The override-clearing rule was written for
threshold changes (which really can shift ranges), not target changes
(which can't shift anything), but the code cleared on every click either
way. Fixed: only clear eventOverrides when the actual threshold values
differ from the last successful run, tracked in lastEventOptions.
Verified: re-detecting with unchanged thresholds now preserves an
existing override; re-detecting after an actual threshold change still
clears it.
- Piece 8's measures section also got a per-measure
<details>disclosure for definitions/formulas (undergrad-friendly plain language, e.g. what "perseverative" actually means) in place of one dense paragraph at the top - same collapsed-by-default pattern already used for frame-index lists.
Added a fourth human-review option: "Confirmed: thigmotaxis (not a real
investigation)". Not a relabeling like entry/poke - a visit confirmed as
thigmotaxis is excluded from every measure entirely (not counted as an
error, doesn't count toward "reached the target" if it happened to be
at the target hole), since it wasn't a real check of that hole at all.
Required extending audited beyond the target-hole-only cases from
before: excluding a NON-target visit can change an error count without
moving any latency window boundary, so primary latency/errors now also
carry a real (previously hardcoded-false) audited check. Verified live
on test50: applying the override to three low-confidence "may be
thigmotaxis" candidates dropped primary errors 1->0 and total errors
39->38, both correctly marked audited.
Investigated whether point-level trajectory correction is actually needed, before deciding whether to build it - see KNOWN_LIMITATIONS.md for the full writeup (evidence, the explicit N=3 caveat, and a rough sketch for later if a future video needs it). Short version: scanned real trajectory data on all three clips for implausible frame-to-frame jumps and suspicious post-gap resumptions; found zero implausible jumps anywhere, including test50 (picked specifically for its constant wandering). The only real post-gap cases (test53, frames 826/854) were pulled as actual video frames and turned out to be the mouse's own tail still hanging out of the target hole - visually confirmed, not a tracking error. Tabled, not skipped: the reasoning and a forward plan are on record, not just an absence.
Tightened the measure definitions' wording and made them a real
accordion (<details name="measure-detail"> - native, no JS, Chrome
120+/Firefox 125+, already assumed by the WebCodecs requirement) so
opening one closes whichever was already open. Verified live: opening a
second definition correctly closed the first, only one open at a time.
Read Gawel et al. 2019 and Illouz et al. 2020 directly (the two papers the brief names) before designing the strategy classifier, rather than inventing scoring heuristics from scratch. Confirmed the spatial-score formula (straight-line distance / actual path length = "path efficiency") verbatim in Illouz. Found the actual named, citable strategy definitions in Gawel's Table 1 (via O'Leary & Brown), which are categorical pattern-matches, not the vague continuous heuristics originally sketched - see the classifier build entry below.
Also found, from the same table, that "perseverative errors" as originally built here doesn't match the literature's own term: what was built is Gawel's WORKING MEMORY error (re-checking a wrong hole, any number of other holes in between allowed). Their actual "perseverative" is stricter - re-checking the SAME wrong hole with NOTHING else checked in between. Renamed the original metric to workingMemoryErrors and added a new, correctly-strict perseverativeErrors alongside it - two different signals (general forgetting vs. fixation on one hole), not one metric under two names. Also added holeDeviationScore (circular distance from target to whichever hole was checked first), also from Gawel's Table 1 - independent of latency/error counts, captures how good the first guess was specifically.
Deliberately did NOT add a peripheral-vs-central path-length split, also in Gawel's table: their version uses a fixed cm radius tuned to their own apparatus, which doesn't transfer cleanly to our three different platform radii (208/222/208px) without inventing a threshold ourselves
- exactly the kind of unbacked constant this project avoids. Logged as a possible later addition if a real need for quantifying thigmotaxis time specifically comes up, not built speculatively now.
Verified live on test53 and test50: renamed working-memory-errors value matches the old perseverative-errors value exactly (no regression from the rename), and the new strict perseverativeErrors is meaningfully lower on test50 (3 vs. 24 working-memory errors) - a real, visible difference between "forgot eventually" and "fixated back-to-back," not two labels on the same number.
Piece 9: search-strategy classifier. Built from the literature's own categorical definitions (Gawel et al. 2019, Table 1, citing O'Leary & Brown), not invented heuristics - see the earlier entry for the exact wording. Renamed the "no clear verdict" case to UNCLEAR rather than MIXED, since the literature's "Mixed (random)" is the actual name of the third strategy, not "couldn't decide" - reusing MIXED for both would collide.
- Verdict requires exactly one categorical pattern to match (Direct: <=2 holes checked, all adjacent to target; Serial: 2+ holes, adjacent to each other, consistent rotational direction, starting away from target; Random: platform-center crossings between checks) - not "highest score wins." Confirmed this distinction matters with real data, not just in theory: test53 and test50 both produced a single pre-target hole adjacent to the target, which trivially scores high on BOTH Spatial and Serial (a lone step is "perfectly consistent" with itself) - the categorical gate (Serial requires 2+ holes) correctly called both Spatial; a naive highest-score rule would have wrongly called both Serial.
- Live validation: test53 (real target) -> Spatial, matching its known direct-approach behavior. test51 (real target) -> Unclear, and looking at the actual sequence (12->13->12->12->11->18->19-target) shows why: a direction reversal and a same-hole revisit before a big jump to hole 18 - genuinely doesn't fit any of the three patterns, which is an honest result on this project's already-documented noisiest clip, not a broken classifier.
- CENTER_CROSSING_FRACTION (0.4x platform radius) has no citation - the papers don't give an exact number - so it's a self-derived starting value like every other first-pass threshold here, flagged as such in the code rather than presented as literature-backed.
- Added a direct human-review override on the verdict itself (separate from the existing event-level dropdown), per an explicit request: a PI reviewing a correctly-event-tagged trial should be able to say "you're not wrong, but try this" without re-litigating every event. Shows the automatic verdict and the human override side by side, never hides the automatic call. Deliberately does NOT clear when the serial/random bars change and the trial is re-classified (unlike eventOverrides on threshold changes) - a human's holistic judgment about the verdict doesn't stop being valid because a score threshold moved. Verified live: override applies, persists through a bar change, and re-renders correctly.
- Also added "Prior trials completed" to the ROI trial-metadata section, at her request rather than a model-proposed addition - a pure human-entered context label (which session/day this is for this animal), never read by tracking/detection/classification code. Motivation: many real videos will look like test50 (searching, not finding) simply because the animal hasn't learned yet, not because anything is wrong - this gives a researcher reading the eventual export the context to tell those apart, without the program guessing at it itself. Verified live: round-trips into confirmedROI.trial.priorTrials correctly.
- Deferred, logged in KNOWN_LIMITATIONS.md: a confirmation step before an override applies ("this will change these fields, OK?") and a timestamped audit log of overrides, so six months later "why doesn't this match my memory of the video" has a real, replicable answer rather than a guess. Also her proposal, not a model suggestion - a real idea, explicitly not today's scope.
Hand-tested the classifier and measures screens against real footage (screenshots, not just re-reading code) and found two real bugs, both queued to fix next, not design questions - full detail in KNOWN_LIMITATIONS.md: Merged Visits doesn't route through effectiveVisitType(), so an overridden event's new type never shows in that table; total latency's audited flag can miss a case primary latency's own audited flag correctly catches, when a thigmotaxis override removes the only target-hole visit.
Also settled, this session, without building any of it yet:
- Threshold-change rationale-capture and an "audited badge says why on hover" both fold into the already-deferred override-confirmation/ audit-log piece - same underlying need (durable, timestamped storage of who-changed-what-and-why), not three separate features.
- Export's compute layer (computeMeasures(), classifyStrategy(), and whatever export/report generation comes later) stays decoupled from single-video UI state on purpose, specifically so a later consumer - a cohort merge tool in particular - doesn't require reworking anything already shipped. Explicit design principle now, not something to discover the hard way later.
- Cohort/batch analysis: tabled the same way point-level correction was, but pinned WITH INTENT as a real candidate to build near the end if time allows, not just noted and dropped - see KNOWN_LIMITATIONS.md for the E-Merge-style sketch (single-trial export stays the complete primary path; a separate, later merge tool consumes multiple single-trial export files; the live pipeline never needs to know "cohort" exists at all). This is deliberately a much smaller, more decoupled deferral than point-level correction was - the difference between "safe to defer" and "safe to defer" is whether deferring costs a rework later, and this one doesn't.
- A post-arrival "commitment" signal (reaches target, checks around or re-visits it, then finally enters) - her observation, already visible in test53's own real event trace, sitting just outside the classifier's deliberately pre-discovery-only scope. Sketched as two new named measures (commitment delay, target re-visit count), not a 5th classifier verdict - see KNOWN_LIMITATIONS.md for the full sketch, including an unverified possible connection to "vicarious trial and error" in the literature, flagged to check before building anything named after it.
Fixed both bugs queued above. Merged Visits now routes through effectiveVisitType() like measures.js/classifier.js already do, instead of reading the raw automatic type. Found one more gap while fixing it: nothing re-rendered the visits table when an override changed, so even the override-aware version would have shown stale data until some unrelated action refreshed the page. Merged Visits is cheap to re-render (no recomputation, just a different lens on already-computed visits), unlike Measures/Classifier which need an explicit re-run since those involve real computation - so it now updates live from the override dropdown's own change event instead of needing a button click.
resolveTotalLatency() now takes the full classified list and the target hole index directly, instead of the already-thigmotaxis-filtered visit list - it can now see a target visit that got excluded, so its override isn't invisible to total latency's audited flag anymore.
Verified live on test53: overriding the final target-hole event (the real entry) to thigmotaxis correctly flips total latency to "not reached" WITH the audited badge (previously showed "not reached" without it, silently losing the same signal primary latency already carried correctly), and Merged Visits immediately shows "thigmotaxis overridden" for that row with no re-click needed.
Correction persistence, built the same night rather than left hanging overnight, per her call. eventOverrides and classifierOverride now survive a reload, joining the same IndexedDB session record Piece 5 already built for ROI state - not a new persistence mechanism, an extension of the existing one.
- The real subtlety, planned for up front rather than discovered as a
bug: an override is only meaningful paired with the exact
tracking/event thresholds that produced the frame ranges it's keyed
to (
holeIndex:startFrame:endFrame). Tracking is never auto-restored on resume (confirmed: resume only auto-reruns median + platform/hole detection, same as before this piece - the user still clicks Track themselves), so a correct restore has to persist the THRESHOLDS too, and seedlastEventOptionson resume so the first post-resume "Detect events" click recognizes them as unchanged instead of wiping the very overrides just restored. This is the same class of gap as this session's earlier override-wipe bug, this time designed for instead of found after the fact. - Storage saves at the moment each button (Track / Detect events / Classify) is clicked, not on every keystroke - what's persisted is always the values that actually produced the current data. The two override selects also persist immediately on their own change event, since waiting for the next button click would lose a correction made right before an unplanned reload.
- Found and fixed a real correctness gap in storage.js while building
this:
saveSession()'sput()fully replaces the stored record, so adding a second save path (corrections) alongside it would have let either one silently wipe whatever the other last saved. Both now go through a shared read-then-write merge instead of a plain put - deliberately two separate transactions (read, then write), not a get-then-put inside one transaction, since awaiting a request mid-transaction risks the transaction auto-committing first in some browsers. - No new "Recalculate" button needed - "Compute measures" and "Classify search strategy" already re-read current override state when clicked; that was already the recalculate mechanism, it just didn't survive a reload before now.
- Verified live end to end on test53, including an actual page reload (not just re-clicking within one session): tracked with a non-default mask threshold (35), applied an event override and a classifier override, reloaded the page, resumed, confirmed the threshold input restored to 35 before re-tracking, re-ran Track -> Detect -> Classify, and confirmed both overrides reappeared exactly where they were, keyed to the identical frame range. The resume banner also now says how many saved corrections are waiting, so it isn't silently unclear why they don't show up until re-run.
Repo-quality audit against the brief's actual 7 scoring dimensions, checked directly rather than assumed: live deployment confirmed working (HTTP 200, auto-deploys on push), 64 real commits with no squashing, .claude/ correctly empty (matches AI_NOTES.md's own "no subagents/hooks/ MCP used" - not a gap, a consistent fact). Two real gaps found: README still had four "(coming)" placeholders including Setup - the literal thing "cold clone works" tests - and there are zero automated tests or CI, though the CV-heavy parts of this app genuinely aren't well served by unit tests (that's what the live validation throughout this log is for); the pure-logic functions (circularHoleDistance, effectiveVisitType, resolveTotalLatency, the classifier's scoring math) are the honest exception - cheap to test, zero coverage, and both real bugs found tonight were in exactly that category. Test/CI work deliberately deferred to tomorrow, before the export piece, not done tonight - export will add more pure logic worth testing too, so building the harness once covers both.
Rewrote README.md as a filled-in-later outline instead of leaving the placeholders as bare "(coming)" markers, so Monday's polish pass starts from a structure with specific prompts, not a blank page. Resolved one placeholder immediately with real evidence rather than leaving it speculative: confirmed directly (Playwright, file:// protocol) that both WebCodecs and IndexedDB work correctly when index.html is opened straight from disk - no local server is genuinely needed, so "how to run it" can say that as a checked fact.
Test suite + CI, the gap the repo-quality audit above flagged, built
before the export piece as planned rather than deferred again. Plain
Node - node:test + node:assert/strict, both built into Node,
nothing to npm install - covering the pure-logic functions the audit
named: circularHoleDistance, effectiveVisitType, getRealVisits,
resolveTotalLatency, quadrantIndex, signedCircularStep. 16 tests,
all passing.
- app/js/*.js stay plain global
<script>files with no module system, by design (no build step). Rather than addmodule.exportsshims into production code to make them Node-requireable, tests/helpers/ load-app.js loads the needed files into a sharedvmcontext in the same order index.html's script tags do - zero modification to app/js itself. - That
vmcontext is a separate JS realm: a result object built inside it has a differentObjectprototype than one written in the test file, soassert.deepStrictEqualreports "same structure but not reference-equal" even when the two are identical data. Fixed by atoPlain()helper in the same file that walks a vm-context value into an ordinary object of the caller's realm before comparing - not a workaround for a bug in the app code, a mismatch between two correct things (vmrealms and strict-equal's prototype check). - Direct regression test for tonight's
resolveTotalLatencybug (bc3a33f): aclassifiedlist where the only target-hole visit is overridden to thigmotaxis must still resolveaudited: true, not silently drop it. The Merged Visits bug (df89719) lived in DOM rendering (renderVisitsTableskippingeffectiveVisitType), not in pure logic, so it has no equivalent unit test here - the fix was routing througheffectiveVisitType, which this suite does cover. node --test tests/(directory form) reproducibly fails on this machine/Node 24 - it tries torequire('tests')as a module instead of discovering test files, throwing MODULE_NOT_FOUND. Not investigated further since a workaround was immediate:node --test tests/*.test.js(explicit glob) works correctly and is what bothpackage.json'stestscript and .github/workflows/tests.yml (npm test, Node 20 on ubuntu-latest) actually run.
Two small fixes from hand-testing all three clips end to end:
- The Merged Visits "human touched this" badge said "overridden," the
Measures table's said "audited" for the identical condition (both use
eff.audited) - one label for one concept now, "audited" throughout, since the app already defines that term in the Measures subtitle. - Real gap, not a preference: dragging a new video onto the drop zone
(or using Browse) bypassed the resume banner entirely, loading
immediately and abandoning whatever it was offering to resume. Browse/
drop are now locked (
fileInput.disabled+ a.lockeddim state on the drop zone, plus an explicit guard in the drop handler as a second layer) for as long as the resume banner has an unresolved offer on screen, and unlocked the moment Resume or Discard is clicked.
Piece 10 (export), first sub-piece: trial summary CSV. Scoped against the brief's own export paragraph, quoted directly rather than re-derived from memory - "CSV/XLSX openable in Excel with no legend needed: one tidy row per trial plus per-event detail, parameters and tool version included. A reloadable intermediate file so the facility can re-analyze without re-tracking." That last sentence is a second, distinct deliverable (the reloadable project file, scoped separately - see scratch/spec-project- file.md, not committed, a build spec written by a separate Claude web session at her request and reviewed here before adopting), not part of this sub-piece.
- CSV only, not XLSX, for now - a real dependency decision, not an oversight: real .xlsx needs a third-party library, breaking the "no dependencies beyond OpenCV.js/mp4box.js" rule, where CSV needs none and the brief's own wording is satisfied by CSV alone. Her call. Logged with a concrete bolt-on plan in KNOWN_LIMITATIONS.md, not just noted - row assembly is kept decoupled from the CSV-writing function specifically so XLSX can be added later as one new function, not a rework.
- Tool version: a hand-bumped constant (
APP_VERSIONin export.js), her call over a date stamp - no build step means no automatic version source, so this is the manual equivalent, bumped when something export-relevant changes. - New file
app/js/export.js, same convention as events.js/measures.js/ classifier.js: pure functions, no DOM access.assembleTrialSummaryRow()builds one plain object (column header -> value);toCSV()is a generic RFC 4180 writer. app.js owns the actualBlob/anchor-click download, since that needs the DOM. - Real staleness risk found and designed around before it could become a
bug, not fixed after:
trackResultandcurrentVisitsare cached, not recomputed at export time, so if the user edits a threshold input without re-running Track/Detect events, the live input no longer matches what actually produced the data on screen - exactly the class of bug this session already fixed twice (Merged Visits, total latency). Export now readslastTrackOptions/lastEventOptionsFull- the options objects captured at the moment Track/Detect events actually ran - never the live inputs, for the "parameters used" columns. Classifier options are the one exception with no such risk (classify- Strategy() recomputes fresh from whatever options it's given, with no cached intermediate to go stale) - export useslastClassifierOptionswhen Classify has been run (so the exported verdict always matches what's currently displayed, never a silent recomputation), and falls back to the live classifier-bar inputs only when Classify has never been run at all, where there's nothing on screen yet to be inconsistent with. - Export button enables at the same point Measures/Classifier do (once events are detected) rather than requiring those buttons be clicked first - measures/classifier are always computed fresh from cached data at export time regardless, so there's no correctness reason to gate on a prior click, and it's one fewer required step.
- Added
tests/export.test.js(4 tests: toCSV escaping/empty-input, and assembleTrialSummaryRow's key fields) to the suite built yesterday, extending it rather than leaving it static. Hit a real vm/Node quirk while writing it: a top-levelconstin a plain vm script does NOT become a property of the global object the wayvarand function declarations do, so destructuringAPP_VERSION(aconst) out of the loaded context was alwaysundefinedeven though it works correctly inside export.js itself - not a bug in export.js, fixed by asserting the version string's shape in the test instead of reading the constant directly.
Trial summary CSV verified live on test51 (with a real thigmotaxis override applied to hole 11) before moving to the next sub-piece: fps matched the original Piece-1 load-time display exactly (15.005265, not a clean 15000/1001 - that CLAUDE.md line was illustrative shorthand, not test51's literal value to 6 decimals - confirmed rather than assumed, screenshots checked directly), and the exported hole sequence correctly excluded hole 11, matching the thigmotaxis override visible in both Merged Visits and the event-review dropdown. Doubled as an end-to-end proof that overrides flow correctly all the way to export.
Piece 10, second sub-piece: per-event detail CSV (the long form). One
row per merged VISIT, not per raw candidate event - a visit is the
meaningful reviewed unit visible in Merged Visits and counted by
measures.js, where a raw event is often just a sub-part of one.
assembleEventDetailRows() routes through the same effectiveVisitType()
helper Merged Visits/measures.js/classifier.js already use, specifically
so this new export path can't independently drift out of sync with
what's on screen the way Merged Visits itself once did (df89719). Video
file repeats on every row (not just the summary row) so each row is
self-contained and traceable without joining back to anything. 2 more
tests (22/22 passing).
Cross-checked both CSVs by hand against the underlying event data across all three clips before moving on - not just visually spot-checked. test53: primary/total latency exactly matched (targetVisit.startFrame - trialStartFrame)/fps using the documented frame-150 trial start; error counts, hole deviation score, and the "1 > 2" sequence all matched the 4-row detail export. test50: target hole 3 never appears anywhere across 45 detail rows, consistent with "not reached" on both latencies and with this clip's already-documented zero-entries behavior - the "3" still shown at the end of the exported sequence is classifyStrategy()'s own convention of always appending the target as the chain's destination, found or not, not a claim it was visited.
Piece 10, third sub-piece: field-selection checkboxes on the trial
summary export (not the event detail export - scoped there deliberately,
40+ columns is where trimming has real value, 12 already doesn't).
Grouped (Trial info / Measures / Search strategy / Parameters used /
Metadata) with select-all/select-none. The group/field list lives once,
in export.js's TRIAL_SUMMARY_FIELD_GROUPS, and app.js renders
checkboxes directly from it rather than hand-listing fields a second
time in HTML - the checkbox list can't list something
assembleTrialSummaryRow() doesn't actually produce, or omit something it
does, since both read the same source list. "Video file" is never a
checkbox - always included, since a row with no video identifier isn't
traceable back to anything, matching the "every reported number links
back to the frame that produced it" rule. All fields checked by default.
2 more tests (24/24 passing).
Field-selection groups became native <details name="exportFieldGroup">
accordions, per her request after seeing how much vertical space five
always-open groups took - same trick already used for measure
definitions (measureRow()), reused rather than hand-rolling new JS.
Collapsed by default; opening one closes whichever other was open,
free from the browser. Purely visual - doesn't touch which checkboxes
are selected.
Her catch, not found by testing: "Primary latency (s)" was already the POST-override value (every measure is computed with overrides applied, there's no separate pre-override number anywhere), and "Primary latency audited" was only ever a yes/no flag, not a second number - so there was no way to actually see "what the pipeline said" next to "what we corrected it to," just a flag that they differ. Already solved identically for the classifier ("Search strategy verdict" vs. "Search strategy automatic verdict" were already two separate columns) - this extends the same pattern to the numeric measures instead of leaving it classifier-only.
- New "Original pipeline calculated" field group, unchecked by default (unlike every other group) - the audited numbers stay the default export, this is an explicit opt-in comparison view, her call on the ordering.
- Only 6 of the measures actually have an original-vs-audited distinction: primary/total latency, primary/total errors, working memory errors, perseverative errors. Hole deviation score, path length, speed, and quadrant time are pure trajectory arithmetic that no event override can touch at all (measures.js's own top comment already says so) - no original-vs-audited column for those, since there is no "original" distinct from what's already shown.
- Computed via a second computeMeasures() call with
{}for eventOverrides - cheap (pure JS over already-cached trackResult/ currentVisits, no video decode), so it always runs rather than only when overrides exist, one code path instead of a "nothing to compare" special case. - 2 more tests (25/25 passing), including one with a real audited vs. original divergence (a thigmotaxis override removing a counted error), not just both columns holding the same number.
Her follow-up catch: even with "Original pipeline calculated" now
available, the default Measures group still had "Primary latency (s)"
sitting next to "Primary latency audited" as two separate checkboxes -
still read like two competing values to pick between, even though the
second was only ever a yes/no flag. Fixed by removing the 6 standalone
"X audited" columns entirely and replacing them with one global toggle
("Label audited values", checked by default) that appends "(audited)"
directly into the value's own cell when it applies - one number with an
inline note, not two parallel columns. The "- original" columns are
untouched by this toggle either way and stay unambiguous by their own
column names - they're never audited by definition (computed with no
overrides), so there was never anything to label there. labelIfAudited()
in export.js, labelAudited defaults to true on
assembleTrialSummaryRow(). 1 more test (26/26 passing), covering the
toggle both on and off.
Piece 4, first half: the reloadable project file's SAVE side. Build spec
was scratch/spec-project-file.md (gitignored, not committed) - a build
spec written by a separate Claude web session at her request, reviewed
against the actual codebase before adopting (one correction: it invented
a fps: {num, den} field that doesn't exist - the decoder already
returns the real exact-rational ingredients, totalFrames/timescale/
trackDuration, just not previously kept around after load).
Adopted with two deliberate deviations, both her call after talking through the shape first (not a silent departure from the spec):
- Stores ONLY
trajectory+ hole signals, not events/visits/measures/ strategy. Those are cheap, pure recomputations from trajectory + thresholds + overrides - the same functions export.js already calls fresh rather than trusting a cached value. Storing them frozen would risk a stale mismatch if the app's own logic changes later (a bug fix wouldn't apply retroactively to an old saved file) - one less thing that can silently drift out of sync with the code that produced it. Also directly matches the brief's own verb: "re-analyze," not "replay." - Real field names in the trajectory (
frame/cx/cy/valid, exactly as track.js already produces them), not the spec's compact shorthand (f/x/y/ok). Her reasoning: this needs to be at least squint- readable in a plain text editor on a machine with nothing better, even though it won't be comfortable reading.
New app/js/project-file.js: buildProjectFile() assembles the plain
object (format/formatVersion/toolVersion/savedAt, video fingerprint,
roi, metadata, thresholds, results, overrides, flags);
serializeProjectFile() is JSON.stringify(..., null, 2) - real
2-space indentation, never minified onto one line, since a researcher
may open this directly, and being able to follow the chunking at a
glance is a stated requirement, not a nice-to-have.
Extended videoInfo/currentFileSize/currentFileLastModified as new
retained state in app.js - decoder.init()'s return value was
previously read once for the load-time display and discarded; the
fingerprint needs it kept around. "Save project file" button appears
(hidden, not just disabled - there's nothing to hide behind a disabled
state before this) right when tracking completes, per the spec ("before
that there is nothing worth saving") - earlier than Detect events/
Classify, so thresholds not yet exercised fall back to live input
values, same posture already established for export's classifier
fallback.
Found and fixed a real cross-realm bug in the test harness itself while
writing tests for this (not app code): tests/helpers/load-app.js's
toPlain() used value.map(toPlain) for arrays, but
Array.prototype.map() constructs its result via the SOURCE array's own
realm/species, not the caller's - so a vm-context array survived as a
non-plain array even after every element inside it was flattened,
failing deepStrictEqual the same way the original bug (bare object
literals) did. Fixed by building the output array by hand with a
literal [] from load-app.js's own realm. 5 new tests (31/31 passing),
including the one that caught this.
Piece 4, second half: the reloadable project file's LOAD side, completing it. A separate, explicit "Open project file" control next to the plain video picker, not one drop zone guessing at file type - proposed in response to her direct question about which approach to take, matching the scratch spec's own suggestion; she approved before it was built.
Flow: pick the .bmp.json -> parse + validate format/formatVersion (refuse a newer, not-yet-understood version rather than guess at it) -> show a summary (source video, saved date, correction count) before asking for anything else -> pick the matching video -> a throwaway decoder peeks its container metadata ONLY (no frame decode) to check the fingerprint before committing to anything -> strict refusal (names both values) on frame count/duration-and-timescale/dimensions/file size, a warning-but- continue on filename only (people rename files), lastModified never checked at all (copying a file changes it) -> state restored, tracking skipped entirely, landing on a fully populated events/measures/classifier view with no further clicks.
Real refactor first, not optional: extracted finishTracking(),
runEventDetection(), computeAndShowMeasures(), and runClassification()
out of their click handlers (same reasoning already applied to
computeMedian()/runDetection() - "pulled out of its click handler for the
same reason"), so the restore path calls the exact same functions a
manual walkthrough uses instead of a second, parallel implementation of
Track/Detect/Measures/Classify that could drift out of sync with the
first one. This is the same principle applied at a larger scale than
anywhere else in the app so far.
One deliberate, logged deviation from "should not trigger the median" in
the scratch spec: computeMedian()/runDetection() DO still run on a
project-file load, purely to populate the ROI review canvas (which needs
medianResult.medianGray to draw). This is real, non-free work - a few
seconds, not the tracking-scale cost - but it's exactly what the existing
browser-only resume already does automatically, and skipping it here
would mean the two resume paths behave inconsistently for a comparatively
small cost. The actual expensive step, tracking itself, is still fully
skipped - that's the real point of the whole piece, and it's intact.
Corrected KNOWN_LIMITATIONS.md's cohort/batch-merge sketch, written before today's project-file/CSV-export split existed: the batch tool's real input is the CSV trial-summary export, not the project file - the project file deliberately has no computed measures to hand it, and making a merge tool re-run the pipeline's own logic a second time would be a real consistency risk, not "easier leverage" (her framing, and the right instinct - confirmed by working through both options with her before touching the doc).
9 new tests (40/40 passing): parseProjectFile's format/version validation, and checkVideoFingerprint's strict-refuse vs. lenient-warn behavior across every field the fingerprint actually checks.
Two real bugs found by her hand-testing on the very first live load, both fixed:
centerDensity/rimDensityareFloat32Arrayper hole in production (track.js), not plain arrays -JSON.stringifydoes NOT serialize a typed array as a normal array, it serializes it as a plain object ({"0":0.1,"1":0.2,...}), which has no.length. Every project-file load was silently finding zero events as a result:findQualifyingRuns()'sconst n = centerDensity.lengthcame backundefined, so its main loop (i < n) never ran a single iteration. Reproduced directly (not assumed) before fixing: confirmed the exact{"0":...}shape with a throwaway Node script, confirmedArray.from()on each hole's array before serializing fixes it. Original tests didn't catch this because the fixture used plain arrays for holeSignals instead of Float32Array - fixed the fixture to match production and added a regression test that exercises the actualserializeProjectFile->JSON.parseround-trip, not justbuildProjectFile()'s return value (the bug only appears after really passing through JSON).- The browser-local (IndexedDB) session record for a project-file-loaded
video never got a
roiStateat all, sinceacceptRoiBtn's handler (the only place that normally callspersistCurrentSession()) is never triggered by a restore. A laterpersistCorrections()call (fromrunEventDetection()/runClassification()in the auto-cascade) would then merge into a session record missingroiStateentirely, and a subsequent page reload's resume-check crashed trying to deserialize it (caught internally, so nothing broke visibly, but the resume banner silently failed for that video - this is thedeserializeRoiStateTypeError she found in the console). Fixed by callingpersistCurrentSession()right after ROI restores inloadFromProjectFile(). Also moved the existingpersistCorrections({ trackOptions })call from trackBtn's own click handler intofinishTracking()itself, so both the real-track path and the project-file restore path persist thresholds the same way, from one place, instead of the restore path needing a second, separate call that could fall out of sync with the first.
1 more test since the count above (41/41 passing).
A third issue surfaced by the same hand-testing pass, worse than the first two. She clicked Resume on the browser-local banner, then (while that cascade was still running) clicked "Open project file" - the page froze on a stale "Loading test51.bmp (1).json..." status line, with a real video frame rendered underneath it, and she reported it as a freeze without a diagnosis attached. Diagnosis found a real race condition - a timing issue between two async cascades, not a pure-function output, so nothing in the test suite could have caught it. Root cause, in two parts:
resumeBtn's handler unlocked browse/drop the INSTANT Resume was clicked, before its own median/detection cascade even started - reopening the exact window the drop-zone-lock piece was built to close, for anything with its own multi-step async chain afterloadVideo()returns (which, before today, was only the resume path - the project-file path now shares the same shape).loadVideo()itself unconditionally unlocked on every call, so even fixing resumeBtn's handler alone wouldn't have helped - the SECOND loadVideo() call (triggered by the project-file flow) would still reset the lock partway through the first one's cascade.- A third contributing gap: the
.lockedCSS class only ever dimmedopenProjectFileBtnvisually (opacity) - the button itself was never actually.disabled, so it stayed fully clickable through the whole window regardless of the state of the fix above.
Fixed by making the lock cover a whole cascade's actual duration, not
just the moment of the click: loadVideo() takes a new keepLocked
option (default false, preserving the plain fileInput/dropZone
behavior exactly as before) - when true, it leaves unlocking to the
caller instead of doing it internally. resumeBtn's handler and
loadFromProjectFile() now pass keepLocked: true and explicitly
unlock at every one of their own exit points (success and every early-
return), so a failure partway through can never leave the UI
permanently locked either - that would have been a worse regression
than the race it fixes. setInputLocked() now also disables
openProjectFileBtn directly, closing the visual-only-dimming gap.
Same-class principle as the two Float32Array/persistence bugs above: surfaced by her actually trying to break something adjacent to the new feature - she reported the symptom in each case (zero events, a console error, a freeze), not a diagnosis. Working out what kind of bug each one actually was (a serialization edge case, a cross-feature persistence gap, a timing race) came after, and none of the three are pure-function bugs the existing testing approach is built to catch. Logged as a reminder that hand-testing interaction BETWEEN features, not just each feature in isolation, is still doing real work this project's automated tests structurally cannot.
Before calling Piece 10 + Piece 4 done, a real browser-automation stress pass (her call, explicitly wanted before moving to the UI/UX redesign) against the actual running app - not simulated, not mocked - covering exactly the class of bug the pure-function test suite structurally can't catch: timing races and cross-feature interaction, using the same tooling the resumeBtn race above was verified with.
Verified directly, with real DOM interaction and internal state inspection (not screenshots alone):
- The resumeBtn race fix holds under real polling:
openProjectFileBtn. disabledstayedtruefor the entire ~1.5s median/detection cascade, never flipped early. - The
inputLockeddefense-in-depth layer works independently of the.disabledattribute: forcedopenProjectFileBtn.disabled = falsemid-cascade to simulate that layer failing on its own, clicked it anyway - the click handler's owninputLockedguard still blocked it. Both layers hold on their own, not just in combination. - Full real pipeline end to end on test50 (real ROI canvas clicks, real
5539-frame track, real event detection: 50 events) -> saved a real
3.7MB
.bmp.json-> confirmed the Float32Array fix directly in that actual file (Array.isArray(centerDensity[0]) === true, correct length) -> reloaded the page, Resumed cleanly, THEN loaded that same project file against its matching video: "Found 50 candidate events," matching the original live run exactly, zero console errors. - Mismatch refusal is specific and recoverable: picked the wrong video against test50's project file, got "Expected 5539 frames, this video has 741," retried immediately with the correct video with no stuck state left over from the failed attempt.
- Cancel mid-flow (before picking a video) clears cleanly - both inputs re-enable, a completely different video loads right after with no residual state.
- Export with every field checkbox deselected still downloads correctly
(the one always-included
Video filecolumn survives, inspected the actual CSV content directly, not just the success message). - Export with zero detected events: trial summary CSV still downloads
with correct values (
not reached,0errors, a correctly-blank hole deviation score since there's no first visit to score, path length unaffected since that's pure trajectory math, no crash); event detail CSV correctly short-circuits to "No hole visits to export" instead of producing an empty file.
One minor, non-blocking rough edge found along the way, not fixed:
uploading the wrong file type (an .mp4) into the project-file JSON
input fails gracefully (no crash) but with a slightly cryptic message
("Unexpected token... is not valid JSON") rather than a plain-English
"that's not a project file." Low-priority polish, not a correctness gap.
Real, pre-existing gap surfaced by this pass, unrelated to either of
today's two pieces - logged in KNOWN_LIMITATIONS.md rather than fixed on
the spot: the search-strategy classifier calls a trial with ZERO
detected visits "spatial" (direct), because isDirect's check
(sequence.length <= 2 && sequence.every(...)) is vacuously true on an
empty sequence - "the mouse never checked anything" gets mislabeled as
"went straight there," which are opposite findings.
Tooling note for reproducing any of this later: the browser-automation
tool categorically rejects file:// URLs - serving app/ over a local
python -m http.server for the session's duration is the workaround.
Scoping the video-roster / multi-video-session redesign (from her Figma mockup + brief, both reviewed directly rather than assumed). Real architectural finding worth recording before any of it gets built: this is NOT the same risk as the already-tabled point-level correction idea. Point-level correction was tabled because it would have changed what an override MEANS, rippling through every consumer's assumptions. This migration only changes WHERE state lives, not what any of it means
- events.js/measures.js/classifier.js/export.js/project-file.js already take their data as parameters rather than reading globals, so none of that layer needs to change at all. Confirmed the scale directly (not guessed): ~285 reference sites for the ~19 variables that need to become per-video, across a 1643-line file. Rather than touch all 285 sites, settled on a much smaller design: since only one video is ever "in the workspace" at a time (her own Figma brief's words), the existing bare variables stay exactly as they are - a snapshot-swap on video switch (save the outgoing video's variables, load the target's back into the same names) replaces what would otherwise be a file-wide rewrite.
Two real decisions made with her before writing code, not defaulted:
- Resume-on-reload restores the WHOLE roster, not just the last-active video - with a single-viewer/roster-viewer toggle at the UI layer (cheap once the whole roster's data is already restored underneath - a display choice, not new data). Deferred to the visual-redesign phase, not built now.
- Decoders are lazy - only the active video ever has a live one, released on switch - given the brief's own "2019 laptop, no GPU" constraint and a folder that could hold hundreds of videos.
- Switching back to an already-tracked video needs to be instant, not a re-track - which surfaced a real, useful side effect: this requires trackResult to be persisted per video, not recomputed on every visit.
First concrete slice, built and tested before any roster UI exists:
extended the browser-local (IndexedDB) resume feature to persist
trackResult too - the exact same exception already made for the
portable project file, just automatic instead of behind an explicit
Save click. finishTracking() now saves trackResult alongside the
thresholds it already saved; resuming a session that has one skips
re-tracking entirely and cascades straight to a populated events/
measures/classifier view - reusing loadFromProjectFile()'s exact
restore path rather than a second implementation of it. Sessions saved
before this change simply lack trackResult and fall back to the
original re-track-required behavior, no migration needed. Updated
storage.js's own top comment (it previously made a now-inaccurate
blanket claim that computed results are never stored) and the resume
banner's correction-count note (it always said "re-run tracking to see
them applied," which stopped being universally true the moment this
shipped). Genuinely useful on its own, independent of the roster ever
getting built - single-video resume is now as fast as the project file
already was.
Second slice: the snapshot-swap mechanism itself. snapshotCurrentVideo()
bundles the ~19 per-video variables into a plain object keyed by
videoKey in a new in-memory videos Map; activateVideoFromSnapshot()
is the restore counterpart - deliberately its own function, not merged
with resumeBtn's or loadFromProjectFile()'s near-identical cascades yet,
since both of those are freshly stress-tested and this is a new path
exercised a different way. Consolidating three call sites into one
shared implementation is a reasonable later cleanup once this one has
real mileage of its own, not something to risk in the same change that
introduces it.
New entry point loadVideoOrSwitch() sits in front of the plain Browse/
drop controls: if the picked file matches a video already touched this
session, it snapshots the outgoing video and restores the target's exact
prior state instead of loadVideo()'s blind fresh reset. No roster UI
exists yet to click between videos - this makes the EXISTING Browse/drop
controls themselves correctly handle "you picked a video you'd already
started" in the meantime, and gives something concrete to hand-test
today without needing to wait for the roster to exist. Also added
protective snapshotCurrentVideo() calls to loadFromProjectFile() (can
run while a different video is already active - Open Project File is
available any time Browse/drop is) and deliberately NOT to resumeBtn's
handler (only ever fires from the page-load resume check, before any
video could already be active this session - would always be a no-op).
Known, deliberately deferred limitation, not a bug: the videos Map
never evicts anything - every video touched this session stays in
memory for the session's lifetime, full trajectory and hole signals
included. Fine at the scale actually exercised so far (a handful of
videos); would need to become a bounded cache (backed by IndexedDB for
anything evicted, same data already being persisted there per the first
slice above) before a real 500-video folder open is safe to build.
Correctly out of scope for this piece specifically, since the roster/
folder-open flow that would actually trigger that scale doesn't exist
yet - noted here so it isn't forgotten once it does.
Real bug found by browser-automation stress-testing the snapshot-swap
mechanism, fixed immediately: trackMouse() is a genuinely long-running
async operation (many seconds to over a minute on a real clip), and
switching to a different video while one was still mid-flight left the
orphaned promise running in the background - confirmed directly, not
assumed: the background test50 track kept decoding for ~104 more
seconds after switching to test53, then completed and silently
overwrote the (now-test53) global trackResult/status text with test50's
data. No console error, no visible sign anything was wrong -
currentFileName/currentVideoKey/totalFrames all correctly showed
test53, while trackResult.totalFrames was test50's 5539. Same class of
race as the earlier resume/project-file bug, in a code path that fix
didn't cover.
Fixed with the same two-layer approach as that earlier fix: (1) primary
defense - trackBtn's handler now calls setInputLocked(true) for its
entire duration, so the video-switch that caused this can no longer
happen while tracking is running; (2) defense-in-depth - captures which
video was being tracked before starting, and refuses to apply the
result (logs to console, returns) if the active video has changed by
the time it resolves, rather than trust the lock alone to be airtight.
Applied the identical fix proactively to computeMedian() too - same
async-write-after-await shape, smaller race window (~1-2s vs. tens of
seconds), not confirmed as an actual bug by the stress test but the same
underlying risk and cheap to close while already in this code. Since
computeMedian() is also called from three already-locked cascades
(resumeBtn, loadFromProjectFile, activateVideoFromSnapshot), gave it a
keepLocked option (same pattern as loadVideo()'s) so those callers'
own outer lock doesn't get released partway through by computeMedian()
internally - only computeMedianBtn's own direct click owns its own
lock/unlock now. Also added a matching re-entrancy guard to
loadVideoOrSwitch() itself, closing the same-shaped sibling risk of two
overlapping switch attempts racing each other, which the stress test
didn't specifically test but shares the identical root cause.
41/41 tests still passing (no pure-logic files touched - this class of bug is exactly what that suite structurally cannot catch, discussed directly with her when she asked whether the CI suite was pulling its weight: it's doing its actual job, this category of bug was never going to be inside its reach, hand-testing/stress-testing is the correct tool for it, not a gap in the suite).
Step 4 of the migration: "Open folder." Two separate native pickers,
her explicit request after describing how a prior tool of hers (Trial
Data Extractor) handled this - "Open folder"
(<input webkitdirectory multiple>, grabs everything recursively) and
"Select videos" (<input multiple>, no webkitdirectory - the plain OS
file dialog already supports exactly the cherry-picking she wanted,
ctrl/cmd-click individual files and shift-click ranges, natively, no
custom picker UI needed). webkitdirectory over the newer File System
Access directory picker - broader support (Chrome/Edge/Firefox/Safari
vs. Chromium-only) - her explicit, strongly-held call: "this build needs
to be able to run on a 1998 computer... that's my big thing in
developing tools, accessibility." Matches CLAUDE.md's own existing hard
constraint (a 2019 laptop, no GPU), not a new value introduced here.
Both pickers feed one shared function: filters the selection down to
actual .mp4 files (a folder grab can't avoid picking up non-video
files), loads the first one immediately via the existing
loadVideoOrSwitch(), and holds the rest in a new pendingFolderFiles
array. There is no roster UI yet to list them in - that's the later
visual-redesign phase - so this stops at "the data a roster needs is
captured now," specifically so that UI won't need its own separate
file-selection logic built later, just a way to display what's already
here. Re-picking any of the other found videos later, via the existing
Browse control, already works correctly through loadVideoOrSwitch() -
no new code needed for that, it's the same mechanism piece already
stress-tested.
Learned from the last two stress-test passes rather than waiting for a
third to find it again: both new buttons were added to setInputLocked()
directly, real .disabled, not just relying on the .locked CSS
class's visual dimming - closing the exact gap openProjectFileBtn had
before its own stress test caught it, up front this time.
Two real gaps found by her hand-testing this immediately, both fixed:
- The "Found N videos" status message was getting silently overwritten within the same action - loadVideo() sets its own "Loaded X: N frames" status partway through the same call, on the same shared status line, so the informative message was never actually visible. Fixed by awaiting the load and setting the summary message AFTER, so it's the last word on what happened, not the first (also folded into one message: "Loaded X (1 of N). N more waiting below.").
- There was genuinely nowhere on screen to see that a second video had
even been found - "captured in memory, check the console" turned out
to be indistinguishable from "lost" for anyone who isn't inspecting
JS state directly. Added a small persistent element
(
pendingFolderFilesEl) listing what's still unopened by name - deliberately NOT the roster UI itself (that's still the later visual- redesign phase), just an honest, permanent breadcrumb that survives whatever the transient top status line says next. Also made loadVideoOrSwitch() remove a video from that list the moment it's actually opened (by any path, not just re-picking it from the pending list), so it can't go stale and keep claiming an already-opened video is still waiting.
Real misread of the brief, caught by her separate Claude web-browser session and independently confirmed by reading the verbatim source doc directly rather than trusting a paraphrase: "The Three Asks.pdf" (a paraphrase) reads "CSV/XLSX," read earlier as "either" and used to justify a CSV-only export. "Source Documents.pdf" (the verbatim transcription, most authoritative of the take-home source docs) actually says "Export: CSV and XLSX" - both formats required, not an either/or. KNOWN_LIMITATIONS.md's CSV-only entry corrected to RESOLVED rather than left standing.
Built app/js/xlsx.js: a from-scratch, dependency-free .xlsx writer, not
the SheetJS library that entry had pencilled in as the obvious choice.
CLAUDE.md caps dependencies at OpenCV.js/mp4box.js, and a .xlsx is
structurally just a .zip of plain XML parts - zip has a "stored"
(uncompressed) mode that needs nothing beyond a CRC-32, which is a
well-known, table-based ~15-line algorithm, not something worth a
library for. Row assembly in export.js is completely unchanged - the
same rows toCSV() already turns into text now also go through toXLSX(),
exactly the "one new function, not a rework" bolt-on the original
scope-cut entry planned for. Both export buttons now download CSV and
XLSX together from one click.
One deliberate content rule in the writer: a value becomes a real Excel number (not text) only when it's already a JS number, or a numeric string with nothing else attached - so "12.35" (a plain measure) sorts and sums like a number, but "12.35 (audited)" and "not reached" stay text, unchanged from how they read in the CSV. Side effect worth knowing, not a bug: a value like fps's "14.9850" becomes the real number 14.985 in Excel (trailing zero dropped, General format) rather than displaying with the CSV's fixed 4 decimal places - correct and arguably more useful for stats, just visually different between the two files.
Verified independently, not just against its own test's expectations:
wrote a sample workbook and opened it with two separate, real third-
party tools neither of which shares any code with the writer -
Python's built-in zipfile (testzip() reports no CRC mismatches
across any entry) and openpyxl (opens it as an actual workbook, reads
back the correct sheet name, and reports each cell with the correct
Python type - float/int for numeric cells, str for text, matching
exactly which columns were expected to land as which type). This is the
same class of verification the resumeBtn/mid-track races got before
being called done: not trusting a hand-rolled binary format against its
own author's test.
New tests (48/48 passing, 7 new): CRC-32 against the standard "123456789" check value, UTF-8 encoding of ASCII/accented/surrogate-pair characters, spreadsheet column-letter naming (A..Z, AA, AB), the numeric- vs-text cell heuristic, and an end-to-end toXLSX() test that parses the actual zip bytes with a small test-only reader (not a copy of the writer's own logic) to check the worksheet XML content directly.
Also closed the trial-identity gap flagged alongside the XLSX question:
the trial summary export had "Prior trials completed" but not trial
number, cohort, group, or animal ID, even though all four already exist
on the ROI form (roiState.trial) - a real gap for a merge tool (or a
person) trying to tell rows apart once a facility has many trials per
animal. Added as four new columns in the existing "Trial info" field
group, checked by default like the rest of that group, blank (not
omitted) when a field was never filled in on the form - same posture as
every other optional field in the export.
A second, independent Claude Code session ran a read-only correctness
audit of app/js at her request that same evening - a deliberate second
opinion, not this session, and not her own hand-testing. Its report
(audits/2026-09-04-correctness-audit.md, moved into the repo from
scratch/ on her call - see below) found 9 CONFIRMED bugs, each
reproduced by loading the real code into the same tests/helpers/ load-app.js harness this project's own suite uses and running it against
real or realistic data, not just read from the source. Two were found
independently by this session too, from opposite directions, within the
last few hours: finding #8 (the XLSX zero-padded-ID bug) is in code
written earlier tonight, and finding #3 (a total tracking failure scoring
a perfect "Spatial" verdict) turned out to be the same root shape as a
gap this session had already found via her own stress-testing and left
deliberately deferred in KNOWN_LIMITATIONS.md, sketched but not yet
built.
Her decision, given directly and without hesitation, once the audit was
in hand: fix everything found tonight rather than bank the remaining time
on visual polish - "I'd rather be thorough and audited than pretty."
That reordered the plan directly: the video-roster migration (steps 5-6)
is paused, all nine findings get fixed now, and the audit itself is kept
in the repo, tracked, rather than left in scratch/ - her read, on the
model's recommendation, was that for a tool whose entire premise is
"trustworthy spreadsheet out," how a wrong number was found and closed is
part of the actual deliverable, not incidental process, and a strong fit
for the brief's own request for "2-3 disagreement moments" in AI_NOTES.md.
Moved to audits/2026-09-04-correctness-audit.md with an added framing
header attributing it correctly (a separate session's work, not hers and
not this session's) and establishing it as a living record: each finding
gets a Status: line as it's fixed, so the file never sits in the repo
misrepresenting a bug as still-open once it isn't.
All nine were verified against the actual current code before touching anything - not relayed on trust. Five were spot-checked by reading the exact cited lines directly (findings 1, 2, 3, 4, 8); all five matched the report exactly, which is what gave confidence to proceed through the remaining four (5, 6, 7, 9) at the report's own stated confidence (CONFIRMED: reproduced against real data, not just reasoned from source).
Fixed, in the audit's own severity order, each with new regression tests (64/64 passing, up from 51 before this pass - every fix here is exactly the pure-logic class of bug this suite is built to catch, unlike the timing races found by browser-automation stress-testing earlier tonight):
- #1 (project file loads a pre-fix file silently, analyzes nothing):
PROJECT_FILE_FORMAT_VERSIONbumped 1 -> 2 - the Float32Array serialization fix earlier tonight changed the actual on-disk shape and the version number never said so.parseProjectFilenow refuses anything other than an exact version match (older AND newer, not just "newer than understood"), plus a newvalidateHoleSignalsShape()check that refuses a malformed payload outright. Confirmed both localscratch/*.bmp.jsonstress-test fixtures are genuinely v1 and are now correctly refused - she'll need to re-save fresh project files to test that flow again, not a regression, the intended effect of the fix. - #2 (one failed frame at a video's last frame invents a confident
"entry"):
classifyRun's entry check now requires the fullENTRY_LOOKAHEAD-wide window before the invalid-frame ratio can call an entry at all (windowIsFull). A truncated window with something invalid in it returns a low-confidencepokewith a reason string ("video ended before a full entry-check window..."), surfaced directly in the existing events-table UI - flagged for human review via the existing "video-end" override, not silently guessed either way. - #3 (total tracking failure scores a perfect "Spatial" verdict): two
new guards in
classifyStrategy- an invalid-startPointguard (cheap insurance against a currently-unreachableNaN) and apxDistance === 0 && endFrame > trialStartFrameguard returningavailable: false, careful to still score a genuine immediate find (endFrame === trialStartFrame) as a real, correct 1. While fixing this, found and closed the closely related, broader root cause in the same function:isDirect's vacuous[].every()check on an empty sequence reported the same false-confident verdict wheneverrealVisits.length === 0, independent ofpxDistance- this is KNOWN_LIMITATIONS.md's own previously-deferred classifier gap (found earlier by her stress-testing, fix sketched but not built), now resolved in the same pass and marked RESOLVED there. - #4 (typing
0into a threshold silently uses the default): two new helpers,parseIntOr/parseFloatOr, replace everyparseX(...) || DEFAULTacross app.js (~20 call sites, not just the handful cited in the report) with an explicitNumber.isFinitecheck. This also resolves the report's sub-finding 4(c) - overrides silently wiped on resume - without a separate change, since the resume-timeJSON.stringifycomparison now sees a correctly-parsed0on both sides instead of a mismatched default. - #5 (hole deviation score changes but reports
audited: false): derivesauditedfrom `anyOverrideBefore(firstVisitOverall.startFrame- 1)`, the same machinery every other measure already uses. Corrected the wrong comment (in both measures.js and export.js) claiming this score could never be override-touched, and added the "Hole deviation score - original" comparison column that absence had also caused to be skipped.
- #6 (a visit before trial start can produce negative latency):
getRealVisits()- the one function both measures.js and classifier.js already share - now takestrialStartFrameand filters out any visit starting before it, before anything downstream sees it. Negative latency is now impossible by construction, not just unlikely. The raw visit list a human reviews in the events/visits table is untouched - only what gets counted toward measures/classification is clamped. - #7 (average speed divides tracked distance by untracked time): the
speed divisor now counts only the same tracked frame-pairs
pathLengthCm's own pxDistance is built from (trackedPairFrames / fps), not wall-clock time from trial start to the end of the whole recording. Closes both inflation sources the report named (ordinary decode misses, and the much larger one - every frame after the mouse enters the hole and is no longer visible) with the same change, since both were already excluded from the numerator; only the denominator was wrong. UI definition string corrected to match. - #8 (XLSX turns a zero-padded Animal ID into a number, CSV doesn't):
fixed by this session, the writer's own author, within hours of writing
the bug - tightened
xlsx.js'sNUMERIC_STRINGregex to exclude leading-zero strings (the same rule JSON's own number grammar uses) rather than hardcoding an "always text" column list into a writer that's deliberately column-agnostic. No computed measure (all viatoFixed()) ever produces a leading-zero string, so this can't misfire on the numbers the heuristic exists to catch. - #9 (event detail times are video-relative, latency is trial-relative,
nothing says so): the event detail sheet now carries a
Trial start framecolumn plus both a video-relative and trial-relative time per visit, so a reader can cross-check directly against the trial summary's latency numbers instead of hitting an unexplained ~5s discrepancy between two files handed over together.
Findings 10-15 (REASONED, not CONFIRMED against real data - staleness
between the on-screen measures table and the export, reengageFraction
silently detaching an override from its visit, ROI edits after tracking
not invalidating trackResult, hole-numbering inconsistency in one export
column, a 19-hole-ring caveat, and several smaller/lower-confidence notes)
were not acted on tonight - real, but each bounded, visible, or currently
latent per the audit's own assessment. Worth a pass if time allows before
submission.
A third, separate Claude Code session ran a companion audit that same
evening - a deliberate second question, not "is this a wrong number"
(the correctness audit above) but "does this repo agree with itself":
one value defined in one place, one word meaning one thing. Report at
scratch/2026-09-04-consistency-audit.md (not moved into the repo
tonight, unlike the correctness audit - her call was to fix a small
batch now and leave the rest, including the move decision, for later).
16 findings, none a wrong number, several real trust/maintainability
gaps. Spot-checked two directly before acting on any of them: the
Blob([Uint8Array]) claim (verified in Node - it is NOT coerced to a
string first) and the stale "(draft)" language (confirmed exact lines).
Her direction, given directly: fix a small batch now, defer everything that's about staleness or "is this ready for other people to read" to Monday/Tuesday (her own polish days), with two explicit exceptions she called out herself as not deferrable:
- Hole numbering (finding #4), fixed in full, not just noted. The
0/1-start toggle changes the displayed target hole (18 vs 19) but was
read live from the DOM every time and never recorded anywhere - a
reload silently reverted to the default, and the export hardcoded
+ 1regardless of what was on screen. Her explicit reasoning: "auditing it later and knowing that it's recorded one way is the best practice."roiState.holeStartis now the recorded source of truth (roi.js), set by the fieldset's change handler and persisted throughstorage.js,project-file.js, andconfirmedROIthe same way every other human-set ROI value already is.holeStartAt()now readsroiState.holeStartinstead of the live radio. Both export sheets apply it instead of a hardcoded+ 1(Target hole,Hole,Hole sequence checked (order)) and both now carry aHole numbering starts atcolumn - not just applying the convention correctly, but recording which one was in effect on that specific row, per her stated reasoning. - Status-region styling (finding #3), fixed for real, not just noted.
Her direct ask: "let's make any error message look different from
success, esp if there's only a color differentiation." The hand-
maintained 6-ID selector list covered half of the actual 12
role="status"regions - everything from measures through export rendereddata-level="error"/"success"as identical plain text. Replaced with a single[role="status"]attribute selector (matches the audit's own suggestion), so the next status region added is styled by default. Also closed the color-only gap directly: error gets a 2px solid border plus a⚠prefix (via::before, so no JS call site needed to change), success gets a dashed border plus a✓prefix - verified directly in a real browser (computed styles checked onmeasuresStatus/exportStatus/exportDetailStatus, three of the six regions that had zero styling before tonight), not just read off the CSS.
Also fixed, the small-batch items from the audit's own top findings:
- #1 - the "(draft)" table header and both stale
app.jscomments claiming event/classifier overrides were in-memory-only. All three predated the persistence fix from earlier tonight and were never updated when it landed. Corrected to describe the actual, tested, shipped behavior. - #16 - README's export bullet still said
[FILL IN once built]hours after export shipped. One line, now factual. - #5 -
downloadTextFile/downloadBinaryFilewere byte-identical apart from a parameter name, split on a comment claimingBlobwould coerce aUint8Arrayto a string if shared - verified directly that claim is false, merged into onedownloadFile(). reengageFraction's missing default (part of #2) - the one threshold inevents.jswith no in-module floor; omitting it madereengageThresholdNaN, andd <= NaNis always false, sogapStaysElevated()always returnedtrueand merged every same-hole event pair into one visit. Not reachable from the UI today (both app.js call sites always supply it), but the one threshold that failed silently instead of loudly - given its own default (0.3), matching every sibling threshold in the same file.
20 new/updated tests (68/68 passing, up from 48 before this pass -
counting both audits' work tonight): hole-numbering honored/recorded in
both export sheets and the project file, mergeVisits no longer
silently merging across a real dip when reengageFraction is omitted,
plus renamed-column updates to existing tests (Target hole (1-indexed)
-> Target hole, Hole (1-indexed) -> Hole, both now paired with a
Hole numbering starts at column).
Deferred to Monday/Tuesday per her explicit call: the broader
threshold-defaults hoist (#2's remaining scope), the frame-pair-loop
extraction (#9), the terminology renames (#7 "event detail" naming, #12
stale "perseverative" comments, #13 "poke" dual meaning, #15 quadrant
label mismatch), the snapshot dead-fields cleanup (#8, #14), and
surfacing the tool version in the UI (#10). All real, none produce a
wrong number, all fine to sit in scratch/2026-09-04-consistency-audit.md
a while longer - same priority ordering as the correctness-audit pass
earlier tonight: fix what threatens correctness or active trust/hard-rule
compliance now, leave "is this tidy" for the days already set aside for
it.
Video-roster migration, step 5: persist the roster itself in IndexedDB, per the two decisions already agreed with her. Built carefully with direct functional verification in a real browser, not the long adversarial stress-test passes earlier steps got - her explicit call for tonight, with tomorrow set aside to test mechanics properly before the visual redesign.
New roster object store (storage.js, IDB_VERSION 1 -> 2, additive -
existing stores untouched), metadata-only per entry
(videoKey/fileName/fileSize/lastModified, never a blob) for every
video seen via "Open folder"/"Select videos," touched or not - deliberately
separate from videoBlobs/sessions, which only exist once a video is
actually opened.
Decision A (replace vs. add, her call not to hardcode either): a new
rosterConflictBanner, same shape as the existing resume banner
(locks input while showing, same button styling). Only appears when the
new selection actually introduces something the existing roster doesn't
already have - reopening the same folder, or a subset of it to keep
working through one video at a time, does not prompt. Also added a
Cancel option beyond the two she asked for, matching the resume banner's
own Discard escape hatch - backing out of a wrong folder pick with
neither choice being "the right one" was a real gap the resume banner
already solved once.
Real bug found by direct testing before it shipped, not assumed correct:
the subset-check was inverted on the first pass - it fired the conflict
banner whenever the EXISTING roster had anything not in the new
selection, which is the ordinary shape of "pick one video to keep
working through an already-saved folder," not a real conflict. Every
such re-pick showed the banner and left inputLocked stuck true since
nothing ever resolved it, silently breaking whatever came next. Fixed by
checking the other direction (does the NEW selection introduce a key the
roster doesn't have), verified directly afterward: same-file reopen, a
genuinely new file added, replace, add, and cancel all confirmed correct
via real IndexedDB calls and a page reload in between (not just
same-session state) - proving persistence was the actual point, not
assumed from the code alone.
Decision B (metadata-only storage for untouched videos) and the
"Locate X.mp4 to continue" re-pick flow are deliberately not built yet:
today's pendingFolderFilesEl breadcrumb is plain text, not a clickable
list, so there's nowhere for a re-pick interaction to live until the
visual redesign gives the roster an actual clickable row - building that
UI now would mean building it twice. Step 5 as shipped tonight is
"the roster survives a reload, honestly reconciled, not a specific
video's file handle."
Step 6 (resume-on-reload restoring the whole roster into view, not just the last-active video) is next - mostly "read what step 5 already writes and render it," now that the storage layer is proven correct.
Step 6 turned out to be a bigger, more real fix than that last line
predicted, found while actually scoping it rather than assumed: before
tonight, only the single most-recently-active video (getLastVideoKey())
was ever resumable after a real page reload. A video tracked and
corrected in an earlier session, that just wasn't the last one touched,
had no path back - re-picking it via Browse/Select videos fell straight
through to a plain loadVideo(), silently treating real, saved
IndexedDB corrections as if the video had never been opened at all. The
in-memory snapshot-swap mechanism (steps 1-4) only ever helped mid-session;
it's empty again after any real reload. Flagged directly to her before
building either version - she chose the full fix over a lighter
"just show the list" option.
Fixed: loadVideoOrSwitch() now checks IndexedDB (loadSession()) for
any video not already covered by the in-memory videos Map, not just
the plain fresh-load fallback. New activateVideoFromSession(key, file, session) mirrors resumeBtn's own restore cascade step-for-step (same
functions, same order: loadVideo -> applyCorrectionsSessionToForm ->
computeMedian -> runDetection -> finishTracking/runEventDetection/
computeAndShowMeasures/runClassification when a trackResult exists) -
deliberately not a new invention, since resumeBtn's cascade is already
proven. A session record's own field names (trackOptions/eventOptions/
classifierOptions) already match what applyCorrectionsSessionToForm()
expects directly - confirmed by reading resumeBtn's own call, which
passes its session object straight through with no reshaping either.
Verified directly, not assumed from the code reading alone: wrote a
synthetic-but-real-shaped session record straight into IndexedDB via
saveSession/saveCorrections (no real video decode needed - the
restore cascade's internals are already proven by resumeBtn, so this
tested exactly the new part: the ROUTING decision), reloaded the page for
a genuinely empty in-memory state, and confirmed a video with a saved
session now correctly routes to activateVideoFromSession while a video
with no session still correctly falls through to plain loadVideo -
both checked with the real functions stubbed to record which path fired,
not guessed from reading the branch.
Deliberately not built tonight: actually SHOWING the roster list after a
cold reload (as opposed to correctly resuming a video once picked,
which is what tonight's fix covers). pendingFolderFiles/
renderPendingFolderFiles() currently hold real File objects and key
off them via videoKeyFor() - a cold-restored roster entry is metadata
only, no File object exists to key off until the user re-picks it, so
displaying restored entries there today would either be a shallow
name-only list that silently never clears itself, or would need real
surgery to unify two different shapes. Left as its own follow-up rather
than rushed - the resume mechanism (tonight's fix) is the part that
actually mattered: a person can already reopen their folder and get
correct behavior when they re-pick any video in it, which was the actual
data-loss risk.
Before stopping for the night, she asked for a drift check: the whole
build, and the whole brief, side by side, plus "what haven't I thought
of." Read Source Documents.pdf (the verbatim brief, not a paraphrase)
in full again rather than working from memory of it, and cross-checked
against the actual current repo state (README.md, AI_NOTES.md,
KNOWN_LIMITATIONS.md, LICENSE, .gitignore, git ls-files, the CI
workflow) rather than assuming from the build history.
Confirmed solid: the load/ROI/track/correct/detect/measure/classify/ export chain matches the brief closely, including the parts it calls out as easy to get wrong (honest failed-frame flagging, adjustable visible thresholds, corrections surviving a reload, all three clips working, no hard-coding to test50). Tonight's two audits closed real gaps in exactly the two hard rules the brief scores hardest on.
Real gaps found, none previously on record in this form:
- "Visualize, generously" is almost entirely unbuilt. The brief names trajectory overlays, path plots colored by time, occupancy heat maps, a hole-visit raster, per-animal learning curves, and cohort comparisons, and says explicitly that this is the part a scientist decides whether to trust the tool by. What exists today: a frame-by-frame canvas overlay and data tables. This is the single largest gap between the build and the brief - flagged directly rather than left for her to discover late, and NOT logged as a "deliberately didn't build" scope cut, since it hasn't actually been decided against, just not reached yet.
- No CV-real example outputs are committed to the repo. The brief
asks explicitly: "Commit the actual generated outputs for the three
clips into your repo as well, so we can read them without re-running
anything." Every real export that exists lives in gitignored
scratch/. Nothing tracked satisfies this today. - No seeded demo state. The brief wants something real visible within ~60 seconds with no setup; today's live app is an empty drop zone until a visitor supplies their own video. A "load a sample video live from the official data source rather than bundling a copy" approach was discussed with her as the shape that respects the brief's separate "do not commit the sample videos to your own repo" instruction, not designed further yet.
- Cross-browser support is claimed, not verified. README says Chrome, Edge, or Firefox; every test this project has ever run, hers and the model's, has been in Chrome. README now says so honestly rather than leaving the claim standing unqualified.
- AI_NOTES.md was stale and over length - hadn't been touched since before tonight's two audits (by far the strongest "disagreement moment" material generated all week), still claimed no subagents had been used even though forked subagents were used for stress-testing earlier this session, and at ~1200 words was already past "a page" before adding anything. Rewritten to ~620 words: three disagreement moments (the CSV/XLSX misread, the two audits, the flat-edge-band resize bug caught only by hand-dragging), the tools/setup note corrected, everything else trimmed on the theory that DECISIONS.md is where the full trail already lives.
- Smaller, already-acknowledged-elsewhere items reconfirmed rather than newly found: nose-vs-body tracking distinction (the brief's own "ideally," not required), visible/adjustable trajectory smoothing as its own step (deliberately not built - honest failure- marking was prioritized over invisible-vs-visible smoothing, which is the harder problem this project chose to solve first), the ROI-drag keyboard gap and the never-tested 200% zoom claim (both already flagged in README's own prior draft, now stated as known-open rather than left as an internal to-do bracket in a file meant to ship).
README.md rewritten to close every bracket that had a real, knowable
answer tonight (who this is for, the license line, the CDN-disclosure
gap in "what leaves your machine," an honest cross-browser caveat, the
accessibility section restated as fact rather than a note-to-self) and
to state the ones that don't yet (demo video, seeded demo state, the
visualization gap) as open items rather than silently dropping them or
leaving raw [FILL IN] brackets in a file meant to be read by an
evaluator.
Her question on sequencing, and the answer given directly rather than decided for her: committed example outputs and seeded demo state both outrank the interface redesign, since they're concrete, verbatim brief requirements and the redesign is a UX improvement on an already- functional flow. Neither belongs on Monday specifically, since seeded demo state is a real feature and Monday is her own explicitly-declared "polish only, no new features" day. Visualizations are flagged as the biggest open question of all - bigger than a Saturday item, needs her own call on scope before anyone builds anything.
2026-09-05 correction, made the same night after she'd had time to think it over rather than decide half-asleep. Two framings above were wrong, not just under-explained, and she caught both directly:
- Visualization was never an open, undecided question - it was already discussed and deliberately sequenced. The "biggest open question of all, needs her own call on scope" line two paragraphs up, and the "NOT logged as a deliberately-didn't-build scope cut, since it hasn't actually been decided against" line earlier in this file (the 2026-09-04 drift-check entry), both overstate how undecided this was. The actual decision, made before tonight: pipeline mechanics (tracking, events, measures, classification, correction, export) come first and are validated against all three clips before visualization is designed, because visualization needs a settled data model to draw from - building it earlier means building against a shape still moving underneath it. What's genuinely still open is only the interface-redesign phase's design, not whether or when to build visualization at all. Now recorded properly in KNOWN_LIMITATIONS.md rather than only living in her head and this log.
- The nose-vs-body tracking bullet (reconfirmed a few paragraphs up as "the brief's own ideally, not required") was flatly wrong, not just under-explained. The brief's actual concern is a naive centroid getting dragged around by the tail/body and contaminating the poke/entry call. That concern doesn't apply here: the primary poke/entry signal is per-hole pixel change against the median (see CLAUDE.md's "two independent signals" section), never centroid-to-hole proximity. The centroid is used only for path length, speed, and quadrant time, where whole-body position is the correct signal, not a stand-in for something better. This was a validated design choice being misfiled as an unaddressed gap, not a softenable wording issue - corrected in README.md (removed from "what I deliberately didn't build," replaced with a line explaining the design) rather than merely toned down.
Seeded demo state, built 2026-09-05. A "Load an example" button
fetches test50.mp4 from the official salk-airc/rse-takehome-2026
sample-data repo plus this repo's own already-tracked project file, then
hands both to the existing loadFromProjectFile() cascade - no new
pipeline logic, just a new way to arrive at code that already works
(tracking skipped entirely, same as any other project-file restore, so
it comfortably meets the brief's ~60-second bar even on a cold laptop).
The project file is duplicated into app/seed/ rather than fetched from
examples/ at its normal committed location. Considered and rejected:
fetching it from GitHub the same way the video is fetched - rejected
because this repo is private, so that fetch would 404 for any evaluator
without her own GitHub auth. The actual reason examples/ isn't
fetchable as-is: Cloudflare Pages deploys from /app only (per
CLAUDE.md's architecture note), and examples/ is a sibling of app/,
never a build-output-tree in the deployed. So a copy inside app/ was
the only option that's both always reachable and doesn't require
changing the repo's visibility. Confirmed directly, not assumed - curled
the deployed examples/... path before building against it and found it
silently falls through to Cloudflare's SPA fallback (index.html, not
the real JSON), which would have made this feature silently broken.
Found the same night, by hand-testing the demo button itself: the
video-roster migration's "restore any touched video after reload"
feature (activateVideoFromSession(), app.js) fires completely
silently. Dragging in a video whose videoKey (name+size+lastModified)
matches something already tracked earlier - in ANY prior session, not
just the most recent one - restores its saved ROI/trajectory/overrides
with zero indication on screen that a restore happened at all, unlike
checkForResumableSession's own banner for the single "last active"
video, which explicitly offers Resume vs. "Discard & start fresh" before
touching anything. Not a hard-rule violation (corrections genuinely
survived, exactly as saved - hard rule #5 is intact), but a real,
confirmed inconsistency: one resume path asks first, the other one
doesn't ask at all. Her own read, confirmed directly: this doesn't
matter for the actual submission (IndexedDB is local to the browser it
was created in - nothing in it ever reaches an evaluator's machine
regardless of whether it's left alone or wiped), so there's no
"leave evidence of testing" question here at all, just a practical one
of not being surprised herself mid-testing.
Fixed the smaller half tonight: activateVideoFromSession() now ends
with a visible status message ("Resumed X - N saved corrections already
applied") whenever this path fires, so it's never silent even though
there's still no explicit choice to decline the restore. The bigger fix -
giving this path the same Resume/Discard choice the last-active-video
banner already has - deliberately NOT built tonight: a real, scoped
improvement, but a genuinely separate, bigger decision (what should
"discard" even mean here - wipe the IndexedDB record? just this session's
view of it?) that shouldn't be rushed in alongside a same-night fix.
Search-strategy classifier scope, worked through live against test50
and test53, 2026-09-05. She noticed that manually assigning test50's
target to hole 5 (ground truth genuinely unknown for this clip - the
mouse never enters) produced a "Spatial (direct)" verdict with almost no
hole sequence, even though the mouse visibly crosses the whole board.
Traced it to real data before answering (pulled centerDensity/
rimDensity straight from the committed test50.bmp.json around the
frames in question) rather than guessing: classifyStrategy() anchors
everything to targetVisits[0].startFrame - the FIRST time the mouse
ever touches whichever hole is marked target. If that first touch
happens very early (as with hole 5 here), the classifier's "sequence
before target" window is trivially short by construction, regardless of
how much wandering happens afterward - not a bug, since Gawel et al.'s
own Direct/Spatial definition is "moves directly to the target," and a
mouse whose first touch of an early-assigned hole IS its first move
satisfies that literally.
Her follow-up sharpened it with test53's real pattern (checks the hole above target, the target, two holes below, then returns to target) - the classifier only ever sees the window before that FIRST target touch, so a real "touched it, wandered, came back" pattern is invisible to it whether the target is a confirmed one (test53) or a guessed one (test50). Confirmed this is inherent to the classifier's own literature- grounded scope (search = pre-discovery, already decided a prior session) rather than something to fix by changing its anchor point - anchoring to the LAST target visit instead would just move the same problem elsewhere and stop matching primary latency's own "first reach" that everything else already keys off. Her call, direct: keep the classifier exactly as it is, build the two already-sketched measures (commitmentDelay, targetRevisitCount - see KNOWN_LIMITATIONS.md) instead of a classifier change. Declined to propose a third/fourth new measure beyond those two without a real literature check first, rather than inventing something to round out the list.
Separately, confirmed directly: none of this touches tracking accuracy. Trajectory, blob position, event detection, path length, speed, and quadrant time are all independent of which hole is labeled target - only latency/errors/hole-deviation/the classifier verdict are target- relative, and all of them are only as meaningful as the assigned target for a clip like test50 where the true target was never reached. That's a property of the clip (incomplete ground truth), not a pipeline defect, and the existing "Target hole uncertain" flag reason plus the manual classifier override (already used here) are the right, already-built tools for it.
Visualization piece 1 (Path tab), 2026-09-05. Interface redesign's third piece (SHELL_SPEC.md §9), started once she confirmed placement: tabs replacing the middle column's content (Video/Path/Heat map/Raster sharing one space), not a pop-out or a resizable bottom drawer. The drawer idea is real and was weighed directly - simultaneous video-plus- visualization viewing without needing a click to switch back - but scoped out as a longer-term enhancement not justified against this submission's timeline, not a rejected idea.
Viridis reconfirmed as the color choice (already decided in
SHELL_SPEC.md) - hand-rolled from its standard published anchor colors
rather than a library, since dependencies are capped at OpenCV.js/
mp4box.js. The path line is drawn from trialStartFrame to the end of
the trajectory, matching exactly what the Path length/speed measures
already describe, so the visual and the number never disagree about
what "the search" covers. A failed-tracking gap breaks the line rather
than bridging it - same hard-rule-#1 posture as the frame-by-frame
green/red overlay, applied to a new visual instead of a new exception
to the rule.
PNG export redraws the same vector drawing calls at 2048px directly,
rather than rastering the small on-screen canvas up - the spec's own
distinction ("not a screenshot of the on-screen canvas... unusable in a
figure"). One shared function (renderPathVisualization) takes any
target canvas and resolution, so the on-screen tab and the export can
never silently drift apart into two different drawings.
Tab enablement follows the same "say why, not just disable" pattern as every button in this app: Path/Heat map disabled with a title + aria- label reason until tracking's run, Raster until events are detected - real ARIA tablist semantics (arrow-key nav skips disabled tabs) rather than styled divs, so this doesn't reinvent what the platform already does correctly.
Heat map and Raster tabs are stubbed ("Coming next piece") - built in sequence per her explicit call, to make sure nothing gets missed by building all three at once.
Path's time coloring switched from a continuous viridis blend to 8 discrete bands, her direct observation after actually looking at it: neighboring time periods read as visually clustered. Not a defect in the continuous version - that's an inherent property of any smooth gradient, not specific to viridis - but a real readability complaint once seen on real data. Binning trades finer time precision for visual separation: each band is a fixed color, not a blend, so two adjacent bands never look almost-the-same the way two adjacent points on a smooth ramp can. The legend is built from the exact same binned function the canvas uses (discrete blocks, not a CSS gradient), so it can never silently drift from what's actually drawn.
Checked directly, her own question: does binning touch the trajectory's faithfulness to the real data? No - every point's real position and every frame-to-frame connection (including failed-tracking gaps, still never bridged) is completely unaffected; only the time-to-color mapping lost precision (which of 8 equal chunks instead of an exact fraction). The one real risk worth naming: since the bands are a fixed, even split of elapsed time - not anything detected from the mouse's actual behavior - a reader could mistake a band edge for a behavioral transition. Made explicit in the tab's own description text rather than left implicit, so a color change there is never mistaken for an event.
Visualization pieces 2 and 3 (Heat map, Raster), 2026-09-05. Both built together, closing out the tabbed visual pane.
Heat map: occupancy (time spent per location), not route - a real, different question from Path, so it gets its own tab rather than a toggle on Path's. Grid cell size is fixed in native-video pixels (16px), not a fixed column count, so resolution scales with whatever the video's own real dimensions are - same "read the real thing, never assume" posture the fps-jump buttons already established. Colored with the same 8-band viridis function Path uses, re-labeled "least visited -> most visited" since it's now a magnitude encoding, not a time encoding - one function, two legends, so the two tabs' visual language stays consistent without duplicating the binning logic. A click seeks to the FIRST frame that visited that cell, since a cell aggregates many frames and "first arrival" is the one unambiguous choice, not an arbitrary pick among several valid ones.
Raster: built as real, focusable DOM elements, not canvas - the
deliberate exception among the three visualization tabs. Path and Heat
map are allowed to stay mouse-only (documented as a limitation in
SHELL_SPEC.md's own accessibility section); Raster is the one that has
to work without a mouse, and a canvas would need a hand-built fake-
focus system to do that correctly, where real DOM elements get it for
free. Implemented as a composite listbox (focus stays on the
container, aria-activedescendant tracks the "current" mark) rather
than making 20+ individual elements each independently tab-stoppable -
the standard WAI-ARIA pattern for a collection this size.
Visit type (poke/entry/thigmotaxis/undetermined) is shown by shape and border, not color alone, in both the marks and their legend - a plain rectangle for poke, a bold border for entry, a diagonal hatch for thigmotaxis (visually reads as "excluded" independent of hue), a dashed outline for undetermined. The legend and the marks share one CSS class per type rather than the legend re-declaring its own colors, so the two can never silently drift apart into different appearances for the same type.
Coverage audit against the brief's actual requirements, 2026-09-05.
Ran COVERAGE_AUDIT_SPEC.md (parent folder) against the current repo,
scoped to exclude the demo video, README/AI_NOTES content depth, and
every already-tracked deferred item (roster, keyboard-nudge for ROI
drag, point-level correction). Result: 46 DONE, 3 PARTIAL/MISSING with
real citations, one of those three being the explicitly-optional MCP/
agent interface. Two real, previously-unflagged, cheap gaps fixed same
night:
- README's data-handling section named what leaves the machine and why, but never named the actual institutional reason this matters for animal-subject footage specifically (IACUC oversight, institutional data-handling policy) - added one sentence rather than leaving the reader to infer it.
- The tracking quality report showed a bare failed-frame count with no sense of WHERE failures happened - the spec's own words, "a number is not a report." Added a whole-video (not just the trial window, to match what trackedCount/totalFrames already describe) timeline strip, binned to the strip's own pixel width and colored by fraction-failed per bin (not just presence/absence), so a cluster of failures reads differently from scattered isolated ones. Click-to-seek, same principle as every other visualization here - reuses the frame-seek path already built for the tab pane rather than a new mechanism.
Independent UX audit, 2026-09-07 (scratch/briefs-and-audits/2026-09-07- ux-audit.md, 31 findings, seven passes). Triaged into trust-critical, easy
(one-line), contained, and "needs a decision" - the last bucket went into
KNOWN_LIMITATIONS.md rather than being fixed same-day, matching the
established practice of fixing real hard-rule/trust issues immediately and
deferring genuine design work. Fixed same day (trust-critical + the full
easy batch):
- Measures/Search strategy going stale. An override changing after
Measures or the classifier verdict were already on screen used to leave
both showing pre-override numbers with a static "re-run to see it
applied" sentence printed on every successful compute regardless -
unable to distinguish "current" from "stale." Now a real state
(
markStale()): dims the result and swaps in an actual warning the moment it goes stale, cleared only by re-running that stage's own compute. Deliberately generic wording ("these results," not "these measures"/"this verdict") to avoid a subject-verb mismatch across both call sites rather than threading a grammatical form through the helper. - Re-tracking with different TRACK thresholds orphaning overrides. The
staleness guard that clears
eventOverrides/eventNoteson a threshold change only ever compared EVENT thresholds (lastEventOptions) - a re-track with a different mask threshold or blob-area gate changes hole signals and therefore every event's frame range the exact same way an event-threshold change does, but went uncaught, leaving stale keys in IndexedDB with the resume banner then claiming them "already applied." Fixed with a parallel snapshot (lastEventOptionsTrackSnapshot) oflastTrackOptionsat the moment current overrides were captured - had to also seed this snapshot insideapplyCorrectionsSessionToForm()(not just reset it on fresh load), since the resume cascade callsfinishTracking()thenrunEventDetection()back to back, and without seeding it the very act of resuming would look like a threshold change and wipe the overrides it was restoring. _AUDITEDproject-file suffix, made conditional. Was unconditional since it was first added (see the "mark as processed" entry in KNOWN_LIMITATIONS.md) - the audit correctly noted this contradicts what "audited" means everywhere else in the app (a human changed a computed value). NowObject.keys(eventOverrides).length > 0 || !!classifierOverride. Deliberately excludeseventNotes: a note never changes a computed value (see its own comment inapp.js), so a note-only file isn't "audited" either - same reasoning that kept notes out of everyauditedflag/badge elsewhere.- Classifier's phantom target-hole step.
sequenceHolesused to appendtargetHoleIndexto the checked-sequence unconditionally, even whentargetVisits.length === 0(target never reached,primaryFrame === Infinity, sosequencecovers non-target visits across the WHOLE trial) - fabricating a final "approach to target" step that never happened. This wasn't just a display bug: the fabricated step fed directly intoserialScore's adjacency/direction calculation. Fixed at the source (chainonly appends the target whentargetVisits.length > 0) and added an explicittargetReachedflag to the return value rather than letting the renderer/export infer it from array position - a non-target hole coincidentally sharing the target's index in some degenerate future case would otherwise silently mislabel. - "False alarm" as a new human-review outcome, distinct from
thigmotaxis: thigmotaxis means the mouse really was there but wasn't
investigating (wall-following); false alarm means the detector's
pixel-change signal fired but nothing real happened at that hole (a
shadow, a decode artifact). Excluded from every count via the same
getRealVisits()mechanism as thigmotaxis, but kept as a distinct string throughout (never collapsed into thigmotaxis) so a reviewer or a future reader of the export can tell which kind of "doesn't count" applies. Gets its own dotted-outline visual treatment (not thigmotaxis's solid fill or undetermined's dashed red) in both the DOM raster and its canvas PNG export. - 0.2%-tracked runs no longer shown as success. Picked 50% tracked as the success/error styling cutoff - a first-pass line, not tested against real data, same posture as every other UI-only threshold in this file. Detect events stays enabled either way regardless of the cutoff (flag, don't block - consistent with how every other degraded- data case in this app is handled, the human decides whether it's still usable).
- Every threshold box now writes back its own resolved default when
cleared (track/event/classifier, seven inputs total) -
parseIntOr/parseFloatOralready correctly used the default rather than silently treating a legitimate 0 as empty (finding #4 of the 2026-09-04 correctness audit), but the box itself never reflected what was actually used, so the screen and the value in force could visibly disagree for the rest of the session. - WebCodecs-unsupported guard moved to the actual choke point. Used to
disable only
fileInput/loadExampleBtn, leaving "Open project file"/ "Open folder"/"Select videos"/any roster click/drag-and-drop live - each ran untildecoder.init()failed deep insideloadVideo(), producing a second, unrelated-looking error for a problem already diagnosed at page load. Guarded insideloadVideo()itself (confirmed the single call site for every video-load path viadecoder.init()appearing nowhere else) rather than disabling five separate entry points individually - also disabled the three previously-uncovered buttons for discoverability, but the real fix is the choke-point guard, since a roster click/drag-and-drop has no button to disable at all.
Not fixed same day, moved to KNOWN_LIMITATIONS.md instead (each needs real design work: a hole add/remove UI or a hard accept-block, a trial-start correction control with a recompute cascade, a keyboard- seek overlay for Path/Heat map/the failure strip, and virtualizing the events table for the stated 2019-laptop-no-GPU target). One exception partially addressed same day without the full fix: the trial summary export now records the trial-start frame every measure was computed from (previously only in the event-detail sheet) - doesn't make trial start correctable, but at least puts a wrong one on the record.
Also did a duplicate/staleness pass over KNOWN_LIMITATIONS.md while adding these (her explicit ask, not incidental): found and marked RESOLVED four entries that were already fixed in code but never updated
- the one-liner+"About"-toggle text split (18
.info-toggles exist now), Merged Visits'effectiveVisitType()bug, and all three visualization tabs being built - all three had been left describing an earlier version of the app, the same failure class as the README-staleness incidents logged earlier in this file. The ROI hole-count entry and the_AUDITEDmark-as-processed entry were corrected in place rather than duplicated, since the audit sharpened claims those entries were already making rather than finding something wholly new.
Later the same day, worked through the rest of the audit's cheap "contained" items (try/finally around all four restore cascades so a throw mid-resume can no longer permanently lock the UI; IndexedDB write failures now surface a real banner instead of console-only; every threshold box now stales its own downstream results the moment it's edited, following the pipeline's actual dependency chain - track thresholds stale events/visits/measures/classifier too, since all four consumed a trackResult produced under the old value, while classifier bars only ever stale the classifier's own result; "0 candidate events" is now error-styled with a specific out-of-range call-out for min center density, the one threshold with a real declared range; three doc-file references removed in favor of stating the fact inline, since a rotating tech reading the deployed app can't open a repo-root .md file the same way a developer can; Load Example's failure message now names GitHub specifically and points at Browse/drag-and-drop as a fallback). Two stale orienting-text paragraphs (finding #14 - the topbar subtitle still described piece-2 scope, and the QC panel's own placeholder text wrongly claimed "Events & merged visits" hadn't moved there yet, when it had) were fixed in the same pass, found while touching the topbar for the storage-warning banner.
Language pass (finding #23), same day. Audited every touchpoint
before changing anything - not every word the finding named turned out
to be a real duplicate. Two are actually naming different things and
were left alone: "Human review" is the CONTROL a person interacts with
(the dropdown, the column header), and "Confirmed: poke"/"Confirmed:
entry" is what an OPTION means when selected - neither competes with a
status word, any more than "Edit" competes with "Edited." The one real
duplicate: "human-confirmed" appeared in exactly two places (the
classifier verdict's inline text, and one export column) naming the
exact same state "audited" already names everywhere else (the
audited-badge, "Label audited values," the "(audited)" cell suffix,
"Human-audited" in the event-detail sheet) - collapsed both onto
"audited" since it was already the majority term. Deliberately did NOT
rename eventOverrides/classifierOverride/the project file's
overrides JSON key to match - those are invisible to a user, and the
overrides key is a literal on-disk field name in every already-saved
project file, seed data included (app/seed/test50.bmp.json has 24 real
eventOverrides entries) - renaming it would need a migration path for
a purely cosmetic, user-invisible consistency gain, which isn't a trade
worth making under this deadline.
2026-09-07, later the same day: demo video recorded, brief-compliance
audit, KNOWN_LIMITATIONS staleness pass. The demo video (Demo_Video.mp4,
3:22) was recorded, following the sequence planned earlier the same day:
test51 live end to end (load, background/ROI, one keyboard ROI correction,
live tracking, event detection with a human-review override and a note,
measures, export CSV+XLSX+project file, all shown live), test50 via Load
Example (resume banner confirmed working), test53 via Open project file
(the video-match/recompute-without-retracking flow shown live, honest about
being a resume rather than a fresh run), ending on a deliberate "Clear
everything." Verified frame-by-frame (not by watching, since the reviewing
session had no audio/playback access) by sampling stills across the whole
timeline with OpenCV - confirmed duration/resolution match the claimed
length, confirmed the full planned sequence is present and nothing is stuck
or broken. One real, minor finding, put to her directly rather than decided
for her: zooming the video panel to 150% briefly drags the whole page into
horizontal scroll (~40s, clipping the header) before self-correcting - a
real containment bug (the video panel's zoom isn't scoped to its own
container). Her call: ship as-is rather than re-record, since it's
cosmetic, self-corrects, and re-recording this close to the deadline
carries more risk than the bug itself. Logged in KNOWN_LIMITATIONS.md
below rather than fixed, since the video is already final.
A mechanical brief-compliance check (audits/2026-09-07-brief-compliance.md,
run against tasks/01-barnes-maze.md's own line items) came back 60 YES, 2
PARTIAL, 0 NO, 1 unconfirmed. The two PARTIALs (test50/test53 shown via
pre-tracked reload rather than a live re-track in the demo video) are the
deliberate design already described above, not gaps - reconfirmed here so a
future reader doesn't mistake the PARTIAL verdict for an oversight. The one
unconfirmed item (whether the README states outright that no GPU is
required) was real: grepped and confirmed the claim was never written down
anywhere, despite CLAUDE.md naming "a 2019 laptop with no GPU" as the target
hardware from the start. Fixed: one sentence added to README's "Who this is
for" section.
Also ran a staleness audit (forked out, read-only first) of README.md,
KNOWN_LIMITATIONS.md, DECISIONS.md, and AI_NOTES.md against current app
state - the same failure class named earlier in this file (docs drifting
behind a fast-moving build). DECISIONS.md's own last ~20 entries and
AI_NOTES.md (still under a page, no factual errors) both checked out clean.
KNOWN_LIMITATIONS.md had one real gap: four UX-audit findings from earlier
Monday's session (decode-miss vs. tracking-miss both collapsing into one
"failed" count in track.js, the video-flag dialog's flagVideoForm.reset()
always reopening blank and silently overwriting an existing flag on
resubmit, the tracking-failure strip's color-only fraction gradient with no
pattern cue, and the raster PNG export baking in pattern-distinct visit bars
but never the legend that explains them) had been found and fixed-elsewhere
in conversation, but never actually written into this file - so the file
still read as if everything was resolved. Added as a new entry, each claim
re-verified directly against the current code (exact line numbers) rather
than restated from memory, since the whole point of this pass was not to
propagate a stale claim into a supposedly-fresh one.