head_music 15.1.0 → 15.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd3edd30892b4392257993e45de98e7c27645b61480c6f478b464005f89b10a7
4
- data.tar.gz: 9755bd71aae10839f15d9a7f73cd6f2526265672c03907e2e2b9db75d6fd4bb0
3
+ metadata.gz: d2446af0205d6b751566da4a2cc132032151d5c9b41732938552d12d3f07d05e
4
+ data.tar.gz: ed75fb7cea9e3a2ea50543bad21d28d0c66b8996cb5d57b4757aa9ef211eb67b
5
5
  SHA512:
6
- metadata.gz: 45706fe9753a6907b79378a7b5334c76fb6cc55f282fe16b77940fe0d8dbe01197c111a452e0974ff2ebe9d5536255858e81da7fb15141a8540ad2600148f817
7
- data.tar.gz: e97830d0f4d8f102ebae608701d5db6a1bdaaa905545a6b14143d2a36f7273ba0398e960b0986d2a05bf5aa704273abea31f89b08db59875ddb059e150b2753b
6
+ metadata.gz: fda48134cdaea328ddcdc1e2e072ea9c94d7a354e99178428969834cab6db36882667f21f7860ba427561d7a70e40eace071a1d4e9a7228e42c6cb92f2749e01
7
+ data.tar.gz: d2d1bfd02a1b1101932acd435836006e1deaa6e81102f233b9b339dfb56c01effc94691ce30063334e7c61eafffee214075c516ff0d831395a8c91b861cfffb0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [15.2.0] - 2026-07-16
11
+
12
+ ### Added
13
+
14
+ - `HeadMusic::Content::Composition#to_h` / `.from_h` — lossless, JSON-safe hash serialization of a composition (schema_version 1). The hash captures name, key signature, meter, composer, origin, voices with roles and ordered placements (tick-precise positions, rhythmic values including ties, exact pitch spellings, rests as `null`), sparse per-bar state (mid-piece key and meter changes, repeat and volta structure), and comments. `from_h` rebuilds through the public builder API and raises `ArgumentError` with path context on malformed input; unknown keys are ignored so the format can evolve additively.
15
+ - `HeadMusic::Content::Composition#to_json` / `.from_json` — thin delegates over `to_h`/`from_h`.
16
+ - `#to_h` on `Content::Voice`, `Content::Placement`, `Content::Bar`, and `Content::Comment`.
17
+
18
+ **Schema v1 is a compatibility surface**: hashes persisted by downstream apps (e.g. in a jsonb column) must keep loading. Additive optional keys are fine within version 1; any change to existing keys' shape or meaning requires a `schema_version` bump.
19
+
20
+ ### Fixed
21
+
22
+ - `RhythmicValue.get` now parses tied value strings ("half tied to eighth", including chained ties), so tied durations round-trip through `#to_s`.
23
+ - `Bar#key_signature=` and `Bar#meter=` coerce strings via `KeySignature.get` / `Meter.get`, so `change_meter(4, "6/8")` no longer stores a raw String.
24
+ - `Voice` placement ordering is now stable: notes placed at the same position (chords) keep their insertion order.
25
+ - `Composition#change_key_signature` / `#change_meter` no longer raise for a bar earlier than the first placement (e.g. a pickup bar).
26
+
10
27
  ## [15.1.0] - 2026-07-07
11
28
 
12
29
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- head_music (15.1.0)
4
+ head_music (15.2.0)
5
5
  activesupport (>= 7.0, < 10)
6
6
  humanize (~> 2.0)
7
7
  i18n (~> 1.8)
@@ -5,19 +5,26 @@ module HeadMusic::Content; end
5
5
  # Encapsulates meter and key signature changes
6
6
  # and repeat structure (repeat barlines and volta brackets) as content semantics
7
7
  class HeadMusic::Content::Bar
8
- attr_reader :composition, :ends_repeat_after_num_plays, :plays_on_passes
9
- attr_accessor :key_signature, :meter
8
+ attr_reader :composition, :ends_repeat_after_num_plays, :plays_on_passes, :key_signature, :meter
10
9
  attr_writer :starts_repeat
11
10
 
12
11
  def initialize(composition, key_signature: nil, meter: nil)
13
12
  @composition = composition
14
- @key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature) if key_signature
15
- @meter = HeadMusic::Rudiment::Meter.get(meter) if meter
13
+ self.key_signature = key_signature
14
+ self.meter = meter
16
15
  @starts_repeat = false
17
16
  @ends_repeat_after_num_plays = nil
18
17
  @plays_on_passes = nil
19
18
  end
20
19
 
20
+ def key_signature=(value)
21
+ @key_signature = value ? HeadMusic::Rudiment::KeySignature.get(value) : nil
22
+ end
23
+
24
+ def meter=(value)
25
+ @meter = value ? HeadMusic::Rudiment::Meter.get(value) : nil
26
+ end
27
+
21
28
  def starts_repeat?
22
29
  @starts_repeat
23
30
  end
@@ -48,6 +55,19 @@ class HeadMusic::Content::Bar
48
55
  ["Bar", key_signature, meter, repeat_summary].compact.join(" ")
49
56
  end
50
57
 
58
+ # Sparse serialization: only non-default state, so a default bar is {}.
59
+ # KeySignature serializes via #name ("F♯ minor") because #to_s ("3 sharps")
60
+ # cannot be parsed back by KeySignature.get.
61
+ def to_h
62
+ hash = {}
63
+ hash["key_signature"] = key_signature.name if key_signature
64
+ hash["meter"] = meter.to_s if meter
65
+ hash["starts_repeat"] = true if starts_repeat?
66
+ hash["ends_repeat_after_num_plays"] = ends_repeat_after_num_plays if ends_repeat?
67
+ hash["plays_on_passes"] = plays_on_passes.dup if plays_on_passes
68
+ hash
69
+ end
70
+
51
71
  private
52
72
 
53
73
  def valid_ends_repeat_after_num_plays?(value)
@@ -15,6 +15,10 @@ class HeadMusic::Content::Comment
15
15
  text
16
16
  end
17
17
 
18
+ def to_h
19
+ {"text" => text, "position" => position&.to_s}
20
+ end
21
+
18
22
  private
19
23
 
20
24
  def ensure_position(position)
@@ -3,8 +3,18 @@ module HeadMusic::Content; end
3
3
 
4
4
  # A composition is musical content.
5
5
  class HeadMusic::Content::Composition
6
+ SCHEMA_VERSION = 1
7
+
6
8
  attr_reader :name, :key_signature, :meter, :voices, :composer, :origin, :comments
7
9
 
10
+ def self.from_h(hash)
11
+ HashDeserializer.new(hash).composition
12
+ end
13
+
14
+ def self.from_json(json)
15
+ from_h(JSON.parse(json))
16
+ end
17
+
8
18
  def initialize(name: nil, key_signature: nil, meter: nil, composer: nil, origin: nil, comments: nil)
9
19
  ensure_attributes(name, key_signature, meter)
10
20
  @composer = composer
@@ -35,10 +45,11 @@ class HeadMusic::Content::Composition
35
45
 
36
46
  def bars(last = latest_bar_number)
37
47
  @bars ||= []
38
- (earliest_bar_number..last).each do |bar_number|
48
+ first = [earliest_bar_number, last].min
49
+ (first..last).each do |bar_number|
39
50
  @bars[bar_number] ||= HeadMusic::Content::Bar.new(self)
40
51
  end
41
- @bars[earliest_bar_number..last]
52
+ @bars[first..last]
42
53
  end
43
54
 
44
55
  def change_key_signature(bar_number, key_signature)
