Skip to content

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

Open
webern wants to merge 2 commits into
mainfrom
claude/friendly-cori-pg1yfm
Open

feat: unified spanner numbering with writer-side assignment#320
webern wants to merge 2 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.

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 in ApiCommon.h 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, and misuse (out-of-range level, empty id, wrong-kind accessor) throws std::logic_error.
  • 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, factory preconditions, wrong-kind accessors, 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
  • New corpus fixture custom/slurs_overbars.musicxml (from the file attached to Add API-level spanner identity for writer-side number assignment #297) pinned in roundtrip-baseline.txt; corert count 833 -> 834, make audit regenerated
  • make test (4777 assertions in 388 test cases), make test-api-roundtrip (159/159 pinned), make test-core-dev (834 files), make check-core-dev, make validate-cpp, make test-cpp-unit
  • Unity build (CMAKE_UNITY_BUILD=ON, batch size 0) compiles

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
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.
@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.

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.
@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.

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

1 participant