use std::collections::{HashMap, HashSet};
pub type DocId = u32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EdgeType {
Cite,
Elaborate,
Corroborate,
Depend,
Implement,
Exemplify,
Contradict,
Supersede,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Polarity {
Positive,
Negative,
Neutral,
}
impl EdgeType {
pub fn polarity(self) -> Polarity {
match self {
EdgeType::Cite | EdgeType::Elaborate | EdgeType::Corroborate => Polarity::Positive,
EdgeType::Contradict | EdgeType::Supersede => Polarity::Negative,
EdgeType::Depend | EdgeType::Implement | EdgeType::Exemplify => Polarity::Neutral,
}
}
pub fn from_slug(s: &str) -> Option<EdgeType> {
Some(match s {
"cite" => EdgeType::Cite,
"elaborate" => EdgeType::Elaborate,
"corroborate" => EdgeType::Corroborate,
"depend" => EdgeType::Depend,
"implement" => EdgeType::Implement,
"exemplify" => EdgeType::Exemplify,
"contradict" => EdgeType::Contradict,
"supersede" => EdgeType::Supersede,
_ => return None,
})
}
}
#[derive(Debug, Clone)]
pub struct Edge {
pub from: DocId,
pub to: DocId,
pub etype: EdgeType,
pub weight: f64,
}
#[derive(Debug, Clone)]
pub struct Document {
pub id: DocId,
pub title: String,
pub depth: u32,
pub recency: f64,
pub epistemic: String,
}
#[derive(Debug, Clone)]
pub struct Corpus {
docs: HashMap<DocId, Document>,
edges: Vec<Edge>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CorpusError {
UnknownEndpoint(DocId),
BadWeight(DocId, DocId),
Empty,
UnknownRelationType(String),
UnknownDocumentRef(String),
}
impl Corpus {
pub fn new(docs: Vec<Document>, edges: Vec<Edge>) -> Result<Self, CorpusError> {
if docs.is_empty() {
return Err(CorpusError::Empty);
}
let map: HashMap<DocId, Document> = docs.into_iter().map(|d| (d.id, d)).collect();
for e in &edges {
if !map.contains_key(&e.from) {
return Err(CorpusError::UnknownEndpoint(e.from));
}
if !map.contains_key(&e.to) {
return Err(CorpusError::UnknownEndpoint(e.to));
}
if !(e.weight > 0.0 && e.weight <= 1.0) {
return Err(CorpusError::BadWeight(e.from, e.to));
}
}
Ok(Corpus { docs: map, edges })
}
pub fn from_declaration(
documents: &[String],
relations: &[(String, String, String, f64)],
) -> Result<Corpus, CorpusError> {
if documents.is_empty() {
return Err(CorpusError::Empty);
}
let index: HashMap<&str, DocId> = documents
.iter()
.enumerate()
.map(|(i, d)| (d.as_str(), i as DocId))
.collect();
let docs: Vec<Document> = documents
.iter()
.enumerate()
.map(|(i, name)| Document {
id: i as DocId,
title: name.clone(),
depth: 0,
recency: 0.5,
epistemic: "believe".to_string(),
})
.collect();
let mut edges = Vec::with_capacity(relations.len());
for (etype, from, to, weight) in relations {
let et = EdgeType::from_slug(etype)
.ok_or_else(|| CorpusError::UnknownRelationType(etype.clone()))?;
let f = *index
.get(from.as_str())
.ok_or_else(|| CorpusError::UnknownDocumentRef(from.clone()))?;
let t = *index
.get(to.as_str())
.ok_or_else(|| CorpusError::UnknownDocumentRef(to.clone()))?;
edges.push(Edge { from: f, to: t, etype: et, weight: *weight });
}
Corpus::new(docs, edges)
}
pub fn len(&self) -> usize {
self.docs.len()
}
pub fn is_empty(&self) -> bool {
self.docs.is_empty()
}
pub fn document(&self, id: DocId) -> Option<&Document> {
self.docs.get(&id)
}
pub fn documents(&self) -> Vec<&Document> {
self.docs.values().collect()
}
pub fn edges(&self) -> &[Edge] {
&self.edges
}
fn ordered_ids(&self) -> Vec<DocId> {
let mut ids: Vec<DocId> = self.docs.keys().copied().collect();
ids.sort_unstable();
ids
}
}
#[derive(Debug, Clone)]
pub struct EprParams {
pub damping: f64,
pub lambda: f64,
pub tolerance: f64,
pub max_iter: usize,
}
impl Default for EprParams {
fn default() -> Self {
EprParams { damping: 0.85, lambda: 0.5, tolerance: 1e-10, max_iter: 200 }
}
}
#[derive(Debug, Clone)]
pub struct EprResult {
pub epr: HashMap<DocId, f64>,
pub epr_plus: HashMap<DocId, f64>,
pub epr_minus: HashMap<DocId, f64>,
pub iterations: usize,
}
fn build_transition(
corpus: &Corpus,
ids: &[DocId],
polarity: Polarity,
) -> (Vec<Vec<(usize, f64)>>, Vec<bool>) {
let index: HashMap<DocId, usize> = ids.iter().enumerate().map(|(i, &id)| (id, i)).collect();
let n = ids.len();
let mut out: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
let mut row_sum: Vec<f64> = vec![0.0; n];
for e in &corpus.edges {
if e.etype.polarity() != polarity {
continue;
}
let (j, i) = (index[&e.from], index[&e.to]);
out[j].push((i, e.weight));
row_sum[j] += e.weight;
}
let mut dangling = vec![false; n];
for j in 0..n {
if row_sum[j] > 0.0 {
for (_, w) in out[j].iter_mut() {
*w /= row_sum[j];
}
} else {
dangling[j] = true; }
}
(out, dangling)
}
fn power_iterate(
transition: &[Vec<(usize, f64)>],
dangling: &[bool],
u: &[f64],
params: &EprParams,
) -> (Vec<f64>, usize) {
let n = u.len();
if n == 0 {
return (Vec::new(), 0);
}
let d = params.damping;
let mut x = vec![1.0 / n as f64; n];
let mut iters = 0;
for _ in 0..params.max_iter {
iters += 1;
let dangling_mass: f64 =
(0..n).filter(|&j| dangling[j]).map(|j| x[j]).sum::<f64>() * d / n as f64;
let mut next = vec![0.0_f64; n];
for i in 0..n {
next[i] = (1.0 - d) * u[i] + dangling_mass;
}
for (j, row) in transition.iter().enumerate() {
if row.is_empty() {
continue;
}
let xj = x[j] * d;
for &(i, p) in row {
next[i] += xj * p;
}
}
let delta: f64 = (0..n).map(|i| (next[i] - x[i]).abs()).sum();
x = next;
if delta < params.tolerance {
break;
}
}
(x, iters)
}
fn teleportation(weights: &[f64]) -> Vec<f64> {
let n = weights.len();
if n == 0 {
return Vec::new();
}
const EPS: f64 = 1e-9;
let floored: Vec<f64> = weights.iter().map(|&w| w.max(0.0) + EPS).collect();
let sum: f64 = floored.iter().sum();
floored.iter().map(|&w| w / sum).collect()
}
pub fn epistemic_pagerank(corpus: &Corpus, params: &EprParams) -> EprResult {
let ids = corpus.ordered_ids();
let n = ids.len();
let u_plus: Vec<f64> = teleportation(
&ids.iter()
.map(|id| 1.0 / (corpus.docs[id].depth as f64 + 1.0))
.collect::<Vec<_>>(),
);
let u_minus: Vec<f64> = teleportation(
&ids.iter().map(|id| corpus.docs[id].recency).collect::<Vec<_>>(),
);
let (p_plus, dangle_plus) = build_transition(corpus, &ids, Polarity::Positive);
let (p_minus, dangle_minus) = build_transition(corpus, &ids, Polarity::Negative);
let (epr_plus_v, iters) = power_iterate(&p_plus, &dangle_plus, &u_plus, params);
let (epr_minus_v, _) = power_iterate(&p_minus, &dangle_minus, &u_minus, params);
let mut epr_plus = HashMap::with_capacity(n);
let mut epr_minus = HashMap::with_capacity(n);
let mut epr = HashMap::with_capacity(n);
for (k, &id) in ids.iter().enumerate() {
let p = epr_plus_v[k];
let m = epr_minus_v[k];
epr_plus.insert(id, p);
epr_minus.insert(id, m);
epr.insert(id, p - params.lambda * m);
}
EprResult { epr, epr_plus, epr_minus, iterations: iters }
}
pub trait MarginalGain {
fn gain(&self, query: &str, candidate: DocId, selected: &[DocId]) -> f64;
}
pub fn greedy_submodular_select(
ground: &[DocId],
k: usize,
query: &str,
gain: &dyn MarginalGain,
) -> Vec<DocId> {
let mut selected: Vec<DocId> = Vec::new();
while selected.len() < k {
let mut best: Option<(DocId, f64)> = None;
for &d in ground {
if selected.contains(&d) {
continue;
}
let g = gain.gain(query, d, &selected);
match best {
Some((_, bg)) if g <= bg => {}
_ => best = Some((d, g)),
}
}
match best {
Some((d, g)) if g > 0.0 => selected.push(d),
_ => break,
}
}
selected
}
#[derive(Debug, Clone)]
pub struct NavBudget {
pub max_docs: usize,
pub epsilon: f64,
}
impl Default for NavBudget {
fn default() -> Self {
NavBudget { max_docs: 5, epsilon: 1e-6 }
}
}
#[derive(Debug, Clone)]
pub struct MdnNavResult {
pub selected: Vec<DocId>,
pub trail: Vec<(DocId, f64)>,
pub total_gain: f64,
}
pub fn navigate_corpus(
corpus: &Corpus,
query: &str,
seed: DocId,
budget: &NavBudget,
gain: &dyn MarginalGain,
) -> MdnNavResult {
let mut selected: Vec<DocId> = Vec::new();
let mut trail: Vec<(DocId, f64)> = Vec::new();
let mut total_gain = 0.0;
if corpus.docs.contains_key(&seed) {
selected.push(seed);
trail.push((seed, 0.0));
}
while selected.len() < budget.max_docs {
let mut frontier: Vec<DocId> = Vec::new();
for e in &corpus.edges {
if selected.contains(&e.from)
&& !selected.contains(&e.to)
&& !frontier.contains(&e.to)
{
frontier.push(e.to);
}
}
if frontier.is_empty() {
break;
}
frontier.sort_unstable();
let mut best: Option<(DocId, f64)> = None;
for &d in &frontier {
let g = gain.gain(query, d, &selected);
match best {
Some((_, bg)) if g <= bg => {}
_ => best = Some((d, g)),
}
}
match best {
Some((d, g)) if g >= budget.epsilon => {
selected.push(d);
trail.push((d, g));
total_gain += g;
}
_ => break,
}
}
MdnNavResult { selected, trail, total_gain }
}
pub fn contradictions(corpus: &Corpus) -> Vec<(DocId, DocId)> {
corpus
.edges
.iter()
.filter(|e| e.etype == EdgeType::Contradict)
.map(|e| (e.from, e.to))
.collect()
}
pub fn is_balanced(corpus: &Corpus) -> bool {
let mut adj: HashMap<DocId, Vec<(DocId, bool)>> = HashMap::new();
for e in &corpus.edges {
let pos = match e.etype.polarity() {
Polarity::Positive => true,
Polarity::Negative => false,
Polarity::Neutral => continue,
};
adj.entry(e.from).or_default().push((e.to, pos));
adj.entry(e.to).or_default().push((e.from, pos));
}
let mut color: HashMap<DocId, bool> = HashMap::new();
for &start in corpus.docs.keys() {
if color.contains_key(&start) {
continue;
}
color.insert(start, true);
let mut stack = vec![start];
while let Some(u) = stack.pop() {
let cu = color[&u];
if let Some(neighbours) = adj.get(&u) {
for &(v, pos) in neighbours {
let required = if pos { cu } else { !cu };
match color.get(&v) {
Some(&cv) => {
if cv != required {
return false; }
}
None => {
color.insert(v, required);
stack.push(v);
}
}
}
}
}
}
true
}
pub fn jeffreys_divergence(p: &[f64], q: &[f64]) -> f64 {
const EPS: f64 = 1e-12;
let n = p.len().max(q.len());
let mut j = 0.0;
for i in 0..n {
let pi = p.get(i).copied().unwrap_or(0.0).max(0.0);
let qi = q.get(i).copied().unwrap_or(0.0).max(0.0);
if pi > 0.0 {
j += pi * (pi / (qi + EPS)).ln();
}
if qi > 0.0 {
j += qi * (qi / (pi + EPS)).ln();
}
}
j.max(0.0)
}
pub fn type_cost_coefficient(etype: EdgeType) -> f64 {
match etype {
EdgeType::Corroborate => 0.5,
EdgeType::Elaborate => 0.7,
EdgeType::Cite => 1.0,
EdgeType::Supersede => 2.0,
EdgeType::Contradict => 3.0,
EdgeType::Depend | EdgeType::Implement | EdgeType::Exemplify => 1.0,
}
}
pub fn edge_cost(edge: &Edge, dist: &HashMap<DocId, Vec<f64>>) -> f64 {
let empty = Vec::new();
let p = dist.get(&edge.from).unwrap_or(&empty);
let q = dist.get(&edge.to).unwrap_or(&empty);
type_cost_coefficient(edge.etype) * jeffreys_divergence(p, q)
}
pub fn shortest_cost_path(
corpus: &Corpus,
dist: &HashMap<DocId, Vec<f64>>,
from: DocId,
to: DocId,
) -> Option<(Vec<DocId>, f64)> {
use std::cmp::Ordering;
use std::collections::BinaryHeap;
if !corpus.docs.contains_key(&from) || !corpus.docs.contains_key(&to) {
return None;
}
let mut adj: HashMap<DocId, Vec<(DocId, f64)>> = HashMap::new();
for e in &corpus.edges {
adj.entry(e.from).or_default().push((e.to, edge_cost(e, dist)));
}
struct State {
cost: f64,
node: DocId,
}
impl PartialEq for State {
fn eq(&self, o: &Self) -> bool {
self.cost == o.cost && self.node == o.node
}
}
impl Eq for State {}
impl Ord for State {
fn cmp(&self, o: &Self) -> Ordering {
o.cost
.partial_cmp(&self.cost)
.unwrap_or(Ordering::Equal)
.then_with(|| o.node.cmp(&self.node))
}
}
impl PartialOrd for State {
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
Some(self.cmp(o))
}
}
let mut best: HashMap<DocId, f64> = HashMap::new();
let mut prev: HashMap<DocId, DocId> = HashMap::new();
let mut heap = BinaryHeap::new();
best.insert(from, 0.0);
heap.push(State { cost: 0.0, node: from });
while let Some(State { cost, node }) = heap.pop() {
if node == to {
let mut path = vec![to];
let mut cur = to;
while let Some(&p) = prev.get(&cur) {
path.push(p);
cur = p;
}
path.reverse();
return Some((path, cost));
}
if cost > *best.get(&node).unwrap_or(&f64::INFINITY) {
continue; }
if let Some(neighbours) = adj.get(&node) {
for &(next, w) in neighbours {
let nc = cost + w;
if nc < *best.get(&next).unwrap_or(&f64::INFINITY) {
best.insert(next, nc);
prev.insert(next, node);
heap.push(State { cost: nc, node: next });
}
}
}
}
None
}
fn title_tokens(s: &str) -> HashSet<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.len() >= 2)
.map(|w| w.to_lowercase())
.collect()
}
pub struct LexicalGain<'a> {
corpus: &'a Corpus,
}
impl<'a> LexicalGain<'a> {
pub fn new(corpus: &'a Corpus) -> Self {
LexicalGain { corpus }
}
}
impl MarginalGain for LexicalGain<'_> {
fn gain(&self, query: &str, candidate: DocId, selected: &[DocId]) -> f64 {
let q = title_tokens(query);
if q.is_empty() {
return 0.0;
}
let mut covered: HashSet<String> = HashSet::new();
for d in selected {
if let Some(doc) = self.corpus.document(*d) {
covered.extend(title_tokens(&doc.title));
}
}
let cand = self
.corpus
.document(candidate)
.map(|d| title_tokens(&d.title))
.unwrap_or_default();
q.iter().filter(|t| cand.contains(*t) && !covered.contains(*t)).count() as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(id: DocId, depth: u32, recency: f64) -> Document {
Document {
id,
title: format!("D{id}"),
depth,
recency,
epistemic: "believe".into(),
}
}
fn edge(from: DocId, to: DocId, etype: EdgeType, weight: f64) -> Edge {
Edge { from, to, etype, weight }
}
fn sum(m: &HashMap<DocId, f64>) -> f64 {
m.values().sum()
}
#[test]
fn edge_polarity_matches_the_paper_decomposition() {
assert_eq!(EdgeType::Cite.polarity(), Polarity::Positive);
assert_eq!(EdgeType::Corroborate.polarity(), Polarity::Positive);
assert_eq!(EdgeType::Contradict.polarity(), Polarity::Negative);
assert_eq!(EdgeType::Supersede.polarity(), Polarity::Negative);
assert_eq!(EdgeType::Depend.polarity(), Polarity::Neutral);
}
#[test]
fn corpus_rejects_unknown_endpoint_and_bad_weight() {
let docs = vec![doc(1, 0, 0.5), doc(2, 1, 0.5)];
assert_eq!(
Corpus::new(docs.clone(), vec![edge(1, 9, EdgeType::Cite, 0.5)]).unwrap_err(),
CorpusError::UnknownEndpoint(9)
);
assert_eq!(
Corpus::new(docs, vec![edge(1, 2, EdgeType::Cite, 1.5)]).unwrap_err(),
CorpusError::BadWeight(1, 2)
);
assert_eq!(Corpus::new(vec![], vec![]).unwrap_err(), CorpusError::Empty);
}
#[test]
fn edge_type_from_slug_roundtrips() {
assert_eq!(EdgeType::from_slug("cite"), Some(EdgeType::Cite));
assert_eq!(EdgeType::from_slug("supersede"), Some(EdgeType::Supersede));
assert_eq!(EdgeType::from_slug("corroborate"), Some(EdgeType::Corroborate));
assert!(EdgeType::from_slug("nope").is_none());
}
#[test]
fn from_declaration_builds_an_mdn_graph() {
let docs = vec!["sess_a".to_string(), "sess_b".to_string(), "sess_c".to_string()];
let rels = vec![
("cite".to_string(), "sess_b".to_string(), "sess_a".to_string(), 0.9),
("contradict".to_string(), "sess_c".to_string(), "sess_a".to_string(), 0.7),
];
let c = Corpus::from_declaration(&docs, &rels).unwrap();
assert_eq!(c.len(), 3);
assert_eq!(c.edges().len(), 2);
let r = epistemic_pagerank(&c, &EprParams::default());
assert!(r.epr_plus[&0] > r.epr_plus[&2]);
}
#[test]
fn from_declaration_rejects_unknown_type_and_doc() {
let docs = vec!["a".to_string(), "b".to_string()];
assert_eq!(
Corpus::from_declaration(&docs, &[("hug".into(), "a".into(), "b".into(), 0.5)])
.unwrap_err(),
CorpusError::UnknownRelationType("hug".into())
);
assert_eq!(
Corpus::from_declaration(&docs, &[("cite".into(), "a".into(), "z".into(), 0.5)])
.unwrap_err(),
CorpusError::UnknownDocumentRef("z".into())
);
}
#[test]
fn epr_plus_and_minus_are_strictly_positive_distributions() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.3), doc(3, 2, 0.9)];
let edges = vec![
edge(2, 1, EdgeType::Cite, 0.9),
edge(3, 1, EdgeType::Cite, 0.8),
edge(3, 2, EdgeType::Contradict, 0.7),
];
let c = Corpus::new(docs, edges).unwrap();
let r = epistemic_pagerank(&c, &EprParams::default());
assert!((sum(&r.epr_plus) - 1.0).abs() < 1e-6, "EPR⁺ sums to 1");
assert!((sum(&r.epr_minus) - 1.0).abs() < 1e-6, "EPR⁻ sums to 1");
assert!(r.epr_plus.values().all(|&v| v > 0.0), "EPR⁺ strictly positive");
assert!(r.epr_minus.values().all(|&v| v > 0.0), "EPR⁻ strictly positive");
}
#[test]
fn net_epr_sums_to_one_minus_lambda() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.5)];
let edges = vec![edge(2, 1, EdgeType::Cite, 0.9)];
let c = Corpus::new(docs, edges).unwrap();
let params = EprParams { lambda: 0.5, ..EprParams::default() };
let r = epistemic_pagerank(&c, ¶ms);
assert!((sum(&r.epr) - (1.0 - params.lambda)).abs() < 1e-6);
}
#[test]
fn a_heavily_cited_document_outranks_an_uncited_one() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.5), doc(3, 1, 0.5), doc(4, 1, 0.5)];
let edges = vec![
edge(2, 1, EdgeType::Cite, 0.9),
edge(3, 1, EdgeType::Cite, 0.9),
];
let c = Corpus::new(docs, edges).unwrap();
let r = epistemic_pagerank(&c, &EprParams::default());
assert!(
r.epr_plus[&1] > r.epr_plus[&4],
"the cited D1 ({}) must outrank the uncited D4 ({})",
r.epr_plus[&1],
r.epr_plus[&4]
);
}
#[test]
fn a_contested_document_can_have_negative_net_epr() {
let docs = vec![doc(1, 0, 0.9), doc(2, 1, 0.1), doc(3, 1, 0.1), doc(4, 1, 0.1)];
let edges = vec![
edge(2, 1, EdgeType::Cite, 0.1),
edge(2, 1, EdgeType::Contradict, 1.0),
edge(3, 1, EdgeType::Contradict, 1.0),
edge(4, 1, EdgeType::Contradict, 1.0),
];
let c = Corpus::new(docs, edges).unwrap();
let params = EprParams { lambda: 1.0, ..EprParams::default() };
let r = epistemic_pagerank(&c, ¶ms);
assert!(
r.epr[&1] < 0.0,
"a heavily-contradicted, lightly-cited doc has negative EPR: {}",
r.epr[&1]
);
}
#[test]
fn power_iteration_converges_well_within_the_cap() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.5), doc(3, 2, 0.9)];
let edges = vec![
edge(2, 1, EdgeType::Cite, 0.9),
edge(3, 2, EdgeType::Cite, 0.8),
];
let c = Corpus::new(docs, edges).unwrap();
let params = EprParams::default();
let r = epistemic_pagerank(&c, ¶ms);
assert!(r.iterations < params.max_iter, "converged before the cap (geometric)");
assert!(r.iterations > 0);
}
#[test]
fn dangling_nodes_keep_epr_a_proper_distribution() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.5)];
let edges = vec![edge(2, 1, EdgeType::Cite, 0.9)];
let c = Corpus::new(docs, edges).unwrap();
let r = epistemic_pagerank(&c, &EprParams::default());
assert!((sum(&r.epr_plus) - 1.0).abs() < 1e-6, "dangling handled, still a distribution");
}
#[test]
fn single_document_corpus_is_well_defined() {
let c = Corpus::new(vec![doc(1, 0, 0.5)], vec![]).unwrap();
let r = epistemic_pagerank(&c, &EprParams::default());
assert!((r.epr_plus[&1] - 1.0).abs() < 1e-9, "the lone doc holds all trust mass");
}
use std::collections::HashSet;
struct CoverageGain {
topics: HashMap<DocId, Vec<u32>>,
}
impl MarginalGain for CoverageGain {
fn gain(&self, _q: &str, candidate: DocId, selected: &[DocId]) -> f64 {
let mut covered: HashSet<u32> = HashSet::new();
for d in selected {
if let Some(ts) = self.topics.get(d) {
covered.extend(ts.iter().copied());
}
}
self.topics
.get(&candidate)
.map(|ts| ts.iter().filter(|t| !covered.contains(t)).count() as f64)
.unwrap_or(0.0)
}
}
fn coverage(topics: &HashMap<DocId, Vec<u32>>, set: &[DocId]) -> f64 {
let mut u: HashSet<u32> = HashSet::new();
for d in set {
if let Some(ts) = topics.get(d) {
u.extend(ts.iter().copied());
}
}
u.len() as f64
}
fn combinations(items: &[DocId], k: usize) -> Vec<Vec<DocId>> {
if k == 0 {
return vec![vec![]];
}
if items.len() < k {
return vec![];
}
let mut out = Vec::new();
for mut rest in combinations(&items[1..], k - 1) {
let mut v = vec![items[0]];
v.append(&mut rest);
out.push(v);
}
out.extend(combinations(&items[1..], k));
out
}
#[test]
fn greedy_submodular_achieves_the_one_minus_one_over_e_bound() {
let topics = HashMap::from([
(1, vec![1, 2, 3]),
(2, vec![3, 4]),
(3, vec![1, 2]),
(4, vec![5, 6, 7]),
(5, vec![7, 8]),
]);
let g = CoverageGain { topics: topics.clone() };
let ground = vec![1, 2, 3, 4, 5];
let k = 2;
let greedy = greedy_submodular_select(&ground, k, "", &g);
let f_greedy = coverage(&topics, &greedy);
let opt = combinations(&ground, k)
.iter()
.map(|c| coverage(&topics, c))
.fold(0.0_f64, f64::max);
let bound = (1.0 - 1.0 / std::f64::consts::E) * opt;
assert!(
f_greedy >= bound - 1e-9,
"greedy f={f_greedy} must be ≥ (1-1/e)·OPT={bound} (OPT={opt})"
);
}
#[test]
fn greedy_marginal_gains_are_non_increasing() {
let topics = HashMap::from([
(1, vec![1, 2, 3, 4]),
(2, vec![3, 4, 5]),
(3, vec![5, 6]),
(4, vec![6, 7]),
]);
let g = CoverageGain { topics };
let ground = vec![1, 2, 3, 4];
let sel = greedy_submodular_select(&ground, 4, "", &g);
let mut prev = f64::INFINITY;
let mut acc: Vec<DocId> = Vec::new();
for d in sel {
let m = g.gain("", d, &acc);
assert!(m <= prev + 1e-9, "marginal gains must be non-increasing (got {m} after {prev})");
prev = m;
acc.push(d);
}
}
#[test]
fn navigate_corpus_is_epsilon_informative_and_only_visits_reachable() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.1), doc(3, 2, 0.1), doc(4, 1, 0.1)];
let edges = vec![
edge(1, 2, EdgeType::Cite, 0.9),
edge(2, 3, EdgeType::Cite, 0.9),
];
let c = Corpus::new(docs, edges).unwrap();
let topics = HashMap::from([(1, vec![1]), (2, vec![2]), (3, vec![3]), (4, vec![4])]);
let g = CoverageGain { topics };
let budget = NavBudget { max_docs: 5, epsilon: 0.5 };
let r = navigate_corpus(&c, "", 1, &budget, &g);
assert!(!r.selected.contains(&4), "unreachable D4 must not be selected");
assert_eq!(r.selected, vec![1, 2, 3], "follows the citation chain");
for &(d, gain) in &r.trail {
if d != 1 {
assert!(gain >= budget.epsilon, "selected {d} below ε floor: {gain}");
}
}
assert!(r.selected.len() <= budget.max_docs);
}
#[test]
fn navigate_stops_when_no_neighbor_clears_epsilon() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.1)];
let edges = vec![edge(1, 2, EdgeType::Cite, 0.9)];
let c = Corpus::new(docs, edges).unwrap();
let topics = HashMap::from([(1, vec![7]), (2, vec![7])]); let g = CoverageGain { topics };
let r = navigate_corpus(&c, "", 1, &NavBudget { max_docs: 5, epsilon: 0.5 }, &g);
assert_eq!(r.selected, vec![1], "the uninformative neighbour is not visited");
}
#[test]
fn contradictions_lists_the_contradict_edges() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.1), doc(3, 1, 0.1)];
let edges = vec![
edge(1, 2, EdgeType::Cite, 0.9),
edge(2, 3, EdgeType::Contradict, 0.8),
];
let c = Corpus::new(docs, edges).unwrap();
assert_eq!(contradictions(&c), vec![(2, 3)]);
}
#[test]
fn balance_theory_even_negatives_balanced_odd_unbalanced() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.1), doc(3, 1, 0.1)];
let all_pos = Corpus::new(
docs.clone(),
vec![
edge(1, 2, EdgeType::Cite, 0.9),
edge(2, 3, EdgeType::Cite, 0.9),
edge(1, 3, EdgeType::Cite, 0.9),
],
)
.unwrap();
assert!(is_balanced(&all_pos), "all-positive cycle is balanced");
let one_neg = Corpus::new(
docs.clone(),
vec![
edge(1, 2, EdgeType::Cite, 0.9),
edge(2, 3, EdgeType::Cite, 0.9),
edge(1, 3, EdgeType::Contradict, 0.9),
],
)
.unwrap();
assert!(!is_balanced(&one_neg), "one negative in the cycle ⇒ unbalanced");
let two_neg = Corpus::new(
docs,
vec![
edge(1, 2, EdgeType::Contradict, 0.9),
edge(2, 3, EdgeType::Contradict, 0.9),
edge(1, 3, EdgeType::Cite, 0.9),
],
)
.unwrap();
assert!(is_balanced(&two_neg), "two negatives (even) ⇒ balanced");
}
#[test]
fn jeffreys_is_a_symmetric_nonnegative_pseudometric() {
let p = vec![0.7, 0.3];
let q = vec![0.3, 0.7];
assert!(jeffreys_divergence(&p, &p) < 1e-9, "J(p,p) = 0");
let jpq = jeffreys_divergence(&p, &q);
let jqp = jeffreys_divergence(&q, &p);
assert!((jpq - jqp).abs() < 1e-12, "J is symmetric");
assert!(jpq > 0.0, "J(p,q) > 0 for distinct distributions");
}
#[test]
fn type_cost_coefficients_rank_citations_cheap_contradictions_expensive() {
assert!(
type_cost_coefficient(EdgeType::Corroborate)
< type_cost_coefficient(EdgeType::Cite)
);
assert!(
type_cost_coefficient(EdgeType::Cite) < type_cost_coefficient(EdgeType::Supersede)
);
assert!(
type_cost_coefficient(EdgeType::Supersede)
< type_cost_coefficient(EdgeType::Contradict)
);
assert_eq!(type_cost_coefficient(EdgeType::Contradict), 3.0);
}
#[test]
fn shortest_path_prefers_cheap_citations_over_an_expensive_contradiction() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.1), doc(3, 2, 0.1)];
let edges = vec![
edge(1, 3, EdgeType::Contradict, 0.9),
edge(1, 2, EdgeType::Cite, 0.9),
edge(2, 3, EdgeType::Cite, 0.9),
];
let c = Corpus::new(docs, edges).unwrap();
let dist = HashMap::from([
(1, vec![0.9, 0.1]),
(2, vec![0.85, 0.15]),
(3, vec![0.1, 0.9]),
]);
let (path, cost) = shortest_cost_path(&c, &dist, 1, 3).unwrap();
assert_eq!(path, vec![1, 2, 3], "Dijkstra takes the cheap two-cite route");
let direct = 3.0 * jeffreys_divergence(&dist[&1], &dist[&3]);
assert!(cost < direct, "indirect cost {cost} < direct contradiction cost {direct}");
}
#[test]
fn shortest_path_none_when_unreachable() {
let docs = vec![doc(1, 0, 0.1), doc(2, 1, 0.1)];
let c = Corpus::new(docs, vec![]).unwrap(); let dist = HashMap::from([(1, vec![1.0]), (2, vec![1.0])]);
assert!(shortest_cost_path(&c, &dist, 1, 2).is_none());
}
fn titled(id: DocId, title: &str) -> Document {
Document { id, title: title.into(), depth: 0, recency: 0.1, epistemic: "believe".into() }
}
#[test]
fn lexical_gain_navigates_to_the_query_relevant_document() {
let docs = vec![
titled(0, "intro overview"),
titled(1, "liability limitation cap"),
titled(2, "termination notice"),
];
let edges = vec![
Edge { from: 0, to: 1, etype: EdgeType::Cite, weight: 0.9 },
Edge { from: 0, to: 2, etype: EdgeType::Cite, weight: 0.9 },
];
let c = Corpus::new(docs, edges).unwrap();
let gain = LexicalGain::new(&c);
let r = navigate_corpus(&c, "liability cap", 0, &NavBudget { max_docs: 3, epsilon: 0.5 }, &gain);
assert!(r.selected.contains(&1), "navigated to the liability doc: {:?}", r.selected);
assert!(!r.selected.contains(&2), "the uninformative termination doc was not visited");
}
#[test]
fn lexical_gain_is_zero_for_irrelevant_titles() {
let c = Corpus::new(vec![titled(0, "alpha"), titled(1, "beta")], vec![]).unwrap();
let g = LexicalGain::new(&c);
assert_eq!(g.gain("zzz qqq", 1, &[]), 0.0);
assert_eq!(g.gain("beta", 1, &[]), 1.0);
assert_eq!(g.gain("beta", 1, &[1]), 0.0);
}
}