Skip to content

feat: unified spanner numbering with writer-side assignment - #320

Merged
webern merged 8 commits into
mainfrom
claude/friendly-cori-pg1yfm
Jul 12, 2026
Merged

feat: unified spanner numbering with writer-side assignment#320
webern merged 8 commits into
mainfrom
claude/friendly-cori-pg1yfm

Conversation

@webern

@webern webern commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Human Summary

Take @rpatters1's suggested design from #297 and attempts to implement it with a spanner identifying object and a sort of spanner resource pool.

AI Summary

MusicXML pairs a spanner's start with its stop using a number attribute, and two spanners of the same type that are open at the same point in the serialized stream must carry different numbers. Which spanners are open at the same point depends on the order the writer emits notes (voice interleaving via backup), so a caller cannot reliably choose the number. This PR moves number assignment into the writer.

The design differs from the shape proposed in #297 (a second spannerId field beside numberLevel): two mutually exclusive fields side by side is the recurring defect that design principle #3 and #249 exist to eliminate. Instead the two are unified into one value type.

  • New SpannerNumber value type (SpannerNumber.h/SpannerNumber.cpp, following the TimeChoice.h/.cpp declaration/implementation split) with exactly three states: unspecified (default, no number emitted for a lone spanner), explicit level 1..16 (emitted verbatim; what the reader produces), and identity (an author-only, never-serialized label; start/continue/stop sharing an id are the same logical spanner and the writer assigns the number). A contradictory state is unrepresentable by construction. Constructed directly (SpannerNumber{3}, SpannerNumber{"my-id"}), not via factory functions. Follows the TimeChoice "choice" pattern: nothing throws. An out-of-range level or an empty id collapses to unspecified at construction, and calling level()/identity() for the wrong kind returns a harmless default (NUMBER_LEVEL_UNSPECIFIED, or an empty string) instead of an exception.
  • Breaking: int numberLevel is replaced by SpannerNumber number in CurveStart, CurveContinue, CurveStop, WedgeStart, WedgeStop, SpannerStart, and SpannerStop. Tuplets are untouched and keep the legacy int + NUMBER_LEVEL_UNSPECIFIED.
  • New SpannerNumberResolver runs once per part before writing. It walks the part in true serialization order (measures, staves, voices ascending, notes in vector order with curve stops/continues/starts per note, directions in vector order) and assigns each identity spanner the lowest free number from a 1..16 pool per staff and per spanner class (slur, tied, wedge, octave-shift, bracket, and dashes pools are independent). While an explicit spanner is open its level is reserved, so explicit and identity spanners sharing a pool never collide. Pool exhaustion (more than 16 concurrently open) throws rather than emitting an illegal number.
  • The wedge and octave-shift writers now emit numbers at all; previously they dropped them on write even when the reader had captured them, so explicit wedge/ottava numbers now survive a round trip.
  • Readers only ever produce unspecified or explicit, keeping read-to-write fidelity for files that already carry numbers.

