head_music 18.0.0 → 20.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.
Files changed (218) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +4 -0
  3. data/.github/workflows/ci.yml +4 -4
  4. data/.github/workflows/release.yml +2 -2
  5. data/.github/workflows/security.yml +1 -1
  6. data/CHANGELOG.md +211 -2
  7. data/CLAUDE.md +1 -1
  8. data/Gemfile +6 -2
  9. data/Gemfile.lock +6 -7
  10. data/README.md +77 -0
  11. data/Rakefile +23 -0
  12. data/bin/guide_grade_corpus.rb +124 -0
  13. data/bin/guide_grade_table.rb +357 -0
  14. data/bin/guide_item_strings.rb +35 -0
  15. data/lib/head_music/analysis/diatonic_interval/consonance_questions.rb +43 -0
  16. data/lib/head_music/analysis/diatonic_interval/inversion.rb +31 -0
  17. data/lib/head_music/analysis/diatonic_interval/localization.rb +38 -0
  18. data/lib/head_music/analysis/diatonic_interval/parser.rb +20 -1
  19. data/lib/head_music/analysis/diatonic_interval.rb +24 -69
  20. data/lib/head_music/content/composition.rb +4 -0
  21. data/lib/head_music/content/voice/continuity.rb +47 -0
  22. data/lib/head_music/content/voice/melodic_line.rb +51 -4
  23. data/lib/head_music/content/voice.rb +23 -74
  24. data/lib/head_music/instruments/instrument.rb +2 -7
  25. data/lib/head_music/locales/de.yml +156 -0
  26. data/lib/head_music/locales/en.yml +433 -0
  27. data/lib/head_music/locales/en_GB.yml +168 -0
  28. data/lib/head_music/locales/es.yml +145 -0
  29. data/lib/head_music/locales/fr.yml +149 -0
  30. data/lib/head_music/locales/it.yml +144 -0
  31. data/lib/head_music/locales/ru.yml +173 -0
  32. data/lib/head_music/notation/abc/body_lexer.rb +222 -299
  33. data/lib/head_music/notation/abc/chord_reader.rb +56 -0
  34. data/lib/head_music/notation/abc/chord_scanner.rb +78 -0
  35. data/lib/head_music/notation/abc/parser.rb +19 -101
  36. data/lib/head_music/notation/abc/repeat_tagger.rb +67 -0
  37. data/lib/head_music/notation/abc/token.rb +21 -0
  38. data/lib/head_music/notation/abc/voice_registry.rb +51 -0
  39. data/lib/head_music/notation/abc/volta_passes.rb +28 -0
  40. data/lib/head_music/notation/abc/writer.rb +2 -22
  41. data/lib/head_music/notation/{music_xml/clef_selector.rb → clef_selector.rb} +2 -2
  42. data/lib/head_music/notation/lily_pond/duration_writer.rb +32 -0
  43. data/lib/head_music/notation/lily_pond/key_mapper.rb +38 -0
  44. data/lib/head_music/notation/lily_pond/pitch_writer.rb +36 -0
  45. data/lib/head_music/notation/lily_pond/preflight.rb +64 -0
  46. data/lib/head_music/notation/lily_pond/render_plan.rb +94 -0
  47. data/lib/head_music/notation/lily_pond/string_text.rb +12 -0
  48. data/lib/head_music/notation/lily_pond/writer.rb +127 -0
  49. data/lib/head_music/notation/lily_pond.rb +14 -0
  50. data/lib/head_music/notation/music_xml/duration_writer.rb +1 -7
  51. data/lib/head_music/notation/music_xml/lyric_writer.rb +60 -0
  52. data/lib/head_music/notation/music_xml/note_writer.rb +105 -0
  53. data/lib/head_music/notation/music_xml/preflight.rb +7 -42
  54. data/lib/head_music/notation/music_xml/render_plan.rb +1 -2
  55. data/lib/head_music/notation/music_xml/writer.rb +14 -143
  56. data/lib/head_music/notation/music_xml/xml_text.rb +21 -0
  57. data/lib/head_music/notation/preflight_checks.rb +40 -0
  58. data/lib/head_music/notation.rb +2 -0
  59. data/lib/head_music/rudiment/base.rb +21 -0
  60. data/lib/head_music/rudiment/chromatic_interval.rb +1 -2
  61. data/lib/head_music/rudiment/consonance.rb +1 -2
  62. data/lib/head_music/rudiment/key_signature.rb +1 -3
  63. data/lib/head_music/rudiment/letter_name.rb +2 -4
  64. data/lib/head_music/rudiment/meter.rb +1 -2
  65. data/lib/head_music/rudiment/note.rb +1 -2
  66. data/lib/head_music/rudiment/pitch/arithmetic.rb +46 -0
  67. data/lib/head_music/rudiment/pitch/helmholtz_notation.rb +24 -0
  68. data/lib/head_music/rudiment/pitch/natural_letter_pitch.rb +35 -0
  69. data/lib/head_music/rudiment/pitch/natural_step.rb +7 -2
  70. data/lib/head_music/rudiment/pitch/step_distance.rb +36 -0
  71. data/lib/head_music/rudiment/pitch.rb +24 -66
  72. data/lib/head_music/rudiment/pitch_class.rb +1 -2
  73. data/lib/head_music/rudiment/qualified_diatonic_context.rb +1 -2
  74. data/lib/head_music/rudiment/quality.rb +1 -2
  75. data/lib/head_music/rudiment/register.rb +3 -5
  76. data/lib/head_music/rudiment/rest.rb +1 -3
  77. data/lib/head_music/rudiment/rhythmic_value.rb +8 -0
  78. data/lib/head_music/rudiment/scale.rb +1 -2
  79. data/lib/head_music/rudiment/scale_type.rb +1 -2
  80. data/lib/head_music/rudiment/spelling.rb +13 -5
  81. data/lib/head_music/rudiment/unpitched_note.rb +1 -2
  82. data/lib/head_music/style/composite_assessment.rb +97 -0
  83. data/lib/head_music/style/guide.rb +122 -0
  84. data/lib/head_music/style/guide_assessment.rb +138 -0
  85. data/lib/head_music/style/guide_item.rb +118 -0
  86. data/lib/head_music/style/guide_item_assessment.rb +63 -0
  87. data/lib/head_music/style/guideline/harmonic_context.rb +40 -0
  88. data/lib/head_music/style/guideline/melodic_context.rb +44 -0
  89. data/lib/head_music/style/guideline/strength.rb +71 -0
  90. data/lib/head_music/style/guideline/voice_context.rb +41 -0
  91. data/lib/head_music/style/guideline/wording.rb +79 -0
  92. data/lib/head_music/style/guideline.rb +128 -0
  93. data/lib/head_music/style/guidelines/{allowed_rhythmic_values_for_fifth_species.rb → allow_fifth_species_rhythmic_values.rb} +1 -4
  94. data/lib/head_music/style/guidelines/{allowed_rhythmic_values_for_combined123.rb → allow_whole_half_quarter_notes.rb} +2 -4
  95. data/lib/head_music/style/guidelines/always_move.rb +1 -3
  96. data/lib/head_music/style/guidelines/approach_perfection_contrarily.rb +1 -3
  97. data/lib/head_music/style/guidelines/avoid_crossing_voices.rb +1 -3
  98. data/lib/head_music/style/guidelines/avoid_overlapping_voices.rb +1 -3
  99. data/lib/head_music/style/guidelines/consonant_climax.rb +23 -2
  100. data/lib/head_music/style/guidelines/consonant_downbeats.rb +1 -3
  101. data/lib/head_music/style/guidelines/contoured.rb +11 -24
  102. data/lib/head_music/style/guidelines/diatonic.rb +1 -3
  103. data/lib/head_music/style/guidelines/direction_changes.rb +1 -3
  104. data/lib/head_music/style/guidelines/directional_step_to_final_note.rb +1 -1
  105. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +2 -7
  106. data/lib/head_music/style/guidelines/end_on_perfect_consonance.rb +1 -3
  107. data/lib/head_music/style/guidelines/end_on_tonic.rb +1 -3
  108. data/lib/head_music/style/guidelines/first_bar_entry.rb +1 -1
  109. data/lib/head_music/style/guidelines/first_bar_half_notes.rb +0 -2
  110. data/lib/head_music/style/guidelines/first_bar_quarter_notes.rb +0 -2
  111. data/lib/head_music/style/guidelines/first_bar_whole_note.rb +0 -2
  112. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +1 -4
  113. data/lib/head_music/style/guidelines/frequent_direction_changes.rb +4 -1
  114. data/lib/head_music/style/guidelines/large_leaps.rb +12 -6
  115. data/lib/head_music/style/guidelines/limit_octave_leaps.rb +8 -3
  116. data/lib/head_music/style/guidelines/maximum_notes.rb +6 -3
  117. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +5 -2
  118. data/lib/head_music/style/guidelines/minimum_notes.rb +5 -2
  119. data/lib/head_music/style/guidelines/minimum_threshold.rb +1 -13
  120. data/lib/head_music/style/guidelines/mixed_rhythmic_values.rb +1 -3
  121. data/lib/head_music/style/guidelines/moderate_direction_changes.rb +4 -1
  122. data/lib/head_music/style/guidelines/mostly_conjunct.rb +9 -3
  123. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +1 -1
  124. data/lib/head_music/style/guidelines/no_parallel_perfect_across_barline.rb +1 -3
  125. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +0 -2
  126. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +0 -2
  127. data/lib/head_music/style/guidelines/no_rests.rb +1 -3
  128. data/lib/head_music/style/guidelines/no_rests_after_note.rb +1 -3
  129. data/lib/head_music/style/guidelines/no_strong_beat_unisons.rb +1 -3
  130. data/lib/head_music/style/guidelines/no_unisons_in_middle.rb +1 -3
  131. data/lib/head_music/style/guidelines/note_count_per_bar.rb +18 -3
  132. data/lib/head_music/style/guidelines/note_fills_final_bar.rb +1 -3
  133. data/lib/head_music/style/guidelines/notes_same_length.rb +1 -3
  134. data/lib/head_music/style/guidelines/one_to_one.rb +1 -3
  135. data/lib/head_music/style/guidelines/one_to_one_with_ties.rb +1 -3
  136. data/lib/head_music/style/guidelines/prefer_contrary_motion.rb +2 -2
  137. data/lib/head_music/style/guidelines/prefer_imperfect.rb +2 -2
  138. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +2 -2
  139. data/lib/head_music/style/guidelines/second_species_break.rb +0 -2
  140. data/lib/head_music/style/guidelines/set_against_another_voice.rb +22 -0
  141. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -23
  142. data/lib/head_music/style/guidelines/singable_range.rb +7 -12
  143. data/lib/head_music/style/guidelines/start_on_perfect_consonance.rb +1 -3
  144. data/lib/head_music/style/guidelines/start_on_tonic.rb +1 -3
  145. data/lib/head_music/style/guidelines/step_down_to_final_note.rb +0 -2
  146. data/lib/head_music/style/guidelines/step_out_of_unison.rb +1 -3
  147. data/lib/head_music/style/guidelines/step_to_final_note.rb +1 -3
  148. data/lib/head_music/style/guidelines/step_up_to_final_note.rb +0 -2
  149. data/lib/head_music/style/guidelines/suspension_treatment.rb +1 -3
  150. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -2
  151. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -2
  152. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +1 -3
  153. data/lib/head_music/style/guides/assessment.rb +22 -0
  154. data/lib/head_music/style/guides/base.rb +139 -4
  155. data/lib/head_music/style/guides/composite_guide.rb +134 -0
  156. data/lib/head_music/style/guides/configured.rb +84 -0
  157. data/lib/head_music/style/guides/contour_melody.rb +46 -0
  158. data/lib/head_music/style/guides/diatonic_melody.rb +10 -27
  159. data/lib/head_music/style/guides/fifth_species_harmony.rb +10 -5
  160. data/lib/head_music/style/guides/fifth_species_melody.rb +6 -2
  161. data/lib/head_music/style/guides/first_species_harmony.rb +18 -4
  162. data/lib/head_music/style/guides/first_species_melody.rb +12 -4
  163. data/lib/head_music/style/guides/first_three_species_harmony.rb +14 -0
  164. data/lib/head_music/style/guides/{combined_first_second_third_species_melody.rb → first_three_species_melody.rb} +8 -5
  165. data/lib/head_music/style/guides/fourth_species_harmony.rb +10 -5
  166. data/lib/head_music/style/guides/fourth_species_melody.rb +5 -3
  167. data/lib/head_music/style/guides/fux_cantus_firmus.rb +8 -3
  168. data/lib/head_music/style/guides/salzer_schachter_cantus_firmus.rb +7 -2
  169. data/lib/head_music/style/guides/second_species_harmony.rb +5 -3
  170. data/lib/head_music/style/guides/second_species_melody.rb +5 -1
  171. data/lib/head_music/style/guides/species_harmony.rb +60 -9
  172. data/lib/head_music/style/guides/species_melody.rb +27 -9
  173. data/lib/head_music/style/guides/third_species_harmony.rb +5 -3
  174. data/lib/head_music/style/guides/third_species_melody.rb +5 -1
  175. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +5 -3
  176. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +5 -1
  177. data/lib/head_music/style/mark.rb +1 -1
  178. data/lib/head_music/style/template.rb +161 -0
  179. data/lib/head_music/version.rb +1 -1
  180. data/lib/head_music.rb +37 -13
  181. data/references/fifth-species-counterpoint.md +4 -4
  182. data/references/fourth-species-counterpoint.md +22 -5
  183. data/references/note-values-by-language.md +173 -0
  184. data/references/third-species-counterpoint.md +7 -7
  185. data/user-stories/backlog/lilypond-interpreter.md +3 -1
  186. data/user-stories/backlog/organizing-content.md +7 -4
  187. data/user-stories/backlog/tell-the-species-apart.md +136 -0
  188. data/user-stories/done/british-note-names.md +834 -0
  189. data/user-stories/done/composite-guides.grades.md +308 -0
  190. data/user-stories/done/composite-guides.md +1138 -0
  191. data/user-stories/done/configurable-guide-registry.md +647 -0
  192. data/user-stories/done/extract-the-harmonic-cores.grades.md +1073 -0
  193. data/user-stories/done/extract-the-harmonic-cores.md +1213 -0
  194. data/user-stories/done/first-class-guide-items.md +852 -0
  195. data/user-stories/done/guard-the-vocabulary-sweep-itself.md +600 -0
  196. data/user-stories/done/guideline-strings-into-i18n.md +931 -0
  197. data/user-stories/done/lilypond-export.md +260 -0
  198. data/user-stories/done/note-values-in-each-language.md +661 -0
  199. data/user-stories/done/re-tier-the-guides.grades.md +1979 -0
  200. data/user-stories/done/re-tier-the-guides.md +753 -0
  201. data/user-stories/done/release-notes-for-19-0-0.md +279 -0
  202. data/user-stories/done/rename-annotation-to-guideline.md +567 -0
  203. data/user-stories/done/species-guide-harmonic-weights.grades.md +353 -0
  204. data/user-stories/done/species-guide-harmonic-weights.md +599 -0
  205. data/user-stories/epics/style-assessment-model.md +386 -0
  206. data/user-stories/index.html +87 -11
  207. metadata +73 -17
  208. data/lib/head_music/style/analysis.rb +0 -52
  209. data/lib/head_music/style/annotation/configured.rb +0 -35
  210. data/lib/head_music/style/annotation.rb +0 -193
  211. data/lib/head_music/style/guides/arch_contour_melody.rb +0 -4
  212. data/lib/head_music/style/guides/ascending_contour_melody.rb +0 -4
  213. data/lib/head_music/style/guides/combined_first_second_third_species_harmony.rb +0 -10
  214. data/lib/head_music/style/guides/descending_contour_melody.rb +0 -4
  215. data/lib/head_music/style/guides/static_contour_melody.rb +0 -5
  216. data/lib/head_music/style/guides/valley_contour_melody.rb +0 -4
  217. data/lib/head_music/style/guides/wave_contour_melody.rb +0 -4
  218. data/user-stories/backlog/lilypond-export.md +0 -74
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a16868734e2fac408461d2202fcbc84be2a796a68371a81a7d2ad5bbeef516b
4
- data.tar.gz: 02becc93275629796177a72894d81cc10feca0819847496e60aa3ddc6f5aac00
3
+ metadata.gz: e8a5d592c5994e59585e09db272d554d2aefcef0f337287ee0fd0206cbab498c
4
+ data.tar.gz: '0964c0d8e7ec6a753886ab34131705daacb51eefffc79f48d281ca6663099a4d'
5
5
  SHA512:
