Soloing & Measures
ci / checks (push) Successful in 1h16m41s

This commit is contained in:
khodowany
2026-10-01 14:05:51 -05:00
parent a7b64c5361
commit 28fc67fba1
21 changed files with 1231 additions and 168 deletions
+410 -100
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import dataclasses
import fractions
import functools
import time
import generator.solo.drafts
import generator.fretboard
import generator.model
@@ -348,121 +351,428 @@ def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=N
class SearchContext:
pools: dict = dataclasses.field(default_factory=dict)
results: dict = dataclasses.field(default_factory=dict)
resources: dict = dataclasses.field(default_factory=dict)
revise: bool = True
batch: int = 0
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
def note_family(notes):
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
@dataclasses.dataclass(frozen=True)
class BeamState:
score: float
decisions: tuple
music: generator.solo.types.ContinuationState
travel: int = 0
fidelity: tuple[float, ...] = ()
def phrase_context(plan, incoming, notes):
return generator.solo.drafts.canonical_context(notes, incoming, plan)
def evaluation_plan(plan, anchors):
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
return dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end, landmarks=landmarks)
def inherited_treatments(plan, incoming):
if plan.passage_id and plan.passage_id == incoming.passage_id:
return incoming.treatments
retained = []
for treatment in incoming.treatments:
future = treatment.start >= plan.start
color = treatment.kind == 'color' and incoming.last and treatment.end > incoming.last.start
pending = treatment.kind == 'suspension' and incoming.last and incoming.last.pitch.number() == treatment.pitch and incoming.last.end > treatment.start and not any(n.start >= treatment.start and n.pitch.number() == treatment.resolution for n in incoming.notes)
if future or color or pending:
retained.append(treatment)
# Completed contracts remain in their original phrase reports; only the
# sounding tail and unresolved obligations require context in a new passage.
return tuple(retained)
def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context):
"""Instantiate a complete timed line. Boundaries here are musical groups."""
F = fractions.Fraction
contracts = tuple(dict.fromkeys((*inherited_treatments(plan, incoming), *plan.treatments, *anchor_treatments(rhythm.anchors, harmony))))
# Cost, actual pitches so far, and the identity of the introduced statement.
states = [(0.0, (), rhythm.reference.notes if isinstance(rhythm.reference, generator.solo.types.RealizedMotif) else ())]
next_anchor = [next((a for a in rhythm.anchors if a.latest >= s.start), None) for s in rhythm.slots]
for index, slot in enumerate(rhythm.slots):
available = options(choices, slot.start, slot.end, plan.register)
pending = next((t for t in contracts if t.kind == 'suspension' and t.deadline is not None and incoming.last and incoming.last.pitch.number() == t.pitch and incoming.last.end == slot.start and t.start <= slot.start <= t.deadline and not any(n.start >= t.start and n.pitch.number() == t.resolution for n in incoming.notes)), None)
if pending:
available = tuple(p for p in available if p.number() == pending.resolution)
if slot.forced:
available = tuple(p for p in available if p == slot.forced)
quoted = slot.protected and plan.treatment in ('quote', 'exact_quote')
if not quoted and slot.end - slot.start >= generator.solo.policy.DEFAULT.long_note:
members = harmony.members(slot.start, slot.end)
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= slot.start and t.end >= slot.end for t in contracts))
if not available:
return (), f'{slot.id}: no valid pitch for sounding interval {slot.start}-{slot.end}'
following = []
group = rhythm.groups[slot.group]
local_index = sum(s.group == slot.group for s in rhythm.slots[:index])
remaining = sum(s.start >= slot.start and (not next_anchor[index] or s.start <= next_anchor[index].latest) for s in rhythm.slots[index:])
span = harmony.at(slot.start)
for cost, notes, home in states:
prior = notes[-1] if notes else incoming.last
reference = home if home and slot.group else rhythm.reference.notes
if local_index >= 3 and group.operation in ('extend', 'extend_diminished', 'answer', 'continue'):
previous_ids = {s.id for s in rhythm.slots if s.group == slot.group - 1}
recent = tuple(n for n in notes if n.event_id in previous_ids) if slot.group else incoming.development.notes if incoming.development else ()
reference = recent or reference
contour = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(reference, reference[1:]))
step = contour[(max(0, slot.reference - 1)) % len(contour)] if contour else 0
if slot.reference == 0 and local_index == 0:
# A group continues the incoming line; it does not teleport to the
# source motif's register or to the center of a fresh bar.
step = contour[-1] if contour and group.operation == 'extend' else 0
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
anchor = next_anchor[index]
target = anchor.pitch.number() if anchor else expected
route = expected
if prior and anchor and span and span.scale:
try:
start_degree = generator.solo.types.scale_index(span.scale, prior.pitch.number())
target_degree = generator.solo.types.scale_index(span.scale, target)
displacement = target_degree - start_degree
count = min(16, max(2, remaining + 1))
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
if paths:
route = generator.solo.types.scale_pitch(span.scale, start_degree + paths[0].steps[0]).number()
except ValueError:
# Collection changes preserve the incoming sound, never its old index.
route = prior.pitch.number() + ((target > prior.pitch.number()) - (target < prior.pitch.number())) * 2
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
if available:
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
if bridge not in ranked:
ranked.append(bridge)
for pitch in ranked:
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
intended = (step > 0) - (step < 0)
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
if identifying and reference and direction != intended:
continue
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
score = cost + motion_cost + direction_cost + max(0, jump - 5) / 12 + exposure
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
path = (*notes, note)
identity = home
if not identity and (index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group):
identity = tuple(n for n in path if n.event_id in {s.id for s in rhythm.slots if s.group == slot.group})
following.append((score, path, identity))
if not following:
return (), f'{group.id}: identifying motion cannot reach the selected anchor within the draft budget'
boundary = index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group
limit = generator.solo.policy.DEFAULT.draft_frontier if boundary else BEAM_WIDTH
# Relevant home/contour alternatives survive; no barline participates.
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (tuple(n.pitch for n in item[2][:3]), item[1][-1].pitch), limit))
context.diagnostics['evaluated_extensions'] += len(following)
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
return tuple((notes, cost / max(1, len(notes)), contracts) for cost, notes, _ in states[:4]), ''
def make_identity(identifier, parent, notes, start, end, relationship, harmony):
local = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start, position=None) for n in notes)
degrees = tuple(dict(harmony.at(n.start).roles.degrees).get(n.pitch.number() % 12, ()) for n in notes)
return generator.solo.types.RealizedMotif(identifier, parent, local, end - start, relationship, 'developed_rhythm', degrees, start)
def make_draft(rhythm, notes, plan, harmony, incoming):
transforms, structural, protected = [], [], []
home = incoming.home if rhythm.relationship not in ('statement', 'contrast') else None
if rhythm.relationship == 'recall' and isinstance(rhythm.reference, generator.solo.types.RealizedMotif):
home = rhythm.reference
parent = incoming.development.id if incoming.development and home else rhythm.reference.id
for index, group in enumerate(rhythm.groups):
ids = {s.id for s in rhythm.slots if s.group == index}
actual = tuple(n for n in notes if n.event_id in ids)
if not actual:
continue
next_start = rhythm.groups[index + 1].start if index + 1 < len(rhythm.groups) else group.end
start, end = group.start, max(min(group.end, next_start), actual[-1].end)
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
if home is None:
home = identity
structural.extend(n.event_id for n in actual[:3])
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
# Closing simplification explicitly releases the characteristic core.
preserves = () if group.operation == 'close' else ('rhythm', 'opening_contour')
if plan.treatment == 'rhythmic_reference':
preserves = ('rhythm',)
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
parent = identity.id
protected.extend(s.id for s in rhythm.slots if s.protected)
for anchor in rhythm.anchors:
note = generator.solo.harmonic.anchor_note(anchor, notes)
if note:
structural.append(note.event_id)
joins = tuple(t.start for t in transforms[1:])
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins)
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
if not valid:
return None
placed = generator.solo.placement.place(notes, tempo, incoming.last)
if placed is None:
return None
travel, notes = placed
try:
canonical = phrase_context(plan, incoming, notes)
except ValueError:
return None
complete = plan.end == (plan.passage_end or plan.end)
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete)
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
if not checked.valid or not valid or not target_ok(notes, plan.target):
return None
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
evaluation = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
if not evaluation.feasible(score_notes):
return None
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
features.update(generator.solo.drafts.relationship_features(draft, notes))
# Fidelity is checked against explicit transformed identities, not against
# the source head as one undifferentiated phrase-long quotation.
features['fidelity'] = features['development']
features['harmonic_exposure'] = checked.exposure
intended_repeats = 0
if draft.home:
reference = draft.home.notes
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events)
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
intended_repeats += 1
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
score = generator.solo.scoring.value(features)
motifs = list(incoming.motifs) if shared else []
decisions = []
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events or transform.start <= n.start < transform.end and n.event_id not in {e for t in draft.transformations for e in t.events})
if not actual:
continue
identity = make_identity(transform.id, transform.parent, actual, transform.start, transform.end, transform.operation, harmony)
motifs.append(identity)
treatment = f'quote octave {round((actual[0].pitch.number() - plan.motif.notes[0].pitch.number()) / 12):+}' if actual[0].role == 'quote' else transform.operation
decisions.append(generator.solo.types.Decision(min(n.start for n in actual), max(n.end for n in actual), 'phrase draft ' + draft.id + '; ' + transform.operation, treatment, actual, 0, transform.id, transform.parent))
# Output ordering follows absolute events, even when a transformation's tail
# was reopened by an overlapping extension.
decisions.sort(key=lambda d: d.start)
emitted = tuple(n for d in decisions for n in d.notes)
if emitted != notes:
return None
home = next((m for m in motifs if draft.home and m.id == draft.home.id), draft.home)
themes = tuple(dict.fromkeys((*incoming.themes, *((home,) if home else ()))))
state_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
last = state_notes[-1] if state_notes else incoming.last
state = generator.solo.types.ContinuationState(plan.passage_id, state_notes, tuple(motifs), contracts,
tuple(a.id for a in draft.anchors if generator.solo.harmonic.anchor_note(a, canonical)),
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits)
def legacy_target_plan(plan, choices):
if not plan.target or plan.anchor_options:
return plan
target = plan.target
duration = fractions.Fraction(1, 2)
arrival = next((p for p in plan.passages if p.start <= target.earliest < p.end and p.intent == 'sustained_arrival'), None)
if arrival and arrival.activity <= fractions.Fraction(1, 4):
duration = arrival.end - target.earliest
pitches = options(choices, target.earliest, target.earliest + duration, plan.register)
anchors = tuple((generator.solo.types.Anchor(plan.id + '_target', target.earliest, target.latest, pitch, target.pitches, plan.register, duration, 'terminal', target.chord, True, target.earliest + duration),) for pitch in pitches if pitch.number() % 12 in target.pitches)
return dataclasses.replace(plan, anchor_options=anchors)
def emitted_notes(phrase):
return tuple(n for d in phrase.decisions for n in d.notes)
def window_variants(phrase, low, high, harmony, sequences, kind):
"""Two fixed outside endpoints bound edits to the existing melody."""
notes = emitted_notes(phrase)
protected = set((*phrase.draft.structural, *phrase.draft.protected))
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
editable = [i for i in inside if notes[i].event_id not in protected]
if not editable:
return ()
candidates = []
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
# Route both sides of a join as one path, with the original outside pitches.
if 1 < right - left < 16:
scale = harmony.at(notes[left].start).scale
try:
first = generator.solo.types.scale_index(scale, notes[left].pitch.number())
displacement = generator.solo.types.scale_index(scale, notes[right].pitch.number()) - first
paths = sequences.retrieve(right - left + 1, displacement, (displacement > 0) - (displacement < 0))
except ValueError:
paths = ()
for path in paths[:generator.solo.policy.DEFAULT.join_alternatives]:
updated = list(notes)
for i in editable:
offset = path.offsets()[i - left]
intended = generator.solo.types.scale_pitch(scale, first + offset).number()
available = options(harmony.choices, notes[i].start, notes[i].end, phrase.plan.register)
if available:
pitch = min(available, key=lambda p: (abs(p.number() - intended), p))
updated[i] = dataclasses.replace(notes[i], pitch=pitch, position=None)
candidates.append((tuple(updated), 'sequence ' + ' + '.join(path.sources)))
for index in editable:
note = notes[index]
if index + 1 < len(notes) and notes[index + 1].start <= high:
after = notes[index + 1]
if after.event_id not in protected and note.end == after.start and note.pitch == after.pitch:
candidates.append(((*notes[:index], dataclasses.replace(note, end=after.end), *notes[index + 2:]), 'shared common-tone arrival'))
if kind == 'join' and after.event_id not in protected and note.end == after.start and note.end - note.start > fractions.Fraction(1, 2):
# The tail and pickup move together, preserving total sounding time.
time = after.start - fractions.Fraction(1, 2)
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
if kind == 'elaboration' and index + 1 < len(notes) and fractions.Fraction(1) <= note.end - note.start < 2:
# Add motion only as an alternative evaluated against the unchanged
# activity envelope; keeping the original draft is always available.
split = note.end - fractions.Fraction(1, 2)
scale = harmony.at(split).scale
target = notes[index + 1].pitch
try:
degree = generator.solo.types.scale_index(scale, note.pitch.number())
finish = generator.solo.types.scale_index(scale, target.number())
pitch = generator.solo.types.scale_pitch(scale, degree + ((finish > degree) - (finish < degree)))
addition = generator.solo.types.Note(split, note.end, pitch, note.sources, 'elaboration', event_id=note.event_id + '_passing')
candidates.append(((*notes[:index], dataclasses.replace(note, end=split), addition, *notes[index + 1:]), 'interpolate toward next structural event'))
except ValueError:
pass
return tuple(candidates)
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
original = emitted_notes(phrase)
viable = [phrase]
proposals = window_variants(phrase, low, high, harmony, sequences, kind)
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
for notes, source in proposals:
if notes == original:
continue
changed_before = tuple(n.event_id for n in original if n not in notes)
changed_after = tuple(n.event_id for n in notes if n not in original)
edit = generator.solo.types.DraftEdit(kind, low, high, changed_before, changed_after, source)
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
if revised:
viable.append(revised)
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
if chosen is not phrase:
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
return chosen
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
started = time.monotonic()
for transform in phrase.draft.transformations:
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, transform.start, transform.end, 'elaboration', context)
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
return phrase
def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
started = time.monotonic()
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
before = phrase
for join in phrase.draft.joins:
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
if phrase == before:
break
context.diagnostics['revision_seconds'] = context.diagnostics.get('revision_seconds', 0) + time.monotonic() - started
return phrase
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
if not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
return first, second
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
harmony = generator.solo.harmonic.Harmony(choices)
seam = first.plan.end
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
combined = (*emitted_notes(first), *emitted_notes(second))
draft = dataclasses.replace(first.draft, end=second.plan.end, notes=(*first.draft.notes, *second.draft.notes), transformations=(*first.draft.transformations, *second.draft.transformations), structural=tuple(dict.fromkeys((*first.draft.structural, *second.draft.structural))), protected=(*first.draft.protected, *second.draft.protected), joins=(*first.draft.joins, seam, *second.draft.joins))
joint = dataclasses.replace(first, decisions=(generator.solo.types.Decision(first.plan.start, second.plan.end, 'provisional phrase pair', 'continue', combined),), draft=draft)
candidates = [(second.score + generator.solo.drafts.relationship_features(draft, combined)['join_coherence'], first, second)]
left_ids = {n.event_id for n in emitted_notes(first)}
for notes, source in window_variants(joint, seam - width, seam + width, harmony, sequences, 'join')[:generator.solo.policy.DEFAULT.join_alternatives]:
left = tuple(n for n in notes if n.event_id in left_ids)
right = tuple(n for n in notes if n.event_id not in left_ids)
if any(n.end > seam for n in left) or any(n.start < seam for n in right):
continue
edit = generator.solo.types.DraftEdit('phrase_join', seam - width, seam + width, tuple(n.event_id for n in combined if n not in notes), tuple(n.event_id for n in notes if n not in combined), source)
if not edit.removed:
continue
revised_left = checked_phrase(first.plan, first.draft, left, first.state.treatments, incoming, harmony, tempo, (*first.edits, edit))
if not revised_left:
continue
revised_right = checked_phrase(second.plan, second.draft, right, second.state.treatments, revised_left.state, harmony, tempo, (*second.edits, edit))
context.diagnostics['pair_revision_attempts'] = context.diagnostics.get('pair_revision_attempts', 0) + 1
if revised_right:
realized = (*emitted_notes(revised_left), *emitted_notes(revised_right))
score = revised_right.score + generator.solo.drafts.relationship_features(draft, realized)['join_coherence']
candidates.append((score, revised_left, revised_right))
_, left, right = min(candidates, key=lambda item: (item[0], len(item[1].edits) + len(item[2].edits)))
if left is not first:
context.diagnostics['pair_revisions_accepted'] = context.diagnostics.get('pair_revisions_accepted', 0) + 1
return left, right
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext()
key = (plan, previous)
# Keep resources alive while identity-keyed cache entries exist. A later
# library cannot accidentally reuse the identity of a collected object.
context.resources[id(sequences)] = sequences
context.resources[id(rhythms)] = rhythms
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms))
key = (*pool_key, context.revise)
if key in context.results:
context.diagnostics['cache_hits'] += 1
return context.results[key]
started = time.monotonic()
context.diagnostics['searches'] += 1
harmony = generator.solo.harmonic.Harmony(choices)
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
initial = incoming if continuing else generator.solo.types.ContinuationState(plan.passage_id, last=incoming.last, treatments=plan.treatments)
backbones = plan.anchor_options or tuple([()])
# A caller explicitly scheduling quotation owns its rhythm and pitches. Such
# plans must provide compatible anchors; no automatic rewrite is permitted.
states = [BeamState(0, (), dataclasses.replace(initial, backbone=i), initial.travel, initial.fidelity) for i in ([initial.backbone] if continuing else range(len(backbones)))]
if plan not in context.pools:
context.pools[plan] = cells(plan, measures, rhythms, choices)
evaluation_plan = dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end)
last_error = ''
for cell in context.pools[plan]:
evaluation = generator.solo.scoring.Evaluation(evaluation_plan, choices, cell.end)
following, pools = [], {}
for state in states:
music = state.music
anchors = backbones[music.backbone]
reference = generator.solo.motifs.reference(plan, cell.gesture, music.motifs)
# Concrete results depend on pitch, fingering, motif, anchor progress,
# rhythm history and unresolved treatments. Abstract rhythms alone cache
# across unrelated incoming lines.
pool_key = (music, anchors)
if pool_key not in pools:
pool = candidates(cell, plan, choices, sequences, music, anchors, harmony)
context.diagnostics['generated_candidates'] += len(pool)
for candidate in pool:
family = note_family(candidate.notes)
counts = context.diagnostics['candidate_families']
counts[family] = counts.get(family, 0) + 1
pools[pool_key] = generator.solo.sequences.diverse(pool, lambda c: (c.rank, c.source), lambda c: (len(c.notes), note_family(c.notes), bool(c.notes and c.notes[0].continuation)), CELL_LIMIT)
for candidate in pools[pool_key]:
context.diagnostics['evaluated_extensions'] += 1
placed = generator.solo.placement.place(candidate.notes, tempo, music.last)
if placed is None:
continue
comfort, notes = placed
canonical = generator.solo.types.sounding_notes((*music.notes, *notes))
completed = cell.end == (plan.passage_end or plan.end)
harmonic = harmony.evaluate(canonical, candidate.treatments, until=cell.end, complete=completed)
valid, error = generator.solo.harmonic.anchors_valid(anchors, canonical, cell.end, complete=completed, closure=plan.closure)
if not harmonic.valid or not valid:
last_error = harmonic.error or error
continue
if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target):
last_error = 'legacy destination was not reached'
continue
motifs, fidelity = music.motifs, state.fidelity
if cell.gesture and cell.end == cell.gesture.end:
valid, mismatch, identity = generator.solo.motifs.complete(cell.gesture, reference, canonical, harmony)
if not valid:
last_error = f'{cell.gesture.id}: insufficient evidence of {cell.gesture.treatment} from {reference.id}'
continue
fidelity += (mismatch,)
if identity:
motifs += (identity,)
if not evaluation.feasible(canonical):
last_error = 'planned activity, sustain or space not fulfilled'
continue
features = evaluation.features(canonical)
features['fidelity'] = (sum(fidelity) + candidate.fidelity) / (len(fidelity) + 1)
features['harmonic_exposure'] = harmonic.exposure
features['connection'] = min(1, abs(canonical[0].pitch.number() - incoming.last.pitch.number()) / 12) if canonical and incoming.last and not continuing else 0
features['movement'] = min(1, (state.travel + comfort) / max(1, len(canonical)) / 2000)
if cell.gesture:
recent = tuple(n for n in canonical if cell.gesture.start <= n.start < cell.gesture.end)
intended_repeats = sum(a.pitch == b.pitch == c.pitch and a.start == x.start - cell.gesture.start and b.start == y.start - cell.gesture.start and c.start == z.start - cell.gesture.start for a, b, c in zip(reference.notes, reference.notes[1:], reference.notes[2:]) for x, y, z in zip(recent, recent[1:], recent[2:]) if x.pitch == y.pitch == z.pitch)
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(canonical) - 2))
total = generator.solo.scoring.value(features)
last = canonical[-1] if canonical else music.last
progress = tuple(a.id for a in anchors if generator.solo.harmonic.anchor_note(a, canonical))
direction = (last.pitch.number() > music.last.pitch.number()) - (last.pitch.number() < music.last.pitch.number()) if last and music.last else music.direction
carried = generator.solo.types.ContinuationState(plan.passage_id, canonical, motifs, candidate.treatments, progress, direction, last, music.backbone, fidelity, state.travel + comfort)
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity, cell.gesture.id if cell.gesture else '', reference.id)
following.append(BeamState(total, (*state.decisions, decision), carried, state.travel + comfort, fidelity))
if not following:
raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; {last_error or "no eligible continuation"}; {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
def rank(state):
return state.score, tuple((n.pitch, n.position, n.start, n.end) for n in state.music.notes)
unique = {}
for state in sorted(following, key=rank):
unique.setdefault(state.music, state)
# A rolling one-gesture window keeps the longer plan bounded. Relevant
# motif/anchor/treatment state survives; a matching last fret is not identity.
limit = generator.solo.policy.DEFAULT.alternatives if cell.gesture and cell.end == cell.gesture.end else BEAM_WIDTH
states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (s.music.backbone, note_family(s.music.notes[-4:]), s.music.last.pitch if s.music.last else None), limit))
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
selected = generator.solo.sequences.diverse(states, lambda s: (s.score, tuple(n.pitch for n in s.music.notes)), lambda s: (s.music.backbone, note_family(s.music.notes[-4:])), generator.solo.policy.DEFAULT.alternatives)
for state in selected:
family = note_family(state.music.notes[-4:])
harmony = generator.solo.harmonic.Harmony(choices)
plan = legacy_target_plan(plan, choices)
if pool_key in context.pools:
selected = context.pools[pool_key]
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
else:
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch)
complete, failure = [], ''
for rhythm in constructions:
paths, error = draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context)
failure = error or failure
context.diagnostics['generated_candidates'] += len(paths)
for notes, cost, contracts in paths:
family = note_family(notes)
counts = context.diagnostics['candidate_families']
counts[family] = counts.get(family, 0) + 1
realizations = [notes]
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
for actual in realizations:
draft = make_draft(rhythm, actual, plan, harmony, incoming)
phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo)
if phrase:
complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost))
if not complete:
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
selected = tuple(elaborate(p, incoming, harmony, sequences, tempo, context) for p in selected)
context.pools[pool_key] = selected
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
result = []
for phrase in selected:
if context.revise:
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
result.append(phrase)
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
for phrase in result:
family = note_family(emitted_notes(phrase))
counts = context.diagnostics['surviving_families']
counts[family] = counts.get(family, 0) + 1
result = tuple(generator.solo.types.Phrase(plan, state.decisions, state.score, state.music, backbones[state.music.backbone]) for state in selected)
context.results[key] = result
return result