head_music 19.0.0 → 20.1.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 (224) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +4 -0
  3. data/.github/workflows/ci.yml +93 -8
  4. data/.github/workflows/release.yml +10 -9
  5. data/.github/workflows/security.yml +10 -8
  6. data/.rubocop.yml +8 -0
  7. data/CHANGELOG.md +189 -4
  8. data/CLAUDE.md +1 -1
  9. data/Gemfile +11 -6
  10. data/Gemfile.lock +8 -9
  11. data/README.md +67 -4
  12. data/Rakefile +23 -0
  13. data/bin/guide_grade_corpus.rb +124 -0
  14. data/bin/guide_grade_table.rb +357 -0
  15. data/bin/guide_item_strings.rb +35 -0
  16. data/lib/head_music/analysis/diatonic_interval/consonance_questions.rb +43 -0
  17. data/lib/head_music/analysis/diatonic_interval/inversion.rb +31 -0
  18. data/lib/head_music/analysis/diatonic_interval/localization.rb +38 -0
  19. data/lib/head_music/analysis/diatonic_interval/parser.rb +20 -1
  20. data/lib/head_music/analysis/diatonic_interval.rb +24 -69
  21. data/lib/head_music/content/composition.rb +4 -0
  22. data/lib/head_music/content/voice/continuity.rb +47 -0
  23. data/lib/head_music/content/voice/melodic_line.rb +51 -4
  24. data/lib/head_music/content/voice.rb +31 -75
  25. data/lib/head_music/locales/de.yml +156 -0
  26. data/lib/head_music/locales/en.yml +431 -3
  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/duration_resolver.rb +4 -64
  33. data/lib/head_music/notation/abc/voice_state.rb +1 -10
  34. data/lib/head_music/notation/abc/writer.rb +2 -22
  35. data/lib/head_music/notation/{music_xml/clef_selector.rb → clef_selector.rb} +2 -2
  36. data/lib/head_music/notation/dotted_duration.rb +81 -2
  37. data/lib/head_music/notation/lily_pond/assignment_reader.rb +65 -0
  38. data/lib/head_music/notation/lily_pond/composition_builder.rb +159 -0
  39. data/lib/head_music/notation/lily_pond/context_reader.rb +85 -0
  40. data/lib/head_music/notation/lily_pond/document.rb +46 -0
  41. data/lib/head_music/notation/lily_pond/document_reader.rb +96 -0
  42. data/lib/head_music/notation/lily_pond/duration_reader.rb +67 -0
  43. data/lib/head_music/notation/lily_pond/duration_writer.rb +32 -0
  44. data/lib/head_music/notation/lily_pond/key_mapper.rb +38 -0
  45. data/lib/head_music/notation/lily_pond/key_reader.rb +63 -0
  46. data/lib/head_music/notation/lily_pond/lexer.rb +198 -0
  47. data/lib/head_music/notation/lily_pond/meter_reader.rb +31 -0
  48. data/lib/head_music/notation/lily_pond/music_item_reader.rb +107 -0
  49. data/lib/head_music/notation/lily_pond/music_reader.rb +127 -0
  50. data/lib/head_music/notation/lily_pond/parse_preflight.rb +48 -0
  51. data/lib/head_music/notation/lily_pond/parser.rb +30 -0
  52. data/lib/head_music/notation/lily_pond/pitch_reader.rb +100 -0
  53. data/lib/head_music/notation/lily_pond/pitch_reader_stack.rb +31 -0
  54. data/lib/head_music/notation/lily_pond/pitch_writer.rb +36 -0
  55. data/lib/head_music/notation/lily_pond/preflight.rb +64 -0
  56. data/lib/head_music/notation/lily_pond/render_plan.rb +50 -0
  57. data/lib/head_music/notation/lily_pond/string_text.rb +18 -0
  58. data/lib/head_music/notation/lily_pond/token.rb +20 -0
  59. data/lib/head_music/notation/lily_pond/token_cursor.rb +68 -0
  60. data/lib/head_music/notation/lily_pond/voice_context.rb +42 -0
  61. data/lib/head_music/notation/lily_pond/voice_stream.rb +110 -0
  62. data/lib/head_music/notation/lily_pond/writer.rb +127 -0
  63. data/lib/head_music/notation/lily_pond.rb +41 -0
  64. data/lib/head_music/notation/music_xml/duration_writer.rb +1 -7
  65. data/lib/head_music/notation/music_xml/preflight.rb +7 -42
  66. data/lib/head_music/notation/music_xml/render_plan.rb +13 -60
  67. data/lib/head_music/notation/music_xml/writer.rb +1 -1
  68. data/lib/head_music/notation/preflight_checks.rb +40 -0
  69. data/lib/head_music/notation/render_plan.rb +71 -0
  70. data/lib/head_music/notation.rb +3 -0
  71. data/lib/head_music/rudiment/base.rb +21 -0
  72. data/lib/head_music/rudiment/chromatic_interval.rb +1 -2
  73. data/lib/head_music/rudiment/consonance.rb +1 -2
  74. data/lib/head_music/rudiment/key_signature.rb +1 -3
  75. data/lib/head_music/rudiment/letter_name.rb +2 -4
  76. data/lib/head_music/rudiment/meter.rb +1 -2
  77. data/lib/head_music/rudiment/note.rb +1 -2
  78. data/lib/head_music/rudiment/pitch/arithmetic.rb +46 -0
  79. data/lib/head_music/rudiment/pitch/helmholtz_notation.rb +24 -0
  80. data/lib/head_music/rudiment/pitch/natural_letter_pitch.rb +35 -0
  81. data/lib/head_music/rudiment/pitch/natural_step.rb +7 -2
  82. data/lib/head_music/rudiment/pitch/step_distance.rb +36 -0
  83. data/lib/head_music/rudiment/pitch.rb +24 -66
  84. data/lib/head_music/rudiment/pitch_class.rb +1 -2
  85. data/lib/head_music/rudiment/qualified_diatonic_context.rb +1 -2
  86. data/lib/head_music/rudiment/quality.rb +1 -2
  87. data/lib/head_music/rudiment/register.rb +3 -5
  88. data/lib/head_music/rudiment/rest.rb +1 -3
  89. data/lib/head_music/rudiment/rhythmic_value.rb +16 -0
  90. data/lib/head_music/rudiment/scale.rb +1 -2
  91. data/lib/head_music/rudiment/scale_type.rb +1 -2
  92. data/lib/head_music/rudiment/spelling.rb +13 -5
  93. data/lib/head_music/rudiment/unpitched_note.rb +1 -2
  94. data/lib/head_music/style/composite_assessment.rb +97 -0
  95. data/lib/head_music/style/guide.rb +51 -29
  96. data/lib/head_music/style/guide_assessment.rb +138 -0
  97. data/lib/head_music/style/guide_item.rb +118 -0
  98. data/lib/head_music/style/guide_item_assessment.rb +63 -0
  99. data/lib/head_music/style/guideline/harmonic_context.rb +40 -0
  100. data/lib/head_music/style/guideline/melodic_context.rb +44 -0
  101. data/lib/head_music/style/guideline/strength.rb +71 -0
  102. data/lib/head_music/style/guideline/voice_context.rb +41 -0
  103. data/lib/head_music/style/guideline/wording.rb +79 -0
  104. data/lib/head_music/style/guideline.rb +128 -0
  105. data/lib/head_music/style/guidelines/{allowed_rhythmic_values_for_fifth_species.rb → allow_fifth_species_rhythmic_values.rb} +1 -4
  106. data/lib/head_music/style/guidelines/{allowed_rhythmic_values_for_combined123.rb → allow_whole_half_quarter_notes.rb} +2 -4
  107. data/lib/head_music/style/guidelines/always_move.rb +1 -3
  108. data/lib/head_music/style/guidelines/approach_perfection_contrarily.rb +1 -3
  109. data/lib/head_music/style/guidelines/avoid_crossing_voices.rb +1 -3
  110. data/lib/head_music/style/guidelines/avoid_overlapping_voices.rb +1 -3
  111. data/lib/head_music/style/guidelines/consonant_climax.rb +23 -2
  112. data/lib/head_music/style/guidelines/consonant_downbeats.rb +1 -3
  113. data/lib/head_music/style/guidelines/contoured.rb +11 -24
  114. data/lib/head_music/style/guidelines/diatonic.rb +1 -3
  115. data/lib/head_music/style/guidelines/direction_changes.rb +1 -3
  116. data/lib/head_music/style/guidelines/directional_step_to_final_note.rb +1 -1
  117. data/lib/head_music/style/guidelines/dissonance_figure_detection.rb +2 -7
  118. data/lib/head_music/style/guidelines/end_on_perfect_consonance.rb +1 -3
  119. data/lib/head_music/style/guidelines/end_on_tonic.rb +1 -3
  120. data/lib/head_music/style/guidelines/first_bar_entry.rb +1 -1
  121. data/lib/head_music/style/guidelines/first_bar_half_notes.rb +0 -2
  122. data/lib/head_music/style/guidelines/first_bar_quarter_notes.rb +0 -2
  123. data/lib/head_music/style/guidelines/first_bar_whole_note.rb +0 -2
  124. data/lib/head_music/style/guidelines/florid_dissonance_treatment.rb +1 -4
  125. data/lib/head_music/style/guidelines/frequent_direction_changes.rb +4 -1
  126. data/lib/head_music/style/guidelines/large_leaps.rb +12 -6
  127. data/lib/head_music/style/guidelines/limit_octave_leaps.rb +8 -3
  128. data/lib/head_music/style/guidelines/maximum_notes.rb +6 -3
  129. data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +5 -2
  130. data/lib/head_music/style/guidelines/minimum_notes.rb +5 -2
  131. data/lib/head_music/style/guidelines/minimum_threshold.rb +1 -13
  132. data/lib/head_music/style/guidelines/mixed_rhythmic_values.rb +1 -3
  133. data/lib/head_music/style/guidelines/moderate_direction_changes.rb +4 -1
  134. data/lib/head_music/style/guidelines/mostly_conjunct.rb +9 -3
  135. data/lib/head_music/style/guidelines/no_parallel_perfect.rb +1 -1
  136. data/lib/head_music/style/guidelines/no_parallel_perfect_across_barline.rb +1 -3
  137. data/lib/head_music/style/guidelines/no_parallel_perfect_on_downbeats.rb +0 -2
  138. data/lib/head_music/style/guidelines/no_parallel_perfect_with_syncopation.rb +0 -2
  139. data/lib/head_music/style/guidelines/no_rests.rb +1 -3
  140. data/lib/head_music/style/guidelines/no_rests_after_note.rb +1 -3
  141. data/lib/head_music/style/guidelines/no_strong_beat_unisons.rb +1 -3
  142. data/lib/head_music/style/guidelines/no_unisons_in_middle.rb +1 -3
  143. data/lib/head_music/style/guidelines/note_count_per_bar.rb +18 -3
  144. data/lib/head_music/style/guidelines/note_fills_final_bar.rb +1 -3
  145. data/lib/head_music/style/guidelines/notes_same_length.rb +1 -3
  146. data/lib/head_music/style/guidelines/one_to_one.rb +1 -3
  147. data/lib/head_music/style/guidelines/one_to_one_with_ties.rb +1 -3
  148. data/lib/head_music/style/guidelines/prefer_contrary_motion.rb +2 -2
  149. data/lib/head_music/style/guidelines/prefer_imperfect.rb +2 -2
  150. data/lib/head_music/style/guidelines/prepare_octave_leaps.rb +2 -2
  151. data/lib/head_music/style/guidelines/second_species_break.rb +0 -2
  152. data/lib/head_music/style/guidelines/set_against_another_voice.rb +22 -0
  153. data/lib/head_music/style/guidelines/singable_intervals.rb +22 -23
  154. data/lib/head_music/style/guidelines/singable_range.rb +7 -12
  155. data/lib/head_music/style/guidelines/start_on_perfect_consonance.rb +1 -3
  156. data/lib/head_music/style/guidelines/start_on_tonic.rb +1 -3
  157. data/lib/head_music/style/guidelines/step_down_to_final_note.rb +0 -2
  158. data/lib/head_music/style/guidelines/step_out_of_unison.rb +1 -3
  159. data/lib/head_music/style/guidelines/step_to_final_note.rb +1 -3
  160. data/lib/head_music/style/guidelines/step_up_to_final_note.rb +0 -2
  161. data/lib/head_music/style/guidelines/suspension_treatment.rb +1 -3
  162. data/lib/head_music/style/guidelines/third_species_dissonance_treatment.rb +0 -2
  163. data/lib/head_music/style/guidelines/triple_meter_dissonance_treatment.rb +0 -2
  164. data/lib/head_music/style/guidelines/weak_beat_dissonance_treatment.rb +1 -3
  165. data/lib/head_music/style/guides/assessment.rb +22 -0
  166. data/lib/head_music/style/guides/base.rb +132 -33
  167. data/lib/head_music/style/guides/composite_guide.rb +134 -0
  168. data/lib/head_music/style/guides/configured.rb +35 -13
  169. data/lib/head_music/style/guides/contour_melody.rb +26 -41
  170. data/lib/head_music/style/guides/diatonic_melody.rb +10 -3
  171. data/lib/head_music/style/guides/fifth_species_harmony.rb +10 -5
  172. data/lib/head_music/style/guides/fifth_species_melody.rb +6 -2
  173. data/lib/head_music/style/guides/first_species_harmony.rb +18 -4
  174. data/lib/head_music/style/guides/first_species_melody.rb +12 -4
  175. data/lib/head_music/style/guides/first_three_species_harmony.rb +14 -0
  176. data/lib/head_music/style/guides/{combined_first_second_third_species_melody.rb → first_three_species_melody.rb} +8 -5
  177. data/lib/head_music/style/guides/fourth_species_harmony.rb +10 -5
  178. data/lib/head_music/style/guides/fourth_species_melody.rb +5 -3
  179. data/lib/head_music/style/guides/fux_cantus_firmus.rb +8 -3
  180. data/lib/head_music/style/guides/salzer_schachter_cantus_firmus.rb +7 -2
  181. data/lib/head_music/style/guides/second_species_harmony.rb +5 -3
  182. data/lib/head_music/style/guides/second_species_melody.rb +5 -1
  183. data/lib/head_music/style/guides/species_harmony.rb +57 -11
  184. data/lib/head_music/style/guides/species_melody.rb +24 -11
  185. data/lib/head_music/style/guides/third_species_harmony.rb +5 -3
  186. data/lib/head_music/style/guides/third_species_melody.rb +5 -1
  187. data/lib/head_music/style/guides/third_species_triple_meter_harmony.rb +5 -3
  188. data/lib/head_music/style/guides/third_species_triple_meter_melody.rb +5 -1
  189. data/lib/head_music/style/mark.rb +1 -1
  190. data/lib/head_music/style/template.rb +161 -0
  191. data/lib/head_music/version.rb +1 -1
  192. data/lib/head_music.rb +32 -7
  193. data/references/fifth-species-counterpoint.md +4 -4
  194. data/references/fourth-species-counterpoint.md +22 -5
  195. data/references/note-values-by-language.md +173 -0
  196. data/references/third-species-counterpoint.md +7 -7
  197. data/user-stories/backlog/organizing-content.md +7 -4
  198. data/user-stories/backlog/tell-the-species-apart.md +136 -0
  199. data/user-stories/done/british-note-names.md +834 -0
  200. data/user-stories/done/composite-guides.grades.md +308 -0
  201. data/user-stories/done/composite-guides.md +1138 -0
  202. data/user-stories/done/extract-the-harmonic-cores.grades.md +1073 -0
  203. data/user-stories/done/extract-the-harmonic-cores.md +1213 -0
  204. data/user-stories/done/first-class-guide-items.md +852 -0
  205. data/user-stories/done/guard-the-vocabulary-sweep-itself.md +600 -0
  206. data/user-stories/done/guideline-strings-into-i18n.md +931 -0
  207. data/user-stories/done/lilypond-export.md +260 -0
  208. data/user-stories/done/lilypond-interpreter.md +395 -0
  209. data/user-stories/done/note-values-in-each-language.md +661 -0
  210. data/user-stories/done/re-tier-the-guides.grades.md +1979 -0
  211. data/user-stories/done/re-tier-the-guides.md +753 -0
  212. data/user-stories/done/release-notes-for-19-0-0.md +279 -0
  213. data/user-stories/done/rename-annotation-to-guideline.md +567 -0
  214. data/user-stories/done/species-guide-harmonic-weights.grades.md +353 -0
  215. data/user-stories/done/species-guide-harmonic-weights.md +599 -0
  216. data/user-stories/epics/style-assessment-model.md +386 -0
  217. data/user-stories/index.html +86 -22
  218. metadata +81 -12
  219. data/lib/head_music/style/analysis.rb +0 -59
  220. data/lib/head_music/style/annotation/configured.rb +0 -35
  221. data/lib/head_music/style/annotation.rb +0 -193
  222. data/lib/head_music/style/guides/combined_first_second_third_species_harmony.rb +0 -10
  223. data/user-stories/backlog/lilypond-export.md +0 -74
  224. data/user-stories/backlog/lilypond-interpreter.md +0 -75
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7462797fd33041d3ab9fea4cf10cfaf329213d6f96d3f3b9cbb4f6a73dfd2890
4
- data.tar.gz: 01c4cff6e9f609ce9543be019c340b3c7a01c21928e7248345d5a50364d85705
3
+ metadata.gz: b3714d51858bd368308d14a5f12544e6f4fb9dbecf0f0eacc1ad795c5e4b3d1e
4
+ data.tar.gz: 99345ffa2b44dfbdc27836b7b7350a38af1665770a74490f52e22bd1a0aa3602
5
5
  SHA512:
