diff --git a/core/src/complement.rs b/core/src/complement.rs index 37dc5596..97e7aa1c 100644 --- a/core/src/complement.rs +++ b/core/src/complement.rs @@ -81,6 +81,58 @@ pub struct ComplementSpec { pub register_offset: i8, } +/// Pitch-variability *ask*, orthogonal to [`RelationMode`] (ADR-0023). +/// +/// The [`crate::gesture::GestureControl`] duality for the complement arranger: +/// it shapes B's pitch over a fixed rhythmic skeleton and never moves an onset, +/// so a grid-locked mode stays grid-locked. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VariationControl { + /// Fraction of the band's scale ladder B may use, `0.0..=1.0`. `0.0` pins + /// every note to the band's anchor degree (a static line, still locked to + /// A's grid); `1.0` uses the whole band — the unconstrained default that + /// matches [`arrange_complement`]. + pub pitch_spread: f64, +} + +impl VariationControl { + /// The identity control: the whole band, i.e. [`arrange_complement`]'s + /// behaviour. + pub const FULL: Self = Self { pitch_spread: 1.0 }; + /// A static line: B collapses onto the band's anchor degree. + pub const LOCKED: Self = Self { pitch_spread: 0.0 }; + + /// Whether the control is in range: a finite `pitch_spread` within + /// `0.0..=1.0`. + #[must_use] + pub fn is_valid(self) -> bool { + self.pitch_spread.is_finite() && (0.0..=1.0).contains(&self.pitch_spread) + } +} + +/// A varied complement: the arranged candidate plus provenance — the control +/// that asked for it (ask) and B's realised pitch spread (is). +#[derive(Debug, Clone)] +pub struct VariedComplement { + /// The arranged candidate (A's score with B appended). + pub complement: ComplementCandidate, + /// The control this candidate was arranged against. + pub control: VariationControl, + /// B's realised pitch ambitus as a fraction of the target band, + /// `0.0..=1.0` — what the spread actually *is*, not what was asked. + pub realized_spread: f64, +} + +/// Errors the varied arrangement entry point can emit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VariationError { + /// The [`VariationControl`] is out of range (non-finite or outside + /// `0.0..=1.0`). + InvalidControl, + /// The underlying arrangement failed. + Arrange(ComplementError), +} + /// Major or natural minor — the two scale shapes the key estimate considers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KeyMode { @@ -621,11 +673,57 @@ fn shift_pitch(pitch: u8, semitones: i8) -> u8 { /// Returns a [`ComplementCandidate`] whose `score` is A's score with B appended /// as a new track on the same master bars. Deterministic for a fixed /// `(score, track_index, spec, seed)`. +/// +/// Equivalent to [`arrange_complement_varied`] with [`VariationControl::FULL`] +/// (the whole-band pitch window). pub fn arrange_complement( score: &Score, track_index: usize, spec: ComplementSpec, seed: GenerationSeed, +) -> Result { + arrange_complement_inner(score, track_index, spec, seed, VariationControl::FULL) +} + +/// Arranges a complement under a [`VariationControl`], with ask-vs-is provenance. +/// +/// Returns the candidate plus the control and B's realised spread +/// ([`VariedComplement`]). The control shapes B's pitch only — onsets stay +/// locked to the chosen [`RelationMode`]'s skeleton — and an out-of-range +/// control is the typed [`VariationError::InvalidControl`]. +/// +/// Deterministic for a fixed `(score, track_index, spec, seed, control)` +/// (SPEC §6): the control narrows the seeded pitch hash, it does not add +/// randomness. +pub fn arrange_complement_varied( + score: &Score, + track_index: usize, + spec: ComplementSpec, + seed: GenerationSeed, + control: VariationControl, +) -> Result { + if !control.is_valid() { + return Err(VariationError::InvalidControl); + } + let complement = arrange_complement_inner(score, track_index, spec, seed, control) + .map_err(VariationError::Arrange)?; + let realized_spread = realized_band_spread(score, track_index, spec, &complement); + Ok(VariedComplement { + complement, + control, + realized_spread, + }) +} + +/// Shared dispatch for both entry points: the ladder-substitution modes +/// (`rhythm_lock`, `register_contrast`, `call_response`) honour `control`'s +/// pitch spread; the others have no ladder to window and ignore it. +fn arrange_complement_inner( + score: &Score, + track_index: usize, + spec: ComplementSpec, + seed: GenerationSeed, + control: VariationControl, ) -> Result { if score.master_bars.is_empty() { return Err(ComplementError::EmptyScore); @@ -636,15 +734,17 @@ pub fn arrange_complement( } match spec.mode { - RelationMode::RhythmLock => arrange_rhythm_lock(score, track_index, &profile, spec, seed), + RelationMode::RhythmLock => { + arrange_rhythm_lock(score, track_index, &profile, spec, seed, control) + } RelationMode::RegisterContrast => { - arrange_register_contrast(score, track_index, &profile, spec, seed) + arrange_register_contrast(score, track_index, &profile, spec, seed, control) } RelationMode::SupportLayer => { arrange_support_layer(score, track_index, &profile, spec, seed) } RelationMode::CallResponse => { - arrange_call_response(score, track_index, &profile, spec, seed) + arrange_call_response(score, track_index, &profile, spec, seed, control) } RelationMode::OctaveDouble => { arrange_octave_double(score, track_index, &profile, spec, seed) @@ -655,6 +755,40 @@ pub fn arrange_complement( } } +/// Measures B's realised pitch spread: its ambitus as a fraction of the target +/// band `[band_lo, band_hi]` (the `shifted_band` A's register maps to). `0.0` +/// when B is a single pitch, a degenerate band, or has no notes. +fn realized_band_spread( + score: &Score, + track_index: usize, + spec: ComplementSpec, + complement: &ComplementCandidate, +) -> f64 { + let Ok(profile) = analyze_part(score, track_index) else { + return 0.0; + }; + let Some(register) = profile.register else { + return 0.0; + }; + let (band_lo, band_hi) = shifted_band(register, spec.register_offset); + let span = band_hi.saturating_sub(band_lo); + if span == 0 { + return 0.0; + } + let b_notes = complement + .score + .tracks + .get(complement.part_b_index) + .map_or_else(Vec::new, voice_notes); + let (Some(lo), Some(hi)) = ( + b_notes.iter().map(|n| n.pitch).min(), + b_notes.iter().map(|n| n.pitch).max(), + ) else { + return 0.0; + }; + f64::from(hi.saturating_sub(lo)) / f64::from(span) +} + /// `counter_melody`: an independent line against A, delegated to the S6 /// generator's `ConstrainedRandomWalk` — the one mode that synthesises a fresh /// sequence instead of deriving B from A's grid. @@ -808,12 +942,14 @@ fn arrange_octave_double( /// rhythm is locked exactly even when A has rests, off-beat starts, different /// rhythms in later bars, or per-bar meter changes — A's onsets already respect /// A's master-bar timeline. Pitch selection is seed-deterministic. +#[allow(clippy::too_many_arguments)] // a ladder mode threading the variation control fn arrange_rhythm_lock( score: &Score, track_index: usize, profile: &PartProfile, spec: ComplementSpec, seed: GenerationSeed, + control: VariationControl, ) -> Result { // Register band for B: A's band shifted, clamped, and ordered. let register = profile.register.ok_or(ComplementError::PartHasNoNotes)?; @@ -823,7 +959,7 @@ fn arrange_rhythm_lock( .tracks .get(track_index) .ok_or(ComplementError::TrackIndexOutOfRange)?; - let event_groups = grid_locked_groups(a_track, profile, band_lo, band_hi, seed); + let event_groups = grid_locked_groups(a_track, profile, band_lo, band_hi, seed, control); Ok(finish_candidate( score, @@ -844,12 +980,14 @@ fn arrange_rhythm_lock( /// zero offset, or a large shift folded back by the clamp), the contrast /// contract cannot be met and the spec is rejected as /// [`ComplementError::InvalidSpec`] rather than silently overlapped. +#[allow(clippy::too_many_arguments)] // a ladder mode threading the variation control fn arrange_register_contrast( score: &Score, track_index: usize, profile: &PartProfile, spec: ComplementSpec, seed: GenerationSeed, + control: VariationControl, ) -> Result { let register = profile.register.ok_or(ComplementError::PartHasNoNotes)?; let (band_lo, band_hi) = shifted_band(register, spec.register_offset); @@ -864,7 +1002,7 @@ fn arrange_register_contrast( .tracks .get(track_index) .ok_or(ComplementError::TrackIndexOutOfRange)?; - let event_groups = grid_locked_groups(a_track, profile, band_lo, band_hi, seed); + let event_groups = grid_locked_groups(a_track, profile, band_lo, band_hi, seed, control); Ok(finish_candidate( score, @@ -946,12 +1084,14 @@ fn arrange_support_layer( /// the band shifted by `spec.register_offset`. B's onsets are disjoint from /// A's by construction. No qualifying gap is /// [`ComplementError::NoGapsToAnswer`]. +#[allow(clippy::too_many_arguments)] // a ladder mode threading the variation control fn arrange_call_response( score: &Score, track_index: usize, profile: &PartProfile, spec: ComplementSpec, seed: GenerationSeed, + control: VariationControl, ) -> Result { let register = profile.register.ok_or(ComplementError::PartHasNoNotes)?; let (band_lo, band_hi) = shifted_band(register, spec.register_offset); @@ -991,12 +1131,13 @@ fn arrange_call_response( let min_gap = u32::from(score.ticks_per_quarter); let scale = scale_intervals_from(profile, band_lo); let ladder = band_scale_ladder(band_lo, band_hi, &scale); + let window = spread_window(ladder.len(), control.pitch_spread); let event_groups: Vec = gaps .iter() .filter(|(start, end)| end.saturating_sub(*start) >= min_gap) .enumerate() .map(|(i, &(start, end))| { - let ladder_index = pitch_index(seed.0, i, ladder.len()); + let ladder_index = pitch_index(seed.0, i, window); let pitch_val = ladder.get(ladder_index).copied().unwrap_or(band_lo); // The call this gap answers: the last A note sounding before it. let call_velocity = a_notes @@ -1045,23 +1186,27 @@ fn shifted_band(register: PitchRange, offset: i8) -> (u8, u8) { /// B's event groups on A's exact onset grid: every B note keeps A's onset, /// duration, and velocity, with the pitch substituted seed-deterministically /// from A's harmonic context mapped into the `[band_lo, band_hi]` register band. +#[allow(clippy::too_many_arguments)] // shared grid builder threading the variation control fn grid_locked_groups( a_track: &Track, profile: &PartProfile, band_lo: u8, band_hi: u8, seed: GenerationSeed, + control: VariationControl, ) -> Vec { // Scale: A's harmonic context, as intervals above the band's low note, // anchored to A's pitch classes (the band picks the octave, not the key). let intervals = scale_intervals_from(profile, band_lo); let ladder = band_scale_ladder(band_lo, band_hi, &intervals); + // Pitch spread narrows how much of the ladder B may wander across. + let window = spread_window(ladder.len(), control.pitch_spread); voice_notes(a_track) .iter() .enumerate() .map(|(i, n)| { - let ladder_index = pitch_index(seed.0, i, ladder.len()); + let ladder_index = pitch_index(seed.0, i, window); let pitch_val = ladder.get(ladder_index).copied().unwrap_or(band_lo); let pitch = Pitch::new(pitch_val).unwrap_or(Pitch(band_lo)); // `n.velocity` originates from a valid AtomNote, so it is always in range. @@ -1192,13 +1337,36 @@ fn scale_intervals_from(profile: &PartProfile, band_lo: u8) -> Vec { intervals } +/// The number of bottom ladder entries B may wander across under a pitch-spread +/// control: `pitch_spread` scales the `ladder_len`, rounded, floored at 1 so B +/// always has at least the anchor degree, capped at the full ladder. +/// +/// `pitch_spread = 1.0` returns `ladder_len` (the whole band — the identity +/// window that reproduces the unconstrained arranger); `0.0` returns `1` (every +/// pick collapses onto degree 0). `ladder_len == 0` returns `0` (an empty +/// ladder, handled by the caller's `unwrap_or`). +fn spread_window(ladder_len: usize, pitch_spread: f64) -> usize { + if ladder_len == 0 { + return 0; + } + let spread = pitch_spread.clamp(0.0, 1.0); + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] // ladder_len ≤ MIDI range; result clamped to [1, ladder_len] + let window = (spread * ladder_len as f64).round() as usize; + window.clamp(1, ladder_len) +} + /// Every on-scale pitch within `[lo, hi]`, across all octaves, ascending — the /// full ladder B draws from. /// /// `intervals` are mod-12 offsets above `lo`'s pitch class, so each octave /// repeats the same shape. Spanning the whole band (not just `lo..=lo + 11`) is /// what lets B inhabit A's real register instead of collapsing into the bottom -/// octave — every degree (ladder_index) was previously placed only in the lowest octave. +/// octave — every degree (`ladder_index`) was previously placed only in the +/// lowest octave. fn band_scale_ladder(lo: u8, hi: u8, intervals: &[u8]) -> Vec { let hi16 = u16::from(hi); let mut ladder: Vec = Vec::new(); @@ -1222,7 +1390,8 @@ fn band_scale_ladder(lo: u8, hi: u8, intervals: &[u8]) -> Vec { ladder } -/// Seed-deterministic scale-degree (ladder_index) picker for note `index` (`SplitMix64` finalizer). +/// Seed-deterministic scale-degree (`ladder_index`) picker for note `index` +/// (`SplitMix64` finalizer). fn pitch_index(seed: u64, index: usize, modulo: usize) -> usize { if modulo == 0 { return 0; diff --git a/core/tests/complement.rs b/core/tests/complement.rs index 188e624a..1bbc2cd7 100644 --- a/core/tests/complement.rs +++ b/core/tests/complement.rs @@ -20,8 +20,9 @@ use griff_core::{ complement::{ - analyze_part, arrange_complement, measure_pair_axes, validate_pair, ComplementError, - ComplementSpec, KeyMode, RelationMode, + analyze_part, arrange_complement, arrange_complement_varied, measure_pair_axes, + validate_pair, ComplementError, ComplementSpec, KeyMode, RelationMode, VariationControl, + VariationError, }, event::{ NoteMark, NoteMarks, Pitch, SpanTechnique, TechniqueEvidence, Tempo, Ticks, TimeSignature, @@ -256,6 +257,137 @@ fn rhythm_lock_b_uses_full_register_band_not_just_bottom_octave() { ); } +// ── VariationControl: pitch/contour spread (ADR-0023) ──────────────────────── + +/// A wide, three-octave part A so the band ladder has room to spread. +fn wide_part_a() -> Score { + score_with_part_a(2, &[48, 55, 60, 67, 72, 79, 84]) +} + +const RHYTHM_LOCK: ComplementSpec = ComplementSpec { + mode: RelationMode::RhythmLock, + register_offset: 0, +}; + +/// Full spread is the identity window: the varied path must reproduce plain +/// `arrange_complement` byte-for-byte, so the default behaviour is unchanged. +#[test] +fn variation_full_spread_equals_plain_arrange() { + let score = wide_part_a(); + let plain = arrange_complement(&score, 0, RHYTHM_LOCK, GenerationSeed(5)).expect("arrange ok"); + let varied = arrange_complement_varied( + &score, + 0, + RHYTHM_LOCK, + GenerationSeed(5), + VariationControl::FULL, + ) + .expect("varied ok"); + assert_eq!( + note_pitches(&varied.complement.score, varied.complement.part_b_index), + note_pitches(&plain.score, plain.part_b_index), + "VariationControl::FULL must be the identity window", + ); +} + +/// Zero spread pins B to the band's anchor degree — one distinct pitch, no +/// matter the seed — while keeping A's onset grid (pitch only, never onsets). +#[test] +fn variation_zero_spread_collapses_b_to_a_single_pitch() { + let score = wide_part_a(); + let varied = arrange_complement_varied( + &score, + 0, + RHYTHM_LOCK, + GenerationSeed(5), + VariationControl::LOCKED, + ) + .expect("varied ok"); + + let mut distinct = note_pitches(&varied.complement.score, varied.complement.part_b_index); + distinct.sort_unstable(); + distinct.dedup(); + assert_eq!( + distinct.len(), + 1, + "zero spread pins B to a single anchor degree, got {distinct:?}", + ); + // Onsets stay locked to A's grid — variation shapes pitch, never rhythm. + let mut b_onsets = note_onsets(&varied.complement.score, varied.complement.part_b_index); + let mut a_onsets = note_onsets(&score, 0); + b_onsets.sort_unstable(); + a_onsets.sort_unstable(); + assert_eq!( + b_onsets, a_onsets, + "variation must not move onsets off A's grid", + ); +} + +/// Deterministic for a fixed `(spec, seed, control)` — SPEC §6. +#[test] +fn variation_is_deterministic_for_fixed_seed_and_control() { + let score = wide_part_a(); + let control = VariationControl { pitch_spread: 0.5 }; + let a = arrange_complement_varied(&score, 0, RHYTHM_LOCK, GenerationSeed(7), control) + .expect("varied ok"); + let b = arrange_complement_varied(&score, 0, RHYTHM_LOCK, GenerationSeed(7), control) + .expect("varied ok"); + assert_eq!( + note_pitches(&a.complement.score, a.complement.part_b_index), + note_pitches(&b.complement.score, b.complement.part_b_index), + ); +} + +/// An out-of-range spread is a typed error, not a silent clamp. +#[test] +fn variation_rejects_out_of_range_control() { + let score = wide_part_a(); + for bad in [1.5_f64, -0.1, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let control = VariationControl { pitch_spread: bad }; + assert!( + matches!( + arrange_complement_varied(&score, 0, RHYTHM_LOCK, GenerationSeed(1), control), + Err(VariationError::InvalidControl) + ), + "spread {bad} must be rejected as InvalidControl", + ); + } +} + +/// The candidate carries the control that asked (ask) and B's realised pitch +/// spread (is) — the GesturedCandidate duality. +#[test] +fn variation_provenance_carries_control_and_realized_spread() { + let score = wide_part_a(); + let full = arrange_complement_varied( + &score, + 0, + RHYTHM_LOCK, + GenerationSeed(5), + VariationControl::FULL, + ) + .expect("varied ok"); + assert_eq!(full.control, VariationControl::FULL, "ask is echoed back"); + assert!( + full.realized_spread > 0.0, + "a full-spread B over a wide band realises a non-zero ambitus, got {}", + full.realized_spread, + ); + + let locked = arrange_complement_varied( + &score, + 0, + RHYTHM_LOCK, + GenerationSeed(5), + VariationControl::LOCKED, + ) + .expect("varied ok"); + assert_eq!( + locked.realized_spread, 0.0, + "a single-pitch B realises zero spread", + ); +} + #[test] fn rhythm_lock_axis_scores_report_locked_rhythm() { let score = score_with_part_a(2, &[60, 62, 64, 65]); diff --git a/docs/adr/0023-variation-control-for-complement.md b/docs/adr/0023-variation-control-for-complement.md new file mode 100644 index 00000000..0040d0cb --- /dev/null +++ b/docs/adr/0023-variation-control-for-complement.md @@ -0,0 +1,96 @@ +# ADR 0023: Control pitch/contour spread of complementary parts + +Date: 2026-06-16 +Status: Proposed + +## Context + +In the grid-locked complement modes (`rhythm_lock`, `register_contrast`, +`call_response`) part B's pitch is chosen by hashing each onset's position into +the band's scale ladder (`pitch_index(seed, i, ladder.len())`). After ADR-0012 +and the bottom-octave fix the ladder spans A's whole register band, but the +choice is *all-or-nothing*: a uniform draw over the entire band, with no control +over **how far B wanders** between a static line and a register-wide one. Users +asking for a "complementary guitar" want exactly that dial — "how much does B +depart from a static line / A's contour". + +Three constraints frame the decision: + +- **Determinism is a hard rule.** SPEC §6: *generation is deterministic under a + fixed seed*; the glossary defines a deterministic generator as + `same seed/input → same result`. Any knob must change the deterministic output, + never introduce nondeterminism. +- **S6 already owns "controlled variability".** Its strategies (constrained + random walk with leap/repeat penalties, motif-transpose + variation) are seeded + and style-bounded (density within corpus mean ± 1σ). Variation is not foreign to + the generator; it is the generator's native idea, exposed as a parameter. +- **Grid-locked means onset-locked.** The grid modes deliberately do **not** + route through an S6 round-trip — "A's onsets already respect A's timeline; + regeneration could only misalign" (`decisions.log`). `rhythm_lock`'s acceptance + criterion is `B's onsets match A's onset grid` and `rhythm_similarity == 1.0`. + A variability knob that drifts B *off the grid* would break the mode's contract. + +The repository already has the pattern for such a knob: the spec / fact / +provenance split of ADR-0012, carried by `GestureControl` / `StructureControl` +(ADR-0015) — a typed *ask* compiled over the S6 generator, run deterministically, +returning the produced *is* as provenance. + +## Decision + +We add **`VariationControl`** — the pitch-variability *ask*, orthogonal to +`RelationMode`, a sibling of `GestureControl` and `StructureControl`. + +1. **One axis to start: `pitch_spread ∈ [0.0, 1.0]`.** The fraction of the band's + scale ladder B may use. `0.0` pins every note to the band's anchor degree (a + static line, still locked to A's grid); `1.0` uses the whole band — the + unconstrained default, i.e. exactly today's `arrange_complement`. + +2. **Pitch only, never onsets.** The knob narrows the modulo of the existing + seeded pitch hash; it does not move a single onset. Grid-locked modes stay + grid-locked — `rhythm_similarity` stays `1.0`. It applies to the + ladder-substitution modes (`rhythm_lock`, `register_contrast`, + `call_response`). `octave_double` and `support_layer` have no pitch + degree-of-freedom to spread; `counter_melody`'s variability is S6's own + (mapping `pitch_spread` onto the walk's leap penalty is a future axis). + +3. **A separate entry point, not a `ComplementSpec` change.** + `arrange_complement_varied(score, idx, spec, seed, control) -> VariedComplement`. + `ComplementSpec` has many construction sites; extending it would break every + caller for a concern that composes cleanly on top — the `generate_gestured` + precedent (a separate entry, not an extra request field). `arrange_complement` + stays and is exactly `arrange_complement_varied(.., VariationControl::FULL)`. + +4. **Deterministic (SPEC §6).** The window only shrinks the range of the seeded + hash; the same `(score, idx, spec, seed, control)` always yields the same B. + No new RNG is introduced. + +5. **Provenance is ask-vs-is.** `VariedComplement { complement, control, + realized_spread }` carries the control that asked and B's realized pitch + ambitus as a fraction of the target band — the `GesturedCandidate` duality. + An out-of-range control is the typed `VariationError::InvalidControl`, never a + silent clamp. + +6. **Future axes live on the same struct.** Contour adherence (track A's + up/down motion), a leap budget, and the `counter_melody` walk-penalty mapping + are added as further `VariationControl` fields. **Onset drift is explicitly + not a `VariationControl` axis** — rhythmic independence already has a home in + `counter_melody` (and any future blended mode), not in a knob bolted onto a + grid-locked mode. + +## Consequences + +- griff gains a "how static ↔ how wandering" dial for complement pitch, without + violating determinism or any mode's grid-lock contract. +- The default path is **byte-identical** (`pitch_spread = 1.0` is the identity + window), so the corpus schema, goldens, and CLI snapshots are unaffected; the + feature is purely additive. +- Composes with `GestureControl` / `StructureControl` — the axes are orthogonal, + as ADR-0015 anticipated ("a complement part can carry its own control"). +- Accepted: `realized_spread` is a coarse ambitus-over-band ratio, not a + per-degree histogram; the window anchors at the band floor (small spread hugs + the low register) rather than centring on A's contour — both are refined by the + future axes above. +- Accepted: `counter_melody` is not wired to the knob in this increment; its + variability stays S6's until the walk-penalty mapping lands. +- A CLI `--variation <0..1>` surface is the natural follow-up; it defaults to + `1.0`, so existing snapshots continue to hold. diff --git a/docs/adr/README.md b/docs/adr/README.md index 21079b83..02019720 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ immutable; supersede it with a new one. New ADRs: copy | [0020](0020-gp-import-validation-harness.md) | Validate Guitar Pro import against a reference oracle | Proposed | | [0021](0021-property-invariants-over-canonical-score.md) | Property-based invariants over the canonical Score | Proposed | | [0022](0022-repeat-unfolding-as-projection.md) | Repeat unfolding is a projection, not a model rewrite | Proposed | +| [0023](0023-variation-control-for-complement.md) | Control pitch/contour spread of complementary parts | Proposed | See also: [`../SPEC.md`](../SPEC.md), [`../glossary.md`](../glossary.md), [`../decisions.log.md`](../decisions.log.md).