Skip to content

Release: decision rules + analysis-framework rebuild#70

Merged
roiguri merged 19 commits into
mainfrom
dev
Jul 16, 2026
Merged

Release: decision rules + analysis-framework rebuild#70
roiguri merged 19 commits into
mainfrom
dev

Conversation

@roiguri

@roiguri roiguri commented Jul 16, 2026

Copy link
Copy Markdown
Owner

@

Summary

Promotes dev to main. Fast-forward relative to main (no divergence, no conflicts). 19 commits across three groups:

Live decision rules (PR #69)

DecisionEngine turning per-decoder probabilities into sustained on/off decisions, session logging of dense decisions + config timeline, live-stream wiring, and the Phase 2 UI (decision panel + settings controls with a threshold line).

Analysis-framework rebuild (PR #62)

  • Rebuild encoding/retrieval on fresh raw replay instead of frozen predictions
  • Propagate custom decoder timepoints downstream; fix parity; generalize stimulus-order diagnostic

Standalone fixes

  • 6a40d60 fix(online): convert LSL µV input to SI volts (decoder saturation)
  • f2ec938 fix(ui): declutter ICA review grid on varied screen sizes
  • ecb5fcf test: update proxy-lifecycle tests for eager-start behavior
  • e21c40b feat(online): verb-category marker labels + real-time latency instrumentation

Notes

  • Fast-forward merge relative to main.
  • dev and main both in sync with their remotes at PR creation time.
    @

roiguri and others added 19 commits July 2, 2026 16:50
The NeurOne LSL proxy streams EEG in microvolts, but the offline pipeline
and decoder are calibrated in SI volts (MNE loads recordings in volts).
OnlinePreprocessor applied no conversion, so live features arrived ~1e6x too
large and predict_proba saturated to exactly 0/1 — observed in the lab
(2026-07-02). Reproduced end-to-end with the lab decoder + phase2 recording:
graded prediction fraction 0.0000 without the scale, 0.8875 with it.

- OnlinePreprocessor.process_batch applies a fixed µV->V scale
  (LSL_TO_SI_SCALE = 1e-6) up front. It's an intrinsic property of the LSL
  input, so it's a module constant, not a constructor knob — no caller has to
  remember it. Proven exact against the lab recording (physical µV / MNE-volts
  = 1e6). Add TestMicrovoltToSiScale locking it in.
- replay_vhdr_to_lsl streams µV via get_data(units="uV") so the dev replay
  reproduces the live path instead of masking the bug with SI-volt data.
- characterize_lsl prints declared per-channel unit metadata so the wire unit
  can be confirmed on real hardware (change the one constant if it differs).
- Derive the topomap grid's ncols/nrows from the screen aspect ratio so
  maximized cells stay near-square on any monitor, instead of a fixed
  near-square grid that crowded titles on wide/low-res displays.
- Move each component's ICLabel category + confidence to the axes xlabel,
  leaving the subplot title as the bare index (ICAxyz). Keeps each line
  short and preserves MNE's title-click toggle + grey-on-reject.
cb2ced1 changed product behavior but left three test files on the old
contract, causing 9 failures. Update the tests (no product change):

- test_stream_source: stub subprocess.run so start()'s new taskkill
  orphan-cleanup neither shells out nor routes through the Popen factory.
- test_phase2_lifecycle: model the idempotent proxy source via a
  source_running flag; assert Halt keeps it running and close stops it.
- test_phase2_launch: stub start_stream_source so constructing the screen
  no longer launches the real LSLProxy.exe.
…rozen predictions

The encoding/retrieval sources previously just read back a past live
session's predictions.csv, so you could never try a different
model/hyperparameters/pos-neg classes for them (only for FL) -- and, as
discovered this session, data/sub_001's actual recorded predictions.csv
predate a backend fix (6a40d60) and are fully saturated/unusable anyway.

Both sources now replay the task recording fresh through
OnlinePreprocessor + a caller-chosen LiveInferenceEngine, the same way
FL already works, pulling markers directly from the recording's own
annotations instead of a saved CSV. This also required fixing
analysis_lib.streaming.load_recording's unit contract to match
OnlinePreprocessor.process_batch's new unconditional uV->SI-volt scale
(get_data(units="uV"), mirroring replay_vhdr_to_lsl.py) -- verified
numerically and via live replay that this was silently broken
otherwise.

Debug snapshots gain an optional, additive task_data_dir manifest field
(mirroring raw_data_dir) so a seeded profile can reference a held-out
task recording without copying it in; --task-data added to the seeder
CLI. Purely additive -- verified the app's regular and debug launch
flows both still work unmodified.

Also: config-validated marker matching (fails loudly on a renamed/missing
marker instead of silently epoching zero trials), an opt-in
alternate-timepoint retraining cell that redraws the CV-AUC plot with
the new timepoint marked, and removal of the now-dead CSV-based epoching
code path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ctx.task_tp() is what per_decoder/selectivity/competition/
confusion_and_perm/timepoint_table all read for "this decoder's
operating timepoint" -- retraining at a custom timepoint swapped the
engine but never updated that bookkeeping, so every cell after the
alt-timepoint one kept marking/using the snapshot's shipped timepoint
instead of the one actually in use.

Update ctx._tp_by_task after training, but after plot_cv_auc renders --
otherwise "trained tp" and "chosen tp" would collapse into the same
line on that one comparison plot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…imulus-order diagnostic

- Propagate a custom-timepoint retrain into ctx._tp_by_task after
  plot_cv_auc renders (not before, so "trained tp" and "chosen tp" stay
  distinct on that plot) -- every downstream cell (per_decoder,
  selectivity, competition, confusion_and_perm, timepoint_table,
  save_run_summary) reads ctx.task_tp(), so without this they kept
  marking/using the snapshot's shipped timepoint instead of the one
  actually in use.
- plots.parity() now takes an optional `models` param (default
  ctx.artifact.models) instead of hardcoding the shipped snapshot's
  decoders -- it was silently comparing "online" (whichever engine
  actually produced preds) against the *original* shipped model on the
  offline side once they diverged.
- Extract fl_marker_diagnostic's timeline + transition-bias shuffle
  test into a generic marker_order_diagnostic(times, labels, ...), and
  move the notebook's diagnostic cell to after epoching so it also
  works for encoding/retrieval (using each trial's true_label), not
  just FL.
- compare_profiles.ipynb: fix a stale ROOTS default (still pointed at
  data/sub_001 instead of the debug_snapshots profiles that actually
  have summaries), add a SOURCE knob so it can compare encoding/
  retrieval held-out summaries too, not just FL.
- Add experiment_config.realtime_animacy_c1.yaml (C=1 vs the baseline
  C=100) for comparing decoder regularization via a seeded snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebuild analysis framework: raw replay for encoding/retrieval, not frozen predictions
…entation

- Add experiment_config.realtime_animacy_verb_labels.yaml: renames the
  learning/retrieval verb markers to embed their known category
  (learning_verb_animate_1/2/3, learning_verb_inanimate_1/2/3, and the
  retrieval equivalents) instead of a bare verb index -- confirmed with
  the experimenter this pairing is fixed, not per-session-randomized.
  Since every marker display in the app (LiveProbabilityChart,
  FrozenEventView) already renders a marker's configured name verbatim,
  this alone surfaces ground-truth animate/inanimate live, with no new
  UI code needed.
- Generalize task_labels.VERB_RE/RETRIEVAL_VERB_RE to capture a verb's
  identity as an opaque string (whatever follows the prefix) instead of
  assuming a bare digit, so the analysis framework works unchanged
  against either naming convention.
- Add real-time latency instrumentation, per the project proposal's
  <100ms (acquisition of an EEG sample window -> classification
  decision) requirement, which nothing previously measured:
  - LSLReceiver.time_correction()/local_clock() -- clock-offset-aware
    primitives, needed because the acquisition PC and decoding laptop
    are separate machines whose clocks don't perfectly agree.
  - StreamWorker computes sample_to_decision_ms (the proposal's actual
    definition) alongside the existing total_ms (our own pipeline's
    compute time -- pull+accumulation+preprocess+infer+emit, all
    attributed to every batch a given pull produced). Diagnostics-only:
    any clock-sync failure degrades to None rather than interrupting
    live decoding.
  - Phase2Header/Phase2Screen surface both as a rolling ~1s average
    ("Pipeline: X ms | E2E: Y ms"), side by side for now pending a
    real-hardware comparison to settle on one.

Verified end-to-end against a live replay (scripts/replay_vhdr_to_lsl.py
+ the debug --phase2 launch): Pipeline ~4-6ms, E2E ~10-12ms, well under
the 100ms target; DEBUG-level logs additionally confirmed the one-time
first-call cost of time_correction() (~640ms) settles to sub-millisecond
overhead for every batch after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per-decoder independent activation decisions (threshold + sustain latch,
multi-active) over the Phase 2 probability stream. Dense decisions.csv +
decision_config.jsonl timeline; engine as a sibling consumer of
prediction_ready; apply-gated, thread-safe live config changes. Hardcoded
defaults first, optional YAML override last.
Pure-Python building blocks for the per-decoder decision layer (plan A1):
DecisionConfig (hardcoded defaults + per-decoder threshold fallback),
ThresholdCriterion (instantaneous gate), and SustainGate (per-decoder,
independent both-edge debounce latch). No Qt, no I/O.
process_batch consumes a prediction_ready batch and threads SustainGate state
across boundaries, returning a DecisionResult of per-sample latches (plan A2).
set_pending_config stages a config that the next batch applies at its boundary
— bumping the version, resetting counters, keeping latches, and stamping a
ConfigChange with the batch's first timestamp for the logger.
Opt-in decision logging (plan A3): on_decisions appends a dense decisions.csv
(one bool column per decoder + config_version) and, on a config change carried
in the result, a full snapshot to decision_config.jsonl — version 0 written at
construction, manifest gains decision_schema_version/initial_config/n_samples.
Adds episodes_from_decisions to recover on/off intervals by diffing the columns.
…map)

Decisions use one global threshold shared by every decoder — decoders still
latch independently via the sustain gate, they just share the threshold value.
Removes DecisionConfig.thresholds / threshold_for and the per-decoder expansion
in the logged config (now {"threshold": ...}). Simpler config, one chart line,
one settings control; a per-decoder threshold was unused clutter.
Predictions and decisions are already per-timepoint, so seconds + target_sfreq
was an unnecessary round-trip. DecisionConfig now carries integer
sustain_timepoints/release_timepoints (counts of predictions); the engine drops
target_sfreq and the seconds_to_samples conversion, counting timepoints
directly. Removes the rounding/step-size ambiguity of a seconds field.
…_ready

Phase B: DecisionBinding, a thin QObject wrapping the pure engine, joins the
pipeline like the logger does — connected to worker.prediction_ready
(DirectConnection), re-emitting each batch's DecisionResult on decision_ready.
StreamWorker is untouched. AppSession builds the engine from hardcoded defaults
(target_sfreq from the preprocessor) and, when logging, seeds the logger's
version-0 config and connects decision_ready -> on_decisions. LiveStreamSession
owns the binding, resets it on start(), forwards decision_ready, and exposes
update_decision_config(). Decisions are computed on every run, logged or not.
DecisionPanel: a single row of per-decoder status tiles (name · latest
probability · ACTIVE/IDLE). An active tile is highlighted in the decoder's own
colour; idle tiles are muted. Multiple tiles light at once — the multi-active
model made visible. Phase2Screen wires live.decision_ready -> _on_decision
(QueuedConnection) for on/off and feeds probabilities from the prediction
stream; resets on Start. DecisionResult is read duck-typed (no backend import).
…se C4)

Replaces the sidebar placeholder with apply-gated decision settings: a threshold
slider + editable spinbox (synced), an integer sustain-timepoints stepper, and
Apply/Reset (enabled only while the draft differs from the applied config, with
distinct enabled/disabled styling and horizontal -/+ steppers). Apply moves both
chart threshold lines (live + event-locked) and, when a run is live, stages the
new config on the engine; the values also seed the next run built from Start.

Contract kept: the panel emits plain {threshold, sustain_timepoints}; AppSession
builds the DecisionConfig (decision_config_defaults / update_decision_config /
build decision_params). Chart/FrozenEventChart gain set_threshold. Folds in C2
(live threshold line). C3 history strip stays deferred.
feat: live decision rules (DecisionEngine + UI)
@roiguri roiguri merged commit 7dcaabb into main Jul 16, 2026
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.

2 participants