diff --git a/AGENTS.md b/AGENTS.md index b9a67ce17..e14ab34b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working ## Architecture references - `ARCHITECTURE.md` - `docs/engineering/acceptance-criteria.md` +- `docs/doctoring/real-audio-accuracy-acceptance.md` - `docs/engineering/harness-engineering.md` - `docs/workflow/one-day-delivery-plan.md` - `docs/workflow/pr-continuity.md` @@ -85,6 +86,7 @@ This section applies to any agent (Claude, Codex, Cursor, opencode, ...) working - Prefer practical, friendly, rehearsal-first wording over academic or authority-heavy language. - Do not reduce the product to a chord analyzer when form, timing, player coordination, playable ranges, simplification, and setup cues are the real rehearsal blockers. - Do not frame usability as a reason to accept weak analysis quality; BandScope should aim for both easy use and high accuracy. +- Do not claim harmony or tempo accuracy from mocked feature matrices. Tier 1 decoded-PCM fixtures and metrics live in `docs/doctoring/real-audio-accuracy-acceptance.md`. ## Safety - Do not add network-dependent runtime paths for local analysis. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ca0df5ac4..08225b7c5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # ARCHITECTURE.md -Last updated: 2026-03-11 +Last updated: 2026-08-16 ## Brand source @@ -127,3 +127,4 @@ Last updated: 2026-03-11 - Supply-chain docs, workflow pinning, and lockfile verification are part of the default quickcheck path so dependency drift is caught early. - Quickcheck and CI are expected to verify dependency review, audit, supplemental inventory, and SBOM baseline presence as part of bootstrap. - Cross-platform build workflow presence and trigger coverage are part of the default supply-chain verification path. +- Real-audio accuracy acceptance is a separate buyer-facing gate from unit coverage. Tier 1 decoded-PCM fixtures, duration-weighted chord recall, and tempo Acc1 live in `docs/doctoring/real-audio-accuracy-acceptance.md` and `bandscope_analysis.accuracy`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f7e784..f980b4b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ ### Added +- Add Tier 1 real-audio accuracy acceptance: a decoded C major WAV must recover `C`, and a 120 BPM click WAV must pass tempo Acc1, with checksum-fail-closed reports. + +### Fixed + +- Reject click-track accuracy fixtures whose beat interval or click pulse cannot be represented by at least one sample, preventing aliased or all-zero tempo evidence from entering acceptance runs. +- Reject click-track fixtures whose chosen pulse frequency aliases to a zero signal, so silent WAVs cannot become tempo acceptance evidence. +- Reject fixture timings whose derived sample count overflows or quantizes below one sample, and reject finite BPM inputs whose derived beat interval becomes non-finite, before they can drive real-audio accuracy allocation or loop authority. +- Reject malformed decoded-PCM accuracy evidence before chord recognition: the C-major acceptance buffer must be a non-empty, finite, floating-point, one-dimensional mono array and sample rates must be finite, positive, and non-Boolean. +- Reject non-numeric accuracy timing, tempo, fixture, and sample-rate evidence at the documented ValueError boundary instead of leaking NumPy or math TypeErrors. +- Reject accuracy-report metric integers whose conversion overflows floating-point evidence instead of letting an `OverflowError` escape the manifest validation boundary. +- Reject Boolean fixture duration, tempo, and sample-rate inputs so Python `True`/`False` values cannot become numeric real-audio acceptance authority or a 1 Hz WAV contract. +- Reject Boolean chord timing and tempo metric inputs so `True`/`False` cannot masquerade as numeric MIR acceptance evidence through Python's `bool`-as-`int` semantics. +- Reject blank or edge-whitespace-padded accuracy-report case IDs, metric names, and truth labels so portable acceptance evidence preserves exact registered identities. +- Reject empty or reversed chord-estimate intervals before duration-weighted recall so malformed timing evidence cannot be silently ignored by an accuracy acceptance score. +- Fail closed when accuracy-report provenance cannot resolve a non-empty product `VERSION`, instead of publishing an `unknown` engine version as valid evidence. +- Reject non-finite chord annotation and estimate timings before duration-weighted recall so NaN/Inf evidence cannot fabricate covered duration. +- Reject non-finite tempo estimates, ground-truth BPM values, and Acc1 tolerances as invalid accuracy evidence instead of recording them as ordinary misses. +- Select Numba 0.66.0 for ARM64 librosa beat tracking after the prior 0.62.1 + and 0.63.1 resolutions reproducibly crashed on macOS arm64, while retaining + the 0.62.1 Intel resolution because the newer release has no macOS Intel wheel. +- Union overlapping matching chord-estimate intervals before duration-weighted recall so acceptance scores cannot double-count annotated time or exceed 100%. +- Reject malformed accuracy-report provenance, including non-hex SHA-256 text and non-finite metric values, before acceptance evidence is consumed. +- Bound accuracy fixture byte size, channel count, sample rate, and decoded duration before checksum staging or PCM allocation. +- Score the C major acceptance case from checksummed on-disk WAV bytes instead of the pre-write in-memory triad. - Name tonight's first playable range on the ready rehearsal map and tell the player to check that span on their instrument before the section. - Display the analyzed song tempo (BPM) as a badge in the rehearsal workspace. - 각 합주 역할(Role)별 개인 연습 진행도를 0~100% 범위로 기록 및 시각화할 수 있는 연습 진척도(`practiceProgress`) 트래커 기능 추가. UI 컨트롤(슬라이더 및 +/- 버튼)과 한/영 다국어 지원 포함. @@ -74,4 +98,4 @@ - `ChordsFeature` (코드 분석) 화면에서 각 파트(Role)의 `transpositionPlan`(이조/조옮김 계획)을 표시하는 기능을 추가했습니다. - `RangesFeature` (음역대 분석) 화면에서 겹침 경고(Overlap warning) 외에 해당 파트의 채보(Transcription) 가능 노드 수를 요약하여 보여주는 기능을 추가했습니다. -- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). \ No newline at end of file +- 신규 UI 요소에 대한 단위 테스트를 추가했습니다 (`apps/desktop/src/features/chords/index.test.tsx`, `apps/desktop/src/features/ranges/index.test.tsx`). diff --git a/CLAUDE.md b/CLAUDE.md index b5a34c1fa..a52887f06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,3 +73,4 @@ Supporting packages: - Lockfiles (`package-lock.json`, `uv.lock`, `Cargo.lock`) are committed and must stay in sync; GitHub Actions are SHA-pinned. Adding a direct dependency requires the admission rationale defined in `AGENTS.md` and `docs/security/dependency-policy.md`. - CI beyond quickcheck: `gate / ci / rust-check` (Tauri cargo check on macOS) and `build-baseline` Windows/macOS amd64+arm64 native builds are merge gates, alongside CodeQL, dependency-review, sbom, bandit, trivy, secret-scan, and security-audit workflows. Do not weaken or skip them. - Version metadata lives in `VERSION`, the root `package.json`, and `CHANGELOG.md`; release flow is tag-driven (see `docs/operations/deploy-runbook.md`). +- Real-audio accuracy acceptance (decoded PCM, not mocked chroma) lives in `docs/doctoring/real-audio-accuracy-acceptance.md` and `bandscope_analysis.accuracy`. diff --git a/docs/doctoring/real-audio-accuracy-acceptance.md b/docs/doctoring/real-audio-accuracy-acceptance.md new file mode 100644 index 000000000..a5a5965a4 --- /dev/null +++ b/docs/doctoring/real-audio-accuracy-acceptance.md @@ -0,0 +1,161 @@ +# Real-audio accuracy acceptance + +Next action: before claiming a rehearsal result is accurate, run the Tier 1 +decoded-PCM cases. A green unit suite that never opens a WAV is not buyer +evidence. + +```bash +uv run --project services/analysis-engine pytest \ + services/analysis-engine/tests/test_accuracy_acceptance.py \ + services/analysis-engine/tests/test_accuracy_boolean_fixture_inputs.py \ + services/analysis-engine/tests/test_accuracy_pcm_input_contract.py \ + services/analysis-engine/tests/test_accuracy_manifest_version.py \ + services/analysis-engine/tests/test_accuracy_metric_interval_contract.py \ + services/analysis-engine/tests/test_accuracy_metric_overflow_contract.py +``` + +## Why this lock exists + +BandScope sells rehearsal guidance. A buyer cannot distinguish “the job +succeeded” from “the engine heard the chord and tempo that are in the file” +until decoded PCM is scored against a known label (Raffel et al., 2014). + +This layer is Tier 1 of issue #770: tiny, license-clean, deterministic WAV +fixtures generated in process. It does not claim genre coverage, perceptual +stem quality, or private-corpus readiness. + +## Platform runtime compatibility + +The decoded `click-120-bpm` case exercises `TemporalAnalyzer`'s production +`librosa.beat_track` path. On macOS arm64 with Python 3.12, the locked Numba +`0.62.1` and `0.63.1` runtimes reproducibly crashed inside librosa's Numba +gufunc during that case; `numba==0.66.0` completed the same path and the full +Tier 1 suite. The analysis engine therefore selects `numba==0.66.0` on ARM64 +and retains `numba==0.62.1` on non-ARM targets because 0.66.0 does not publish +macOS Intel wheels required by the cross-platform build lane. The complete +`uv.lock` is regenerated by uv. This is a compatibility floor, not an accuracy +claim or a security exception. + +Security Notes: the dependency change remains within the existing local-only +audio boundary. No network or subprocess authority is added; dependency +review, audit, SBOM, and cross-platform build gates remain required. + +## Held cases + +- `c-major-triad`: three seconds of C4+E4+G4 written to WAV, checksummed, + decoded from those bytes, and scored with duration-weighted chord recall. + Pass when recall of `C` is at least `0.70`. That floor is a BandScope Tier 1 + tolerance. Matching estimate intervals are clipped to the annotation window + and unioned before duration is accumulated, so overlapping or duplicate + estimates cannot count the same annotated time twice or produce recall above + `1.0`. Annotation and estimate times must be finite non-Boolean numbers before + clipping, and each estimate interval must have a strictly increasing + start/end pair; Boolean, NaN, infinite, empty, or reversed timing is invalid + acceptance evidence and fails closed instead of being silently ignored or + allowed to fabricate covered duration. File-backed WAV evaluation accepts an + ordinary mono or multichannel WAV and deterministically downmixes decoded + multichannel samples to mono before the production recognizer runs. The direct + `evaluate_c_major_pcm` boundary is narrower: callers must provide a non-empty, + one-dimensional, finite floating-point mono PCM array and a finite positive + non-Boolean sample rate. Unresolved multichannel arrays passed directly, + integer arrays, NaN/infinite samples, or invalid rate evidence are rejected + rather than coerced at that direct evaluator boundary. The metric family is + WCSR/CSR (Odekerken et al., 2021; Raffel et al., 2014). +- `click-120-bpm`: eight seconds of 120 BPM clicks decoded by + `TemporalAnalyzer`. Pass when estimated tempo satisfies Acc1 at 4% + (Schreiber & Müller, 2020). Acc1 does not credit half-time or double-time. + Estimated BPM, true BPM, and the tolerance must all be finite non-Boolean + numbers; Boolean or non-finite metric inputs are invalid acceptance evidence + and fail closed rather than being recorded as an ordinary miss. This matters + in Python because `bool` is an integer subtype and would otherwise satisfy + ordinary numeric comparisons. +- Fixture generation and WAV serialization accept only finite positive + non-Boolean duration, BPM, and sample-rate evidence. In Python, `True` would + otherwise act as numeric `1`, which could create a one-second fixture, a + one-BPM click contract, or a one-Hz WAV while still looking type-compatible at + runtime. Derived fixture timing must also remain representable: the computed + fixture sample count, beat interval, and click-pulse width must each be finite + and must resolve to at least one sample at the requested rate. This prevents a + nominally valid high BPM from placing multiple beats onto the same sample and + rejects a low sample rate that produces an all-zero “click” fixture. Inputs + that overflow or undersample those derived quantities fail closed before + allocation, loop construction, or file serialization. +- Checksum mismatch fails closed on both file evaluators. Do not score a + tampered file as a pass. +- Machine-readable case reports are accepted only when the registered + provenance fields are present and typed, `audio_sha256` is exactly 64 + hexadecimal characters, and `metric_value` is a finite numeric value. + Boolean, NaN, infinity, overflow-to-float, malformed digest, or missing-field + evidence fails closed rather than becoming a portable acceptance record. +- When a caller does not provide an explicit engine version, report creation + resolves the repository product `VERSION`. Missing or empty `VERSION` + provenance fails closed; `unknown` is not accepted as a substitute for the + exact engine version required by the accuracy evidence contract. + +## Claim boundary + +A passing case supports only the registered fixture, metric, engine version, +and tolerance. It does not establish universal musical correctness. + +Keys-left, keys-right, and acoustic-guitar roles still carry arrangement +defaults (`C#`, `Emaj7`, `Eb`). Lead vocal harmony is the role that currently +reflects the `other` stem recognizer. Do not treat those defaults as +measured accuracy. + +## References + +Odekerken, D., Koops, H. V., & Volk, A. (2021). Improving audio chord +estimation by alignment and integration of crowd-sourced symbolic music. +*Transactions of the International Society for Music Information Retrieval, +4*(1), 141–155. https://doi.org/10.5334/tismir.81 + +Raffel, C., McFee, B., Humphrey, E. J., Salamon, J., Nieto, O., Liang, D., & +Ellis, D. P. W. (2014). MIR_EVAL: A transparent implementation of common MIR +metrics. In *Proceedings of the 15th International Society for Music +Information Retrieval Conference* (pp. 367–372). + +Schreiber, H., & Müller, M. (2020). Music tempo estimation: Are we done yet? +*Transactions of the International Society for Music Information Retrieval, +3*(1), 111–125. https://doi.org/10.5334/tismir.43 + +## Security Notes + +- Attack surface: generated WAV bytes, fixture duration/BPM/sample-rate inputs, + decoded PCM shape/dtype/finiteness/sample-rate evidence, SHA-256 digests, + recognizer segment timings, tempo estimates, product-version provenance, and + parsed case-report mappings passed into the accuracy acceptance path. +- Trust boundary: untrusted audio, runtime numeric evidence, decoded buffer + structure, recognizer output, and manifests; trusted repo-controlled fixture + definitions, true labels, metric definitions, registered floors, and the + repository product `VERSION`. +- Mitigations: no network, no shell, checksum fail-closed before C-major + decode and before tempo scoring, deterministic file-decoder downmix for + ordinary mono/stereo and bounded multichannel WAV input, a 100 MiB fixture + byte cap before checksum reads, an eight-channel cap, a 192 kHz sample-rate + cap, and a 15-minute decoded-duration cap, non-empty finite floating-point + one-dimensional mono PCM admission at the direct C-major evaluator boundary, + finite positive non-Boolean decoded sample-rate evidence, overlap-safe chord + duration, finite non-Boolean annotation/estimate timing, strictly increasing + estimate intervals, finite non-Boolean tempo metric inputs, finite positive + non-Boolean fixture duration/BPM/sample-rate inputs, finite derived fixture + sample count, beat interval, and click-pulse width with a one-sample minimum, + plus a non-zero click signal check, + strict SHA-256 syntax, finite-only report metric values including overflow + rejection, exact non-empty product-version provenance, bounded fixture + durations, and no copyrighted commercial recordings. Fixture paths are pytest + temp files; reports store SHA-256 and labels, not waveform bytes. +- Test points: deterministic digest, over-sized fixture rejection before digest + allocation, C major recall after file decode, ordinary stereo WAV downmix at + the file-decoder boundary, excessive channel/rate/duration rejection, direct decoded-PCM + empty/non-floating/non-finite/non-mono rejection and invalid sample-rate + rejection, overlapping matching intervals do not double-count annotation + duration, non-finite and Boolean chord annotation/estimate timing rejection, + empty/reversed estimate interval rejection, silence-on-disk vs in-memory + triad, 120 BPM Acc1, non-finite and Boolean tempo estimate / truth / tolerance + rejection, non-finite and Boolean fixture generation/WAV sample-rate + rejection, derived sample-count overflow and zero-sample rejection, aliased + zero-signal rejection, beat-interval overflow and sub-one-sample rejection, + sub-one-sample click-pulse + rejection, checksum mismatch through both file evaluators, malformed/non-hex + manifest provenance, NaN/infinity/overflow report rejection, missing/empty + product `VERSION` rejection, and silence must not pass as C major. diff --git a/docs/plans/2026-08-16-real-audio-accuracy-acceptance.md b/docs/plans/2026-08-16-real-audio-accuracy-acceptance.md new file mode 100644 index 000000000..13f2c1557 --- /dev/null +++ b/docs/plans/2026-08-16-real-audio-accuracy-acceptance.md @@ -0,0 +1,60 @@ +# Real-audio accuracy acceptance (Tier 1) + +**Goal:** Give BandScope a buyer-readable accuracy gate: decoded PCM from a +known WAV must recover the expected chord or tempo, with a versioned report. + +**Architecture:** A new `bandscope_analysis.accuracy` package generates +license-clean fixtures, scores them with documented MIR metrics, and emits a +strict case-report schema. Tests write real WAV files and call production +`ChordRecognizer` and `TemporalAnalyzer` helpers. Stem separation stays out +of this slice. + +**Tech Stack:** NumPy, soundfile, librosa beat tracking, pytest. + +## Security Notes + +### Attack surface + +Generated WAV files, SHA-256 digests, decoded PCM arrays, and JSON-shaped +accuracy reports. + +### Trust boundary + +Untrusted: any on-disk fixture bytes and any parsed report mapping. +Trusted: in-repo generators, metric definitions, and registered floors. + +### Mitigations + +- No network and no shell interpolation. +- Checksum mismatch raises before C-major decode and before tempo scoring. +- Fixture bytes are capped before hashing; WAV headers are bounded by channel, + sample-rate, and duration limits before decoded PCM allocation. +- Manifest parsing fails closed on missing or mistyped fields. +- Fixtures are short, synthetic, and license-clean. + +### Test points + +- Deterministic C major digest +- Duration-weighted C recall after file decode +- Silence on disk fails even when a C major array exists in memory +- 120 BPM Acc1 after file decode +- Checksum mismatch through both file evaluators +- Oversized, excessive-channel, excessive-rate, and excessive-duration input rejection +- Malformed report rejection +- Silence must not pass as C major + +### Realistic threats + +A tampered fixture or a missing digest could be scored as a pass and then +cited as release evidence. + +### Logging and privacy + +`TemporalAnalyzer` may log the fixture path. Tests use pytest `tmp_path` +names only. Case reports keep SHA-256, metric, and labels; they do not store +PCM. + +### Remaining risk + +Tier 1 does not cover Demucs stems, private commercial recordings, or +CPU/GPU numeric parity. Those remain later #770 tiers. diff --git a/services/analysis-engine/pyproject.toml b/services/analysis-engine/pyproject.toml index fb8f7f062..270653315 100644 --- a/services/analysis-engine/pyproject.toml +++ b/services/analysis-engine/pyproject.toml @@ -10,7 +10,12 @@ requires-python = ">=3.12" dependencies = [ "demucs>=4.0.1 ; sys_platform != 'darwin' or platform_machine == 'arm64'", "librosa>=0.11.0", - "numba<0.67.0", + # librosa 0.11.0's beat tracker segfaults on macOS arm64 with numba 0.62.1 + # and 0.63.1; 0.66.0 is the first tested release that keeps this path alive. + "numba==0.66.0 ; platform_machine == 'arm64'", + # Numba 0.66.0 has no macOS Intel wheel; retain the x86 package resolution + # for the mandatory macOS amd64 build lane. + "numba==0.62.1 ; platform_machine != 'arm64'", "numpy>=1.26", "soundfile>=0.13.1", "urllib3>=2.7.0", diff --git a/services/analysis-engine/src/bandscope_analysis/accuracy/__init__.py b/services/analysis-engine/src/bandscope_analysis/accuracy/__init__.py new file mode 100644 index 000000000..beb5cfb4e --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/accuracy/__init__.py @@ -0,0 +1,55 @@ +"""Real-audio accuracy acceptance helpers for BandScope rehearsal analysis. + +Next action: run the Tier 1 fixture tests before claiming a harmony or tempo +result is accurate. These helpers score decoded PCM against known labels. They +do not replace Demucs stem separation or private-corpus benchmarks. +""" + +from .evaluate import evaluate_c_major_file, evaluate_c_major_pcm, evaluate_click_tempo_file +from .fixtures import ( + C_MAJOR_LABEL, + DEFAULT_CLICK_BPM, + DEFAULT_SAMPLE_RATE, + MAX_ACCURACY_CHANNELS, + MAX_ACCURACY_DURATION_SECONDS, + MAX_ACCURACY_FILE_BYTES, + MAX_ACCURACY_SAMPLE_RATE, + assert_fixture_checksum, + read_pcm_wav, + read_verified_fixture_bytes, + render_c_major_triad, + render_click_track, + write_pcm_wav, +) +from .manifest import ( + AccuracyCaseReport, + build_case_report, + parse_case_report, + read_product_version, +) +from .metrics import duration_weighted_chord_recall, tempo_acc1 + +__all__ = [ + "AccuracyCaseReport", + "C_MAJOR_LABEL", + "DEFAULT_CLICK_BPM", + "DEFAULT_SAMPLE_RATE", + "MAX_ACCURACY_CHANNELS", + "MAX_ACCURACY_DURATION_SECONDS", + "MAX_ACCURACY_FILE_BYTES", + "MAX_ACCURACY_SAMPLE_RATE", + "assert_fixture_checksum", + "build_case_report", + "duration_weighted_chord_recall", + "evaluate_c_major_file", + "evaluate_c_major_pcm", + "evaluate_click_tempo_file", + "parse_case_report", + "read_pcm_wav", + "read_verified_fixture_bytes", + "read_product_version", + "render_c_major_triad", + "render_click_track", + "tempo_acc1", + "write_pcm_wav", +] diff --git a/services/analysis-engine/src/bandscope_analysis/accuracy/evaluate.py b/services/analysis-engine/src/bandscope_analysis/accuracy/evaluate.py new file mode 100644 index 000000000..8556921f8 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/accuracy/evaluate.py @@ -0,0 +1,129 @@ +"""Score decoded PCM fixtures through production analysis helpers.""" + +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Iterator + +import numpy as np +from numpy.typing import NDArray + +from bandscope_analysis.accuracy.fixtures import ( + C_MAJOR_LABEL, + DEFAULT_CLICK_BPM, + DEFAULT_SAMPLE_RATE, + _validate_wav_header, + read_pcm_wav, + read_verified_fixture_bytes, +) +from bandscope_analysis.accuracy.manifest import AccuracyCaseReport, build_case_report +from bandscope_analysis.accuracy.metrics import duration_weighted_chord_recall, tempo_acc1 +from bandscope_analysis.accuracy.numeric import is_finite_real +from bandscope_analysis.chords.chord_recognizer import ChordRecognizer +from bandscope_analysis.temporal.analyzer import TemporalAnalyzer + +C_MAJOR_RECALL_FLOOR = 0.70 + + +@contextmanager +def _verified_fixture_path(audio_path: Path, expected_sha256: str) -> Iterator[Path]: + """Stage the exact checksum-verified bytes at an app-owned temporary path.""" + payload = read_verified_fixture_bytes(audio_path, expected_sha256) + with TemporaryDirectory(prefix="bandscope-accuracy-") as temp_dir: + verified_path = Path(temp_dir) / "verified.wav" + verified_path.write_bytes(payload) + _validate_wav_header(verified_path) + yield verified_path + + +def evaluate_c_major_pcm( + audio: NDArray[np.floating], + sample_rate: int = DEFAULT_SAMPLE_RATE, + audio_sha256: str = "0" * 64, +) -> AccuracyCaseReport: + """Score a C major triad through the production chord recognizer. + + Args: + audio: Decoded one-dimensional non-empty finite floating-point mono PCM. + Do not pass a chroma matrix, integer payload, or unresolved + multichannel buffer. + sample_rate: Finite positive non-Boolean sample rate of ``audio``. + audio_sha256: Digest of the on-disk fixture that produced ``audio``. + + Returns: + A case report whose metric is duration-weighted recall of ``C``. + + Raises: + ValueError: If PCM is empty, non-floating, non-finite, not mono, or the + sample-rate evidence is Boolean, non-finite, or non-positive. + """ + if not is_finite_real(sample_rate) or sample_rate <= 0: + raise ValueError("sample_rate must be a finite positive non-Boolean number") + if ( + not isinstance(audio, np.ndarray) + or audio.ndim != 1 + or audio.size == 0 + or not np.issubdtype(audio.dtype, np.floating) + or not np.isfinite(audio).all() + ): + raise ValueError("audio must be non-empty finite floating-point mono PCM") + samples = np.asarray(audio, dtype=np.float32) + + recognizer = ChordRecognizer() + tracked = recognizer.recognize(samples, sr=sample_rate) + segments = [(item["start_time"], item["end_time"], item["chord"]) for item in tracked] + duration = float(len(samples) / sample_rate) + recall = duration_weighted_chord_recall(segments, C_MAJOR_LABEL, 0.0, duration) + return build_case_report( + case_id="c-major-triad", + audio_sha256=audio_sha256, + metric_name="duration_weighted_chord_recall", + metric_value=recall, + passed=recall >= C_MAJOR_RECALL_FLOOR, + true_label=C_MAJOR_LABEL, + ) + + +def evaluate_c_major_file(audio_path: Path, expected_sha256: str) -> AccuracyCaseReport: + """Checksum, decode, and score one immutable C major WAV snapshot. + + Args: + audio_path: On-disk WAV written by ``write_pcm_wav``. + expected_sha256: Registered digest. Mismatch fails closed before decode. + + Returns: + A case report whose metric is duration-weighted recall of ``C``. + """ + with _verified_fixture_path(audio_path, expected_sha256) as verified_path: + audio, sample_rate = read_pcm_wav(verified_path) + return evaluate_c_major_pcm(audio, sample_rate, expected_sha256) + + +def evaluate_click_tempo_file( + audio_path: Path, + expected_sha256: str, + true_bpm: float = DEFAULT_CLICK_BPM, +) -> AccuracyCaseReport: + """Score one immutable checksum-verified click-track WAV snapshot. + + Args: + audio_path: On-disk WAV written by ``write_pcm_wav``. + expected_sha256: Registered digest. Mismatch fails closed. + true_bpm: Known click tempo. + + Returns: + A case report whose metric is 1.0 on Acc1 pass and 0.0 on Acc1 fail. + """ + with _verified_fixture_path(audio_path, expected_sha256) as verified_path: + features = TemporalAnalyzer().analyze(verified_path) + passed = tempo_acc1(features["bpm"], true_bpm) + return build_case_report( + case_id=f"click-{true_bpm:g}-bpm", + audio_sha256=expected_sha256, + metric_name="tempo_acc1", + metric_value=1.0 if passed else 0.0, + passed=passed, + true_label=f"{true_bpm:g} bpm", + ) diff --git a/services/analysis-engine/src/bandscope_analysis/accuracy/fixtures.py b/services/analysis-engine/src/bandscope_analysis/accuracy/fixtures.py new file mode 100644 index 000000000..84776ad5d --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/accuracy/fixtures.py @@ -0,0 +1,246 @@ +"""Deterministic, license-clean PCM fixtures for accuracy acceptance.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import numpy as np +import soundfile as sf # type: ignore[import-untyped] +from numpy.typing import NDArray + +from bandscope_analysis.accuracy.numeric import is_finite_real + +C_MAJOR_LABEL = "C" +DEFAULT_SAMPLE_RATE = 22_050 +DEFAULT_CLICK_BPM = 120.0 +C4_HZ = 261.63 +E4_HZ = 329.63 +G4_HZ = 392.00 +MAX_ACCURACY_FILE_BYTES = 100 * 1024 * 1024 +MAX_ACCURACY_DURATION_SECONDS = 15 * 60 +MAX_ACCURACY_CHANNELS = 8 +MAX_ACCURACY_SAMPLE_RATE = 192_000 +_CLICK_FREQUENCY_HZ = 1_000.0 +_CLICK_DURATION_SECONDS = 0.01 +_CLICK_DECAY = 80.0 + + +def _fixture_sample_count(duration_seconds: float, sample_rate: int) -> int: + """Convert fixture timing evidence to at least one finite integer sample.""" + scaled_sample_count = duration_seconds * sample_rate + if not np.isfinite(scaled_sample_count) or scaled_sample_count < 1: + raise ValueError("fixture sample count must be finite and at least one sample") + return int(scaled_sample_count) + + +def render_c_major_triad( + duration_seconds: float = 3.0, + sample_rate: int = DEFAULT_SAMPLE_RATE, +) -> NDArray[np.float32]: + """Render a unit-peak C major triad as float32 PCM. + + Args: + duration_seconds: Finite positive non-Boolean fixture length in seconds. + sample_rate: Finite positive non-Boolean samples-per-second rate. + + Returns: + Mono float32 samples in ``[-1, 1]``. + + Raises: + ValueError: If duration or sample rate is Boolean, non-finite, or not positive, + or if their derived sample count is non-finite or below one sample. + """ + if not is_finite_real(duration_seconds) or duration_seconds <= 0: + raise ValueError("duration_seconds must be a finite positive non-Boolean number") + if not is_finite_real(sample_rate) or sample_rate <= 0: + raise ValueError("sample_rate must be a finite positive non-Boolean number") + + sample_count = _fixture_sample_count(duration_seconds, sample_rate) + times = np.arange(sample_count, dtype=np.float32) / np.float32(sample_rate) + mix = ( + np.sin(2 * np.pi * np.float32(C4_HZ) * times) + + np.sin(2 * np.pi * np.float32(E4_HZ) * times) + + np.sin(2 * np.pi * np.float32(G4_HZ) * times) + ) / np.float32(3.0) + return np.asarray(mix, dtype=np.float32) + + +def render_click_track( + bpm: float = DEFAULT_CLICK_BPM, + duration_seconds: float = 8.0, + sample_rate: int = DEFAULT_SAMPLE_RATE, +) -> NDArray[np.float32]: + """Render a click track at a known tempo. + + Args: + bpm: Finite positive non-Boolean true tempo in beats per minute. + duration_seconds: Finite positive non-Boolean fixture length in seconds. + sample_rate: Finite positive non-Boolean samples-per-second rate. + + Returns: + Mono float32 samples with a decaying click on each beat. + + Raises: + ValueError: If tempo, duration, or sample rate is Boolean, non-finite, + or not positive, if the derived sample count is non-finite or below + one sample, if the derived beat interval is non-finite or shorter + than one sample, or if the click pulse itself is shorter than one + sample at the requested rate. + """ + if not is_finite_real(bpm) or bpm <= 0: + raise ValueError("bpm must be positive, finite, and non-Boolean") + if not is_finite_real(duration_seconds) or duration_seconds <= 0: + raise ValueError("duration_seconds must be a finite positive non-Boolean number") + if not is_finite_real(sample_rate) or sample_rate <= 0: + raise ValueError("sample_rate must be a finite positive non-Boolean number") + + sample_count = _fixture_sample_count(duration_seconds, sample_rate) + audio = np.zeros(sample_count, dtype=np.float32) + with np.errstate(over="ignore", divide="ignore", invalid="ignore"): + interval_seconds = float(np.divide(60.0, bpm)) + interval_samples = float(np.multiply(interval_seconds, sample_rate)) + if not np.isfinite(interval_seconds): + raise ValueError("bpm must produce a finite beat interval") + if not np.isfinite(interval_samples) or interval_samples < 1: + raise ValueError("beat interval must be finite and at least one sample") + + click_sample_count = _CLICK_DURATION_SECONDS * sample_rate + if not np.isfinite(click_sample_count) or click_sample_count < 1: + raise ValueError("click length must be finite and at least one sample") + click_length = int(click_sample_count) + click_times = np.arange(click_length, dtype=np.float32) / np.float32(sample_rate) + click = ( + np.sin(2 * np.pi * np.float32(_CLICK_FREQUENCY_HZ) * click_times) + * np.exp(-click_times * np.float32(_CLICK_DECAY)) + ).astype(np.float32) + beat_time = 0.0 + while True: + start = int(beat_time * sample_rate) + if start >= sample_count: + break + end = min(sample_count, start + click_length) + audio[start:end] += click[: end - start] + beat_time += interval_seconds + + peak = float(np.max(np.abs(audio))) + if peak <= 0: + raise ValueError("click pulse must contain non-zero signal") + audio /= np.float32(peak) + return audio + + +def write_pcm_wav(path: Path, audio: NDArray[np.floating], sample_rate: int) -> str: + """Write a WAV file and return the SHA-256 digest of the bytes on disk. + + Args: + path: Destination path. Parent directories are created. + audio: Mono PCM samples. + sample_rate: Finite positive non-Boolean samples-per-second rate used to write the file. + + Returns: + Lowercase hex SHA-256 of the written file. + + Raises: + ValueError: If the sample rate is Boolean, non-finite, or not positive. + """ + if not is_finite_real(sample_rate) or sample_rate <= 0: + raise ValueError("sample_rate must be a finite positive non-Boolean number") + path.parent.mkdir(parents=True, exist_ok=True) + sf.write(path, np.asarray(audio, dtype=np.float32), sample_rate) + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _validate_wav_header(path: Path) -> None: + """Validate WAV resource metadata before allocating decoded PCM.""" + try: + info = sf.info(path) + except Exception as error: + raise ValueError("WAV header could not be inspected") from error + + if info.channels > MAX_ACCURACY_CHANNELS: + raise ValueError( + f"WAV has too many channels: {info.channels} (max {MAX_ACCURACY_CHANNELS})" + ) + if info.samplerate <= 0 or info.samplerate > MAX_ACCURACY_SAMPLE_RATE: + raise ValueError(f"WAV sample rate is outside the supported range: {info.samplerate}") + if not np.isfinite(info.duration): + raise ValueError("WAV duration must be finite") + if info.duration > MAX_ACCURACY_DURATION_SECONDS: + raise ValueError( + f"WAV is too long for accuracy analysis: {info.duration:g} seconds " + f"(max {MAX_ACCURACY_DURATION_SECONDS} seconds)" + ) + + +def read_pcm_wav(path: Path) -> tuple[NDArray[np.float32], int]: + """Decode a WAV file to mono float32 PCM. + + Args: + path: Existing WAV path written by ``write_pcm_wav``. + + Returns: + A tuple of mono samples and the file sample rate. + + Raises: + ValueError: If the header exceeds the acceptance resource limits or the + file has no samples after decode. + """ + _validate_wav_header(path) + audio, sample_rate = sf.read(path, dtype="float32", always_2d=False) + samples = np.asarray(audio, dtype=np.float32) + if samples.ndim > 1: + samples = np.mean(samples, axis=1).astype(np.float32) + if samples.size == 0: + raise ValueError("decoded WAV has no samples") + return samples, int(sample_rate) + + +def read_verified_fixture_bytes(path: Path, expected_sha256: str) -> bytes: + """Read one immutable fixture snapshot and verify its registered digest. + + Args: + path: Existing WAV path. + expected_sha256: Lowercase hex digest recorded in the case manifest. + + Returns: + The exact bytes whose SHA-256 matched ``expected_sha256``. + + Raises: + ValueError: If the file exceeds the byte limit, cannot be read, or the + snapshot digest does not match. + """ + try: + file_size = path.stat().st_size + except OSError as error: + raise ValueError("Accuracy fixture could not be inspected") from error + if file_size > MAX_ACCURACY_FILE_BYTES: + raise ValueError( + f"Accuracy fixture is too large: {file_size} bytes " + f"(max {MAX_ACCURACY_FILE_BYTES} bytes)" + ) + + try: + with path.open("rb") as fileobj: + payload = fileobj.read(MAX_ACCURACY_FILE_BYTES + 1) + except OSError as error: + raise ValueError("Accuracy fixture could not be read") from error + if len(payload) > MAX_ACCURACY_FILE_BYTES: + raise ValueError(f"Accuracy fixture is too large (max {MAX_ACCURACY_FILE_BYTES} bytes)") + actual = hashlib.sha256(payload).hexdigest() + if actual != expected_sha256: + raise ValueError("Accuracy fixture checksum mismatch") + return payload + + +def assert_fixture_checksum(path: Path, expected_sha256: str) -> None: + """Fail closed when a fixture file does not match its registered digest. + + Args: + path: Existing WAV path. + expected_sha256: Lowercase hex digest recorded in the case manifest. + + Raises: + ValueError: If the file digest does not match. + """ + read_verified_fixture_bytes(path, expected_sha256) diff --git a/services/analysis-engine/src/bandscope_analysis/accuracy/manifest.py b/services/analysis-engine/src/bandscope_analysis/accuracy/manifest.py new file mode 100644 index 000000000..b1d07634c --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/accuracy/manifest.py @@ -0,0 +1,169 @@ +"""Accuracy-case report schema for exact-value acceptance evidence.""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import TypedDict + +REQUIRED_REPORT_KEYS = frozenset( + { + "case_id", + "audio_sha256", + "metric_name", + "metric_value", + "passed", + "engine_version", + "true_label", + } +) +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") + + +class AccuracyCaseReport(TypedDict): + """One scored fixture case that a buyer can read without opening logs.""" + + case_id: str + audio_sha256: str + metric_name: str + metric_value: float + passed: bool + engine_version: str + true_label: str + + +def read_product_version(start: Path | None = None) -> str: + """Return the nearest non-empty ``VERSION`` file contents. + + Accuracy reports are provenance evidence, so an unavailable product version + is not converted into an ``unknown`` value that could still pass report + validation. + + Args: + start: File or directory to walk upward from. Defaults to this module. + + Returns: + Stripped product version text. + + Raises: + ValueError: If no non-empty ``VERSION`` file exists in the ancestor path. + """ + current = start if start is not None else Path(__file__).resolve() + cursor = current.parent if current.is_file() else current + for parent in (cursor, *cursor.parents): + candidate = parent / "VERSION" + if not candidate.is_file(): + continue + text = candidate.read_text(encoding="utf-8").strip() + if text: + return text + raise ValueError("Product VERSION file is missing or empty") + + +def build_case_report( + *, + case_id: str, + audio_sha256: str, + metric_name: str, + metric_value: float, + passed: bool, + true_label: str, + engine_version: str | None = None, +) -> AccuracyCaseReport: + """Build a validated accuracy case report. + + Args: + case_id: Stable fixture identifier such as ``c-major-triad``. + audio_sha256: Digest of the decoded fixture bytes. + metric_name: Registered metric id such as ``duration_weighted_chord_recall``. + metric_value: Numeric score for this run. + passed: Whether the score met the registered tolerance. + true_label: Ground-truth label the buyer should hear. + engine_version: Optional exact override. Defaults to the product ``VERSION``. + + Returns: + A report that ``parse_case_report`` will accept. + + Raises: + ValueError: If required report evidence is invalid or the default product + ``VERSION`` cannot be resolved. + """ + report: dict[str, object] = { + "case_id": case_id, + "audio_sha256": audio_sha256, + "metric_name": metric_name, + "metric_value": metric_value, + "passed": passed, + "engine_version": engine_version if engine_version is not None else read_product_version(), + "true_label": true_label, + } + return parse_case_report(report) + + +def parse_case_report(value: object) -> AccuracyCaseReport: + """Validate an accuracy case report and return a typed copy. + + Args: + value: Untrusted mapping, typically loaded from JSON. + + Returns: + The same fields after type and presence checks. + + Raises: + ValueError: If the payload is missing keys or uses invalid values. + """ + if not isinstance(value, dict): + raise ValueError("Accuracy case report must be an object") + + missing = REQUIRED_REPORT_KEYS.difference(value) + if missing: + missing_names = ", ".join(sorted(missing)) + raise ValueError(f"Accuracy case report is missing: {missing_names}") + + case_id = value["case_id"] + audio_sha256 = value["audio_sha256"] + metric_name = value["metric_name"] + metric_value = value["metric_value"] + passed = value["passed"] + engine_version = value["engine_version"] + true_label = value["true_label"] + + if not isinstance(case_id, str) or not case_id or case_id.strip() != case_id: + raise ValueError("case_id must be an exact non-empty string") + if ( + not isinstance(audio_sha256, str) + or len(audio_sha256) != 64 + or any(character not in _HEX_DIGITS for character in audio_sha256) + ): + raise ValueError("audio_sha256 must be a 64-character hex digest") + if not isinstance(metric_name, str) or not metric_name or metric_name.strip() != metric_name: + raise ValueError("metric_name must be an exact non-empty string") + if isinstance(metric_value, bool) or not isinstance(metric_value, (int, float)): + raise ValueError("metric_value must be a finite number") + try: + normalized_metric_value = float(metric_value) + except OverflowError as error: + raise ValueError("metric_value must be a finite number") from error + if not math.isfinite(normalized_metric_value): + raise ValueError("metric_value must be a finite number") + if not isinstance(passed, bool): + raise ValueError("passed must be a boolean") + if ( + not isinstance(engine_version, str) + or not engine_version + or engine_version.strip() != engine_version + or engine_version.lower() == "unknown" + ): + raise ValueError("engine_version must be an exact non-empty version, not unknown") + if not isinstance(true_label, str) or not true_label or true_label.strip() != true_label: + raise ValueError("true_label must be an exact non-empty string") + + return { + "case_id": case_id, + "audio_sha256": audio_sha256, + "metric_name": metric_name, + "metric_value": normalized_metric_value, + "passed": passed, + "engine_version": engine_version, + "true_label": true_label, + } diff --git a/services/analysis-engine/src/bandscope_analysis/accuracy/metrics.py b/services/analysis-engine/src/bandscope_analysis/accuracy/metrics.py new file mode 100644 index 000000000..4b06ec08e --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/accuracy/metrics.py @@ -0,0 +1,108 @@ +"""Recognized MIR metrics for BandScope accuracy acceptance.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from bandscope_analysis.accuracy.numeric import is_finite_real + + +def duration_weighted_chord_recall( + segments: Sequence[tuple[float, float, str]], + expected_chord: str, + start_seconds: float, + end_seconds: float, +) -> float: + """Return the annotated-interval fraction labeled with ``expected_chord``. + + This is a single-label duration-weighted recall on one interval, the + smallest WCSR-style score that still answers “did the engine hear the + known chord for most of the fixture?” (Odekerken et al., 2021; Raffel + et al., 2014). Matching estimate intervals are unioned after clipping to + the annotation window, so overlapping estimates cannot count the same + annotated time more than once. Non-finite, Boolean, empty, or reversed + annotation and estimate timing is invalid acceptance evidence and fails + closed before clipping. + + Args: + segments: ``(start, end, chord)`` estimates in seconds. Segment timing + values must be finite non-Boolean numbers and each end must be + greater than its start. + expected_chord: Ground-truth chord symbol for the interval. + start_seconds: Inclusive finite non-Boolean annotation start. + end_seconds: Exclusive finite non-Boolean annotation end. Must be + greater than start. + + Returns: + A value in ``[0, 1]``. + + Raises: + ValueError: If annotation or estimate timing is Boolean, non-finite, + empty, or reversed. + """ + if not is_finite_real(start_seconds) or not is_finite_real(end_seconds): + raise ValueError("annotation times must be finite numbers") + if end_seconds <= start_seconds: + raise ValueError("annotation end_seconds must be greater than start_seconds") + + matching_intervals: list[tuple[float, float]] = [] + for segment_start, segment_end, chord in segments: + if not is_finite_real(segment_start) or not is_finite_real(segment_end): + raise ValueError("segment times must be finite numbers") + if segment_end <= segment_start: + raise ValueError("segment end_seconds must be greater than start_seconds") + overlap_start = max(start_seconds, segment_start) + overlap_end = min(end_seconds, segment_end) + if overlap_end > overlap_start and chord == expected_chord: + matching_intervals.append((overlap_start, overlap_end)) + + if not matching_intervals: + return 0.0 + + matching_intervals.sort(key=lambda interval: (interval[0], interval[1])) + current_start, current_end = matching_intervals[0] + covered = 0.0 + for interval_start, interval_end in matching_intervals[1:]: + if interval_start <= current_end: + current_end = max(current_end, interval_end) + continue + covered += current_end - current_start + current_start, current_end = interval_start, interval_end + covered += current_end - current_start + return covered / (end_seconds - start_seconds) + + +def tempo_acc1( + estimated_bpm: float, + true_bpm: float, + relative_tolerance: float = 0.04, +) -> bool: + """Return whether estimated tempo is within Acc1 tolerance of the true tempo. + + Acc1 accepts an estimate within ``relative_tolerance`` of the true BPM and + does not credit octave errors (Schreiber & Müller, 2020). Boolean or + non-finite estimates, ground truth, or tolerances are invalid evidence and + fail closed instead of being converted into an ordinary metric miss. + + Args: + estimated_bpm: Engine tempo in beats per minute. Must be a finite + non-Boolean number. + true_bpm: Known fixture tempo. Must be a finite positive non-Boolean + number. + relative_tolerance: Finite non-negative non-Boolean Acc1 window. The + default is 4%. + + Returns: + ``True`` when the estimate is inside the Acc1 window. + + Raises: + ValueError: If any metric input is Boolean/non-finite, ``true_bpm`` is + not positive, or the tolerance is negative. + """ + if not is_finite_real(estimated_bpm): + raise ValueError("estimated_bpm must be finite") + if not is_finite_real(true_bpm) or true_bpm <= 0: + raise ValueError("true_bpm must be finite and positive") + if not is_finite_real(relative_tolerance) or relative_tolerance < 0: + raise ValueError("relative_tolerance must be finite and non-negative") + return abs(estimated_bpm - true_bpm) / true_bpm <= relative_tolerance diff --git a/services/analysis-engine/src/bandscope_analysis/accuracy/numeric.py b/services/analysis-engine/src/bandscope_analysis/accuracy/numeric.py new file mode 100644 index 000000000..806baea43 --- /dev/null +++ b/services/analysis-engine/src/bandscope_analysis/accuracy/numeric.py @@ -0,0 +1,11 @@ +"""Numeric evidence validation for real-audio accuracy acceptance.""" + +from __future__ import annotations + +import math +from numbers import Real + + +def is_finite_real(value: object) -> bool: + """Return whether a value is a finite non-Boolean real number.""" + return isinstance(value, Real) and not isinstance(value, bool) and math.isfinite(value) diff --git a/services/analysis-engine/tests/test_accuracy_acceptance.py b/services/analysis-engine/tests/test_accuracy_acceptance.py new file mode 100644 index 000000000..aca8ff738 --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_acceptance.py @@ -0,0 +1,480 @@ +"""Real-audio accuracy acceptance for decoded PCM fixtures. + +These cases prove a buyer-visible claim: a known waveform written to disk, +decoded, and analyzed yields the expected chord or tempo. Mocked chroma +matrices are not acceptance evidence. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest +import soundfile as sf + +import bandscope_analysis.accuracy.fixtures as fixture_helpers +from bandscope_analysis.accuracy import ( + C_MAJOR_LABEL, + DEFAULT_CLICK_BPM, + DEFAULT_SAMPLE_RATE, + MAX_ACCURACY_CHANNELS, + MAX_ACCURACY_DURATION_SECONDS, + MAX_ACCURACY_FILE_BYTES, + MAX_ACCURACY_SAMPLE_RATE, + build_case_report, + duration_weighted_chord_recall, + evaluate_c_major_file, + evaluate_c_major_pcm, + evaluate_click_tempo_file, + parse_case_report, + read_pcm_wav, + read_product_version, + read_verified_fixture_bytes, + render_c_major_triad, + render_click_track, + tempo_acc1, + write_pcm_wav, +) +from bandscope_analysis.accuracy.evaluate import C_MAJOR_RECALL_FLOOR +from bandscope_analysis.api import build_demo_rehearsal_song + + +def test_c_major_fixture_is_deterministic(tmp_path: Path) -> None: + """Two writes of the same triad must share one SHA-256 digest.""" + audio = render_c_major_triad() + first = write_pcm_wav(tmp_path / "a.wav", audio, DEFAULT_SAMPLE_RATE) + second = write_pcm_wav(tmp_path / "b.wav", audio, DEFAULT_SAMPLE_RATE) + assert first == second + assert len(first) == 64 + + +def test_c_major_wav_recovers_c_after_file_decode(tmp_path: Path) -> None: + """A decoded C major WAV must recover C for most of the fixture duration.""" + audio = render_c_major_triad(duration_seconds=3.0) + path = tmp_path / "c-major.wav" + digest = write_pcm_wav(path, audio, DEFAULT_SAMPLE_RATE) + report = evaluate_c_major_file(path, digest) + assert report["true_label"] == C_MAJOR_LABEL + assert report["metric_name"] == "duration_weighted_chord_recall" + assert report["metric_value"] >= C_MAJOR_RECALL_FLOOR + assert report["passed"] is True + assert report["audio_sha256"] == digest + + +def test_click_wav_recovers_120_bpm_acc1(tmp_path: Path) -> None: + """A decoded 120 BPM click WAV must pass tempo Acc1.""" + audio = render_click_track(bpm=DEFAULT_CLICK_BPM, duration_seconds=8.0) + path = tmp_path / "click-120.wav" + digest = write_pcm_wav(path, audio, DEFAULT_SAMPLE_RATE) + report = evaluate_click_tempo_file(path, digest, DEFAULT_CLICK_BPM) + assert report["passed"] is True + assert report["metric_name"] == "tempo_acc1" + assert report["true_label"] == "120 bpm" + + +def test_silence_does_not_pass_c_major_recall() -> None: + """Silence must not be reported as a passing C major acceptance case.""" + silence = np.zeros(DEFAULT_SAMPLE_RATE, dtype=np.float32) + report = evaluate_c_major_pcm(silence, DEFAULT_SAMPLE_RATE, "b" * 64) + assert report["passed"] is False + assert report["metric_value"] < C_MAJOR_RECALL_FLOOR + + +def test_click_tempo_acc1_fails_when_true_tempo_is_wrong(tmp_path: Path) -> None: + """Acc1 must fail when the registered true tempo is not the click tempo.""" + audio = render_click_track(bpm=DEFAULT_CLICK_BPM, duration_seconds=8.0) + path = tmp_path / "click-wrong-label.wav" + digest = write_pcm_wav(path, audio, DEFAULT_SAMPLE_RATE) + report = evaluate_click_tempo_file(path, digest, true_bpm=40.0) + assert report["passed"] is False + assert report["metric_value"] == 0.0 + + +def test_checksum_mismatch_fails_closed(tmp_path: Path) -> None: + """A tampered fixture must not be scored as a passing case.""" + click = render_click_track() + click_path = tmp_path / "click.wav" + write_pcm_wav(click_path, click, DEFAULT_SAMPLE_RATE) + with pytest.raises(ValueError, match="checksum mismatch"): + evaluate_click_tempo_file(click_path, "0" * 64) + + triad = render_c_major_triad() + triad_path = tmp_path / "c-major.wav" + write_pcm_wav(triad_path, triad, DEFAULT_SAMPLE_RATE) + with pytest.raises(ValueError, match="checksum mismatch"): + evaluate_c_major_file(triad_path, "0" * 64) + + +def test_c_major_file_decode_scores_disk_not_memory(tmp_path: Path) -> None: + """Silence on disk must fail even when a C major array exists in memory.""" + triad = render_c_major_triad(duration_seconds=3.0) + silence = np.zeros_like(triad) + path = tmp_path / "silence.wav" + digest = write_pcm_wav(path, silence, DEFAULT_SAMPLE_RATE) + report = evaluate_c_major_file(path, digest) + assert report["passed"] is False + assert report["metric_value"] < C_MAJOR_RECALL_FLOOR + memory_report = evaluate_c_major_pcm(triad, DEFAULT_SAMPLE_RATE, digest) + assert memory_report["passed"] is True + + +def test_read_pcm_wav_mixes_stereo_to_mono(tmp_path: Path) -> None: + """A stereo fixture must collapse to mono before scoring.""" + path = tmp_path / "stereo.wav" + stereo = np.column_stack([np.ones(8, dtype=np.float32), np.zeros(8, dtype=np.float32)]) + sf.write(path, stereo, DEFAULT_SAMPLE_RATE) + audio, sample_rate = read_pcm_wav(path) + assert sample_rate == DEFAULT_SAMPLE_RATE + assert audio.shape == (8,) + assert np.allclose(audio, 0.5, atol=1e-3) + + +def test_read_pcm_wav_rejects_empty_file(tmp_path: Path) -> None: + """An empty WAV must fail closed instead of scoring as a pass.""" + path = tmp_path / "empty.wav" + sf.write(path, np.zeros(0, dtype=np.float32), DEFAULT_SAMPLE_RATE) + with pytest.raises(ValueError, match="no samples"): + read_pcm_wav(path) + + +def test_read_pcm_wav_rejects_resource_excesses(tmp_path: Path) -> None: + """WAV decode must reject excessive channels, rates, and duration first.""" + too_many_channels = tmp_path / "too-many-channels.wav" + sf.write( + too_many_channels, + np.zeros((8, MAX_ACCURACY_CHANNELS + 1), dtype=np.float32), + DEFAULT_SAMPLE_RATE, + ) + with pytest.raises(ValueError, match="too many channels"): + read_pcm_wav(too_many_channels) + + too_fast = tmp_path / "too-fast.wav" + sf.write(too_fast, np.zeros(8, dtype=np.float32), MAX_ACCURACY_SAMPLE_RATE + 1) + with pytest.raises(ValueError, match="sample rate"): + read_pcm_wav(too_fast) + + too_long = tmp_path / "too-long.wav" + sf.write( + too_long, + np.zeros(MAX_ACCURACY_DURATION_SECONDS + 1, dtype=np.float32), + 1, + ) + with pytest.raises(ValueError, match="too long"): + read_pcm_wav(too_long) + + +def test_click_tempo_file_applies_wav_resource_limits(tmp_path: Path) -> None: + """Tempo acceptance must use the same header guard as chord acceptance.""" + path = tmp_path / "too-many-channels-tempo.wav" + audio = np.zeros((8, MAX_ACCURACY_CHANNELS + 1), dtype=np.float32) + digest = write_pcm_wav(path, audio, DEFAULT_SAMPLE_RATE) + + with pytest.raises(ValueError, match="too many channels"): + evaluate_click_tempo_file(path, digest) + + +def test_read_pcm_wav_rejects_uninspectable_or_non_finite_header( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Header inspection must fail closed before an unbounded decode attempt.""" + path = tmp_path / "header.wav" + path.write_bytes(b"not-a-wav") + + def raise_info(_path: Path) -> object: + raise RuntimeError("invalid header") + + monkeypatch.setattr(sf, "info", raise_info) + with pytest.raises(ValueError, match="header could not be inspected"): + read_pcm_wav(path) + + monkeypatch.setattr( + sf, + "info", + lambda _path: SimpleNamespace( + channels=1, + samplerate=DEFAULT_SAMPLE_RATE, + duration=float("nan"), + ), + ) + with pytest.raises(ValueError, match="duration must be finite"): + read_pcm_wav(path) + + +def test_read_verified_fixture_rejects_oversized_file(tmp_path: Path) -> None: + """Digest verification must not read an over-sized fixture into memory.""" + path = tmp_path / "oversized.wav" + with path.open("wb") as fileobj: + fileobj.truncate(MAX_ACCURACY_FILE_BYTES + 1) + + with pytest.raises(ValueError, match="too large"): + read_verified_fixture_bytes(path, "0" * 64) + + +def test_read_verified_fixture_normalizes_file_inspection_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Fixture reads must expose stable errors when stat or open fails.""" + path = tmp_path / "fixture.wav" + path.write_bytes(b"fixture") + + def raise_stat(_path: Path) -> object: + raise OSError("stat denied") + + monkeypatch.setattr(Path, "stat", raise_stat) + with pytest.raises(ValueError, match="could not be inspected"): + read_verified_fixture_bytes(path, "0" * 64) + + monkeypatch.undo() + + def raise_open(_path: Path, *_args: object, **_kwargs: object) -> object: + raise OSError("open denied") + + monkeypatch.setattr(Path, "open", raise_open) + with pytest.raises(ValueError, match="could not be read"): + read_verified_fixture_bytes(path, "0" * 64) + + +def test_read_verified_fixture_rejects_growth_after_stat( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A file that grows after stat must not be hashed as truncated evidence.""" + path = tmp_path / "grown.wav" + path.write_bytes(b"12345") + monkeypatch.setattr(fixture_helpers, "MAX_ACCURACY_FILE_BYTES", 4) + monkeypatch.setattr(Path, "stat", lambda _path: SimpleNamespace(st_size=4)) + + with pytest.raises(ValueError, match="too large"): + read_verified_fixture_bytes(path, "0" * 64) + + +def test_pipeline_surfaces_c_on_active_lead_vocal() -> None: + """Unmocked assembly must put measured C on lead vocal when that stem is active.""" + audio = render_c_major_triad(duration_seconds=3.0) + silence = np.zeros_like(audio) + song = build_demo_rehearsal_song( + { + "stems": { + "vocals": audio * np.float32(0.35), + "bass": silence, + "drums": silence, + "other": audio, + }, + "sr": DEFAULT_SAMPLE_RATE, + "separation": {"duration_seconds": 3.0, "chunk_count": 1, "notes": "accuracy"}, + } + ) + assert song["id"] == "analyzed-song" + lead_chords = [ + role["harmony"]["chord"] + for section in song["sections"] + for role in section["roles"] + if role["id"] == "lead-vocal" + ] + assert C_MAJOR_LABEL in lead_chords + + +def test_render_helpers_reject_non_positive_inputs() -> None: + """Fixture helpers must refuse empty or reversed generation parameters.""" + with pytest.raises(ValueError, match="duration_seconds"): + render_c_major_triad(duration_seconds=0) + with pytest.raises(ValueError, match="sample_rate"): + render_c_major_triad(sample_rate=0) + with pytest.raises(ValueError, match="must be positive"): + render_click_track(bpm=0) + with pytest.raises(ValueError, match="sample_rate"): + write_pcm_wav(Path("unused.wav"), np.zeros(4, dtype=np.float32), 0) + + +def test_render_helpers_reject_non_finite_inputs() -> None: + """Fixture generation must reject non-finite timing before allocation/loops.""" + with pytest.raises(ValueError, match="duration_seconds.*finite"): + render_c_major_triad(duration_seconds=np.nan) + with pytest.raises(ValueError, match="bpm.*finite"): + render_click_track(bpm=np.nan) + with pytest.raises(ValueError, match="duration_seconds.*finite"): + render_click_track(duration_seconds=np.inf) + + +def test_click_track_rejects_zero_length_click_evidence() -> None: + """A sample rate that cannot represent one click sample must fail closed.""" + with pytest.raises(ValueError, match="click length"): + render_click_track(bpm=60.0, duration_seconds=1.0, sample_rate=1) + + +def test_duration_weighted_recall_covers_overlap_and_misses() -> None: + """Recall must count only overlapping time that matches the expected chord.""" + assert duration_weighted_chord_recall([(0.0, 2.0, "C"), (2.0, 4.0, "G")], "C", 0.0, 4.0) == 0.5 + assert duration_weighted_chord_recall([(5.0, 6.0, "C")], "C", 0.0, 2.0) == 0.0 + with pytest.raises(ValueError, match="end_seconds"): + duration_weighted_chord_recall([], "C", 1.0, 1.0) + + +def test_duration_weighted_recall_unions_overlapping_matching_estimates() -> None: + """Overlapping matching estimates must not count annotation time twice.""" + recall = duration_weighted_chord_recall( + [(0.0, 2.0, "C"), (1.0, 3.0, "C"), (4.0, 5.0, "C")], + "C", + 0.0, + 5.0, + ) + assert recall == pytest.approx(0.8) + + +@pytest.mark.parametrize( + ("segments", "start_seconds", "end_seconds"), + [ + ([(np.nan, 2.0, "C")], 0.0, 4.0), + ([(0.0, np.inf, "C")], 0.0, 4.0), + ([(0.0, 2.0, "C")], np.nan, 4.0), + ([(0.0, 2.0, "C")], 0.0, np.inf), + ], +) +def test_duration_weighted_recall_rejects_non_finite_timing( + segments: list[tuple[float, float, str]], + start_seconds: float, + end_seconds: float, +) -> None: + """Non-finite estimate or annotation times must not fabricate recall.""" + with pytest.raises(ValueError, match="finite"): + duration_weighted_chord_recall(segments, "C", start_seconds, end_seconds) + + +@pytest.mark.parametrize( + ("segments", "start_seconds", "end_seconds"), + [ + ([(True, 2.0, "C")], 0.0, 4.0), + ([(0.0, True, "C")], 0.0, 4.0), + ([(0.0, 2.0, "C")], False, 4.0), + ([(0.0, 2.0, "C")], 0.0, True), + ], +) +def test_duration_weighted_recall_rejects_boolean_timing_evidence( + segments: list[tuple[float | bool, float | bool, str]], + start_seconds: float | bool, + end_seconds: float | bool, +) -> None: + """Boolean timestamps must not be accepted as numeric MIR timing evidence.""" + with pytest.raises(ValueError, match="times must be finite numbers"): + duration_weighted_chord_recall(segments, "C", start_seconds, end_seconds) # type: ignore[arg-type] + + +def test_tempo_acc1_window_and_guards() -> None: + """Acc1 must accept a 4% window and reject octave errors and bad inputs.""" + assert tempo_acc1(120.0, 120.0) is True + assert tempo_acc1(124.8, 120.0) is True + assert tempo_acc1(240.0, 120.0) is False + with pytest.raises(ValueError, match="true_bpm"): + tempo_acc1(120.0, 0.0) + with pytest.raises(ValueError, match="relative_tolerance"): + tempo_acc1(120.0, 120.0, relative_tolerance=-0.01) + + +@pytest.mark.parametrize( + ("estimated_bpm", "true_bpm", "relative_tolerance", "message"), + [ + (np.nan, 120.0, 0.04, "estimated_bpm"), + (np.inf, 120.0, 0.04, "estimated_bpm"), + (120.0, np.nan, 0.04, "true_bpm"), + (120.0, np.inf, 0.04, "true_bpm"), + (120.0, 120.0, np.nan, "relative_tolerance"), + (120.0, 120.0, np.inf, "relative_tolerance"), + ], +) +def test_tempo_acc1_rejects_non_finite_evidence( + estimated_bpm: float, + true_bpm: float, + relative_tolerance: float, + message: str, +) -> None: + """Non-finite estimate, truth, or tolerance must fail closed.""" + with pytest.raises(ValueError, match=message): + tempo_acc1(estimated_bpm, true_bpm, relative_tolerance) + + +@pytest.mark.parametrize( + ("estimated_bpm", "true_bpm", "relative_tolerance", "message"), + [ + (True, 120.0, 0.04, "estimated_bpm"), + (120.0, True, 0.04, "true_bpm"), + (120.0, 120.0, True, "relative_tolerance"), + ], +) +def test_tempo_acc1_rejects_boolean_numeric_evidence( + estimated_bpm: float | bool, + true_bpm: float | bool, + relative_tolerance: float | bool, + message: str, +) -> None: + """Boolean values must not satisfy numeric Acc1 evidence contracts.""" + with pytest.raises(ValueError, match=message): + tempo_acc1(estimated_bpm, true_bpm, relative_tolerance) # type: ignore[arg-type] + + +def test_tempo_acc1_rejects_non_numeric_evidence() -> None: + """String metric inputs must fail at the accuracy boundary, not in math.""" + with pytest.raises(ValueError, match="estimated_bpm"): + tempo_acc1("120", 120) # type: ignore[arg-type] + + +def test_duration_weighted_recall_rejects_non_numeric_evidence() -> None: + """String timing inputs must fail at the metric boundary, not in math.""" + with pytest.raises(ValueError, match="annotation times"): + duration_weighted_chord_recall([], "C", "0", 1) # type: ignore[arg-type] + + +def test_parse_case_report_rejects_malformed_payloads() -> None: + """Manifest parsing must fail closed on missing or mistyped fields.""" + valid = build_case_report( + case_id="c-major-triad", + audio_sha256="a" * 64, + metric_name="duration_weighted_chord_recall", + metric_value=0.9, + passed=True, + true_label="C", + engine_version="0.1.3", + ) + assert parse_case_report(valid)["passed"] is True + + with pytest.raises(ValueError, match="must be an object"): + parse_case_report(["not", "an", "object"]) + with pytest.raises(ValueError, match="missing"): + parse_case_report({"case_id": "only"}) + with pytest.raises(ValueError, match="case_id"): + parse_case_report({**valid, "case_id": ""}) + with pytest.raises(ValueError, match="audio_sha256"): + parse_case_report({**valid, "audio_sha256": "short"}) + with pytest.raises(ValueError, match="audio_sha256"): + parse_case_report({**valid, "audio_sha256": "g" * 64}) + with pytest.raises(ValueError, match="metric_name"): + parse_case_report({**valid, "metric_name": ""}) + with pytest.raises(ValueError, match="metric_value"): + parse_case_report({**valid, "metric_value": True}) + with pytest.raises(ValueError, match="metric_value"): + parse_case_report({**valid, "metric_value": "0.9"}) + with pytest.raises(ValueError, match="metric_value"): + parse_case_report({**valid, "metric_value": np.nan}) + with pytest.raises(ValueError, match="metric_value"): + parse_case_report({**valid, "metric_value": np.inf}) + with pytest.raises(ValueError, match="passed"): + parse_case_report({**valid, "passed": 1}) + with pytest.raises(ValueError, match="engine_version"): + parse_case_report({**valid, "engine_version": ""}) + with pytest.raises(ValueError, match="true_label"): + parse_case_report({**valid, "true_label": ""}) + + +def test_read_product_version_uses_version_file_and_fails_closed(tmp_path: Path) -> None: + """Version lookup must read VERSION and reject missing provenance.""" + versioned = tmp_path / "versioned" + versioned.mkdir() + (versioned / "VERSION").write_text("9.9.9\n", encoding="utf-8") + assert read_product_version(versioned) == "9.9.9" + assert read_product_version(versioned / "VERSION") == "9.9.9" + empty = tmp_path / "empty-tree" + empty.mkdir() + (empty / "VERSION").write_text(" \n", encoding="utf-8") + with pytest.raises(ValueError, match="VERSION"): + read_product_version(empty) + assert read_product_version() != "unknown" diff --git a/services/analysis-engine/tests/test_accuracy_boolean_fixture_inputs.py b/services/analysis-engine/tests/test_accuracy_boolean_fixture_inputs.py new file mode 100644 index 000000000..a163d7532 --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_boolean_fixture_inputs.py @@ -0,0 +1,117 @@ +"""Boolean and derived-arithmetic guards for deterministic real-audio fixtures.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import numpy as np +import pytest + +from bandscope_analysis.accuracy import render_c_major_triad, render_click_track, write_pcm_wav + + +@pytest.mark.parametrize( + ("keyword", "value", "message"), + [ + ("duration_seconds", True, "duration_seconds"), + ("sample_rate", True, "sample_rate"), + ], +) +def test_c_major_fixture_rejects_boolean_numeric_evidence( + keyword: str, + value: bool, + message: str, +) -> None: + """Boolean duration/rate values must not become numeric fixture authority.""" + kwargs = {keyword: cast(Any, value)} + with pytest.raises(ValueError, match=message): + render_c_major_triad(**kwargs) + + +@pytest.mark.parametrize( + ("keyword", "value", "message"), + [ + ("bpm", True, "bpm"), + ("duration_seconds", True, "duration_seconds"), + ("sample_rate", True, "sample_rate"), + ], +) +def test_click_fixture_rejects_boolean_numeric_evidence( + keyword: str, + value: bool, + message: str, +) -> None: + """Boolean tempo/duration/rate values must fail before allocation or loops.""" + kwargs = {keyword: cast(Any, value)} + with pytest.raises(ValueError, match=message): + render_click_track(**kwargs) + + +@pytest.mark.parametrize("factory", [render_c_major_triad, render_click_track]) +def test_fixture_rejects_nonfinite_scaled_sample_count(factory: Any) -> None: + """Finite inputs whose product overflows must fail before allocation authority.""" + with pytest.raises(ValueError, match="sample count"): + factory(duration_seconds=1e308) + + +@pytest.mark.parametrize("factory", [render_c_major_triad, render_click_track]) +def test_fixture_rejects_duration_shorter_than_one_sample(factory: Any) -> None: + """Positive durations that quantize to zero samples must fail as evidence.""" + sub_sample_duration = np.nextafter(0.0, 1.0) + + with pytest.raises(ValueError, match="sample count"): + factory(duration_seconds=sub_sample_duration) + + +def test_click_fixture_rejects_tempo_whose_beat_interval_overflows() -> None: + """A finite positive tempo must not become infinite loop timing authority.""" + smallest_positive = np.nextafter(0.0, 1.0) + + with pytest.raises(ValueError, match="bpm"): + render_click_track(bpm=smallest_positive, duration_seconds=1.0) + + +def test_click_fixture_rejects_tempo_shorter_than_one_sample() -> None: + """A click cadence faster than one sample must not alias acceptance evidence.""" + with pytest.raises(ValueError, match="beat interval"): + render_click_track(bpm=6_001.0, duration_seconds=0.02, sample_rate=100) + + +def test_click_fixture_rejects_sample_rate_too_low_for_one_click_sample() -> None: + """A click fixture must contain at least one sample of click evidence.""" + with pytest.raises(ValueError, match="click length"): + render_click_track(bpm=60.0, duration_seconds=0.1, sample_rate=99) + + +def test_click_fixture_rejects_a_silent_aliased_pulse() -> None: + """A valid-sized but silent click pulse cannot become tempo evidence.""" + with pytest.raises(ValueError, match="non-zero signal"): + render_click_track(bpm=60.0, duration_seconds=0.1, sample_rate=100) + + +def test_wav_writer_rejects_boolean_sample_rate(tmp_path: Path) -> None: + """A Boolean sample rate must not be serialized as a 1 Hz WAV contract.""" + with pytest.raises(ValueError, match="sample_rate"): + write_pcm_wav( + tmp_path / "boolean-rate.wav", + np.zeros(4, dtype=np.float32), + cast(Any, True), + ) + + +@pytest.mark.parametrize("factory", [render_c_major_triad, render_click_track]) +def test_fixture_rejects_non_numeric_scalar_evidence(factory: Any) -> None: + """Non-numeric scalar evidence must use the documented ValueError boundary.""" + with pytest.raises(ValueError): + factory(duration_seconds=cast(Any, "1.0")) + + +def test_wav_writer_rejects_non_numeric_sample_rate(tmp_path: Path) -> None: + """A string sample rate must not reach the WAV encoder as implicit input.""" + with pytest.raises(ValueError, match="sample_rate"): + write_pcm_wav( + tmp_path / "string-rate.wav", + np.zeros(4, dtype=np.float32), + cast(Any, "22050"), + ) diff --git a/services/analysis-engine/tests/test_accuracy_manifest_string_contract.py b/services/analysis-engine/tests/test_accuracy_manifest_string_contract.py new file mode 100644 index 000000000..fb0fbff14 --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_manifest_string_contract.py @@ -0,0 +1,37 @@ +"""Regression tests for accuracy-report string evidence authority.""" + +from __future__ import annotations + +import pytest + +from bandscope_analysis.accuracy import build_case_report, parse_case_report + + +@pytest.mark.parametrize( + ("field_name", "invalid_value"), + [ + ("case_id", " "), + ("case_id", " c-major-triad "), + ("metric_name", " "), + ("metric_name", " tempo_acc1 "), + ("true_label", " "), + ("true_label", " C "), + ], +) +def test_accuracy_report_rejects_blank_or_padded_string_evidence( + field_name: str, + invalid_value: str, +) -> None: + """Identifiers and truth labels must be exact non-blank evidence strings.""" + valid = build_case_report( + case_id="c-major-triad", + audio_sha256="a" * 64, + metric_name="duration_weighted_chord_recall", + metric_value=0.9, + passed=True, + true_label="C", + engine_version="0.1.3", + ) + + with pytest.raises(ValueError, match=field_name): + parse_case_report({**valid, field_name: invalid_value}) diff --git a/services/analysis-engine/tests/test_accuracy_manifest_version.py b/services/analysis-engine/tests/test_accuracy_manifest_version.py new file mode 100644 index 000000000..62c8f0ea3 --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_manifest_version.py @@ -0,0 +1,49 @@ +"""Regression tests for accuracy-report engine-version provenance.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bandscope_analysis.accuracy import build_case_report, parse_case_report, read_product_version + + +def test_missing_or_empty_product_version_fails_closed(tmp_path: Path) -> None: + """Accuracy evidence must not publish an ``unknown`` engine version.""" + missing_tree = tmp_path / "missing-tree" + missing_tree.mkdir() + with pytest.raises(ValueError, match="VERSION"): + read_product_version(missing_tree) + + empty_tree = tmp_path / "empty-tree" + empty_tree.mkdir() + (empty_tree / "VERSION").write_text(" \n", encoding="utf-8") + with pytest.raises(ValueError, match="VERSION"): + read_product_version(empty_tree) + + +@pytest.mark.parametrize("engine_version", ["unknown", "UNKNOWN", " 0.1.3 "]) +def test_inexact_engine_version_is_rejected_at_report_boundary(engine_version: str) -> None: + """Unknown or whitespace-obscured versions must not become valid evidence.""" + report = { + "case_id": "c-major-triad", + "audio_sha256": "a" * 64, + "metric_name": "duration_weighted_chord_recall", + "metric_value": 0.9, + "passed": True, + "engine_version": engine_version, + "true_label": "C", + } + with pytest.raises(ValueError, match="engine_version"): + parse_case_report(report) + with pytest.raises(ValueError, match="engine_version"): + build_case_report( + case_id="c-major-triad", + audio_sha256="a" * 64, + metric_name="duration_weighted_chord_recall", + metric_value=0.9, + passed=True, + true_label="C", + engine_version=engine_version, + ) diff --git a/services/analysis-engine/tests/test_accuracy_metric_interval_contract.py b/services/analysis-engine/tests/test_accuracy_metric_interval_contract.py new file mode 100644 index 000000000..814b12790 --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_metric_interval_contract.py @@ -0,0 +1,28 @@ +"""Regression tests for MIR acceptance interval authority.""" + +from __future__ import annotations + +import pytest + +from bandscope_analysis.accuracy import duration_weighted_chord_recall + + +@pytest.mark.parametrize( + ("segment_start", "segment_end"), + [ + (2.0, 2.0), + (3.0, 2.0), + ], +) +def test_duration_weighted_recall_rejects_empty_or_reversed_estimate_intervals( + segment_start: float, + segment_end: float, +) -> None: + """Malformed estimate intervals must fail closed before acceptance scoring.""" + with pytest.raises(ValueError, match="segment end_seconds must be greater than start_seconds"): + duration_weighted_chord_recall( + [(segment_start, segment_end, "C")], + "C", + 0.0, + 4.0, + ) diff --git a/services/analysis-engine/tests/test_accuracy_metric_overflow_contract.py b/services/analysis-engine/tests/test_accuracy_metric_overflow_contract.py new file mode 100644 index 000000000..6177df77e --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_metric_overflow_contract.py @@ -0,0 +1,37 @@ +"""Regression tests for accuracy-report numeric overflow authority.""" + +from __future__ import annotations + +import pytest + +from bandscope_analysis.accuracy import build_case_report, parse_case_report + + +def test_accuracy_report_rejects_integer_metric_that_overflows_float() -> None: + """Huge integer metrics must fail closed instead of escaping validation.""" + valid = build_case_report( + case_id="c-major-triad", + audio_sha256="a" * 64, + metric_name="duration_weighted_chord_recall", + metric_value=0.9, + passed=True, + true_label="C", + engine_version="0.1.3", + ) + + with pytest.raises(ValueError, match="metric_value"): + parse_case_report({**valid, "metric_value": 10**400}) + + +def test_build_case_report_rejects_boolean_metric_evidence() -> None: + """Builder coercion must not turn Boolean evidence into a numeric score.""" + with pytest.raises(ValueError, match="metric_value"): + build_case_report( + case_id="c-major-triad", + audio_sha256="a" * 64, + metric_name="duration_weighted_chord_recall", + metric_value=True, + passed=True, + true_label="C", + engine_version="0.1.3", + ) diff --git a/services/analysis-engine/tests/test_accuracy_pcm_input_contract.py b/services/analysis-engine/tests/test_accuracy_pcm_input_contract.py new file mode 100644 index 000000000..fdf22c61b --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_pcm_input_contract.py @@ -0,0 +1,42 @@ +"""Fail-closed decoded-PCM evidence contract for accuracy acceptance.""" + +from __future__ import annotations + +from typing import Any, cast + +import numpy as np +import pytest + +from bandscope_analysis.accuracy import evaluate_c_major_pcm, render_c_major_triad + + +@pytest.mark.parametrize("sample_rate", [True, "22050", 0, -1, float("nan"), float("inf")]) +def test_c_major_pcm_rejects_invalid_sample_rate(sample_rate: object) -> None: + """Invalid rate evidence must fail before production recognition or division.""" + audio = render_c_major_triad(duration_seconds=0.25) + + with pytest.raises(ValueError, match="sample_rate"): + evaluate_c_major_pcm(audio, cast(Any, sample_rate), "a" * 64) + + +def test_c_major_pcm_rejects_non_mono_audio() -> None: + """Accuracy acceptance must not reinterpret multichannel arrays as mono PCM.""" + stereo = np.zeros((32, 2), dtype=np.float32) + + with pytest.raises(ValueError, match="audio"): + evaluate_c_major_pcm(stereo, 22_050, "a" * 64) + + +@pytest.mark.parametrize( + "audio", + [ + np.zeros(0, dtype=np.float32), + np.array([0.0, np.nan], dtype=np.float32), + np.array([0.0, np.inf], dtype=np.float32), + np.zeros(32, dtype=np.int16), + ], +) +def test_c_major_pcm_rejects_malformed_decoded_audio(audio: np.ndarray) -> None: + """Empty, non-finite, or non-floating decoded evidence must fail closed.""" + with pytest.raises(ValueError, match="audio"): + evaluate_c_major_pcm(audio, 22_050, "a" * 64) diff --git a/services/analysis-engine/tests/test_accuracy_same_byte_provenance.py b/services/analysis-engine/tests/test_accuracy_same_byte_provenance.py new file mode 100644 index 000000000..771dbad07 --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_same_byte_provenance.py @@ -0,0 +1,95 @@ +"""Same-byte provenance regressions for real-audio accuracy evidence.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from bandscope_analysis.accuracy import ( + DEFAULT_CLICK_BPM, + DEFAULT_SAMPLE_RATE, + assert_fixture_checksum, + evaluate_c_major_file, + evaluate_click_tempo_file, + render_c_major_triad, + render_click_track, + write_pcm_wav, +) + + +def _replace_after_first_read( + monkeypatch: pytest.MonkeyPatch, + target: Path, + replacement_bytes: bytes, +) -> None: + """Replace ``target`` immediately after its first ``Path.read_bytes`` snapshot.""" + original_read_bytes = Path.read_bytes + replaced = False + + def read_bytes(path: Path) -> bytes: + nonlocal replaced + payload = original_read_bytes(path) + if path == target and not replaced: + replaced = True + path.write_bytes(replacement_bytes) + return payload + + monkeypatch.setattr(Path, "read_bytes", read_bytes) + + +def test_assert_fixture_checksum_accepts_matching_snapshot(tmp_path: Path) -> None: + """The compatibility checksum boundary must remain directly executable.""" + target = tmp_path / "matching.wav" + digest = write_pcm_wav(target, render_c_major_triad(), DEFAULT_SAMPLE_RATE) + + assert_fixture_checksum(target, digest) + + +def test_c_major_file_scores_the_bytes_that_satisfied_checksum( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Checksum evidence and chord scoring must bind to one immutable byte snapshot.""" + target = tmp_path / "c-major.wav" + digest = write_pcm_wav(target, render_c_major_triad(), DEFAULT_SAMPLE_RATE) + + replacement = tmp_path / "silence.wav" + write_pcm_wav( + replacement, + np.zeros(DEFAULT_SAMPLE_RATE * 3, dtype=np.float32), + DEFAULT_SAMPLE_RATE, + ) + _replace_after_first_read(monkeypatch, target, replacement.read_bytes()) + + report = evaluate_c_major_file(target, digest) + + assert report["audio_sha256"] == digest + assert report["passed"] is True + + +def test_click_file_scores_the_bytes_that_satisfied_checksum( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Checksum evidence and tempo scoring must bind to one immutable byte snapshot.""" + target = tmp_path / "click.wav" + digest = write_pcm_wav( + target, + render_click_track(bpm=DEFAULT_CLICK_BPM), + DEFAULT_SAMPLE_RATE, + ) + + replacement = tmp_path / "silence.wav" + write_pcm_wav( + replacement, + np.zeros(DEFAULT_SAMPLE_RATE * 8, dtype=np.float32), + DEFAULT_SAMPLE_RATE, + ) + _replace_after_first_read(monkeypatch, target, replacement.read_bytes()) + + report = evaluate_click_tempo_file(target, digest, DEFAULT_CLICK_BPM) + + assert report["audio_sha256"] == digest + assert report["passed"] is True diff --git a/services/analysis-engine/tests/test_accuracy_tempo_case_identity.py b/services/analysis-engine/tests/test_accuracy_tempo_case_identity.py new file mode 100644 index 000000000..c39264638 --- /dev/null +++ b/services/analysis-engine/tests/test_accuracy_tempo_case_identity.py @@ -0,0 +1,26 @@ +"""Regression contract for tempo accuracy report identity.""" + +from __future__ import annotations + +from pathlib import Path + +from bandscope_analysis.accuracy import ( + DEFAULT_CLICK_BPM, + DEFAULT_SAMPLE_RATE, + evaluate_click_tempo_file, + render_click_track, + write_pcm_wav, +) + + +def test_click_report_case_id_matches_registered_true_tempo(tmp_path: Path) -> None: + """A non-default truth label must not be published under the 120 BPM case ID.""" + audio = render_click_track(bpm=DEFAULT_CLICK_BPM, duration_seconds=8.0) + path = tmp_path / "click-wrong-label.wav" + digest = write_pcm_wav(path, audio, DEFAULT_SAMPLE_RATE) + + report = evaluate_click_tempo_file(path, digest, true_bpm=40.0) + + assert report["case_id"] == "click-40-bpm" + assert report["true_label"] == "40 bpm" + assert report["passed"] is False diff --git a/services/analysis-engine/uv.lock b/services/analysis-engine/uv.lock index 47f7be6ef..f8a4d830b 100644 --- a/services/analysis-engine/uv.lock +++ b/services/analysis-engine/uv.lock @@ -1,9 +1,11 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version < '3.13'", + "python_full_version >= '3.13' and platform_machine == 'arm64'", + "python_full_version >= '3.13' and platform_machine != 'arm64'", + "python_full_version < '3.13' and platform_machine == 'arm64'", + "python_full_version < '3.13' and platform_machine != 'arm64'", ] [[package]] @@ -103,7 +105,8 @@ source = { editable = "." } dependencies = [ { name = "demucs", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'" }, { name = "librosa" }, - { name = "numba" }, + { name = "numba", version = "0.62.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'arm64'" }, + { name = "numba", version = "0.66.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'arm64'" }, { name = "numpy" }, { name = "soundfile" }, { name = "urllib3" }, @@ -123,7 +126,8 @@ dev = [ requires-dist = [ { name = "demucs", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'", specifier = ">=4.0.1" }, { name = "librosa", specifier = ">=0.11.0" }, - { name = "numba", specifier = "<0.67.0" }, + { name = "numba", marker = "platform_machine != 'arm64'", specifier = "==0.62.1" }, + { name = "numba", marker = "platform_machine == 'arm64'", specifier = "==0.66.0" }, { name = "numpy", specifier = ">=1.26" }, { name = "soundfile", specifier = ">=0.13.1" }, { name = "urllib3", specifier = ">=2.7.0" }, @@ -637,7 +641,8 @@ dependencies = [ { name = "joblib" }, { name = "lazy-loader" }, { name = "msgpack" }, - { name = "numba" }, + { name = "numba", version = "0.62.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'arm64'" }, + { name = "numba", version = "0.66.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'arm64'" }, { name = "numpy" }, { name = "pooch" }, { name = "scikit-learn" }, @@ -717,6 +722,10 @@ wheels = [ name = "llvmlite" version = "0.45.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine != 'arm64'", + "python_full_version < '3.13' and platform_machine != 'arm64'", +] sdist = { url = "https://files.pythonhosted.org/packages/99/8d/5baf1cef7f9c084fb35a8afbde88074f0d6a727bc63ef764fe0e7543ba40/llvmlite-0.45.1.tar.gz", hash = "sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32", size = 185600, upload-time = "2025-10-01T17:59:52.046Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e2/7c/82cbd5c656e8991bcc110c69d05913be2229302a92acb96109e166ae31fb/llvmlite-0.45.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:28e763aba92fe9c72296911e040231d486447c01d4f90027c8e893d89d49b20e", size = 43043524, upload-time = "2025-10-01T18:03:30.666Z" }, @@ -731,6 +740,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/56/ed35668130e32dbfad2eb37356793b0a95f23494ab5be7d9bf5cb75850ee/llvmlite-0.45.1-cp313-cp313-win_amd64.whl", hash = "sha256:080e6f8d0778a8239cd47686d402cb66eb165e421efa9391366a9b7e5810a38b", size = 38132232, upload-time = "2025-10-01T18:05:14.477Z" }, ] +[[package]] +name = "llvmlite" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'arm64'", + "python_full_version < '3.13' and platform_machine == 'arm64'", +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/a0/acc8ffcd5bdc63df0097e22c719bfcd61b604358343089313a8aebbb24ab/llvmlite-0.48.0.tar.gz", hash = "sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2", size = 184016, upload-time = "2026-07-02T20:20:05.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/a2/28696a9e61e245d1a79816d29d106692a90a2b6e7d78c98b326db70827af/llvmlite-0.48.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:d66c3beb4209087ddd4cf4ed2a0856b6887e6a913bdcf1aacfec9851cf2cba4e", size = 40480651, upload-time = "2026-07-01T18:41:35.694Z" }, + { url = "https://files.pythonhosted.org/packages/3a/27/5ae2f3722606360480707adb47f001ad89df8251d06b14ee80336e660b66/llvmlite-0.48.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5e5a5131045b72345c71062ea1a91910dde913792b6c9b28ebb2c1c0a712e98", size = 58343459, upload-time = "2026-07-01T18:41:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/9c/23/fe9316d14626b42c73ef0b502e724705a6ee9450afe53759c0a99c37c2d7/llvmlite-0.48.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a83a99ef0c05b4ccddf9b6218ed9fe84b653a0caf7c1d9dbe148d6d16c67f518", size = 40480652, upload-time = "2026-07-01T18:41:52.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/5e/7b3e20d64650ca3c80af0cdb664ec4b575ec83d9d4dd05bea8bd31f9bbb6/llvmlite-0.48.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fe5cb59b2063bfa039dcb8ca6481c0181bf552f340d10dcf61d7996a665556e", size = 58343457, upload-time = "2026-07-01T18:41:56.41Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/8170f2e0c217f88069c333d85bb976e536b332aecfcce606ddbdb249385f/llvmlite-0.48.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:321f1ac39b462603f0b589751aecf2d237d056f6d005749c1752b6f23ec3f074", size = 40480650, upload-time = "2026-07-01T18:42:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c3/470b8c4ff9ae2db2f9cf5c3e73de76ed908a32788ae9eb5602d43e6a476b/llvmlite-0.48.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d66fae72802175b0bfe1ea06e624b51e2d7aee6c3c34bbd09739b8f88e8e0b", size = 58343457, upload-time = "2026-07-01T18:42:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/94/e3/7a93e09c9f94e637ca90209ceef0334a9a1d45b0bdb7c92ff922d25d6187/llvmlite-0.48.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7a5c413317050a1d67c34708bde97707f9b2257ef1017f7532d21fe7d9a9ff30", size = 40480654, upload-time = "2026-07-01T18:42:25.076Z" }, + { url = "https://files.pythonhosted.org/packages/1a/cf/7aac11a1f1c7ec54b60c7f6814e87561fb6b55b2f290455d7941eb113420/llvmlite-0.48.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:054aa7d46595935565f276cf0c1659b4f10929c996dd4a606875fae26fba2a23", size = 58343460, upload-time = "2026-07-01T18:42:29.545Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -749,7 +778,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, @@ -760,7 +788,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, @@ -771,7 +798,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, @@ -782,7 +808,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, @@ -793,7 +818,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, @@ -931,9 +955,13 @@ wheels = [ name = "numba" version = "0.62.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine != 'arm64'", + "python_full_version < '3.13' and platform_machine != 'arm64'", +] dependencies = [ - { name = "llvmlite" }, - { name = "numpy" }, + { name = "llvmlite", version = "0.45.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'arm64'" }, + { name = "numpy", marker = "platform_machine != 'arm64'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/20/33dbdbfe60e5fd8e3dbfde299d106279a33d9f8308346022316781368591/numba-0.62.1.tar.gz", hash = "sha256:7b774242aa890e34c21200a1fc62e5b5757d5286267e71103257f4e2af0d5161", size = 2749817, upload-time = "2025-09-29T10:46:31.551Z" } wheels = [ @@ -949,6 +977,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/ec/9d414e7a80d6d1dc4af0e07c6bfe293ce0b04ea4d0ed6c45dad9bd6e72eb/numba-0.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:bbf3f88b461514287df66bc8d0307e949b09f2b6f67da92265094e8fa1282dd8", size = 2745529, upload-time = "2025-09-29T10:44:31.738Z" }, ] +[[package]] +name = "numba" +version = "0.66.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'arm64'", + "python_full_version < '3.13' and platform_machine == 'arm64'", +] +dependencies = [ + { name = "llvmlite", version = "0.48.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'arm64'" }, + { name = "numpy", marker = "platform_machine == 'arm64'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/a0/570e3dc53e5602b49108f62a13e529f1eec8bfc7ef37d49c825924dcf546/numba-0.66.0.tar.gz", hash = "sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363", size = 2806181, upload-time = "2026-07-01T23:12:46.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a3/70deb7f88461c1cd5d16aa990c2380604102661a427667b8950dcdccc27f/numba-0.66.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:53ca5900b7cab15109796030113a6b28576bae5ad7bb507ad6dd1360ddd81ba4", size = 2727264, upload-time = "2026-07-01T23:12:18.669Z" }, + { url = "https://files.pythonhosted.org/packages/71/ef/a82d6fd6bf1b0fe461651e924d3647eeec9ac17f8eee4896264bf7480930/numba-0.66.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efe0d2d5099790df945e0cb6e1b3104bd965d7bbfac50d62f1d5d1d6ade0825d", size = 3566974, upload-time = "2026-07-01T23:12:22.116Z" }, + { url = "https://files.pythonhosted.org/packages/03/52/176c02d005c5c5143cde10a85bbcdcb6236d9e34c3aac089380e0506cd1d/numba-0.66.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:380b2556a2019ccd1e956ae77dd257eaa39403f7520768b626d44b755112785e", size = 2727084, upload-time = "2026-07-01T23:12:25.434Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ec/5b51457cbe96e4831141d83e892e65191b23a1b78728456c62909d231ace/numba-0.66.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdf506775d9f02eb92a87bf5c5b1e0d25506fd18cafd769f4ed914a8feac73e7", size = 3573529, upload-time = "2026-07-01T23:12:28.944Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/7e0e73550eb4e41ede6e72fb5371f4539537a4d770a3b73fa9b61aea0622/numba-0.66.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:46ae5f2b19e2af3c33c2df100306a90ea2f981c8158b0390f8bf6c20eee7357e", size = 2727296, upload-time = "2026-07-01T23:12:32.39Z" }, + { url = "https://files.pythonhosted.org/packages/93/99/edebf7de890b73973d839dd971cf73734adfb81ffa1b4504f84b9059c3e5/numba-0.66.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63b943eb2c9ba371908ce2cd6dfc643db51fc40f7966993376a1701bc922f537", size = 3543537, upload-time = "2026-07-01T23:12:35.566Z" }, + { url = "https://files.pythonhosted.org/packages/10/6f/5e77a7397a37dd16f57a7b72e7e470db5227b68e3639df0d13a8e674883d/numba-0.66.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:db7735d15ea17a283d6485b9fa3504769f78fd86e5146638ad5e8da57c031b9e", size = 2730342, upload-time = "2026-07-01T23:12:38.758Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/9b363287b85fcd4537ea3878793822878b2ac1008a78159d2096fea628de/numba-0.66.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c1144ba1720ea59ad79f4f488ed54d149b2613b357f7e445678b7d0739c70e9", size = 3596323, upload-time = "2026-07-01T23:12:42.805Z" }, +] + [[package]] name = "numpy" version = "2.3.5"