@@ -50,7 +61,7 @@ class HeadMusic::Content::Composition
50
61
  end
51
62
 
52
63
  def earliest_bar_number
53
- [voices.map(&:earliest_bar_number), 1].flatten.min
64
+ [voices.map(&:earliest_bar_number), first_allocated_bar_number, 1].flatten.compact.min
54
65
  end
55
66
 
56
67
  def latest_bar_number
@@ -77,8 +88,32 @@ class HeadMusic::Content::Composition
77
88
  HeadMusic::Notation::MusicXML.render(self)
78
89
  end
79
90
 
91
+ def to_h
92
+ {
93
+ "schema_version" => SCHEMA_VERSION,
94
+ "name" => name,
95
+ "key_signature" => key_signature.name,
96
+ "meter" => meter.to_s,
97
+ "composer" => composer&.to_s,
98
+ "origin" => origin&.to_s,
99
+ "voices" => voices.map(&:to_h),
100
+ "bars" => bars_to_h,
101
+ "comments" => comments.map(&:to_h)
102
+ }
103
+ end
104
+
105
+ def to_json(*_args)
106
+ to_h.to_json
107
+ end
108
+
80
109
  private
81
110
 
111
+ # Bars can be allocated below the voices' earliest bar (e.g. a key or meter
112
+ # change in a pickup bar), so the earliest bar reflects those allocations too.
113
+ def first_allocated_bar_number
114
+ (@bars || []).index { |bar| !bar.nil? }
115
+ end
116
+
82
117
  def ensure_attributes(name, key_signature, meter)
83
118
  @name = name || "Composition"
84
119
  @key_signature = HeadMusic::Rudiment::KeySignature.get(key_signature) if key_signature
@@ -94,4 +129,191 @@ class HeadMusic::Content::Composition
94
129
  def last_key_signature_change(bar_number)
95
130
  bars(bar_number)[earliest_bar_number..bar_number].reverse.detect(&:key_signature)
96
131
  end
132
+
133
+ # Iterates the raw sparse array (not the public #bars slice, which loses the
134
+ # number offset), pairing each non-default bar with its number.
135
+ def bars_to_h
136
+ (@bars || []).each_with_index.filter_map do |bar, number|
137
+ next if bar.nil?
138
+
139
+ bar_hash = bar.to_h
140
+ next if bar_hash.empty?
141
+
142
+ {"number" => number}.merge(bar_hash)
143
+ end
144
+ end
145
+
146
+ # Rebuilds a composition from a schema v1 hash by replaying the public
147
+ # builder API in dependency order: meter and key changes first (position
148
+ # strings roll counts and ticks over via the meter map), then placements,
149
+ # then repeat flags (a pickup-bar flag needs its bar allocated), then
150
+ # comments. Validates values at the boundary so corrupted input raises
151
+ # ArgumentError with path context instead of silently deserializing wrong.
152
+ class HashDeserializer
153
+ def initialize(hash)
154
+ raise ArgumentError, "expected a Hash, got #{hash.class}" unless hash.is_a?(Hash)
155
+
156
+ @hash = hash.deep_transform_keys(&:to_s)
157
+ validate_schema_version
158
+ end
159
+
160
+ def composition
161
+ @composition ||= build_base_composition.tap do |composition|
162
+ apply_bar_changes(composition)
163
+ build_voices(composition)
164
+ apply_repeat_flags(composition)
165
+ add_comments(composition)
166
+ end
167
+ end
168
+
169
+ private
170
+
171
+ attr_reader :hash
172
+
173
+ def validate_schema_version
174
+ version = hash["schema_version"]
175
+ return if version.is_a?(Integer) && version == SCHEMA_VERSION
176
+
177
+ raise ArgumentError, "unsupported schema_version: #{version.inspect} (supported: #{SCHEMA_VERSION})"
178
+ end
179
+
180
+ def build_base_composition
181
+ HeadMusic::Content::Composition.new(
182
+ name: hash["name"],
183
+ key_signature: parsed_key_signature(hash["key_signature"], "key_signature"),
184
+ meter: parsed_meter(hash["meter"], "meter"),
185
+ composer: hash["composer"],
186
+ origin: hash["origin"]
187
+ )
188
+ end
189
+
190
+ def bar_hashes
191
+ @bar_hashes ||= Array(hash["bars"])
192
+ end
193
+
194
+ def apply_bar_changes(composition)
195
+ bar_hashes.each_with_index do |bar_hash, index|
196
+ number = validated_bar_number(bar_hash, index)
197
+ path = "bars[#{index}]"
198
+ key_signature = parsed_key_signature(bar_hash["key_signature"], path)
199
+ meter = parsed_meter(bar_hash["meter"], path)
200
+ composition.change_key_signature(number, key_signature) if key_signature
201
+ composition.change_meter(number, meter) if meter
202
+ end
203
+ end
204
+
205
+ def build_voices(composition)
206
+ Array(hash["voices"]).each_with_index do |voice_hash, voice_index|
207
+ voice = composition.add_voice(role: voice_hash["role"])
208
+ Array(voice_hash["placements"]).each_with_index do |placement_hash, placement_index|
209
+ path = "voices[#{voice_index}].placements[#{placement_index}]"
210
+ position = parsed_position(placement_hash["position"], path)
211
+ rhythmic_value = parsed_rhythmic_value(placement_hash["rhythmic_value"], path)
212
+ pitch = parsed_pitch(placement_hash["pitch"], path)
213
+ voice.place(position, rhythmic_value, pitch)
214
+ end
215
+ end
216
+ end
217
+
218
+ def apply_repeat_flags(composition)
219
+ bar_hashes.each_with_index do |bar_hash, index|
220
+ next unless repeat_state?(bar_hash)
221
+
222
+ bar = composition.bars(validated_bar_number(bar_hash, index)).last
223
+ bar.starts_repeat = true if bar_hash["starts_repeat"]
224
+ bar.ends_repeat_after_num_plays = bar_hash["ends_repeat_after_num_plays"] if bar_hash["ends_repeat_after_num_plays"]
225
+ bar.plays_on_passes = bar_hash["plays_on_passes"] if bar_hash["plays_on_passes"]
226
+ end
227
+ end
228
+
229
+ def add_comments(composition)
230
+ Array(hash["comments"]).each_with_index do |comment_hash, index|
231
+ position = parsed_position(comment_hash["position"], "comments[#{index}]") if comment_hash["position"]
232
+ composition.add_comment(comment_hash["text"], position)
233
+ end
234
+ end
235
+
236
+ def repeat_state?(bar_hash)
237
+ bar_hash["starts_repeat"] || bar_hash["ends_repeat_after_num_plays"] || bar_hash["plays_on_passes"]
238
+ end
239
+
240
+ def validated_bar_number(bar_hash, index)
241
+ number = bar_hash["number"]
242
+ unless number.is_a?(Integer) && number >= 0
243
+ raise ArgumentError, "bars[#{index}]: bar number must be an Integer of at least 0, got #{number.inspect}"
244
+ end
245
+ number
246
+ end
247
+
248
+ # Position silently coerces garbage strings to "0:1:000", which would
249
+ # mislocate content with no error, so the shape is validated up front.
250
+ # Accepts "bar", "bar:count", or "bar:count:tick" with non-negative parts.
251
+ def parsed_position(value, path)
252
+ return nil if value.nil?
253
+
254
+ unless value.is_a?(String) && value.match?(/\A\d+(:\d+){0,2}\z/)
255
+ raise ArgumentError, "#{path}: unknown position #{value.inspect}"
256
+ end
257
+ value
258
+ end
259
+
260
+ # KeySignature.get returns a hollow object (nil tonic_spelling) for
261
+ # garbage rather than nil, so presence of the tonic is the real check.
262
+ def parsed_key_signature(value, path)
263
+ return nil if value.nil?
264
+
265
+ key_signature = begin
266
+ HeadMusic::Rudiment::KeySignature.get(value)
267
+ rescue
268
+ nil
269
+ end
270
+ unless key_signature&.tonic_spelling
271
+ raise ArgumentError, "#{path}: unknown key signature #{value.inspect}"
272
+ end
273
+ key_signature
274
+ end
275
+
276
+ def parsed_meter(value, path)
277
+ return nil if value.nil?
278
+
279
+ meter = begin
280
+ HeadMusic::Rudiment::Meter.get(value)
281
+ rescue
282
+ nil
283
+ end
284
+ unless meter&.top_number&.positive? && meter.bottom_number.positive?
285
+ raise ArgumentError, "#{path}: unknown meter #{value.inspect}"
286
+ end
287
+ meter
288
+ end
289
+
290
+ def parsed_rhythmic_value(value, path)
291
+ rhythmic_value = HeadMusic::Rudiment::RhythmicValue.get(value)
292
+ unless valid_rhythmic_value?(rhythmic_value)
293
+ raise ArgumentError, "#{path}: unknown rhythmic value #{value.inspect}"
294
+ end
295
+ rhythmic_value
296
+ end
297
+
298
+ # RhythmicValue.get returns a hollow object (nil unit) for garbage rather
299
+ # than nil, and a tied tail can be hollow while the head parses, so the
300
+ # whole tie chain is checked.
301
+ def valid_rhythmic_value?(rhythmic_value)
302
+ return false unless rhythmic_value.is_a?(HeadMusic::Rudiment::RhythmicValue)
303
+ return false unless rhythmic_value.unit
304
+
305
+ rhythmic_value.tied_value.nil? || valid_rhythmic_value?(rhythmic_value.tied_value)
306
+ end
307
+
308
+ # A nil pitch is a rest; a non-nil string that fails to parse would
309
+ # otherwise silently deserialize as a rest.
310
+ def parsed_pitch(value, path)
311
+ return nil if value.nil?
312
+
313
+ pitch = HeadMusic::Rudiment::Pitch.get(value)
314
+ raise ArgumentError, "#{path}: unknown pitch #{value.inspect}" unless pitch
315
+
316
+ pitch
317
+ end
318
+ end
97
319
  end
