Skip to content

api: preserve an explicit or omitted staff on notes - #323

Merged
webern merged 4 commits into
mainfrom
fix-note-staff-drop
Jul 12, 2026
Merged

api: preserve an explicit or omitted staff on notes#323
webern merged 4 commits into
mainfrom
fix-note-staff-drop

Conversation

@webern

@webern webern commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Human Summary

This is a round-trip fidelity knob. mx::api was automatically deciding when to emit a staff number and when not to, but this did not match some files. So we added a ternary Bool so that an author (or round trip) could turn on a forceable write of the staff number when it is not technically needed.

AI Summary

NoteWriter::setStaffAndVoice only wrote <staff> when the part had more than one staff, since
staff number is otherwise redundant with containment (measure -> staves -> voices -> notes). But
<staff> is legal MusicXML on a single-staff part too, and many real-world exporters
(Finale/Sibelius/MuseScore) write it unconditionally -- so a source's explicit <staff> was
silently dropped on every single-staff-part note on round-trip. This was the single largest drop
signature in the api round-trip corpus (drop:staff, ~285 of 675 currently-failing files).

Added NoteData::writeStaffNumber, a ternary Bool fidelity knob with the same shape as
ClefData::writeStaffNumber (doctrine principle 7):

  • unspecified (the default): the automatic rule -- omit <staff> on a single-staff part
    (where 1 is implied), include it otherwise. Authors never need to touch it.
  • yes / no: force the element on or off. The reader sets these only when the source
    diverges from the automatic rule, so the common case stays unspecified.
  • The writer ignores no for a note that is not on the first staff, where omitting <staff>
    would silently move the note to staff 1.

Why not a two-state bool

The first cut of this PR used bool isStaffValueSpecified (default false), mirroring the
legacy DirectionData field. That shape had three defects the ternary fixes:

  • It recorded a source observation rather than a writer instruction, so it meant nothing on the
    authoring path -- and it is exactly the bool is...Specified pattern issue api: replace value + isSpecified bool pairs with an explicit optional value type #249 exists to
    retire (new instances are rejected by .github/instructions/api-headers.instructions.md).
  • Two states cannot express "omit": a multi-staff note whose source legally omitted <staff>
    (implied 1) had <staff>1</staff> injected on round-trip -- the same fidelity-bug class in
    the reverse direction. The ternary's no closes it (and unlocks
    lysuite/ly02e_Rests_NoType.xml, which pins exactly that shape).
  • api -> xml -> api was not a fixed point: on a multi-staff part an authored false read back
    as true on every note, so two ScoreDatas for identical music compared unequal. With
    divergence-only recording, sources that follow the sensible rule read back unspecified,
    matching authored scores.

