use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use crate::comparison::RaterCounts;
use crate::relation_graph::EntityKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StructuralEdge {
pub from: EntityKey,
pub to: EntityKey,
pub kind: EdgeKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EdgeKind {
Seq,
Dep,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PredEdge {
pub pred: EntityKey,
pub kind: EdgeKind,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct ComponentDeltas {
pub risk_dim: f64,
pub leverage: f64,
pub optionality: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum TensionCause {
Structure { edge: StructuralEdge },
Composition { deltas: ComponentDeltas },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EvidenceGrade {
Determined { counts: RaterCounts },
AgentProposed { counts: RaterCounts },
Projected,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct DetectedTension {
pub preferred: EntityKey,
pub surfaced: EntityKey,
pub cause: TensionCause,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Tension {
pub preferred: EntityKey,
pub surfaced: EntityKey,
pub cause: TensionCause,
pub grade: EvidenceGrade,
}
impl DetectedTension {
pub(crate) fn with_grade(self, grade: EvidenceGrade) -> Tension {
Tension {
preferred: self.preferred,
surfaced: self.surfaced,
cause: self.cause,
grade,
}
}
}
pub(crate) struct DetectInputs<'a> {
pub delivery_order: &'a [EntityKey],
pub page_k: usize,
pub value_dim: &'a BTreeMap<EntityKey, f64>,
pub multiplier: &'a BTreeMap<EntityKey, f64>,
pub full_score: &'a BTreeMap<EntityKey, f64>,
pub risk_dim: &'a BTreeMap<EntityKey, f64>,
pub leverage: &'a BTreeMap<EntityKey, f64>,
pub optionality: &'a BTreeMap<EntityKey, f64>,
pub preds: &'a BTreeMap<EntityKey, Vec<PredEdge>>,
}
fn is_zero(w: f64) -> bool {
w.abs().total_cmp(&0.0).is_eq()
}
fn structural_edge(
preds: &BTreeMap<EntityKey, Vec<PredEdge>>,
preferred: EntityKey,
surfaced: EntityKey,
) -> Option<StructuralEdge> {
let mut parent: BTreeMap<EntityKey, (EntityKey, EdgeKind)> = BTreeMap::new();
let mut visited: BTreeSet<EntityKey> = BTreeSet::from([preferred]);
let mut queue: VecDeque<EntityKey> = VecDeque::from([preferred]);
while let Some(node) = queue.pop_front() {
if node == surfaced {
return parent.get(&surfaced).map(|&(to, kind)| StructuralEdge {
from: surfaced,
to,
kind,
});
}
let mut edges = preds.get(&node).cloned().unwrap_or_default();
edges.sort_by_key(|e| e.pred);
for PredEdge { pred, kind } in edges {
if visited.insert(pred) {
parent.insert(pred, (node, kind));
queue.push_back(pred);
}
}
}
None
}
pub(crate) fn detect(inputs: &DetectInputs<'_>) -> Vec<DetectedTension> {
let val = |k: &EntityKey| inputs.value_dim.get(k).copied().unwrap_or(0.0);
let mult = |k: &EntityKey| inputs.multiplier.get(k).copied().unwrap_or(0.0);
let comp = |m: &BTreeMap<EntityKey, f64>, k: &EntityKey| m.get(k).copied().unwrap_or(0.0);
let mut out = Vec::new();
for (bi, &surfaced) in inputs.delivery_order.iter().enumerate() {
if bi >= inputs.page_k {
continue; }
for (ai, &preferred) in inputs.delivery_order.iter().enumerate() {
if ai <= bi {
continue; }
if is_zero(mult(&preferred)) || is_zero(mult(&surfaced)) {
continue;
}
if val(&preferred).total_cmp(&val(&surfaced)) != Ordering::Greater {
continue;
}
let cause = if let Some(edge) = structural_edge(inputs.preds, preferred, surfaced) {
TensionCause::Structure { edge }
} else if inputs
.full_score
.get(&preferred)
.copied()
.unwrap_or(0.0)
.total_cmp(&inputs.full_score.get(&surfaced).copied().unwrap_or(0.0))
.is_eq()
{
continue; } else {
TensionCause::Composition {
deltas: ComponentDeltas {
risk_dim: comp(inputs.risk_dim, &surfaced)
- comp(inputs.risk_dim, &preferred),
leverage: comp(inputs.leverage, &surfaced)
- comp(inputs.leverage, &preferred),
optionality: comp(inputs.optionality, &surfaced)
- comp(inputs.optionality, &preferred),
},
}
};
out.push(DetectedTension {
preferred,
surfaced,
cause,
});
}
}
out
}
pub(crate) fn zero_weight_excluded(inputs: &DetectInputs<'_>) -> usize {
let val = |k: &EntityKey| inputs.value_dim.get(k).copied().unwrap_or(0.0);
let mult = |k: &EntityKey| inputs.multiplier.get(k).copied().unwrap_or(0.0);
let mut count = 0;
for (bi, &surfaced) in inputs.delivery_order.iter().enumerate() {
if bi >= inputs.page_k {
continue;
}
for &preferred in inputs.delivery_order.iter().skip(bi + 1) {
if !(is_zero(mult(&preferred)) || is_zero(mult(&surfaced))) {
continue;
}
if val(&preferred).total_cmp(&val(&surfaced)) == Ordering::Greater {
count += 1;
}
}
}
count
}
pub(crate) fn grade(
knob_on: bool,
determined_verdict: bool,
verdict_counts: RaterCounts,
determined_full: bool,
full_counts: RaterCounts,
) -> EvidenceGrade {
if determined_verdict {
EvidenceGrade::Determined {
counts: verdict_counts,
}
} else if knob_on && determined_full {
EvidenceGrade::AgentProposed {
counts: full_counts,
}
} else {
EvidenceGrade::Projected
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(id: u32) -> EntityKey {
EntityKey { prefix: "SL", id }
}
struct Fx {
order: Vec<EntityKey>,
value_dim: BTreeMap<EntityKey, f64>,
multiplier: BTreeMap<EntityKey, f64>,
full_score: BTreeMap<EntityKey, f64>,
risk_dim: BTreeMap<EntityKey, f64>,
leverage: BTreeMap<EntityKey, f64>,
optionality: BTreeMap<EntityKey, f64>,
preds: BTreeMap<EntityKey, Vec<PredEdge>>,
}
impl Fx {
fn new(nodes: &[(u32, f64, f64, f64, f64, f64, f64)]) -> Self {
let mut fx = Fx {
order: Vec::new(),
value_dim: BTreeMap::new(),
multiplier: BTreeMap::new(),
full_score: BTreeMap::new(),
risk_dim: BTreeMap::new(),
leverage: BTreeMap::new(),
optionality: BTreeMap::new(),
preds: BTreeMap::new(),
};
for &(id, v, m, s, r, l, o) in nodes {
let k = key(id);
fx.order.push(k);
fx.value_dim.insert(k, v);
fx.multiplier.insert(k, m);
fx.full_score.insert(k, s);
fx.risk_dim.insert(k, r);
fx.leverage.insert(k, l);
fx.optionality.insert(k, o);
}
fx
}
fn edge(mut self, node: u32, pred: u32, kind: EdgeKind) -> Self {
self.preds.entry(key(node)).or_default().push(PredEdge {
pred: key(pred),
kind,
});
self
}
fn inputs(&self, page_k: usize) -> DetectInputs<'_> {
DetectInputs {
delivery_order: &self.order,
page_k,
value_dim: &self.value_dim,
multiplier: &self.multiplier,
full_score: &self.full_score,
risk_dim: &self.risk_dim,
leverage: &self.leverage,
optionality: &self.optionality,
preds: &self.preds,
}
}
}
#[test]
fn no_tension_when_delivery_matches_value() {
let fx = Fx::new(&[
(1, 10.0, 1.0, 10.0, 0.0, 0.0, 0.0),
(2, 5.0, 1.0, 5.0, 0.0, 0.0, 0.0),
]);
assert!(detect(&fx.inputs(2)).is_empty());
}
#[test]
fn structure_direct_edge_cited() {
let fx = Fx::new(&[
(1, 5.0, 1.0, 8.0, 0.0, 0.0, 0.0),
(2, 10.0, 1.0, 4.0, 0.0, 0.0, 0.0),
])
.edge(2, 1, EdgeKind::Seq);
let out = detect(&fx.inputs(2));
assert_eq!(out.len(), 1);
assert_eq!(out[0].preferred, key(2));
assert_eq!(out[0].surfaced, key(1));
assert_eq!(
out[0].cause,
TensionCause::Structure {
edge: StructuralEdge {
from: key(1),
to: key(2),
kind: EdgeKind::Seq,
}
}
);
}
#[test]
fn structure_transitive_path_through_on_page_node() {
let fx = Fx::new(&[
(1, 3.0, 1.0, 9.0, 0.0, 0.0, 0.0),
(2, 4.0, 1.0, 7.0, 0.0, 0.0, 0.0),
(3, 10.0, 1.0, 5.0, 0.0, 0.0, 0.0),
])
.edge(3, 2, EdgeKind::Dep)
.edge(2, 1, EdgeKind::Dep);
let out = detect(&fx.inputs(3));
let t = out
.iter()
.find(|t| t.preferred == key(3) && t.surfaced == key(1))
.expect("3-over-1 tension present");
assert_eq!(
t.cause,
TensionCause::Structure {
edge: StructuralEdge {
from: key(1),
to: key(2),
kind: EdgeKind::Dep,
}
},
"first forward edge from surfaced B=1 is B->M (1<-2)"
);
}
#[test]
fn structure_path_through_off_page_node() {
let fx = Fx::new(&[
(1, 3.0, 1.0, 9.0, 0.0, 0.0, 0.0),
(2, 2.0, 1.0, 8.0, 0.0, 0.0, 0.0),
(3, 10.0, 1.0, 5.0, 0.0, 0.0, 0.0),
])
.edge(3, 2, EdgeKind::Seq)
.edge(2, 1, EdgeKind::Seq);
let out = detect(&fx.inputs(1));
let t = out
.iter()
.find(|t| t.preferred == key(3) && t.surfaced == key(1))
.expect("off-page preferred still detected (F-4)");
assert!(matches!(t.cause, TensionCause::Structure { .. }));
}
#[test]
fn composition_when_no_structural_path() {
let fx = Fx::new(&[
(1, 5.0, 1.0, 12.0, 0.8, 2.1, 0.3),
(2, 10.0, 1.0, 4.0, 0.0, 0.0, 0.0),
]);
let out = detect(&fx.inputs(2));
assert_eq!(out.len(), 1);
assert_eq!(
out[0].cause,
TensionCause::Composition {
deltas: ComponentDeltas {
risk_dim: 0.8,
leverage: 2.1,
optionality: 0.3,
}
}
);
}
#[test]
fn value_dim_tie_is_not_a_tension() {
let fx = Fx::new(&[
(1, 5.0, 1.0, 8.0, 0.0, 0.0, 0.0),
(2, 5.0, 1.0, 4.0, 0.0, 0.0, 0.0),
]);
assert!(detect(&fx.inputs(2)).is_empty());
}
#[test]
fn equal_full_score_tiebreak_excluded() {
let fx = Fx::new(&[
(1, 5.0, 1.0, 7.0, 0.0, 0.0, 0.0),
(2, 10.0, 1.0, 7.0, 0.0, 0.0, 0.0),
]);
assert!(detect(&fx.inputs(2)).is_empty());
}
#[test]
fn zero_multiplier_pair_excluded() {
let fx = Fx::new(&[
(1, 5.0, 0.0, 8.0, 0.0, 0.0, 0.0),
(2, 10.0, 1.0, 4.0, 0.0, 0.0, 0.0),
])
.edge(2, 1, EdgeKind::Seq);
assert!(detect(&fx.inputs(2)).is_empty());
}
fn mixed_fx() -> Fx {
Fx::new(&[
(1, 3.0, 1.0, 9.0, 0.0, 0.0, 0.0),
(2, 3.0, 1.0, 8.0, 0.0, 1.0, 0.0),
(3, 10.0, 1.0, 5.0, 0.0, 0.0, 0.0),
])
.edge(3, 1, EdgeKind::Seq)
}
#[test]
fn mixed_structure_and_composition_both_emitted_in_order() {
let out = detect(&mixed_fx().inputs(3));
assert_eq!(out.len(), 2, "value-tied 1-2 pair excluded: {out:?}");
assert_eq!((out[0].surfaced, out[0].preferred), (key(1), key(3)));
assert!(matches!(out[0].cause, TensionCause::Structure { .. }));
assert_eq!((out[1].surfaced, out[1].preferred), (key(2), key(3)));
assert!(matches!(out[1].cause, TensionCause::Composition { .. }));
}
#[test]
fn detection_is_deterministic() {
assert_eq!(detect(&mixed_fx().inputs(3)), detect(&mixed_fx().inputs(3)));
}
fn counts(human: u32, agent: u32) -> RaterCounts {
RaterCounts { human, agent }
}
#[test]
fn knob_on_human_determined_is_determined_with_human_counts() {
let g = grade(true, true, counts(3, 0), false, counts(3, 4));
assert_eq!(
g,
EvidenceGrade::Determined {
counts: counts(3, 0)
}
);
}
#[test]
fn knob_on_agent_only_determined_is_agent_proposed_with_full_counts() {
let g = grade(true, false, counts(0, 0), true, counts(0, 4));
assert_eq!(
g,
EvidenceGrade::AgentProposed {
counts: counts(0, 4)
}
);
}
#[test]
fn neither_system_determines_is_projected() {
let g = grade(true, false, counts(0, 0), false, counts(0, 0));
assert_eq!(g, EvidenceGrade::Projected);
}
#[test]
fn knob_off_agent_proposed_unreachable() {
let g = grade(false, false, counts(0, 0), true, counts(0, 5));
assert_eq!(g, EvidenceGrade::Projected);
}
#[test]
fn knob_off_determined_uses_verdict_counts() {
let g = grade(false, true, counts(2, 3), false, counts(2, 3));
assert_eq!(
g,
EvidenceGrade::Determined {
counts: counts(2, 3)
}
);
}
#[test]
fn zero_weight_excluded_counts_m0_value_inversions() {
let fx = Fx::new(&[
(1, 5.0, 0.0, 8.0, 0.0, 0.0, 0.0),
(2, 10.0, 1.0, 4.0, 0.0, 0.0, 0.0),
]);
assert_eq!(zero_weight_excluded(&fx.inputs(2)), 1);
assert!(
detect(&fx.inputs(2)).is_empty(),
"m=0 pair is not a tension"
);
}
#[test]
fn zero_weight_excluded_ignores_full_weight_and_non_inversions() {
let fx = Fx::new(&[
(1, 10.0, 1.0, 8.0, 0.0, 0.0, 0.0), (2, 5.0, 0.0, 4.0, 0.0, 0.0, 0.0), ]);
assert_eq!(zero_weight_excluded(&fx.inputs(2)), 0);
}
}