Skip to content

Refuse to label scale degrees the roman numerals cannot spell - #251

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/roman-numeral-degree-guard
Sep 17, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/roman-numeral-degree-guard

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #249

The bug

Key.RomanNumeralOf indexed a fixed seven-entry table with RomanNumerals[(degree.Degree - 1) % RomanNumerals.Length]. Scale.DegreeOf returns a degree over Mode.Intervals.Count, and three modes have more than seven of those — Chromatic (12), OctatonicHalfWhole (8) and OctatonicWholeHalf (8). For those, the % 7 wrapped distinct degrees onto the same numeral, silently:

Key chromatic = Key.Create(PitchClass.Create(0), Mode.Chromatic);
chromatic.RomanNumeralOf(Chord.Parse("C"));   // "I"  (degree 1)
chromatic.RomanNumeralOf(Chord.Parse("G#"));  // "I"  (degree 8) — same label, different chord

The two halves of a round trip also disagreed: ChordFromRomanNumeral bounds-checks the degree and throws rather than wrapping, so it already asserted that a ninth degree means nothing while RomanNumeralOf happily invented a label for one.

The change

Option (a) from the issue, which the triage comment recommends: guard rather than extend the table. Extending it would require deciding what the eighth and twelfth degrees are called, and there is no convention to appeal to — that is a musicological answer, not a code one. Guarding makes the smaller claim, that roman-numeral analysis is undefined outside a conventional degree system, and restores the symmetry with ChordFromRomanNumeral.

  • RomanNumeralOf throws ArgumentException when the resolved degree is beyond the seventh, naming the degree and the mode. ArgumentException rather than ChordFromRomanNumeral's FormatException because nothing is being parsed here — this is a validation failure, which is what CLAUDE.md's error-handling standard asks for.
  • The % 7 is gone from the index, so the guard is what keeps it in range rather than there being two overlapping defences.
  • <exception> docs on RomanNumeralOf and on Progression.RomanNumerals, its one caller.

Degrees one through seven are untouched, including in Chromatic, so every diatonic key — the overwhelming majority of use — keeps exactly the labels it had.

Tests

RomanNumeral_DistinctDegreesNeverShareALabel is the invariant the issue asks for: across Chromatic and both octatonic modes, walk every scale degree and assert no two distinct degrees produce the same string, with degrees past the seventh required to throw. It fails on main at chromatic degree 8 (Expected exception of exact type ArgumentException but no exception was thrown) and passes with the change — verified by running it against the unfixed source first.

RomanNumeral_ChromaticKeyStillLabelsTheFirstSevenDegrees pins the behaviour that is deliberately not changing, so a future widening of the guard cannot quietly take the working chromatic labels with it.

Full suite: 1252 tests, 1244 passed, 8 skipped (Windows-only), 0 failed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LP6XbVRYgiHfN3o2a7MC4x


Generated by Claude Code

RomanNumeralOf indexed a fixed seven-entry table with `% 7`, so in a mode
with more than seven degrees two distinct degrees were handed the same
numeral. In Chromatic, degree 8 came back as "I" alongside the tonic and
degree 9 as "II" alongside degree 2; both octatonic modes collided their
eighth degree onto the tonic.

Guard the degree instead of wrapping it, which is what ChordFromRomanNumeral
already does in the other direction, so a round trip no longer disagrees with
itself about whether a ninth degree means anything. Degrees one to seven are
unchanged, including in Chromatic, so every diatonic key keeps its labels.

Fixes #249

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LP6XbVRYgiHfN3o2a7MC4x
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Key.RomanNumeralOf produces duplicate/incorrect labels for modes with more than 7 degrees

2 participants