@@ -38,6 +38,14 @@ class HeadMusic::Content::Placement
38
38
  "#{rhythmic_value} #{pitch || "rest"} at #{position}"
39
39
  end
40
40
 
41
+ def to_h
42
+ {
43
+ "position" => position.to_s,
44
+ "rhythmic_value" => rhythmic_value.to_s,
45
+ "pitch" => pitch&.to_s
46
+ }
47
+ end
48
+
41
49
  private
42
50
 
43
51
  def starts_during?(other_placement)
@@ -140,15 +140,24 @@ class HeadMusic::Content::Voice
140
140
  [role, pitches_string].join(": ")
141
141
  end
142
142
 
143
+ def to_h
144
+ {
145
+ "role" => role&.to_s,
146
+ "placements" => placements.map(&:to_h)
147
+ }
148
+ end
149
+
143
150
  private
144
151
 
145
152
  def bar_start_position(placement)
146
153
  HeadMusic::Content::Position.new(composition, placement.position.bar_number, 1, 0)
147
154
  end
148
155
 
156
+ # Stable insertion: co-positioned placements (chords) keep their insertion order,
157
+ # which serialization round-trips depend on.
149
158
  def insert_into_placements(placement)
150
- @placements << placement
151
- @placements = @placements.sort
159
+ index = @placements.index { |existing| existing > placement } || @placements.length
160
+ @placements.insert(index, placement)
152
161
  end
153
162
 
154
163
  def pitches_string
@@ -20,7 +20,11 @@ class HeadMusic::Rudiment::RhythmicValue
20
20
  new(identifier)
21
21
  when Symbol, String
22
22
  original_identifier = identifier.to_s.strip
23
- # First try the new parser which handles all formats
23
+ # Handle tied values first, so "half tied to eighth" round-trips through #to_s
24
+ tied = from_tied_words(original_identifier)
25
+ return tied if tied
26
+
27
+ # Then try the new parser which handles all formats
24
28
  parsed = Parser.parse(original_identifier)
25
29
  return parsed if parsed
26
30
 
@@ -34,6 +38,19 @@ class HeadMusic::Rudiment::RhythmicValue
34
38
  end
35
39
  end
36
40
 
41
+ # Splits on the first " tied to " and recurses on the remainder,
42
+ # so chained ties ("half tied to eighth tied to sixteenth") nest naturally.
43
+ def self.from_tied_words(identifier)
44
+ head, tail = identifier.split(/\s+tied\s+to\s+/, 2)
45
+ return nil unless tail
46
+
47
+ head_value = get(head)
48
+ tied_value = get(tail)
49
+ return nil unless head_value && tied_value
50
+
51
+ new(head_value.unit, dots: head_value.dots, tied_value: tied_value)
52
+ end
53
+
37
54
  def self.from_words(identifier)
38
55
  new(unit_from_words(identifier), dots: dots_from_words(identifier))
39
56
  end
@@ -1,3 +1,3 @@
1
1
  module HeadMusic
2
- VERSION = "15.1.0"
2
+ VERSION = "15.2.0"
3
3
  end
data/lib/head_music.rb CHANGED
@@ -7,10 +7,12 @@ end
7
7
 
8
8
  require "head_music/version"
9
9
 
10
+ require "active_support/core_ext/hash/keys"
10
11
  require "active_support/core_ext/module/delegation"
11
12
  require "active_support/core_ext/string/access"
12
13
  require "active_support/core_ext/integer/inflections"
13
14
  require "humanize"
15
+ require "json"
14
16
  require "i18n"
15
17
  require "i18n/backend/fallbacks"
16
18
 
