diff --git a/core/src/novelty.rs b/core/src/novelty.rs index 3be0949b..07331d9f 100644 --- a/core/src/novelty.rs +++ b/core/src/novelty.rs @@ -30,7 +30,7 @@ use std::collections::BTreeSet; -use crate::score::{AtomEvent, Score, Track}; +use crate::score::{AtomEvent, LossReport, Score, Track}; use crate::scoring::{Axes, Axis, WeightPolicy}; const AXIS_QUOTE_NOVELTY: &str = "quote_novelty"; @@ -185,6 +185,83 @@ pub fn novelty_weights_v1() -> WeightPolicy { WeightPolicy::uniform("novelty", 1, &NOVELTY_AXIS_LABELS) } +/// Default minimum verbatim-quote share for flagging a phrase as a near- +/// duplicate of an earlier one (#76). +/// +/// A chorus/verse repeat quotes almost all of an earlier phrase; a distinct +/// phrase shares at most a short motif, so a high bar keeps false positives low. +pub const PHRASE_DUPLICATE_SHARE: f64 = 0.8; + +/// A phrase flagged as a near-duplicate of an earlier one (#76). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PhraseDuplicate { + /// Index, within the phrase list, of the earlier phrase it most closely quotes. + pub of: usize, + /// Share of this phrase's melodic line covered by that verbatim quote, in + /// `[0, 1]` — `1.0` is an exact (possibly transposed) repeat. + pub quote_share: f64, +} + +/// Flags each phrase that near-duplicates an *earlier* one in `phrases` (#76). +/// +/// For phrase *i*, compares its `track_index` line against phrases `0..i` with +/// [`measure_novelty`] (transposition- and resolution-aware); when the longest +/// verbatim quote covers at least `min_quote_share` of phrase *i*'s notes, it is +/// flagged a near-duplicate of the earlier phrase that quote comes from. The +/// first occurrence of a repeated phrase is canonical (never flagged); only +/// later repeats are. Returns one entry per phrase (`None` = distinct enough). +/// +/// Both sides are reduced to `track_index` first, so a reference resolves to the +/// same musical line even when an earlier track also sounds in that phrase. +/// Curation surfaces the flag; whether to drop the repeat stays the curator's +/// call — the guard measures, the caller decides (ADR-0017 spirit). +#[must_use] +pub fn flag_phrase_duplicates( + phrases: &[Score], + track_index: usize, + min_quote_share: f64, +) -> Vec> { + let lines: Vec = phrases + .iter() + .map(|p| single_track_line(p, track_index)) + .collect(); + lines + .iter() + .enumerate() + .map(|(i, candidate)| { + let earlier = lines.get(..i).unwrap_or(&[]); + match measure_novelty(candidate, 0, earlier) { + Ok(report) if report.candidate_notes > 0 => { + // Reason: note counts are tiny relative to f64 mantissa precision. + #[allow(clippy::cast_precision_loss)] + let share = report.longest_match_notes as f64 / report.candidate_notes as f64; + match report.longest_match_reference { + Some(of) if share >= min_quote_share => { + Some(PhraseDuplicate { of, quote_share: share }) + } + _ => None, + } + } + // Out-of-range / empty track, or no quote: not a flagged duplicate. + _ => None, + } + }) + .collect() +} + +/// A copy of `score` keeping only `track_index` as its sole track, so a novelty +/// comparison reads that one line on both the candidate and the references +/// (master bars are irrelevant to the transition representation). +fn single_track_line(score: &Score, track_index: usize) -> Score { + Score { + ticks_per_quarter: score.ticks_per_quarter, + master_bars: Vec::new(), + tracks: score.tracks.get(track_index).cloned().into_iter().collect(), + source_meta: None, + loss: LossReport::new(), + } +} + /// `(total − taken) / total`, or `1.0` when there is no total. /// /// Computed as a single correctly-rounded division so exact shares (`0.5`, diff --git a/core/tests/novelty.rs b/core/tests/novelty.rs index 1983d52d..2d6ab670 100644 --- a/core/tests/novelty.rs +++ b/core/tests/novelty.rs @@ -32,7 +32,8 @@ use griff_core::{ event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}, novelty::{ - measure_novelty, novelty_axes, novelty_weights_v1, NoveltyError, NOVELTY_AXIS_LABELS, + flag_phrase_duplicates, measure_novelty, novelty_axes, novelty_weights_v1, NoveltyError, + NOVELTY_AXIS_LABELS, }, score::{ AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, @@ -391,3 +392,52 @@ fn fresh_material_outranks_a_copy() { assert_eq!(order[0], 1, "the fresh candidate ranks first"); assert_eq!(scored[1].provenance.policy_id, "novelty"); } + +// ── phrase de-duplication (#76) ─────────────────────────────────────────────── + +#[test] +fn flag_phrase_duplicates_marks_a_later_repeat_as_a_duplicate_of_the_first() { + // Phrase 0 = the reference melody, 1 = fresh, 2 = a verbatim repeat of 0. + let phrases = vec![ + build_score(480, vec![track_of(quarters(480, &REF_PITCHES))]), + fresh(), + build_score(480, vec![track_of(quarters(480, &REF_PITCHES))]), + ]; + let flags = flag_phrase_duplicates(&phrases, 0, 0.8); + + assert_eq!(flags.len(), 3); + assert!(flags[0].is_none(), "the first occurrence is canonical"); + assert!(flags[1].is_none(), "fresh material is not a duplicate"); + let dup = flags[2].expect("the repeat is flagged"); + assert_eq!(dup.of, 0, "flagged as a duplicate of the first phrase"); + assert!(dup.quote_share >= 0.99, "a verbatim repeat is ~100% quote"); +} + +#[test] +fn flag_phrase_duplicates_leaves_distinct_phrases_unflagged() { + let phrases = vec![ + build_score(480, vec![track_of(quarters(480, &REF_PITCHES))]), + fresh(), + ]; + assert!(flag_phrase_duplicates(&phrases, 0, 0.8) + .iter() + .all(Option::is_none)); +} + +#[test] +fn flag_phrase_duplicates_compares_the_detected_track_on_both_sides() { + // The detected track is index 1; track 0 is a *different* decoy in each + // phrase, so comparing track 0 by mistake would not flag — only the repeated + // track-1 melody should. This guards the single-track reduction: a wrong-track + // comparison sees mismatched decoys and fails to flag. + let decoy_a = track_of(quarters(480, &[40, 41, 42, 43])); + let decoy_b = track_of(quarters(480, &[40, 45, 41, 47])); + let phrases = vec![ + build_score(480, vec![decoy_a, track_of(quarters(480, &REF_PITCHES))]), + build_score(480, vec![decoy_b, track_of(quarters(480, &REF_PITCHES))]), + ]; + let flags = flag_phrase_duplicates(&phrases, 1, 0.8); + let dup = flags[1].expect("the track-1 repeat is flagged despite a different track 0"); + assert_eq!(dup.of, 0); + assert!(dup.quote_share >= 0.99, "the track-1 line is a verbatim repeat"); +} diff --git a/web/src/lib.rs b/web/src/lib.rs index e5e720bb..0c364e70 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -30,6 +30,7 @@ use griff_core::complement::{ use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; use griff_core::generate::GenerationSeed; use griff_core::import::import_score_auto; +use griff_core::novelty::{flag_phrase_duplicates, PHRASE_DUPLICATE_SHARE}; use griff_core::score::{ AtomEvent, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, Track, Voice, }; @@ -706,6 +707,10 @@ fn split_segments_to_json( }) .collect(); + // Flag near-duplicate phrases (later repeats of an earlier one) for review (#76). + let phrase_scores: Vec = kept.iter().map(|(_, _, sub)| sub.clone()).collect(); + let dups = flag_phrase_duplicates(&phrase_scores, track_index, PHRASE_DUPLICATE_SHARE); + let mut json = String::from("{\"error\":null,\"chunks\":["); for (phrase, (start, end, sub)) in kept.iter().enumerate() { let pid = format!("{id}_p{phrase}"); @@ -755,6 +760,18 @@ fn split_segments_to_json( } else { json.push_str("[]"); } + // Near-duplicate flag (#76): which earlier phrase this one quotes and by + // how much, or null when it is distinct. + match dups.get(phrase).and_then(|d| d.as_ref()) { + Some(d) => { + let _ = write!( + json, + ",\"duplicate_of\":{},\"duplicate_share\":{:.2}", + d.of, d.quote_share + ); + } + None => json.push_str(",\"duplicate_of\":null,\"duplicate_share\":null"), + } let _ = write!(json, ",\"chunk\":\"{}\"}}", json_escape(&pretty)); } json.push_str("]}"); diff --git a/web/static/app.js b/web/static/app.js index 68a15991..4e5c7844 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -429,7 +429,10 @@ function renderPhrase() { els.splitInfo.textContent = cmp ? `A=phrase ${splitIdx + 1} (${tracks[0].notes.length}n, blue) vs ` + `B=phrase ${compareIdx + 1} (${tracks[1].notes.length}n, amber)` - : `${ch.id} · bars ${ch.bar_lo}–${ch.bar_hi} · ${(ch.notes || []).length} notes`; + : `${ch.id} · bars ${ch.bar_lo}–${ch.bar_hi} · ${(ch.notes || []).length} notes` + + (ch.duplicate_of != null + ? ` · ≈ phrase ${ch.duplicate_of + 1} (${Math.round(ch.duplicate_share * 100)}% quote)` + : ''); renderPhraseTags(tags); els.splitPrev.disabled = splitIdx === 0; els.splitNext.disabled = splitIdx === splitChunks.length - 1;