What's wrong
RomanNumeralOf indexes a fixed 7-entry table with RomanNumerals[(degree.Degree - 1) % RomanNumerals.Length]. degree.Degree comes from Scale.DegreeOf, which returns i + 1 where i ranges over Mode.Intervals.Count — and Mode supports 8-note modes (OctatonicHalfWhole/OctatonicWholeHalf) and the 12-note Chromatic mode. For these modes the % 7 wraps distinct scale degrees onto the same roman numeral, silently.
Why it matters (concrete failure scenario)
Key chromatic = Key.Create(PitchClass.Create(0), Mode.Chromatic);
chromatic.RomanNumeralOf(Chord.Parse("C#")); // "II" (degree 2)
chromatic.RomanNumeralOf(Chord.Parse("Ab")); // "II" (degree 9) — same label, different chord
A chord rooted at scale degree 2 (C♯) and one rooted at degree 9 (A♭) both resolve to "II". The tonic (degree 1) and the 8th chromatic degree similarly collide onto "I". Mode.OctatonicHalfWhole (8 degrees) has the same collision between degree 8 and the tonic. This is also inconsistent with the inverse operation: ChordFromRomanNumeral explicitly bounds-checks the degree and throws FormatException rather than wrapping — so the two "both directions" APIs disagree on how to handle non-heptatonic keys.
Suggested fix / acceptance criteria
Either (a) throw a descriptive exception from RomanNumeralOf when degree.Degree > Mode.DegreeCount for modes without a conventional 7-degree numeral system (mirroring ChordFromRomanNumeral's guard), or (b) size/select the numeral table from the mode's actual degree count so degrees beyond 7 get distinct labels instead of wrapping. Add a test asserting RomanNumeralOf never returns the same string for two distinct, non-enharmonic scale degrees within one mode, at least for Chromatic and both octatonic modes.
File: Semantics.Music/Key.cs (RomanNumeralOf, RomanNumerals)
What's wrong
RomanNumeralOfindexes a fixed 7-entry table withRomanNumerals[(degree.Degree - 1) % RomanNumerals.Length].degree.Degreecomes fromScale.DegreeOf, which returnsi + 1whereiranges overMode.Intervals.Count— andModesupports 8-note modes (OctatonicHalfWhole/OctatonicWholeHalf) and the 12-noteChromaticmode. For these modes the% 7wraps distinct scale degrees onto the same roman numeral, silently.Why it matters (concrete failure scenario)
A chord rooted at scale degree 2 (C♯) and one rooted at degree 9 (A♭) both resolve to
"II". The tonic (degree 1) and the 8th chromatic degree similarly collide onto"I".Mode.OctatonicHalfWhole(8 degrees) has the same collision between degree 8 and the tonic. This is also inconsistent with the inverse operation:ChordFromRomanNumeralexplicitly bounds-checks the degree and throwsFormatExceptionrather than wrapping — so the two "both directions" APIs disagree on how to handle non-heptatonic keys.Suggested fix / acceptance criteria
Either (a) throw a descriptive exception from
RomanNumeralOfwhendegree.Degree > Mode.DegreeCountfor modes without a conventional 7-degree numeral system (mirroringChordFromRomanNumeral's guard), or (b) size/select the numeral table from the mode's actual degree count so degrees beyond 7 get distinct labels instead of wrapping. Add a test assertingRomanNumeralOfnever returns the same string for two distinct, non-enharmonic scale degrees within one mode, at least forChromaticand both octatonic modes.File:
Semantics.Music/Key.cs(RomanNumeralOf,RomanNumerals)