@@ -0,0 +1,335 @@
1
+ <!--
2
+ metadata:
3
+ created_at: 2026-07-16T12:00:00-07:00
4
+ activated_at: 2026-07-16T18:59:42-07:00
5
+ planned_at: 2026-07-16T19:25:49-07:00
6
+ finished_at: 2026-07-16T21:46:48-07:00
7
+ updated_at: 2026-07-16T21:46:48-07:00
8
+ -->
9
+
10
+ # Story: Composition Serialization (to_h / from_h)
11
+
12
+ ## Summary
13
+
14
+ AS a developer using HeadMusic
15
+
16
+ I WANT to serialize a `HeadMusic::Content::Composition` to a plain hash and reconstruct it losslessly
17
+
18
+ SO THAT downstream apps can persist compositions in a database and reload them with full fidelity
19
+
20
+ ## Background
21
+
22
+ HeadMusic can render a `Composition` outward to notation formats (`#to_abc`, `#to_musicxml`, `#to_s`) and read *inward* from ABC (`HeadMusic::Notation::ABC.parse`). What's missing is a canonical, round-trippable **serialization of the object itself** — a structured form that captures everything the model holds and rebuilds an equivalent object.
23
+
24
+ This is distinct from the notation-format work under the [Notation Module epic](../epics/notation-module.md). ABC, MusicXML, and LilyPond are engraving/interchange formats — each lossy relative to the in-memory object (tick-precise positions, voice roles, comments, and mid-piece changes don't all survive an ABC round trip). This story is about **persistence fidelity**, not notation: a JSON-able hash that is the source of truth from which notation formats can be regenerated.
25
+
26
+ **Driving consumer.** The bardtheory app is adding a Staff Notation View. It will store `Composition` records in its database — a JSON `definition` column as the source of truth, plus a cached MusicXML render — and reference them by slug in lesson content. It needs `#to_h` / `.from_h` to persist and reload compositions. This serialization is a hard prerequisite for that story; bardtheory pins `head_music` from RubyGems (currently `~> 15.1`), so a released version carrying these methods is the deliverable.
27
+
28
+ ## Example
29
+
30
+ ```ruby
31
+ composition = HeadMusic::Notation::ABC.parse(abc) # or built any other way
32
+ hash = composition.to_h # plain, JSON-safe Hash
33
+
34
+ restored = HeadMusic::Content::Composition.from_h(hash)
35
+ restored.to_h == composition.to_h # => true
36
+ restored.to_musicxml == composition.to_musicxml # => true
37
+ ```
38
+
39
+ ## Acceptance Criteria
40
+
41
+ - `HeadMusic::Content::Composition#to_h` returns a plain, JSON-serializable Hash — only `Hash`/`Array`/`String`/`Integer`/`Float`/`true`/`false`/`nil`; no symbols as values and no custom objects.
42
+ - `HeadMusic::Content::Composition.from_h(hash)` reconstructs an equivalent Composition using the existing public construction API (`new`, `add_voice`, `Voice#place`, `change_key_signature`, `change_meter`, `add_comment`) — not private state, where the public builders suffice.
43
+ - **Round-trip identity:** for any Composition `c`, `Composition.from_h(c.to_h).to_h == c.to_h`, and both `#to_musicxml` and `#to_abc` output match the original.
44
+ - **JSON-safety:** `Composition.from_h(JSON.parse(c.to_h.to_json))` round-trips unchanged (guards against non-JSON values leaking into the hash).
45
+ - The hash captures full model fidelity, at minimum:
46
+ - Composition attributes: `name`, `key_signature`, `meter`, `composer`, `origin`, `comments`.
47
+ - Voices with their `role` and ordered placements.
48
+ - Placements: `position` at full precision including tick offsets (`"1:1:480"`, not rounded to the beat grid), `rhythmic_value`, and `pitch` as a pitch-name string — with `nil` preserved as a rest.
49
+ - Chords (simultaneous pitches at a position).
50
+ - Mid-piece `change_key_signature(bar, ...)` and `change_meter(bar, ...)`.
51
+ - Comments (`add_comment(text, position = nil)`, and the constructor's `comments:` string-or-array form).
52
+ - Pitch spellings/accidentals round-trip faithfully (preserve the object's exact pitch string; don't normalize enharmonics).
53
+ - The hash carries a `schema_version` so the format can evolve.
54
+ - Specs cover: single-voice diatonic, accidentals, rests, chords, multi-voice with roles, tick-precise positions, a mid-piece key change, a mid-piece meter change, comments, and a round trip seeded from existing ABC fixtures (e.g. `ABCFixtures::SPEED_THE_PLOUGH`).
55
+
56
+ ## Notes
57
+
58
+ **Existing construction/serialization surface** (verified in the current codebase):
59
+
60
+ - Constructor: `Composition#initialize(name:, key_signature:, meter:, composer:, origin:, comments:)` (`lib/head_music/content/composition.rb:8`); `key_signature`/`meter` coerce from strings via `KeySignature.get` / `Meter.get`.
61
+ - Builders: `#add_voice(role:)` (`composition.rb:16`), `Voice#place(position, rhythmic_value, pitch = nil)` (`lib/head_music/content/voice.rb:19`), `#add_comment` (`composition.rb:21`), `#change_key_signature(bar_number, key_signature)` (`composition.rb:44`), `#change_meter(bar_number, meter)` (`composition.rb:48`).
62
+ - Existing serializers: `#to_abc` (`composition.rb:72`), `#to_musicxml` (`composition.rb:76`), `#to_s` (`composition.rb:68`). No `to_h`/`as_json`/`deconstruct` exists today; the only importer is ABC.
63
+
64
+ **Suggested hash shape** (illustrative — adjust to the real model):
65
+
66
+ ```ruby
67
+ {
68
+ "schema_version" => 1,
69
+ "name" => "…",
70
+ "key_signature" => "C major",
71
+ "meter" => "4/4",
72
+ "composer" => nil,
73
+ "origin" => nil,
74
+ "comments" => [{ "text" => "…", "position" => "1:1" }],
75
+ "key_changes" => [{ "bar" => 5, "key_signature" => "G major" }],
76
+ "meter_changes" => [{ "bar" => 9, "meter" => "3/4" }],
77
+ "voices" => [
78
+ {
79
+ "role" => "melody",
80
+ "placements" => [
81
+ { "position" => "1:1", "rhythmic_value" => "quarter", "pitch" => "C4" },
82
+ { "position" => "1:1:480", "rhythmic_value" => "eighth", "pitch" => "E4" },
83
+ { "position" => "1:2", "rhythmic_value" => "quarter", "pitch" => nil }
84
+ ]
85
+ }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ **Design preferences.**
91
+
92
+ - Prefer composing `#to_h` from smaller `#to_h` methods on `Voice`, `Placement`, etc. if that fits the codebase style, rather than one monolithic method.
93
+ - Round-trip via strings where constructors already coerce them (key signature, meter, pitch) to avoid instantiating value objects in the hash.
94
+ - No new runtime dependencies; pure Ruby / stdlib.
95
+
96
+ **Serialization layering — `to_h`/`from_h` is the primitive; JSON is thin sugar.**
97
+
98
+ The expensive work is Composition↔hash (walking voices/placements/positions/changes/comments and rebuilding via the builder API). Hash↔JSON-string is trivial (`JSON.generate`/`JSON.parse`), so a JSON API is not a lighter lift — it's the same work plus a string layer. Keep `to_h`/`from_h` as the source of truth and, if convenient, add `to_json`/`from_json` as one-line delegates:
99
+
100
+ ```ruby
101
+ def to_h; ...; end # the real primitive
102
+ def to_json(*args) = to_h.to_json(*args) # optional string/API-boundary sugar
103
+ def self.from_h(hash); ...; end # the real primitive
104
+ def self.from_json(str) = from_h(JSON.parse(str)) # optional one-liner
105
+ ```
106
+
107
+ Rationale for hash-first, not JSON-string-first:
108
+
109
+ - **Postgres json/jsonb wants the hash, not a string.** The driving consumer stores `definition` in a jsonb column; ActiveRecord serializes/deserializes a Ruby `Hash` to/from jsonb automatically (`record.definition = comp.to_h`; `Composition.from_h(record.definition)`). It never handles a JSON string. Returning a JSON string instead would force AR to re-parse it, or push the caller to a plain `text` column and forfeit jsonb indexing/query — strictly worse for this path.
110
+ - **Ruby convention.** Idiomatic `to_json` is built on top of `to_h`/`as_json`; there is no standard `from_json` (the convention is `JSON.parse` → build from hash). The delegates above follow that.
111
+ - **Keep the primitive named `to_h`, not `as_json`.** `as_json` is the ActiveSupport hook; `to_h` stays framework-neutral, which suits a plain gem, and jsonb columns consume it just as happily.
112
+
113
+ So: add `to_json`/`from_json` as optional conveniences for string/HTTP boundaries, but they carry no weight for the DB storage path — that path is pure hash.
114
+
115
+ **Open questions for planning.**
116
+
117
+ - Exact key names and nesting (e.g. inline `key_changes`/`meter_changes` vs. a per-bar structure).
118
+ - How chords are represented in the placement list (grouped array vs. repeated positions) — follow whatever the model already does internally.
119
+ - Whether `from_h` should tolerate/upgrade older `schema_version`s, or hard-fail on mismatch for v1.
120
+
121
+ ## Implementation Plan
122
+
123
+ ### Overview
124
+
125
+ Add `#to_h` methods to `Placement`, `Comment`, `Bar`, and `Voice`, composed by `Composition#to_h`, plus `Composition.from_h` that replays the hash through the existing public builder API in dependency order (key/meter changes → voices/placements → repeat flags → comments) — the same access path the ABC parser uses (`lib/head_music/notation/abc/parser.rb:250-265`). Three small prerequisite fixes make the round trip actually lossless: tied `RhythmicValue` string parsing (confirmed silent-loss bug), coercing `Bar` writers, and stable placement ordering. Ships as gem 15.2.0 for the bardtheory `~> 15.1` pin.
126
+
127
+ ### Finalized hash schema (schema_version 1)
128
+
129
+ All keys are strings; all values are JSON primitives. `voices`, `bars`, and `comments` are always present (possibly `[]`). Placement and comment hashes always include all their keys (`pitch`/`position` may be `null`); top-level `composer`/`origin` emit `null` explicitly — schema stability over compactness. `bars` is sparse: only bars with non-default state appear, each carrying `number` plus only the keys that are set.
130
+
131
+ ```json
132
+ {
133
+ "schema_version": 1,
134
+ "name": "Speed the Plough",
135
+ "key_signature": "G major",
136
+ "meter": "4/4",
137
+ "composer": null,
138
+ "origin": null,
139
+ "voices": [
140
+ {
141
+ "role": "melody",
142
+ "placements": [
143
+ {"position": "1:1:000", "rhythmic_value": "eighth", "pitch": "G4"},
144
+ {"position": "1:1:480", "rhythmic_value": "eighth", "pitch": null},
145
+ {"position": "3:1:000", "rhythmic_value": "quarter", "pitch": "C5"},
146
+ {"position": "3:1:000", "rhythmic_value": "quarter", "pitch": "E5"}
147
+ ]
148
+ }
149
+ ],
150
+ "bars": [
151
+ {"number": 1, "starts_repeat": true},
152
+ {"number": 5, "key_signature": "D major", "meter": "6/8"},
153
+ {"number": 8, "ends_repeat_after_num_plays": 2, "plays_on_passes": [1, 2]}
154
+ ],
155
+ "comments": [
156
+ {"text": "First strain", "position": "1:1:000"},
157
+ {"text": "Traditional", "position": null}
158
+ ]
159
+ }
160
+ ```
161
+
162
+ Value formats — each chosen because the corresponding constructor verifiably coerces the string back:
163
+
164
+ | Field | Format | Round-trip vehicle |
165
+ |---|---|---|
166
+ | `key_signature` (top-level and per-bar) | `KeySignature#name`, e.g. `"F♯ minor"` — **never** `#to_s`, which returns the unparseable `"3 flats"` | `KeySignature.get` (splits tonic + scale type, `key_signature.rb:14-22`); `#name` is always `"<tonic> <parent-scale-type>"` because construction normalizes to the parent type |
167
+ | `meter` | `Meter#to_s`, e.g. `"6/8"` | `Meter.get` (verified) |
168
+ | `position` | `Position#to_s`, e.g. `"1:1:480"` (tick zero-padded to 3) | `Position.new(composition, string)` (verified) |
169
+ | `rhythmic_value` | `RhythmicValue#to_s`, e.g. `"dotted quarter"`, `"half tied to eighth"` | `RhythmicValue.get` (after Step 1 fix) |
170
+ | `pitch` | `Pitch#to_s` with unicode accidentals (`"B♭3"`, `"F𝄪5"`); `null` = rest | `Pitch.get` (verified for unicode; note `"F##5"` returns nil — always emit `#to_s`) |
171
+ | `role` | `role&.to_s` or `null` | `add_voice(role:)`; `cantus_firmus?` already matches on `role.to_s`, so symbol→string is behavior-preserving |
172
+
173
+ ### Resolution of the story's open questions (with evidence)
174
+
175
+ 1. **Key names/nesting for changes:** a single sparse `"bars"` array, not separate `key_changes`/`meter_changes`. Evidence: `Bar` (`lib/head_music/content/bar.rb`) holds key signature, meter, *and* repeat structure (`starts_repeat`, `ends_repeat_after_num_plays`, `plays_on_passes`) together — and repeats, though absent from the story's fidelity list, are **forced into scope**: `ABC.parse(ABCFixtures::SPEED_THE_PLOUGH)` sets `starts_repeat` on bar 1 and `ends_repeat_after_num_plays = 2` on bar 8 (verified), so the required `to_abc`-equality criterion fails without them.
176
+ 2. **Chords:** the model's only chord representation is multiple co-positioned placements in one voice (the ABC lexer treats `[...]` chords as unsupported tokens, `body_lexer.rb:175-176`). No special schema structure needed — the ordered placement list with duplicate `position` values covers it. Caveat: `to_musicxml` currently raises `RenderError` on exactly this structure (verified), so the MusicXML-equality criterion is scoped to compositions MusicXML can render (see Risks).
177
+ 3. **schema_version policy:** hard-fail. `raise ArgumentError, "unsupported schema_version: #{version.inspect} (supported: 1)"` unless strictly `Integer` `1` (reject `"1"`; `.inspect` disambiguates). Unknown *keys* are ignored, so additive v1.x evolution stays possible. No migration machinery until a v2 exists.
178
+ 4. **Position string parsing:** exists and round-trips — `Position#to_s` emits `"bar:count:tick"` and the constructor parses it by splitting on `/\D+/` (`position.rb:12-19`). Two verified constraints shape `from_h`: (a) parsing rolls counts/ticks over via `composition.meter_at`, so **meter changes must be applied before any position string parses**; (b) negative bars don't survive (`"-1:1:000"` reparses as `[0,1,1]`) — bar 0 does — so `from_h` rejects bar numbers below 0. (Integer `{bar, count, tick}` components were considered for jsonb queryability; strings win on story alignment and compactness given (a) and (b) are handled.)
179
+ 5. **Comments:** `Comment` stores text plus an optional `Position` (accepts strings; raises on a foreign composition's Position). The constructor's `comments:` param is **text-only** (`composition.rb:13`), so `from_h` must use `add_comment(text, position)` for every comment; `to_h` always converges both input shapes to `[{"text" => ..., "position" => ... | null}]`.
180
+
181
+ ### Steps
182
+
183
+ 1. **Fix tied `RhythmicValue` string parsing (prerequisite — confirmed silent-loss bug)**
184
+ - `RhythmicValue.get("half tied to eighth")` currently drops the tie and returns plain `half`, while the ABC duration resolver produces tied values (`abc/duration_resolver.rb:67-73`) — any ABC-seeded composition with a cross-unit duration would serialize lossily and *pass* naive specs. In `.get`'s String branch, split on `" tied to "` and rebuild recursively (`new(head.unit, dots: head.dots, tied_value: tail)`).
185
+ - Spec: `get(rv.to_s) == rv` for single and chained ties.
186
+ - Files: `lib/head_music/rudiment/rhythmic_value.rb`, `spec/head_music/rudiment/rhythmic_value_spec.rb`
187
+
188
+ 2. **Coerce `Bar#key_signature=` / `#meter=` writers**
189
+ - They are plain `attr_accessor`s (`bar.rb:9`), so `change_meter(4, "6/8")` stores a raw String, which then breaks `Position` rollover (`meter.ticks_per_count`) and `Bar#to_h`. Replace with writers that call `KeySignature.get` / `Meter.get` when non-nil, mirroring `Bar#initialize` — backward compatible since objects pass through `.get` unchanged.
190
+ - Files: `lib/head_music/content/bar.rb`, `spec/head_music/content/bar_spec.rb`
191
+
192
+ 3. **Make `Voice` placement ordering stable**
193
+ - `insert_into_placements` re-sorts with `Placement#<=>` comparing position only; Ruby's sort is unstable, so chord-note order is officially nondeterministic — and the chord round-trip criterion makes order load-bearing. Replace the sort with stable insertion: `index = @placements.index { |existing| existing > placement } || @placements.length; @placements.insert(index, placement)`. Do **not** add a tie-breaker to `Placement#<=>` (position-only comparison is used semantically elsewhere).
194
+ - Files: `lib/head_music/content/voice.rb` (lines 149-152), `spec/head_music/content/voice_spec.rb` (co-positioned chord ordering example)
195
+
196
+ 4. **Harden `Composition#bars` for bar numbers below `earliest_bar_number`**
197
+ - Verified: `change_key_signature(0, ...)` on a composition without bar-0 placements raises `NoMethodError` on `nil`. One-line fix: iterate/slice from `[earliest_bar_number, last].min`.
198
+ - Files: `lib/head_music/content/composition.rb` (lines 36-42), `spec/head_music/content/composition_spec.rb`
199
+
200
+ 5. **Leaf `#to_h` methods**
201
+ - `Placement#to_h` → `{"position" => position.to_s, "rhythmic_value" => rhythmic_value.to_s, "pitch" => pitch&.to_s}`
202
+ - `Comment#to_h` → `{"text" => text, "position" => position&.to_s}`
203
+ - `Bar#to_h` → sparse hash without `number` (Bar doesn't know its own number): each of key_signature (via `#name`), meter, `starts_repeat`, `ends_repeat_after_num_plays`, `plays_on_passes` only when set; `{}` for a default bar
204
+ - `Voice#to_h` → `{"role" => role&.to_s, "placements" => placements.map(&:to_h)}`
205
+ - Files: `lib/head_music/content/placement.rb`, `comment.rb`, `bar.rb`, `voice.rb`; unit examples in the four existing spec files under `spec/head_music/content/`
206
+
207
+ 6. **`Composition#to_h` + `SCHEMA_VERSION = 1`**
208
+ - Assemble the schema; `key_signature.name`, `meter.to_s`. For bars, do **not** use the public `bars` method (it returns a slice, losing the number offset, and stops at `latest_bar_number`); use a private helper over the raw `@bars` array: `each_with_index.filter_map { |bar, number| ... {"number" => number}.merge(bar.to_h) unless bar.nil? || bar.to_h.empty? }`.
209
+ - Files: `lib/head_music/content/composition.rb`, `spec/head_music/content/composition_spec.rb`
210
+
211
+ 7. **`Composition.from_h` + `to_json`/`from_json` delegates**
212
+ - Entry: raise `ArgumentError` on non-Hash; `deep_transform_keys(&:to_s)` (ActiveSupport is already a runtime dependency) so symbol-keyed Ruby hashes also load; strict `schema_version` check per Resolution 3.
213
+ - Four phases, each load-bearing:
214
+ 1. `new(name:, key_signature:, meter:, composer:, origin:)` then `change_key_signature`/`change_meter` per bar entry — **before any position parses** (meter map drives count/tick rollover).
215
+ 2. `add_voice(role:)` + `voice.place(position, rhythmic_value, pitch)` in serialized order (stable insertion from Step 3 preserves chord order).
216
+ 3. Repeat flags via `composition.bars(number).last` + Bar's validating public setters — **after placements**, so a pickup-bar-0 flag finds its bar.
217
+ 4. `add_comment(text, position)` last (positions parse under the final meter map).
218
+ - **Boundary validation (verified failure modes):** `Pitch.get("garbage")` returns nil — a corrupted pitch would silently deserialize as a *rest*; `RhythmicValue.get("garbage")` returns a broken object that explodes later; `Meter.get("garbage")` raises a useless internal arity error. `from_h` must validate factory results and raise `ArgumentError` with path context (e.g. `voices[0].placements[3]: unknown pitch "H#4"`), and reject bar numbers `< 0`. `ArgumentError` matches the content module's existing convention (`bar.rb`, `comment.rb`).
219
+ - Delegates: `def to_json(*_args) = to_h.to_json`; `def self.from_json(json) = from_h(JSON.parse(json))`.
220
+ - Files: `lib/head_music/content/composition.rb`
221
+
222
+ 8. **Round-trip spec suite**
223
+ - New file: `spec/head_music/content/composition_serialization_spec.rb` with a shared helper `expect_lossless_round_trip(composition)` asserting `from_h(to_h).to_h == to_h` plus `to_abc` equality (and `to_musicxml` equality where renderable). Scenario matrix in Testing Strategy below.
224
+ - Files: `spec/head_music/content/composition_serialization_spec.rb`
225
+
226
+ 9. **Finish: lint, coverage, release prep**
227
+ - `bundle exec rubocop -a`, `bundle exec rake` (90% coverage gate). Bump to 15.2.0 (minor — bardtheory pins `~> 15.1`). CHANGELOG entry documenting the schema v1 hash as public API: once bardtheory persists it in jsonb, the schema is a compatibility surface independent of gem semver — additive optional keys are fine within version 1 (because `from_h` ignores unknown keys); any change to existing keys' shape or meaning requires a `schema_version` bump.
228
+ - Files: `lib/head_music/version.rb` (or equivalent), `CHANGELOG.md`
229
+
230
+ ### Design Considerations
231
+
232
+ (No UI surface — this is a pure-Ruby library story; the ui-ux and accessibility perspectives were deliberately skipped.)
233
+
234
+ - `#to_h` on each content class (alongside existing `#to_s`) matches the gem's value-object style; the Notation precedent of delegating to renderer classes doesn't apply — those translate to foreign formats, while `to_h` is the object's own intrinsic representation. Standard Ruby disables Metrics cops (confirmed), so no cop pressure forces an extracted serializer; extract a builder class from `from_h` only if it exceeds ~5 private helpers.
235
+ - Exporter limitations must never leak into the serialization contract: the hash is the source of truth; for compositions MusicXML rejects, `to_h`/`from_h` still round-trips losslessly and `from_h(c.to_h).to_musicxml` raises the same `RenderError`.
236
+ - `to_h` is pure aside from idempotently materializing the already-memoized `@bars` (which `meter_at` does anyway); `Hash#==` ignores key order, so specs compare hashes, never `to_json` strings.
237
+
238
+ ### Testing Strategy
239
+
240
+ | Acceptance criterion | Test |
241
+ |---|---|
242
+ | JSON-safe hash | Recursive walk asserting only Hash/Array/String/Integer/Float/booleans/nil; string keys only |
243
+ | Round-trip + `to_abc`/`to_musicxml` equality | `expect_lossless_round_trip` per scenario below |
244
+ | JSON safety | `from_h(JSON.parse(c.to_h.to_json)).to_h == c.to_h`; also covers the `to_json`/`from_json` delegates |
245
+ | Single-voice diatonic | `ABC.parse` of a simple tune |
246
+ | Accidentals / exact spellings | `ABCFixtures::CHROMATIC_AIR` (also covers composer + origin + minor key); one manual double-sharp placement to prove `𝄪` survives |
247
+ | Rests | `voice.place("1:2:000", :quarter)` → `"pitch" => nil` |
248
+ | Chords | Two placements at `"1:1:000"` in one voice; order preserved; `to_h` + `to_abc` only (MusicXML raises — documented) |
249
+ | Multi-voice with roles | Two `add_voice(role:)` voices, including two voices with the *same* role (voices are an ordered array, never a role-keyed map) |
250
+ | Tick-precise positions | Placement at `"1:1:480"`; exact string in hash |
251
+ | Mid-piece key change | `change_key_signature(5, "D major")` with a string arg (exercises Step 2 coercion) |
252
+ | Mid-piece meter change | `change_meter(3, "6/8")` **with a later placement whose count only parses correctly under 6/8** — locks in the phase-1-before-phase-2 ordering |
253
+ | Comments | With position, without position, and constructor `comments:` string/array forms converging |
254
+ | ABC fixture seed + repeats | `ABC.parse(ABCFixtures::SPEED_THE_PLOUGH)`: `from_h(to_h).to_abc == original.to_abc` (repeats at bars 1 and 8 are load-bearing) and `to_musicxml` equality |
255
+ | Tied durations | ABC note spanning e.g. 5 eighths round-trips (Step 1 fix) |
256
+ | schema_version | Present, `== 1`; `from_h` raises ArgumentError on missing / `2` / `"1"` |
257
+ | Malformed input | Non-Hash, unknown pitch string, unknown rhythmic value, negative bar number → `ArgumentError` with path context, never `NoMethodError` |
258
+ | Edge cases | Empty composition (`voices: []`, compare `to_h` only — `to_musicxml` raises "no voices" on both sides); voice with role but zero placements; `plays_on_passes: [1, 2]` volta bars; unknown top-level key ignored; `"name" => nil` vs `"name" => "Composition"` produce `to_h`-identical results (pinned as documented equivalence) |
259
+
260
+ Unit-level `#to_h` examples extend the existing mirrored specs: `composition_spec.rb`, `voice_spec.rb`, `placement_spec.rb`, `bar_spec.rb`, `comment_spec.rb`, `rhythmic_value_spec.rb`.
261
+
262
+ ### Risks & Open Questions
263
+
264
+ **Risks / edge cases**
265
+
266
+ - **Tied-duration bug is the stealthiest failure**: without Step 1, ABC-seeded hashes are silently lossy and round-trip specs that avoid tied durations still pass. It's first in the step order deliberately.
267
+ - **Chords vs `to_musicxml`**: the MusicXML-equality criterion is scoped to renderable compositions (same-voice chords raise `RenderError` today). Fixing MusicXML chord rendering is a separate story — resist coupling it here; bardtheory's cached-render path works today for what it renders, and this story blocks their release.
268
+ - **Phase ordering in `from_h` is load-bearing** (meters before placements; placements before repeat flags; comments last). The 6/8 meter-change spec pins it against regression.
269
+ - **Negative bar numbers** don't survive position strings (`"-1:1:000"` → bar 0) and would index `@bars` from the tail; v1 declares bar ≥ 0 as the supported domain and `from_h` rejects below that. Nothing in the ABC parser produces negatives today.
270
+ - **Silent factory nils**: `Pitch.get` returning nil turns corrupted pitches into rests; only the Step 7 boundary validation prevents wrong-music-no-error outcomes on drifted jsonb rows.
271
+ - **Schema-as-API discipline**: after bardtheory persists hashes, schema v1 is a public contract. `from_h` ignoring unknown keys is what keeps additive evolution possible; hold that line in review.
272
+
273
+ **Open questions — resolved by the user (2026-07-16)**
274
+
275
+ 1. **Unicode vs ASCII key/pitch strings**: **Unicode verbatim.** Serialize `KeySignature#name` (`"F♯ minor"`) and `Pitch#to_s` (`"B♭3"`) exactly as emitted; pin with a spec.
276
+ 2. **Repeats formally in scope**: **In scope.** Bar-level repeat structure (`starts_repeat`, `ends_repeat_after_num_plays`, `plays_on_passes`) serializes in the sparse `"bars"` array; the SPEED_THE_PLOUGH `to_abc` criterion stands as written.
277
+ 3. **`schema_version` strictness**: **Strict Integer.** `from_h` raises `ArgumentError` on `"1"`, `2`, or missing.
278
+
279
+ ## Review
280
+
281
+ **Date:** 2026-07-16 · **Commit reviewed:** 5b0fd0a · Full suite: 5640 examples, 0 failures, 99.73% line coverage.
282
+
283
+ ### Acceptance criteria
284
+
285
+ - ✅ **`#to_h` returns a plain JSON-serializable Hash** — `lib/head_music/content/composition.rb:91-103`; recursive-walk assertion in `composition_serialization_spec.rb:20-34`.
286
+ - ✅ **`.from_h` rebuilds via the public construction API** — `HashDeserializer` (`composition.rb:152-301`) replays `new` → `change_key_signature`/`change_meter` → `add_voice` + `Voice#place` → public `Bar` repeat setters → `add_comment`; no private state touched.
287
+ - ⚠️ **Round-trip identity incl. `to_musicxml`/`to_abc` equality** — *scoped-met*: `from_h(c.to_h).to_h == c.to_h` holds universally; renderer-output equality is asserted wherever the renderers can render. ABC comparison skipped for multi-voice, mid-piece changes, and chords; MusicXML for same-voice chords and empty compositions — pre-existing renderer limitations, not serialization loss. Compensating specs pin that the restored composition raises the identical `RenderError`. The literal "for any Composition" is unsatisfiable without separate renderer fixes.
288
+ - ✅ **JSON-safety round trip** — `from_h(JSON.parse(c.to_h.to_json)).to_h == c.to_h` plus `to_json`/`from_json` delegates (`composition_serialization_spec.rb:73-82`).
289
+ - ✅ **Full model fidelity** — attributes, voices with roles + ordered placements, tick-precise positions, tied rhythmic values, nil-pitch rests, chords as co-positioned placements, mid-piece key/meter changes and repeat/volta state in sparse `"bars"`, comments in both builder and constructor forms.
290
+ - ✅ **Pitch spellings round-trip faithfully** — enharmonics not normalized (`B♭4` vs `A♯4`); double sharp `F𝄪5` and `"F♯ minor"` verbatim unicode.
291
+ - ✅ **`schema_version` carried** — `SCHEMA_VERSION = 1`; strict-Integer validation raising `ArgumentError` on missing/`2`/`"1"`.
292
+ - ✅ **Spec coverage of all listed scenarios** — all ten present in `composition_serialization_spec.rb` (single-voice diatonic L85, accidentals L102 via `CHROMATIC_AIR`, rests L168, chords L190, multi-voice roles L216, tick positions L238, key change L259, meter change with phase-ordering pin L279, comments L314, `SPEED_THE_PLOUGH` round trip with repeats L349), plus tied durations, malformed input, and edge cases beyond the list.
293
+
294
+ **Deliverable note:** version bumped to 15.2.0 with CHANGELOG documenting schema v1 as a compatibility surface, but publishing to RubyGems is a manual step outside this branch.
295
+
296
+ ### Code review findings
297
+
298
+ **Critical:** none.
299
+
300
+ **Important:**
301
+
302
+ 1. ✅ **FIXED** — `composition.rb` — **invalid key signature slips through validation.** `KeySignature.get("Q major")` returns a hollow object (`tonic_spelling=nil`) rather than nil, passing the truthiness guard. Fixed: `parsed_key_signature` now requires `tonic_spelling`; corrupted-input specs added (top-level and per-bar).
303
+ 2. ✅ **FIXED** — `composition.rb` — **position strings never validated.** `Position.new` silently coerces garbage to `"0:1:000"`. Fixed: new `parsed_position` helper validates the `bar[:count[:tick]]` shape (non-negative parts) for placements and comments; specs added for garbage, negative-bar, and comment positions.
304
+
305
+ **Minor:**
306
+
307
+ 3. `composition.rb` — repeat flags accept any truthy value (string `"yes"` acts as `true`); harmless for real JSON (booleans survive), inconsistent with otherwise-strict validation. Accepted as-is.
308
+ 4. ✅ **FIXED** — corrupted-input spec block lacked invalid-key-signature and invalid-position cases; five specs added alongside findings 1–2.
309
+
310
+ **Verified non-issues:** `to_json(*_args)` nests correctly in `{a: composition}.to_json`; stable chord insertion correct; `from_tied_words` recursion terminates; `to_h` leaks no mutable state; pickup-bar reindexing consistent.
311
+
312
+ **Verdict:** solid, well-tested feature. Findings 1–2 (and the spec gap, finding 4) fixed post-review; full suite 5645 examples, 0 failures, 99.73% line coverage. Nothing blocks `finish` except the manual RubyGems release.
313
+
314
+ ## Learnings
315
+
316
+ **What went well**
317
+
318
+ - **Planning that verifies code claims pays off.** The planner ran live probes instead of trusting the story's assumptions and surfaced three real pre-existing bugs (tied `RhythmicValue.get` silently dropping ties, uncoerced `Bar` writers, unstable chord ordering) before implementation. Sequencing those as prerequisite steps meant the round-trip specs were trustworthy from the start — a naive plan would have shipped lossy serialization with passing tests.
319
+ - **Independent spec-writer as verifier.** A separate agent wrote the round-trip suite against the finished implementation, instructed to fix any bugs its specs exposed. It found zero — meaningful evidence rather than self-confirmation.
320
+ - **File-conflict-based wave planning** allowed safe parallelism (tied-value fix ∥ content-model fixes) with no conflicts between agents.
321
+ - **Resolving open questions before implementation** (unicode verbatim, repeats in scope, strict Integer schema_version) meant zero mid-implementation scope churn.
322
+
323
+ **What was surprising**
324
+
325
+ - **Hollow objects are the gem's dominant boundary failure mode.** `RhythmicValue.get("garbage")`, `KeySignature.get("Q major")`, and `Meter.get("0/4")` all return broken-but-truthy objects instead of nil/raising, while `Pitch.get` returns nil and `Position` silently coerces garbage to `"0:1:000"`. Every factory needed a bespoke validity check; code review caught the two that slipped (key signature, position).
326
+ - **Repeats were forced into scope by an acceptance criterion**, not by the fidelity list — the `SPEED_THE_PLOUGH` `to_abc` equality was unimplementable without serializing repeat structure. Criteria examples can carry hidden scope.
327
+ - **The renderers are lossier than expected**: ABC raises on multi-voice, mid-piece changes, and chords; MusicXML raises on same-voice chords. The "for any Composition" renderer-equality criterion was unsatisfiable as written and had to be scoped to renderable material.
328
+
329
+ **What to do differently next time**
330
+
331
+ - When planning a `from_h`-style boundary, enumerate every factory's garbage behavior up front (a quick probe table) instead of discovering hollow-object cases one review at a time.
332
+ - Write renderer-equality acceptance criteria as "where the renderer supports the material" — or spike renderer limits before pinning criteria to fixtures.
333
+ - Follow-up stories worth filing: MusicXML same-voice chord rendering; the bar-1 change visibility off-by-one in `last_meter_change`/`last_key_signature_change`; ASCII key/pitch aliases for jsonb querying if bardtheory wants them.
334
+
335
+ **Reminder:** the RubyGems release of 15.2.0 is the actual deliverable for bardtheory — this branch only stages it.
@@ -262,6 +262,10 @@
262
262
  "title": "LilyPond Interpreter",
263
263
  "path": "backlog/lilypond-interpreter.md"
264
264
  },
265
+ {
266
+ "title": "Organizing Content",
267
+ "path": "backlog/organizing-content.md"
268
+ },
265
269
  {
266
270
  "title": "Sixteenth-Century (Renaissance) Style Guides",
267
271
  "path": "backlog/sixteenth-century-style.md"
@@ -269,18 +273,13 @@
269
273
  {
270
274
  "title": "Split Counterpoint Species Guides by Pedagogical Author",
271
275
  "path": "backlog/split-counterpoint-species-by-author.md"
272
- },
273
- {
274
- "title": "Organizing Content",
275
- "path": "backlog/organizing-content.md"
276
276
  }
277
277
  ],
278
- "active": [
279
- ],
278
+ "active": [],
280
279
  "done": [
281
280
  {
282
- "title": "MusicXML Export",
283
- "path": "current/music-xml-export.md"
281
+ "title": "Composition Serialization (to_h / from_h)",
282
+ "path": "done/composition-serialization.md"
284
283
  },
285
284
  {
286
285
  "title": "ABC Notation Export",
@@ -322,6 +321,10 @@
322
321
  "title": "Dyad Analysis",
323
322
  "path": "done/dyad-analysis.md"
324
323
  },
324
+ {
325
+ "title": "expand-playing-techniques",
326
+ "path": "done/expand-playing-techniques.md"
327
+ },
325
328
  {
326
329
  "title": "Handling Time and Tempo",
327
330
  "path": "done/handle-time.md"
@@ -330,6 +333,10 @@
330
333
  "title": "Instrument Inheritance Architecture",
331
334
  "path": "done/instrument-architecture.md"
332
335
  },
336
+ {
337
+ "title": "instrument-variant",
338
+ "path": "done/instrument-variant.md"
339
+ },
333
340
  {
334
341
  "title": "Instruments can have strings with pitches",
335
342
  "path": "done/string-pitches.md"
@@ -346,6 +353,10 @@
346
353
  "title": "Move StaffPosition to Notation Module",
347
354
  "path": "done/move-staff-position-to-notation.md"
348
355
  },
356
+ {
357
+ "title": "MusicXML Export",
358
+ "path": "done/music-xml-export.md"
359
+ },
349
360
  {
350
361
  "title": "Notation Module Foundation",
351
362
  "path": "done/notation-module-foundation.md"
@@ -370,14 +381,6 @@
370
381
  "title": "Sonority Identification",
371
382
  "path": "done/sonority-identification.md"
372
383
  },
373
- {
374
- "title": "expand-playing-techniques",
375
- "path": "done/expand-playing-techniques.md"
376
- },
377
- {
378
- "title": "instrument-variant",
379
- "path": "done/instrument-variant.md"
380
- },
381
384
  {
382
385
  "title": "superclass-for-note",
383
386
  "path": "done/superclass-for-note.md"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: head_music
3
3
  version: !ruby/object:Gem::Version
4
- version: 15.1.0
4
+ version: 15.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rob Head
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-07 00:00:00.000000000 Z
11
+ date: 2026-07-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -421,6 +421,7 @@ files:
421
421
  - user-stories/backlog/split-counterpoint-species-by-author.md
422
422
  - user-stories/done/abc-notation-export.md
423
423
  - user-stories/done/abc-notation-interpreter.md
424
+ - user-stories/done/composition-serialization.md
424
425
  - user-stories/done/configurable-large-leap-recovery.md
425
426
  - user-stories/done/consonance-dissonance-classification.md
426
427
  - user-stories/done/dyad-analysis.md