head_music 17.5.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a439a4b39c708eb38984b3878e7bc0c02ce92d7c693d34041ed3b8c69acfffe
4
- data.tar.gz: d0b702f38b7de8826fe7a345c3b17d0a8ea8c963bd922343889ba97a47f3538e
3
+ metadata.gz: 8a16868734e2fac408461d2202fcbc84be2a796a68371a81a7d2ad5bbeef516b
4
+ data.tar.gz: 02becc93275629796177a72894d81cc10feca0819847496e60aa3ddc6f5aac00
5
5
  SHA512:
6
- metadata.gz: 266406dca2449e15a0d72e4ac315190929aa048d18c6236f07d5eea23d5b220dcc037408edb7f5a2a508e677dee5bbf70bb96b620e1bcdcf75df145623efff9e
7
- data.tar.gz: 81fcf78c8ec6234b1478d0c6ca92acdc096bb714de76e33d1bd19bf21e52fce8c0b3831fa72b2e8ec2cbe4dad21d399886cd0e62c416934fd6778ab08d03308d
6
+ metadata.gz: e3533678a1a2b58558080cad6c03f04fe4b5b205d4413456b728c5ae2e1a598e3a09ebc0329c9f6fde263e84f857eb4571918087afe23a083278facaea7aa460
7
+ data.tar.gz: 3d605bff3a0459e56cddd4911e7699d2b295bf337e7f468094d0f4cc19e55f9b3036c21e59c95263f3bf19c69dfffa99093a8564f036aa625b776199e959e8f4
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [18.0.0] - 2026-07-27
11
+
12
+ ### Added
13
+
14
+ - `HeadMusic::Utilities::Accidentals` converts accidentals in a string between ASCII spellings and canonical unicode glyphs, replacing the hand-rolled `gsub` and `tr` chains that were scattered across the gem and disagreed with each other. `.to_unicode` converts whole chord symbols rather than bare pitch names — `Bbmaj7#11` becomes `B♭maj7♯11` — and `.to_ascii` converts back, emitting the spellings the gem's own parsers accept (`x` for a double sharp, never `##`). Both directions are idempotent, and conversion is all-or-nothing per accidental so a double never half-converts.
15
+ - The forward direction is safe to run over prose. Conversion happens only inside a candidate chord token, an uppercase letter name that does not continue a longer word or number, so `Above`, `Bebop`, `1.0b2`, and `0x1B2F` are untouched while `Bb`, `Ab7`, `Bbm`, and `Ebmaj7` convert. A chord-quality allowlist rescues `Bbm` and `Ebmaj7` from the word-boundary guard that protects `Above`; the allowlist was measured against a full system word list and is pinned by a spec that sweeps it.
16
+ - `##` is now accepted everywhere `bb` already was, ending the asymmetry between the two doubles. `Alteration.get("##")` resolves to `double_sharp` and `Spelling.get("C##")` returns `C𝄪`.
17
+ - The utility is deliberately narrower than `Spelling` and `Pitch` in two ways, both consequences of being safe to run over prose. It is case-sensitive, because a case-insensitive rule reads the `B` of `B7` as a flat sign — so `to_unicode("eb")` is `"eb"` while `Spelling.get("eb")` is `E♭`. And a bare token is read as an accidental rather than a pitch name — `to_unicode("bb")` is `𝄫`, the double-flat sign, while `Spelling.get("bb")` is `B♭`. Parse with `Spelling` first when a pitch name may be lowercase. `to_ascii` preserves `♮` for the same reason: dropping it would turn the valid spelling `C♮` into the different spelling `C`.
18
+
19
+ ### Changed
20
+
21
+ - **Breaking.** `Spelling.get("C##")` returns a `Spelling` instead of `nil`, and the same widening applies to `Pitch`, `Note`, `KeySignature`, and ABC `K:` fields. Code that treated that `nil` as a validation signal now silently accepts double sharps.
22
+ - **Breaking.** `Alteration::PATTERN`, `Alteration::MATCHER`, and `Alteration::SYMBOLS` gain `##` and are now sorted longest-first so that a two-character spelling always matches before its one-character prefix. Code embedding these in its own regular expressions will see different matches.
23
+ - **Breaking.** `Alteration.symbols` now returns `SYMBOLS` rather than a separately derived list that disagreed with it.
24
+ - **Breaking.** `Utilities::HashKey` normalizes ASCII accidentals before mapping them to word suffixes, so ASCII and unicode spellings of the same accidental now produce the same key. Previously it handled ASCII `#` but not ASCII `b`, so `"C#"` keyed to `:c_sharp` while `"Bb"` keyed to `:bb`. Now `"Bb"` keys to `:b_flat`, `"Bbb"` to `:b_double_flat`, and `"Cx"` to `:c_double_sharp`. Words containing those letters are unaffected — `:bass_clarinet` and `:blues_major_pentatonic` are unchanged — because normalization goes through the prose-safe `Utilities::Accidentals` rather than a bare substitution. The glyph-to-word table is now derived from `Alteration` rather than hard-coded, leaving `alterations.yml` as the single inventory.
25
+ - `Utilities::HashKey` is class-methods-only, matching `Utilities::Case`. `.new` and `#to_sym` are gone; `.for` was already the only entry point in use. The per-instance memo they wrapped could never be hit twice, since each instance was discarded after one call.
26
+ - **Breaking.** `Analysis::Dyad` gains double sharps in its enharmonic universe, which were previously built as candidates and then silently discarded by the parser. Candidate spellings grow from 28 to 35, and a major third gains three respellings.
27
+ - **Breaking.** `Rudiment::Scale::SCALE_REGEX` is removed. It was unreferenced, and its singles-only `[#b]?` was an instance of the asymmetry this release closes.
28
+ - **Breaking.** `KeySignature` and `Scale` memoization keys change for identifiers containing accidentals. `Fx`, `C##`, and `F𝄪` now unify onto one cache entry, as they name the same key signature.
29
+ - `Rudiment::Note::PITCH_PATTERN` no longer truncates a double sharp. `"C##4"` previously parsed as `["C", "#", nil]`, silently losing the register, because the optional quantifier bound to the pattern's final alternative rather than to the whole alternation.
30
+ - `Alteration#representations` now spans every symbol record rather than only the primary one, so a double sharp reports `"##"` alongside `"x"` and `𝄪`. It also accepts a `locale_code:` keyword and memoizes per locale, since the list includes the localized `#name`.
31
+ - An ABC `K:` field naming a double-altered tonic still raises `ParseError`, but with a message that says so — `Cannot express double-altered tonic Cx in an ABC K: field` rather than the incidental `Unrecognized mode` it produced when the narrower key pattern failed to match the accidental.
32
+
33
+ ### Removed
34
+
35
+ - `Content::Staff` no longer prints to standard output when it cannot resolve a clef key. It previously wrote `Warning: Clef '...' not found.` and `Using instrument clef.` to stdout. The fallback behavior is unchanged — an unrecognized key still resolves to the instrument's clef when an instrument is present, and to the treble clef otherwise — it is simply no longer announced.
36
+
10
37
  ## [17.5.0] - 2026-07-21
11
38
 
12
39
  ### Added
data/CLAUDE.md CHANGED
@@ -150,6 +150,26 @@ The gem supports multiple languages through the HeadMusic::Named mixin:
150
150
  - Shared examples in `spec/support/`
151
151
  - `composition_context.rb` provides test utilities
152
152
 
153
+ **Never assert on console output.** Do not use `output(...).to_stdout` or `.to_stderr`. A library should not print to the console, so a spec that asserts it printed something is pinning behavior the gem should not have — and it makes the printing hard to remove later, because deleting the `puts` breaks a test that looks unrelated to the change.
154
+
155
+ Test the behavior the message was describing instead. When a fallback or recovery path is worth pinning, assert what it produces and that it does not raise:
156
+
157
+ ```ruby
158
+ # Not this
159
+ it "prints a warning" do
160
+ expect { staff }.to output(/Warning: Clef 'foo' not found/).to_stdout
161
+ end
162
+
163
+ # This
164
+ it "recovers from the unrecognized key instead of raising" do
165
+ expect { staff }.not_to raise_error
166
+ end
167
+
168
+ it "prefers the instrument's clef over the generic fallback" do
169
+ expect(staff.clef).not_to eq described_class.new("foo").clef
170
+ end
171
+ ```
172
+
153
173
  ### Documentation Philosophy
154
174
 
155
175
  This project deliberately deprioritizes formal documentation in favor of clear, comprehensive tests.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (17.5.0)
4
+ head_music (18.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
@@ -12,12 +12,10 @@ class HeadMusic::Content::Staff
12
12
  begin
13
13
  @default_clef = HeadMusic::Rudiment::Clef.get(default_clef_key)
14
14
  rescue KeyError, NoMethodError
15
- puts("Warning: Clef '#{default_clef_key}' not found.")
16
- if @instrument
17
- puts("Using instrument clef.")
18
- @default_clef = @instrument.default_staves.first.clef
15
+ @default_clef = if @instrument
16
+ @instrument.default_staves.first.clef
19
17
  else
20
- @default_clef = HeadMusic::Rudiment::Clef.get(:treble_clef)
18
+ HeadMusic::Rudiment::Clef.get(:treble_clef)
21
19
  end
22
20
  end
23
21
  @line_count = line_count || DEFAULT_LINE_COUNT
@@ -44,6 +44,6 @@ class HeadMusic::Instruments::InstrumentName
44
44
  end
45
45
 
46
46
  def format_pitch_name(pitch_designation)
47
- pitch_designation.to_s.tr("b", "♭").tr("#", "♯")
47
+ HeadMusic::Utilities::Accidentals.to_unicode(pitch_designation)
48
48
  end
49
49
  end
@@ -4,7 +4,10 @@
4
4
  # KeySignature.get, which splits tonic and scale type on whitespace,
5
5
  # so the mode word is normalized into a "tonic mode" string first.
6
6
  class HeadMusic::Notation::ABC::KeyMapper
7
- KEY_PATTERN = /\A([A-G])([#♯b♭]?)\s*([A-Za-z]*)/
7
+ # The alteration group wraps the pattern in (?:...) before making it optional.
8
+ # Writing (...)? instead leaves match[2] nil for every unaltered key ("C", "Ador"),
9
+ # which raises downstream where the capture is interpolated into a string.
10
+ KEY_PATTERN = /\A([A-G])((?:#{HeadMusic::Rudiment::Alteration::PATTERN.source})?)\s*([A-Za-z]*)/
8
11
 
9
12
  # Mode words are matched case-insensitively on their first three letters,
10
13
  # so abbreviations ("dor") and full names ("Dorian") both resolve.
@@ -48,9 +51,15 @@ class HeadMusic::Notation::ABC::KeyMapper
48
51
  raise_render_error("Cannot render double-altered tonic #{spelling} in an ABC K: field")
49
52
  end
50
53
 
51
- # ABC convention uses ASCII "#"/"b" rather than the unicode signs
52
- # that Spelling#to_s produces.
53
- "#{spelling.letter_name}#{alteration&.ascii}"
54
+ # ABC convention uses ASCII "#"/"b" rather than the unicode signs that
55
+ # Spelling#to_s produces. The double-altered guard above runs first, since this
56
+ # would otherwise happily emit "Cx".
57
+ #
58
+ # A natural sign is dropped rather than converted. Accidentals.to_ascii preserves
59
+ # "♮" on purpose — mapping it away would turn the spelling "C♮" into the different
60
+ # spelling "C" — but an ABC K: field has no way to express a natural tonic, and
61
+ # "C♮ major" names the same key signature as "C major".
62
+ HeadMusic::Utilities::Accidentals.to_ascii(spelling.to_s).delete("♮")
54
63
  end
55
64
  private_class_method :tonic_string
56
65
 
@@ -87,7 +96,18 @@ class HeadMusic::Notation::ABC::KeyMapper
87
96
  raise_parse_error("Unrecognized key")
88
97
  end
89
98
 
99
+ # An ABC K: field cannot express a double-altered tonic, and .abc_value raises
100
+ # rather than rendering one. Reject it on the way in too: KEY_PATTERN interpolates
101
+ # the whole Alteration::PATTERN so that "bb" and "##" bind correctly, which also
102
+ # makes them matchable here. Without this guard "K:Cx" would parse to C𝄪 major and
103
+ # report "no sharps or flats" — a plausible-looking wrong answer where the narrower
104
+ # pattern used to give a clear parse error.
90
105
  def tonic
106
+ alteration = HeadMusic::Rudiment::Alteration.get(match[2])
107
+ if alteration && (alteration.double_sharp? || alteration.double_flat?)
108
+ raise_parse_error("Cannot express double-altered tonic #{match[1]}#{match[2]} in an ABC K: field")
109
+ end
110
+
91
111
  match[1] + match[2]
92
112
  end
93
113
 
@@ -15,7 +15,15 @@ class HeadMusic::Rudiment::Alteration < HeadMusic::Rudiment::Base
15
15
  YAML.load_file(File.expand_path("alterations.yml", __dir__), symbolize_names: true)[:alterations].freeze
16
16
 
17
17
  ALTERATION_IDENTIFIERS = ALTERATION_RECORDS.keys.freeze
18
- SYMBOLS = ALTERATION_RECORDS.map { |key, attributes| attributes[:symbols].map { |symbol| [symbol[:unicode], symbol[:ascii]] } }.flatten.reject { |s| s.nil? || s.empty? }.freeze
18
+ # Sorted longest-first because Regexp.union alternates in array order rather than by
19
+ # longest match. Without it, "bb" would match the single-flat branch and leave a
20
+ # stray "b" behind, half-converting a double. Ordering among equal-length symbols is
21
+ # unspecified and does not matter: equal-length strings cannot prefix one another.
22
+ SYMBOLS = ALTERATION_RECORDS
23
+ .map { |key, attributes| attributes[:symbols].map { |symbol| [symbol[:unicode], symbol[:ascii]] } }
24
+ .flatten.reject { |s| s.nil? || s.empty? }.uniq
25
+ .sort_by { |symbol| -symbol.length }
26
+ .freeze
19
27
  PATTERN = Regexp.union(SYMBOLS)
20
28
  MATCHER = PATTERN
21
29
 
@@ -24,7 +32,7 @@ class HeadMusic::Rudiment::Alteration < HeadMusic::Rudiment::Base
24
32
  end
25
33
 
26
34
  def self.symbols
27
- @symbols ||= all.map { |alteration| [alteration.ascii, alteration.unicode] }.flatten.reject { |s| s.nil? || s.empty? }
35
+ SYMBOLS
28
36
  end
29
37
 
30
38
  def self.symbol?(candidate)
@@ -53,9 +61,16 @@ class HeadMusic::Rudiment::Alteration < HeadMusic::Rudiment::Base
53
61
  super || identifier.to_s.tr("_", " ")
54
62
  end
55
63
 
56
- def representations
57
- [identifier, identifier.to_s, name, ascii, unicode, html_entity]
58
- .reject { |representation| representation.to_s.strip == "" }
64
+ # Spans every symbol record, not just the primary one, so that an alternate ASCII
65
+ # spelling like "##" resolves through .get. Memoized per locale because .get runs on
66
+ # every parse and walks this list for each alteration, and #name is locale-dependent.
67
+ def representations(locale_code: I18n.locale)
68
+ @representations ||= {}
69
+ @representations[locale_code] ||=
70
+ ([identifier, identifier.to_s, name(locale_code: locale_code)] +
71
+ musical_symbols.flat_map { |symbol| [symbol.ascii, symbol.unicode, symbol.html_entity] })
72
+ .uniq
73
+ .reject { |representation| representation.to_s.strip == "" }
59
74
  end
60
75
 
61
76
  ALTERATION_RECORDS.keys.each do |key|
@@ -27,6 +27,12 @@ alterations:
27
27
  double_sharp:
28
28
  semitones: 2
29
29
  symbols:
30
+ # "x" stays first: #ascii reads the first record, and ABC's accidental table
31
+ # is keyed on "x". "##" is accepted as input for symmetry with "bb", but is
32
+ # never emitted.
30
33
  - ascii: "x"
31
34
  unicode: "𝄪"
32
35
  html_entity: "&#119082;"
36
+ - ascii: "##"
37
+ unicode: "𝄪"
38
+ html_entity: "&#119082;"
@@ -18,7 +18,7 @@ class HeadMusic::Rudiment::KeySignature < HeadMusic::Rudiment::Base
18
18
 
19
19
  if identifier.is_a?(String)
20
20
  tonic_spelling, scale_type_name = identifier.strip.split(/\s/)
21
- hash_key = HeadMusic::Utilities::HashKey.for(identifier.gsub(/#|♯/, " sharp").gsub(/(\w)[b♭]/, '\\1 flat'))
21
+ hash_key = HeadMusic::Utilities::HashKey.for(identifier)
22
22
  @key_signatures[hash_key] ||= new(tonic_spelling, scale_type_name)
23
23
  elsif identifier.is_a?(HeadMusic::Rudiment::DiatonicContext)
24
24
  identifier.key_signature
@@ -16,8 +16,12 @@ class HeadMusic::Rudiment::Note < HeadMusic::Rudiment::RhythmicElement
16
16
  delegate :pitch_class, :midi_note_number, :frequency, to: :pitch
17
17
 
18
18
  # Regex pattern for parsing note strings like "C#4 quarter" or "Eb3 dotted half"
19
- # Extract the core pattern from Spelling::MATCHER without anchors
20
- PITCH_PATTERN = /([A-G])(#{HeadMusic::Rudiment::Alteration::PATTERN.source}?)(-?\d+)?/i
19
+ # Extract the core pattern from Spelling::MATCHER without anchors.
20
+ #
21
+ # The alteration group wraps the whole pattern in (?:...) before making it
22
+ # optional. Interpolating "#{...}?" instead binds the "?" to the pattern's final
23
+ # alternative alone, which truncates "C##4" to a bare "C#" and loses the register.
24
+ PITCH_PATTERN = /([A-G])((?:#{HeadMusic::Rudiment::Alteration::PATTERN.source})?)(-?\d+)?/i
21
25
  MATCHER = /^\s*(#{PITCH_PATTERN.source})\s+(.+)$/i
22
26
 
23
27
  def self.get(pitch, rhythmic_value = nil)
@@ -3,15 +3,13 @@ module HeadMusic::Rudiment; end
3
3
 
4
4
  # A scale contains ordered pitches starting at a tonal center.
5
5
  class HeadMusic::Rudiment::Scale < HeadMusic::Rudiment::Base
6
- SCALE_REGEX = /^[A-G][#b]?\s+\w+$/
7
-
8
6
  def self.get(root_pitch, scale_type = nil)
9
7
  root_pitch = HeadMusic::Rudiment::Pitch.get(root_pitch)
10
8
  scale_type = HeadMusic::Rudiment::ScaleType.get(scale_type || HeadMusic::Rudiment::ScaleType::DEFAULT)
11
9
  @scales ||= {}
12
- hash_key = HeadMusic::Utilities::HashKey.for(
13
- [root_pitch, scale_type].join(" ").gsub(/#|♯/, "sharp").gsub(/(\w)[b♭]/, '\\1flat')
14
- )
10
+ # Pitch#to_s already emits unicode accidentals and HashKey already desymbolizes
11
+ # them, so no accidental normalization is needed here.
12
+ hash_key = HeadMusic::Utilities::HashKey.for([root_pitch, scale_type].join(" "))
15
13
  @scales[hash_key] ||= new(root_pitch, scale_type)
16
14
  end
17
15
 
@@ -0,0 +1,137 @@
1
+ # A namespace for utilities classes and modules
2
+ module HeadMusic::Utilities; end
3
+
4
+ # Util for converting accidentals in a string between ASCII spellings and canonical
5
+ # unicode glyphs.
6
+ #
7
+ # Safe to run over prose. Conversion happens only inside a candidate chord token — an
8
+ # uppercase letter name that does not continue a longer word or number — so "Above",
9
+ # "Bebop", "1.0b2", and "0x1B2F" are untouched while "Bb", "Ab7", "Bbm", and
10
+ # "Bbmaj7#11" convert.
11
+ #
12
+ # Deliberately narrower than the gem's parsers in two ways, both of which follow from
13
+ # being safe over prose:
14
+ #
15
+ # 1. Case-sensitive. Spelling and Pitch accept a lowercase letter name; this does not,
16
+ # because a case-insensitive rule reads the "B" of "B7" as a flat sign and every
17
+ # lowercase word in a sentence becomes a candidate. So to_unicode("eb") is "eb"
18
+ # while Spelling.get("eb") is "E♭".
19
+ # 2. A bare accidental token is read as an accidental, not as a pitch name. to_unicode
20
+ # ("bb") is "𝄫" — the double-flat sign — while Spelling.get("bb") is "B♭". Callers
21
+ # holding a pitch name that may be lowercase should parse it with Spelling first.
22
+ #
23
+ # The maps are derived from Alteration lazily rather than at load time. Eager
24
+ # derivation is not an option anywhere in the load sequence: Alteration.all
25
+ # instantiates Notation::MusicalSymbol, which is not required until much later in
26
+ # head_music.rb.
27
+ class HeadMusic::Utilities::Accidentals
28
+ # Chord qualities that may follow a flat without a word boundary, so that "Bbm" and
29
+ # "Ebmaj7" convert while "Above" does not. "m" subsumes "maj", "min", and "m7";
30
+ # uppercase forms like "BbM7" already clear the (?![a-z]) guard unaided.
31
+ #
32
+ # Measured against /usr/share/dict/words: this list costs exactly two false
33
+ # positives ("Abmho", "Ebbman") across 235,976 capitalized words. Each entry clears
34
+ # a real word by one letter — "sus" vs "Absurd", "dim" vs "Abdomen" and "Abdicate" —
35
+ # so any addition here needs re-measuring rather than eyeballing.
36
+ CHORD_QUALITIES = %w[m dim aug sus].freeze
37
+
38
+ # The scale degrees a chord extension can alter. Requiring the altered degree to be
39
+ # one of these is what separates "C7b9" from "Figure C2b3": both are an accidental
40
+ # flanked by digits inside a token that opens with a letter name, and only the
41
+ # degree tells them apart.
42
+ EXTENSION_DEGREES = %w[13 11 9 6 5 4].freeze
43
+
44
+ # A candidate chord symbol. One quantifier over a character class, so there is no
45
+ # nesting and no ReDoS surface. This is what scopes the altered-extension rule
46
+ # below: "1.0b2" and "0x1B2F" never open a token, so they can never reach it.
47
+ CHORD_TOKEN = /(?<![A-Za-z0-9])[A-G][A-Za-z0-9#]*/
48
+
49
+ # "Bb" => "B♭", "C##" => "C𝄪", "Cx" => "C𝄪", "Bbmaj7#11" => "B♭maj7♯11".
50
+ # Idempotent, and all-or-nothing per accidental: a double never half-converts.
51
+ def self.to_unicode(text)
52
+ string = text.to_s
53
+ return unicode_for.fetch(string) if unicode_for.key?(string)
54
+
55
+ string.gsub(CHORD_TOKEN) do |token|
56
+ token.gsub(ascii_matcher) { |match| unicode_for.fetch(match) }
57
+ end
58
+ end
59
+
60
+ # "B♭" => "Bb", "C𝄪" => "Cx". Emits the spellings the gem's own parsers accept,
61
+ # so "x" rather than "##" for a double sharp.
62
+ #
63
+ # Deliberately not letter-anchored: unicode glyphs are unambiguous, and consumers
64
+ # feed this scale degrees and roman numerals ("♭7", "♭VI") that have no letter name.
65
+ # A natural sign is left as "♮" — mapping it to "" would turn the valid spelling
66
+ # "C♮" into the different spelling "C". Idempotent.
67
+ def self.to_ascii(text)
68
+ text.to_s.gsub(unicode_matcher) { |match| ascii_for.fetch(match) }
69
+ end
70
+
71
+ def self.unicode_for
72
+ @unicode_for ||=
73
+ altering_alterations.flat_map(&:musical_symbols)
74
+ .reject { |symbol| symbol.ascii.to_s.empty? }
75
+ .to_h { |symbol| [symbol.ascii, symbol.unicode] }
76
+ end
77
+
78
+ # One entry per alteration, keyed on its primary #ascii, so "##" is never emitted.
79
+ def self.ascii_for
80
+ @ascii_for ||= altering_alterations.to_h { |alteration| [alteration.unicode, alteration.ascii] }
81
+ end
82
+
83
+ # Applied only inside a CHORD_TOKEN. No /i flag — case-insensitivity would read the
84
+ # "B" of "B7" as a flat sign.
85
+ #
86
+ # Two anchor contexts. The first is the pitch name's own accidental, anchored to the
87
+ # letter name, with a per-family guard: "#" cannot occur in an English word at all,
88
+ # "b" can but is rescued by CHORD_QUALITIES, and "x" takes the bare guard because an
89
+ # allowlist there would buy "Cxm7" and cost "Axminster" and "Exmoor". The second is
90
+ # an altered extension ("7#11", "7b9"), which has no letter to anchor to and is
91
+ # instead flanked by digits. Being inside a CHORD_TOKEN is not sufficient on its own
92
+ # — "Figure C2b3" would qualify — so the altered degree must also be a real chord
93
+ # extension.
94
+ def self.ascii_matcher
95
+ @ascii_matcher ||=
96
+ /(?<=[A-G])(?:#{sharp_branch}|#{flat_branch}|#{double_sharp_branch})|#{extension_branch}/
97
+ end
98
+
99
+ def self.unicode_matcher
100
+ @unicode_matcher ||= Regexp.union(ascii_for.keys)
101
+ end
102
+ private_class_method :ascii_matcher, :unicode_matcher
103
+
104
+ def self.sharp_branch
105
+ longest_first(spellings_matching(/#/))
106
+ end
107
+
108
+ def self.flat_branch
109
+ "(?:#{longest_first(spellings_matching(/b/))})(?:(?![a-z])|(?=#{longest_first(CHORD_QUALITIES)}))"
110
+ end
111
+
112
+ def self.double_sharp_branch
113
+ "(?:#{longest_first(spellings_matching(/x/))})(?![a-z])"
114
+ end
115
+
116
+ def self.extension_branch
117
+ "(?<=\\d)(?:#{longest_first(spellings_matching(/[#b]/))})(?=(?:#{longest_first(EXTENSION_DEGREES)})(?![0-9]))"
118
+ end
119
+
120
+ def self.spellings_matching(pattern)
121
+ unicode_for.keys.grep(pattern)
122
+ end
123
+
124
+ # Regexp.union alternates in array order rather than by longest match, so every
125
+ # two-character spelling must precede its one-character prefix or a double
126
+ # half-converts.
127
+ def self.longest_first(spellings)
128
+ Regexp.union(spellings.sort_by { |spelling| -spelling.length }).source
129
+ end
130
+
131
+ def self.altering_alterations
132
+ HeadMusic::Rudiment::Alteration.all.reject(&:natural?)
133
+ end
134
+
135
+ private_class_method :sharp_branch, :flat_branch, :double_sharp_branch,
136
+ :extension_branch, :spellings_matching, :longest_first, :altering_alterations
137
+ end
@@ -3,39 +3,54 @@ module HeadMusic::Utilities; end
3
3
 
4
4
  # Util for converting an object to a consistent hash key
5
5
  class HeadMusic::Utilities::HashKey
6
+ # Keyed on the identifier as given, so a symbol and its string spelling occupy
7
+ # separate entries for the same result. Never evicted: the gem's identifiers come
8
+ # from finite catalogs, so the vocabulary is bounded. A caller generating
9
+ # identifiers dynamically would grow this without limit.
6
10
  def self.for(identifier)
7
11
  @hash_keys ||= {}
8
- @hash_keys[identifier] ||= new(identifier).to_sym
12
+ @hash_keys[identifier] ||=
13
+ HeadMusic::Utilities::Case.to_snake_case(
14
+ I18n.transliterate(desymbolized_string(identifier))
15
+ ).to_sym
9
16
  end
10
17
 
11
- attr_reader :original
12
-
13
- def initialize(identifier)
14
- @original = identifier
15
- end
16
-
17
- def to_sym
18
- normalized_string.to_sym
19
- end
20
-
21
- private
22
-
23
- def normalized_string
24
- @normalized_string ||=
25
- HeadMusic::Utilities::Case.to_snake_case(transliterated_string)
26
- end
27
-
28
- def transliterated_string
29
- I18n.transliterate(desymbolized_string)
30
- end
31
-
32
- def desymbolized_string
33
- original.to_s
34
- .gsub("𝄫", "_double_flat")
35
- .gsub("♭", "_flat")
36
- .gsub("♮", "_natural")
37
- .gsub("♯", "_sharp")
18
+ # Accidentals become word suffixes, so that "C♯" and "C sharp" reach the same key.
19
+ #
20
+ # ASCII spellings are normalized to their unicode glyphs first rather than mapped
21
+ # here. Mapping ASCII "b" directly is what a naive implementation gets wrong — it
22
+ # turns :bass_clarinet into :b_flatass_clarinet — and avoiding that by handling
23
+ # ASCII "#" but not ASCII "b" left the two sides asymmetric, with "C#" keying to
24
+ # :c_sharp while "Bb" keyed to :bb. Accidentals.to_unicode is prose-safe, so it
25
+ # closes that gap without the mangling.
26
+ #
27
+ # The two ASCII sharp rules that follow are a fallback, not a second inventory. They
28
+ # catch a "#" that Accidentals.to_unicode leaves alone because it is not attached to
29
+ # a letter name, and they are safe to apply bare because "#" cannot occur in a word —
30
+ # which is exactly why the corresponding ASCII flat rules must not exist here.
31
+ # Doubles first: the single rule would otherwise consume the first "#" of a "##".
32
+ def self.desymbolized_string(identifier)
33
+ normalized = HeadMusic::Utilities::Accidentals.to_unicode(identifier)
34
+ word_suffixes
35
+ .reduce(normalized) { |string, (glyph, suffix)| string.gsub(glyph, suffix) }
36
+ .gsub("##", "_double_sharp")
38
37
  .gsub("#", "_sharp")
39
- .gsub("𝄪", "_double_sharp")
40
38
  end
39
+ private_class_method :desymbolized_string
40
+
41
+ # Derived from Alteration so that alterations.yml stays the only place the glyph
42
+ # inventory lives. Each alteration's identifier is already the word form a key wants
43
+ # (:double_sharp -> "_double_sharp"). Every unicode glyph is a single character, so
44
+ # unlike the ASCII spellings they need no longest-first ordering.
45
+ #
46
+ # Resolved lazily, and it must stay that way: Alteration includes Named, whose
47
+ # .get_by_name calls back into HashKey. Nothing on Alteration's initialization path
48
+ # reaches that method today, which is what keeps the cycle from closing — so if
49
+ # Named ever starts building a hash key while constructing an object, this memo
50
+ # would recurse through a half-built Alteration.all.
51
+ def self.word_suffixes
52
+ @word_suffixes ||=
53
+ HeadMusic::Rudiment::Alteration.all.to_h { |alteration| [alteration.unicode, "_#{alteration.identifier}"] }
54
+ end
55
+ private_class_method :word_suffixes
41
56
  end
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "17.5.0"
2
+ VERSION = "18.0.0"
3
3
  end
data/lib/head_music.rb CHANGED
@@ -18,11 +18,11 @@ require "i18n/backend/fallbacks"
18
18
 
19
19
  # Configure I18n for HeadMusic locales
20
20
  # Include fallbacks backend if not already included.
21
- # :nocov: — one-time, load-order-dependent bootstrap guard; module inclusion is
22
- # irreversible, so the fresh-install arm cannot be re-exercised in-process. The
23
- # already-included arm is asserted by the idempotency spec.
21
+ # simplecov:disable — one-time, load-order-dependent bootstrap guard; module
22
+ # inclusion is irreversible, so the fresh-install arm cannot be re-exercised
23
+ # in-process. The already-included arm is asserted by the idempotency spec.
24
24
  I18n::Backend::Simple.include(I18n::Backend::Fallbacks) unless I18n::Backend::Simple.included_modules.include?(I18n::Backend::Fallbacks)
25
- # :nocov:
25
+ # simplecov:enable
26
26
 
27
27
  # Add HeadMusic locale files to the load path (additive, doesn't overwrite)
28
28
  I18n.load_path += Dir[File.join(File.dirname(__dir__), "lib", "head_music", "locales", "*.yml")]
@@ -45,14 +45,15 @@ HEAD_MUSIC_FALLBACKS = {
45
45
  }.freeze
46
46
 
47
47
  HEAD_MUSIC_FALLBACKS.each do |locale, fallbacks|
48
- # :nocov: — load-order-dependent bootstrap guard; whether the fresh-configure
49
- # arm runs depends on global I18n state at first require. The already-configured
50
- # arm is asserted by the idempotency spec.
48
+ # simplecov:disable — load-order-dependent bootstrap guard; whether the
49
+ # fresh-configure arm runs depends on global I18n state at first require. The
50
+ # already-configured arm is asserted by the idempotency spec.
51
51
  I18n.fallbacks[locale] = fallbacks if I18n.fallbacks[locale].empty? || I18n.fallbacks[locale] == [locale]
52
- # :nocov:
52
+ # simplecov:enable
53
53
  end
54
54
 
55
55
  # utilities
56
+ require "head_music/utilities/accidentals"
56
57
  require "head_music/utilities/case"
57
58
  require "head_music/utilities/hash_key"
58
59
 
@@ -0,0 +1,552 @@
1
+ <!--
2
+ metadata:
3
+ created_at: 2026-07-26T18:56:39-07:00
4
+ activated_at: 2026-07-26T19:28:59-07:00
5
+ planned_at: 2026-07-26T20:05:31-07:00
6
+ finished_at: 2026-07-27T07:27:30-07:00
7
+ updated_at: 2026-07-27T07:27:30-07:00
8
+ -->
9
+
10
+ # Story: Accidental String Normalization
11
+
12
+ ## Summary
13
+
14
+ AS a developer building on head_music
15
+ I WANT a string utility that converts accidentals between ASCII and canonical Unicode
16
+ SO THAT every consumer stops hand-rolling its own `gsub` chain and getting the double accidentals wrong
17
+
18
+ ## Acceptance Criteria
19
+
20
+ ### The utility
21
+
22
+ - A public utility converts ASCII accidentals in a string to canonical Unicode: `Bb` → `B♭`, `F#` → `F♯`, `Bbb` → `B𝄫`, `Cx` → `C𝄪`.
23
+ - **`##` converts to `𝄪`.** It's the notation most writers reach for, and the gem currently rejects it outright.
24
+ - The reverse direction is public too: Unicode → ASCII, emitting the spellings the gem's own parsers accept (`x` for double sharp, not `##`).
25
+ - Both directions are **idempotent** — running either on already-converted text is a no-op.
26
+ - Conversion is **all-or-nothing per accidental.** A double never half-converts; `C##` yields `C𝄪`, never `C♯#`.
27
+ - The utility handles a bare accidental token, a pitch name, and accidentals embedded in prose.
28
+ - English words containing accidental-shaped substrings are untouched: "Above", "Absence", "Bebop", "ebb", "back".
29
+
30
+ ### Consistent behavior across the gem
31
+
32
+ - **`##` is recognized everywhere `bb` is**, removing the asymmetry between the two doubles. `Alteration.get("##")` resolves to `double_sharp` and `Spelling.get("C##")` returns `C𝄪`, matching how `bb` already behaves.
33
+ - **Every hand-rolled conversion in the gem is replaced by the utility**, and the `gsub`/`tr` chain it replaced is deleted. The full list is in the Notes; none is left behind as a second implementation.
34
+ - Any two call sites given the same input produce the same output. Today they don't: `instrument_name.rb` renders `Bbb` as `B♭♭` while `Spelling` renders it `B𝄫`.
35
+ - A characterization spec pins the behavior of each migrated call site so the consolidation is provably lossless — the ABC notation writers in particular have consumers that reject Unicode.
36
+
37
+ ## Notes
38
+
39
+ ### Why this exists
40
+
41
+ Every consumer of the gem that needs to display or accept accidentals writes its own conversion, and they disagree with each other. This story consolidates that into one tested utility.
42
+
43
+ The immediate trigger: `bardtheory` needed to normalize drafted dictionary definitions and built `app/services/accidental_converter.rb` locally — six anchored regex rules plus a residue detector. It works, but it's the fourth or fifth independent implementation of this idea across the two repos, and it deliberately refuses to handle doubles because the gem gives it nowhere good to land them.
44
+
45
+ ### What the gem has today (verified 2026-07-26)
46
+
47
+ **Correct glyph data exists** — `lib/head_music/rudiment/alterations.yml`, loaded at `alteration.rb:14-15`:
48
+
49
+ | identifier | semitones | ascii | unicode | html_entity |
50
+ | --- | --- | --- | --- | --- |
51
+ | `double_flat` | -2 | `bb` | `𝄫` | `&#119083;` |
52
+ | `flat` | -1 | `b` | `♭` | `&#9837;` |
53
+ | `natural` | 0 | `` | `♮` | `&#9838;` |
54
+ | `sharp` | 1 | `#` | `♯` | `&#9839;` |
55
+ | `double_sharp` | 2 | `x` | `𝄪` | `&#119082;` |
56
+
57
+ The glyphs are the correct single codepoints — 𝄫 U+1D12B and 𝄪 U+1D12A, not the doubled-single fallback `♭♭`/`♯♯`. This is the right foundation; the story is about exposing it, not fixing it.
58
+
59
+ **A pitch-name round-trip works** via parse-then-`to_s`, and it already handles doubles:
60
+
61
+ ```ruby
62
+ Spelling.get("Bb") # => "B♭"
63
+ Spelling.get("Bbb") # => "B𝄫"
64
+ Spelling.get("Cx") # => "C𝄪"
65
+ Spelling.get("C##") # => nil <-- the gap
66
+ ```
67
+
68
+ **`##` is not recognized.** `Alteration::PATTERN` (`alteration.rb:19-20`) is:
69
+
70
+ ```ruby
71
+ /𝄫|bb|♭|b|♮|♯|\#|𝄪|x/
72
+ ```
73
+
74
+ `#` appears as a single alternative with no two-character branch, so `Alteration.get("##")` returns `nil` and `Spelling::MATCHER` (`spelling.rb:11`) rejects `"C##"` entirely. Double flat is spelled `bb` and works; double sharp is spelled only `x`. That asymmetry is the surprising part, and it's what makes `##` the specific thing to fix.
75
+
76
+ **No general string utility exists.** There is no `to_unicode`, `normalize`, `canonical`, or `glyph` method in `lib/`. `Spelling`/`Pitch` expose an `Alteration` but have no `ascii`/`unicode` method of their own.
77
+
78
+ ### The hand-rolled implementations to replace
79
+
80
+ - `lib/head_music/instruments/instrument_name.rb:47` — `format_pitch_name`, a private `tr("b", "♭").tr("#", "♯")`. **Singles only, and it corrupts doubles**: `"Bbb"` becomes `"B♭♭"`, and `"Above"` would become `"A♭ove"` if it ever saw prose.
81
+ - `lib/head_music/rudiment/key_signature.rb:21` and `lib/head_music/rudiment/scale.rb:13` — inline `gsub(/#|♯/, "sharp").gsub(/(\w)[b♭]/, '\1flat')`, singles only.
82
+ - `lib/head_music/notation/abc/key_mapper.rb:53` and `abc/pitch_writer.rb:53` — manual `alteration&.ascii` concatenation for the Unicode → ASCII direction.
83
+ - `lib/head_music/notation/abc/key_mapper.rb:7` — `KEY_PATTERN = /\A([A-G])([#♯b♭]?)\s*([A-Za-z]*)/`, singles only.
84
+ - `lib/head_music/utilities/hash_key.rb:32-40` — `desymbolized_string` maps accidentals to word suffixes. Handles both doubles, but targets snake_case symbols rather than notation. Note the ordering: `gsub("♯", "_sharp")` runs before `gsub("𝄪", "_double_sharp")`, harmless only because `𝄪` is a distinct codepoint — worth tidying while nearby.
85
+
86
+ Downstream in `bardtheory`:
87
+
88
+ - `app/services/accidental_converter.rb` — six anchored rules for pitches, Roman numerals, and scale degrees, plus a residue detector. Deliberately declines doubles. Should collapse onto this utility once it exists, keeping only its Roman-numeral and scale-degree rules, which are notation the gem doesn't model.
89
+ - `app/services/pivot_chord_service.rb:127` — `gsub("♭", "b").gsub("♯", "#").gsub("\u{1D12B}", "bb")` for the Unicode → ASCII direction. **Omits `\u{1D12A}` (𝄪)**, so a double sharp survives as Unicode and then fails the `Pitch.get` that consumes it.
90
+
91
+ ### Design notes for whoever picks this up
92
+
93
+ **Prose is the hard part, not pitch names.** A pitch name has a known shape; prose does not. The rule that makes prose safe is anchoring every pattern to a musical context rather than to a bare `b` or `#`. Two findings from bardtheory's implementation, both measured against a real 65-document corpus, are worth inheriting:
94
+
95
+ 1. **No `/i` flag.** Case-insensitivity has a real false positive: `B7` (a B dominant seventh) becomes `♭7`. Every genuine flat-degree is lowercase; every pitch name is uppercase. Case-sensitivity separates them for free.
96
+ 2. **`(?![a-z])`, not a trailing `\b`.** A literal `\b` correctly rejects "Above" and "Absence" but also rejects `Ab7`, a genuine flat. The negative lookahead does both correctly.
97
+
98
+ **Half-conversion is the failure mode to design against.** A naive single-accidental rule applied to `C##` converts the first symbol and leaves `C♯#` — worse than not converting, because it looks converted. Match doubles before singles.
99
+
100
+ **`##` is accepted everywhere, not just by the utility** (decided 2026-07-26). Widen `Alteration::PATTERN` so `Spelling.get("C##")` works, rather than having the utility pre-map `##` → `x` and leaving the grammar asymmetric. Widening changes parsing behavior for existing callers, so it wants the characterization specs above — but the alternative leaves the gem permanently accepting `bb` while rejecting `##`, which is the confusing state this story exists to end.
101
+
102
+ Note the ordering constraint this creates: `##` must be matched **before** the single `#` alternative in `PATTERN`, or the regex engine takes the single and leaves a stray `#`. The same applies to `bb` before `b`, which the current pattern already gets right.
103
+
104
+ **Naturals are a live question.** `natural`'s `ascii` is the empty string, so `Alteration.get("")` returns `nil` and `Spelling.get("C").to_s` is `"C"` — naturals never render as `♮`. Whether the utility should ever *emit* `♮` needs deciding; the safe default is no.
105
+
106
+ **The downstream consumer is a real constraint, not hypothetical.** `bardtheory` has a story waiting on this one (`accidental-converter-on-head-music`), and its `AccidentalConverter` is the closest thing to a reference implementation for the prose case — six anchored rules, measured against a 65-document corpus. Worth reading before designing the prose matcher rather than starting cold.
107
+
108
+ ## Learnings
109
+
110
+ ### Measurement beat intuition every time it came up
111
+
112
+ Three design questions were settled by running candidates against a real corpus rather than reasoning about them, and in each case the measured answer differed from the plausible one:
113
+
114
+ - Extending the chord-quality allowlist to `x` looks symmetric with extending it to `b`. It costs `Axminster`, `Exmoor`, `Axman`, `Axion` and buys only `Cxm7`, which is not real notation.
115
+ - Including mode words (`dor`, `ion`, `lyd`) in the allowlist looks obviously right for a music gem. It costs five `Ebion*` words and buys nothing, because space-free mode syntax is ABC input that reaches `KEY_PATTERN`, not this utility.
116
+ - `sus` clears `Absurd` by one letter; `dim` clears `Abdomen` and `Abdicate` by one. Nothing about that is visible by inspection.
117
+
118
+ The measurements are now pinned as specs, so the next person doesn't have to re-derive them — and can't silently break them.
119
+
120
+ ### Characterization specs inherit the blind spots of whoever writes the migration
121
+
122
+ The most useful finding of the whole story. A 21-key ABC no-Unicode guard was written *specifically* to catch unicode leaking into a `K:` field. The leak that actually happened was on a natural-spelled tonic, and every key in the sample had a plain or accidental tonic. Right idea, wrong sample, zero protection.
123
+
124
+ The same shape recurred in the hostile-input corpus for the extension rule: every case started with a digit or lowercase (`1.0b2`, `0x1b2f`, `1b2c3d`), so the letter-initial shape (`Figure C2b3`, `A1b2`) was entirely unpinned. **Collecting examples is not the same as varying the shape systematically.** When building negative test cases, enumerate the structural variants first, then find an instance of each — not the other way round.
125
+
126
+ This is a direct argument for adversarial review by someone who didn't write the code. Both blind spots were found by review, and neither could have been found by the specs as written.
127
+
128
+ ### Widening a parser is not symmetric with widening a writer
129
+
130
+ Making `KEY_PATTERN` accept `##` also made it accept `Cx` as an ABC tonic, which produced a confident wrong answer — `C𝄪 major`, "no sharps or flats" — where the narrow pattern had raised a clean `ParseError`. The writer already refused to render such a tonic, so reader and writer silently disagreed.
131
+
132
+ **Replacing an error with a plausible lie is a regression even when no test fails.** Any time a pattern is widened, ask separately what the *other* direction now has to reject.
133
+
134
+ ### Check where a test actually runs before trusting it
135
+
136
+ The word-list sweep read the host's `/usr/share/dict/words` and asserted a macOS-specific result. CI runs `ubuntu-latest`, which has no such file, so the spec skipped — the guard it promised never ran where it mattered, and would have failed spuriously on a Linux box with a different word list. Vendoring a fixture made it deterministic and real. A skip is not a pass.
137
+
138
+ ### Check whether an "out of scope" is yours or inherited
139
+
140
+ Altered extensions (`Bbmaj7#11`) were deferred because the plan grouped them with Roman numerals and scale degrees. Challenged, the grouping didn't survive: `#11` is part of a chord symbol, a Roman numeral isn't. The work took about ten minutes and closed a real gap — half-converting a chord symbol is the same failure mode as half-converting a double.
141
+
142
+ The lesson is not "be more ambitious." It's that a scope boundary copied from an upstream document should be re-derived before it's defended.
143
+
144
+ ### Parallel review with different mandates found disjoint defects
145
+
146
+ The product-manager and code-reviewer agents ran concurrently over the same commit and their findings barely overlapped. The PM found the natural-sign leak and the missing acceptance-criteria pins; the reviewer found the parse-side asymmetry, the CI fragility, and the locale-dependent memoization. Neither would have surfaced the other's set. Two mandates, not two passes.
147
+
148
+ ### Verify the plan's own claims
149
+
150
+ The plan asserted `KeySignature.get("C bebop")` hashed to `:c_be_flatop`. True of the `gsub` in isolation, unreachable through the public API — `bebop` is not a scale type this gem defines. One claim in maybe forty, but it had a spec written against it before it was checked. Plans are evidence, not fact.
151
+
152
+ ### Accepted debt
153
+
154
+ Criterion 10 ("none is left behind as a second implementation") is **partially met, accepted deliberately**. `lib/head_music/utilities/hash_key.rb` still hard-codes all five glyphs, mapping glyph → word suffix (`♭` → `_flat`). That is a third direction the utility doesn't model, so it is duplication of the glyph *inventory* rather than a competing conversion. The list is small, stable, and now spec-covered. Deriving it from `Alteration` remains a clean follow-up if the inventory ever grows again.
155
+
156
+ ## Review
157
+
158
+ Reviewed 2026-07-26 against commit `75e54ee` by a product-manager agent (acceptance criteria) and a code-reviewer agent (quality and correctness), running in parallel. Every finding below was independently reproduced before being acted on. Fixes are uncommitted at the time of writing; the suite is at **6316 examples, 0 failures**, RuboCop clean, no vulnerabilities, 99.70% line coverage, RubyCritic 87.94.
159
+
160
+ ### Acceptance criteria
161
+
162
+ | # | Criterion | Verdict |
163
+ | --- | --- | --- |
164
+ | 1 | ASCII → canonical Unicode (`Bb`, `F#`, `Bbb`, `Cx`) | ✅ met |
165
+ | 2 | `##` converts to `𝄪` | ✅ met |
166
+ | 3 | Reverse direction public, emits `x` not `##` | ✅ met |
167
+ | 4 | Both directions idempotent | ✅ met — swept 30,000 words plus the full corpus, zero non-idempotent inputs |
168
+ | 5 | All-or-nothing per accidental | ✅ met for valid notation; `C###` → `C𝄪#` is now pinned as known residue |
169
+ | 6 | Bare token, pitch name, prose | ✅ met, all three tested |
170
+ | 7 | English words untouched | ✅ met — independently re-measured over 235,976 words |
171
+ | 8 | `##` recognized everywhere `bb` is | ✅ met; the two assertions the story names verbatim had **no spec** and now do |
172
+ | 9 | Any two call sites agree | ✅ met for uppercase input; the lowercase divergence is real and now documented |
173
+ | 10 | Every hand-rolled conversion replaced | ⚠️ **partially met** — see below |
174
+ | 11 | Characterization spec per migrated call site | ✅ met after fixes; three sites had no spec at review time |
175
+
176
+ ### Findings fixed during review
177
+
178
+ **1. ABC regression — unicode leaking into a `K:` field (blocking).** `KeyMapper.abc_value(KeySignature.get("C♮ major"))` returned `"C♮"`. The migration replaced a concatenation that dropped naturals for free (`Alteration#ascii` is `""` for a natural) with `to_ascii`, which preserves `♮` by design. The double-altered guard above it catches doubles but not naturals. Fixed by dropping the natural explicitly at the ABC boundary. **This is exactly what the ABC guard specs existed to catch and didn't** — all 21 keys in the guard list had a plain or accidental tonic, never a natural one.
179
+
180
+ **2. ABC reader accepted what the writer refuses (blocking).** Widening `KEY_PATTERN` to the full `Alteration::PATTERN` was necessary for the `(?:...)` fix, but it also admitted `bb`, `##`, and `x` as tonics. `K:Cx` parsed to `C𝄪 major` reporting "no sharps or flats" — musically wrong — where the narrower pattern used to raise a clean `ParseError`. A plausible-looking wrong answer is worse than an error. Fixed by mirroring the double-altered guard on the parse side.
181
+
182
+ **3. The extension rule fired on non-chord identifiers.** `Figure C2b3` → `Figure C2♭3`, `A1b2` → `A1♭2`. The chord-token scope blocks digit-initial strings (`1.0b2`, `0x1B2F`) but not letter-initial ones, and every hostile case in the spec started with a digit or lowercase letter, so the whole shape was unpinned. Fixed by requiring the altered degree to be a real chord extension (`EXTENSION_DEGREES = %w[13 11 9 6 5 4]`). Measured: eliminates all seven false positives with zero must-convert regressions and an unchanged dictionary baseline.
183
+
184
+ **4. The dictionary spec was green-by-skip on CI.** It read the host's `/usr/share/dict/words` and asserted exact equality against a macOS `web2`-specific result (`Abmho` is a web2-ism). CI runs `ubuntu-latest`, which has no such file — so the guard the comment promised never ran where it mattered, and would have failed spuriously on a Linux box with `wamerican`. Replaced with a vendored 2,391-word fixture (`spec/support/fixtures/prose_words_at_risk.txt`) containing every capitalized word whose second character could begin an accidental — the exact set the guards protect.
185
+
186
+ **5. Three migrated call sites had no characterization spec** despite being plan steps: `Note::PITCH_PATTERN`, `HashKey`, and the whole-document ABC guard. All three now pinned.
187
+
188
+ **6. `representations` memoization froze a locale-dependent value.** `#name` resolves through `I18n.locale`. Inert today (only `:en` names are registered), but latent: once the German names in `locales/de.yml` are wired up, `Alteration.get("Kreuz")` would depend on which locale was active at first call. Now memoized per locale, with the duplicate entries the widening introduced deduped.
189
+
190
+ **7. Two undocumented narrowings.** The utility is deliberately case-sensitive and reads a bare token as an accidental, so `to_unicode("eb")` is `"eb"` while `Spelling.get("eb")` is `E♭`, and `to_unicode("bb")` is `𝄫` while `Spelling.get("bb")` is `B♭`. Both follow from being safe over prose. Now stated in the class comment, the CHANGELOG, and specs.
191
+
192
+ **8. Minor.** `ascii_matcher`/`unicode_matcher` made private (no external consumer); the equal-length sort-stability caveat noted where it could mislead; a redundant map assertion dropped.
193
+
194
+ ### Accepted, not fixed
195
+
196
+ - **`hash_key.rb` remains a second hard-coded glyph inventory** (criterion 10). It maps glyph → word suffix (`♭` → `_flat`), a third direction the utility doesn't model, so `to_unicode`/`to_ascii` genuinely cannot replace it — but the criterion as written says "none is left behind as a second implementation," and by the letter one is. Deriving it from `Alteration` is a reasonable follow-up.
197
+ - **`pitch_writer.rb` not migrated.** Correct: it's a lookup key into `ACCIDENTAL_FRAGMENTS.invert`, not a conversion, and the `""` → `"="` entry means natural must stay in the map. The `#ascii` invariant it depends on is now pinned.
198
+ - **`C###` → `C𝄪#` half-converts** while `Bbbb` is left whole. Neither is valid notation; the asymmetry is recorded as a spec rather than hidden.
199
+ - **`dyad_spec` pins a private method and two magic counts.** Deliberate — the surrounding assertions are `be_an(Array)`, which is what let the 28 → 35 change pass silently in the first place.
200
+
201
+ ### Verified sound
202
+
203
+ No reachable `KeyError` in either gsub block (every alternative derives from the same map; `CHORD_QUALITIES` and `EXTENSION_DEGREES` appear only in zero-width lookaheads). Both regex-interpolation fixes are correct with unchanged capture counts, and `pitch/parser.rb` and `spelling.rb` were correctly left alone — they already wrap the interpolation in a group. `nil`, `""`, frozen strings, newlines, and already-converted input all behave. No ReDoS: 50,000 words in 33ms.
204
+
205
+ ## Implementation Plan
206
+
207
+ ### Overview
208
+
209
+ Add `HeadMusic::Utilities::Accidentals` — a class-method-only utility mirroring `Utilities::Case` — that derives its ASCII↔Unicode maps **lazily** from `Alteration.all`, keeping `alterations.yml` the single source of truth. Add `##` as a second `symbols:` record under `double_sharp`, sort `SYMBOLS` longest-first, and widen `representations` to span all symbol records. Then delete each hand-rolled chain — or, where the input is already Unicode, delete it in favor of nothing at all.
210
+
211
+ The forward direction converts **whole chord symbols**, not just pitch names: `Bbmaj7#11` → `B♭maj7♯11`. It does this by scoping conversion to a candidate chord token, which is what makes the altered-extension rule safe to include at all.
212
+
213
+ The planner reports verifying the change set against patched scratch copies: the full suite passes all 6097 examples with exactly two failures, the hard-coded regex literals at `alteration_spec.rb:101-102`, which should be behavioral regardless. Re-verify during implementation rather than trusting this.
214
+
215
+ ### Decisions on the open questions
216
+
217
+ **1. Home: `HeadMusic::Utilities::Accidentals`, required at `lib/head_music.rb:57`.**
218
+
219
+ Load order is a non-issue *because every `Alteration` reference sits inside a memoized class method* — nothing touches it at class-definition time. This matters more than it looks: a file at line 58 referencing `Alteration::PATTERN` eagerly raises `uninitialized constant HeadMusic::Rudiment` (the *module* doesn't exist yet), and `Alteration.all` is poisoned at **every** load point because `initialize_musical_symbols` instantiates `Notation::MusicalSymbol`, unavailable until line 156. Lazy memoization dissolves both. This also rules out a `Notation` home — `Rudiment` would then depend on a module loaded 90 lines later — and rules out `Alteration` itself, a value object with `private_class_method :new`.
220
+
221
+ **2. The `(?![a-z])` guard is per-alternative, and flats additionally take a chord-quality allowlist.** *(refines the story's settled note; decided 2026-07-26)*
222
+
223
+ Applied uniformly the guard is a false-negative machine — `F#m`, `A#m`, `C#maj7`, `F#dim` all silently unchanged, and `key_mapper.rb`'s own doc comment cites `F#m` as its motivating input. `#` cannot occur in an English word, so the guard is pure cost there. That fixes the sharp side but leaves an asymmetry: `F#m` converts while `Bbm` and `Ebmaj7` do not, because the flat side must keep its guard or `Above` breaks.
224
+
225
+ **That asymmetry is not acceptable — all three must convert.** Closed by giving the flat branch an escape hatch: match when followed by a non-lowercase character **or** by a lowercase chord quality. So the three branches are:
226
+
227
+ | branch | rule | why |
228
+ | --- | --- | --- |
229
+ | `##`, `#` | no guard | cannot occur in an English word |
230
+ | `bb`, `b` | `(?![a-z])` **or** `(?=m\|dim\|aug\|sus)` | needs the guard for `Above`; needs the allowlist for `Bbm` |
231
+ | `x` | `(?![a-z])` only | allowlist here costs `Axman`, `Axminster`, `Exmoor` and buys only `Cxm7`, which is not real notation |
232
+
233
+ `m` subsumes `maj`, `min`, `mix`, and `m7`, so the list is four entries, not a chord vocabulary. Uppercase suffixes (`BbM7`) already pass the plain guard for free.
234
+
235
+ Measured against `/usr/share/dict/words` (235,976 words, each capitalized, since the `(?<=[A-G])` lookbehind only fires on uppercase):
236
+
237
+ | design | must-convert failures | dictionary false positives |
238
+ | --- | --- | --- |
239
+ | guard only, uniform | 10 | 5 (pre-existing: `Ab`, `Abb`, `Ax`, `Ebb`, `Ex`) |
240
+ | + allowlist on flats **and** `x`, incl. mode words | 0 | 23 (+18: `Axman`, `Axminster`, `Exmoor`, `Ebionism`…) |
241
+ | + allowlist on flats only, incl. mode words | 0 | 12 (+7) |
242
+ | **+ chord qualities on flats only** ← chosen | **0** | **7 (+2: `Abmho`, `Ebbman`)** |
243
+
244
+ Mode words (`dor`, `phr`, `lyd`, `ion`…) are deliberately excluded: they cost five `Ebion*` false positives and buy nothing, because space-free mode syntax (`Ebdor`) is ABC input, which reaches `KEY_PATTERN` in step 8 rather than this utility. Prose writes "E♭ Dorian" with a space.
245
+
246
+ Residual cost: `Abmho` and `Ebbman`, both capitalized, both absent from any plausible music-theory corpus.
247
+
248
+ **2a. Altered extensions are in scope too** *(decided 2026-07-26)*
249
+
250
+ `Bbmaj7#11`, `C7b9`, `G7#5b9`, `Db7b9#11` must convert fully, not just their pitch-name accidental. These have no letter name to anchor to — the accidental is flanked by digits — so they need a second rule, and applied globally that rule is unsafe (`1.0b2`, `0x1b2f`, `revision 2b1` all convert).
251
+
252
+ The fix is to scope conversion to a **candidate chord token** — an uppercase letter name that doesn't continue a longer word or number — and run both rules only inside it. Beta versions, hex literals, and `Figure 2b` never open such a token, so the digit-flanked rule can't reach them.
253
+
254
+ | rule | must-convert failures | false positives (29-case adversarial list) |
255
+ | --- | --- | --- |
256
+ | letter-anchored only (previous plan) | 9 | 0 |
257
+ | + naive digit-flanked, applied globally | 0 | 5 (`1.0b2`, `0x1b2f`, `1b2c3d`, `version 3b7`, `revision 2b1`) |
258
+ | **+ digit-flanked, scoped to a chord token** ← chosen | **0** | **0** |
259
+
260
+ The token-scoped version leaves the dictionary baseline byte-identical (same 7 words), stays idempotent, preserves all-or-nothing (`C##` → `C𝄪`, no stray `#`), and is ReDoS-free: 2.6ms on `"A" + "b" * 40_000`, 2.7ms on 250KB of prose. Note this also *strengthens* prose safety — the token boundary is a second line of defense behind the per-family guards, not a replacement for them.
261
+
262
+ Scale degrees (`b7`) and Roman numerals (`♭VI`) still stay downstream, and fall out for free: neither opens with an uppercase letter name, so neither enters a token.
263
+
264
+ **3. No `♮` in either direction.**
265
+
266
+ `to_unicode` *cannot* emit it: natural's `ascii` is `""`, filtered out of the map, so no token could trigger it. `to_ascii` leaves a literal `♮` untouched, because mapping `♮` → `""` is **lossy** — `Spelling.get("C♮").to_s == "C♮"` and `Pitch.get("C♮4").to_s == "C♮4"`, so `to_unicode(to_ascii("C♮"))` would yield `"C"` and destroy information the gem models. ABC distinguishes them too (`=C` vs `C`). Consequence to document: `to_ascii` is "ASCII for *altering* accidentals," not "pure ASCII."
267
+
268
+ **4. `##` via YAML second record + longest-first sort + widened `representations`.**
269
+
270
+ All three are required; each alone is insufficient. Sorting rather than reordering the YAML is deliberate — reordering would silently change `Alteration.all` and `ALTERATION_IDENTIFIERS` ordering, whereas the sort touches only the regex.
271
+
272
+ **5. Forward anchored, reverse unanchored.**
273
+
274
+ Unicode glyphs are unambiguous, so the reverse direction must have no letter-anchor, or `♭VI` and `♭7` — exactly what bardtheory feeds it — stop converting and the downstream consolidation fails.
275
+
276
+ ### Recommended public API
277
+
278
+ ```ruby
279
+ # lib/head_music/utilities/accidentals.rb
280
+ module HeadMusic::Utilities; end
281
+
282
+ # Converts accidentals in a string between ASCII spellings and canonical Unicode
283
+ # glyphs. Safe to run over prose: conversion happens only inside a token that opens
284
+ # with an uppercase letter name, so "Above", "Bebop", and "1.0b2" are untouched
285
+ # while "Bb", "Ab7", and "Bbmaj7#11" convert.
286
+ #
287
+ # Maps are derived from Alteration lazily rather than at load time, so this file can
288
+ # be required with the other utilities, before rudiments. Eager derivation is not an
289
+ # option anywhere in the load sequence: Alteration.all instantiates
290
+ # Notation::MusicalSymbol, which is not required until much later.
291
+ class HeadMusic::Utilities::Accidentals
292
+ # A candidate chord symbol: an uppercase letter name that doesn't continue a longer
293
+ # word or number, plus the chord-ish characters that may follow. One quantifier over
294
+ # a character class -- no nesting, so no ReDoS surface. This is what scopes the
295
+ # extension rule below: "1.0b2" and "0x1B2F" never open a token, so they can't reach it.
296
+ CHORD_TOKEN = /(?<![A-Za-z0-9])[A-G][A-Za-z0-9#]*/
297
+
298
+ # "Bb" => "B♭", "C##" => "C𝄪", "Bbmaj7#11" => "B♭maj7♯11". Idempotent.
299
+ def self.to_unicode(text)
300
+ string = text.to_s
301
+ return unicode_for.fetch(string) if unicode_for.key?(string)
302
+
303
+ string.gsub(CHORD_TOKEN) do |token|
304
+ token.gsub(ascii_matcher) { |match| unicode_for.fetch(match) }
305
+ end
306
+ end
307
+
308
+ # "B♭" => "Bb", "C𝄪" => "Cx". Emits the spellings the gem's parsers accept.
309
+ # Deliberately NOT letter-anchored: unicode glyphs are unambiguous, and "♭VI"
310
+ # must convert. A natural sign is left as "♮" -- mapping it to "" would turn the
311
+ # valid spelling "C♮" into the different spelling "C". Idempotent.
312
+ def self.to_ascii(text)
313
+ text.to_s.gsub(unicode_matcher) { |match| ascii_for.fetch(match) }
314
+ end
315
+
316
+ def self.unicode_for
317
+ @unicode_for ||=
318
+ altering_alterations.flat_map(&:musical_symbols)
319
+ .reject { |symbol| symbol.ascii.to_s.empty? }
320
+ .to_h { |symbol| [symbol.ascii, symbol.unicode] }
321
+ end
322
+
323
+ # One entry per alteration, using its primary #ascii, so "##" is never emitted.
324
+ def self.ascii_for
325
+ @ascii_for ||= altering_alterations.to_h { |alteration| [alteration.unicode, alteration.ascii] }
326
+ end
327
+
328
+ # Chord qualities that may follow a flat without a word boundary, so that "Bbm"
329
+ # and "Ebmaj7" convert while "Above" does not. "m" subsumes "maj", "min", and
330
+ # "m7"; uppercase forms like "BbM7" already clear the (?![a-z]) guard unaided.
331
+ # Mode words are excluded on purpose -- space-free mode syntax ("Ebdor") is ABC
332
+ # input, which KEY_PATTERN handles, and "ion" alone costs five false positives.
333
+ CHORD_QUALITIES = %w[m dim aug sus].freeze
334
+
335
+ # Applied only inside a CHORD_TOKEN. Longest-first so a double never half-converts.
336
+ # No /i flag -- case-insensitivity would read "B7" as "♭7".
337
+ #
338
+ # Two anchor contexts. The first is the pitch name's own accidental, anchored to the
339
+ # letter name, with a per-family guard: "#" cannot occur in an English word at all,
340
+ # "b" can but is rescued by the chord-quality allowlist, and "x" takes the bare guard
341
+ # because an allowlist there buys "Cxm7" and costs "Axminster"/"Exmoor". The second is
342
+ # an altered extension ("7#11", "7b9"), which has no letter to anchor to and is instead
343
+ # flanked by digits -- safe only because CHORD_TOKEN already established we are inside
344
+ # a chord symbol.
345
+ def self.ascii_matcher
346
+ @ascii_matcher ||=
347
+ /(?<=[A-G])(?:#{sharp_branch}|#{flat_branch}|#{double_sharp_branch})|#{extension_branch}/
348
+ end
349
+
350
+ def self.extension_branch
351
+ "(?<=\\d)(?:#{longest_first(unicode_for.keys.grep(/[#b]/))})(?=\\d)"
352
+ end
353
+
354
+ def self.unicode_matcher
355
+ @unicode_matcher ||= Regexp.union(ascii_for.keys)
356
+ end
357
+
358
+ def self.sharp_branch
359
+ longest_first(spellings_matching(/#/))
360
+ end
361
+
362
+ def self.flat_branch
363
+ "(?:#{longest_first(spellings_matching(/b/))})(?:(?![a-z])|(?=#{longest_first(CHORD_QUALITIES)}))"
364
+ end
365
+
366
+ def self.double_sharp_branch
367
+ "(?:#{longest_first(spellings_matching(/x/))})(?![a-z])"
368
+ end
369
+
370
+ def self.spellings_matching(pattern)
371
+ unicode_for.keys.grep(pattern)
372
+ end
373
+
374
+ def self.longest_first(spellings)
375
+ Regexp.union(spellings.sort_by { |spelling| -spelling.length }).source
376
+ end
377
+ private_class_method :sharp_branch, :flat_branch, :double_sharp_branch,
378
+ :extension_branch, :spellings_matching, :longest_first
379
+
380
+ def self.altering_alterations
381
+ HeadMusic::Rudiment::Alteration.all.reject(&:natural?)
382
+ end
383
+ private_class_method :altering_alterations
384
+ end
385
+ ```
386
+
387
+ The bare-token fast path (`unicode_for.fetch`) handles `"b"` → `"♭"` and `"##"` → `"𝄪"` without widening the regex, because a context-free `b` in prose is exactly the false positive the lookbehind exists to prevent. `to_ascii(to_unicode("C##")) == "Cx"` — the normalizing round trip the story asks for.
388
+
389
+ **Why two levels of `gsub` rather than one flat pattern.** The altered-extension rule (`7#11`, `7b9`) can't anchor to a letter name — the accidental is flanked by digits. Applied globally that rule is unsafe: it converts `1.0b2` (a beta version), `0x1b2f` (hex), and `revision 2b1`. Scoping it inside `CHORD_TOKEN` removes the entire class of false positive, because none of those strings opens a token with an uppercase letter name. Measured: the naive digit-flanked rule produces 5 false positives on a 29-case adversarial list; the token-scoped version produces 0, and leaves the 236k-word dictionary baseline byte-identical at the same 7 words.
390
+
391
+ ### Steps
392
+
393
+ **1. Characterization specs — land first, green on unmodified code**
394
+
395
+ - **ABC no-Unicode guard** (highest value; ABC consumers reject Unicode). Add across the existing round-trip examples in `abc/writer_spec.rb`, `key_mapper_spec.rb`, `pitch_writer_spec.rb`: `expect(described_class.abc_value(key_signature)).not_to match(/[♭♯♮𝄫𝄪]/)` and the equivalent for `writer.token(pitch)` and the whole document. No such guard exists today.
396
+ - **`key_signature_spec.rb`** — the hash key is private, so pin the observable: memoization identity (`.get("Bb major")` equals itself), `.get("Bb major") == .get("B♭ major")`, `.get("Bbb major").name == "B𝄫 major"`, `.get("Fx major").name == "F𝄪 major"`.
397
+ - **`scale_spec.rb`** — same shape, plus `.get("Bb4","major").pitch_names.first == "B♭4"`.
398
+ - **`dyad_spec.rb`** — pin **exact counts**: `all_spellings.size == 28`, `Dyad.new("C4","E4").enharmonic_respellings.size == 5`. Existing assertions are `be > 0` / `be_an(Array)`, so the coming growth passes silently.
399
+ - **`alteration_spec.rb`** — pin `Alteration.get(:double_sharp).ascii == "x"`, the invariant `PitchWriter` silently depends on.
400
+ - **`instrument_name_spec.rb`** — extend the existing `"in B♭"` (line 12) and `"in F♯"` (line 17).
401
+
402
+ **2. Add the utility**
403
+
404
+ `lib/head_music/utilities/accidentals.rb` + `require` at `lib/head_music.rb:57` + `spec/head_music/utilities/accidentals_spec.rb`. No consumers yet; independently mergeable. Include a spec that the utility works on a cold `require "head_music"` with no prior `Alteration` use.
405
+
406
+ **3. Widen `##`**
407
+
408
+ `alterations.yml`: second `symbols:` record under `double_sharp` (`ascii: "##"`, keeping the `x` record **first**). `alteration.rb:18`: append `.uniq.sort_by { |s| -s.length }` (the `.uniq` is load-bearing — the YAML record otherwise duplicates `𝄪`). `alteration.rb:56-59`: memoize and widen `representations` to span all `musical_symbols`, or the new row is inert and `Spelling.get("C##")` returns a bare `C` — worse than today's honest `nil`. The memoization is reported as a 10x win (200k calls: `get(nil)` 3.55s → 0.38s, `get("#")` 2.68s → 0.16s), and `get` runs on every parse. `alteration.rb:26-28`: align `self.symbols` with `SYMBOLS` or delete it — it currently disagrees with `symbol?` and `PATTERN` and has zero callers. Replace `alteration_spec.rb:101-102`'s literal-regex assertions with behavioral ones (`expect("C##"[PATTERN]).to eq "##"`).
409
+
410
+ **4. Fix `rudiment/note.rb:20`**
411
+
412
+ `PITCH_PATTERN = /([A-G])(#{Alteration::PATTERN.source}?)(-?\d+)?/i` binds the `?` to the final `x` branch only; it works today by accident (the group can match empty only via `x?`). Change to `((?:#{...})?)`. Keep exactly 3 capture groups (`note.rb:38` reads `match[5]`); `Regexp.union` adds none (it emits `(?-mix:…)`), so `register.rb:30-34` stays safe.
413
+
414
+ Verified: `"C##4".match(PITCH_PATTERN).captures` is currently `["C", "#", nil]` — it truncates at the first `#` and loses the register entirely. The longest-first sort in step 3 fixes this case on its own, but the `?` binding is wrong regardless and should be corrected while here.
415
+
416
+ **4a. Full list of `Alteration::PATTERN` consumers** (grep-verified; step 3 widens the pattern under all of them)
417
+
418
+ - `rudiment/spelling.rb:11` — `MATCHER`, embeds it with `/i`. The primary target.
419
+ - `rudiment/note.rb:20` — `PITCH_PATTERN`, the `?`-binding bug above.
420
+ - `rudiment/pitch/parser.rb:12` — `(#{Alteration::PATTERN.source})?`. The `?` is correctly *outside* the group here, so this one is already right; no change needed, but it's the counter-example that makes the `note.rb` form recognizable as a mistake.
421
+ - `rudiment/pitch.rb:126` — `Pitch#natural`, `to_s.gsub(PATTERN, "")`. Widening is a strict improvement: it currently strips `C##4` to `C4` only because `#` matches twice; with `##` in the union it strips as one token. Verified `Pitch.get("C#4").natural.to_s == "C4"` today.
422
+ - `rudiment/register.rb:30-34` — consumes `Spelling::MATCHER`'s captures positionally; group count must not change.
423
+
424
+ **5. `instrument_name.rb:46-48`**
425
+
426
+ `format_pitch_name` becomes `Accidentals.to_unicode(pitch_designation)`; delete the `tr` chain. Spec-only impact: no catalog instrument uses a double (`pitch_key` values are `a a_flat b_flat c d e_flat f g`).
427
+
428
+ **6. `key_signature.rb:21`**
429
+
430
+ `HashKey.for(Accidentals.to_unicode(identifier))`; delete both `gsub`s. Differential over 33 inputs — 27 identical, 6 changed, all improvements:
431
+
432
+ | identifier | old | new |
433
+ | --- | --- | --- |
434
+ | `"Bbb major"` | `:b_flatb_major` | `:b_double_flat_major` |
435
+ | `"Fx major"` / `"Cx minor"` | `:fx_major` / `:cx_minor` | `:f_double_sharp_major` / `:c_double_sharp_minor` |
436
+ | `"C## major"` | `:c_sharp_sharp_major` | `:c_double_sharp_major` |
437
+ | **`"C bebop"`** | **`:c_be_flatop`** | `:c_bebop` |
438
+ | `"bb major"` | `:b_flat_major` *(collides)* | `:bb_major` |
439
+
440
+ The first four *unify* `Fx`/`C##`/`F𝄪` onto one cache entry — correct, they are the same key signature.
441
+
442
+ **Correction found during implementation:** the `"C bebop"` → `:c_be_flatop` mangling is real in the gsub but **unreachable through the public API** — `bebop` is not a scale type this gem defines, so `KeySignature.get("C bebop")` raises in `ScaleType` before the hash key matters. The `blues_*` scale types that *do* exist are unaffected, because `(\w)[b♭]` requires a word character before the `b` and theirs is preceded by a space. The spec written for this pins the reachable case (`C blues_minor_pentatonic`) instead.
443
+
444
+ **7. `scale.rb:12-14`**
445
+
446
+ Collapse to `HashKey.for([root_pitch, scale_type].join(" "))`. **No utility call needed:** `Pitch#to_s` already emits Unicode and `HashKey` already desymbolizes it, so the whole chain is dead (`\w` doesn't even match the astral `𝄫`/`𝄪`). Measured: `:bflat4_major` → `:b_flat4_major`, `:fsharp4_minor` → `:f_sharp4_minor`, no collisions. Also **delete `SCALE_REGEX` (line 6)** — unreferenced in `lib/` and `spec/`, and its `[#b]?` is the same singles-only trap this story closes.
447
+
448
+ **8. `key_mapper.rb:7`**
449
+
450
+ `KEY_PATTERN = /\A([A-G])((?:#{Alteration::PATTERN.source})?)\s*([A-Za-z]*)/`. **The inner `(?:...)?` is load-bearing:** writing `(...)?` makes `match[2]` `nil` and raises `TypeError: no implicit conversion of nil into String` at line 91 for every *unaltered* key — it breaks `C`, `Ador`, `Cmaj`, `Dm`. No mode word begins with `b`, `#`, or `x`, so the widened group cannot eat one (`Bloc`/`Blocrian` checked).
451
+
452
+ **9. `key_mapper.rb:53`**
453
+
454
+ `Accidentals.to_ascii(spelling.to_s)`. Keep the double-altered `raise` at lines 47-49 **above** it; ABC `K:` genuinely cannot express doubles, and `to_ascii` would happily emit `Cx`.
455
+
456
+ **10. `hash_key.rb:32-40`**
457
+
458
+ Reorder doubles before singles and add `"##" => "_double_sharp"` *before* `"#" => "_sharp"` (today the line-38 `#` rule means any appended `##` rule can never fire). Do **not** add ASCII `b` → `_flat`; it would mangle `blues_major_pentatonic`, `bass_clarinet`, and every other key containing `b`. Tidying, not behavior — after step 6, `##` no longer reaches `HashKey` from `KeySignature`.
459
+
460
+ **11. Leave `pitch_writer.rb:52-57` alone**
461
+
462
+ Recorded here so it isn't mistaken for an oversight. `pitch.alteration&.ascii.to_s` is not a conversion chain; it is a lookup key into `ACCIDENTAL_FRAGMENTS.invert` (`{"#"=>"^","x"=>"^^","b"=>"_","bb"=>"__",""=>"="}`), and `#ascii` still returns `"x"` after the YAML change. Substituting `to_ascii` produces identical strings with more indirection.
463
+
464
+ **12. Record the `Dyad` expansion**
465
+
466
+ `dyad.rb:80` sets `ALTERATION_SIGNS[2] = "##"`, builds `"C##"` spellings that `Spelling.get` returns `nil` for, and `.compact`s them away, so double sharps are **absent from the gem's enharmonic universe today**. After step 3: `all_spellings` 28 → 35, `enharmonic_respellings` for C4/E4 5 → 8. Spec comments at `dyad_spec.rb:322` and `:420` name `C##` as an expected-but-missing equivalent, suggesting this is the latent intent. Update the step-1 counts; don't suppress it.
467
+
468
+ **13. Ship as a major version**
469
+
470
+ `bundle exec rubocop -a`, `bundle exec rake validate`, bump `lib/head_music/version.rb` from `17.5.0` to **`18.0.0`** (decided 2026-07-26 — this is a breaking release, not a feature release).
471
+
472
+ The `### Added` entry covers the utility and `##` support, but the CHANGELOG needs a **`### Changed` / `### Breaking`** section that names each behavior change a consumer could be pinned to:
473
+
474
+ - `Spelling.get("C##")` returns a `Spelling` instead of `nil`. Anything using that `nil` as a validation signal now silently accepts double sharps. Same for `Pitch.get`, `Note.get`, and ABC `K:` fields.
475
+ - `Alteration::PATTERN` and `SYMBOLS` gain `##` and reorder longest-first. Anything embedding them in its own regex changes behavior.
476
+ - `KeySignature` hash keys change for six inputs (step 6 table), including the `Fx`/`C##`/`F𝄪` unification onto one cache entry.
477
+ - `Scale` hash keys change (`:bflat4_major` → `:b_flat4_major`).
478
+ - `Dyad#all_spellings` grows 28 → 35 and `#enharmonic_respellings` grows correspondingly, as double sharps enter the enharmonic universe for the first time.
479
+ - `Scale::SCALE_REGEX` is removed (unreferenced, but public by Ruby's rules).
480
+ - `Alteration.symbols` is aligned or removed.
481
+
482
+ ### Testing Strategy
483
+
484
+ **Prose corpus** — all verified against the three-branch matcher:
485
+
486
+ | Converts | | Left alone | |
487
+ | --- | --- | --- | --- |
488
+ | `F#m` → `F♯m` | `Bbm` → `B♭m` | `Above`, `Absence`, `Abbey` | `B7` (the `/i` trap) |
489
+ | `A#m` → `A♯m` | `Ebmaj7` → `E♭maj7` | `Bebop`, `ebb`, `back` | `DB`, `Text`, `Exit` |
490
+ | `C#maj7` → `C♯maj7` | `Abmin` → `A♭min` | `subbass`, `cabbage` | `## Heading` (Markdown) |
491
+ | `F#dim` → `F♯dim` | `Bbsus4`, `Ebdim7`, `Abaug` | `Bach`, `Debussy` | `C4 quarter`, `Gb` |
492
+ | `Ab7` → `A♭7` | `BbM7` → `B♭M7` | `Absurd`, `Abdomen`, `Abdicate` | `Ebbing`, `Abbot`, `Adagio` |
493
+ | `Bb`/`Bbb`/`Cx`/`C##` | bare `b`/`#`/`bb`/`##`/`x` | `Abstract`, `Aberrant`, `Ebony` | `Fabric`, `Dabble`, `Abundant` |
494
+ | `Bbmaj7#11` → `B♭maj7♯11` | `C7b9` → `C7♭9` | `1.0b2`, `0x1b2f`, `0x1B2F` | `version 3b7`, `revision 2b1` |
495
+ | `G7#5b9` → `G7♯5♭9` | `Db7b9#11` → `D♭7♭9♯11` | `Figure 2b`, `Chapter 7b` | `1b2c3d`, `Deadbeef`, `DEADBEEF` |
496
+
497
+ The `Absurd`/`Abdomen`/`Abdicate` column is not decoration — those are the near-misses the four-entry allowlist survives by one letter each (`sur` vs `sus`, `dom` vs `dim`, `dic` vs `dim`). Any future addition to `CHORD_QUALITIES` must be re-measured against the dictionary, not eyeballed.
498
+
499
+ **Dictionary sweep as a spec.** Guard the allowlist with a test that runs the matcher over `/usr/share/dict/words` (capitalized) and asserts the false-positive set is exactly `%w[Ab Abb Ax Ebb Ex Abmho Ebbman]` — five pre-existing, two added. This turns "we measured it once" into a regression test. Skip gracefully when the file is absent, since it is not present on all CI images.
500
+
501
+ Known limitations to pin honestly: **`Ebb and flow` → `E𝄫 and flow`**, **`Ex` → `E𝄪`**, and the two dictionary words above. `x` cannot leave the ASCII set without breaking round-trip idempotence; all are bounded by the uppercase lookbehind.
502
+
503
+ **Altered extensions are in scope** and must convert whole: `Bbmaj7#11` → `B♭maj7♯11`, not `B♭maj7#11`. Half-converting a chord symbol is the same failure mode as half-converting a double — it looks converted. Pin `G7#5b9` and `Db7b9#11` specifically, since they exercise the extension rule twice in one token.
504
+
505
+ **Hostile-input corpus for the token scope** — these must all survive untouched, and they are the reason the extension rule is token-scoped rather than global: `1.0b2`, `0x1b2f`, `0x1B2F`, `1b2c3d`, `version 3b7`, `revision 2b1`, `Figure 2b`, `Chapter 7b`, `Deadbeef`, `DEADBEEF`.
506
+
507
+ **Real-world symbols, all verified against the design:**
508
+
509
+ | input | output |
510
+ | --- | --- |
511
+ | `Bb/D` | `B♭/D` (slash chords split into two tokens; both sides convert) |
512
+ | `F#m7b5/Eb` | `F♯m7♭5/E♭` |
513
+ | `Bbmaj7#11/D` | `B♭maj7♯11/D` |
514
+ | `Ebmaj9#11` | `E♭maj9♯11` |
515
+ | `Ab13b9` | `A♭13♭9` |
516
+ | `Gb7sus4` | `G♭7sus4` |
517
+ | `C7alt` | `C7alt` (unchanged — nothing to convert) |
518
+
519
+ The single best regression case, because it exercises prose safety and both conversion rules in one line:
520
+
521
+ ```ruby
522
+ Accidentals.to_unicode("Above the Bbm7 we play Ebmaj7.")
523
+ # => "Above the B♭m7 we play E♭maj7."
524
+ ```
525
+
526
+ `Above` survives, `Bbm7` converts on the chord-quality allowlist, `Ebmaj7` likewise — and the word that breaks the naive implementation sits directly next to the chord that breaks the guard-only one.
527
+
528
+ Also required: idempotency both directions; all-or-nothing (`expect(to_unicode("C##")).not_to include("#")`); `to_ascii` round-trip over all 28 spellings reparsing to the identical `Spelling`; `Accidentals.ascii_for["𝄪"] == "x"` (guards the `musical_symbols.first` dependency); `Note.get("C4 quarter")`/`("C##4 quarter")`/`("Cx4 quarter")`; `Pitch.get("C##4").natural.to_s == "C4"`; `Spelling.get("CX")` still `nil`; a cold-require spec. Run the full suite after each step against the 6097-example baseline; coverage ≥90%.
529
+
530
+ **Checked, not a risk:** ReDoS — flat alternation of literals, no nesting, no quantifier over a group; `"A" + "b"*40_000` in 1.5ms, 245KB prose in 9ms. Astral handling is clean (Ruby strings are codepoint-based), with one trap: `𝄫`/`𝄪` are non-word chars, so `/𝄫\b/` **matches** `"C𝄫x"` — independent confirmation that `\b` was the wrong guard.
531
+
532
+ ### Risks / watch out for
533
+
534
+ - **`KEY_PATTERN`'s inner `(?:...)?`** — get this wrong and every *unaltered* ABC key raises `TypeError`. The failure is in the common path, not the new one.
535
+ - **The uniform `(?![a-z])` guard** — stated as settled in the story; applied uniformly it silently refuses `F#m`, and guard-only (without the allowlist) silently refuses `Bbm` and `Ebmaj7`. Both failure modes are silent no-ops, which is exactly the "looks converted" trap the story warns about, one level up.
536
+ - **`CHORD_QUALITIES` is measured, not intuited.** `sus` clears `Absurd` by one letter and `dim` clears `Abdomen`/`Abdicate` by one. Adding an entry without re-running the dictionary sweep is how this regresses.
537
+ - **`CHORD_TOKEN` is load-bearing for safety, not just tidiness.** The extension rule is only safe *because* it runs inside the token. Anyone who later "simplifies" `to_unicode` into a single flat `gsub` reintroduces `1.0b2` → `1.0♭2` and hex corruption. The two-level `gsub` needs a comment saying so — it is written in the API sketch above; keep it.
538
+ - **`Regexp.union` is not longest-match.** It alternates in array order, so every branch builder sorts longest-first. `CHORD_TOKEN`'s trailing `[A-Za-z0-9#]*` deliberately excludes `b`-as-a-terminator concerns by including `b` in the alphanumeric class — verify the token still ends where you expect on `Bb/D` slash chords (it splits into two tokens, which is correct).
539
+ - **`representations` must widen with the YAML** — otherwise `Spelling.get("C##")` returns a bare `C`, silently.
540
+ - **`ascii_for` depends on `musical_symbols.first`.** Reorder the `double_sharp` YAML records and `to_ascii` starts emitting `C##`, which `Pitch.get` accepts but ABC's `ACCIDENTAL_FRAGMENTS` does not — a failure with no visible link to a YAML edit.
541
+ - **The `Dyad` growth is invisible to the suite.** Loose assertions mean it passes silently; it is the largest behavioral change in the story and the story never mentions it.
542
+ - **`##` becomes valid input everywhere** — `Pitch::Parser`, `Note`, `KeySignature`, ABC `K:` fields. Downstream code treating `Spelling.get("C##") == nil` as a validation signal now gets a spelling. This is the breaking-change surface, and the reason step 13 ships `18.0.0` rather than a minor bump.
543
+ - **Pre-existing, out of scope, do not accidentally "fix":** `KeySignature.get("D flat major")` and `("Db major")` share hash key `:d_flat_major` under both old and new schemes; the former raises `NoMethodError` if called *first* and returns D♭ major if called second. The characterization specs will lock this order-dependence in — flag it as known.
544
+ - **Downstream needs a release, not a path gem.** bardtheory's story is blocked on a published `18.0.0`, and must budget for the major-version upgrade rather than a drop-in bump. The unanchored reverse direction is what makes its consolidation possible; its `pivot_chord_service.rb:127` omits `𝄪` and is fixed for free. Roman-numeral (`♭VI`) and scale-degree (`♭7`) rules **must stay downstream** — head_music models neither. The audit found **17** independent implementations across the two repos, not the four or five the story estimates, plus a third direction (accidental → spoken words, 4 sites) that `Alteration#name` already serves with i18n — worth a follow-up story.
545
+
546
+ ### Open questions
547
+
548
+ 1. ~~**The `F#m` / `Bbm` asymmetry, and altered extensions.**~~ **RESOLVED 2026-07-26 — fully closed, nothing outstanding.** `F#m`, `Bbm`, and `Ebmaj7` convert via the four-entry `CHORD_QUALITIES` allowlist (decision 2); `Bbmaj7#11`, `C7b9`, and friends convert via the token-scoped extension rule (decision 2a). Both measured. Scale degrees and Roman numerals remain downstream, which the token scope enforces structurally rather than by convention.
549
+ 2. **Should `KEY_PATTERN` accept doubles at all,** given `abc_value` raises on rendering them? Widening buys symmetric parse/render errors but lets a field other ABC tools reject through the front door. Planned: widen, for the single-source-of-truth goal.
550
+ 3. **Residue detection in the gem?** `to_unicode("Bbbb")` returns the input unchanged — correct all-or-nothing, but *silently*, and silence is indistinguishable from "nothing to convert." Planned: keep the utility pure `String -> String`, leave detection downstream; a separate predicate is far cheaper than changing the return type.
551
+ 4. **`Dyad`'s 28 → 35 expansion** — intended, or does `all_spellings` want a "practical spellings" filter? The spec comments suggest someone already wondered and settled for a loose assertion.
552
+ 5. **`to_ascii` output is not pure ASCII** (retains `♮`). Acceptable, or add a separate affordance for `C♮` → `C`? `Pitch#natural` already does the pitch-object version.
@@ -277,6 +277,10 @@
277
277
  ],
278
278
  "active": [],
279
279
  "done": [
280
+ {
281
+ "title": "Accidental String Normalization",
282
+ "path": "done/accidental-string-normalization.md"
283
+ },
280
284
  {
281
285
  "title": "Lyrics",
282
286
  "path": "done/lyrics.md"
@@ -403,15 +407,15 @@
403
407
  },
404
408
  {
405
409
  "title": "Untitled",
406
- "path": "done/superclass-for-note.md"
410
+ "path": "done/expand-playing-techniques.md"
407
411
  },
408
412
  {
409
413
  "title": "Untitled",
410
- "path": "done/expand-playing-techniques.md"
414
+ "path": "done/instrument-variant.md"
411
415
  },
412
416
  {
413
417
  "title": "Untitled",
414
- "path": "done/instrument-variant.md"
418
+ "path": "done/superclass-for-note.md"
415
419
  }
416
420
  ]
417
421
  };
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: head_music
3
3
  version: !ruby/object:Gem::Version
4
- version: 17.5.0
4
+ version: 18.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Head
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-21 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -445,6 +445,7 @@ files:
445
445
  - lib/head_music/time/smpte_timecode.rb
446
446
  - lib/head_music/time/tempo_event.rb
447
447
  - lib/head_music/time/tempo_map.rb
448
+ - lib/head_music/utilities/accidentals.rb
448
449
  - lib/head_music/utilities/case.rb
449
450
  - lib/head_music/utilities/hash_key.rb
450
451
  - lib/head_music/value_equality.rb
@@ -464,6 +465,7 @@ files:
464
465
  - user-stories/done/abc-notation-export.md
465
466
  - user-stories/done/abc-notation-interpreter.md
466
467
  - user-stories/done/abc-tie-input.md
468
+ - user-stories/done/accidental-string-normalization.md
467
469
  - user-stories/done/chord-placement-model.md
468
470
  - user-stories/done/composition-serialization.md
469
471
  - user-stories/done/configurable-large-leap-recovery.md