Testing

  • explicitStaffOnSingleStaffPartRoundTrips (reader sets yes, writer emits),
    implicitStaffOnSingleStaffPartOmitsElement (authored default omits),
    omittedStaffOnMultiStaffPartRoundTrips (reader sets no / leaves unspecified, writer
    omits/emits per note), writeStaffNumberNoIsIgnoredOffFirstStaff (the off-first-staff guard)
    in NoteDataTest.cpp
  • make test: all pass (4925 assertions in 413 test cases, plus the three examples)
  • make test-api-roundtrip: 171 passed, 0 failed (of 171 pinned)
  • make discover-api-roundtrip: no regressions; newly pinned ly24c_GraceNote_MeasureEnd
    and ly02e_Rests_NoType (this PR's two directions), plus custom/systems-and-pages.xml,
    ksuite/k008a_Beaming.xml, and ksuite/k014a_Fermatas.xml, found already passing from
    earlier merged work and pinned to defend them
  • make fmt / make check: clean

References

@webern webern added bug software defect non-breaking fixes or implementation that do not 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 7, 2026 — with Claude
@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% 28513 / 36640
Functions 74.3% 6352 / 8551
Branches 50.7% 22678 / 44751

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

Metric Coverage Covered / Total
Lines 80.8% 6396 / 7918
Functions 67.7% 2218 / 3276
Branches 49.6% 5478 / 11054

Core HTML report | API HTML report

Commit dfe9185c493f76563deb828adeb87f8dbac6a396.

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

@webern webern changed the title fix: preserve explicit &lt;staff&gt; on single-staff parts fix: preserve explicit staff on single-staff parts Jul 7, 2026
@webern
webern force-pushed the fix-note-staff-drop branch from d08a608 to fa268e0 Compare July 7, 2026 09:23
@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% 28513 / 36640
Functions 74.3% 6352 / 8551
Branches 50.7% 22678 / 44751

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

Metric Coverage Covered / Total
Lines 80.9% 6429 / 7948
Functions 67.8% 2222 / 3277
Branches 49.6% 5506 / 11102

Core HTML report | API HTML report

Commit 0a6fe0968dd76663538ff05cb0550d46a92a66c8.

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

NoteWriter::setStaffAndVoice only wrote <staff> when the part had more than
one staff, since staff number is otherwise redundant with containment
(measure -> staves -> voices -> notes). But <staff> is legal MusicXML on a
single-staff part too, and many real-world exporters write it unconditionally
-- so a source's explicit <staff> was silently dropped on round-trip on
every single-staff part, the single largest drop signature in the api
round-trip corpus.

Added NoteData::isStaffValueSpecified (mirrors the existing
DirectionData::isStaffValueSpecified), set by NoteReader whenever the
source note carried an explicit <staff>. NoteWriter now emits <staff>
whenever the part is multi-staff (unchanged, structurally required) or the
source had it explicitly, matching the same source-had-it-explicitly
convention already used for <voice> (NoteData::userRequestedVoiceNumber).

Closes #275
@webern
webern force-pushed the fix-note-staff-drop branch from fa268e0 to 855325a Compare July 11, 2026 14:07
@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.1% 6562 / 8096
Functions 68.0% 2250 / 3307
Branches 49.7% 5628 / 11323

Core HTML report | API HTML report

Commit 79d7520a7d7b145c95cc1b1073c5bef77ce625f3.

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

webern added 3 commits July 12, 2026 15:21
Replace the two-state bool isStaffValueSpecified with Bool
writeStaffNumber, the same principle-7 fidelity-knob shape as
ClefData::writeStaffNumber: unspecified (the default) applies the
automatic rule (omit <staff> on a single-staff part, include it
otherwise), yes/no force the element on or off, and the reader records
an override only when the source diverges from the automatic rule.

The bool could not express omission: a multi-staff note whose source
legally omitted <staff> (implied 1) had <staff>1</staff> injected on
round-trip. It also made api -> xml -> api non-idempotent on multi-staff
parts (authored false read back as true on every note), and exporters
that write <staff> unconditionally stamped the flag true corpus-wide,
recording noise instead of divergence. The writer ignores no for a note
off the first staff, where omission would move the note to staff 1.
lysuite/ly02e_Rests_NoType.xml is unlocked by writeStaffNumber's no
direction: a two-staff part whose last note legally omits <staff> no
longer has <staff>1</staff> injected on round-trip.

custom/systems-and-pages.xml, ksuite/k008a_Beaming.xml, and
ksuite/k014a_Fermatas.xml were found already passing when discovery was
re-run; they were unlocked by earlier merged changes and never pinned.
@webern webern changed the title fix: preserve explicit staff on single-staff parts fix: preserve a source's explicit or omitted staff on notes (ternary writeStaffNumber) Jul 12, 2026
@webern webern changed the title fix: preserve a source's explicit or omitted staff on notes (ternary writeStaffNumber) api: preserve an explicit or omitted staff on notes Jul 12, 2026
@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 82.1% 6940 / 8452
Functions 68.9% 2307 / 3347
Branches 50.7% 6021 / 11875

Core HTML report | API HTML report

Commit bce576fb2cae560fc7134d318cfa438201a0c3cc.

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

@webern
webern merged commit 7144bb6 into main Jul 12, 2026
10 checks passed
@webern
webern deleted the fix-note-staff-drop branch July 12, 2026 14:09
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 bug software defect impl Affects the mx::impl layer non-breaking fixes or implementation that do not require breaking changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

api: round-trip drops staff

1 participant