6
- metadata.gz: e3533678a1a2b58558080cad6c03f04fe4b5b205d4413456b728c5ae2e1a598e3a09ebc0329c9f6fde263e84f857eb4571918087afe23a083278facaea7aa460
7
- data.tar.gz: 3d605bff3a0459e56cddd4911e7699d2b295bf337e7f468094d0f4cc19e55f9b3036c21e59c95263f3bf19c69dfffa99093a8564f036aa625b776199e959e8f4
6
+ metadata.gz: dafb4e6534d8985a4563a650e141f61d652b40a76e7adf2d3ce7d8820ce5f41038dc1754f893b18756a2f30192813b8e752899656b6abab5bc5dd073a732abad
7
+ data.tar.gz: cdd300bd23ca8bbe011f7712385d7b7d80cebb99eae61281bc24049c01f535069116eff46a46fe296acf0c846f3a7e4a36d59562d5c03cb29a4ed4c7d5930ede
@@ -37,6 +37,10 @@ updates:
37
37
  update-types: ["version-update:semver-major"]
38
38
  - dependency-name: "i18n"
39
39
  update-types: ["version-update:semver-major"]
40
+ # simplecov 1.1.x is a SyntaxError on Ruby 3.3 (see the Gemfile comment).
41
+ # Dependabot rewrites a Gemfile constraint to get past it, so hold it here.
42
+ - dependency-name: "simplecov"
43
+ versions: [">= 1.1"]
40
44
 
41
45
  # Enable version updates for GitHub Actions
42
46
  - package-ecosystem: "github-actions"
@@ -16,7 +16,7 @@ jobs:
16
16
  activesupport-version: ['7.2', '8.0']
17
17
 
18
18
  steps:
19
- - uses: actions/checkout@v5
19
+ - uses: actions/checkout@v7
20
20
 
21
21
  - name: Set up Ruby ${{ matrix.ruby-version }}
22
22
  uses: ruby/setup-ruby@v1
@@ -34,7 +34,7 @@ jobs:
34
34
 
35
35
  - name: Upload coverage to Codecov
36
36
  if: matrix.ruby-version == '3.3' && matrix.activesupport-version == '8.0'
37
- uses: codecov/codecov-action@v5
37
+ uses: codecov/codecov-action@v7
38
38
  with:
39
39
  token: ${{ secrets.CODECOV_TOKEN }}
40
40
  fail_ci_if_error: false
@@ -43,7 +43,7 @@ jobs:
43
43
  lint:
44
44
  runs-on: ubuntu-latest
45
45
  steps:
46
- - uses: actions/checkout@v5
46
+ - uses: actions/checkout@v7
47
47
 
48
48
  - name: Set up Ruby
49
49
  uses: ruby/setup-ruby@v1
@@ -57,7 +57,7 @@ jobs:
57
57
  build:
58
58
  runs-on: ubuntu-latest
59
59
  steps:
60
- - uses: actions/checkout@v5
60
+ - uses: actions/checkout@v7
61
61
 
62
62
  - name: Set up Ruby
63
63
  uses: ruby/setup-ruby@v1
@@ -13,7 +13,7 @@ jobs:
13
13
  id-token: write
14
14
 
15
15
  steps:
16
- - uses: actions/checkout@v5
16
+ - uses: actions/checkout@v7
17
17
 
18
18
  - name: Set up Ruby
19
19
  uses: ruby/setup-ruby@v1
@@ -31,7 +31,7 @@ jobs:
31
31
  run: gem build *.gemspec
32
32
 
33
33
  - name: Create GitHub Release
34
- uses: softprops/action-gh-release@v2
34
+ uses: softprops/action-gh-release@v3
35
35
  with:
36
36
  files: '*.gem'
37
37
  generate_release_notes: true
@@ -15,7 +15,7 @@ jobs:
15
15
  runs-on: ubuntu-latest
16
16
 
17
17
  steps:
18
- - uses: actions/checkout@v5
18
+ - uses: actions/checkout@v7
19
19
 
20
20
  - name: Set up Ruby
21
21
  uses: ruby/setup-ruby@v1
data/CHANGELOG.md CHANGED
@@ -5,7 +5,190 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [20.0.0] - 2026-08-30
9
+
10
+ The [style assessment model](https://github.com/roberthead/head_music/tree/main/user-stories/epics/style-assessment-model.md) epic, released together. Five stories reshaped how a guide is declared, how it grades, what it says, and what a consumer asks for — so the breaking changes below are one migration rather than five. Two notation stories ride along: LilyPond export, and note values named in each reader's own language.
11
+
12
+ **Migrating from 19.0.0**, in the order a consumer will hit them:
13
+
14
+ 1. `Style::Analysis` is `Style::GuideAssessment`; `Guide#analyze(voice)` is `#assess(voice)`; `Analysis#annotations` is `GuideAssessment#guide_item_assessments`. `Style::Annotation` is `Style::Guideline`, and `Annotation::Configured` is `Style::GuideItem`.
15
+ 2. Ask for a species rather than pairing its halves: `Guide.get("first_species")` returns a composite that grades melody and harmony together and combines them geometrically. The seven composite keys are new; the two `combined_first_second_third_species_*` keys are now `first_three_species_*`.
16
+ 3. Grades move. Re-tiering weighs what a guide teaches above the craft it inherits, so any stored fitness from 19.0.0 is not comparable to one from 20.0.0. Regrade rather than migrate.
17
+ 4. Guideline strings are i18n templates. A consumer reading `MESSAGE` constants reads `GuideItemAssessment#message` instead.
18
+
19
+ ### Added
20
+
21
+ - **Composite guides.** A species is a melody guide and a harmony guide, and a student submits one line to be judged by both. `Style::Guide.get("first_species")` now answers with a `Guides::CompositeGuide` over the two, and six siblings do the same: `second_species`, `third_species`, `third_species_triple_meter`, `fourth_species`, `fifth_species`, and `first_three_species`. `Guide.all` grows 23 → 30. Which two guides make up a species, and how their grades combine, is counterpoint pedagogy; it belongs here rather than in each consuming application.
22
+
23
+ A composite **composes grades, not items**. Merging its members' item lists cannot even be built — both members gate on `MinimumNotes.with(3)`, `GuideItem` equality is by value, and `Base.reject_duplicates` refuses the union — and would undo the tier budgets besides, putting nineteen primaries into one φ⁻¹ budget. The two levels grade by different arithmetic on purpose: rules inside a rubric trade off by weight, while a melody grade and a harmony grade must both hold.
24
+
25
+ - `Style::CompositeAssessment`, which a composite returns from `assess(voice)`. Its `fitness` is the **geometric mean** of its members' grades, so a perfect melody against a half-graded harmony reads 0.707 rather than 0.75, and either half at zero takes the whole grade to zero. `assessments` holds one `GuideAssessment` per member, and `fitness_by_category` splits the grade into the melody and harmony halves a consumer wants to show separately.
26
+
27
+ When any member is unassessable the composite is too, and it grades on its members' **gate factors alone** — `GuideAssessment`'s own rule with the nouns raised: one member failing a precondition means the composite has not earned a grade on the other members either.
28
+
29
+ - `GuideAssessment#assessments`, answering `[self]`, so a consumer walks a leaf assessment and a composite one the same way without asking which it holds. `GuideAssessment#fitness_by_category` answers the same shape, as one group of one.
30
+
31
+ - `GuideAssessment#gate_factor` is public, and returns a `Float` for a gate-less guide rather than the Integer `1` it used to compute internally. A composite reads it when a member is unassessable.
32
+
33
+ - `composite?` and `categories` on every guide. A composite spans its members' categories rather than claiming one, so its `category` is `nil` and a consumer grouping the registry by category gains a `nil` bucket; `categories` is what answers for it. A leaf answers `[category]`.
34
+
35
+ - The rubric gains a second axis, orthogonal to tier: **strength**. Within a tier, a `:strong` guideline weighs twice a `:weak` one, normalized by that tier's own total. `Guideline.strength` declares it — `strength :weak, because: "…"`, where the reason is required for `:weak` and refused for `:strong` — and it defaults to `:strong`, so the axis is inert until a guideline opts in. An all-strong rubric grades bit-identically to one with no strength axis at all.
36
+
37
+ Unlike tier, strength is a property of the guideline rather than of the list it was declared in: a preference is a preference in every guide that names it. It is never inherited by a subclass, because `WeakBeatDissonanceTreatment` bases two treatments that are the taught rule of their own guides. An item may override it — `Guideline.with(strength: :weak)` — for the tradition-dependent case, where `ApproachPerfectionContrarily` is prohibited in Fux and merely cautioned later.
38
+
39
+ Eight guidelines are classified `:weak`: `FrequentDirectionChanges`, `LargeLeaps`, `LimitOctaveLeaps`, `ModerateDirectionChanges`, `MostlyConjunct`, `PreferContraryMotion`, `PreferImperfect`, and `PrepareOctaveLeaps`.
40
+
41
+ - `GuideItem#strength` and `GuideItemAssessment#strength`. The assessment's is keyword-defaulted from the item rather than required, so existing direct-construction sites keep working, and validated there as well, since it is a seam a caller can reach without going through `GuideItem`; it is stamped rather than delegated so that re-classifying a guideline later cannot silently rewrite a persisted grade.
42
+
43
+ - `HeadMusic::Style::Guidelines::SetAgainstAnotherVoice` — the definitional precondition of a harmony guide: counterpoint is a relationship between voices, and a voice alone has no harmony to assess.
44
+
45
+ - `HeadMusic::Style::Template` — renders every customer-facing string in the style module, and refuses the four ways I18n fails quietly: a template rendered with no values keeps its `%{}` without raising, a value named for a reserved key hijacks the lookup, a missing key resolves to "Translation missing: …", and a word passed as `count` silently selects a plural. Every render passes `raise: true` and is checked for a surviving interpolation.
46
+
47
+ `Template.verify!` runs at load over the whole registry — twenty-three guide instructions, and every template the sixty-seven guide items can render, including the violation branches a guideline chooses between — so a missing entry stops `require` rather than reaching a student. It runs in English deliberately: a host application's locale must not decide whether the gem loads.
48
+
49
+ Where a locale has no plural data, `Template.pluralize` falls back to Ruby rather than raising, and records the key it fell back for.
50
+
51
+ - British spellings for the five style strings that have them — `neighbour`, `metre`, and a bar rather than a measure. `en_GB` sits mid-chain, so German, French, Italian and Russian pick these up on the way to `en`. Note that any pluralized `en_GB` entry must carry the complete set of forms: I18n stops at a plural hash that is present but incomplete rather than continuing past it, so a partial one would raise for those four languages and never for a British reader.
52
+
53
+ - **Note values in each language.** German, Spanish, French, Italian and Russian name note values as their own teachers do — *Viertel*, *negra*, *noire*, *semiminima*, *четвертная* — rather than inheriting British words on the way to `en`. No single English serves all four inheritors: the vocabulary splits into fractional, mensural-Latin, and shape families, and French *croche* is the **eighth** where its cognate *crotchet* is the quarter, so borrowed English actively misleads. The words, their derivation rules, plural behaviour, and the sources that disagree live in `references/note-values-by-language.md`.
54
+
55
+ The vocabulary itself lives under `head_music.rudiments`, beside the `rhythmic_unit` label already there, in three groups of eleven units each — `maxima` down to `hundred_twenty_eighth`: `rhythmic_units` (the bare unit, pluralizable, counted by `note_count_per_bar`), `note_values`, and `rest_values`. The three do not share a shape everywhere — a British note value drops the noun (*a crotchet*, not *a crotchet note*) while a British rest keeps it, and a French or Spanish rest names the concept (*soupir*, *silencio de negra*) rather than compounding the note value. Every locale also translates the words "note" and "rest" themselves. `Style::Template` gains a `scope:` keyword so style sentences borrow the rudiment vocabulary through the same seam that guards plural fallback and unfilled interpolations.
56
+
57
+ - **LilyPond export.** `HeadMusic::Content::Composition#to_lilypond` renders a composition as a complete LilyPond document string, delegating to `HeadMusic::Notation::LilyPond.render(composition, **options)` — the outward complement of the inward `Notation::<Format>.parse` interpreters, in the same facade-plus-helpers shape as the ABC and MusicXML writers. The document carries a `\version` line, a `\header` with the composition's title and composer, and a `\score` with one staff per voice in absolute pitch mode — key signature, meter, and a clef chosen per voice, with mid-piece `\key` and `\time` changes emitted at the bar where they occur, one line per bar with a trailing bar check.
58
+
59
+ Whole-composition problems raise `Notation::LilyPond::RenderError` before any assembly — a voiceless composition, positional gaps, notes crossing barlines, a voice that ends mid-bar, unpitched sounds, and unmappable keys, durations, or alterations — so a returned string is always a complete document. Generated fixtures compile under the LilyPond CLI in the specs.
60
+
61
+ - `Rudiment::RhythmicValue#tied_chain` — the value and every link tied after it, in order, so a writer walks a chain of tied values the same way it walks a chain of one.
62
+
63
+ ### Changed
64
+
65
+ - **Breaking.** `GuideAssessment.new` raises `ArgumentError` when handed a composite guide, naming `guide.assess(voice)` as the seam that grades it correctly. Flattening a composite's items into one rubric would return a plausible number computed by the wrong arithmetic.
66
+
67
+ - **Breaking.** Four renames, freeing the word "combined", which named mixed rhythm on two guides and would have named a guide composed of members as well:
68
+
69
+ | Was | Is |
70
+ | --- | --- |
71
+ | `Guides::CombinedFirstSecondThirdSpeciesMelody` | `Guides::FirstThreeSpeciesMelody` |
72
+ | `Guides::CombinedFirstSecondThirdSpeciesHarmony` | `Guides::FirstThreeSpeciesHarmony` |
73
+ | `Guidelines::AllowedRhythmicValuesForCombined123` | `Guidelines::AllowWholeHalfQuarterNotes` |
74
+ | `Guidelines::AllowedRhythmicValuesForFifthSpecies` | `Guidelines::AllowFifthSpeciesRhythmicValues` |
75
+
76
+ The registry keys `combined_first_second_third_species_melody` and `..._harmony` become `first_three_species_melody` and `first_three_species_harmony`, and the locale keys move with them. The two guideline names take different shapes deliberately: the first three species allow a set small enough to say in a name, and fifth species allows that set plus eighths and ties under conditions the guideline itself decides.
77
+
78
+ - No existing guide's grade changes. Measured across the whole corpus — 3266 rows, 142 voices × 23 guides — every row is identical before and after.
79
+
80
+ - **Breaking.** `GuideItem#initialize` takes `strength:` as a keyword, so its configuration hash must now be passed explicitly — `GuideItem.new(SomeGuideline, {minimum: 3})` rather than `GuideItem.new(SomeGuideline, minimum: 3)`. `Guideline.with(minimum: 3)` is unaffected and remains the ordinary way to build one.
81
+
82
+ - **Breaking.** A guide that declares no `primary_items` raises `ArgumentError`, naming the guide and what it did declare. A guide that is all background has no subject, and grading it 1.0 in silence is the same "nothing to find fault in" confusion the gates fixed. Gate-only guides fall to the same check, deliberately. Every registered guide already declared a primary, so this closes a door rather than fixing a break.
83
+
84
+ - **Breaking.** The seven species harmony guides demote `SpeciesHarmony::HARMONIC_CORE`, `DIMINUTION_HARMONIC_CORE`, and `NoParallelPerfectWithSyncopation` to `secondary_items`, mirroring the melodic demotion. A harmony guide now weighs the dissonance treatment it teaches above the two-part craft every harmony guide shares. `SecondSpeciesHarmony` gave 9/10 of its grade to rules it did not write and now gives φ⁻¹ to `WeakBeatDissonanceTreatment` alone; a fixture failing that rule moves 0.824 → 0.698, and a parallel octave costs about half what it did.
85
+
86
+ Each guide declares its tiers outright — `primary_items` for what it teaches, `secondary_items` splatting the shared craft constants — so the tier of every item is readable at the call site, and the specs hold the guides to the policy that a shared-core member stays background. `FirstThreeSpeciesHarmony` gains the diminution core it was missing — it covers two diminution species — and is the only guide anywhere whose set of guidelines changed.
87
+
88
+ - **`NoParallelPerfectOnDownbeats` is a taught rule in first species harmony**, not inherited background — the one exception to the demotion above. It sits in the primary tier of `Guides::FirstSpeciesHarmony` alone, weighing 0.2060 beside `NoUnisonsInMiddle` and `OneToOne` at 0.2060 each, rather than the 0.0637 the shared harmonic core would give it; no other guide is affected.
89
+
90
+ A species guide is normally about the dissonance treatment its rhythm makes possible, and two-part craft is background. First species has no dissonance treatment, and its other two primaries are rhythm-and-texture bookkeeping — so note-against-note consonance handling is what the species teaches. Promoting the same rule in the six guides that *do* teach a dissonance treatment would weigh it as heavily as their subject, and would *raise* the grade of a submission already failing that subject by halving the weight it forgoes.
91
+
92
+ The exception is registered in `Guides::SpeciesHarmony::HARMONIC_CRAFT_PROMOTIONS`, and the specs hold every other harmony guide to the policy. A cantus firmus doubled an octave above itself grades 0.6674 where the shared-core weighting would read 0.8300, while Fux chapter one figure 5 as published still grades exactly 1.0.
93
+
94
+ - `MostlyConjunct` marks each skip and leap at the ordinary penalty rather than `SMALL_PENALTY_FACTOR`, and says it is soft with `strength :weak` instead. The two say different things: a mark's fitness compounds into the item's own grade, so it set both how bad one instance was and how fast the item collapsed on repeats. Six leaps now grade 0.056 rather than 0.236. `SMALL_PENALTY_FACTOR` is unchanged and still used by `SecondSpeciesBreak`, which holds two severities inside one guideline.
95
+
96
+ - **Breaking.** A guide declares its guidelines in three tiers rather than one flat `RULESET`, and the tier decides how much each one counts. `gate_items` are preconditions whose fitness multiplies the grade; `primary_items` are what the guide teaches and share φ⁻¹ of the rubric; `secondary_items` are background it inherits and share φ⁻². `Guides::Base.ruleset` and every `::RULESET` constant are removed — read `guide_items`, or one tier at a time.
97
+
98
+ Tier is the list an entry is declared in rather than a property of the entry, because the shared cores are shared objects: `SpeciesMelody::MELODIC_CORE` is splatted into six guides, and `ContourMelody` treats as background exactly what `DiatonicMelody` teaches. Guides whose tiers depend on configuration override `items_by_tier` with a keyword signature, as `ContourMelody` does.
99
+
100
+ - **Breaking.** `Style::Annotation::Configured` becomes `Style::GuideItem`: a guideline paired with the configuration one guide gives it, with `guideline` and `config` readers and value equality. It no longer answers `#new(voice)`, `#with`, or `#default_gate?`.
101
+
102
+ - **Breaking.** `Style::Analysis` becomes `Style::GuideAssessment`, and `#annotations` becomes `#guide_item_assessments`, which returns frozen `Style::GuideItemAssessment` values rather than live guideline instances. Each carries `tier`, `fitness`, `marks`, `message`, and the `guide_item` it came from.
103
+
104
+ - **Breaking.** `guide.analyze(voice)` is replaced by `guide.assess(voice)`, which returns a `GuideAssessment`, and `guide.assess_items(voice)`, which returns the assessments it grades. `Style::Guide.get` and `GuideAssessment.new` both duck-check `assess_items`: guidelines and guide items answer `assess` too, with different arguments.
105
+
106
+ - **Breaking.** Per-entry `weight` and `gate` are removed, along with `Guideline#weight`, `#gate?`, `.default_weight`, `.default_gate?`, `Contoured::DEFAULT_WEIGHT`, `MinimumThreshold.default_gate?`, and `ContourMelody::PEER_WEIGHT_BUDGET`. Tier replaces both. Whether a rule gates is the guide's editorial choice, not a property of the guideline — the same threshold can be a low gate in one guide and a stylistic minimum in another.
107
+
108
+ - **Breaking.** `Guideline.new` is private. A guideline instance is the analysis context, not a result; `Guideline.assess` is the seam, and what comes back is a `GuideItemAssessment`.
109
+
110
+ - **Breaking.** Every guide now declares a precondition, and failing one stops the assessment rather than scaling it. A voice that cannot be assessed reports `GuideAssessment#assessable? == false`, grades the product of its gates, and yields only gate assessments — the rubric is not computed. Previously a failed precondition multiplied a fully-computed rubric, so a four-note cantus firmus had its climax and leaps halved for being short.
111
+
112
+ `assess_items` therefore returns a variable-length list. A consumer upserting rows keyed by guide item must not read a missing row as a rule that was deleted.
113
+
114
+ - **Breaking.** Grades change outside the degenerate range, deliberately. Three sources, with every affected row recorded in the story's grade table:
115
+
116
+ | Change | Effect |
117
+ | --- | --- |
118
+ | The seven species harmony guides gain `SetAgainstAnotherVoice` and a three-note minimum | They raised `NoMethodError` for a voice with no companion, at every length. They grade it now. |
119
+ | The seven species melody guides gain a three-note minimum | Each graded an empty voice 1.000 — no fault found, because there was nothing to find fault in. |
120
+ | `FuxCantusFirmus`, `SalzerSchachterCantusFirmus` and `DiatonicMelody` split their note minimum | A three-note gate asks whether this is a melody; the eight- or five-note prescription stays a rubric item, matching `MaximumNotes`, which always was one. A four-note cantus firmus moves from 0.500 unassessable to 0.969 assessable. |
121
+ | The species guides demote the shared melodic cores to `secondary_items` | A guide weighs its own rhythmic rules above the craft it inherits. A valid first-species line scored 0.883 against `ThirdSpeciesMelody` and now scores 0.561. |
122
+
123
+ Grading was byte-identical to 19.0.0 through the guide-item refactor above; these are the deliberate corrections that followed it. The string changes below do not affect it.
124
+
125
+ - **Breaking.** Guideline strings move out of the classes and into the locale files. Every `MESSAGE` constant is removed. A guideline is addressed by the snake_case of its class name, so a new one needs no declaration — only entries under `head_music.style.guidelines.<key>`:
126
+
127
+ | Key | Reads |
128
+ | --- | --- |
129
+ | `name` | a short label, e.g. "Minimum of eight notes" |
130
+ | `instruction` | what to do |
131
+ | `violations.default` | what to do differently |
132
+
133
+ All three are templates. `GuideItem` renders them for its own configuration — `#name`, `#instruction`, `#violation_preview` — so the same guideline reads "at least three notes" in a gate and "at least eight" in a rubric. A guideline configured per guide supplies its interpolations from `self.template_values(config)`; a guide that wants a variant of the sentence names it with `violation_key:`, as `FuxCantusFirmus` does for `LargeLeaps`. A guideline that chooses between variants during the analysis itself declares them all with `.violation_keys`, as `ConsonantClimax` does for a climax dissonant with the tonic.
134
+
135
+ All fifty-five guidelines carry a `name` and an `instruction`. The name labels the rule — "No voice crossing", "Leap recovery" — and the instruction says what to write, where the violation says what to do differently: "Keep your line on its own side of the other voice" against "Avoid crossing voices". Both are a first draft, as the guide instructions are.
136
+
137
+ A rendered name is upcased on its first letter. A name may lead with an interpolation — `"%{contour} contour"` — whose value stays lowercase for the violation sentence that embeds it mid-clause, so "Arch contour" and "Write a melody with the arch contour" come from one locale value.
138
+
139
+ Both stay optional. A guideline with no `name` reads its class key as a sentence, and one with no `instruction` falls back to the violation, which is already phrased imperatively — so a guideline added before anyone writes it either one still reads. Until now that fallback was every guideline's only name: forty-six of the fifty-six in the registry rendered "Avoid crossing voices" or "Triple meter dissonance treatment" out of the class key, identically in every language, and the American spelling in that second one could not be regionalized because it never passed through a locale file at all.
140
+
141
+ The `message:` option that `SingableIntervals` and `LargeLeaps` accepted is removed with them: it passed an English sentence through the config hash, which is the thing this change exists to stop. A guide item declared with `message:` raises `ArgumentError` naming `violation_key:` rather than ignoring the key, since an unrecognized option would otherwise ride along in `config` and be dropped at render — a custom sentence vanishing with no error.
142
+
143
+ `GuideItemAssessment#message` is now `nil` for an adherent item rather than the message it would have printed. Read `GuideItem#violation_preview` for the sentence in the abstract.
144
+
145
+ `GuideItemAssessment#name`, and `#to_s` with it, answer the item's rendered name — "Minimum of eight notes" — rather than the guideline's class path. A consumer building a results list holds assessments rather than items, so that is where a rubric gets its labels.
146
+
147
+ - **Breaking.** Guides gain `#instruction` — what a guide asks a student to write, as distinct from how it grades what they wrote — and their names move under `head_music.style.guides.<key>.name` from the flat `<key>`. The twenty-three instructions are a first draft.
148
+
149
+ - **Breaking.** `Notation::MusicXML::ClefSelector` is now `Notation::ClefSelector`, and the old name no longer resolves. Choosing a clef for a voice's tessitura is format-independent, and the LilyPond writer reads it alongside the MusicXML one. The shared preflight checks the two writers agree on — contiguous placements, notes within barlines — move to `Notation::PreflightChecks` the same way.
150
+
151
+ ## [19.0.0] - 2026-08-07
152
+
153
+ ### Added
154
+
155
+ - `HeadMusic::Style::Guide` — a lookup facade over every style guide in the gem. `Guide.get("first_species_harmony")` resolves a key to a guide; an unknown key returns `nil` rather than raising, so a consumer can ask about a guide the gem does not have. `Guide.get!` raises `KeyError` instead, and `Guide.known?`, `.all`, `.keys`, and `.key_for` round out the surface. Keys are stable strings suitable for storing in a database. The registry holds twenty-three:
156
+
157
+ | Category | Keys |
158
+ | --- | --- |
159
+ | `:melody` | `fux_cantus_firmus`, `salzer_schachter_cantus_firmus`, `diatonic_melody`, `first_species_melody`, `second_species_melody`, `third_species_melody`, `third_species_triple_meter_melody`, `fourth_species_melody`, `first_three_species_melody`, `fifth_species_melody`, `arch_contour_melody`, `ascending_contour_melody`, `descending_contour_melody`, `static_contour_melody`, `valley_contour_melody`, `wave_contour_melody` |
160
+ | `:harmony` | `first_species_harmony`, `second_species_harmony`, `third_species_harmony`, `third_species_triple_meter_harmony`, `fourth_species_harmony`, `first_three_species_harmony`, `fifth_species_harmony` |
161
+
162
+ - Guides now carry identity: `.key` (snake_case of the class name), `.category` (`:melody` or `:harmony`, derived from the `SpeciesMelody`/`SpeciesHarmony` ancestry), and `.display_name` (localizable, with a computed English default). Consumers no longer need to hand-maintain a map of guide constants to categories.
163
+ - `HeadMusic::Style::Guides::Configured` — the guide-layer twin of `Annotation::Configured`. It pairs a guide class with options and answers `analyze(voice)`, so it drops into `Style::Analysis` wherever a guide class was expected. `Guides::Base.with(**options)` returns one, and `#with` layers further options without dropping earlier ones.
164
+ - `HeadMusic::Style::Guides::ContourMelody` — one configurable guide replacing the six contour subclasses: `ContourMelody.with(contour: :arch, minimum_melodic_intervals: 2)`. An invalid contour raises `ArgumentError` at configuration time, not at analysis.
165
+
166
+ ### Changed
167
+
168
+ - **Breaking.** The six contour guide classes are removed: `ArchContourMelody`, `AscendingContourMelody`, `DescendingContourMelody`, `StaticContourMelody`, `ValleyContourMelody`, and `WaveContourMelody`. Their rulesets are unchanged and still reachable — by key, through the registry. Migrate constant references to `Guide.get`:
169
+
170
+ | Removed constant | Replacement |
171
+ | --- | --- |
172
+ | `Guides::ArchContourMelody` | `Style::Guide.get("arch_contour_melody")` |
173
+ | `Guides::AscendingContourMelody` | `Style::Guide.get("ascending_contour_melody")` |
174
+ | `Guides::DescendingContourMelody` | `Style::Guide.get("descending_contour_melody")` |
175
+ | `Guides::StaticContourMelody` | `Style::Guide.get("static_contour_melody")` |
176
+ | `Guides::ValleyContourMelody` | `Style::Guide.get("valley_contour_melody")` |
177
+ | `Guides::WaveContourMelody` | `Style::Guide.get("wave_contour_melody")` |
178
+
179
+ - **Breaking.** `Guides::DiatonicMelody.contour_ruleset` and `Guides::DiatonicMelody::CONTOUR_PEER_WEIGHT_BUDGET` are removed. The budget constant now lives on the guide that uses it, as `Guides::ContourMelody::PEER_WEIGHT_BUDGET`.
180
+ - **Breaking.** Neither a configured guide nor `Guides::ContourMelody` has a `::RULESET` constant — read `#ruleset` or `.ruleset(**options)` instead. `Guides::Base.ruleset` is now a method rather than a bare constant read, and `ContourMelody` deliberately does not descend from `DiatonicMelody`, so a guide whose ruleset depends on configuration raises rather than silently resolving an ancestor's ruleset through Ruby's constant lookup.
181
+ - **Breaking.** `guide.name` on a configured guide returns the underlying class name, which is shared across all six contour configurations. Persist `guide.key`, not `guide.name`; use `guide.display_name` for presentation.
182
+ - `Style::Analysis.new` now raises `ArgumentError` when given something that cannot answer `analyze`. Previously a `nil` guide — the result of an unrecognized key — surfaced much later as a `NoMethodError` on `nil`.
183
+ - **Breaking.** `Guides::Base.with` raises `ArgumentError` when given options for a guide that takes none, rather than accepting them and failing at the first `analyze` with Ruby's bare `wrong number of arguments (given 1, expected 0)`. A guide accepts configuration by declaring keywords on `.ruleset`, so `FuxCantusFirmus.with(contour: :arch)` now names the guide and the options it cannot take. `Guides::Configured` resolves its ruleset in its constructor for the same reason: a configuration error belongs at `.with`, not mid-grading.
184
+ - **Breaking.** `Instruments::Instrument.get` no longer accepts a second positional argument. The deprecated two-argument form joined its arguments with an underscore, so `Instrument.get("trumpet", "in_c")` becomes `Instrument.get("trumpet_in_c")`. It never emitted a runtime deprecation warning, so check call sites rather than logs. The two-argument form also fell back to the base instrument when the combined name did not exist — `get("trumpet", "in_q")` returned the trumpet — where `get("trumpet_in_q")` returns `nil`.
185
+ - The six contour rulesets are byte-for-byte what they were: the same ten rubric peers sharing φ⁻² evenly, `Contoured` at φ⁻¹, and the same per-contour motion gate (omitted for `static`). Fitness values are unchanged.
186
+
187
+ - **Breaking.** The ABC parser and lexer and the MusicXML writer each shed a responsibility to a collaborator, leaving no file in the gem above a RubyCritic C. Behavior is unchanged and no public method signature moved, but four constants did, and the old names no longer resolve:
188
+ - `Notation::ABC::BodyLexer::Token` is now `Notation::ABC::Token`. The token type is consumed by `Parser` and `Preflight`, not just the lexer.
189
+ - `Notation::ABC::BodyLexer::ChordNote` is now `Notation::ABC::ChordScanner::ChordNote`, alongside the bracket-chord scanning that produces it.
190
+ - `Notation::ABC::Parser::REPEAT_ENDING_STYLES`, `REPEAT_STARTING_STYLES`, and `SECTION_ENDING_STYLES` are now on `Notation::ABC::RepeatTagger`, which applies the repeat and volta marks they describe.
191
+ - `Notation::MusicXML::Writer::XML_ESCAPES` is now `Notation::MusicXML::XmlText::ESCAPES`, shared with the note and lyric writers. `Writer::INDENT` still resolves, through the same module.
9
192
 
10
193
  ## [18.0.0] - 2026-07-27
11
194
 
@@ -533,7 +716,33 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
533
716
 
534
717
  For changes in versions prior to 0.28.0, please refer to the git history.
535
718
 
536
- [Unreleased]: https://github.com/roberthead/head_music/compare/v8.2.0...HEAD
719
+ [Unreleased]: https://github.com/roberthead/head_music/compare/v20.0.0...HEAD
720
+ [20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
721
+ [19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
722
+ [18.0.0]: https://github.com/roberthead/head_music/compare/v17.5.0...v18.0.0
723
+ [17.5.0]: https://github.com/roberthead/head_music/compare/v17.3.0...v17.5.0
724
+ [17.3.0]: https://github.com/roberthead/head_music/compare/v17.2.0...v17.3.0
725
+ [17.2.0]: https://github.com/roberthead/head_music/compare/v17.1.0...v17.2.0
726
+ [17.1.0]: https://github.com/roberthead/head_music/compare/v17.0.0...v17.1.0
727
+ [17.0.0]: https://github.com/roberthead/head_music/compare/v15.2.0...v17.0.0
728
+ [15.2.0]: https://github.com/roberthead/head_music/compare/v15.0.0...v15.2.0
729
+ [15.0.0]: https://github.com/roberthead/head_music/compare/v14.0.0...v15.0.0
730
+ [14.0.0]: https://github.com/roberthead/head_music/compare/v13.0.0...v14.0.0
731
+ [13.0.0]: https://github.com/roberthead/head_music/compare/v12.6.0...v13.0.0
732
+ [12.6.0]: https://github.com/roberthead/head_music/compare/v12.5.0...v12.6.0
733
+ [12.5.0]: https://github.com/roberthead/head_music/compare/v12.4.0...v12.5.0
734
+ [12.4.0]: https://github.com/roberthead/head_music/compare/v12.3.0...v12.4.0
735
+ [12.3.0]: https://github.com/roberthead/head_music/compare/v12.2.0...v12.3.0
736
+ [12.2.0]: https://github.com/roberthead/head_music/compare/v12.1.0...v12.2.0
737
+ [12.1.0]: https://github.com/roberthead/head_music/compare/v12.0.1...v12.1.0
738
+ [12.0.1]: https://github.com/roberthead/head_music/compare/v12.0.0...v12.0.1
739
+ [12.0.0]: https://github.com/roberthead/head_music/compare/v11.8.0...v12.0.0
740
+ [11.8.0]: https://github.com/roberthead/head_music/compare/v11.7.0...v11.8.0
741
+ [11.7.0]: https://github.com/roberthead/head_music/compare/v11.6.1...v11.7.0
742
+ [11.6.1]: https://github.com/roberthead/head_music/compare/v11.6.0...v11.6.1
743
+ [11.6.0]: https://github.com/roberthead/head_music/compare/v11.5.1...v11.6.0
744
+ [11.0.0]: https://github.com/roberthead/head_music/compare/v9.1.0...v11.0.0
745
+ [9.0.0]: https://github.com/roberthead/head_music/compare/v8.4.0...v9.0.0
537
746
  [8.2.0]: https://github.com/roberthead/head_music/compare/v8.1.1...v8.2.0
538
747
  [8.1.1]: https://github.com/roberthead/head_music/compare/v8.1.0...v8.1.1
539
748
  [8.1.0]: https://github.com/roberthead/head_music/compare/v8.0.2...v8.1.0
data/CLAUDE.md CHANGED
@@ -114,7 +114,7 @@ The codebase follows a domain-driven design with clear module boundaries:
114
114
  7. **HeadMusic::Style** - Composition rules and guidelines
115
115
  - Counterpoint rules (species counterpoint guides)
116
116
  - Voice leading guidelines
117
- - Style analysis and annotations
117
+ - Style analysis and guidelines
118
118
  - Historical traditions (medieval, renaissance, modern)
119
119
 
120
120
  8. **HeadMusic::Utilities** - Shared helpers
data/Gemfile CHANGED
@@ -13,10 +13,14 @@ end
13
13
  gem "standard", require: false
14
14
 
15
15
  group :test do
16
- gem "rubocop", require: false
16
+ # rubocop is not listed directly: standard pins it (~> 1.88.0) and must
17
+ # lead. Listing it here let Dependabot bump it past standard, which the
18
+ # resolver "fixed" by downgrading standard 1.56 -> 1.35.
17
19
  gem "rubocop-rspec", require: false
18
20
  gem "rubocop-rake", require: false
19
- gem "simplecov", require: false
21
+ # simplecov 1.1.x forwards an anonymous block inside a block, which is a
22
+ # syntax error on Ruby 3.3. Unpin once upstream drops that or we drop 3.3.
23
+ gem "simplecov", "< 1.1", require: false
20
24
  end
21
25
 
22
26
  group :development do
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (18.0.0)
4
+ head_music (20.0.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (>= 2, < 4)
7
7
  i18n (~> 1.8)
@@ -9,7 +9,7 @@ PATH
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- activesupport (8.1.3)
12
+ activesupport (8.1.3.1)
13
13
  base64
14
14
  bigdecimal
15
15
  concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -87,7 +87,7 @@ GEM
87
87
  i18n (1.15.2)
88
88
  concurrent-ruby (~> 1.0)
89
89
  ice_nine (0.11.2)
90
- json (2.21.1)
90
+ json (2.21.2)
91
91
  kramdown (2.5.2)
92
92
  rexml (>= 3.4.4)
93
93
  language_server-protocol (3.17.0.6)
@@ -180,7 +180,7 @@ GEM
180
180
  virtus (~> 2.0)
181
181
  securerandom (0.4.1)
182
182
  sexp_processor (4.17.5)
183
- simplecov (1.0.2)
183
+ simplecov (1.0.3)
184
184
  standard (1.56.0)
185
185
  language_server-protocol (~> 3.17.0.2)
186
186
  lint_roller (~> 1.0)
@@ -207,7 +207,7 @@ GEM
207
207
  coercible (~> 1.0)
208
208
  descendants_tracker (~> 0.0, >= 0.0.3)
209
209
  yard (0.9.45)
210
- zeitwerk (2.8.2)
210
+ zeitwerk (2.8.3)
211
211
 
212
212
  PLATFORMS
213
213
  arm64-darwin-22
@@ -223,11 +223,10 @@ DEPENDENCIES
223
223
  rexml (~> 3.4)
224
224
  rspec (~> 3.0)
225
225
  rspec-its (~> 2.0)
226
- rubocop
227
226
  rubocop-rake
228
227
  rubocop-rspec
229
228
  rubycritic
230
- simplecov
229
+ simplecov (< 1.1)
231
230
  standard
232
231
  yard
233
232
 
data/README.md CHANGED
@@ -52,6 +52,83 @@ chord = HeadMusic::Analysis::PitchSet.new(pitches)
52
52
  puts chord.major_triad? # => true
53
53
  ```
54
54
 
55
+ ## Style Analysis
56
+
57
+ Look up a style guide by key and analyze a voice against it. `Style::Guide.get` returns `nil` for an
58
+ unknown key, so a stored key can be validated before use.
59
+
60
+ ```ruby
61
+ guide = HeadMusic::Style::Guide.get('first_species_harmony')
62
+ guide.category # => :harmony
63
+ guide.display_name # => "First Species Harmony"
64
+
65
+ assessment = guide.assess(voice)
66
+ assessment.fitness # => 0.0 to 1.0
67
+ assessment.messages # => ["Prefer contrary motion. Move voices in different melodic directions."]
68
+ ```
69
+
70
+ A guide declares its guidelines in three tiers, and the tier decides how much each one counts:
71
+
72
+ ```ruby
73
+ guide.gate_items # preconditions -- can this voice be assessed at all?
74
+ guide.primary_items # what the guide is about
75
+ guide.secondary_items # background craft it inherits rather than teaches
76
+ guide.guide_items # all three, in that order
77
+ ```
78
+
79
+ A gate asks whether the voice can be assessed at all. Failing one **stops** the assessment — the
80
+ rubric is not computed, and the grade is the gates alone:
81
+
82
+ ```ruby
83
+ assessment.assessable? # => false for a voice too short to judge, or with no companion voice
84
+ assessment.fitness # => the gates' product; the rubric was never reached
85
+ ```
86
+
87
+ Among the rules that are assessed, primaries share φ⁻¹ of the rubric and secondaries share φ⁻², which
88
+ is why a species guide weighs its own rules as heavily as all the craft it inherits put together. The
89
+ budgets are fixed rather than divided by item count, so what a guide teaches does not thin out as it
90
+ inherits more. A rubric that declares only one tier is renormalized to the full range.
91
+
92
+ Within a tier, a second axis: **strength**. A prohibition (`:strong`, the default) weighs twice a
93
+ preference (`:weak`), normalized by that tier's own total. Strength never crosses a tier boundary, and
94
+ it is inert on gates, which multiply the whole rubric:
95
+
96
+ ```ruby
97
+ HeadMusic::Style::Guidelines::NoParallelPerfectOnDownbeats.strength # => :strong
98
+ HeadMusic::Style::Guidelines::PreferContraryMotion.strength # => :weak
99
+ ```
100
+
101
+ Unlike tier, strength is a property of the guideline rather than of the list it was declared in — a
102
+ preference is a preference in every guide that names it. An item may override it for the
103
+ tradition-dependent case, with `Guideline.with(strength: :weak)`.
104
+
105
+ Each entry is a `Style::GuideItem` — a guideline plus the configuration this guide gives it — and
106
+ assessing one yields a frozen `Style::GuideItemAssessment`:
107
+
108
+ ```ruby
109
+ item = guide.primary_items.first
110
+ item.guideline # => HeadMusic::Style::Guidelines::NoUnisonsInMiddle
111
+ item.config # => {}
112
+ item.strength # => :strong
113
+
114
+ assessment.guide_item_assessments.first.tier # => :gate
115
+ assessment.guide_item_assessments.first.strength # => :strong
116
+ assessment.guide_item_assessments.first.fitness # => 0.0 to 1.0
117
+ ```
118
+
119
+ Guides whose tiers vary by configuration are built with `.with`. The six contour melodies are
120
+ registered under their own keys, and each key is exactly one such configuration:
121
+
122
+ ```ruby
123
+ HeadMusic::Style::Guide.get('arch_contour_melody')
124
+ # the same guide, spelled out
125
+ HeadMusic::Style::Guides::ContourMelody.with(contour: :arch, minimum_melodic_intervals: 2)
126
+ ```
127
+
128
+ Configure it differently and you get a different guide — one the registry does not hold, whose `key`
129
+ is `nil` and whose `display_name` falls back to the class. Prefer the key when you mean a registered
130
+ guide, and `.with` when you deliberately want a configuration of your own.
131
+
55
132
  ## Documentation
56
133
 
57
134
  - **API Documentation**: [rubydoc.info/gems/head_music](https://rubydoc.info/gems/head_music)
data/Rakefile CHANGED
@@ -45,3 +45,26 @@ desc "Open coverage report in browser"
45
45
  task :coverage do
46
46
  sh "open coverage/index.html" if File.exist?("coverage/index.html")
47
47
  end
48
+
49
+ namespace :style do
50
+ desc "Regenerate the pinned English guide strings (spec/fixtures/style/english_strings.yml)"
51
+ task :snapshot_english do
52
+ require "yaml"
53
+ $LOAD_PATH.unshift File.expand_path("lib", __dir__)
54
+ require "head_music"
55
+
56
+ guides = HeadMusic::Style::Guide::ALL
57
+ items = guides.flat_map(&:guide_items).uniq
58
+ snapshot = %i[en en_GB].to_h do |locale|
59
+ strings = I18n.with_locale(locale) do
60
+ guides.flat_map { |guide| [guide.display_name, guide.instruction] } +
61
+ items.flat_map { |item| [item.name, item.instruction] + item.violation_previews }
62
+ end
63
+ [locale.to_s, strings]
64
+ end
65
+
66
+ path = File.expand_path("spec/fixtures/style/english_strings.yml", __dir__)
67
+ File.write(path, snapshot.to_yaml)
68
+ puts "Wrote #{snapshot.values.sum(&:size)} strings to #{path}"
69
+ end
70
+ end