Skip to content

Fix Phantom audit findings (2026-07-05)#41

Merged
leesaenz merged 20 commits into
mainfrom
fix/audit-2026-07
Jul 6, 2026
Merged

Fix Phantom audit findings (2026-07-05)#41
leesaenz merged 20 commits into
mainfrom
fix/audit-2026-07

Conversation

@leesaenz

@leesaenz leesaenz commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Fix Phantom audit findings (2026-07-05)

Addresses all 22 findings from the 2026-07-05 audit: 1 crash bug, 2 High-value perf
wins, and a batch of Medium/Low robustness, duplication, and polish items.

Highlights

  • P-11 (crash): phantom update no longer crashes on pre-release release tags (1.2.3-rc1, 1.2.0b1, 2.0.0-beta).
  • P-02/P-01/P-03 (perf): the ×4-oversampled true-peak is now computed once per file (was twice), and the mono mixdown, full-signal RMS, and content hash are memoized. full_diagnostic on a 60 s stereo file is 36.5% faster (before: 3283.9 ms → after: 2085.7 ms cold; repeat analyses of the same bytes now hit the analysis cache at ~14 ms; see scripts/bench_full_diagnostic.py).
  • CLI robustness: uninstall reports failed removals instead of false success; all uv calls have timeouts; error output no longer leaks absolute paths; render can no longer overwrite its own source file (including same-inode/case-variant collisions).

No breaking API changes

All 18 MCP tool response schemas are preserved (independently verified against main — zero keys added/removed/renamed). Two intended value-level changes:

  • dynamics.dynamic_range_db returns null (was 0.0) for unmeasurably short audio (P-12).
  • Stereo files with per-channel DC offset are now detected (P-13) — previously antiphase/asymmetric DC canceled in the mono mixdown and was missed; the dc_offset problem's details gain an additive channel key on this new path.

Deferrals (documented, evidence-based)

  • P-05 single-pass spectral: profiled — band pass is 12.7% of analyze_spectrum but ~0.7% of full_diagnostic; not worth the numeric-drift risk. Probe committed.
  • P-18 update-fallback exit-code rewrite: pinned with tests instead (uv wording change would surface via the lock tests).
  • AnalysisCache(max_entries=8) left unchanged (batch churn is acceptable LRU behavior; bumping is a memory-vs-hitrate tradeoff needing sign-off).

Testing

  • 52 new tests (all synthetic fixtures, no real audio). Full suite: 1036 passed, 47 skipped (baseline: 984 passed, 44 skipped; the 3 extra skips are new pedalboard-gated parity tests).
  • Timing evidence and a per-file "true-peak computed once" regression test included.
  • Independently verified vs main: byte-identical loudness/problems/dynamics outputs on synthetic fixtures; identical MCP tool list.

🤖 Generated with Claude Code

leesaenz and others added 18 commits July 5, 2026 19:09
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…P-16)

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (P-12)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Convert AudioData.mono from a plain @Property (recomputed np.mean on
every access) to a functools.cached_property, and add a new mono_rms
cached_property for the full-signal RMS of the mono mixdown. Both compute
once per instance and store into the instance __dict__ (permitted for
non-field attributes under Pydantic v2; not frozen, absent from
model_dump()).

Apply P-04 at dynamics.py only, where audio is in scope: replace
rms = float(np.sqrt(np.mean(mono**2))) with rms = audio.mono_rms. This is
bit-identical to the prior float32 computation (guard test asserts exact
equality), so all numeric outputs are unchanged — pure memoization.

Intentionally NOT changed (semantic drift avoidance):
- problems.py _detect_snr receives a bare mono array (no audio in scope).
- stereo.py uses per-channel is_near_silent(left)/is_near_silent(right)
  and per-channel/mid-side RMS, not the mono mixdown.
- is_near_silent keeps its array-based signature (callers pass left/right).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lper (P-02, P-06, P-09)

