head_music 14.0.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +1 -1
- data/lib/head_music/style/analysis.rb +19 -4
- data/lib/head_music/style/annotation.rb +42 -1
- data/lib/head_music/style/guidelines/contoured.rb +9 -3
- data/lib/head_music/style/guidelines/diatonic.rb +8 -0
- data/lib/head_music/style/guidelines/maximum_notes.rb +10 -2
- data/lib/head_music/style/guidelines/minimum_melodic_intervals.rb +55 -0
- data/lib/head_music/style/guidelines/minimum_notes.rb +6 -2
- data/lib/head_music/style/guides/arch_contour_melody.rb +1 -4
- data/lib/head_music/style/guides/ascending_contour_melody.rb +1 -4
- data/lib/head_music/style/guides/descending_contour_melody.rb +1 -4
- data/lib/head_music/style/guides/diatonic_melody.rb +24 -0
- data/lib/head_music/style/guides/static_contour_melody.rb +2 -4
- data/lib/head_music/style/guides/valley_contour_melody.rb +1 -4
- data/lib/head_music/style/guides/wave_contour_melody.rb +1 -4
- data/lib/head_music/version.rb +1 -1
- data/lib/head_music.rb +1 -0
- data/user-stories/done/scoring-architecture.md +352 -0
- data/user-stories/index.html +17 -13
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c29fa7f976d16091512b8bdc9eb8befcb4766c4bb70d387a67991e9b9897a5c
|
|
4
|
+
data.tar.gz: b43c0fed22c3ed7c79a7a0195847740ba7b3bea608a24921893a329f0c66ba7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 47d395cc1709167182e0810c7383120395e927629a2e5e5076e197bdacfd377445546a842a032fecabd9865f62e878f969fec301535758072b45db229c80e8e4
|
|
7
|
+
data.tar.gz: '0950f0d5a6a2b830a1733d8d0a7d19eb9fcb87815e25d3e562dc6aa94bc95c16a2d7710ca31890e23d1dcc07fc64247030565741d2edf38da6cb177f7e091fb9'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [15.0.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `HeadMusic::Style::Guidelines::MinimumMelodicIntervals` — a sufficiency gate on the number of moving melodic intervals, so a line that never (or barely) moves reads as a non-attempt rather than a flawed melody (`MinimumMelodicIntervals.with(2)`). The contour guides use it; `StaticContourMelody` omits it so a repeated single pitch remains a legitimate static contour.
|
|
15
|
+
- `weight:` and `gate:` options on `Annotation.with` — any ruleset entry can now carry a rubric weight or be marked as a gate, and `Configured#with` layers options so presets compose (e.g. `MinimumNotes.with(5).with(gate: true)`)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Breaking:** `Analysis#fitness` is now a gated weighted rubric instead of an unweighted geometric mean: the product of the gate fitnesses multiplies a weighted arithmetic mean of the remaining (rubric) rules. Every fitness value shifts numerically; downstream consumers that compare grades against stored thresholds must recalibrate.
|
|
20
|
+
- Non-attempts now grade zero: sufficiency guidelines (`MinimumNotes`, `MinimumMelodicIntervals`) act as graded gate multipliers, so an empty or insufficient line scales the whole grade down to 0 instead of averaging against the other rules.
|
|
21
|
+
- Contour guides weight `Contoured` at the inverse golden ratio (φ⁻¹ ≈ 0.618) with their ten rubric peers sharing φ⁻² evenly, so a wrong-contour but otherwise perfect line grades exactly ~0.618 (`HeadMusic::GOLDEN_RATIO_INVERSE`)
|
|
22
|
+
- `Diatonic` and `MaximumNotes` are rate-normalized (fitness raised to 1/note-count), so grades are length-invariant: the same violation rate scores the same in an eight-note line as in a sixteen-note line
|
|
23
|
+
- Broken-but-real work now lands on a deliberate soft floor (roughly 0.3–0.55): rate-normalized rules bottom out near φ⁻¹ and the arithmetic mean averages them, so a gate-passing melody that breaks most of the rubric grades substantially below perfect without collapsing toward the gated zero of a non-attempt
|
|
24
|
+
|
|
10
25
|
## [14.0.0] - 2026-07-05
|
|
11
26
|
|
|
12
27
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -19,19 +19,34 @@ class HeadMusic::Style::Analysis
|
|
|
19
19
|
@annotations ||= @guide.analyze(voice)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# The grade: sufficiency gates multiply against a weighted average of the
|
|
23
|
+
# rubric rules, so an insufficient exercise scales the whole grade down
|
|
24
|
+
# while ordinary rules trade off against each other by weight.
|
|
22
25
|
def fitness
|
|
23
26
|
return 1.0 if annotations.empty?
|
|
24
27
|
|
|
25
|
-
@fitness ||=
|
|
28
|
+
@fitness ||= gate_factor * rubric_fitness
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def adherent?
|
|
29
|
-
|
|
32
|
+
annotations.all?(&:adherent?)
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
private
|
|
33
36
|
|
|
34
|
-
def
|
|
35
|
-
|
|
37
|
+
def gate_factor
|
|
38
|
+
gates.map(&:fitness).reduce(1, :*)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def rubric_fitness
|
|
42
|
+
rubric = annotations.reject(&:gate?)
|
|
43
|
+
total_weight = rubric.sum(&:weight)
|
|
44
|
+
return 1.0 if rubric.empty? || total_weight.zero?
|
|
45
|
+
|
|
46
|
+
rubric.sum { |annotation| annotation.weight * annotation.fitness } / total_weight
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def gates
|
|
50
|
+
annotations.select(&:gate?)
|
|
36
51
|
end
|
|
37
52
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
class HeadMusic::Style::Annotation
|
|
3
3
|
MESSAGE = "Write music."
|
|
4
4
|
|
|
5
|
+
DEFAULT_WEIGHT = 1.0
|
|
6
|
+
|
|
5
7
|
attr_reader :voice
|
|
6
8
|
|
|
7
9
|
delegate(
|
|
@@ -34,6 +36,14 @@ class HeadMusic::Style::Annotation
|
|
|
34
36
|
Configured.new(self, options)
|
|
35
37
|
end
|
|
36
38
|
|
|
39
|
+
def self.default_weight
|
|
40
|
+
DEFAULT_WEIGHT
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.default_gate?
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
37
47
|
# A RULESET entry pairing a guideline class with configuration. Quacks like a
|
|
38
48
|
# class to the analyze loop by responding to #new(voice).
|
|
39
49
|
class Configured
|
|
@@ -48,6 +58,19 @@ class HeadMusic::Style::Annotation
|
|
|
48
58
|
guideline_class.new(voice, **options)
|
|
49
59
|
end
|
|
50
60
|
|
|
61
|
+
# Layers additional options onto an already-configured entry, e.g.
|
|
62
|
+
# MinimumNotes.with(5).with(gate: true), without dropping prior options.
|
|
63
|
+
def with(**more)
|
|
64
|
+
Configured.new(guideline_class, options.merge(more))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Mirrors the class-level predicate so build-time RULESET filters can
|
|
68
|
+
# classify any entry (bare class or configured) uniformly. A per-entry
|
|
69
|
+
# gate: option takes precedence over the guideline class's default.
|
|
70
|
+
def default_gate?
|
|
71
|
+
options.fetch(:gate, guideline_class.default_gate?)
|
|
72
|
+
end
|
|
73
|
+
|
|
51
74
|
def name
|
|
52
75
|
guideline_class.name
|
|
53
76
|
end
|
|
@@ -56,13 +79,24 @@ class HeadMusic::Style::Annotation
|
|
|
56
79
|
end
|
|
57
80
|
|
|
58
81
|
def fitness
|
|
59
|
-
[marks].flatten.compact.map(&:fitness)
|
|
82
|
+
mark_fitnesses = [marks].flatten.compact.map(&:fitness)
|
|
83
|
+
return 1.0 if mark_fitnesses.empty?
|
|
84
|
+
|
|
85
|
+
mark_fitnesses.reduce(1, :*)**(1.0 / [fitness_denominator, 1].max)
|
|
60
86
|
end
|
|
61
87
|
|
|
62
88
|
def adherent?
|
|
63
89
|
fitness == 1
|
|
64
90
|
end
|
|
65
91
|
|
|
92
|
+
def weight
|
|
93
|
+
options.fetch(:weight, self.class.default_weight)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def gate?
|
|
97
|
+
options.fetch(:gate, self.class.default_gate?)
|
|
98
|
+
end
|
|
99
|
+
|
|
66
100
|
def has_notes?
|
|
67
101
|
!!first_note
|
|
68
102
|
end
|
|
@@ -91,6 +125,13 @@ class HeadMusic::Style::Annotation
|
|
|
91
125
|
|
|
92
126
|
attr_reader :options
|
|
93
127
|
|
|
128
|
+
# Normalization rate for the product of mark fitnesses. Subclasses override
|
|
129
|
+
# (e.g. with an opportunity count) to score by violation rate rather than
|
|
130
|
+
# raw violation count. The default of 1 preserves the raw product.
|
|
131
|
+
def fitness_denominator
|
|
132
|
+
1
|
|
133
|
+
end
|
|
134
|
+
|
|
94
135
|
def voices
|
|
95
136
|
@voices ||= voice.composition.voices
|
|
96
137
|
end
|
|
@@ -8,19 +8,25 @@ class HeadMusic::Style::Guidelines::Contoured < HeadMusic::Style::Annotation
|
|
|
8
8
|
|
|
9
9
|
TREND_REVERSAL_SEMITONES = 3 # a trend reversal must exceed a whole step
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
DEFAULT_WEIGHT = HeadMusic::GOLDEN_RATIO_INVERSE
|
|
12
|
+
|
|
13
|
+
def self.with(contour_key, **options)
|
|
12
14
|
contour = contour_key.to_s.downcase.to_sym
|
|
13
15
|
unless CONTOURS.include?(contour)
|
|
14
16
|
raise ArgumentError, "Contour must be one of: #{CONTOURS.join(", ")} (got #{contour_key.inspect})"
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
super(contour: contour)
|
|
19
|
+
super(contour: contour, **options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.default_weight
|
|
23
|
+
DEFAULT_WEIGHT
|
|
18
24
|
end
|
|
19
25
|
|
|
20
26
|
def marks
|
|
21
27
|
return if notes.empty? || matches_contour?
|
|
22
28
|
|
|
23
|
-
HeadMusic::Style::Mark.for_all(notes)
|
|
29
|
+
HeadMusic::Style::Mark.for_all(notes, fitness: HeadMusic::GOLDEN_RATIO_INVERSE**2)
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def message
|
|
@@ -9,6 +9,14 @@ class HeadMusic::Style::Guidelines::Diatonic < HeadMusic::Style::Annotation
|
|
|
9
9
|
HeadMusic::Style::Mark.for_each(notes_not_in_key_excluding_penultimate_leading_tone)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
# Score by the rate of out-of-key notes rather than the raw count,
|
|
15
|
+
# so fitness is invariant to melody length.
|
|
16
|
+
def fitness_denominator
|
|
17
|
+
notes.length
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
private
|
|
13
21
|
|
|
14
22
|
def notes_not_in_key_excluding_penultimate_leading_tone
|
|
@@ -4,8 +4,8 @@ module HeadMusic::Style::Guidelines; end
|
|
|
4
4
|
# Flags a melody with more than the allowed number of notes.
|
|
5
5
|
# Configure the threshold with the factory, e.g. MaximumNotes.with(14).
|
|
6
6
|
class HeadMusic::Style::Guidelines::MaximumNotes < HeadMusic::Style::Annotation
|
|
7
|
-
def self.with(maximum)
|
|
8
|
-
super(maximum: maximum)
|
|
7
|
+
def self.with(maximum, **options)
|
|
8
|
+
super(maximum: maximum, **options)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def marks
|
|
@@ -16,6 +16,14 @@ class HeadMusic::Style::Guidelines::MaximumNotes < HeadMusic::Style::Annotation
|
|
|
16
16
|
"Write up to #{maximum.humanize} notes."
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
# Score by the rate of overage notes rather than the raw count,
|
|
22
|
+
# so fitness is invariant to melody length.
|
|
23
|
+
def fitness_denominator
|
|
24
|
+
notes.length
|
|
25
|
+
end
|
|
26
|
+
|
|
19
27
|
private
|
|
20
28
|
|
|
21
29
|
def maximum
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Module for style guidelines.
|
|
2
|
+
module HeadMusic::Style::Guidelines; end
|
|
3
|
+
|
|
4
|
+
# Flags a melody with fewer than the required number of moving melodic intervals.
|
|
5
|
+
# Repeated-note pairs don't count as motion, so an all-repeated-note line gates to 0.
|
|
6
|
+
# Configure the threshold with the factory, e.g. MinimumMelodicIntervals.with(2).
|
|
7
|
+
class HeadMusic::Style::Guidelines::MinimumMelodicIntervals < HeadMusic::Style::Annotation
|
|
8
|
+
def self.with(minimum, **options)
|
|
9
|
+
super(minimum: minimum, **options)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.default_gate?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def marks
|
|
17
|
+
return no_motion_mark if moving_intervals.empty?
|
|
18
|
+
|
|
19
|
+
deficiency_mark
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def message
|
|
23
|
+
"Write at least #{minimum.humanize} melodic intervals."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def minimum
|
|
29
|
+
options.fetch(:minimum)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def moving_intervals
|
|
33
|
+
melodic_intervals.select(&:moving?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def no_motion_mark
|
|
37
|
+
return no_placements_mark if placements.empty?
|
|
38
|
+
|
|
39
|
+
HeadMusic::Style::Mark.for_all(placements, fitness: 0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def no_placements_mark
|
|
43
|
+
HeadMusic::Style::Mark.new(
|
|
44
|
+
HeadMusic::Content::Position.new(composition, "1:1"),
|
|
45
|
+
HeadMusic::Content::Position.new(composition, "2:1"),
|
|
46
|
+
fitness: 0
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deficiency_mark
|
|
51
|
+
return unless moving_intervals.length < minimum
|
|
52
|
+
|
|
53
|
+
HeadMusic::Style::Mark.for_all(placements, fitness: moving_intervals.length.to_f / minimum)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -4,8 +4,12 @@ module HeadMusic::Style::Guidelines; end
|
|
|
4
4
|
# Flags a melody with fewer than the required number of notes.
|
|
5
5
|
# Configure the threshold with the factory, e.g. MinimumNotes.with(8).
|
|
6
6
|
class HeadMusic::Style::Guidelines::MinimumNotes < HeadMusic::Style::Annotation
|
|
7
|
-
def self.with(minimum)
|
|
8
|
-
super(minimum: minimum)
|
|
7
|
+
def self.with(minimum, **options)
|
|
8
|
+
super(minimum: minimum, **options)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.default_gate?
|
|
12
|
+
true
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def marks
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
# A free diatonic melody with an arch contour (interior climax).
|
|
2
2
|
class HeadMusic::Style::Guides::ArchContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
-
RULESET =
|
|
4
|
-
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
-
HeadMusic::Style::Guidelines::Contoured.with(:arch)
|
|
6
|
-
].freeze
|
|
3
|
+
RULESET = contour_ruleset(:arch, minimum_melodic_intervals: 2)
|
|
7
4
|
end
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
# A free diatonic melody with an ascending contour (departs its floor, arrives at its ceiling).
|
|
2
2
|
class HeadMusic::Style::Guides::AscendingContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
-
RULESET =
|
|
4
|
-
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
-
HeadMusic::Style::Guidelines::Contoured.with(:ascending)
|
|
6
|
-
].freeze
|
|
3
|
+
RULESET = contour_ruleset(:ascending, minimum_melodic_intervals: 1)
|
|
7
4
|
end
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
# A free diatonic melody with a descending contour (departs its ceiling, arrives at its floor).
|
|
2
2
|
class HeadMusic::Style::Guides::DescendingContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
-
RULESET =
|
|
4
|
-
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
-
HeadMusic::Style::Guidelines::Contoured.with(:descending)
|
|
6
|
-
].freeze
|
|
3
|
+
RULESET = contour_ruleset(:descending, minimum_melodic_intervals: 1)
|
|
7
4
|
end
|
|
@@ -18,4 +18,28 @@ class HeadMusic::Style::Guides::DiatonicMelody < HeadMusic::Style::Guides::Speci
|
|
|
18
18
|
HeadMusic::Style::Guidelines::MinimumNotes.with(5),
|
|
19
19
|
HeadMusic::Style::Guidelines::MaximumNotes.with(32)
|
|
20
20
|
].freeze
|
|
21
|
+
|
|
22
|
+
# The non-gate peers of a contour guide share phi^-2 of rubric weight, so
|
|
23
|
+
# that with Contoured at its default weight of phi^-1 (and phi^-1 + phi^-2
|
|
24
|
+
# = 1), a wrong contour on an otherwise perfect line grades exactly phi^-1.
|
|
25
|
+
CONTOUR_PEER_WEIGHT_BUDGET = HeadMusic::GOLDEN_RATIO_INVERSE**2
|
|
26
|
+
|
|
27
|
+
# Builds a contour guide's ruleset from this guide's rules: gate entries
|
|
28
|
+
# pass through unchanged, non-gate peers split the peer weight budget
|
|
29
|
+
# evenly, and the contour guideline is appended at its own default weight.
|
|
30
|
+
# An optional motion gate excludes non-attempts (nil omits it, so a static
|
|
31
|
+
# contour can legitimately repeat a single pitch).
|
|
32
|
+
def self.contour_ruleset(contour_key, minimum_melodic_intervals: nil)
|
|
33
|
+
gates, peers = RULESET.partition(&:default_gate?)
|
|
34
|
+
peer_weight = CONTOUR_PEER_WEIGHT_BUDGET / peers.length
|
|
35
|
+
motion_gate =
|
|
36
|
+
minimum_melodic_intervals &&
|
|
37
|
+
HeadMusic::Style::Guidelines::MinimumMelodicIntervals.with(minimum_melodic_intervals)
|
|
38
|
+
[
|
|
39
|
+
*gates,
|
|
40
|
+
motion_gate,
|
|
41
|
+
*peers.map { |rule| rule.with(weight: peer_weight) },
|
|
42
|
+
HeadMusic::Style::Guidelines::Contoured.with(contour_key)
|
|
43
|
+
].compact.freeze
|
|
44
|
+
end
|
|
21
45
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# A free diatonic melody with a static contour (narrow range, non-directional endpoints).
|
|
2
|
+
# No motion gate: an all-repeated-note line is a legitimate static contour.
|
|
2
3
|
class HeadMusic::Style::Guides::StaticContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
-
RULESET =
|
|
4
|
-
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
-
HeadMusic::Style::Guidelines::Contoured.with(:static)
|
|
6
|
-
].freeze
|
|
4
|
+
RULESET = contour_ruleset(:static)
|
|
7
5
|
end
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
# A free diatonic melody with a valley contour (interior nadir).
|
|
2
2
|
class HeadMusic::Style::Guides::ValleyContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
-
RULESET =
|
|
4
|
-
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
-
HeadMusic::Style::Guidelines::Contoured.with(:valley)
|
|
6
|
-
].freeze
|
|
3
|
+
RULESET = contour_ruleset(:valley, minimum_melodic_intervals: 2)
|
|
7
4
|
end
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
# A free diatonic melody with a wave contour (repeated undulation at the trend level).
|
|
2
2
|
class HeadMusic::Style::Guides::WaveContourMelody < HeadMusic::Style::Guides::DiatonicMelody
|
|
3
|
-
RULESET =
|
|
4
|
-
*HeadMusic::Style::Guides::DiatonicMelody::RULESET,
|
|
5
|
-
HeadMusic::Style::Guidelines::Contoured.with(:wave)
|
|
6
|
-
].freeze
|
|
3
|
+
RULESET = contour_ruleset(:wave, minimum_melodic_intervals: 2)
|
|
7
4
|
end
|
data/lib/head_music/version.rb
CHANGED
data/lib/head_music.rb
CHANGED
|
@@ -154,6 +154,7 @@ require "head_music/style/mark"
|
|
|
154
154
|
# style guidelines
|
|
155
155
|
require "head_music/style/guidelines/always_move"
|
|
156
156
|
require "head_music/style/guidelines/approach_perfection_contrarily"
|
|
157
|
+
require "head_music/style/guidelines/minimum_melodic_intervals"
|
|
157
158
|
require "head_music/style/guidelines/minimum_notes"
|
|
158
159
|
require "head_music/style/guidelines/avoid_crossing_voices"
|
|
159
160
|
require "head_music/style/guidelines/avoid_overlapping_voices"
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
metadata:
|
|
3
|
+
created_at: 2026-07-06T09:06:45-07:00
|
|
4
|
+
activated_at: 2026-07-06T09:14:45-07:00
|
|
5
|
+
planned_at: 2026-07-06T09:35:50-07:00
|
|
6
|
+
finished_at: 2026-07-06T12:20:54-07:00
|
|
7
|
+
updated_at: 2026-07-06T12:20:54-07:00
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# Story: Rework Style Analysis Scoring as a Weighted Rubric
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
AS a curriculum author scoring student exercises (in the sibling `bardtheory` project)
|
|
15
|
+
|
|
16
|
+
I WANT the `Style::Analysis` fitness score to behave like a graded rubric — where the rules that define an exercise dominate the grade, and the score does not swing with the length of the line
|
|
17
|
+
|
|
18
|
+
SO THAT an otherwise-perfect melody that ignores the assigned brief (e.g. the wrong contour for a contour guide) earns a low grade, and two proportionally-equal submissions of different lengths earn the same grade
|
|
19
|
+
|
|
20
|
+
## Background
|
|
21
|
+
|
|
22
|
+
The scalar returned by `HeadMusic::Style::Analysis#fitness` is being adopted by the `bardtheory` curriculum as the **grade on a learning exercise**. That semantic — a grade, not an aesthetic judgment — is what drives every decision below.
|
|
23
|
+
|
|
24
|
+
Two problems surfaced in the melody contour guides, and they turn out to be **orthogonal**:
|
|
25
|
+
|
|
26
|
+
1. **Single-rule failures are diluted.** A melody that satisfies every rule except the assigned contour scores ~96%. Measured empirically on a 5-note descending line analyzed under `ArchContourMelody`: overall fitness `0.9607`, with the only failing annotation being `Contoured` at `0.618`.
|
|
27
|
+
|
|
28
|
+
2. **Scores are volatile with line length.** Rules that emit one penalty mark per offending note compound multiplicatively, so a proportionally-equal violation costs far more on a long line than a short one.
|
|
29
|
+
|
|
30
|
+
### Why #1 happens
|
|
31
|
+
|
|
32
|
+
`Analysis#fitness` is an **unweighted geometric mean** over every rule in the guide's `RULESET`:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# lib/head_music/style/analysis.rb:25
|
|
36
|
+
fitness_scores.reduce(:*)**(1.0 / fitness_scores.length)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`ArchContourMelody` has 12 rules. One failing rule at the golden penalty (`PENALTY_FACTOR = GOLDEN_RATIO_INVERSE ≈ 0.618`) is diluted by the `1/12` exponent:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
0.618 ^ (1/12) = 0.9607
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The contour is not ignored — it is one binary rule competing with eleven others, and the geometric mean's exponent compresses any single failure toward 1.0.
|
|
46
|
+
|
|
47
|
+
A related, arguably worse defect of the same exponent: **a rule's failure is diluted by however many other rules happen to sit in the guide.** Wrong contour scores `0.618^(1/12) = 0.96` in a 12-rule guide but `0.618^(1/8) = 0.94` in an 8-rule guide. Adding an unrelated rule to a guide makes every existing rule more forgiving.
|
|
48
|
+
|
|
49
|
+
### Why #2 happens
|
|
50
|
+
|
|
51
|
+
An annotation's fitness is the **product** of its marks' fitnesses (`annotation.rb:59`), and guidelines built with `Mark.for_each` emit one mark per offending note. A rule broken on 2 notes scores `0.618² = 0.38`; the same 20%-violation rate on a 30-note line breaks ~6 notes and scores `0.618⁶ = 0.056`. Same proportional badness, wildly different score, purely because the line is longer. Note that `Contoured` itself is length-invariant (it emits a single `Mark.for_all`), which confirms the two problems are independent.
|
|
52
|
+
|
|
53
|
+
## Target behavior
|
|
54
|
+
|
|
55
|
+
- An otherwise-perfect melody with the **wrong contour** for its guide should grade **under 70%** — ideally **exactly 61.8%** (`GOLDEN_RATIO_INVERSE`).
|
|
56
|
+
- Two submissions with the **same proportional adherence** but different lengths should grade the **same** (within the tolerance of genuinely length-dependent musical facts, e.g. contour minimum-length guards).
|
|
57
|
+
|
|
58
|
+
## Design decisions (agreed)
|
|
59
|
+
|
|
60
|
+
These were settled during design discussion; they are the spec, not open questions.
|
|
61
|
+
|
|
62
|
+
- **Aggregator: weighted _arithmetic_ mean at the top level**, replacing the geometric mean. Weights are rubric point values. Rationale: the score is a *grade*, and grades are additive point rubrics. A weighted arithmetic mean expresses "contour is worth N points of the exercise" directly and legibly, and — critically for a teaching tool — produces a grade breakdown a student can be shown ("you lost 38 of your 100 points on contour"). The geometric mean's floor for a single dominant failure is its own penalty value (`≈61.8%`), which is unreachable-from-below and requires absurd weights (~30–100× everything else) to approach; the arithmetic mean hits any target with legible weights.
|
|
63
|
+
|
|
64
|
+
- **Golden-ratio rubric split (Design B).** Using the identity `φ⁻¹ + φ⁻² = 1` (`0.618 + 0.382 = 1`): weight the defining rule (contour) at `φ⁻¹ ≈ 61.8%` of the rubric and everything else at `φ⁻² ≈ 38.2%`, and have the contour rule score `φ⁻² = 0.382` (not zero) on failure. A wrong contour on an otherwise-perfect line then scores `0.618·0.382 + 0.382·1 = 0.618` — exactly the golden target. This keeps the golden penalty *inside* the rule (0.382 is itself a golden value) and makes "not a zero" true at both the rule and overall level. The contour weight is a **configurable default** (defaults to `φ⁻¹`), overridable per guide — the golden value is the chosen default, not a hardcoded constraint.
|
|
65
|
+
|
|
66
|
+
- **Rate-normalize each rule's fitness** so a rule reports a *rate* of adherence, not a *count* of violations. This makes each rule's score length-invariant at the source, which (with fixed weights) makes the overall grade length-invariant by construction. This is the fix for problem #2 and is independent of the aggregator choice.
|
|
67
|
+
|
|
68
|
+
- **Weights live at guideline-default + guide-level override.** A guideline declares a sensible default weight (its intrinsic importance); a guide's `RULESET` entry may override it for that context (via the existing `Annotation.with(...)` composition mechanism). This honors "the same rule matters differently in different guides" (contour dominates a contour guide) without re-encoding shared judgments in every ruleset and letting them drift.
|
|
69
|
+
|
|
70
|
+
- **Keep the golden multiplicative penalty _inside_ each rule.** `Mark#fitness`, `PENALTY_FACTOR`, and per-mark composition stay as-is at the rule level. Only the **top-level** fuser in `Analysis#fitness` changes from geometric to weighted-arithmetic. The multiplicative world is preserved where it already lives.
|
|
71
|
+
|
|
72
|
+
- **Contour is not a gate.** A wrong-contour line is not disqualified (0%); a student who wrote a musically excellent line that missed the brief demonstrated real skill and should grade ~61.8%, not zero. Under Design B "not a zero" holds even at the rule level (the contour rule bottoms out at `φ⁻²`, not 0). Contour is the highest-weighted *rubric* line item — a quality judgment, not a qualification.
|
|
73
|
+
|
|
74
|
+
- **Gate tier (qualification to be assessed).** Separate from quality, some rules answer "is there a gradable attempt here at all?" — their failure means *nothing to assess*, not *assessed poorly*. These compose **multiplicatively in front of the rubric**:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
fitness = ∏(gate fitnesses) × weighted_arithmetic_mean(rubric rules)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
This restores the hard-zero for genuinely disqualifying failures (an empty line → 0) that the arithmetic mean would otherwise delete — *without* zeroing quality failures like contour. Verified regression this fixes: an empty melody under the plain weighted mean scores **0.9653** (every rule vacuously passes at 1.0 except the lone low-weight `MinimumNotes`), where it must be 0. Gates are **graded multipliers**, not binary: an empty line → `MinimumNotes` = 0 → ×0; a line 4-of-5 notes → ×0.8 (mild haircut); a sufficient line → ×1 (no effect). Gates are pulled **out** of the rubric mean (no double-count), so rubric peer weights are computed over the non-gate rules.
|
|
81
|
+
|
|
82
|
+
- **Gate set (v1): sufficiency thresholds only.** `MinimumNotes`, and a new `MinimumMelodicIntervals` where appropriate — both check "is there enough of an attempt to grade?", never quality. Gate-ness is a **per-guide option** (`SomeRule.with(gate: true)`, defaulting per-guideline), applied "when appropriate" — a rule may gate in one guide and be absent or rubric in another. Broader gate candidates are deferred to a follow-up audit.
|
|
83
|
+
|
|
84
|
+
- **Soft floor for broken (but real) work.** For a submission that *passes the gates* (a real attempt) but scores badly on the rubric, the arithmetic mean deliberately stops failures from cascading to zero: it floors around 0.4–0.5, not near 0, because rate-normalized rules bottom out near `φ⁻¹` and the mean averages them. This is the intended consequence of grade semantics (partial credit) — a real-but-flawed attempt is not a literal 0%, while a non-attempt (gate failure) still is.
|
|
85
|
+
|
|
86
|
+
## Acceptance Criteria
|
|
87
|
+
|
|
88
|
+
- `Analysis#fitness` computes `∏(gate fitnesses) × weighted_arithmetic_mean(rubric rules)` — gates multiply in front; rubric rules are a weight-normalized arithmetic mean.
|
|
89
|
+
- Annotations expose a `weight` (per-guideline default, overridable via `Annotation.with(...)`) and a `gate?` predicate (per-guideline default, overridable via `.with(gate: …)`). Both defaults are configurable, not hardcoded magic numbers.
|
|
90
|
+
- Gates are pulled out of the rubric mean; rubric peer weights are computed over the non-gate rules so the contour share stays exactly `φ⁻¹`.
|
|
91
|
+
- In the contour guides, the `Contoured` rule carries weight `φ⁻¹` of the rubric (≈61.8%) by default; the remaining rubric rules split `φ⁻²` (≈38.2%). On a wrong contour the contour rule scores `φ⁻²` (0.382), not zero.
|
|
92
|
+
- An otherwise-perfect melody with the wrong contour grades at 61.8% (± rounding); definitely under 70%.
|
|
93
|
+
- Rule fitnesses are rate-normalized: two lines with the same proportional violation rate but different lengths produce the same rule fitness (and the same overall grade).
|
|
94
|
+
- A wrong contour is **not** a zero, at either the rule level (`φ⁻²`) or the overall grade (~0.618).
|
|
95
|
+
- **A non-attempt is a zero.** An empty line (and, where a `MinimumMelodicIntervals` gate applies, a line without enough melodic motion) grades 0 via the gate multiplier. A line just short of a sufficiency threshold takes a proportional haircut (e.g. 4-of-5 notes → ×0.8), not a cliff.
|
|
96
|
+
- `MinimumNotes` is a gate (graded multiplier); a `MinimumMelodicIntervals` gate exists and is wired into guides where appropriate.
|
|
97
|
+
- `adherent?` means every rule is adherent (a fully-correct submission still grades 1.0).
|
|
98
|
+
- Existing guide specs are updated to the new scoring; a perfect submission remains 1.0; a real-but-fully-broken submission (passing the gates) grades ~0.4–0.5 (soft floor, intended); a non-attempt grades 0.
|
|
99
|
+
|
|
100
|
+
## Scenario: Wrong contour grades in the low 60s
|
|
101
|
+
|
|
102
|
+
Given an otherwise well-formed diatonic melody whose shape does not match the assigned contour
|
|
103
|
+
|
|
104
|
+
When it is analyzed under a contour guide (e.g. `ArchContourMelody`)
|
|
105
|
+
|
|
106
|
+
Then the overall fitness is 61.8% (± rounding), and in all cases under 70%
|
|
107
|
+
|
|
108
|
+
And the failing `Contoured` annotation is the dominant contributor to the lost credit
|
|
109
|
+
|
|
110
|
+
## Scenario: Grade is length-invariant
|
|
111
|
+
|
|
112
|
+
Given two melodies that violate the same rule at the same proportional rate (e.g. 20% of notes), one short and one long
|
|
113
|
+
|
|
114
|
+
When each is analyzed under the same guide
|
|
115
|
+
|
|
116
|
+
Then both receive the same fitness for that rule, and the same overall grade
|
|
117
|
+
|
|
118
|
+
## Scenario: A rule's weight is contextual to the guide
|
|
119
|
+
|
|
120
|
+
Given a guideline that is defining in one guide and incidental in another
|
|
121
|
+
|
|
122
|
+
When the same guideline appears in two different `RULESET`s with different weights
|
|
123
|
+
|
|
124
|
+
Then each guide grades that rule according to its own weight, without changing the guideline class
|
|
125
|
+
|
|
126
|
+
## Scenario: A wrong contour is not a zero
|
|
127
|
+
|
|
128
|
+
Given a musically excellent melody with the wrong contour
|
|
129
|
+
|
|
130
|
+
When it is analyzed under a contour guide
|
|
131
|
+
|
|
132
|
+
Then the grade is low-but-nonzero (≈61.8%), reflecting the quality of the line while penalizing the missed brief
|
|
133
|
+
|
|
134
|
+
## Scenario: A perfect submission still grades 100%
|
|
135
|
+
|
|
136
|
+
Given a melody that satisfies every rule in its guide
|
|
137
|
+
|
|
138
|
+
When it is analyzed
|
|
139
|
+
|
|
140
|
+
Then `fitness` is 1.0 and `adherent?` is true
|
|
141
|
+
|
|
142
|
+
## Scenario: A non-attempt grades zero (gate)
|
|
143
|
+
|
|
144
|
+
Given an empty voice (no notes), or — where a `MinimumMelodicIntervals` gate applies — a line with insufficient melodic motion
|
|
145
|
+
|
|
146
|
+
When it is analyzed under a guide whose sufficiency rule is a gate
|
|
147
|
+
|
|
148
|
+
Then the gate fitness is 0, the gate multiplier drives the overall grade to 0
|
|
149
|
+
|
|
150
|
+
And this holds regardless of how many other rules vacuously pass
|
|
151
|
+
|
|
152
|
+
## Scenario: Falling just short of a sufficiency threshold takes a haircut, not a cliff
|
|
153
|
+
|
|
154
|
+
Given a melody with 4 notes under a guide requiring a minimum of 5 (`MinimumNotes.with(5, gate: true)`)
|
|
155
|
+
|
|
156
|
+
When it is analyzed
|
|
157
|
+
|
|
158
|
+
Then the gate contributes a graded multiplier (`4/5 = 0.8`), scaling the rubric grade down proportionally rather than to zero
|
|
159
|
+
|
|
160
|
+
## Open questions (resolved during planning)
|
|
161
|
+
|
|
162
|
+
- **Contour arrangement → resolved: Design B.** Contour weight defaults to `φ⁻¹` (≈61.8%); the contour rule scores `φ⁻²` (0.382) on failure. Both land the wrong-contour case on exactly 61.8% while keeping "not a zero" true at the rule level. (Design A — weight `φ⁻²`, rule → 0 — was rejected because it forces a raw zero and contradicts "golden penalty inside the rule.")
|
|
163
|
+
- **Literal constants vs. configurable → resolved: configurable, defaulting to golden.** The contour weight is a per-guideline default that happens to default to `φ⁻¹`; guides may override it. The golden value is the chosen default, not a hardcoded constraint.
|
|
164
|
+
- **Broken-line floor → resolved: accept the soft floor.** A real (gate-passing) but fully-broken melody grades ~0.4–0.5, not near 0. This is the intended consequence of the arithmetic mean and grade semantics.
|
|
165
|
+
- **Gate tier → resolved: needed, and narrow.** A qualification-to-be-assessed tier multiplies in front of the rubric (`∏ gate × rubric`). Gates are graded multipliers, not binary. The v1 gate set is sufficiency-only — `MinimumNotes` plus a new `MinimumMelodicIntervals` — wired per guide "when appropriate." Contour is explicitly *not* a gate (it is a rubric quality item). This restores correct 0-for-non-attempt behavior that the arithmetic mean would otherwise lose (empty line was scoring 0.9653).
|
|
166
|
+
- **Opportunities denominator → default `notes.length` where a rule scales with length; genuinely rule-specific otherwise.** For this story only `diatonic` and `maximum_notes` (the length-scaling rules that appear in contour guides) override `fitness_denominator`; the other `for_each` rules are deferred to a bounded follow-up.
|
|
167
|
+
- **Defining rule in non-contour guides → deferred.** Non-contour guides stay flat (all weights default to 1.0) for this story; promoting a defining rule (e.g. `Diatonic` in strict species) is a follow-up.
|
|
168
|
+
- **Structured grade-breakdown object for `bardtheory` → deferred.** The weighted terms make it cheap to add later (likely a small `Rubric` object); out of scope for v1, which delivers only the scalar.
|
|
169
|
+
- **Migration blast radius → measured.** ~81 spec files under `spec/head_music/style/` reference `fitness`/`adherent`/`messages`; most assertions are range-based and survive. See the migration step in the plan.
|
|
170
|
+
|
|
171
|
+
## Notes
|
|
172
|
+
|
|
173
|
+
- Empirical baseline reproduced during design: a 5-note C-major descending line (`G4 F4 E4 D4 C4`) under `ArchContourMelody` → `fitness 0.9607`, 12 annotations, only `Contoured` failing at `0.618`.
|
|
174
|
+
- Relevant files: `lib/head_music/style/analysis.rb`, `lib/head_music/style/annotation.rb`, `lib/head_music/style/mark.rb`, `lib/head_music/style/guidelines/contoured.rb`, `lib/head_music/style/guides/*contour_melody.rb`, `lib/head_music.rb` (golden constants).
|
|
175
|
+
|
|
176
|
+
## Implementation Plan
|
|
177
|
+
|
|
178
|
+
### Overview
|
|
179
|
+
|
|
180
|
+
Convert `HeadMusic::Style::Analysis#fitness` to `∏(gate fitnesses) × weight-normalized arithmetic mean of the rubric rules`; give `Annotation` a per-guideline default `weight` and a `gate?` predicate, both overridable through the existing `Annotation.with(...)` / `Configured` factory; and make length-scaling rules report an **adherence rate** via an overridable `fitness_denominator`. Contour guides weight `Contoured` at `φ⁻¹` (0.618, a configurable default) with rubric peers sharing `φ⁻²` (0.382); the contour rule fails to `φ⁻²` (0.382, not zero). `MinimumNotes` (and a new `MinimumMelodicIntervals`, where appropriate) become gates. Behavioral change only — no data model, persistence, or migrations.
|
|
181
|
+
|
|
182
|
+
**Crux resolved (Design B):** wrong-contour on an otherwise-perfect line = `0.618·0.382 + 0.382·1.0 = 0.618` exactly (gates pass, ×1). Perfect = `1.0`. Empty line = `0` (gate ×0). The contour rule bottoms out at `φ⁻²`, so "not a zero" holds at the rule and overall level; a non-attempt is still 0 via the gate.
|
|
183
|
+
|
|
184
|
+
**Gate regression this fixes (verified):** without the gate tier, an empty melody scores **0.9653** under the plain weighted mean — every rule vacuously passes at 1.0 except the lone low-weight `MinimumNotes` at 0. The old geometric mean hid this (one 0 zeros the product); the arithmetic mean deletes that implicit gate, so the gate tier is mandatory, not optional.
|
|
185
|
+
|
|
186
|
+
### Steps
|
|
187
|
+
|
|
188
|
+
1. **Characterization test first — lock the baseline before changing anything.**
|
|
189
|
+
- Assert the *current* `Analysis.new(ArchContourMelody, voice).fitness` for a 5-note descending line ≈ `0.9607`. Run green, then update/remove after the change lands (documents the reproduced baseline).
|
|
190
|
+
- Files: `spec/head_music/style/guides/arch_contour_melody_spec.rb`
|
|
191
|
+
|
|
192
|
+
2. **Add `weight` and `gate?` to `Annotation`.**
|
|
193
|
+
- Add `DEFAULT_WEIGHT = 1.0` and `def self.default_weight = DEFAULT_WEIGHT` (subclasses override).
|
|
194
|
+
- Add `def weight = options.fetch(:weight, self.class.default_weight)` (reads the existing protected `options`, `annotation.rb:92`). Do **not** add a parallel options hash — `weight`/`gate` share the `@options` bag with domain config like `contour:`.
|
|
195
|
+
- Add `def self.default_gate? = false` and `def gate? = options.fetch(:gate, self.class.default_gate?)`, so gate-ness is a per-guideline default overridable per guide (`SomeRule.with(gate: true)`).
|
|
196
|
+
- `Annotation.with(**options)` (line 33) already accepts arbitrary keys, so `SomeGuideline.with(weight: w, gate: true)` works for bare classes unchanged.
|
|
197
|
+
- Add `Configured#with(**more)` → `Configured.new(guideline_class, options.merge(more))` so an already-configured entry (e.g. `MinimumNotes.with(5)`) can take a weight/gate override without dropping prior options. `Configured#new(voice)` already splats `**options`, so `:weight`/`:gate` flow through automatically.
|
|
198
|
+
- Files: `lib/head_music/style/annotation.rb`
|
|
199
|
+
|
|
200
|
+
3. **Fix `Contoured.with` signature and set its golden default weight + penalty.**
|
|
201
|
+
- Change `self.with(contour_key)` → `self.with(contour_key, **options)`, calling `super(contour: contour, **options)` (the positional-only signature is why `weight:` can't currently reach this rule).
|
|
202
|
+
- Add `def self.default_weight = HeadMusic::GOLDEN_RATIO_INVERSE` (φ⁻¹ ≈ 0.618). Prefer a named constant (e.g. `DEFAULT_WEIGHT = HeadMusic::GOLDEN_RATIO_INVERSE`) so the default is legible and overridable.
|
|
203
|
+
- Files: `lib/head_music/style/guidelines/contoured.rb`
|
|
204
|
+
|
|
205
|
+
4. **Contour rule fails to `φ⁻²`, not zero (the crux, Design B).**
|
|
206
|
+
- Change `Mark.for_all(notes)` → `Mark.for_all(notes, fitness: HeadMusic::GOLDEN_RATIO_INVERSE**2)` (0.382), using the same explicit-`fitness:` mechanism 6 guidelines already use. `Mark#fitness`, `PENALTY_FACTOR`, and per-mark composition are untouched at the framework level; the golden penalty stays inside the rule.
|
|
207
|
+
- Files: `lib/head_music/style/guidelines/contoured.rb`
|
|
208
|
+
|
|
209
|
+
5. **Rate-normalize `Annotation#fitness` via an overridable denominator — NOT a blanket nth-root.**
|
|
210
|
+
- A global geometric mean over `marks.length` is a no-op for `for_all` rules (1 mark) and *double-penalizes* the 6 rules that already hand-roll a rate (`PENALTY_FACTOR**exponent`). Give the *rule* ownership of its opportunity count:
|
|
211
|
+
```ruby
|
|
212
|
+
def fitness
|
|
213
|
+
mark_fitnesses = [marks].flatten.compact.map(&:fitness)
|
|
214
|
+
return 1.0 if mark_fitnesses.empty?
|
|
215
|
+
mark_fitnesses.reduce(1, :*)**(1.0 / fitness_denominator)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def fitness_denominator = 1
|
|
219
|
+
```
|
|
220
|
+
- Default `1` leaves every `for_all` gestalt rule (Contoured, MostlyConjunct, …) and the 6 custom-`fitness:` rules **exactly as today**. Override `fitness_denominator` only on rules whose penalty genuinely scales with line length → `PENALTY^(k/n)`, length-invariant for a fixed rate `k/n`. Guard so the denominator is never `< 1`.
|
|
221
|
+
- Files: `lib/head_music/style/annotation.rb`
|
|
222
|
+
|
|
223
|
+
6. **Override `fitness_denominator` on the two length-scaling rules in contour guides — defer the rest.**
|
|
224
|
+
- The 10 `for_each` guidelines: `diatonic`, `maximum_notes`, `no_rests`, `no_rests_after_note`, `notes_same_length`, `one_to_one`, `one_to_one_with_ties`, `consonant_climax`, `singable_range`, `avoid_overlapping_voices`. For this story override only the two that scale with length **and** appear in contour guides:
|
|
225
|
+
- `diatonic.rb`: `def fitness_denominator = notes.length` (out-of-key notes / total — clean rate demonstrator).
|
|
226
|
+
- `maximum_notes.rb`: `def fitness_denominator = notes.length` (overage notes / total).
|
|
227
|
+
- Leave harmony-only `for_each` rules and all `for_all` rules for a bounded follow-up. `consonant_climax`/`singable_range`/`avoid_overlapping_voices` already emit 1–2 marks with severity in an explicit `fitness:` and are already near length-invariant — do not touch.
|
|
228
|
+
- Files: `lib/head_music/style/guidelines/diatonic.rb`, `lib/head_music/style/guidelines/maximum_notes.rb`
|
|
229
|
+
|
|
230
|
+
7. **Rewrite `Analysis#fitness` as gate-multiplier × weighted arithmetic mean, and harden `adherent?`.**
|
|
231
|
+
```ruby
|
|
232
|
+
def fitness
|
|
233
|
+
return 1.0 if annotations.empty?
|
|
234
|
+
@fitness ||= gate_factor * rubric_fitness
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def adherent?
|
|
238
|
+
annotations.all?(&:adherent?)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
def gate_factor
|
|
244
|
+
gates.map(&:fitness).reduce(1, :*) # multiplicative, graded (0 disqualifies)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def rubric_fitness
|
|
248
|
+
rubric = annotations.reject(&:gate?)
|
|
249
|
+
total_weight = rubric.sum(&:weight)
|
|
250
|
+
return 1.0 if rubric.empty? || total_weight.zero?
|
|
251
|
+
rubric.sum { |a| a.weight * a.fitness } / total_weight
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def gates
|
|
255
|
+
annotations.select(&:gate?)
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
- Gates multiply in front; the rubric is the weight-normalized arithmetic mean of the **non-gate** rules. `all?(&:adherent?)` (gates + rubric) is order-independent and decouples "is it perfect?" from float summation. `@fitness ||=` is safe (`0.0` is truthy). Empty-annotations, no-rubric-rules, and all-zero-weight edges short-circuit sensibly.
|
|
259
|
+
- Verified landings under this formula: empty line → `0`; wrong-contour otherwise-perfect → `0.618`; perfect → `1.0`.
|
|
260
|
+
- Files: `lib/head_music/style/analysis.rb`
|
|
261
|
+
|
|
262
|
+
8. **Designate the sufficiency gates.**
|
|
263
|
+
- Make `MinimumNotes` a gate by default: `def self.default_gate? = true`. Its existing `marks` already returns fitness `0` (empty) or `notes.length / minimum` (short) — exactly the graded multiplier we want; no change to its scoring logic.
|
|
264
|
+
- Add a new `MinimumMelodicIntervals` guideline mirroring `MinimumNotes` (`.with(n)`, `default_gate? = true`), counting melodic intervals (`melodic_intervals` / adjacent note-pairs) against a minimum, with the same graded-deficiency mark (`count / minimum`, `0` when none). This is a sufficiency check ("enough melodic motion to assess"), distinct from `always_move` (which flags each static pair as a quality issue).
|
|
265
|
+
- Wire gates per guide "when appropriate" — `MinimumNotes` is already in `DiatonicMelody`/CF rulesets, so it becomes a gate there automatically; add `MinimumMelodicIntervals.with(n)` only to guides that need it. Do **not** blanket-add it.
|
|
266
|
+
- Because gates are pulled out of the rubric mean (step 7), `MinimumNotes` no longer contributes a rubric weight — so the contour weighting in step 9 must divide `φ⁻²` across the **non-gate** peers only.
|
|
267
|
+
- Files: `lib/head_music/style/guidelines/minimum_notes.rb`, new `lib/head_music/style/guidelines/minimum_melodic_intervals.rb` (+ `require` in `lib/head_music.rb`), spec for the new guideline.
|
|
268
|
+
|
|
269
|
+
9. **Weight the six contour guide RULESETs over the non-gate (rubric) peers (Design B).**
|
|
270
|
+
```ruby
|
|
271
|
+
PEERS = HeadMusic::Style::Guides::DiatonicMelody::RULESET
|
|
272
|
+
RUBRIC_PEERS = PEERS.reject { |rule| gate_rule?(rule) } # exclude MinimumNotes gate
|
|
273
|
+
PEER_WEIGHT = (HeadMusic::GOLDEN_RATIO_INVERSE**2) / RUBRIC_PEERS.length
|
|
274
|
+
RULESET = [
|
|
275
|
+
*PEERS.map { |rule| gate_rule?(rule) ? rule : rule.with(weight: PEER_WEIGHT) },
|
|
276
|
+
HeadMusic::Style::Guidelines::Contoured.with(:arch) # weight defaults to φ⁻¹
|
|
277
|
+
].freeze
|
|
278
|
+
```
|
|
279
|
+
- `DiatonicMelody::RULESET` has **11 peers** (verified); `MinimumNotes` is now a gate, leaving **10 rubric peers** → `PEER_WEIGHT = φ⁻²/10`. Rubric peers share `φ⁻²` total; `Contoured` carries `φ⁻¹`. Because `φ⁻¹+φ⁻²=1` the weighted mean lands on `0.618` for a wrong contour with gates passing. (Determining `gate_rule?` at ruleset-build time: expose a class-level gate check, or filter on the guideline class — the gate default lives on the class, `MinimumNotes.default_gate? == true`.)
|
|
280
|
+
- `.map` returns a new array, so the shared `DiatonicMelody::RULESET` is not mutated. `rule.with(weight:)` works for both bare classes (→ `Annotation.with`) and already-`Configured` entries (→ new `Configured#with` merge). `Contoured.with(:arch, weight: …)` can override per guide if ever desired.
|
|
281
|
+
- Files: `lib/head_music/style/guides/{arch,valley,wave,ascending,descending,static}_contour_melody.rb`
|
|
282
|
+
|
|
283
|
+
10. **Regenerate spec expectations, then `bundle exec rubocop -a` and `bundle exec rspec`.** (Migration detail below.)
|
|
284
|
+
|
|
285
|
+
### Testing Strategy
|
|
286
|
+
|
|
287
|
+
- **Characterization baseline** (step 1): capture `0.9607` before the change; update after.
|
|
288
|
+
- **The five story scenarios**, in `spec/head_music/style/guides/*_contour_melody_spec.rb` (create if missing):
|
|
289
|
+
1. Otherwise-perfect line, wrong contour → `be_within(1e-6).of(HeadMusic::GOLDEN_RATIO_INVERSE)` **and** `be < 0.70`.
|
|
290
|
+
2. Length-invariance → two melodies (prefer `HeadMusic::Notation::ABC.parse` per project memory) with the same proportional Diatonic violation rate at different lengths → equal overall fitness.
|
|
291
|
+
3. Perfect submission → `eq 1.0`, `adherent?` true.
|
|
292
|
+
4. Real-but-fully-broken (passes gates) → `be_between(0.3, 0.55)` (soft floor: substantially below perfect, not near 0).
|
|
293
|
+
5. Contextual weight override → `Klass.with(weight: 2.0).new(voice).weight == 2.0`; default reads `default_weight`; `MinimumNotes.with(5).with(weight: 0.1)` still enforces minimum 5.
|
|
294
|
+
6. Non-attempt (gate) → empty voice → `fitness == 0` and `adherent?` false; assert the empty-line regression (would be ~0.9653 without gates) is now 0.
|
|
295
|
+
7. Sufficiency haircut → 4-of-5 notes under `MinimumNotes.with(5, gate: true)` → gate multiplier 0.8, overall = `0.8 × rubric` (not 0, not full).
|
|
296
|
+
- **Gate specs**: `MinimumNotes#gate?` true by default and overridable (`.with(5, gate: false)`); new `MinimumMelodicIntervals` gate — sufficient motion → adherent, too little → graded deficiency, none → 0.
|
|
297
|
+
- **Rule-level rate spec** on `Diatonic`: 1-of-5 and 2-of-10 out-of-key → both `PENALTY^0.2`.
|
|
298
|
+
- **Rule-level penalty spec** on `Contoured`: a wrong-contour line → annotation fitness `eq HeadMusic::GOLDEN_RATIO_INVERSE**2` (0.382).
|
|
299
|
+
- **Exact-`eq` assertions that must change:**
|
|
300
|
+
- `spec/head_music/style/guidelines/contoured_spec.rb` (the `eq PENALTY_FACTOR` rule-level cases) → change to `eq HeadMusic::GOLDEN_RATIO_INVERSE**2` (Contoured now fails to 0.382).
|
|
301
|
+
- `spec/head_music/style/guidelines/diatonic_spec.rb`, `maximum_notes_spec.rb`: single-violation `eq PENALTY_FACTOR` cases → recompute to `PENALTY^(1/n)` per fixture.
|
|
302
|
+
- **Assertions that must stay green (do not edit):** the many `eq PENALTY_FACTOR` / `eq SMALL_PENALTY_FACTOR` assertions in `for_all` guideline specs (large_leaps, singable_intervals, suspension_treatment, etc.) are unaffected (denominator 1). `analysis_spec.rb` adherent cases (`eq 1.0`) should still pass — verify.
|
|
303
|
+
- **Migration of the ~81 spec files:** most assertions are range-based (`be_between`, `be <`) and survive. Process: (1) `grep -rn "its(:fitness)\|\.fitness).to\|adherent" spec/head_music/style/` and classify each as range (spot-check), exact-on-`for_all` (unaffected), exact-on-Contoured/overridden-`for_each` (regenerate), or guide-level (regenerate); (2) write a throwaway script in the scratchpad that instantiates each affected guide/fixture and prints the actual new fitness — source expected constants from it rather than hand-computing; do **not** check it in; (3) batch edits by guide, running `bundle exec rspec spec/head_music/style/guides` and `.../guidelines` per directory to localize failures.
|
|
304
|
+
|
|
305
|
+
### Risks & Sequencing
|
|
306
|
+
|
|
307
|
+
- **Land order to keep the suite green:** steps 2 → 3 → 4 → 5 → 7 (framework: weight + gate + gate-multiplier fitness + rate hook + Contoured penalty) as one coherent change, then 6 (rate overrides), then 8 (designate gates + `MinimumMelodicIntervals`), then 9 (contour weights over rubric peers), then 10 (spec migration). Run the full suite after 7 and again after 9.
|
|
308
|
+
- **[Resolved by the gate tier] Veto-to-zero semantics.** The old geometric mean zeroed the whole grade on any single `0`-fitness rule; the arithmetic mean would delete that (empty line → 0.9653). The gate tier restores it *for sufficiency rules only* (`MinimumNotes`, `MinimumMelodicIntervals`). **Audit remaining `fitness: 0` paths** to confirm none *other* than sufficiency needs veto power — grep the guidelines for `fitness: 0`; if a non-sufficiency rule genuinely must disqualify, mark it a gate too. Do not assume the two known gates are exhaustive.
|
|
309
|
+
- **[bardtheory coordination — highest] Every grade shifts numerically.** Breaking change for the downstream consumer. Repo is at 14.0.0 → bump **major** and document the fitness-scale change (and the new 0-for-non-attempt behavior) in the changelog. Any consumer pinning expected fitness values will break.
|
|
310
|
+
- **Global aggregator change.** `Analysis#fitness` is global, so *every* guide moves from geometric to gate-multiplier × weighted-arithmetic mean (non-contour rubric rules default to weight 1.0 → equal-weight arithmetic mean). Direction is the same but magnitudes differ across all 26 guides — the ~81 specs are the safety net; do not skip a full-suite run.
|
|
311
|
+
- **Gate-ness at ruleset-build time.** Step 9 needs to know which RULESET entries are gates *before* instances exist, to compute rubric peer weight. Keep the gate default on the guideline class (`self.default_gate?`) so a class/`Configured` entry can be classified without a voice. A per-entry `.with(gate: true)` override that flips a normally-rubric rule to a gate in one guide would not be visible to that build-time filter — if that case ever arises, compute peer weights from the `Configured` options, not just the class.
|
|
312
|
+
- **Edge cases to add explicitly:** empty melody (→ 0), single-note melody, a line with all-repeated notes (0 melodic motion — the `MinimumMelodicIntervals` case), and a guide where multiple rubric rules fail simultaneously (confirm graceful degradation, not collapse).
|
|
313
|
+
- **Process note:** the product-manager specialist stalled during planning and returned no output; scope/edge-case items were reasoned directly and folded in. Treat the scope boundary and deferred items (grade-breakdown object, non-contour defining rules, remaining `for_each` rate overrides) as recommendations to confirm, not vetted PM decisions.
|
|
314
|
+
|
|
315
|
+
## Review
|
|
316
|
+
|
|
317
|
+
Reviewed 2026-07-06 at commit `041590e` (diff vs merge-base `15399a4` with `main`; working tree clean, all changes committed and pushed). Reviewers: product-manager (acceptance criteria) and code-reviewer (quality/conventions) agents. Full style suite at review time: 1038 examples, 0 failures; full suite 5206 examples, 0 failures.
|
|
318
|
+
|
|
319
|
+
### Acceptance criteria
|
|
320
|
+
|
|
321
|
+
- ✅ **Gate × weighted-mean aggregator** — `analysis.rb:25-51`: `fitness = gate_factor * rubric_fitness`; gates multiply in front, rubric is the weight-normalized arithmetic mean of non-gate annotations. Empty-annotations, empty-rubric, and zero-total-weight edges guarded.
|
|
322
|
+
- ✅ **`weight` / `gate?` with configurable defaults** — `annotation.rb`: `DEFAULT_WEIGHT`/`default_weight`, `default_gate?`, instance readers via the options bag; `Configured#with` merges without dropping prior options. Spec'd both directions (defaults and overrides).
|
|
323
|
+
- ✅ **Gates excluded from rubric weights** — `diatonic_melody.rb` `contour_ruleset` partitions by `default_gate?`; arch spec asserts exactly 10 rubric peers at φ⁻²/10 and no mutation of the shared `RULESET`.
|
|
324
|
+
- ✅ **Contoured at φ⁻¹, fails to φ⁻²** — `contoured.rb`: `DEFAULT_WEIGHT = GOLDEN_RATIO_INVERSE`, failure marks carry `fitness: GOLDEN_RATIO_INVERSE**2`; six contoured_spec failure cases updated to `eq φ⁻²`.
|
|
325
|
+
- ✅ **Wrong contour grades 61.8%, under 70%** — measured `0.6180339887498949` (φ⁻¹ to float epsilon); spec asserts `be_within(1e-6)` and `be < 0.70`, plus Contoured as dominant lost-credit contributor.
|
|
326
|
+
- ✅ **Rate normalization** — `fitness_denominator` hook (default 1, clamped ≥ 1); `Diatonic` and `MaximumNotes` override with `notes.length`. Rule-level invariance spec'd (1-of-5 ≡ 2-of-10) and guide-level (8 vs 16 notes, equal grade within 1e-6). Other `for_each` rules deferred per the story's resolved scope.
|
|
327
|
+
- ✅ **Wrong contour not a zero** — rule level φ⁻² and overall ≈0.618, both asserted.
|
|
328
|
+
- ✅ **Non-attempt is a zero; near-miss is a haircut** — empty voice → `fitness eq 0.0`; all-repeated line → motion gate 0; 4-of-5 notes → overall = 0.8 × rubric mean (within 1e-9), not a cliff.
|
|
329
|
+
- ✅ **Gates wired** — `MinimumNotes.default_gate?` true (scoring unchanged); new `MinimumMelodicIntervals` (counts moving, non-unison intervals) gated into arch/valley/wave (min 2) and ascending/descending (min 1); static deliberately omits it (all-repeated line is legitimate static) with an explaining comment.
|
|
330
|
+
- ✅ **`adherent?` = all rules adherent** — `analysis.rb:31-33`; spec'd both directions; perfect submission still `fitness eq 1.0` and adherent.
|
|
331
|
+
- ✅ **Spec migration** — all six contour guide specs updated; perfect = 1.0; gate-passing broken line grades 0.4918 within the asserted (0.3, 0.55) soft floor; non-attempt 0. Breaking-change bookkeeping done: version 15.0.0, CHANGELOG entry with downstream-recalibration warning.
|
|
332
|
+
|
|
333
|
+
### Code review findings
|
|
334
|
+
|
|
335
|
+
No blockers, no should-fix items. The math was verified exact (golden identity holds to float epsilon; peer weight computed dynamically so the identity survives peer-count changes), `@fitness ||=` memoization of `0.0` is safe (floats are truthy), `weight:`/`gate:` cannot collide with any existing domain option key, and load order for the new guideline is correct. Nits (optional follow-ups):
|
|
336
|
+
|
|
337
|
+
1. **[Fixed post-review]** Inline `gate:`/`weight:` keywords were unsupported on the positional `.with` factories — the scenario prose writes `MinimumNotes.with(5, gate: true)`, but `MinimumNotes`/`MaximumNotes`/`MinimumMelodicIntervals` had positional-only `.with(n)`; overrides only worked via chaining. Fixed by adding a `**options` passthrough to all three factories (matching `Contoured.with`), with inline-override specs on each. Suite re-run green (5209 examples, 0 failures).
|
|
338
|
+
2. **`Configured#default_gate?` naming** — returns the *effective* gate flag (honors per-entry `gate:` override), not strictly the class default; the comment explains the intent, so readability-only.
|
|
339
|
+
3. Empty `## [Unreleased]` section sits above `[15.0.0]` in CHANGELOG.md — keepachangelog-conventional, harmless.
|
|
340
|
+
|
|
341
|
+
### Verdict
|
|
342
|
+
|
|
343
|
+
Nothing blocks `finish`. Deferred items (grade-breakdown object, non-contour defining rules, remaining `for_each` rate overrides, broader gate audit) remain open by design.
|
|
344
|
+
|
|
345
|
+
## Learnings
|
|
346
|
+
|
|
347
|
+
- **Settling design decisions in the story paid off.** Every open question (aggregator choice, Design B, gate tier, soft floor, denominator scope) was resolved before implementation, so four implementation agents executed the plan with essentially zero deviation or rework. The one plan estimate that was wrong (interim characterization value 0.9485) was wrong for an interesting reason — Contoured's `default_weight` took effect the moment it was defined, before any guide wiring — and the plan's "verify empirically, trust the measurement" instruction absorbed it without drama.
|
|
348
|
+
- **Empirical anchors beat hand-computed expectations.** The characterization-test-first step, the pre-verified regression number (empty line → 0.9653 under a plain weighted mean), and the instruction to source spec constants from actual runs kept every numeric assertion honest. The golden identity landed exact: wrong-contour fitness measured φ⁻¹ to 1e-15.
|
|
349
|
+
- **The migration blast radius was ~20× smaller than feared.** ~81 spec files referenced fitness; only 4 examples actually broke, because most existing assertions were range-based (`be_between`, `be <`). Range assertions on emergent scores are the resilient choice; exact `eq` belongs only on identity-level values (rule penalties, perfect = 1.0).
|
|
350
|
+
- **Changing an aggregator deletes implicit behavior — hunt for it.** The geometric mean's product gave every zero-fitness rule silent veto power; the arithmetic mean deleted that. Identifying the regression *before* coding (empty line would grade 0.9653) is what produced the gate tier, the story's most important architectural addition.
|
|
351
|
+
- **Documented syntax must be executable.** The review caught that the story prose wrote `MinimumNotes.with(5, gate: true)` while the factories were positional-only. With a downstream consumer (`bardtheory`) likely to copy examples verbatim, aligning signatures with prose was worth the immediate follow-up fix.
|
|
352
|
+
- **Process**: parallel agents on disjoint file sets (rate overrides ∥ gate guideline) worked cleanly; the framework-first land order kept the suite nearly green throughout. The product-manager stall during planning was successfully compensated by folding scope reasoning into the plan directly.
|
data/user-stories/index.html
CHANGED
|
@@ -258,10 +258,6 @@
|
|
|
258
258
|
"title": "LilyPond Interpreter",
|
|
259
259
|
"path": "backlog/lilypond-interpreter.md"
|
|
260
260
|
},
|
|
261
|
-
{
|
|
262
|
-
"title": "Organizing Content",
|
|
263
|
-
"path": "backlog/organizing-content.md"
|
|
264
|
-
},
|
|
265
261
|
{
|
|
266
262
|
"title": "Sixteenth-Century (Renaissance) Style Guides",
|
|
267
263
|
"path": "backlog/sixteenth-century-style.md"
|
|
@@ -269,10 +265,18 @@
|
|
|
269
265
|
{
|
|
270
266
|
"title": "Split Counterpoint Species Guides by Pedagogical Author",
|
|
271
267
|
"path": "backlog/split-counterpoint-species-by-author.md"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"title": "organizing-content",
|
|
271
|
+
"path": "backlog/organizing-content.md"
|
|
272
272
|
}
|
|
273
273
|
],
|
|
274
274
|
"active": [],
|
|
275
275
|
"done": [
|
|
276
|
+
{
|
|
277
|
+
"title": "Rework Style Analysis Scoring as a Weighted Rubric",
|
|
278
|
+
"path": "done/scoring-architecture.md"
|
|
279
|
+
},
|
|
276
280
|
{
|
|
277
281
|
"title": "Melody Contour Guides",
|
|
278
282
|
"path": "done/melody-contour-guides.md"
|
|
@@ -313,18 +317,10 @@
|
|
|
313
317
|
"title": "Instrument Inheritance Architecture",
|
|
314
318
|
"path": "done/instrument-architecture.md"
|
|
315
319
|
},
|
|
316
|
-
{
|
|
317
|
-
"title": "Instrument Variant Refactoring",
|
|
318
|
-
"path": "done/instrument-variant.md"
|
|
319
|
-
},
|
|
320
320
|
{
|
|
321
321
|
"title": "Instruments can have strings with pitches",
|
|
322
322
|
"path": "done/string-pitches.md"
|
|
323
323
|
},
|
|
324
|
-
{
|
|
325
|
-
"title": "Load Playing Techniques from YAML Data File",
|
|
326
|
-
"path": "done/expand-playing-techniques.md"
|
|
327
|
-
},
|
|
328
324
|
{
|
|
329
325
|
"title": "Move MusicalSymbol to Notation Module",
|
|
330
326
|
"path": "done/move-musical-symbol-to-notation.md"
|
|
@@ -362,7 +358,15 @@
|
|
|
362
358
|
"path": "done/sonority-identification.md"
|
|
363
359
|
},
|
|
364
360
|
{
|
|
365
|
-
"title": "
|
|
361
|
+
"title": "expand-playing-techniques",
|
|
362
|
+
"path": "done/expand-playing-techniques.md"
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
"title": "instrument-variant",
|
|
366
|
+
"path": "done/instrument-variant.md"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
"title": "superclass-for-note",
|
|
366
370
|
"path": "done/superclass-for-note.md"
|
|
367
371
|
}
|
|
368
372
|
]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: head_music
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 15.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rob Head
|
|
@@ -305,6 +305,7 @@ files:
|
|
|
305
305
|
- lib/head_music/style/guidelines/large_leaps.rb
|
|
306
306
|
- lib/head_music/style/guidelines/limit_octave_leaps.rb
|
|
307
307
|
- lib/head_music/style/guidelines/maximum_notes.rb
|
|
308
|
+
- lib/head_music/style/guidelines/minimum_melodic_intervals.rb
|
|
308
309
|
- lib/head_music/style/guidelines/minimum_notes.rb
|
|
309
310
|
- lib/head_music/style/guidelines/mixed_rhythmic_values.rb
|
|
310
311
|
- lib/head_music/style/guidelines/moderate_direction_changes.rb
|
|
@@ -414,6 +415,7 @@ files:
|
|
|
414
415
|
- user-stories/done/notation-style.md
|
|
415
416
|
- user-stories/done/percussion_set.md
|
|
416
417
|
- user-stories/done/pitch-class-set-analysis.md
|
|
418
|
+
- user-stories/done/scoring-architecture.md
|
|
417
419
|
- user-stories/done/sonority-identification.md
|
|
418
420
|
- user-stories/done/string-pitches.md
|
|
419
421
|
- user-stories/done/superclass-for-note.md
|