6
- metadata.gz: c14082d1dc7e8405227b46be83e803b3037808c817200e8cadf18ead15dda70f17248d92a2c370d52f92eb9ea4241aa7dad07869e016979edc301c9d30f077aa
7
- data.tar.gz: '06868ca14c8cca41f22c395cb04d84a6291492471cf034a9efd69afc4a822951cd2fc9ad4d2c7b4a94ed7d68b8bd70a8003090092b96291733ad1378f48c3d19'
6
+ metadata.gz: d95aa74479fe498564dc7e1bc5427660b9687e286a31a4b5cbbb1c0a274bebe9413ae18e2176dbbf38c351e4b1517275d5bf8dce590642c88c2301ea83f038b6
7
+ data.tar.gz: 4446808775faf30ef42dceca7de10248a88d8c8265ab4faa3c11589bd6ddb7dd60ff9c10ce2cb34a68f9e039061bc3633484e01b2002f649bb3f885bd5d7e968
@@ -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"
@@ -6,14 +6,39 @@ on:
6
6
  pull_request:
7
7
  branches: [ main ]
8
8
 
9
+ permissions:
10
+ contents: read
11
+
12
+ # A rebase flow means force-pushes are routine; without this, each one leaves
13
+ # the superseded run burning minutes alongside its replacement.
14
+ concurrency:
15
+ group: ${{ github.workflow }}-${{ github.ref }}
16
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
17
+
9
18
  jobs:
10
19
  test:
20
+ name: test (ruby ${{ matrix.ruby-version }}, activesupport ${{ matrix.activesupport-version || 'latest' }})
11
21
  runs-on: ubuntu-latest
22
+ timeout-minutes: 10
12
23
  strategy:
13
24
  fail-fast: false
14
25
  matrix:
15
- ruby-version: ['3.3', '3.4']
16
- activesupport-version: ['7.2', '8.0']
26
+ # The corners of the supported range rather than a full cross product.
27
+ # An empty activesupport-version installs from the lockfile, covering
28
+ # the newest release the gemspec allows; ActiveSupport 7.2 is not
29
+ # paired with Ruby 4.0, which it predates.
30
+ include:
31
+ - ruby-version: '3.3'
32
+ activesupport-version: '7.2'
33
+ - ruby-version: '3.3'
34
+ activesupport-version: ''
35
+ coverage: true
36
+ - ruby-version: '3.4'
37
+ activesupport-version: '7.2'
38
+ - ruby-version: '3.4'
39
+ activesupport-version: '8.0'
40
+ - ruby-version: '4.0'
41
+ activesupport-version: ''
17
42
 
18
43
  steps:
19
44
  - uses: actions/checkout@v7
@@ -24,31 +49,90 @@ jobs:
24
49
  ruby-version: ${{ matrix.ruby-version }}
25
50
  bundler-cache: false
26
51
 
27
- - name: Install dependencies with ActiveSupport ${{ matrix.activesupport-version }}
52
+ - name: Install dependencies with ActiveSupport ${{ matrix.activesupport-version || 'latest' }}
53
+ env:
54
+ ACTIVESUPPORT_VERSION: ${{ matrix.activesupport-version }}
28
55
  run: |
29
56
  bundle config set --local path vendor/bundle
30
- ACTIVESUPPORT_VERSION="${{ matrix.activesupport-version }}" bundle install
57
+ bundle install
31
58
 
32
59
  - name: Run tests
33
60
  run: bundle exec rspec
34
61
 
35
62
  - name: Upload coverage to Codecov
36
- if: matrix.ruby-version == '3.3' && matrix.activesupport-version == '8.0'
63
+ if: matrix.coverage
37
64
  uses: codecov/codecov-action@v7
38
65
  with:
39
66
  token: ${{ secrets.CODECOV_TOKEN }}
40
67
  fail_ci_if_error: false
41
68
  verbose: true
42
69
 
70
+ # Months of warning about upcoming Ruby releases, without a red check that
71
+ # blocks a merge.
72
+ test-ruby-head:
73
+ name: test (ruby-head, advisory)
74
+ runs-on: ubuntu-latest
75
+ timeout-minutes: 10
76
+ continue-on-error: true
77
+ env:
78
+ # Bundler otherwise switches to the lockfile's BUNDLED WITH, and 2.7.2
79
+ # raises "uninitialized constant Pathname::SEPARATOR_PAT" on a Ruby this
80
+ # new. A bundler bug is not the breakage this job exists to find.
81
+ BUNDLE_VERSION: system
82
+ steps:
83
+ - uses: actions/checkout@v7
84
+
85
+ - name: Set up Ruby head
86
+ uses: ruby/setup-ruby@v1
87
+ with:
88
+ ruby-version: head
89
+ bundler: latest
90
+ bundler-cache: false
91
+
92
+ - name: Install dependencies
93
+ run: |
94
+ bundle config set --local path vendor/bundle
95
+ bundle install
96
+
97
+ - name: Run tests
98
+ run: bundle exec rspec
99
+
100
+ # The LilyPond specs treat the real binary as an oracle and skip when none is
101
+ # installed, so without this job nothing proves the rendered documents
102
+ # actually compile. Runs the whole suite because SimpleCov's minimum applies
103
+ # to any rspec invocation.
104
+ test-lilypond:
105
+ name: test (with the lilypond toolchain)
106
+ runs-on: ubuntu-latest
107
+ timeout-minutes: 15
108
+ steps:
109
+ - uses: actions/checkout@v7
110
+
111
+ - name: Install LilyPond
112
+ run: |
113
+ sudo apt-get update
114
+ sudo apt-get install -y lilypond
115
+ lilypond --version
116
+
117
+ - name: Set up Ruby
118
+ uses: ruby/setup-ruby@v1
119
+ with:
120
+ ruby-version: .ruby-version
121
+ bundler-cache: true
122
+
123
+ - name: Run tests
124
+ run: bundle exec rspec
125
+
43
126
  lint:
44
127
  runs-on: ubuntu-latest
128
+ timeout-minutes: 5
45
129
  steps:
46
130
  - uses: actions/checkout@v7
47
131
 
48
132
  - name: Set up Ruby
49
133
  uses: ruby/setup-ruby@v1
50
134
  with:
51
- ruby-version: '3.3.0'
135
+ ruby-version: .ruby-version
52
136
  bundler-cache: true
53
137
 
54
138
  - name: Run RuboCop
@@ -56,13 +140,14 @@ jobs:
56
140
 
57
141
  build:
58
142
  runs-on: ubuntu-latest
143
+ timeout-minutes: 5
59
144
  steps:
60
145
  - uses: actions/checkout@v7
61
146
 
62
147
  - name: Set up Ruby
63
148
  uses: ruby/setup-ruby@v1
64
149
  with:
65
- ruby-version: '3.3.0'
150
+ ruby-version: .ruby-version
66
151
  bundler-cache: true
67
152
 
68
153
  - name: Build gem
@@ -71,4 +156,4 @@ jobs:
71
156
  - name: Check gem
72
157
  run: |
73
158
  gem install *.gem
74
- gem specification *.gem
159
+ gem specification *.gem
@@ -8,6 +8,7 @@ on:
8
8
  jobs:
9
9
  release:
10
10
  runs-on: ubuntu-latest
11
+ timeout-minutes: 15
11
12
  permissions:
12
13
  contents: write
13
14
  id-token: write
@@ -18,7 +19,7 @@ jobs:
18
19
  - name: Set up Ruby
19
20
  uses: ruby/setup-ruby@v1
20
21
  with:
21
- ruby-version: '3.3.0'
22
+ ruby-version: .ruby-version
22
23
  bundler-cache: true
23
24
 
24
25
  - name: Run tests
@@ -38,12 +39,12 @@ jobs:
38
39
  env:
39
40
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40
41
 
42
+ # Trusted publishing: RubyGems exchanges this job's OIDC token for a
43
+ # short-lived credential, so there is no API key to store or rotate.
44
+ # Requires head_music to have this workflow registered as a trusted
45
+ # publisher at rubygems.org/gems/head_music/trusted_publishers.
46
+ - name: Configure RubyGems credentials
47
+ uses: rubygems/configure-rubygems-credentials@v2
48
+
41
49
  - name: Publish to RubyGems
42
- run: |
43
- mkdir -p $HOME/.gem
44
- touch $HOME/.gem/credentials
45
- chmod 0600 $HOME/.gem/credentials
46
- printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
47
- gem push *.gem
48
- env:
49
- GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
50
+ run: gem push *.gem
@@ -10,9 +10,17 @@ on:
10
10
  - cron: '0 9 * * *'
11
11
  workflow_dispatch:
12
12
 
13
+ permissions:
14
+ contents: read
15
+
16
+ concurrency:
17
+ group: ${{ github.workflow }}-${{ github.ref }}
18
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
19
+
13
20
  jobs:
14
21
  security:
15
22
  runs-on: ubuntu-latest
23
+ timeout-minutes: 5
16
24
 
17
25
  steps:
18
26
  - uses: actions/checkout@v7
@@ -20,14 +28,8 @@ jobs:
20
28
  - name: Set up Ruby
21
29
  uses: ruby/setup-ruby@v1
22
30
  with:
23
- ruby-version: '3.3.0'
31
+ ruby-version: .ruby-version
24
32
  bundler-cache: true
25
33
 
26
- - name: Install bundler-audit
27
- run: gem install bundler-audit
28
-
29
34
  - name: Run bundler-audit
30
- run: bundle-audit check --update
31
-
32
- - name: Run RuboCop Security
33
- run: bundle exec rubocop --only Security
35
+ run: bundle exec bundle-audit check --update
data/.rubocop.yml CHANGED
@@ -15,6 +15,14 @@ AllCops:
15
15
  - vendor/**/*
16
16
  TargetRubyVersion: 3.3.0
17
17
 
18
+ # Off in standard's config, so the lint job missed them; the security
19
+ # workflow's "--only Security" pass was the only thing covering them.
20
+ Security/IoMethods:
21
+ Enabled: true
22
+
23
+ Security/MarshalLoad:
24
+ Enabled: true
25
+
18
26
  RSpec:
19
27
  Enabled: true
20
28
 
data/CHANGELOG.md CHANGED
@@ -7,14 +7,172 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [20.1.0] - 2026-09-05
11
+
12
+ The other half of the LilyPond export released in 20.0.0. A document written by the writer, or by hand, now reads back into a composition, so `parse(render(composition))` reproduces the music. Nothing in 20.0.0 changed shape; a consumer upgrades by upgrading.
13
+
14
+ ### Added
15
+
16
+ - **LilyPond import.** `HeadMusic::Notation::LilyPond.parse(string)` reads a LilyPond document, or a bare music expression, into a `Content::Composition`: absolute and `\relative` pitches with `is`/`es` accidentals and Dutch contractions, durations with dots and carry-over, rests and whole-bar rests, chords, intra-bar ties, `\key`, `\time` (including mid-piece changes), `\clef`, bar checks, `\header` title and composer, and `\new Staff` / `\new Voice` contexts with `instrumentName` as the voice role. Everything the LilyPond writer emits reads back, so `parse(render(composition))` reproduces the music. `\version`, `\layout`, and `\midi` blocks are skipped whole, as are the header fields the reader does not use, so the Scheme in an everyday engraving preamble costs nothing. A bar-check mismatch raises `LilyPond::ParseError`; constructs outside the subset raise `LilyPond::UnsupportedFeatureError` rather than being skipped.
17
+ - `HeadMusic::Notation::DottedDuration.rhythmic_value_for(fraction)`, the inverse of `dotted_unit_fraction`, shared by the ABC and LilyPond readers.
18
+
19
+ ### Changed
20
+
21
+ - **Placing a note no longer costs time linear in the voice's length.** `Content::Voice#place` scanned its placements from the front twice — once for a placement already at the position, once for the insertion point — so filling a long voice was quadratic in its length. Both are now binary searches over the position order the list already keeps. Reading a 28KB LilyPond score went from 76 seconds to under 9; the remaining time is in `Content::Position` arithmetic rather than here.
22
+
23
+ ## [20.0.0] - 2026-08-30
24
+
25
+ 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.
26
+
27
+ **Migrating from 19.0.0**, in the order a consumer will hit them:
28
+
29
+ 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`.
30
+ 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_*`.
31
+ 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.
32
+ 4. Guideline strings are i18n templates. A consumer reading `MESSAGE` constants reads `GuideItemAssessment#message` instead.
33
+
34
+ ### Added
35
+
36
+ - **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.
37
+
38
+ 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.
39
+
40
+ - `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.
41
+
42
+ 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.
43
+
44
+ - `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.
45
+
46
+ - `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.
47
+
48
+ - `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]`.
49
+
50
+ - 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.
51
+
52
+ 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.
53
+
54
+ Eight guidelines are classified `:weak`: `FrequentDirectionChanges`, `LargeLeaps`, `LimitOctaveLeaps`, `ModerateDirectionChanges`, `MostlyConjunct`, `PreferContraryMotion`, `PreferImperfect`, and `PrepareOctaveLeaps`.
55
+
56
+ - `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.
57
+
58
+ - `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.
59
+
60
+ - `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.
61
+
62
+ `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.
63
+
64
+ Where a locale has no plural data, `Template.pluralize` falls back to Ruby rather than raising, and records the key it fell back for.
65
+
66
+ - 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.
67
+
68
+ - **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`.
69
+
70
+ 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.
71
+
72
+ - **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.
73
+
74
+ 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.
75
+
76
+ - `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.
77
+
78
+ ### Changed
79
+
80
+ - **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.
81
+
82
+ - **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:
83
+
84
+ | Was | Is |
85
+ | --- | --- |
86
+ | `Guides::CombinedFirstSecondThirdSpeciesMelody` | `Guides::FirstThreeSpeciesMelody` |
87
+ | `Guides::CombinedFirstSecondThirdSpeciesHarmony` | `Guides::FirstThreeSpeciesHarmony` |
88
+ | `Guidelines::AllowedRhythmicValuesForCombined123` | `Guidelines::AllowWholeHalfQuarterNotes` |
89
+ | `Guidelines::AllowedRhythmicValuesForFifthSpecies` | `Guidelines::AllowFifthSpeciesRhythmicValues` |
90
+
91
+ 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.
92
+
93
+ - No existing guide's grade changes. Measured across the whole corpus — 3266 rows, 142 voices × 23 guides — every row is identical before and after.
94
+
95
+ - **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.
96
+
97
+ - **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.
98
+
99
+ - **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.
100
+
101
+ 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.
102
+
103
+ - **`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.
104
+
105
+ 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.
106
+
107
+ 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.
108
+
109
+ - `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.
110
+
111
+ - **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.
112
+
113
+ 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.
114
+
115
+ - **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?`.
116
+
117
+ - **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.
118
+
119
+ - **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.
120
+
121
+ - **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.
122
+
123
+ - **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`.
124
+
125
+ - **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.
126
+
127
+ `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.
128
+
129
+ - **Breaking.** Grades change outside the degenerate range, deliberately. Three sources, with every affected row recorded in the story's grade table:
130
+
131
+ | Change | Effect |
132
+ | --- | --- |
133
+ | 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. |
134
+ | 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. |
135
+ | `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. |
136
+ | 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. |
137
+
138
+ 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.
139
+
140
+ - **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>`:
141
+
142
+ | Key | Reads |
143
+ | --- | --- |
144
+ | `name` | a short label, e.g. "Minimum of eight notes" |
145
+ | `instruction` | what to do |
146
+ | `violations.default` | what to do differently |
147
+
148
+ 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.
149
+
150
+ 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.
151
+
152
+ 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.
153
+
154
+ 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.
155
+
156
+ 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.
157
+
158
+ `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.
159
+
160
+ `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.
161
+
162
+ - **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.
163
+
164
+ - **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.
165
+
166
+ ## [19.0.0] - 2026-08-07
167
+
10
168
  ### Added
11
169
 
12
170
  - `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:
13
171
 
14
172
  | Category | Keys |
15
173
  | --- | --- |
16
- | `: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`, `combined_first_second_third_species_melody`, `fifth_species_melody`, `arch_contour_melody`, `ascending_contour_melody`, `descending_contour_melody`, `static_contour_melody`, `valley_contour_melody`, `wave_contour_melody` |
17
- | `:harmony` | `first_species_harmony`, `second_species_harmony`, `third_species_harmony`, `third_species_triple_meter_harmony`, `fourth_species_harmony`, `combined_first_second_third_species_harmony`, `fifth_species_harmony` |
174
+ | `: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` |
175
+ | `: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` |
18
176
 
19
177
  - 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.
20
178
  - `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.
@@ -573,7 +731,34 @@ note = HeadMusic::Rudiment::Note.get("F#4 dotted-quarter")
573
731
 
574
732
  For changes in versions prior to 0.28.0, please refer to the git history.
575
733
 
576
- [Unreleased]: https://github.com/roberthead/head_music/compare/v8.2.0...HEAD
734
+ [Unreleased]: https://github.com/roberthead/head_music/compare/v20.1.0...HEAD
735
+ [20.1.0]: https://github.com/roberthead/head_music/compare/v20.0.0...v20.1.0
736
+ [20.0.0]: https://github.com/roberthead/head_music/compare/v19.0.0...v20.0.0
737
+ [19.0.0]: https://github.com/roberthead/head_music/compare/v18.0.0...v19.0.0
738
+ [18.0.0]: https://github.com/roberthead/head_music/compare/v17.5.0...v18.0.0
739
+ [17.5.0]: https://github.com/roberthead/head_music/compare/v17.3.0...v17.5.0
740
+ [17.3.0]: https://github.com/roberthead/head_music/compare/v17.2.0...v17.3.0
741
+ [17.2.0]: https://github.com/roberthead/head_music/compare/v17.1.0...v17.2.0
742
+ [17.1.0]: https://github.com/roberthead/head_music/compare/v17.0.0...v17.1.0
743
+ [17.0.0]: https://github.com/roberthead/head_music/compare/v15.2.0...v17.0.0
744
+ [15.2.0]: https://github.com/roberthead/head_music/compare/v15.0.0...v15.2.0
745
+ [15.0.0]: https://github.com/roberthead/head_music/compare/v14.0.0...v15.0.0
746
+ [14.0.0]: https://github.com/roberthead/head_music/compare/v13.0.0...v14.0.0
747
+ [13.0.0]: https://github.com/roberthead/head_music/compare/v12.6.0...v13.0.0
748
+ [12.6.0]: https://github.com/roberthead/head_music/compare/v12.5.0...v12.6.0
749
+ [12.5.0]: https://github.com/roberthead/head_music/compare/v12.4.0...v12.5.0
750
+ [12.4.0]: https://github.com/roberthead/head_music/compare/v12.3.0...v12.4.0
751
+ [12.3.0]: https://github.com/roberthead/head_music/compare/v12.2.0...v12.3.0
752
+ [12.2.0]: https://github.com/roberthead/head_music/compare/v12.1.0...v12.2.0
753
+ [12.1.0]: https://github.com/roberthead/head_music/compare/v12.0.1...v12.1.0
754
+ [12.0.1]: https://github.com/roberthead/head_music/compare/v12.0.0...v12.0.1
755
+ [12.0.0]: https://github.com/roberthead/head_music/compare/v11.8.0...v12.0.0
756
+ [11.8.0]: https://github.com/roberthead/head_music/compare/v11.7.0...v11.8.0
757
+ [11.7.0]: https://github.com/roberthead/head_music/compare/v11.6.1...v11.7.0
758
+ [11.6.1]: https://github.com/roberthead/head_music/compare/v11.6.0...v11.6.1
759
+ [11.6.0]: https://github.com/roberthead/head_music/compare/v11.5.1...v11.6.0
760
+ [11.0.0]: https://github.com/roberthead/head_music/compare/v9.1.0...v11.0.0
761
+ [9.0.0]: https://github.com/roberthead/head_music/compare/v8.4.0...v9.0.0
577
762
  [8.2.0]: https://github.com/roberthead/head_music/compare/v8.1.1...v8.2.0
578
763
  [8.1.1]: https://github.com/roberthead/head_music/compare/v8.1.0...v8.1.1
579
764
  [8.1.0]: https://github.com/roberthead/head_music/compare/v8.0.2...v8.1.0
@@ -595,4 +780,4 @@ For changes in versions prior to 0.28.0, please refer to the git history.
595
780
  [2.0.0]: https://github.com/roberthead/head_music/compare/v1.0.0...v2.0.0
596
781
  [1.0.0]: https://github.com/roberthead/head_music/compare/v0.29.0...v1.0.0
597
782
  [0.29.0]: https://github.com/roberthead/head_music/compare/v0.28.0...v0.29.0
598
- [0.28.0]: https://github.com/roberthead/head_music/releases/tag/v0.28.0
783
+ [0.28.0]: https://github.com/roberthead/head_music/releases/tag/v0.28.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
@@ -5,18 +5,23 @@ ruby ">= 3.3.0"
5
5
  # Specify your gem's dependencies in head_music.gemspec
6
6
  gemspec
7
7
 
8
- # Allow CI to test against specific ActiveSupport versions
9
- if ENV["ACTIVESUPPORT_VERSION"]
10
- gem "activesupport", "~> #{ENV["ACTIVESUPPORT_VERSION"]}.0"
11
- end
8
+ # Allow CI to test against specific ActiveSupport versions. An unset or empty
9
+ # value leaves the gemspec range alone -- empty is truthy in Ruby, so a matrix
10
+ # cell that sets the key to "" would otherwise resolve against "~> .0".
11
+ activesupport_version = ENV["ACTIVESUPPORT_VERSION"].to_s
12
+ gem "activesupport", "~> #{activesupport_version}.0" unless activesupport_version.empty?
12
13
 
13
14
  gem "standard", require: false
14
15
 
15
16
  group :test do
16
- gem "rubocop", require: false
17
+ # rubocop is not listed directly: standard pins it (~> 1.88.0) and must
18
+ # lead. Listing it here let Dependabot bump it past standard, which the
19
+ # resolver "fixed" by downgrading standard 1.56 -> 1.35.
17
20
  gem "rubocop-rspec", require: false
18
21
  gem "rubocop-rake", require: false
19
- gem "simplecov", require: false
22
+ # simplecov 1.1.x forwards an anonymous block inside a block, which is a
23
+ # syntax error on Ruby 3.3. Unpin once upstream drops that or we drop 3.3.
24
+ gem "simplecov", "< 1.1", require: false
20
25
  end
21
26
 
22
27
  group :development do
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (19.0.0)
4
+ head_music (20.1.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)
@@ -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
@@ -216,23 +216,22 @@ PLATFORMS
216
216
  x86_64-linux
217
217
 
218
218
  DEPENDENCIES
219
- bundler-audit
219
+ bundler-audit (~> 0.9, >= 0)
220
220
  head_music!
221
221
  kramdown
222
222
  rake (~> 13.0)
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
- yard
231
+ yard (~> 0.9, >= 0)
233
232
 
234
233
  RUBY VERSION
235
- ruby 3.3.0p0
234
+ ruby 3.3.0p0
236
235
 
237
236
  BUNDLED WITH
238
- 2.4.12
237
+ 4.0.20