Skip to content

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

Closed

Description

@matt-edmondson

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)

Activity

  1. matt-edmondson commented on Sep 16, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Category: Bug
    Priority: Medium
    Area: Semantics.Music — Key.RomanNumeralOf

    Medium: a genuine correctness bug producing silently wrong labels, but confined to the non-heptatonic modes (Chromatic, OctatonicHalfWhole, OctatonicWholeHalf). Diatonic keys — the overwhelming majority of use — are unaffected, which is what keeps it off High. The sharpest detail is the internal inconsistency the issue points out: ChordFromRomanNumeral bounds-checks the degree and throws, while RomanNumeralOf wraps with % 7, so the two halves of a round trip disagree about whether a 9th degree is meaningful.

    Suggested assignment: none specific.

    Related: open PR #250 ("Read C6/9 as a six-nine chord, not a slash bass") is in the same Semantics.Music chord/key area, but a distinct defect — not a duplicate and not blocking. Worth a glance for whoever picks this up only because the two touch neighbouring parsing/labelling code.

    Open PR covering this: none.

    Suggested next step: pick (a) or (b) deliberately rather than by whichever is easier to write, because they encode different claims. (a) throwing says roman-numeral analysis is undefined outside conventional degree systems, which is defensible and matches what ChordFromRomanNumeral already asserts. (b) extending the table says these modes have a numeral spelling — which requires deciding what the 8th and 12th degrees are actually called, and there is no single convention to appeal to. (a) is the smaller claim and restores the symmetry between the two directions; (b) needs a musicological answer before it needs code. The proposed test — no two distinct non-enharmonic degrees within one mode share a string — is the right invariant either way.


    Generated by Claude Code

  2. added a commit that references this issue on Oct 14, 2026
    a180875
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions