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