use std::collections::{HashMap, HashSet};
use pagesift_stat::Delta;
use crate::block::{Block, jaccard};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Quality {
pub mean: f64,
pub lo: f64,
pub hi: f64,
}
#[derive(Debug, Clone)]
pub struct Candidate {
pub blocks: Vec<Block>,
pub quality: Quality,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum From {
Both,
Left,
Right,
}
#[derive(Debug, Clone)]
pub struct Merged {
pub blocks: Vec<Block>,
pub from: Vec<From>,
}
impl Merged {
pub fn markdown(&self) -> String {
crate::block::join(&self.blocks)
}
}
pub fn merge(left: &Candidate, right: &Candidate, delta: Delta) -> Merged {
let mut ids = HashMap::new();
let mut id = |b: &Block| {
let n = ids.len() as u32;
*ids.entry(b.fingerprint).or_insert(n)
};
let a: Vec<u32> = left.blocks.iter().map(&mut id).collect();
let b: Vec<u32> = right.blocks.iter().map(&mut id).collect();
let anchors = pagesift_diff::align(&a, &b);
let wa: Vec<HashSet<String>> = left.blocks.iter().map(Block::words).collect();
let wb: Vec<HashSet<String>> = right.blocks.iter().map(Block::words).collect();
let tau = same_block_threshold(&anchors, &wa, &wb, delta);
let bytes = |c: &Candidate| c.blocks.iter().map(|b| b.text.len()).sum::<usize>();
let mut budget = bytes(left) + bytes(right);
let mut out = Merged {
blocks: vec![],
from: vec![],
};
let (mut i0, mut j0) = (0, 0);
for &(i, j) in anchors.iter().chain(std::iter::once(&(a.len(), b.len()))) {
let g = Gap {
is: i0..i,
js: j0..j,
tau,
};
gap(left, right, g, &wa, &wb, &mut budget, &mut out);
if i < a.len() {
out.blocks.push(left.blocks[i].clone());
out.from.push(From::Both);
}
(i0, j0) = (i + 1, j + 1);
}
out
}
fn same_block_threshold(
anchors: &[(usize, usize)],
wa: &[HashSet<String>],
wb: &[HashSet<String>],
delta: Delta,
) -> f64 {
let t = anchors.len();
if t < 2 {
return 1.0;
}
let want = (1.0 / delta.get()).ceil() as usize;
let shifts = want.div_ceil(t).min(t - 1);
let mut null: Vec<f64> = (1..=shifts)
.flat_map(|s| (0..t).map(move |k| (anchors[k].0, anchors[(k + s) % t].1)))
.map(|(i, j)| jaccard(&wa[i], &wb[j]))
.collect();
null.sort_by(f64::total_cmp);
let k = ((1.0 - delta.get()) * null.len() as f64).ceil() as usize;
null[k.clamp(1, null.len()) - 1]
}
struct Gap {
is: std::ops::Range<usize>,
js: std::ops::Range<usize>,
tau: f64,
}
fn left_side_wins(left: &Candidate, right: &Candidate, g: &Gap) -> bool {
fn text<'a>(c: &'a Candidate, r: &std::ops::Range<usize>) -> Vec<&'a str> {
c.blocks[r.clone()]
.iter()
.map(|b| b.text.as_str())
.collect()
}
(left.quality.mean, g.is.len(), text(left, &g.is))
> (right.quality.mean, g.js.len(), text(right, &g.js))
}
fn gap(
left: &Candidate,
right: &Candidate,
g: Gap,
wa: &[HashSet<String>],
wb: &[HashSet<String>],
budget: &mut usize,
out: &mut Merged,
) {
let cells = g.is.len().saturating_mul(g.js.len());
if cells > *budget {
let (c, r, from) = if left_side_wins(left, right, &g) {
(left, g.is.clone(), From::Left)
} else {
(right, g.js.clone(), From::Right)
};
for k in r {
if c.blocks[k].template_prob.is_none_or(|p| p < 0.5) {
out.blocks.push(c.blocks[k].clone());
out.from.push(from);
}
}
return;
}
*budget -= cells;
let (i0, j0, i1, j1, tau) = (g.is.start, g.js.start, g.is.end, g.js.end, g.tau);
let pairs = monotone_matching(g.is, g.js, |i, j| {
let s = jaccard(&wa[i], &wb[j]);
(s > tau).then_some(s)
});
let keep_left = |i: usize| keeps(&left.blocks[i], left.quality, right.quality);
let keep_right = |j: usize| keeps(&right.blocks[j], right.quality, left.quality);
let (mut i, mut j) = (i0, j0);
for &(pi, pj) in pairs.iter().chain(std::iter::once(&(i1, j1))) {
for k in i..pi {
if keep_left(k) {
out.blocks.push(left.blocks[k].clone());
out.from.push(From::Left);
}
}
for k in j..pj {
if keep_right(k) {
out.blocks.push(right.blocks[k].clone());
out.from.push(From::Right);
}
}
if pi < i1 {
let (l, r) = (&left.blocks[pi], &right.blocks[pj]);
let left_wins =
(left.quality.mean, r.text.as_str()) > (right.quality.mean, l.text.as_str());
out.blocks
.push(if left_wins { l.clone() } else { r.clone() });
out.from
.push(if left_wins { From::Left } else { From::Right });
}
(i, j) = (pi + 1, pj + 1);
}
}
fn keeps(block: &Block, own: Quality, other: Quality) -> bool {
block.template_prob.is_none_or(|p| p < 0.5) && own.hi >= other.lo
}
fn monotone_matching(
is: std::ops::Range<usize>,
js: std::ops::Range<usize>,
sim: impl Fn(usize, usize) -> Option<f64>,
) -> Vec<(usize, usize)> {
let (n, m) = (is.len(), js.len());
if n == 0 || m == 0 {
return vec![];
}
let s: Vec<Vec<Option<f64>>> = is
.clone()
.map(|i| js.clone().map(|j| sim(i, j)).collect())
.collect();
let mut best = vec![vec![0.0f64; m + 1]; n + 1];
for x in (0..n).rev() {
for y in (0..m).rev() {
let take = s[x][y].map_or(f64::NEG_INFINITY, |v| v + best[x + 1][y + 1]);
best[x][y] = take.max(best[x + 1][y]).max(best[x][y + 1]);
}
}
let (mut x, mut y, mut out) = (0, 0, vec![]);
while x < n && y < m {
if let Some(v) = s[x][y]
&& best[x][y] == v + best[x + 1][y + 1]
{
out.push((is.start + x, js.start + y));
(x, y) = (x + 1, y + 1);
} else if best[x][y] == best[x + 1][y] {
x += 1;
} else {
y += 1;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block::split;
fn cand(md: &str, mean: f64) -> Candidate {
Candidate {
blocks: split(md),
quality: Quality {
mean,
lo: mean - 0.1,
hi: mean + 0.1,
},
}
}
fn set(m: &Merged) -> Vec<String> {
let mut v: Vec<String> = m.blocks.iter().map(|b| b.text.clone()).collect();
v.sort();
v
}
const BASE: &str = "# Title\n\nFirst paragraph about rust.\n\nSecond paragraph about cats.\n\nThird paragraph about dogs.\n\nFourth paragraph about birds.\n";
#[test]
fn identical_candidates_merge_to_the_input() {
let m = merge(&cand(BASE, 0.8), &cand(BASE, 0.8), Delta::default());
assert_eq!(m.markdown(), BASE);
assert!(m.from.iter().all(|f| *f == From::Both));
}
#[test]
fn keeps_every_shared_block_and_fills_in_missing_ones() {
let http = cand(
"# Title\n\nFirst paragraph about rust.\n\nFourth paragraph about birds.\n",
0.7,
);
let browser = cand(BASE, 0.7);
let m = merge(&http, &browser, Delta::default());
assert_eq!(m.markdown(), BASE);
let shared = [
"# Title",
"First paragraph about rust.",
"Fourth paragraph about birds.",
];
assert!(shared.iter().all(|s| m.blocks.iter().any(|b| b.text == *s)));
}
#[test]
fn result_set_does_not_depend_on_argument_order() {
let x = cand(
"# Title\n\nFirst paragraph about rust.\n\nSecond paragraph about cats, edited.\n\nFourth paragraph about birds.\n\nOnly here.\n",
0.6,
);
let y = cand(BASE, 0.9);
assert_eq!(
set(&merge(&x, &y, Delta::default())),
set(&merge(&y, &x, Delta::default()))
);
}
#[test]
fn conflicting_block_comes_from_the_better_candidate_and_template_blocks_are_dropped() {
let mut x = cand(
"# Title\n\nFirst paragraph about rust.\n\nSecond paragraph about cats and more.\n\nThird paragraph about dogs.\n\nFourth paragraph about birds.\n",
0.95,
);
let y = cand(BASE, 0.5);
let m = merge(&x, &y, Delta::default());
assert!(
m.blocks
.iter()
.any(|b| b.text == "Second paragraph about cats and more.")
);
assert!(
!m.blocks
.iter()
.any(|b| b.text == "Second paragraph about cats.")
);
x.blocks.push(crate::block::Block {
template_prob: Some(0.9),
..crate::block::Block::new(crate::block::Kind::Paragraph, "Subscribe now".into())
});
let m = merge(&x, &y, Delta::default());
assert!(!m.blocks.iter().any(|b| b.text == "Subscribe now"));
}
#[test]
fn huge_unanchored_gap_stays_linear_and_keeps_the_better_side() {
let side = |tag: &str, n: usize| -> String {
(0..n)
.map(|i| format!("{tag} block number {i} with words w{i}x\n\n"))
.collect()
};
let n = 5000;
let (x, y) = (cand(&side("left", n), 0.9), cand(&side("right", n), 0.5));
let (m, took) = pagesift_testkit::cpu_time(|| merge(&x, &y, Delta::default()));
assert_eq!(m.blocks.len(), n);
assert!(m.from.iter().all(|f| *f == From::Left));
assert_eq!(
set(&merge(&y, &x, Delta::default())),
set(&m),
"argument order"
);
assert!(
took < pagesift_testkit::budget(std::time::Duration::from_millis(100)),
"{took:?}"
);
}
#[test]
fn small_gaps_still_match_block_by_block() {
let x = cand(
"# Title\n\nFirst paragraph about rust.\n\nSecond paragraph about cats.\n\nFourth paragraph about birds.\n",
0.7,
);
let y = cand(
"# Title\n\nFirst paragraph about rust.\n\nSecond paragraph about cats!\n\nThird paragraph about dogs.\n\nFourth paragraph about birds.\n",
0.7,
);
let m = merge(&x, &y, Delta::default());
assert!(
m.blocks
.iter()
.any(|b| b.text == "Third paragraph about dogs.")
);
assert_eq!(
m.blocks
.iter()
.filter(|b| b.text.starts_with("Second"))
.count(),
1
);
}
}