The x4-oversampled TruePeakDetector FIR was the dominant cost in the
pipeline and ran twice in full_diagnostic (once in analyze_loudness,
once in detect_problems' inter-sample-peak detector). New _truepeak.py
computes per-channel (sample_peak, true_peak) once, reusing a single
TruePeakDetector across channels (P-06), and memoizes on the AudioData
instance so both consumers share it (P-02). Also extracts the shared
4096/2048 FrequencyBands loop into spectral._octave_band_energies,
called by both spectral and masking (P-09) -- pure dedup, no numeric
change.

Numeric parity verified to 0.0 vs fresh-per-channel construction on
asymmetric stereo, clipped-mono, and stereo-noise fixtures (Essentia
TruePeakDetector is stateless across channels). A shared-once test
proves the constructor runs exactly once across analyze_loudness +
detect_problems on the same AudioData (was 4 for stereo). ISP detail
keys, thresholds, and dBTP conversion unchanged; no MCP schema drift.

Timing (full_diagnostic, 60s stereo, best of 3):
  BEFORE: 3283.9 ms
  AFTER:  2084.6 ms
  Delta:  36.5% faster (saved 1199.3 ms)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cache (P-01)

Lift `_cached_analysis(audio, func_name, func)` from comparison.py into
_cache.py (next to `analysis_cache`); comparison.py now re-exports it so
existing callers/tests (`from phantom.comparison import _cached_analysis`)
keep working. No circular import: _cache.py references AudioData only under
TYPE_CHECKING.

Rewire `_run_full_analysis` (used by full_diagnostic and batch_diagnostic)
and the CLI `_run_selected_analyses` to route all six analyzers through
`_cached_analysis`. Server uses literal keys and the CLI uses `fn.__name__`;
both resolve to the same keys the compare_* tools use (analyze_spectrum,
analyze_loudness, analyze_dynamics, analyze_stereo, analyze_phase,
detect_problems), so full_diagnostic/batch_diagnostic/analyze now populate
the cross-call cache and a subsequent compare_to_profile/compare_to_reference
on the same bytes reuses the work. Response shapes and numeric outputs are
unchanged — cache stores/returns the identical Pydantic model instances.

Cache size: AnalysisCache(max_entries=8) is left unchanged. full_diagnostic
now stores 6 entries for one file; batch_diagnostic over many files will
churn/evict (LRU, correctness preserved, just fewer cross-call hits). Bumping
max_entries is a memory-vs-hitrate tradeoff deferred to Lee's sign-off.

Bench (bench_full_diagnostic.py, 60s stereo, best of 3): the committed
harness warms up then times the same file, which are now cache hits, so it
reports ~48 ms (was ~2085 ms) — same-bytes re-analysis is ~43x faster.
Cold-path (cache cleared each iter) measures ~2160 ms vs Task 6's 2084.6 ms;
the ~77 ms delta is the one-time SHA-256 hashing to populate the cache
(6 gets + 6 puts of the 21 MB sample buffer), the same cost compare_* already
pays. No regression in analysis work itself.

TDD: added test_full_diagnostic_populates_cache (RED on old code, GREEN now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ath (P-01 follow-up)

Fix 1 — memoize the audio content digest. `_hash_audio` previously
re-hashed the full sample buffer (21.2 MB on the 60 s stereo bench) on
every cache get/put — ~12x per full_diagnostic — adding +77 ms (~3.7%)
to the cold path when Task 7 routed composite tools through
analysis_cache. `_content_digest` now computes the SHA-256 of the
per-instance-constant content (samples + sample_rate + num_channels)
once and memoizes the hash object on audio.__dict__["_content_hash"] —
the same mechanism as _true_peaks (Task 6) and AudioData.mono (Task 5).
`_hash_audio` .copy()s that base and feeds only the cheap func_name
suffix, so the cache key is byte-identical to the old single-pass
formula (no key-format change; asserted by test). Samples are never
mutated after construction, so the memo is sound.

Fix 2 — bench measures the cold path again. Added AnalysisCache.clear();
bench_full_diagnostic.py clears analysis_cache before the warmup and
each timed run, so the COLD number measures analysis cost (not cache
hits), and prints a second WARM line for the cache-hit path.

Bench (60 s stereo, synthetic): COLD 2084.0/2082.2 ms (best of 3) —
within noise of Task 6's 2084.6 ms baseline; the +77 ms regression is
gone. WARM (cache-hit path) 14.1/14.3 ms.

Tests: 3 new cache tests (content-digest-once RED->GREEN, byte-identical
key guard, clear). Full suite 1009 passed, 44 skipped; ruff clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (P-10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
uninstall._remove_startup_hook now delegates the phantom-block stripping to
setup_reaper._remove_startup_block, the single content->content implementation
(returns None / "" / stripped content). uninstall keeps its own path I/O,
write/unlink, and the P-15 bool contract; only the strip logic is shared.

Both call sites shared the same latent bug: if the "-- [/phantom]" end marker
was missing, the skip flag never reset and the rest of the file was deleted to
EOF. The canonical function is hardened to buffer post-marker lines and recover
them when no end marker is found, so a hand-edited __startup.lua no longer loses
the user's trailing content. Added a TDD regression test for that edge.

Also sandbox test_uninstall_with_yes in an isolated filesystem so the --yes
uninstall can't rewrite the developer's real ./.mcp.json (a pre-existing test
hygiene bug that tripped the CLI first-run auto-setup when the two CLI test
files ran adjacently).

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

Extend the full_diagnostic timing harness with _probe_spectral(), which
splits analyze_spectrum time into its two framed FFT passes on the same
60 s stereo fixture:

  - analyze_spectrum total (2048/1024 + 4096/2048 passes): ~122.7 ms
  - octave-band pass alone (_octave_band_energies):        ~15.6 ms
  - band-pass share of spectral:                            ~12.7%

Reproduced identically across 3 runs. The share is just above the ~10%
gate, so a single-pass fusion follow-up is technically warranted -- but
DEFERRED here: the plan forbids attempting the multi-resolution single
pass in this task, and in absolute terms the upside is ~15.6 ms, only
~0.7% of the ~2087 ms full_diagnostic cost. The two passes use different
frame sizes (2048 vs 4096), so fusion is non-trivial and risks numeric
drift; any follow-up must ship its own TDD parity tests.

Decision: DEFER P-05 (profiled, follow-up low priority). Existing
COLD/WARM output text is unchanged; script-only change.

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

P-13: _detect_dc_offset now takes the AudioData object and checks DC per
channel for stereo, flagging if either channel exceeds the threshold.
Antiphase DC (L=+x, R=-x) cancels in the mono mixdown and was previously
missed. Mono behavior is byte-identical; the stereo flag path adds an
additive 'channel' detail key (details is an untyped dict by design).

P-14: guard demucs input normalization against zero-std (silent) input.
(wav - ref.mean()) / ref.std() divided by zero on silent files, feeding
NaNs into the model. Now raises a musician-friendly AnalysisError first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r (P-19, P-22)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w update catch (P-21, P-18)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…P-07, P-08)

P-07: analyze_masking_matrix now resamples each stem to the target rate
just-in-time via resample_to_match (the same per-stem call align_sample_rates
makes internally) and drops the resampled AudioData after reading its band
energies, so peak memory holds one resampled copy instead of N. Numerically
identical to the prior batch-align; a 3-stem mixed-rate parity test locks the
invariant. Reuses the Task 8 helper rather than re-inlining resample logic.

P-08: fix_audio gains an additive keyword-only `audio: AudioData | None = None`
param. When provided, the internal load_audio(file_path) is skipped and the
supplied data reused; the input path is still validated. The interactive CLI
path (phantom fix -i), which already decodes the input for detect_problems, now
threads that AudioData in, avoiding a second decode. audio=None is byte-identical
to today. The MCP server fix_audio tool signature and response schema are
unchanged (it never passes audio=).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final whole-branch review findings for fix/audit-2026-07:

- Important: render self-overwrite guard missed same-inode collisions that
  realpath does not normalize (hardlinks; case-variant names on case-insensitive
  filesystems like macOS APFS, e.g. SONG.WAV -> SONG.wav). ffmpeg's -y would
  clobber the source. Extended the guard with os.path.samefile. TDD via a
  hardlink test (deterministic on every filesystem).
- Minor: test_full_diagnostic_populates_cache now uses the public
  analysis_cache.clear() instead of poking ._lock/._store.
- Minor: separate --output-dir help text updated (no longer "./stems"; now
  stems/ under the confined Phantom output directory).
- Minor: added a CLI cache-population test twin (test_analyze_cli_populates_cache)
  covering _run_selected_analyses keying the cache via fn.__name__.
- Minor: documented the intentional best-effort swallow of a `uv tool list`
  failure/timeout in update.py (reinstall proceeds without extras).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread tests/test_cache.py Fixed
Comment thread tests/test_loudness.py Fixed
Comment thread tests/test_loudness.py Fixed
…s-platform (CI)

Two CI failures on this branch, both test-portability issues (no product
code changed):

1. PII/policy check ('Check for absolute paths'): the P-17 redaction tests
   in test_cli_formatting.py hardcoded /Users/... paths that tripped the CI
   grep pattern /Users/[a-z]+/. Swapped the fixtures to /private/... paths
   that do NOT match the policy pattern but DO still match the redaction
   regex in cli/_formatting.py, so the tests keep verifying real redaction.
   Updated the corresponding 'not in out' assertions to the new directories.

2. Golden-value test broke on Linux CI by 1 ULP: TestBandEnergyHelperParity
   .test_band_energies_match_golden asserted a hardcoded float32 array with
   rtol=0, atol=0. The Linux/x86 Essentia build differs from macOS in the
   last ULP on a couple values. Relaxed ONLY the hardcoded-golden comparison
   to np.allclose(rtol=1e-5, atol=1e-9) — tight enough to catch real drift,
   loose enough for cross-platform float32 wobble. All in-process two-sided
   parity assertions (delegation equality, matrix streaming parity) left
   exact, since platform differences cancel in-process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leesaenz leesaenz force-pushed the fix/audit-2026-07 branch from 82ffffc to 256ac8c Compare July 6, 2026 04:36
Addresses the three code-review comments on PR #41: phantom._cache,
phantom.loudness, and phantom.problems were each imported with both
'import' and 'from ... import' in the same test scope. Monkeypatch
targets are unchanged objects, so spy semantics are identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leesaenz leesaenz merged commit cc05ccc into main Jul 6, 2026
11 checks passed
@leesaenz leesaenz deleted the fix/audit-2026-07 branch July 6, 2026 04:45
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.

1 participant