Skip to content

Land Epoch Reviewer stack, remove stale docs/dead code, gate the release pipeline - #235

Merged
jdpigeon merged 31 commits into
mainfrom
land-epoch-reviewer
Aug 7, 2026
Merged

jdpigeon merged 31 commits into
mainfrom
land-epoch-reviewer

Conversation

@jdpigeon

@jdpigeon jdpigeon commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Four commits. The first recovers work that was stranded; the rest clean up after it, close the gap that made it possible to miss, and act on review.

Net: +2,320 / −1,011 across 42 files.

1. Land the Epoch Reviewer stack — 387fac2

PRs #222–#225 all report MERGED, but each merged into its parent branch, not main. Only the bottom of the stack (pyodide-analysis-fix, #220) was retargeted; when it landed, the children were never re-pointed. The result: main got a plan document and nothing else, while the implementation sat on epoch-reviewer-phase1, 24 commits ahead and invisible.

Clean merge, no conflicts — the stack never touched package.json/package-lock.json, so main's dependabot bumps didn't collide.

Plus a fix cluster that matters independently of the feature:

Commit Fix
beb1620 Scale Muse µV→V on load, so auto-flag stops rejecting every epoch
7fdccfb Default EEG on + warn before a run with no brain data
c1cb40d Remove the Emotiv-era USB receiver step from the Muse connect flow
69396e4 Show real condition names (Faces/Houses) instead of STIMULUS_n
68a410b Open workspace folder via absolute path in the main process

2. Remove stale docs and dead code — a97bd04

Dead code, all verified zero-caller after the merge:

  • plotTestPlot — debug scaffolding that rendered a four-point zigzag titled "Test Plot" into the real plotKey: 'topo' UI slot. Its only caller was swapped for plotTopoMap in Fix Pyodide analysis pipeline (harvest #194: dataKey routing, MEMFS, MNE 1.x) #220.
  • cleanEpochsPlot — rendered a matplotlib epochs figure in WASM then immediately plt.close()d it, never routed to the UI. Became dead in this merge — the Epoch Reviewer replaced its last caller, as the plan predicted.
  • parseSingleQuoteJSON — obsoleted by the worker's toJs() dict_converter.

Docs: deleted docs/lsl-implementation-plan.md (477 L — LSL shipped in #204/#221; every file in its "New files to create" inventory exists). Corrected device-connectivity.md, which opened with "currently: Muse only" — false since the EEGDriver registry.

Also dropped the "Pre-existing TypeScript errors (do not treat as regressions)" entry from .llms/learnings.md and TODOS.md. Those errors are fixed, and a standing instruction to ignore type errors in two files is a permanently disabled alarm.

3. Gate the release pipeline — f3f432c

The Release workflow has never executed. No tag has ever been pushed. As written, a tag ran npm install + package-ci and published whatever compiled — no test, lint, or typecheck on the release path.

  • New verify job (test + typecheck + lint) that the publish matrix needs:. Nothing builds or uploads until it's green.
  • Split package-ci into build → verify artifacts → publish, so a build with a missing Pyodide payload can't be published.
  • npm ci in both workflows, so the tree CI passed on is the tree that ships.
  • fail-fast: true to limit half-good uploads across the three OS legs.

⚠️ Behavior change: publish.releaseType: "draft". electron-updater is wired to this repo, so a tag would otherwise push an update to installed clients the moment it built. Draft means artifacts upload but a human publishes.

4. Address review — 2b339c7

−1,490 net. Every item from the review was taken; two were taken further than proposed.

Docs + the vendored skill (−950). Deleted faces-houses-qa-plan.md (a manual QA script nothing reads), epoch-review-ui-plan.md (a plan for work in this diff; Phase 3/4 already live in TODOS.md), and the accidentally-vendored agent-browser skill including its checked-in symlink.

The path contract: detect → make impossible. The review was right that regex-scraping src/main/index.ts to compare a folder name is brittle. New src/shared/pyodideAssets.ts exports PYODIDE_SOURCE_DIR / PYODIDE_RESOURCE_DIR; src/main/index.ts imports them, and tests/pyodideAssets.test.ts is deleted. One assertion survives in build.check.ts: package.json is JSON and cannot import, so extraResources ↔ constants is the one pairing a shared symbol can't enforce.

Test harness collapsed. build.test.ts → build.check.ts — the default glob never matches it, so vitest.build.config.ts and the vitest.config exclude both go. Two files coordinating to hide one test, replaced by a filename.

YAGNI. getPtpThresholdPreset(_device) ignored its parameter and returned a literal → PTP_THRESHOLD. hideAutoFlagEpochs (no experiment sets it) and the always-true showAutoFlag param. EpochArraysMeta.sfreq/.drop_log and SuggestedRejection.channel/.peak_uv — zero readers outside fixtures, dropped from utils.py too. The amp ± gain control (lanes already autoscale). The settling banner (HelpSidebar says the same thing, and this PR adds the Help entry point to that screen).

Native + stdlib. role="button"/tabIndex/onKeyDown → <button type="button"> in both places. times.findIndex((t) => t >= 0). conditionIndexForCode → Math.max(0, codes.indexOf(code)) inline. The channel-count warning now uses showMessageBox, matching handleCleanData, instead of a Radix Dialog plus a state field.

Shrink. downsampleMinMax's width >= length branch, meanTrace's empty guard, the double-modulo in conditionPalette, utils.py's single-file short-circuit, and the dataKey === 'epochArrays' special-case in the worker. palette is now required in plot_topo/plot_conditions, so the copied _DEFAULT_CONDITION_PALETTE is gone.

One deviation worth flagging. The review asked to drop peak_uv from utils.py, but test_ptp_in_physiological_range — the µV→V regression guard the review explicitly kept — read its value. Rather than keep the field or lose the guard, the test now computes peak-to-peak from the epochs directly. It tests the data instead of the reporting payload, which is what it was always trying to do.

Test plan

Authored on a machine with no Node, no node_modules, and no MNE — CI is the first execution. The previous push already answered the biggest unknowns: the ubuntu leg passed, which means npm ci succeeded (lockfile in sync), the merged stack passes against main's dependabot bumps, and test:build ran clean after Build.

This push needs re-verification of:

  • npm test — fixture edits in EpochReviewer.test.tsx / epochArrays.test.ts, and the removed conditionIndexForCode / downsampleMinMax cases
  • npm run typecheck — the PTP_THRESHOLD swap, dropped interface fields, and the renderReview signature change
  • npm run lint — two <div role="button"> → <button> conversions
  • npm run build + npm run test:build — the renamed check and new shared constants
  • Analysis (ERP round-trip) — test_suggest_rejections.py rewritten; palette now required in plot_topo/plot_conditions

Not covered by CI:

  • Manual: run Faces/Houses, Clean, reject epochs, confirm the live ERP updates and the cleaned .fif round-trips to Analyze
  • v1.0.0-rc.1 dry-run — the release path is still unproven end to end

Still open

Recorded in TODOS.md, not addressed here:

  • No packaged-app launch smoke test. build.check.ts proves the payload is on disk; nothing proves the app starts. That's the gap that would catch a prod-only Pyodide regression automatically.
  • The release pipeline is gated but unexercised. Worth an RC tag before any demo depends on a released build.

Dano Morrison and others added 30 commits July 6, 2026 18:00
The "Clean Data" flow told Pyodide to `raw_epochs.save("<host OS path>")`,
but the worker's filesystem is MEMFS (in-memory) and cannot reach host
disk — so the cleaned `-epo.fif` never landed on disk and the Analyze
screen's `readWorkspaceCleanedEEGData` picker found nothing in a fresh
workspace. This repairs that flow with a MEMFS→host write-back bridge,
the one slice the epoch-review plan (§6d / Open Question 8) declares
resolved and standalone.

Chain: Python saves to MEMFS `/tmp/<subject>-cleaned-epo.fif` → the
worker reads the bytes and posts them back zero-copy (transferable
ArrayBuffer) on a new `savedEpochs` dataKey → `pyodideMessageEpic`
writes them to `Data/<subject>/EEG/<subject>-cleaned-epo.fif` via a new
`fs:writeCleanedEpochs` IPC channel that mirrors `fs:storePyodideImagePng`.

- src/main/index.ts: new `fs:writeCleanedEpochs` handler (binary
  renderer→disk write)
- src/preload/index.ts + electron.d.ts: `writeCleanedEpochs` bridge + type
- webworker.js: `readFileAfter` read-back path posts MEMFS bytes as a
  transferable
- webworker/index.ts: `saveEpochs(worker, subject)` now saves to MEMFS
  (overwrite=True) and requests read-back
- pyodideEpics.ts: route `savedEpochs` → `writeCleanedEpochs`, with a
  success/error toast

Output path is bit-identical to what the Analyze picker globs (`epo.fif`
suffix). Also tempers docs/epoch-review-ui-plan.md against source.

typecheck: clean · eslint: clean · build: green
Resolves the forge-blocking open questions and writes a forge-ready
Phase 0/1 execution plan into docs/epoch-review-ui-plan.md §0:

- OQ1 rendering → Canvas 2D + DOM/SVG overlay, thin drawEpochs boundary,
  viewport windowing; WebGL a later swap.
- OQ2 transport → extend the dataKey pattern (epochArrays, applyRejection);
  defer the runPython RPC (matches TODOS).
- OQ6 live ERP preview → in scope for v1 (Phase 1), computed CLIENT-SIDE
  over the Phase-0 buffer (pure computeErp mean, zero worker round-trips) —
  Flavor 1 (while-cleaning) only; real-time-during-experiment is a separate
  future non-Python effort.
- Shared condition-color palette built in Phase 0 (used by Phase-0 render,
  so not dead code).
- Narrow Pyodide buffer-path fidelity test added in Phase 0.
- Delivery split into two sequential PRs.

Also updates the standing Pyodide-fidelity TODO to note the narrow test.
First half of the interactive epoch-review UI (docs/epoch-review-ui-plan.md
§0/§9). Read-only this phase — proves the data path and the Canvas 2D
rendering choice; click-to-reject, apply, and live ERP are Phase 1.

Data path (all new, extends the proven dataKey pattern — no runPython RPC):
  Load Dataset → loadEpochsEpic → requestEpochArrays('raw_epochs')
    → worker get_epochs_arrays() writes a float32 buffer to MEMFS + returns meta
    → worker posts { buffer, results, dataKey:'epochArrays' } zero-copy transferable
    → pyodideMessageEpic → SetEpochArrays → reducer → CleanComponent → EpochReviewer

- utils.py: get_epochs_arrays(epochs, out_path) → float32 C-order
  [epoch][channel][time] buffer + metadata (Marker/stim channel excluded via
  pick_types(eeg=True)); plot_topo gains a palette param; condition-color
  fallback DRYed to one _DEFAULT_CONDITION_PALETTE constant.
- webworker.js: readFileAfter read-back unified to post { buffer, results }
  (savedEpochs write-back from PR #222 migrated to the same field).
- conditionPalette.ts: canonical condition palette, single source for the
  Python matplotlib plots (JSON-injected) and the React reviewer.
- EpochReviewer.tsx: Canvas 2D traces (epochs side-by-side, channels stacked)
  + DOM overlay (labels, dividers), viewport windowing (first 8 epochs),
  spike-preserving min/max downsampling, condition coloring, empty state, DPR
  scaling. Pure decode/downsample helpers in epochArrays.ts.

Tests: native pytest (get_epochs_arrays buffer decodes to get_data(), Marker
excluded, byte length, event codes) + vitest (decode/downsample/color-index).

typecheck 0 · eslint 0 errors · vitest 37/37 · native pytest 10/10 · build green
Second half of the epoch-review UI (docs/epoch-review-ui-plan.md §0/§9) —
turns the static Phase 0 viewer into a working, teach-as-you-go cleaner.

Interaction (EpochReviewer, Canvas 2D + DOM overlay, OQ1):
- Click an epoch column to toggle it rejected (greyed trace + wash + ✕).
- Prev/Next navigation through epoch windows; column→ABSOLUTE index math is
  offset-aware so the right epochs are dropped after paging.
- Amplitude gain control (clamped, traces can't bleed across lanes).

Live ERP preview (OQ6, client-side — the teaching payoff):
- LiveErpPane averages NON-rejected epochs per condition via the pure
  meanTrace() over the Phase-0 buffer and redraws on every toggle. ZERO worker
  round-trips (OQ2=2A, no runPython RPC); empty selection → flat line, no NaN.

Apply at "Clean Data":
- apply_rejection(epochs, drop_indices, bad_channels) in utils.py (epochs.drop
  + info['bads']), native-tested BIT-IDENTICAL to a manual MNE drop/bads.
- CleanEpochs carries {dropIndices, badChannels}; cleanEpochsEpic posts
  apply_rejection (trailing ';' suppresses the Epochs PyProxy) → saves via the
  existing write-back bridge (PR #222) → re-fetches arrays + stats. Local
  selection clears after commit (fresh 0-based indices next round).

Bad-channel UI is Phase 2 — apply_rejection takes the param but the UI wires
badChannels: [] for now.

typecheck 0 · eslint 0 errors · vitest 44/44 · native pytest 14/14 · build green
Auto-flag = dev-gated one-click button + expandable threshold settings;
suggestions computed in Python (peak-to-peak), pre-marked but overridable.
Bad-channel flagging enabled on all devices with a warning Dialog on the 2nd
bad channel of a 4-ch dataset. Condition legends use codeToLabel.
…bels

Full-parity phase (docs/epoch-review-ui-plan.md §0/§9).

Bad-channel flagging (OQ5):
- Click a channel label to toggle it bad (struck-through red label + red lane
  wash across all epochs). Flows into the Phase-1 apply path:
  CleanEpochs({dropIndices, badChannels}) → apply_rejection sets info['bads'].
- shadcn Dialog warns when marking a 2nd bad channel on a 4-channel (Muse)
  dataset — informational, user can proceed.

Auto-flag (OQ4 + New-A, Python/MNE):
- Dev-gated "Auto-flag artifacts" button (AUTO_FLAG_EXPERIMENTS set, keyed by
  the EXPERIMENTS enum) + expandable peak-to-peak threshold settings.
- suggest_rejections(epochs, threshold_uv) in utils.py (peak-to-peak per epoch,
  Marker excluded; advisory — drops nothing), native-tested. New
  'suggestedRejections' dataKey (fire-and-forget, no runPython RPC) →
  SetSuggestedRejections → CleanComponent PRE-MARKS suggested epochs (additive
  union, fully overridable) and shows reasons. Actual drop still via
  apply_rejection, so the saved .fif stays MNE-exact.

Condition legend (New-B):
- EpochReviewer + LiveErpPane legends use human-readable labels via
  buildMarkerRegistry(stimuli).codeToLabel (fallback to "Condition {code}").

typecheck 0 · eslint 0 errors · vitest 44/44 · native pytest 17/17 · build green
Phase 3 (onboarding layer, OQ3 open — route via office-hours/ceo-review) and
Phase 4 (N-channel/WebGL, a11y, keyboard, perf; OQ7 open) added under Deferred.
Three non-blocking Phase 2 review notes (bad-channel display exclude='bads',
additive auto-flag re-merge, threshold min guard) added under tech debt.
- Strip process-narrating / Phase-history comments (EpochReviewer top block,
  pyodideActions, pyodideEpics, webworker index, EpochReviewer color/legend);
  keep only definition-level intent comments.
- Invert auto-flag registration: replace the central AUTO_FLAG_EXPERIMENTS set
  with an opt-out hideAutoFlagEpochs field on ExperimentParameters (default on),
  colocated with the experiment definition.
- Memoize buildMarkerRegistry by stimuli reference so the code->label map isn't
  rebuilt on every CleanComponent render.
- Record the repo's comment-style preference in .llms/learnings.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uiGzUcGN7j6qDWrfH5MCr
Lets any contributor drive the live BrainWaves Electron app over CDP
(agent-browser connect 9333) for QA/dogfooding, instead of a headless
browser that whitescreens without the preload bridge. SKILL.md self-
documents install (npm i -g agent-browser); skills-lock.json pins the
source + content hash. See .llms/learnings.md for the launch/attach loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaZP9vefeQV4CvNAm5Vv2Y
…very epoch

load_data built the MNE RawArray straight from the µV CSV values without the
µV→V scale MNE expects, so peak-to-peak came back ~1e6× inflated (tens of
millions of "µV") and suggest_rejections flagged all epochs → blank ERPs.

- Scale eeg rows ×1e-6 in load_data, keyed on ch_type=='eeg' (stim/Marker row
  untouched; marker codes stay numeric).
- Emit µV (×1e6) at the two display boundaries — the epoch-viewer buffer
  (get_epochs_arrays) and the ERP plot (already labeled uV) — so the viewer and
  plot show the same numbers as before while MNE-internal units are now correct.
- Regression guard: assert normal ptp lands 1–1000µV (not ~1e7).
- Fix test_detects_injected_artifact: it added a DC offset (ptp-neutral) and
  only "passed" because the units bug flagged everything; now injects a real
  single-sample spike.

QA plan 6e (T1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
openWorkspaceDir passed a relative "BrainWaves_Workspaces/<title>" to
shell.showItemInFolder, which silently no-ops on non-absolute paths — the Home
screen "Go to Folder" button did nothing. Add a shell:openWorkspaceDir handler
that resolves the absolute path with getWorkspaceDir and opens it via
shell.openPath; point the renderer at it.

QA plan 9a (T4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
isEEGEnabled defaulted to false — nonsensical for an EEG app, and a run would
silently record behavior only with no warning. Flip the default to true, and
guard handleStartExperiment: if EEG is disabled, or enabled but no device is
connected, confirm ("...records responses but no brain data. Continue anyway?")
before starting, reusing the existing showMessageBox pattern.

QA plan 7 (T3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
Muse is Bluetooth — there is no USB dongle. Drop the COMPUTER_CONNECTABILITY
"Insert the USB Receiver" step so "Turn your headset on" → Next goes straight to
searching for devices.

QA plan 8 (T6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
…TIMULUS_n

buildMarkerRegistry now derives display labels from the experiment's own
stimulus.condition, so Clean counts/legend and the ERP read "Face"/"House"
instead of the generic STIMULUS_1/2. Numeric codes are untouched (they still
drive the CSV Marker column and MNE event_id — labels are display-only). Falls
back to STIMULUS_n when a stimulus has no condition, and refuses to collapse two
codes under one label (would silently merge conditions in analysis). The Clean
legend and Python ERP legend pick the names up automatically (both read off the
registry / epochs.event_id).

QA plan 6b (T7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
The savedEpochs branch wrote whatever buffer arrived and only caught write
errors. An empty/dropped buffer wrote nothing and errored nothing, leaving the
Analyze picker mysteriously empty. Guard: if the buffer is missing or
zero-length, surface an error toast instead of failing silently.

QA plan 9b (T5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
Cleaning with all epochs flagged produced an empty, unanalyzable dataset (and
previously wrote a degenerate .fif with no error) — the source of the empty
Analyze picker after auto-flag over-rejected. handleCleanData now confirms
("This will reject all N epochs, leaving nothing to analyze") before dropping
when the reject set covers every epoch.

Note: this file also carries in-progress epoch-reviewer Phase 2 Clean-screen
changes that were already staged in the working tree; they are committed here
because they could not be separated non-interactively. The T2 guard is the
handleCleanData method + its wiring.

QA plan 6e/9b (T2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
- 6a: the ⚙ auto-flag settings button now uses Button size="icon" (h-9 w-9),
  matching the height of the adjacent Clean Data / Auto-flag buttons.
- 6g: replace the raw µV number input with a range slider labeled by effect
  ("More flags ← → Fewer flags"), showing the current µV inline and via
  aria-valuetext. Bounds come from getPtpThresholdPreset(device) — a single
  validated range today, with a per-device seam for later (no fabricated
  per-hardware ceilings). Only meaningful now that units are fixed (T1).

QA plan 6a/6g.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
When a run finished, RunComponent silently re-rendered the identical pre-run
landing — no confirmation it recorded, and the only way forward (top nav) was
undiscoverable. Track hasFinished and, once a run ends, show a completion panel
("Recording complete") with a primary "Clean your data →" link to the Clean
screen and a "Run again" secondary. Closes the Collect dead-end.

QA plan 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
- 3: replace the Emotiv saline-soak help tip with device-agnostic guidance
  (clean bare skin, sweep hair) that also explains sensor settling; rename the
  HELP_STEP.SIGNAL_SALINE step to SIGNAL_SETTLING. Mount the HelpButton +
  HelpSidebar on the Explore screen (previously Collect-only).
- 1 (lightweight): show an amber role="status" settling banner for the first
  ~45s after connecting ("a red, jumpy signal is normal while sensors make
  contact… watch it turn green"), auto-dismissed by a timer and manually
  closeable. No change to the signal-quality math — the honest red state still
  surfaces after the window. Full amber per-dot settling state (DF2) deferred to
  a live design-review pass.

QA plan 3 + 1 (lightweight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
…dedupe wrapper

Concrete markup fixes for the pre-run landing (QA item 4), short of the full
two-column DF1 redesign (deferred to a live design-review):
- The "mystery empty bar" was a `w-full` button holding only a pencil emoji;
  now a small ghost icon button beside the title.
- Collapse the redundant nested h-screen/gradient wrapper (the outer container
  already provides it) into a centered flex.
- Wrap the subject/group/session summary in a compact shadcn Card, right-size
  the Run Experiment button (no longer w-full), and drop the <h1>{title}</h1>
  that duplicated the nav breadcrumb.

QA plan 4 (markup subset; DF1 live-signal column deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
When no cleaned data exists, the "Select Clean Datasets" picker rendered an
empty box with no guidance — the confusing blank Analyze screen from the QA
walkthrough. Show a warm message ("No cleaned data yet — clean a recording
first…") with a "Go to Clean →" link instead.

QA plan 9b / T11 (Analyze empty state).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
Checks off T1–T7, 6a/6g, #3/#4-markup/#5, lightweight #1, and the Analyze empty
state (commits beb1620..376740f), and documents why #2/#6c (profile-first),
#4-DF1 + DF2-full (visual/design-review), and #6d (Phase-3 product) are deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTrEip1cj1QLTU48qr9jLw
- Refactored the web worker's message handling to ensure asynchronous commands are processed in sequence, preventing overlap and ensuring Python globals are available before subsequent commands.
- Introduced a new function `load_clean_epochs` in `utils.py` to load and concatenate cleaned .fif epoch files from MEMFS paths, improving data handling.
- Updated the `loadCleanedEpochs` function in `index.ts` to utilize the new `load_clean_epochs` function for better clarity and functionality.
- Adjusted message posting logic to prevent sending non-structured-cloneable MNE objects back to the main thread.

QA plan updates included for the new functionality.
Epoch reviewer Phase 2: bad-channel flagging, auto-flag suggestions, condition labels
The epoch-reviewer stack was based on the pyodide-analysis-fix branch and each
PR merged into its parent rather than main, so only docs/epoch-review-ui-plan.md
reached main when #220 landed. This brings in Phases 0-2 plus the UX and
analysis fixes that rode along.

- Phase 0: epoch arrays shipped to a Canvas renderer (#223)
- Phase 1: interactive rejection, live ERP, apply/save (#224)
- Phase 2: bad-channel flagging, auto-flag suggestions, condition labels (#225)
- Clean->Analyze round-trip: persist cleaned epochs to host disk (#222)
- fix(analysis): scale Muse uV->V on load so auto-flag stops rejecting every epoch
- fix(experiment): default EEG on + warn before a run with no brain data
- fix(connect): remove Emotiv-era USB receiver step from Muse connect flow
- feat(analysis): show real condition names instead of STIMULUS_n
Sweep of debris left after the epoch-reviewer stack landed.

Dead code (zero callers, verified post-merge):
- plotTestPlot — debug scaffolding that rendered a four-point zigzag titled
  "Test Plot" into the real plotKey:'topo' UI slot. Its only caller was
  swapped for plotTopoMap in #220.
- cleanEpochsPlot — rendered a matplotlib epochs figure in WASM then
  immediately plt.close()'d it; never routed to the UI. The Epoch Reviewer
  replaced its last caller.
- parseSingleQuoteJSON — obsoleted by the worker's toJs() dict_converter.

Docs:
- Delete docs/lsl-implementation-plan.md. LSL shipped in #204/#221; every file
  in its "New files to create" inventory exists. Durable knowledge already
  lives in .llms/learnings.md; remaining per-platform checks are in TODOS.md.
- device-connectivity.md said "currently: Muse only" — false since the
  EEGDriver registry (Muse, Neurosity, LSL). Corrected and pointed at the
  driver contract; the Muse-specific flow diagrams are retained as such.
- epoch-review-ui-plan.md still called itself "Preliminary ... not yet an
  implementation spec" after Phases 0-2 merged. Status updated.

Stale known-issues (fixed, but still instructing readers to ignore errors in
two files — which would mask real regressions):
- Drop the "Pre-existing TypeScript errors" entry from .llms/learnings.md and
  TODOS.md. experimentEpics.ts now carries explicit eslint-disable + typed
  workarounds, routes.tsx is clean v6, and CI's typecheck passes.
- deviceActions.ts: drop a "(Phase 3)" reference to the deleted LSL plan.
The Release workflow has never run — no tag has ever been pushed — and as
written a tag published whatever compiled: no test, lint, or typecheck ran on
the release path, and both workflows used 'npm install', so the tree CI passed
on was not necessarily the tree that shipped.

Release workflow:
- New 'verify' job (test + typecheck + lint) that the publish matrix depends
  on. Nothing builds or uploads until it is green.
- Split 'package-ci' into build -> verify artifacts -> publish, so a build with
  a missing Pyodide payload cannot be published.
- npm ci, and fail-fast so a broken tag uploads as few half-good artifacts as
  possible across the three OS legs.

test.yml: npm ci, plus the artifact check after Build.

package.json: publish.releaseType 'draft'. electron-updater is wired to this
repo, so a tag would otherwise push an update to installed clients the moment
it built. Draft means artifacts are uploaded but a human publishes.

New tests:
- tests/pyodideAssets.test.ts — static guard on the contract between
  extraResources ('to': pyodide) and the main process protocol handler
  (path.join(process.resourcesPath, 'pyodide')), plus the dev-side path and the
  directory InstallMNE writes into. These live in three files with no shared
  constant; a mismatch is invisible in dev and broke prod completely once (see
  .llms/learnings.md). Needs no build, so it runs in the default suite.
- tests/build.test.ts — post-build presence checks: the three bundles, the
  Pyodide runtime files, and every wheel named in the package manifest. Runs
  via 'npm run test:build' under its own config and is excluded from the
  default run, so a missing build is a loud ordering error, never a silent skip.

TODOS: record the two things this still does not cover — an actual packaged-app
launch smoke test, and the first release dry-run.
…e code

docs + vendored skill
- Delete docs/faces-houses-qa-plan.md (603 L) and docs/epoch-review-ui-plan.md.
  A manual QA script and a plan for work that just shipped; nothing reads
  either. Phase 3/4 already live in TODOS.md as their own bullets.
- Delete the accidentally-vendored agent-browser skill (.agents/, skills-lock,
  and the .claude symlink) — a checked-in symlink is a Windows-clone hazard.

path contract: detect -> impossible
- New src/shared/pyodideAssets.ts exports PYODIDE_SOURCE_DIR and
  PYODIDE_RESOURCE_DIR; src/main/index.ts imports them instead of repeating
  literals. Deletes tests/pyodideAssets.test.ts, which regex-scraped main's
  source to compare a folder name. package.json is JSON and can't import, so
  that one pairing is asserted in build.check.ts.
- Rename build.test.ts -> build.check.ts: the default glob never picks it up,
  so vitest.build.config.ts and the vitest.config exclude both go.
- Drop the packageJson.main check (out/main/index.js already covers it).

yagni
- PtpThresholdPreset + getPtpThresholdPreset(_device) ignored its parameter and
  returned a literal -> PTP_THRESHOLD const. DEFAULT_PTP_THRESHOLD_UV folds in.
- hideAutoFlagEpochs: no experiment sets it; renderReview's showAutoFlag param
  was always true.
- EpochArraysMeta.sfreq/.drop_log and SuggestedRejection.channel/.peak_uv had
  zero readers outside fixtures. Dropped from utils.py too.
- EpochReviewer amp +/- gain: each lane already autoscales, so gain only bought
  clipping.
- EEGExplorationComponent settling banner: HelpSidebar says the same thing and
  this PR adds the Help entry point to that screen.

native + stdlib
- role=button/tabIndex/onKeyDown -> <button type="button"> in both places.
- times.findIndex((t) => t >= 0) for the t=0 line.
- conditionIndexForCode -> Math.max(0, codes.indexOf(code)) at three call sites.
- Channel-count warning uses window.electronAPI.showMessageBox, matching
  handleCleanData, instead of a Radix Dialog plus a state field.

shrink
- Dropped downsampleMinMax's width >= length branch (only tests reached it),
  meanTrace's empty guard (LiveErpPane already skips empty groups),
  conditionPalette's double-modulo, utils.py's single-file short-circuit, and
  webworker's dataKey === 'epochArrays' special-case.
- palette is now required in plot_topo/plot_conditions; both call sites inject
  it, so the copied _DEFAULT_CONDITION_PALETTE is gone.
- LiveErpPane derives includedCount; EpochReviewer reuses visibleCount and
  hoists uniqueSortedCodes above the effect.
- cleanEpochsEpic is tap + map, not async returning a value to map back.
- Deleted experimentReducer.test.ts (a literal asserted against a literal).

test_suggest_rejections no longer asserts on the dropped keys. The uV->V
regression guard now computes peak-to-peak from the epochs directly, so it
tests the data rather than the reporting payload.
CI caught this: the guard was removed but its test remained, so the empty
case divided by zero and returned NaN.

The sole caller (LiveErpPane) already `continue`s on empty condition groups,
so both the guard and the test covered an unreachable path. Deleted the test
and documented the non-empty precondition on meanTrace instead.
- EpochReviewer: the divider loop still referenced `count`, which the
  visibleCount dedupe removed.
- CleanComponent: the suggestion <li> key still read s.channel, dropped from
  SuggestedRejection. Key is index + position now.
- Remove two comments left behind by the deleted gain state and dialog state.
…an include

CI: `vitest run tests/build.check.ts` found no test files. Positional args
filter against `include`, and build.check.ts deliberately doesn't match the
default *.test.ts glob, so nothing matched.

Kept the review's substance — the filename alone keeps it out of `npm test`,
so there is no `exclude` in vitest.config.ts and no two-file coordination.
The build config now only supplies the include.
@jdpigeon
jdpigeon merged commit c26fc29 into main Aug 7, 2026
10 checks passed
@jdpigeon
jdpigeon deleted the land-epoch-reviewer branch August 7, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant