Skip to content

Commit 16de70f

Browse files
committed
Merge: MATLAB/Octave parity test for calibration + headless CI
Adds a differential test proving calibration.py matches the MATLAB PresentTarget.m staircase bit-for-bit (via Octave), a GitHub Actions workflow running the full suite headless, and guarded psychopy imports so logic/timing tests run without PsychoPy.
2 parents 9ac3d5c + 9333022 commit 16de70f

8 files changed

Lines changed: 299 additions & 5 deletions

File tree

.github/workflows/tests.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
pytest:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
# Octave is the reference engine for the calibration MATLAB-parity test
14+
# (tests/test_calibration_matlab_parity.py). Without it that test skips
15+
# with a loud warning, so installing it here is what makes the parity
16+
# check actually run in CI.
17+
- name: Install Octave
18+
run: |
19+
sudo apt-get update
20+
sudo apt-get install -y --no-install-recommends octave
21+
octave --version | head -1
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v5
25+
26+
# PsychoPy is deliberately NOT installed: it's a heavy GUI/audio dep whose
27+
# transitive build (ffpyplayer → SDL headers) is fragile on headless CI.
28+
# The source modules guard their psychopy imports (try/except), so the
29+
# whole test suite imports and runs without it; only pandas is needed at
30+
# runtime (sequences.py). We install just that + pytest and put `src` on
31+
# PYTHONPATH rather than resolving the project's full dependency graph.
32+
- name: Create venv and install minimal test deps
33+
run: |
34+
uv venv
35+
uv pip install pytest pandas
36+
37+
# tests/test_overlay.py is excluded: it's not an automated test (no test
38+
# functions) but a manual `visual.Window` script that needs a live display
39+
# and imports psychopy at module top. Everything else runs headless, incl.
40+
# the MATLAB-parity test (Octave installed above lets it run, not skip).
41+
- name: Run tests
42+
env:
43+
PYTHONPATH: src
44+
run: .venv/bin/python -m pytest -v --ignore=tests/test_overlay.py

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ data/*/
66
*.egg-info/
77
.pytest_cache/
88
.DS_Store
9-
.idea
9+
.idea
10+
.claude/

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ This project uses UV for managing Python packages.
22

33
It's recommended to use Python through UV, but if you need to, `python3` is available while `python` is not.
44

5+
The calibration MATLAB-parity test (`tests/test_calibration_matlab_parity.py`) needs GNU Octave (or
6+
MATLAB) on PATH to run the reference algorithm; install it with `brew install octave`. Without an
7+
engine the test skips with a loud warning rather than silently passing.
8+
59
## Environments: UV (development) vs. Anaconda (production)
610

711
- **UV is for development.** It is the canonical local workflow and the only one with a pinned

src/mid_det/debug.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77

88
from dataclasses import dataclass, field
99

10-
from psychopy import visual
10+
try:
11+
from psychopy import visual
12+
except ModuleNotFoundError: # headless/CI without PsychoPy; only needed to build the overlay
13+
visual = None # type: ignore[assignment]
1114

1215

1316
@dataclass

src/mid_det/display.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212

1313
from dataclasses import dataclass
1414

15-
from psychopy import visual
15+
try:
16+
from psychopy import visual
17+
except ModuleNotFoundError: # headless/CI without PsychoPy; only needed to build stimuli
18+
visual = None # type: ignore[assignment]
1619

1720
from mid_det import config
1821

src/mid_det/trial.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,22 @@
1010
from collections.abc import Callable
1111

1212
import pandas as pd
13-
from psychopy import core, logging, visual
14-
from psychopy.hardware import keyboard
13+
14+
try:
15+
from psychopy import core, logging, visual
16+
from psychopy.hardware import keyboard
17+
except ModuleNotFoundError:
18+
# Headless/CI without PsychoPy: keep this module importable so the pure-logic
19+
# (_compute_reward) and timing (run_response, driven by a fake window/clock in
20+
# tests) code stays testable. `core` is a namespace with the attributes those
21+
# paths reference — tests patch core.Clock; real runs always have PsychoPy.
22+
import types
23+
24+
visual = keyboard = None # type: ignore[assignment]
25+
logging = types.SimpleNamespace(exp=lambda *a, **k: None) # type: ignore[assignment]
26+
core = types.SimpleNamespace( # type: ignore[assignment]
27+
Clock=None, CountdownTimer=None, quit=lambda *a, **k: None
28+
)
1529

1630
from mid_det import config
1731
from mid_det.calibration import CalibrationState

tests/matlab_ref/calibration_ref.m

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
function cal_vec = calibration_ref(cues, wins, basert, rt_change)
2+
% CALIBRATION_REF Reference target-window staircase, extracted verbatim from
3+
% the MATLAB MID task (MID-updated/scripts/PresentTarget.m, lines 14-48).
4+
%
5+
% Only the calibration math is kept; the Psychtoolbox Screen/GetKey/timing glue
6+
% is removed and replaced by scripted outcomes so the algorithm can be driven
7+
% deterministically and compared against the Python port (CalibrationState).
8+
%
9+
% cues 1xN vector of cue ids (1..6), in trial order
10+
% wins 1xN vector of binary outcomes (1 = hit, 0 = miss/early), trial order
11+
% basert base RT in seconds (var.basert)
12+
% rt_change per-step adjustment in seconds (var.rt_change)
13+
%
14+
% Returns cal_vec: 1xN vector of the target window emitted on each trial
15+
% (data.calibration_vector in the original).
16+
17+
% Per-cue history, mirroring var.calibrations{1..6} and data.wins{1..6}.
18+
calibrations = {[],[],[],[],[],[]};
19+
win_hist = {[],[],[],[],[],[]};
20+
cal_vec = zeros(1, numel(cues));
21+
22+
for ind = 1:numel(cues)
23+
c = cues(ind);
24+
25+
% get the calibration vector for this cue
26+
prior_calibrations = calibrations{c};
27+
28+
% get the wins vector for this cue
29+
prior_wins = win_hist{c};
30+
31+
% if the calibration vector is empty, set calibration to base RT
32+
if isempty(prior_calibrations)
33+
current_calibration = basert;
34+
35+
% if there are at least 3 responses in this category, calculate a
36+
% recalibration
37+
elseif length(prior_wins) > 2
38+
% if the ratio of wins to losses is greater than 0.66, decrement by
39+
% the specified rt_change. otherwise increment
40+
ratio = sum(prior_wins)/length(prior_wins);
41+
if ratio > 0.66
42+
current_calibration = prior_calibrations(end) - rt_change;
43+
elseif ratio <= 0.66
44+
current_calibration = prior_calibrations(end) + rt_change;
45+
end
46+
else
47+
% if under 3 occurences, just set the current calibration to the
48+
% prior
49+
current_calibration = prior_calibrations(end);
50+
end
51+
52+
% add the current calibration to the prior calibration vector
53+
prior_calibrations(end+1) = current_calibration;
54+
55+
% reset the history of calibrations to add the new one
56+
calibrations{c} = prior_calibrations;
57+
58+
% add the current calibration to the absolute calibration vector (1D)
59+
cal_vec(ind) = current_calibration;
60+
61+
% outcome recorded AFTER the trial (mirrors PresentTarget.m lines 82-115:
62+
% early press and miss both append 0, a hit appends 1)
63+
prior_wins(end+1) = wins(ind);
64+
win_hist{c} = prior_wins;
65+
end
66+
end
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
Differential parity test: Python `CalibrationState` vs. the real MATLAB
3+
staircase algorithm, executed through Octave (or MATLAB) as a reference engine.
4+
5+
Unlike `test_calibration.py` (which asserts against hand-derived numbers), this
6+
feeds the *same* scripted win/loss sequences to both implementations and checks
7+
the per-trial target windows match. The reference lives in
8+
`tests/matlab_ref/calibration_ref.m`, extracted verbatim from
9+
`MID-updated/scripts/PresentTarget.m` lines 14-48.
10+
11+
If neither `octave` nor `matlab` is on PATH, the parity tests skip — but a loud
12+
warning is emitted so a green run never silently implies parity was verified.
13+
"""
14+
from __future__ import annotations
15+
16+
import shutil
17+
import subprocess
18+
import warnings
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
from mid_det import config
24+
from mid_det.calibration import CalibrationState
25+
26+
_REF_DIR = Path(__file__).parent / "matlab_ref"
27+
28+
# cue id (1..6, as used by var.cues in MATLAB) -> (polarity, magnitude)
29+
_CUE_TO_PM = {cue_id: pm for pm, cue_id in config.TRIAL_TYPE_MAP.items()}
30+
31+
32+
# ── reference-engine discovery ────────────────────────────────────────────────
33+
34+
def _find_engine() -> tuple[str, str] | None:
35+
"""Return (kind, executable_path) for octave or matlab, else None."""
36+
for kind in ("octave", "matlab"):
37+
path = shutil.which(kind)
38+
if path:
39+
return kind, path
40+
return None
41+
42+
43+
_ENGINE = _find_engine()
44+
45+
46+
@pytest.fixture(scope="session", autouse=True)
47+
def _warn_if_no_engine():
48+
"""Make a missing reference engine impossible to overlook in the summary."""
49+
if _ENGINE is None:
50+
warnings.warn(
51+
"calibration MATLAB-parity NOT verified — no Octave/MATLAB engine "
52+
"found on PATH; install octave (`brew install octave`) to run it.",
53+
stacklevel=2,
54+
)
55+
yield
56+
57+
58+
requires_engine = pytest.mark.skipif(
59+
_ENGINE is None, reason="no octave/matlab engine on PATH (see emitted warning)"
60+
)
61+
62+
63+
# ── drivers ───────────────────────────────────────────────────────────────────
64+
65+
def _python_windows(
66+
cues: list[int], wins: list[int], base_rt: float, rt_change: float
67+
) -> list[float]:
68+
"""Per-trial target window from the Python implementation under test."""
69+
cal = CalibrationState(base_rt_s=base_rt, rt_change_s=rt_change)
70+
out: list[float] = []
71+
for cue_id, win in zip(cues, wins):
72+
polarity, magnitude = _CUE_TO_PM[cue_id]
73+
out.append(cal.next_target_dur_s(polarity, magnitude))
74+
cal.record_outcome(polarity, magnitude, bool(win))
75+
return out
76+
77+
78+
def _octave_windows(
79+
cues: list[int], wins: list[int], base_rt: float, rt_change: float
80+
) -> list[float]:
81+
"""Per-trial target window from the MATLAB/Octave reference engine."""
82+
assert _ENGINE is not None
83+
kind, exe = _ENGINE
84+
cues_lit = " ".join(str(c) for c in cues)
85+
wins_lit = " ".join(str(w) for w in wins)
86+
# %.17g round-trips a double exactly; printf recycles the format per element.
87+
code = (
88+
f"addpath('{_REF_DIR}');"
89+
f"cv = calibration_ref([{cues_lit}], [{wins_lit}], {base_rt!r}, {rt_change!r});"
90+
f"printf('%.17g\\n', cv);"
91+
)
92+
cmd = [exe, "-batch", code] if kind == "matlab" else [exe, "--quiet", "--eval", code]
93+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
94+
if proc.returncode != 0:
95+
raise RuntimeError(
96+
f"{kind} reference failed (rc={proc.returncode}):\n{proc.stderr}"
97+
)
98+
return [float(line) for line in proc.stdout.split() if line.strip()]
99+
100+
101+
def _assert_parity(
102+
cues: list[int], wins: list[int], base_rt: float, rt_change: float
103+
) -> list[float]:
104+
py = _python_windows(cues, wins, base_rt, rt_change)
105+
ref = _octave_windows(cues, wins, base_rt, rt_change)
106+
assert len(py) == len(cues)
107+
assert ref == pytest.approx(py, abs=1e-12)
108+
return py
109+
110+
111+
# ── shared test data ──────────────────────────────────────────────────────────
112+
113+
def _random_sequence(seed: int, n: int) -> tuple[list[int], list[int]]:
114+
"""Interleaved (cue, win) trials spanning all 6 cues, reproducible by seed."""
115+
import random
116+
117+
rng = random.Random(seed)
118+
cue_ids = sorted(_CUE_TO_PM)
119+
cues = [rng.choice(cue_ids) for _ in range(n)]
120+
wins = [rng.randint(0, 1) for _ in range(n)]
121+
return cues, wins
122+
123+
124+
# all-wins / all-misses runs on a single cue, plus the 0.66 boundary case
125+
_ALL_WINS = ([5] * 8, [1] * 8)
126+
_ALL_MISS = ([2] * 8, [0] * 8)
127+
_BOUNDARY = ([4] * 51, [1] * 33 + [0] * 17 + [1]) # ratio hits exactly 33/50 = 0.66
128+
129+
_PARAMS = [(config.BASE_RT_S, config.RT_CHANGE_S), (config.BASE_RT_PRACTICE_S, 1 / 60.0)]
130+
131+
132+
# ── tests ─────────────────────────────────────────────────────────────────────
133+
134+
@requires_engine
135+
@pytest.mark.parametrize("base_rt,rt_change", _PARAMS)
136+
@pytest.mark.parametrize("seed", [0, 1, 7, 42, 123])
137+
def test_parity_random_sequences(base_rt, rt_change, seed):
138+
cues, wins = _random_sequence(seed, n=120)
139+
windows = _assert_parity(cues, wins, base_rt, rt_change)
140+
# guard against a vacuous pass: adaptation must actually have moved the window
141+
assert any(w != pytest.approx(base_rt) for w in windows)
142+
143+
144+
@requires_engine
145+
@pytest.mark.parametrize("base_rt,rt_change", _PARAMS)
146+
@pytest.mark.parametrize("cues,wins", [_ALL_WINS, _ALL_MISS, _BOUNDARY])
147+
def test_parity_edge_cases(base_rt, rt_change, cues, wins):
148+
_assert_parity(list(cues), list(wins), base_rt, rt_change)
149+
150+
151+
@requires_engine
152+
def test_parity_check_is_discriminating():
153+
"""A deliberately wrong Python result must NOT match the reference — proves
154+
the comparison can actually fail, so a passing parity run is meaningful."""
155+
cues, wins = _random_sequence(seed=99, n=40)
156+
ref = _octave_windows(cues, wins, config.BASE_RT_S, config.RT_CHANGE_S)
157+
perturbed = list(ref)
158+
perturbed[-1] += 0.020 # one trial off by a full rt_change step
159+
assert perturbed != pytest.approx(ref, abs=1e-12)

0 commit comments

Comments
 (0)