Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 78 additions & 1 deletion core/src/novelty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Option<PhraseDuplicate>> {
let lines: Vec<Score> = 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`,
Expand Down
52 changes: 51 additions & 1 deletion core/tests/novelty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
17 changes: 17 additions & 0 deletions web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<Score> = 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}");
Expand Down Expand Up @@ -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("]}");
Expand Down
5 changes: 4 additions & 1 deletion web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading