Conversation
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
@
Summary
Promotes
devtomain. Fast-forward relative tomain(no divergence, no conflicts). 19 commits across three groups:Live decision rules (PR #69)
DecisionEngineturning 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)
Standalone fixes
6a40d60fix(online): convert LSL µV input to SI volts (decoder saturation)f2ec938fix(ui): declutter ICA review grid on varied screen sizesecb5fcftest: update proxy-lifecycle tests for eager-start behaviore21c40bfeat(online): verb-category marker labels + real-time latency instrumentationNotes
main.devandmainboth in sync with their remotes at PR creation time.@