diff --git a/corpus/fixtures/registral_regression/cluster_middle_register.musicxml b/corpus/fixtures/registral_regression/cluster_middle_register.musicxml new file mode 100644 index 0000000..8753daa --- /dev/null +++ b/corpus/fixtures/registral_regression/cluster_middle_register.musicxml @@ -0,0 +1,78 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Cluster + + + + + + + + 10080 + + + G + 2 + + + + + C + 4 + + 20160 + half + + + + + D + 4 + + 20160 + half + + + + + E + 4 + + 20160 + half + + + + + F + 4 + + 20160 + half + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/high_register_concentration.musicxml b/corpus/fixtures/registral_regression/high_register_concentration.musicxml new file mode 100644 index 0000000..0c1e2ca --- /dev/null +++ b/corpus/fixtures/registral_regression/high_register_concentration.musicxml @@ -0,0 +1,69 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + High cluster + + + + + + + + 10080 + + + G + 2 + + + + + C + 6 + + 20160 + half + + + + + E + 6 + + 20160 + half + + + + + G + 6 + + 20160 + half + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/low_register_concentration.musicxml b/corpus/fixtures/registral_regression/low_register_concentration.musicxml new file mode 100644 index 0000000..2233979 --- /dev/null +++ b/corpus/fixtures/registral_regression/low_register_concentration.musicxml @@ -0,0 +1,69 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Low cluster + + + + + + + + 10080 + + + F + 4 + + + + + C + 2 + + 20160 + half + + + + + E + 2 + + 20160 + half + + + + + G + 2 + + 20160 + half + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/registral_contraction.musicxml b/corpus/fixtures/registral_regression/registral_contraction.musicxml new file mode 100644 index 0000000..88145e4 --- /dev/null +++ b/corpus/fixtures/registral_regression/registral_contraction.musicxml @@ -0,0 +1,122 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Contraction + + + + + + + + 10080 + + + G + 2 + + + + + C + 3 + + 10080 + quarter + + + + + C + 5 + + 10080 + quarter + + + + G + 3 + + 10080 + quarter + + + + + G + 4 + + 10080 + quarter + + + + A + 3 + + 10080 + quarter + + + + + E + 4 + + 10080 + quarter + + + + B + 3 + + 10080 + quarter + + + + + D + 4 + + 10080 + quarter + + + + + + + C + 4 + + 10080 + quarter + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/registral_expansion.musicxml b/corpus/fixtures/registral_regression/registral_expansion.musicxml new file mode 100644 index 0000000..1f4d2b0 --- /dev/null +++ b/corpus/fixtures/registral_regression/registral_expansion.musicxml @@ -0,0 +1,122 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Expansion + + + + + + + + 10080 + + + G + 2 + + + + + C + 4 + + 10080 + quarter + + + + B + 3 + + 10080 + quarter + + + + + D + 4 + + 10080 + quarter + + + + A + 3 + + 10080 + quarter + + + + + E + 4 + + 10080 + quarter + + + + G + 3 + + 10080 + quarter + + + + + G + 4 + + 10080 + quarter + + + + + + + C + 3 + + 10080 + quarter + + + + + C + 5 + + 10080 + quarter + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/same_span_filled_middle.musicxml b/corpus/fixtures/registral_regression/same_span_filled_middle.musicxml new file mode 100644 index 0000000..b9385c1 --- /dev/null +++ b/corpus/fixtures/registral_regression/same_span_filled_middle.musicxml @@ -0,0 +1,96 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Filled middle + + + + + + + + 10080 + + + G + 2 + + + + + C + 3 + + 20160 + half + + + + + G + 3 + + 20160 + half + + + + + C + 4 + + 20160 + half + + + + + E + 4 + + 20160 + half + + + + + G + 4 + + 20160 + half + + + + + C + 5 + + 20160 + half + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/same_span_sparse_extremes.musicxml b/corpus/fixtures/registral_regression/same_span_sparse_extremes.musicxml new file mode 100644 index 0000000..aaa7fb5 --- /dev/null +++ b/corpus/fixtures/registral_regression/same_span_sparse_extremes.musicxml @@ -0,0 +1,60 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Sparse extremes + + + + + + + + 10080 + + + G + 2 + + + + + C + 3 + + 20160 + half + + + + + C + 5 + + 20160 + half + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/unison_register.musicxml b/corpus/fixtures/registral_regression/unison_register.musicxml new file mode 100644 index 0000000..39d5bea --- /dev/null +++ b/corpus/fixtures/registral_regression/unison_register.musicxml @@ -0,0 +1,144 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Unison 1 + + + Unison 2 + + + Unison 3 + + + Unison 4 + + + + + + + + 10080 + + + G + 2 + + + + + C + 4 + + 20160 + half + + + light-heavy + + + + + + + + + 10080 + + + G + 2 + + + + + C + 4 + + 20160 + half + + + light-heavy + + + + + + + + + 10080 + + + G + 2 + + + + + C + 4 + + 20160 + half + + + light-heavy + + + + + + + + + 10080 + + + G + 2 + + + + + C + 4 + + 20160 + half + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/fixtures/registral_regression/wide_bipolar_register.musicxml b/corpus/fixtures/registral_regression/wide_bipolar_register.musicxml new file mode 100644 index 0000000..44acfe0 --- /dev/null +++ b/corpus/fixtures/registral_regression/wide_bipolar_register.musicxml @@ -0,0 +1,60 @@ + + + + Music21 Fragment + + Music21 + + 2026-06-06 + music21 v.9.9.1 + + + + + 7 + 40 + + + + + Bipolar + + + + + + + + 10080 + + + G + 2 + + + + + C + 2 + + 20160 + half + + + + + C + 6 + + 20160 + half + + + light-heavy + + + + \ No newline at end of file diff --git a/corpus/reports/registral_regression_inspection.json b/corpus/reports/registral_regression_inspection.json new file mode 100644 index 0000000..a0be934 --- /dev/null +++ b/corpus/reports/registral_regression_inspection.json @@ -0,0 +1,251 @@ +{ + "phase": "1_qualitative", + "analysis_params": { + "observation_mode": "event_boundaries", + "analysis_profile": "occupied_space", + "register_low": "A0", + "register_high": "C8", + "time_step": 0.25, + "window_size": 4.0, + "tie_policy": "as_imported" + }, + "fixtures": [ + { + "fixture": "cluster_middle_register", + "path": "corpus\\fixtures\\registral_regression\\cluster_middle_register.musicxml", + "rows": [ + { + "t": 1.0, + "registral_span": 5.0, + "mean_pairwise_registral_distance": 2.833333333333333, + "occupancy_entropy": 0.3096247654715364, + "active_note_count": 4 + } + ], + "span_min": 5.0, + "span_max": 5.0, + "concentration": { + "mean_active_midi": 62.75, + "min_active_midi": 60, + "max_active_midi": 65 + } + }, + { + "fixture": "high_register_concentration", + "path": "corpus\\fixtures\\registral_regression\\high_register_concentration.musicxml", + "rows": [ + { + "t": 1.0, + "registral_span": 7.0, + "mean_pairwise_registral_distance": 4.666666666666666, + "occupancy_entropy": 0.24537182128348387, + "active_note_count": 3 + } + ], + "span_min": 7.0, + "span_max": 7.0, + "concentration": { + "mean_active_midi": 87.66666666666667, + "min_active_midi": 84, + "max_active_midi": 91 + } + }, + { + "fixture": "low_register_concentration", + "path": "corpus\\fixtures\\registral_regression\\low_register_concentration.musicxml", + "rows": [ + { + "t": 1.0, + "registral_span": 7.0, + "mean_pairwise_registral_distance": 4.666666666666666, + "occupancy_entropy": 0.24537182128348387, + "active_note_count": 3 + } + ], + "span_min": 7.0, + "span_max": 7.0, + "concentration": { + "mean_active_midi": 39.666666666666664, + "min_active_midi": 36, + "max_active_midi": 43 + } + }, + { + "fixture": "registral_contraction", + "path": "corpus\\fixtures\\registral_regression\\registral_contraction.musicxml", + "rows": [ + { + "t": 0.5, + "registral_span": 24.0, + "mean_pairwise_registral_distance": 24.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + }, + { + "t": 1.5, + "registral_span": 12.0, + "mean_pairwise_registral_distance": 12.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + }, + { + "t": 2.5, + "registral_span": 7.0, + "mean_pairwise_registral_distance": 7.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + }, + { + "t": 3.5, + "registral_span": 3.0, + "mean_pairwise_registral_distance": 3.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + }, + { + "t": 4.5, + "registral_span": 0.0, + "mean_pairwise_registral_distance": 0.0, + "occupancy_entropy": 0.0, + "active_note_count": 1 + } + ], + "span_min": 0.0, + "span_max": 24.0, + "concentration": { + "mean_active_midi": 60.44444444444444, + "min_active_midi": 48, + "max_active_midi": 72 + } + }, + { + "fixture": "registral_expansion", + "path": "corpus\\fixtures\\registral_regression\\registral_expansion.musicxml", + "rows": [ + { + "t": 0.5, + "registral_span": 0.0, + "mean_pairwise_registral_distance": 0.0, + "occupancy_entropy": 0.0, + "active_note_count": 1 + }, + { + "t": 1.5, + "registral_span": 3.0, + "mean_pairwise_registral_distance": 3.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + }, + { + "t": 2.5, + "registral_span": 7.0, + "mean_pairwise_registral_distance": 7.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + }, + { + "t": 3.5, + "registral_span": 12.0, + "mean_pairwise_registral_distance": 12.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + }, + { + "t": 4.5, + "registral_span": 24.0, + "mean_pairwise_registral_distance": 24.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + } + ], + "span_min": 0.0, + "span_max": 24.0, + "concentration": { + "mean_active_midi": 60.44444444444444, + "min_active_midi": 48, + "max_active_midi": 72 + } + }, + { + "fixture": "same_span_filled_middle", + "path": "corpus\\fixtures\\registral_regression\\same_span_filled_middle.musicxml", + "rows": [ + { + "t": 1.0, + "registral_span": 24.0, + "mean_pairwise_registral_distance": 10.666666666666666, + "occupancy_entropy": 0.40018420401925203, + "active_note_count": 6 + } + ], + "span_min": 24.0, + "span_max": 24.0, + "concentration": { + "mean_active_midi": 61.0, + "min_active_midi": 48, + "max_active_midi": 72 + } + }, + { + "fixture": "same_span_sparse_extremes", + "path": "corpus\\fixtures\\registral_regression\\same_span_sparse_extremes.musicxml", + "rows": [ + { + "t": 1.0, + "registral_span": 24.0, + "mean_pairwise_registral_distance": 24.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + } + ], + "span_min": 24.0, + "span_max": 24.0, + "concentration": { + "mean_active_midi": 60.0, + "min_active_midi": 48, + "max_active_midi": 72 + } + }, + { + "fixture": "unison_register", + "path": "corpus\\fixtures\\registral_regression\\unison_register.musicxml", + "rows": [ + { + "t": 1.0, + "registral_span": 0.0, + "mean_pairwise_registral_distance": 0.0, + "occupancy_entropy": 0.0, + "active_note_count": 1 + } + ], + "span_min": 0.0, + "span_max": 0.0, + "concentration": { + "mean_active_midi": 60.0, + "min_active_midi": 60, + "max_active_midi": 60 + } + }, + { + "fixture": "wide_bipolar_register", + "path": "corpus\\fixtures\\registral_regression\\wide_bipolar_register.musicxml", + "rows": [ + { + "t": 1.0, + "registral_span": 48.0, + "mean_pairwise_registral_distance": 48.0, + "occupancy_entropy": 0.1548123827357682, + "active_note_count": 2 + } + ], + "span_min": 48.0, + "span_max": 48.0, + "concentration": { + "mean_active_midi": 60.0, + "min_active_midi": 36, + "max_active_midi": 84 + } + } + ], + "note": "Exploratory only; not golden regression baselines." +} \ No newline at end of file diff --git a/corpus/reports/registral_regression_inspection.md b/corpus/reports/registral_regression_inspection.md new file mode 100644 index 0000000..209d413 --- /dev/null +++ b/corpus/reports/registral_regression_inspection.md @@ -0,0 +1,83 @@ +# Registral regression fixture inspection (Phase 1) + +Exploratory report generated by `corpus/scripts/inspect_registral_regression.py`. +Values are **not** strict golden references. + +## Analysis parameters + +```json +{ + "observation_mode": "event_boundaries", + "analysis_profile": "occupied_space", + "register_low": "A0", + "register_high": "C8", + "time_step": 0.25, + "window_size": 4.0, + "tie_policy": "as_imported" +} +``` + +## Fixture summaries + +### `cluster_middle_register` +- span range: 5.0 … 5.0 +- concentration map (active MIDI): mean=62.75, min=60, max=65 +- per-row metrics: + - t=1.00: span=5.0, pairwise=2.83, entropy=0.310, n=4 + +### `high_register_concentration` +- span range: 7.0 … 7.0 +- concentration map (active MIDI): mean=87.66666666666667, min=84, max=91 +- per-row metrics: + - t=1.00: span=7.0, pairwise=4.67, entropy=0.245, n=3 + +### `low_register_concentration` +- span range: 7.0 … 7.0 +- concentration map (active MIDI): mean=39.666666666666664, min=36, max=43 +- per-row metrics: + - t=1.00: span=7.0, pairwise=4.67, entropy=0.245, n=3 + +### `registral_contraction` +- span range: 0.0 … 24.0 +- concentration map (active MIDI): mean=60.44444444444444, min=48, max=72 +- per-row metrics: + - t=0.50: span=24.0, pairwise=24.00, entropy=0.155, n=2 + - t=1.50: span=12.0, pairwise=12.00, entropy=0.155, n=2 + - t=2.50: span=7.0, pairwise=7.00, entropy=0.155, n=2 + - t=3.50: span=3.0, pairwise=3.00, entropy=0.155, n=2 + - t=4.50: span=0.0, pairwise=0.00, entropy=0.000, n=1 + +### `registral_expansion` +- span range: 0.0 … 24.0 +- concentration map (active MIDI): mean=60.44444444444444, min=48, max=72 +- per-row metrics: + - t=0.50: span=0.0, pairwise=0.00, entropy=0.000, n=1 + - t=1.50: span=3.0, pairwise=3.00, entropy=0.155, n=2 + - t=2.50: span=7.0, pairwise=7.00, entropy=0.155, n=2 + - t=3.50: span=12.0, pairwise=12.00, entropy=0.155, n=2 + - t=4.50: span=24.0, pairwise=24.00, entropy=0.155, n=2 + +### `same_span_filled_middle` +- span range: 24.0 … 24.0 +- concentration map (active MIDI): mean=61.0, min=48, max=72 +- per-row metrics: + - t=1.00: span=24.0, pairwise=10.67, entropy=0.400, n=6 + +### `same_span_sparse_extremes` +- span range: 24.0 … 24.0 +- concentration map (active MIDI): mean=60.0, min=48, max=72 +- per-row metrics: + - t=1.00: span=24.0, pairwise=24.00, entropy=0.155, n=2 + +### `unison_register` +- span range: 0.0 … 0.0 +- concentration map (active MIDI): mean=60.0, min=60, max=60 +- per-row metrics: + - t=1.00: span=0.0, pairwise=0.00, entropy=0.000, n=1 + +### `wide_bipolar_register` +- span range: 48.0 … 48.0 +- concentration map (active MIDI): mean=60.0, min=36, max=84 +- per-row metrics: + - t=1.00: span=48.0, pairwise=48.00, entropy=0.155, n=2 + diff --git a/corpus/scripts/create_registral_regression_fixtures.py b/corpus/scripts/create_registral_regression_fixtures.py new file mode 100644 index 0000000..4f87068 --- /dev/null +++ b/corpus/scripts/create_registral_regression_fixtures.py @@ -0,0 +1,120 @@ +"""Generate deterministic MusicXML fixtures for registral-regression qualitative tests.""" + +from __future__ import annotations + +from pathlib import Path + +from music21 import chord, note, stream + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "fixtures" / "registral_regression" + + +def _write(name: str, sc: stream.Score) -> Path: + FIXTURES.mkdir(parents=True, exist_ok=True) + path = FIXTURES / name + sc.write("musicxml", fp=str(path)) + return path + + +def build_unison_register() -> Path: + sc = stream.Score() + for i in range(4): + p = stream.Part(id=f"P{i + 1}") + p.partName = f"Unison {i + 1}" + p.insert(0, note.Note("C4", quarterLength=2.0)) + sc.insert(0, p) + return _write("unison_register.musicxml", sc) + + +def build_cluster_middle_register() -> Path: + p = stream.Part() + p.partName = "Cluster" + p.insert(0, chord.Chord(["C4", "D4", "E4", "F4"], quarterLength=2.0)) + return _write("cluster_middle_register.musicxml", stream.Score([p])) + + +def build_wide_bipolar_register() -> Path: + p = stream.Part() + p.partName = "Bipolar" + p.insert(0, chord.Chord(["C2", "C6"], quarterLength=2.0)) + return _write("wide_bipolar_register.musicxml", stream.Score([p])) + + +def _expansion_notes() -> list[tuple[float, stream.GeneralObject]]: + return [ + (0.0, note.Note("C4", quarterLength=1.0)), + (1.0, chord.Chord(["B3", "D4"], quarterLength=1.0)), + (2.0, chord.Chord(["A3", "E4"], quarterLength=1.0)), + (3.0, chord.Chord(["G3", "G4"], quarterLength=1.0)), + (4.0, chord.Chord(["C3", "C5"], quarterLength=1.0)), + ] + + +def build_registral_expansion() -> Path: + p = stream.Part() + p.partName = "Expansion" + for offset, el in _expansion_notes(): + p.insert(offset, el) + return _write("registral_expansion.musicxml", stream.Score([p])) + + +def build_registral_contraction() -> Path: + p = stream.Part() + p.partName = "Contraction" + for i, (_offset, el) in enumerate(reversed(_expansion_notes())): + p.insert(float(i), el) + return _write("registral_contraction.musicxml", stream.Score([p])) + + +def build_high_register_concentration() -> Path: + p = stream.Part() + p.partName = "High cluster" + p.insert(0, chord.Chord(["C6", "E6", "G6"], quarterLength=2.0)) + return _write("high_register_concentration.musicxml", stream.Score([p])) + + +def build_low_register_concentration() -> Path: + p = stream.Part() + p.partName = "Low cluster" + p.insert(0, chord.Chord(["C2", "E2", "G2"], quarterLength=2.0)) + return _write("low_register_concentration.musicxml", stream.Score([p])) + + +def build_same_span_sparse_extremes() -> Path: + p = stream.Part() + p.partName = "Sparse extremes" + p.insert(0, chord.Chord(["C3", "C5"], quarterLength=2.0)) + return _write("same_span_sparse_extremes.musicxml", stream.Score([p])) + + +def build_same_span_filled_middle() -> Path: + p = stream.Part() + p.partName = "Filled middle" + p.insert(0, chord.Chord(["C3", "G3", "C4", "E4", "G4", "C5"], quarterLength=2.0)) + return _write("same_span_filled_middle.musicxml", stream.Score([p])) + + +def build_all() -> dict[str, Path]: + builders = { + "unison_register": build_unison_register, + "cluster_middle_register": build_cluster_middle_register, + "wide_bipolar_register": build_wide_bipolar_register, + "registral_expansion": build_registral_expansion, + "registral_contraction": build_registral_contraction, + "high_register_concentration": build_high_register_concentration, + "low_register_concentration": build_low_register_concentration, + "same_span_sparse_extremes": build_same_span_sparse_extremes, + "same_span_filled_middle": build_same_span_filled_middle, + } + return {name: fn() for name, fn in builders.items()} + + +def main() -> None: + paths = build_all() + for name, path in paths.items(): + print(f"{name}: {path}") + + +if __name__ == "__main__": + main() diff --git a/corpus/scripts/inspect_registral_regression.py b/corpus/scripts/inspect_registral_regression.py new file mode 100644 index 0000000..fd6f7c8 --- /dev/null +++ b/corpus/scripts/inspect_registral_regression.py @@ -0,0 +1,155 @@ +"""Exploratory inspection report for registral-regression fixtures (Phase 1, qualitative).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +from registral_dispersion.concentration_map import build_registral_concentration_matrix +from registral_dispersion.observation import OBSERVATION_MODE_EVENT_BOUNDARIES +from registral_dispersion.profiles import ANALYSIS_PROFILE_OCCUPIED_SPACE +from registral_dispersion.service import run_registral_dispersion_analysis + +ROOT = Path(__file__).resolve().parent.parent +FIXTURES = ROOT / "fixtures" / "registral_regression" +REPORTS = ROOT / "reports" + +ANALYSIS_PARAMS = { + "observation_mode": OBSERVATION_MODE_EVENT_BOUNDARIES, + "analysis_profile": ANALYSIS_PROFILE_OCCUPIED_SPACE, + "register_low": "A0", + "register_high": "C8", + "time_step": 0.25, + "window_size": 4.0, + "tie_policy": "as_imported", +} + + +def _finite_spans(results: dict) -> list[float]: + spans = np.asarray(results["registral_span"], dtype=float) + return [float(x) for x in spans if np.isfinite(x)] + + +def _row_summary(results: dict) -> list[dict]: + rows = [] + n = len(results["t"]) + for i in range(n): + span = float(results["registral_span"][i]) + if not np.isfinite(span): + continue + rows.append( + { + "t": float(results["t"][i]), + "registral_span": span, + "mean_pairwise_registral_distance": float(results["mean_pairwise_registral_distance"][i]), + "occupancy_entropy": float(results["occupancy_entropy"][i]), + "active_note_count": int(results["active_note_count"][i]), + } + ) + return rows + + +def _concentration_location(path: Path) -> dict: + bundle = build_registral_concentration_matrix( + str(path), + 21.0, + 108.0, + time_bin_size=1.0, + concentration_mode="unique_pitch_heights", + ) + mat = np.asarray(bundle["matrix"], dtype=float) + mids = np.asarray(bundle["pitch_midi"], dtype=int) + active = mids[np.any(mat > 0, axis=1)] + if active.size == 0: + return {"mean_active_midi": None, "min_active_midi": None, "max_active_midi": None} + weights = mat[np.any(mat > 0, axis=1)].sum(axis=1) + mean_midi = float(np.average(active, weights=weights)) + return { + "mean_active_midi": mean_midi, + "min_active_midi": int(active.min()), + "max_active_midi": int(active.max()), + } + + +def inspect_fixture(path: Path) -> dict: + out = run_registral_dispersion_analysis(str(path), ANALYSIS_PARAMS) + if out.get("error"): + return {"fixture": path.name, "error": out["error"]} + results = out["results"] + spans = _finite_spans(results) + return { + "fixture": path.stem, + "path": str(path.relative_to(ROOT.parent)), + "rows": _row_summary(results), + "span_min": min(spans) if spans else None, + "span_max": max(spans) if spans else None, + "concentration": _concentration_location(path), + } + + +def build_report() -> dict: + entries = [] + for path in sorted(FIXTURES.glob("*.musicxml")): + entries.append(inspect_fixture(path)) + return { + "phase": "1_qualitative", + "analysis_params": ANALYSIS_PARAMS, + "fixtures": entries, + "note": "Exploratory only; not golden regression baselines.", + } + + +def write_markdown(report: dict, path: Path) -> None: + lines = [ + "# Registral regression fixture inspection (Phase 1)", + "", + "Exploratory report generated by `corpus/scripts/inspect_registral_regression.py`.", + "Values are **not** strict golden references.", + "", + "## Analysis parameters", + "", + "```json", + json.dumps(report["analysis_params"], indent=2), + "```", + "", + "## Fixture summaries", + "", + ] + for entry in report["fixtures"]: + lines.append(f"### `{entry['fixture']}`") + if entry.get("error"): + lines.append(f"- **Error:** {entry['error']}") + lines.append("") + continue + lines.append(f"- span range: {entry['span_min']} … {entry['span_max']}") + loc = entry["concentration"] + lines.append( + f"- concentration map (active MIDI): mean={loc['mean_active_midi']}, " + f"min={loc['min_active_midi']}, max={loc['max_active_midi']}" + ) + lines.append("- per-row metrics:") + for row in entry["rows"]: + lines.append( + f" - t={row['t']:.2f}: span={row['registral_span']:.1f}, " + f"pairwise={row['mean_pairwise_registral_distance']:.2f}, " + f"entropy={row['occupancy_entropy']:.3f}, n={row['active_note_count']}" + ) + lines.append("") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + REPORTS.mkdir(parents=True, exist_ok=True) + report = build_report() + json_path = REPORTS / "registral_regression_inspection.json" + md_path = REPORTS / "registral_regression_inspection.md" + json_path.write_text(json.dumps(report, indent=2), encoding="utf-8") + write_markdown(report, md_path) + print(f"Wrote {json_path}") + print(f"Wrote {md_path}") + + +if __name__ == "__main__": + main() diff --git a/docs/METRIC_SEMANTICS.md b/docs/METRIC_SEMANTICS.md index 29bda13..51cca0f 100644 --- a/docs/METRIC_SEMANTICS.md +++ b/docs/METRIC_SEMANTICS.md @@ -330,5 +330,5 @@ R = register_high_midi − register_low_midi - [README.md](../README.md) — install, CLI, defaults - [PARAMETERIZATION_GUIDE.md](PARAMETERIZATION_GUIDE.md) — advised presets for static, moving, and summarize workflows -- [benchmarks/README.md](../benchmarks/README.md) — synthetic regression fixtures (not perceptual validation) +- [REGISTRAL_REGRESSION_FIXTURES.md](REGISTRAL_REGRESSION_FIXTURES.md) — controlled MusicXML fixtures for qualitative metric invariants (Phase 1) - Export strings in `src/registral_dispersion/metric_documentation.py` (mirrored in CSV `#` comments and JSON) diff --git a/docs/REGISTRAL_REGRESSION_FIXTURES.md b/docs/REGISTRAL_REGRESSION_FIXTURES.md new file mode 100644 index 0000000..0965719 --- /dev/null +++ b/docs/REGISTRAL_REGRESSION_FIXTURES.md @@ -0,0 +1,91 @@ +# Registral regression fixtures (Phase 1) + +**Location:** `corpus/fixtures/registral_regression/` +**Generator:** `corpus/scripts/create_registral_regression_fixtures.py` +**Inspection report:** `corpus/reports/registral_regression_inspection.md` (exploratory, not golden) +**Tests:** `tests/test_registral_regression_fixtures.py` +**Metric semantics:** [METRIC_SEMANTICS.md](METRIC_SEMANTICS.md) + +--- + +## Purpose + +This suite provides **controlled, deterministic MusicXML fixtures** for **qualitative regression** of registral-dispersion behaviour. Each fixture encodes a musically interpretable registral situation (concentration, polarization, expansion, transposition invariance, same-span / different internal spacing). + +These fixtures are: + +- **symbolic-score-only** (parsed via **music21**); +- **not** acoustic, perceptual, or orchestration validation corpora; +- **Phase 1:** structural invariant tests only — **not** strict golden numeric baselines. + +Regenerate fixtures after intentional design changes: + +```bash +python corpus/scripts/create_registral_regression_fixtures.py +python corpus/scripts/inspect_registral_regression.py +python -m pytest tests/test_registral_regression_fixtures.py -q +``` + +--- + +## Standard analysis parameters (tests & inspection) + +| Parameter | Value | +|-----------|--------| +| `observation_mode` | `event_boundaries` | +| `analysis_profile` | `occupied_space` | +| `pitch_sampling_mode` (implied) | `unique_pitch_heights` | +| `register_low` / `register_high` | `A0` / `C8` | +| `tie_policy` | `as_imported` | + +Event-boundary mode gives one row per **constant active pitch set**, which suits directional fixtures (expansion / contraction). + +--- + +## Fixture catalogue + +| Fixture | Musical design | Expected metric behaviour (qualitative) | +|---------|----------------|----------------------------------------| +| **`unison_register`** | Four parts, all **C4** | Span **0**; pairwise **0**; entropy **minimal** (single occupied bin). Total registral concentration. | +| **`cluster_middle_register`** | Sustained **C4–F4** chromatic cluster | **Low** span; **low–moderate** pairwise; entropy **moderate** (several adjacent bins). Compact middle-register saturation. | +| **`wide_bipolar_register`** | **C2 + C6** (no middle fill) | **High** span and pairwise; entropy **moderate/low** (polarized extremes). **Largest span** in this suite. Concentration map: two separated bands. | +| **`registral_expansion`** | Sequential states C4 → … → C3–C5 | Span **increases** over time (monotonic in Phase 1 inspection). Pairwise generally increases. Map shows **widening** occupation. | +| **`registral_contraction`** | Reverse of expansion | Span **decreases** over time. Pairwise generally decreases. Map shows **narrowing** occupation. | +| **`high_register_concentration`** | **C6–E6–G6** cluster | Small–moderate span; activity in **high** MIDI rows (mean active MIDI ≫ middle C). | +| **`low_register_concentration`** | **C2–E2–G2** (parallel structure) | **Same** span, pairwise, and entropy as high fixture; **lower** absolute MIDI location on concentration map. Tests transposition invariance of dispersion vs location. | +| **`same_span_sparse_extremes`** | **C3 + C5** only | Span = **24** semitones; **high** pairwise (only extreme pair); **lower** entropy. | +| **`same_span_filled_middle`** | **C3, G3, C4, E4, G4, C5** | **Same** span as sparse; **lower** pairwise (internal fill); **higher** entropy and `active_note_count`. Demonstrates span alone is insufficient. | + +--- + +## What each metric family should show + +| Measure | These fixtures test | +|---------|---------------------| +| **`registral_span` / `dispersion_degree`** | Zero (unison); ordering cluster < wide; monotonic expansion/contraction; equality sparse vs filled. | +| **`mean_pairwise_registral_distance`** | Zero (unison); cluster < wide; filled < sparse at equal span; tracks expansion/contraction. | +| **`occupancy_entropy`** | Minimal (unison); filled > sparse at equal span; **not** interchangeable with span. | +| **Concentration map** | High vs low register location; bipolar two-band pattern (visual / row inspection). | + +**Warning:** span, pairwise distance, entropy, and concentration-map intensity measure **different** aspects of registral structure. Do not rank scores using a single column. + +--- + +## Golden regression vs qualitative only + +| Suitable for future **golden** numeric regression | **Qualitative only** (Phase 1) | +|---------------------------------------------------|--------------------------------| +| `unison_register` span/pairwise/entropy = 0 | Exact entropy values across full A0–C8 band (bin count dependent) | +| `same_span_*` span equality | Concentration-map display normalization | +| `high_register_concentration` vs `low_register_concentration` dispersion equality | Perceptual “brightness” of heatmap colours | +| Monotonic span sequences in expansion/contraction | Global summary aggregates | + +Promote numbers to frozen JSON only after explicit Phase 2 review and pinned `package_version`. + +--- + +## Related documentation + +- [METRIC_SEMANTICS.md](METRIC_SEMANTICS.md) — formulas and interpretive limits +- [PARAMETERIZATION_GUIDE.md](PARAMETERIZATION_GUIDE.md) — advised analysis presets +- [benchmarks/README.md](../benchmarks/README.md) — separate synthetic summarize regression suite diff --git a/tests/test_registral_regression_fixtures.py b/tests/test_registral_regression_fixtures.py new file mode 100644 index 0000000..4cf4357 --- /dev/null +++ b/tests/test_registral_regression_fixtures.py @@ -0,0 +1,163 @@ +"""Qualitative invariant tests for controlled registral-regression MusicXML fixtures.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from registral_dispersion.concentration_map import build_registral_concentration_matrix +from registral_dispersion.observation import OBSERVATION_MODE_EVENT_BOUNDARIES +from registral_dispersion.profiles import ANALYSIS_PROFILE_OCCUPIED_SPACE +from registral_dispersion.service import run_registral_dispersion_analysis + +REPO_ROOT = Path(__file__).resolve().parent.parent +FIXTURES = REPO_ROOT / "corpus" / "fixtures" / "registral_regression" +DOCS = REPO_ROOT / "docs" / "REGISTRAL_REGRESSION_FIXTURES.md" + +ANALYSIS_PARAMS = { + "observation_mode": OBSERVATION_MODE_EVENT_BOUNDARIES, + "analysis_profile": ANALYSIS_PROFILE_OCCUPIED_SPACE, + "register_low": "A0", + "register_high": "C8", + "time_step": 0.25, + "window_size": 4.0, + "tie_policy": "as_imported", +} + +FIXTURE_NAMES = ( + "unison_register", + "cluster_middle_register", + "wide_bipolar_register", + "registral_expansion", + "registral_contraction", + "high_register_concentration", + "low_register_concentration", + "same_span_sparse_extremes", + "same_span_filled_middle", +) + + +def _fixture_path(name: str) -> Path: + return FIXTURES / f"{name}.musicxml" + + +def _analyze(name: str) -> dict: + path = _fixture_path(name) + if not path.is_file(): + pytest.skip(f"Fixture not found: {path}") + out = run_registral_dispersion_analysis(str(path), ANALYSIS_PARAMS) + assert out.get("error") is None, out.get("error") + return out + + +def _finite_spans(results: dict) -> np.ndarray: + spans = np.asarray(results["registral_span"], dtype=float) + return spans[np.isfinite(spans)] + + +def _single_row_metrics(name: str) -> dict[str, float]: + results = _analyze(name)["results"] + spans = _finite_spans(results) + assert spans.size >= 1 + idx = int(np.argmax(np.asarray(results["registral_span"], dtype=float) == spans[0])) + return { + "span": float(results["registral_span"][idx]), + "pairwise": float(results["mean_pairwise_registral_distance"][idx]), + "entropy": float(results["occupancy_entropy"][idx]), + "active_note_count": float(results["active_note_count"][idx]), + } + + +def _concentration_mean_midi(name: str) -> float: + path = _fixture_path(name) + bundle = build_registral_concentration_matrix( + str(path), + 21.0, + 108.0, + time_bin_size=1.0, + concentration_mode="unique_pitch_heights", + ) + mat = np.asarray(bundle["matrix"], dtype=float) + mids = np.asarray(bundle["pitch_midi"], dtype=int) + active = mids[np.any(mat > 0, axis=1)] + assert active.size > 0 + weights = mat[np.any(mat > 0, axis=1)].sum(axis=1) + return float(np.average(active, weights=weights)) + + +@pytest.mark.parametrize("name", FIXTURE_NAMES) +def test_all_registral_regression_fixtures_parse(name: str) -> None: + out = _analyze(name) + assert "results" in out + assert len(out["results"]["t"]) >= 1 + + +def test_unison_register_has_zero_or_minimal_span() -> None: + m = _single_row_metrics("unison_register") + assert m["span"] == 0.0 + assert m["pairwise"] == 0.0 + assert m["entropy"] == 0.0 + + +def test_cluster_middle_less_spread_than_wide_bipolar() -> None: + cluster = _single_row_metrics("cluster_middle_register") + wide = _single_row_metrics("wide_bipolar_register") + assert cluster["span"] < wide["span"] + assert cluster["pairwise"] < wide["pairwise"] + + +def test_wide_bipolar_has_largest_span_in_fixture_set() -> None: + wide_span = _single_row_metrics("wide_bipolar_register")["span"] + others = [_single_row_metrics(name)["span"] for name in FIXTURE_NAMES if name != "wide_bipolar_register"] + assert wide_span >= max(others) + + +def test_registral_expansion_increases_span_over_time() -> None: + spans = _finite_spans(_analyze("registral_expansion")["results"]) + assert spans.size >= 2 + assert spans[-1] > spans[0] + assert np.all(np.diff(spans) >= 0) + + +def test_registral_contraction_decreases_span_over_time() -> None: + spans = _finite_spans(_analyze("registral_contraction")["results"]) + assert spans.size >= 2 + assert spans[-1] < spans[0] + assert np.all(np.diff(spans) <= 0) + + +def test_high_low_register_concentration_same_dispersion_different_location() -> None: + high = _single_row_metrics("high_register_concentration") + low = _single_row_metrics("low_register_concentration") + assert high["span"] == pytest.approx(low["span"]) + assert high["pairwise"] == pytest.approx(low["pairwise"]) + assert high["entropy"] == pytest.approx(low["entropy"]) + assert _concentration_mean_midi("high_register_concentration") > _concentration_mean_midi( + "low_register_concentration" + ) + + +def test_same_span_different_internal_distribution() -> None: + sparse = _single_row_metrics("same_span_sparse_extremes") + filled = _single_row_metrics("same_span_filled_middle") + assert sparse["span"] == pytest.approx(filled["span"]) + assert filled["pairwise"] < sparse["pairwise"] + assert filled["entropy"] > sparse["entropy"] + assert filled["active_note_count"] > sparse["active_note_count"] + + +def test_concentration_map_reflects_high_vs_low_register_location() -> None: + high_mean = _concentration_mean_midi("high_register_concentration") + low_mean = _concentration_mean_midi("low_register_concentration") + assert high_mean > 72 + assert low_mean < 48 + + +def test_documentation_lists_all_fixtures() -> None: + if not DOCS.is_file(): + pytest.skip("Documentation not found") + text = DOCS.read_text(encoding="utf-8") + for name in FIXTURE_NAMES: + assert name in text