Testing

  • SpannerNumberTest: three-state semantics, out-of-range/empty-input construction collapsing to unspecified, wrong-kind accessors returning harmless defaults (never throwing), equality across all kind combinations
  • SpannerIdentityTest: the Add API-level spanner identity for writer-side number assignment #297 scenario — slurs disjoint in musical time that overlap in the stream once voice 1 serializes ahead of voice 2 get distinct numbers (1, 1-reused, 2); explicit + identity pool sharing; slur/tied pool independence; wedge number round-trip
  • data/rpatters1/slurs_overbars.musicxml: the file rpatters1 attached to Add API-level spanner identity for writer-side number assignment #297 (a genuine Finale export, confirmed provenance in review), pinned in roundtrip-baseline.txt alongside his other contributed fixtures in data/rpatters1/
  • make test, make test-api-roundtrip (166/166 pinned), make test-core-dev (835 corert files), make check-core-dev, make validate-cpp, make test-cpp-unit, make probe-cpp
  • Unity build (CMAKE_UNITY_BUILD=ON, batch size 0) compiles
  • Rebased over main (through feat: add score-part groups, measure-numbering multi-rest/system attrs, and swing to mx::api #333) and CI re-run on the rebased branch

References

@webern webern added feature new feature request breaking fixes or implementation that require breaking changes api Affects the mx::api layer impl Affects the mx::impl layer ai Issues opened by, or through, a coding agent. labels Jul 6, 2026 — with Claude
@webern
webern force-pushed the claude/friendly-cori-pg1yfm branch from 41c2446 to e6bc65d Compare July 7, 2026 09:38
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Coverage report

API coverage src/private/mx/{api,impl,utility}/

Metric Coverage Covered / Total
(api coverage not produced)

Core HTML report | API HTML report

Commit 3a8a3b659389c145aa9f8a9eede95b634486ef65.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Coverage report

Core-dev coverage src/private/mx/core/

Metric Coverage Covered / Total
Lines 77.8% 28514 / 36640
Functions 74.3% 6352 / 8551
Branches 50.7% 22680 / 44751

API coverage src/private/mx/{api,impl,utility}/

Metric Coverage Covered / Total
Lines 81.2% 6632 / 8167
Functions 68.0% 2238 / 3293
Branches 49.7% 5719 / 11501

Core HTML report | API HTML report

Commit 53dd840359525db5d5eb01585b60fcb714928397.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

gen-quality gen/

gen-quality: 84.5 / 100   (floor 84.5, +0.0)

  structure     86.5  x0.50   [fn 90.5 / file 82.6]
  cyclomatic    88.4  x0.25
  cognitive     76.6  x0.25

  409 functions across 31 files, 7702 lines (largest file 1044)
  max cc 56  max cognitive 44  max fn loc 152

Worst offenders (top 5 per axis; full lists in score.json):
  cyclomatic gen/xsd/analyze.py:311     report                             56
  cyclomatic gen/plates/build.py:956    _validate_config_against_ir        35
  cyclomatic gen/press/context.py:145   plate_context                      34
  cyclomatic gen/__main__.py:46         _ir                                23
  cyclomatic gen/tests/test_ir.py:102   _check_references                  20
  cognitive  gen/xsd/analyze.py:311     report                             44
  cognitive  gen/ir/resolve.py:119      flat_elements                      40
  cognitive  gen/tests/test_ir.py:102   _check_references                  38
  cognitive  gen/press/context.py:145   plate_context                      37
  cognitive  gen/xsd/analyze.py:207     _sccs                              37
  size       gen/xsd/analyze.py:311     report                             152
  size       gen/press/context.py:145   plate_context                      96
  size       gen/plates/build.py:533    _value_plate                       89
  size       gen/plates/build.py:956    _validate_config_against_ir        89
  size       gen/ir/resolve.py:119      flat_elements                      78

Commit 53dd840359525db5d5eb01585b60fcb714928397.

webern added 2 commits July 11, 2026 14:07
Replace the raw int numberLevel on the six spanner start/continue/stop
structs (CurveStart/Continue/Stop, WedgeStart/Stop, SpannerStart/Stop)
with a single SpannerNumber value type in ApiCommon.h. Exactly one of
three states -- unspecified, explicit level 1..16 (emitted verbatim), or
an author-only identity label (never serialized) -- so a contradictory
object is unrepresentable by construction (design principle #3).

The writer now owns number assignment for identity spanners: a new
SpannerNumberResolver walks each part in true serialization order
(measures -> staves -> voices ascending -> notes, directions in vector
order, mirroring MeasureWriter/NotationsWriter/DirectionWriter) and
assigns the lowest free number from a 1..16 pool per staff and per
spanner class (slur, tied, wedge, octave-shift, bracket, dashes).
Explicit levels reserve their number for their serialized extent so
explicit and identity spanners never collide. Pool exhaustion throws
rather than emitting an illegal number.

Wedge and octave-shift writers now emit the resolved number (they
previously dropped numbers entirely). Readers produce only unspecified
or explicit; identity is authoring-only.

Adds the slurs_overbars fixture from #297 (two slurs disjoint in musical
time that overlap in the serialized stream once voice 1 is written ahead
of voice 2), pinned in roundtrip-baseline.txt; corert count 833 -> 834.

This is a deliberate breaking change to mx::api.
The rebase onto main picked up the new api::OttavaStop type (a
SpannerStop plus an optional size, from the octave-shift stop-size
change) everywhere except SpannerNumberResolver.cpp, which still read
item.number on ottava stops and did not compile. Register ottava stops
by their nested spannerStop (matching the address DirectionWriter uses
for lookup) and re-run clang-format over the conflict-resolved files.
@webern
webern force-pushed the claude/friendly-cori-pg1yfm branch from bbd0f06 to e36ed5e Compare July 11, 2026 14:07
@github-actions

Copy link
Copy Markdown

Coverage report

API coverage src/private/mx/{api,impl,utility}/

Metric Coverage Covered / Total
(api coverage not produced)

Core HTML report | API HTML report

Commit 7fda4e0f0d0b557e63686f6927c7546426fab4e9.

@github-actions

Copy link
Copy Markdown

gen-quality gen/

gen-quality: 84.5 / 100   (floor 84.5, +0.0)

  structure     86.5  x0.50   [fn 90.5 / file 82.6]
  cyclomatic    88.4  x0.25
  cognitive     76.6  x0.25

  409 functions across 31 files, 7702 lines (largest file 1044)
  max cc 56  max cognitive 44  max fn loc 152

Worst offenders (top 5 per axis; full lists in score.json):
  cyclomatic gen/xsd/analyze.py:311     report                             56
  cyclomatic gen/plates/build.py:956    _validate_config_against_ir        35
  cyclomatic gen/press/context.py:145   plate_context                      34
  cyclomatic gen/__main__.py:46         _ir                                23
  cyclomatic gen/tests/test_ir.py:102   _check_references                  20
  cognitive  gen/xsd/analyze.py:311     report                             44
  cognitive  gen/ir/resolve.py:119      flat_elements                      40
  cognitive  gen/tests/test_ir.py:102   _check_references                  38
  cognitive  gen/press/context.py:145   plate_context                      37
  cognitive  gen/xsd/analyze.py:207     _sccs                              37
  size       gen/xsd/analyze.py:311     report                             152
  size       gen/press/context.py:145   plate_context                      96
  size       gen/plates/build.py:533    _value_plate                       89
  size       gen/plates/build.py:956    _validate_config_against_ir        89
  size       gen/ir/resolve.py:119      flat_elements                      78

Commit 7fda4e0f0d0b557e63686f6927c7546426fab4e9.

The rebase onto main also picked up #321's TimeChoice split of
MeasureData::timeSignature into a variant of TimeSignatureData/
ComplexTimeSignature, but SpannerIdentityTest.cpp (added by this PR)
still set the old direct beats/beatType members, which no longer
exist and failed to compile everywhere.

Separately, main had already grown its own corert-pinned file count
to 834 (a new rpatters1 fixture from #321) before this PR's own
slurs_overbars fixture was rebased on top, so the true total is 835,
not the 834 the previous repair commit left in place. Ran `make audit`
to regenerate the missing feature sidecar and corpus.xml, and bumped
the pinned count accordingly.

Verified locally: make test, test-core-dev, test-api-roundtrip,
test-cpp-unit, validate-cpp, probe-cpp, check, check-core-dev, and a
unity build all pass.
@github-actions

Copy link
Copy Markdown

Coverage report

Core-dev coverage src/private/mx/core/

Metric Coverage Covered / Total
Lines 77.8% 28514 / 36640
Functions 74.3% 6352 / 8552
Branches 50.7% 22680 / 44751

API coverage src/private/mx/{api,impl,utility}/

Metric Coverage Covered / Total
Lines 81.4% 6765 / 8315
Functions 68.2% 2266 / 3323
Branches 49.8% 5841 / 11722

Core HTML report | API HTML report

Commit 118e3d5fbf7ac9aabb8000ecba632f2a61135367.

@github-actions

Copy link
Copy Markdown

gen-quality gen/

gen-quality: 84.5 / 100   (floor 84.5, +0.0)

  structure     86.5  x0.50   [fn 90.5 / file 82.6]
  cyclomatic    88.4  x0.25
  cognitive     76.6  x0.25

  409 functions across 31 files, 7702 lines (largest file 1044)
  max cc 56  max cognitive 44  max fn loc 152

Worst offenders (top 5 per axis; full lists in score.json):
  cyclomatic gen/xsd/analyze.py:311     report                             56
  cyclomatic gen/plates/build.py:956    _validate_config_against_ir        35
  cyclomatic gen/press/context.py:145   plate_context                      34
  cyclomatic gen/__main__.py:46         _ir                                23
  cyclomatic gen/tests/test_ir.py:102   _check_references                  20
  cognitive  gen/xsd/analyze.py:311     report                             44
  cognitive  gen/ir/resolve.py:119      flat_elements                      40
  cognitive  gen/tests/test_ir.py:102   _check_references                  38
  cognitive  gen/press/context.py:145   plate_context                      37
  cognitive  gen/xsd/analyze.py:207     _sccs                              37
  size       gen/xsd/analyze.py:311     report                             152
  size       gen/press/context.py:145   plate_context                      96
  size       gen/plates/build.py:533    _value_plate                       89
  size       gen/plates/build.py:956    _validate_config_against_ir        89
  size       gen/ir/resolve.py:119      flat_elements                      78

Commit 118e3d5fbf7ac9aabb8000ecba632f2a61135367.

Comment thread data/rpatters1/slurs_overbars.musicxml
webern added 2 commits July 12, 2026 12:51
Move SpannerNumber out of ApiCommon.h into its own SpannerNumber.h/.cpp
pair (declaration + implementation, matching the TimeChoice.h/.cpp
split). Replace the makeLevel/makeIdentity factory functions with
constructors. Drop exception throwing entirely, following the
TimeChoice pattern: an out-of-range level or empty id collapses to
unspecified at construction, and calling level()/identity() for the
wrong kind returns a harmless default (NUMBER_LEVEL_UNSPECIFIED, or an
empty string) instead of throwing.

Move the slurs_overbars fixture from data/custom into data/rpatters1
now that its provenance is confirmed: rpatters1 attached the original
file to issue #297, so it belongs alongside his other contributed
fixture rather than in data/custom (reserved for LLM-authored files).
Regenerated data/corpus.xml and updated roundtrip-baseline.txt for the
new path.
…repair-w6uwh2

# Conflicts:
#	src/private/mxtest/api/roundtrip-baseline.txt

webern commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

Pushed a commit addressing the pr feedback TODOs:

  • SpannerNumber moved out of ApiCommon.h into its own SpannerNumber.h/SpannerNumber.cpp (declaration + implementation split, matching TimeChoice.h/.cpp).
  • makeLevel/makeIdentity factory functions replaced with constructors (SpannerNumber{3}, SpannerNumber{"my-id"}).
  • No more throwing. Following the TimeChoice pattern: an out-of-range level or an empty id collapses to unspecified at construction, and calling level()/identity() for the wrong kind returns a harmless default (NUMBER_LEVEL_UNSPECIFIED, or an empty string) instead of throwing.

On the two // TODO: ... Is this part of the spanner interface? Does it belong in ApiCommon or somewhere else? comments on Placement and MeasureNumbering: I left those where they are. They predate this PR (this PR only inserted SpannerNumber above them, it didn't add them) and they're not spanner-specific — Placement is used across CurveData.h, DefaultsData.h, DirectionData.h, and PositionData.h, and MeasureNumbering by MeasureData.h. Moving them would be a separate, unscoped cleanup touching several unrelated headers, so I didn't fold it into this PR. Happy to do that as a follow-up if you'd like.

Also moved slurs_overbars.musicxml/.features.xml into data/rpatters1/ per your last comment, updated roundtrip-baseline.txt and regenerated data/corpus.xml, and rebased over the #333 merge to main (conflict was just the round-trip allow-list, resolved by keeping both new pins). CI is running on the new commit.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown

Coverage report

Core-dev coverage src/private/mx/core/

Metric Coverage Covered / Total
Lines 77.8% 28514 / 36644
Functions 74.3% 6352 / 8554
Branches 50.7% 22680 / 44751

API coverage src/private/mx/{api,impl,utility}/

Metric Coverage Covered / Total
Lines 81.5% 6880 / 8437
Functions 68.6% 2296 / 3346
Branches 50.1% 5939 / 11857

Core HTML report | API HTML report

Commit 557706ef98a73f68b9d3801ea9d6ad27aa7b04af.

@github-actions

Copy link
Copy Markdown

gen-quality gen/

gen-quality: 84.5 / 100   (floor 84.5, +0.0)

  structure     86.5  x0.50   [fn 90.5 / file 82.6]
  cyclomatic    88.4  x0.25
  cognitive     76.6  x0.25

  409 functions across 31 files, 7702 lines (largest file 1044)
  max cc 56  max cognitive 44  max fn loc 152

Worst offenders (top 5 per axis; full lists in score.json):
  cyclomatic gen/xsd/analyze.py:311     report                             56
  cyclomatic gen/plates/build.py:956    _validate_config_against_ir        35
  cyclomatic gen/press/context.py:145   plate_context                      34
  cyclomatic gen/__main__.py:46         _ir                                23
  cyclomatic gen/tests/test_ir.py:102   _check_references                  20
  cognitive  gen/xsd/analyze.py:311     report                             44
  cognitive  gen/ir/resolve.py:119      flat_elements                      40
  cognitive  gen/tests/test_ir.py:102   _check_references                  38
  cognitive  gen/press/context.py:145   plate_context                      37
  cognitive  gen/xsd/analyze.py:207     _sccs                              37
  size       gen/xsd/analyze.py:311     report                             152
  size       gen/press/context.py:145   plate_context                      96
  size       gen/plates/build.py:533    _value_plate                       89
  size       gen/plates/build.py:956    _validate_config_against_ir        89
  size       gen/ir/resolve.py:119      flat_elements                      78

Commit 557706ef98a73f68b9d3801ea9d6ad27aa7b04af.

@webern
webern merged commit 5c41a7f into main Jul 12, 2026
10 checks passed
@webern
webern deleted the claude/friendly-cori-pg1yfm branch July 12, 2026 13:18
webern added a commit that referenced this pull request Jul 19, 2026
## Human Summary

Changes the way pedals are modeled into two different things. Pedal
marks, which are the old fashioned Ped and * marks, and a separate type
for more modern lines.

## Summary

`DirectionReader::parsePedal` handled only `PedalType` start/stop and
silently dropped
`sostenuto`, `change`, `continue`, `discontinue`, and `resume`, even
though `core::PedalType`
carries all seven tags. This was the last unmodeled item in #324.

The shape decision (called for in #324) was to store the pedal type as
one fact in one field
rather than scatter it across start/stop/other vectors. This replaces
the `pedalStarts` /
`pedalStops` spanner vectors on `DirectionData` with a single
`std::vector<PedalData> pedals`,
where each `PedalData` carries a `PedalKind` enum covering every
pedal-line type. Each pedal event
stands on its own and is placed at its own tick, so the model has no
neighbor-dependent meaning.

- New `PedalData.h`: the `PedalKind` enum (`start`, `stop`, `sostenuto`,
`change`, `continueLine`,
  `discontinue`, `resume`) and the `PedalData` struct.
- Reader: line pedals (`line="yes"`, plus the inherently line-formatting
types
`sostenuto`/`change`/`continue`/`discontinue`/`resume`) go to `pedals`;
sign start/stop still
  route through `MarkData` as `MarkType::pedal` / `damp`, unchanged.
- Writer: a single `emitPedal` maps `PedalKind` back to
`core::PedalType`, emits `line="yes"`, and
  emits nothing for an unspecified kind.

Only two corpus files use these types (`lysuite/ly31a_Directions.xml`,
`lysuite/ly33a_Spanners.xml`)
and both fail first on unrelated features, so this does not flip any
file to PASS. It is validated
by a focused unit test that round-trips each pedal type through the api.

This is a breaking change: it reshapes `DirectionData`'s pedal fields.

## Testing

- [x] New `PedalAllKinds` round-trips all seven pedal types through the
api; `PedalPlacement` covers
  placement (`*Pedal*`: 44 assertions in 5 test cases)
- [x] Full unit suite passes (5130 assertions in 451 test cases)
- [x] api round-trip regression unchanged (284 passed, 0 failed of 284
pinned)

## References

- Closes #324 (the `symbol` item from that worklist is tracked
separately by #294 and is not
  included here)
- Follows the merged direction-type stack #358#363
- Part of #208
- Spanner-numbering context: #320, #351
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai Issues opened by, or through, a coding agent. api Affects the mx::api layer breaking fixes or implementation that require breaking changes feature new feature request impl Affects the mx::impl layer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add API-level spanner identity for writer-side number assignment

2 participants