"""Duration-normalized musical evaluation, independent of search/notation chunks.""" from __future__ import annotations import fractions import dataclasses import generator.solo.policy import generator.solo.types def passages(plan): if plan.passages: return plan.passages end = plan.end - plan.release active = (generator.solo.types.ActivitySpan(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else () return active + ((generator.solo.types.ActivitySpan(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ()) def intervals(choices, start, end): return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start) def overlap(start, end, intervals): return sum((max(0, min(end, b) - max(start, a)) for a, b in intervals), fractions.Fraction()) def corridor(pitch, low, high): return max(low - pitch, 0, pitch - high) def motif_fidelity(notes, motif, treatment): if treatment in ('quote', 'exact_quote'): return 0.0 if treatment == 'rhythmic_reference': return 1.0 if len(notes) < min(2, len(motif.notes)) else 0.0 actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:])) expected = motif.contour() if not actual or not expected: return 0.0 if len(notes) == len(motif.notes) == 1 else 1.0 count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual) errors = [] for index in range(count): reference = expected[min(len(expected) - 1, index * len(expected) // max(1, len(actual)))] motion = actual[index] direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0)) errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12)) evidence = min(2, len(expected)) return max(sum(errors) / len(errors), max(0, evidence - len(actual)) / evidence) class Evaluation: """Prepare fixed harmonic windows once; only soft penalties use float time.""" def __init__(self, plan, choices, until=None): self.plan = plan self.until = plan.end if until is None else until self.duration = max(float(self.until - plan.start), 0.001) self.windows = [] self.required = [] for passage in passages(plan): end = min(passage.end, self.until) if end <= passage.start: continue active = intervals(choices, passage.start, end) length = float(sum((b - a for a, b in active), fractions.Fraction())) if length: self.windows.append((float(passage.start), float(end), tuple((float(a), float(b)) for a, b in active), length, float(passage.activity) * length, float(passage.occupancy), 0.5 if self.until < passage.end else 0)) if passage.end <= self.until: protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference') and passage.end <= plan.start + plan.motif.duration authored_silence = protected and not any(overlap(plan.start + n.start, plan.start + n.end, active) for n in plan.motif.notes) self.required.append((passage, active, authored_silence)) self.landmarks = tuple((float(p.earliest), float(p.latest), p.low, p.high, p.direction) for p in plan.landmarks if p.latest <= self.until) def feasible(self, notes): # Hard silence/space requirements retain exact rational boundaries. for passage, active, authored_silence in self.required: if passage.intent == 'space': if any(n.start < passage.end and n.end > passage.start for n in notes): return False elif active and not authored_silence and not any(n.start < b and n.end > a for n in notes for a, b in active): return False return True def features(self, notes, *, previous=None, movement=0): notes = generator.solo.types.sounding_notes(notes) sounded = tuple((float(n.start), float(n.end), n.pitch.number()) for n in notes) activity, occupancy = 0.0, 0.0 for start, end, active, length, expected, occupied_goal, tolerance in self.windows: attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded) occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active) deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration)) activity += length * min(1, deviation / max(1, expected)) occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband) if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'): # One broad range over the complete thought; quiet bars are not failed # local quotas. Missing harmony and explicit release are excluded here. active = tuple(window for window in self.windows if window[4] > 0) length = sum(w[3] for w in active) attacks = sum(any(a <= onset < b for w in active for a, b in w[2]) for onset, _, _ in sounded) occupied = sum(max(0, min(end, b) - max(start, a)) for start, end, _ in sounded for w in active for a, b in w[2]) low, high = generator.solo.policy.DEFAULT.lead_activity_range activity = length * corridor(attacks / max(length, .001), low, high) / high low, high = generator.solo.policy.DEFAULT.lead_occupancy_range occupancy = length * corridor(occupied / max(length, .001), low, high) if self.plan.treatment in ('source_variation', 'thematic_recomposition'): occupancy = 0.0 # Space belongs to the source and its explicit edits. start, end = float(self.plan.start), float(self.until) durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded] register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001) motions = [b[2] - a[2] for a, b in zip(sounded, sounded[1:])] contour = sum(min(1, max(0, abs(m) - 5) / 12) for m in motions) / max(1, len(motions)) repetition = sum(a == b == 0 for a, b in zip(motions, motions[1:])) / max(1, len(motions) - 1) landmarks = [] for earliest, latest, low, high, direction in self.landmarks: prior = next((n for n in reversed(sounded) if n[0] < earliest), None) landmarks.append(min((0.75 * min(1, corridor(n[2], low, high) / 12) + 0.25 * bool(prior and direction and (n[2] - prior[2]) * direction <= 0) for n in sounded if earliest <= n[0] <= latest), default=1)) motif_notes = tuple(n for n in notes if n.role in ('motif', 'quote')) connection = min(1, abs(sounded[0][2] - previous.pitch.number()) / 12) if sounded and previous else 0 return {'activity': activity / self.duration, 'occupancy': occupancy / self.duration, 'register': register, 'contour': contour, 'repetition': repetition, 'landmarks': sum(landmarks) / max(1, len(landmarks)), 'connection': connection, 'movement': min(1, movement / max(1, len(notes)) / 2000), 'fidelity': motif_fidelity(motif_notes, self.plan.motif, self.plan.treatment) if motif_notes else 0} def features(plan, notes, choices, *, until=None, previous=None, movement=0): return Evaluation(plan, choices, until).features(notes, previous=previous, movement=movement) def value(features): return sum(weight * features.get(name, 0) for name, weight in generator.solo.policy.DEFAULT.weights) def feasible(plan, notes, choices, until): return Evaluation(plan, choices, until).feasible(notes) @dataclasses.dataclass(frozen=True) class MusicalEvaluation: version: str scope: tuple valid: bool pending: tuple raw: dict denominators: dict weights: dict contributions: dict total: float error: str = '' MUSICAL_WEIGHTS = {'rhythm':3., 'destination':2., 'harmony':2., 'register':1., 'repetition':1., 'activity':1., 'difficulty':.5} def musical(plan, draft, notes, incoming, harmony, *, travel=0, weights=None, bar=None): """The sole final evaluator of the active thematic composer. Scores are normalized on complete sounding events, never notation fragments. Contracts are validity requirements, not additional similarity rewards. Retrieval estimates are deliberately not added to this result. """ from generator.solo import drafts, groups, harmonic, phrasing, types notes=types.sounding_notes(notes) bar=getattr(harmony,'bar',fractions.Fraction(4)) if bar is None else bar from generator import vocabulary weights=dict(vocabulary.data('grammar').get('evaluation',{}).get('weights',MUSICAL_WEIGHTS) if weights is None else weights) if set(weights)!=set(MUSICAL_WEIGHTS):raise ValueError('unknown or missing evaluation contribution') checked=harmony.evaluate(notes, incoming.treatments, until=plan.end, complete=True, enclosures=draft.enclosures, expressions=draft.expressions) identity,identity_error=drafts.contract_valid(draft,notes) anchors,anchor_error=harmonic.anchors_valid(draft.anchors,notes,plan.end,complete=True,closure=plan.closure) feasible=Evaluation(plan,harmony.choices).feasible(notes) valid=checked.valid and identity and anchors and feasible error=checked.error or identity_error or anchor_error or ('' if feasible else 'activity intent not fulfilled') span=float(plan.end-plan.start); sounded=sum(float(n.end-n.start) for n in notes) islands=sum(a.end