Skip to content

Fix correctness bugs from the full codebase review#88

Open
matt-pharr wants to merge 16 commits into
developfrom
bugfix/review-findings
Open

Fix correctness bugs from the full codebase review#88
matt-pharr wants to merge 16 commits into
developfrom
bugfix/review-findings

Conversation

@matt-pharr

Copy link
Copy Markdown
Collaborator

Summary

A multi-agent review of the codebase (core, QS pipeline, data/fetch, service, GUI, tests) surfaced three critical bugs and a long tail of majors. This branch fixes them, adds regression tests for each, then fills the highest-risk coverage gaps found by a follow-up test audit (73% → 79%, with a CI floor at 75%).

Critical physics/data fixes

  • 2-point mode number aliased to n = 0 on every fetched shotround(phase/Δφ) never wrapped Δφ to its principal value, and the pair picker chose the widest separation (320° on real shots). Wrapped in core + alias-safe pair selection. Verified on shot 174446: the n-map now shows n = 1…5 (was all-zero).
  • Signed-n convention disagreed between estimator families (2-point/+n vs phase-fit/−n) while docstrings claimed agreement. All five estimators now pinned to the same signed n, with a cross-family test; verified consistent on real data.
  • Remote-backend pulls clobbered the local shot file — a one-signal custom pull on the default DIII-D backend destroyed every previously fetched channel. Remote results now merge through the writer's incremental path.

Major fixes

  • QS fit: sigma_override corrupted fit_signal/residual/χ² (de-normalized by the wrong σ); fit_cond default restored to OMFIT's 1e3 inversion cutoff (10 was the K-trust threshold, silently truncating basis directions).
  • Fetch: KSTAR plasma tree_signals (Ip/Bt/κ) were silently never fetched; pointname alias collisions silently dropped a channel; transient transport failures poisoned the incremental cache (closes Fetch: distinguish connection-error from no-data channels (dropped tunnel poisons the incremental cache) #63).
  • Spectral: spectrogram cross-power scaled to csd's one-sided density — "rms amplitude" was off by a data-dependent factor of thousands.
  • Service: refresh() now clears the channel→θ cache; QS ±1σ bands moved from untyped meta.sigma to the contract's lower/upper (exports now match the screen); channel_usage no longer marks consumed channels droppable; NaN samples serialize as JSON null instead of 500ing.
  • GUI (verified with Playwright against the live app): machine switches no longer show the previous shot's data under the new shot's labels; failed fetches are retryable (Plot button + Retry); only a 422 is diagnosed as "no QS array"; SensorsTab set selection resets on machine change; the rail device picker snaps per-device pull defaults; selective store subscriptions stop per-keystroke re-renders of the heavy tabs.
  • n-map band follows the requested f_max (closes Rotating modes maxed at 50 kHz #85) — was hard-capped at 50 kHz regardless of the knob; verified to the 100 kHz Nyquist on shot 174446.

Tests, CI, docs

  • Test audit fixes: test_nodes.py ran against the wrong device via a string-sort accident (its family-mixing guard was tautological and the contour test permanently skipped); CI no longer treats "no tests collected" as success; pexpect declared.
  • New coverage for the previously-untested orchestration: the POST /api/fetch job/SSE machinery, the DIII-D mdsthin fetch body (stub connection), run_remote command construction (fake subprocess), the gaussian fit bases (value-pinned vs numerical integration), the linear/endpoints detrend estimators, and the ESLD-2019 swap. PullControl's device defaults extracted to a pure tested helper (closes GUI: unit-test PullControl device-aware logic #64's core ask).
  • CI enforces a 75% coverage floor; CLAUDE.md updated to the real architecture (retired _slcontour references, closed issues dropped) plus a "Running the tests" section.

Notes for reviewers

  • KSTAR (deliberately minimal per @matt-pharr): no transport/VPN code touched; the two data-path changes (tree_signals fetch, alias-collision guard) are covered by offline stub tests but need one live verification. Separately: is the kstar.json alias \MC1T10 → \MC1P03 (since shot 17377) intentional? It points a toroidal-array position at a poloidal sensor's node.
  • Signed-n semantics: b ∝ cos(nφ − ωt) (rotating toward +φ) reports +n everywhere; the n-map still displays |n| by design.
  • Known follow-ups (not in this PR): the cursor analyses' separate 1–25 kHz band; mock catch-all routes; allow_origins=["*"]; unbounded _JOBS; SSH ControlMaster teardown race.

Test plan

  • uv run pytest — 448 passed, 4 skipped (offline, deterministic)
  • uv run ty check src/magnetics, ruff format --check, eslint, tsc — clean
  • cd gui/web && npm run test — 46 passed
  • Playwright against the live app: rail device snap, SensorsTab reset, QS/rotating tabs render, no false banners
  • Real-shot verification (174446/184927): mode numbers, wrapped Δφ, cross-family sign, f_max band
  • Live KSTAR pull (needs the one person with access)

🤖 Generated with Claude Code

matt-pharr and others added 16 commits July 10, 2026 00:35
round(phase/delta_phi) assumed the unwrapped n*delta_phi stayed inside
+/-180 deg, but the pair picker chose the widest raw separation (320 deg
on fetched DIII-D shots), aliasing every rotating mode to n = 0. Wrap
delta_phi to its signed principal value in the core estimators and pick
the widest pair that still resolves |n| <= 5 unaliased.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cos(n*phi - omega*t) mode measured through conj(sig)*ref cross-phases
ramps as +n*phi, but fit_toroidal_mode fitted c - n*phi,
array_mode_spectrogram projected onto an unconjugated template, and
mac_n_spectrum matched e^{-in*phi} shapes - so the phase-fit family
reported -n while the 2-point family reported +n, despite docstrings
claiming they agree. Flip the fit family to +n, fix the service ramp
and poloidal detrend, use the production delta_phi = phi2 - phi1
convention in fixtures, and pin all five estimators to the same signed
n with a cross-family test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ering

The remote backend dispatched before the incremental-merge bookkeeping
and rsynced the cluster result directly over shot_<n>.h5 - since the
cluster file holds only the requested selection, a one-signal custom
pull (the GUI's QS custom-signal panel on the default DIII-D remote
backend) silently destroyed every previously fetched channel. Dispatch
the remote branch after the merge/skip resolution, stage the cluster
file beside the local one when a compatible file exists, and fold it in
through _write_h5's merge path (time-base dedup, replace-on-conflict,
fetched/missing bookkeeping identical to a local incremental fetch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The design matrix and RHS are normalized by the effective sigma (which
honors sigma_override and NaN fills), but fit_signal was de-normalized
by the dataset's raw signal_sigma - so any GUI edit of the
"uncertainty (sigma)" box scaled fit_signal by signal_sigma/override,
corrupting residual, chi_sq, and red_chi_sq while the coefficients
stayed correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fit_cond is 1/rcond for the lstsq inversion, but its default (10) was
the GUI's "warn when K > 10" trust threshold, not OMFIT SLCONTOUR's
1e3 reference cutoff. Any fit with K in (10, 1e3) silently zeroed
basis directions the reference fit keeps, and the reported K(eff)
stayed <= 10 by construction - the quality panel looked healthiest
exactly when the regularization was distorting the fit. Align the
core, service, and GUI defaults on 1e3 and leave the trust threshold
to contracts.quality_for_k.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two silent-loss bugs on the tree paths:

- The KSTAR _tree_transport branch never passed tree_signals to
  _fetch_mds_tree, so Ip/B_T/kappa were dropped from every KSTAR pull
  (absent from fetched AND missing lists) and helicity fell back to
  its default. Fetch them on the same connection after the sensors,
  first usable candidate wins, window-sliced (not resample()d onto the
  sensors' microsecond grid) - mirroring the NSTX path.

- _resolve_pointnames mapped queried-pointname -> canonical id in a
  plain dict, so a per-era alias equal to another sensor's pointname
  (kstar.json: \MC1T10 -> \MC1P03 since shot 17377) collided: both
  fetched channels were relabeled to one name and the HDF5 writer
  silently dropped one. Query the node once, let the sensor literally
  named by the pointname keep it, and report the alias as skipped.

KSTAR live behavior needs on-device verification (new plasma-node
requests over the VPN); the changes are otherwise covered by offline
stub-connection tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dropped tunnel/ControlMaster mid-pull marked every remaining channel
ok=False, and _write_h5 recorded them in channels_missing - so the next
same-window pull treated them as already-attempted and silently skipped
them, leaving the shot file permanently incomplete without --force.
Classify failures at every fetch site (transport vs no_data, by
exception type with message-word fallback) and exclude transport
failures from the cached missing set; they are retried on the next pull
while genuine no-data channels stay cached.

Closes #63.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
compute_spectrogram used raw |conj(S2)*S1| with no window-power or fs
scaling, yet rms_by_mode applied the same sqrt(sum(power)*df) formula
as cross_spectrum (whose csd is a proper density) - so the n-spectrum
axis labeled "rms amplitude" was off by a data-dependent factor of
thousands. Apply csd's density scaling (1/(fs*sum(win^2)), doubled off
DC/Nyquist); a unit sine now reports 0.707 RMS through both estimators.
Log-power views only shift by a constant; coherence and phase are
ratios/angles and are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four service-seam fixes from the review:

- refresh() cleared every lru_cache except _real_theta, so a re-pull
  with more probes kept serving the stale channel->theta map and the
  poloidal nodes 422'd until server restart.
- The QS amplitude/phase_t 1-sigma bands rode in untyped meta.sigma,
  which export.py drops - the downloaded HDF5 lacked the band shown on
  screen. Move them to the contract's per-series lower/upper (already
  exported, same as the rotating mode-shape nodes) and read those in
  the GUI.
- channel_usage marked kappa/ip/bt and the QS midplane array as
  "unused / droppable" although the theta* correction, QS helicity,
  and the QS fit consume them.
- NaN/Inf anywhere in a node payload was an opaque 500 (starlette
  serializes with allow_nan=False). Raw-signal nodes now emit
  non-finite samples as JSON null (a Plotly gap) via
  contracts.json_finite; zrange/chi^2/K scalars are guarded; and
  quality_for_k now matches contract.ts on +/-inf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fixes

Frontend correctness fixes from the review, verified with Playwright
against the live app:

- useNode's stale-while-revalidate is now param-scoped only: a machine
  switch drops the stale node instead of rendering the previous shot's
  data under the new shot's labels (and export descriptors). Adds a
  retryKey so an identical-key fetch can re-run: the QS Plot button and
  a new Retry button recover from transient failures (previously
  impossible without jiggling a parameter).
- QS tab: only a 422 is diagnosed as "shot lacks the QS array"; other
  failures show a retryable error instead of a confidently wrong
  banner. Per-node failures surface in an error strip and in the
  section placeholders instead of eternal "loading..."; the mode-map
  panel shows the server error instead of fake "Computing..." progress.
  Removed the hardcoded shot-specific 3140 ms cursor write; the Plot
  button's dirty highlight compares params by value.
- PullControl snaps per-device defaults whenever the store's device
  changes - the left rail's picker previously left backend "remote"
  (and a DIII-D window) active for tree devices while the select
  displayed "mdsthin".
- SensorsTab resets its per-device set overrides on machine change
  (they are keyed by set name; carrying them over rendered a
  permanently empty sensor scene).
- Both heavy tabs use selective store subscriptions instead of
  re-rendering every Plotly panel on each credential-field keystroke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The array n-map's dedicated STFT was computed over a fixed 0-50 kHz
band with the GUI's fmin/fmax applied only as a crop, so raising f_max
past 50 kHz showed nothing above it - the rotating view read as
hard-capped at 50 kHz (the power spectrogram already extended to the
data's Nyquist). Thread the requested fmax into _array_mode_spec as
the compute-band ceiling, quantized to 50-kHz steps so crops within a
step still hit the cached STFT. Verified on shot 174446: fmax=120 now
yields the map out to the 100 kHz Nyquist.

Closes #85.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stale docs

- tests/test_nodes.py resolved its shot by taking the first machine,
  but list_shots() sorts ids as strings, so the NSTX shot '204718'
  sorted before the DIII-D '990000' and the whole module silently ran
  against NSTX: the DIII-D family-mixing guard was tautological (every
  NSTX name maps to family "?") and the contour test permanently
  skipped. Pin the DIII-D fixture shot and drop the try/except->skip
  guards - the fixture guarantees the arrays, so a builder crash is
  now red. Strengthen the K-finiteness test to actually check K, and
  remove test_cli's dead `or True`.
- CI treated pytest exit 5 ("no tests collected") as success - a
  collection break would go green with zero tests run. The suite is
  400+ tests; drop the tolerance.
- Declare pexpect (kstar_transport imports it lazily; a wheel install
  died at live-pull time).
- CLAUDE.md described the retired _slcontour shim and a phantom
  core/quasistationary port (#40/#44 are closed); rewrite the QS
  architecture/layout/known-gaps to the promoted core/qs_* reality.
  Fix the stale run-dev.sh "QS is a stub" header, contract.ts's dead
  "magfit" path, and test_contract_shapes' extra-that-never-was note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fills the highest-risk gaps from the test audit (73% -> 79% line
coverage, 448 py + 46 js tests):

- test_fetch_api.py: the POST /api/fetch job machinery - request
  forwarding, job lifecycle, error mapping (Exception vs SystemExit),
  live progress visibility, cache refresh, per-job isolation, and the
  SSE frame protocol PullControl's EventSource parses. This was the
  most stateful code in the service with zero coverage.
- test_mdsthin_fetch.py: the DIII-D _fetch_mdsthin body against a stub
  mdsthin.Connection - getMany batching/ordering, the per-channel
  fallback, window/stride TDI suffixes, transport-vs-no-data
  classification (the #63 chain through the real writer), and
  per_channel mode.
- test_slcontour_fit.py: gaussian-point/-integral bases value-pinned
  against direct numerical integration (quad/dblquad), including
  periodic wrapping and the ridge (eps=inf) cases; the mixed-mode
  sinusoidal-integral pinned as the exact area-average (the site of
  the deliberate divergence from OMFIT's buggy expression).
- test_qs_prep_branches.py: the linear/endpoints detrend estimators
  (previously dead zones despite being GUI options) and the DIII-D
  2019 ESLD66M079/319 wiring-swap branch, both directions.
- test_remote_orchestration.py: run_remote's command construction with
  subprocess faked - jump selection on/off-site, per-user /tmp stage,
  the remote fetch argv, rsync copy-back target, cleanup, teardown,
  and fail-fast on a dead master connect.
- gui: PullControl's per-device defaults extracted to a pure
  lib/deviceDefaults.ts helper with vitest cases per device shape
  (closes #64's core ask).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Regression guard, not a target: the suite sits at ~79%, and the
headroom covers the deliberately-untestable surfaces (live transports,
notebook plotting). Raise as coverage grows; never lower to merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
E741/E402/F541 in the new test files slipped past the local gate run
(ruff check was skipped on the last files before push).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant