Phase-gate direction bug: one-sided gate reported as a bug, changed, reverted
Messages between electrode pairs are gated by phase relationship. For a given channel pair, an edge fires — contributes a message to the graph — only if coherence exceeds a threshold and phase delta exceeds a separate threshold:
gate = (coh > threshold) & (phase > phase_threshold)
This was flagged as one-sided: it only catches a phase relationship in one rotational direction. The reasoning was that the sign of the phase angle determines which channel is source vs. destination, so a one-sided gate should be dropping half of the real relationships. Changed to a symmetric form:
gate = (coh > threshold) & (phase.abs() > phase_threshold)
Event counts roughly doubled (about 2,470 events/trial one-sided vs. about 4,890 two-sided, across 8 trials). An initial accuracy check at loose thresholds looked consistent with historical baselines.
Regression
At the actual working thresholds (not the looser ones used for the initial check), both training time and accuracy got worse after the change.
Root cause
Each channel pair is represented as two directed edges, i→j and j→i. The cross-spectrum between the two channels' wavelet transforms:
xwt_real = src_r*dst_r + src_i*dst_i (symmetric under src/dst swap)
xwt_imag = src_i*dst_r - src_r*dst_i (antisymmetric under src/dst swap)
Swapping source and destination — going from edge i→j to edge j→i — is a
conjugation: xwt(j→i) = conj(xwt(i→j)). Smoothing is linear and applied
identically to both directed copies, so the relationship holds after smoothing:
phase(j→i) = -phase(i→j), coherence(i→j) = coherence(j→i),
for every pair, at every time/frequency cell.
phase > +threshold) or j→i fires (phase < -threshold,
since phase(j→i) = -phase(i→j)) — mutually exclusive by sign. Exactly
one directed edge fires per real phase relationship; which one fires encodes the direction. The
symmetric (.abs()) version fires both simultaneously whenever the pair has a strong
phase relationship in either direction, doubling event volume with duplicate, contradictory-direction
edges.
Coherence and phase computed the same way, on real data: coherence(f,t) for one channel pair, the same array with the surrogate-null 95th-percentile region outlined, cross-spectrum phase, and the surrogate threshold curve by frequency. From the surrogate-calibration debug tooling in the sibling BCI motor-imagery pipeline (FC3→FC4, one right-hand trial) — same coherence / phase / surrogate-significance machinery as this epilepsy pipeline, different dataset.
Resolution
Gate reverted to the original one-sided form. Comment added in the code explaining why it's correct, rather than a bug.
Verification
Re-run at the real working thresholds with the reverted gate: mean accuracy 0.854, up from 0.836 under the two-sided version at looser thresholds.
Numerical trace on one real trial, one channel pair, at the time/frequency cell where coherence
peaked: phase(i→j) = +0.241094 rad, phase(j→i) = -0.241094 rad
(sum = 0 exactly), coherence(i→j) = coherence(j→i) = 0.999934 (exact match).
One-sided gate fired on i→j, not on j→i, at that cell. Scanned across every time/frequency
cell for the pair over the full trial: i→j fired on 1,089 cells, j→i on 534 cells,
0 cells fired on both.
The event-count and accuracy numbers alone did not indicate why the change broke things; the conjugate-symmetry identity did.