use telic::planning::utility::*;
#[test]
fn linear_curve_clamps() {
let curve = ResponseCurve::Linear { min: 0.0, max: 10.0 };
assert_eq!(curve.evaluate(0.0), 0.0);
assert_eq!(curve.evaluate(5.0), 0.5);
assert_eq!(curve.evaluate(10.0), 1.0);
assert_eq!(curve.evaluate(-5.0), 0.0); assert_eq!(curve.evaluate(20.0), 1.0); }
#[test]
fn linear_curve_equal_min_max() {
let curve = ResponseCurve::Linear { min: 5.0, max: 5.0 };
assert_eq!(curve.evaluate(5.0), 1.0);
assert_eq!(curve.evaluate(4.0), 0.0);
}
#[test]
fn inverse_curve() {
let curve = ResponseCurve::Inverse { steepness: 1.0 };
assert_eq!(curve.evaluate(0.0), 1.0); assert_eq!(curve.evaluate(1.0), 0.5); assert!((curve.evaluate(3.0) - 0.25).abs() < 0.001); }
#[test]
fn threshold_curve() {
let curve = ResponseCurve::Threshold { threshold: 5.0 };
assert_eq!(curve.evaluate(4.9), 0.0);
assert_eq!(curve.evaluate(5.0), 1.0);
assert_eq!(curve.evaluate(10.0), 1.0);
}
#[test]
fn boolean_curve() {
assert_eq!(ResponseCurve::Boolean.evaluate(0.0), 0.0);
assert_eq!(ResponseCurve::Boolean.evaluate(-1.0), 0.0);
assert_eq!(ResponseCurve::Boolean.evaluate(0.001), 1.0);
assert_eq!(ResponseCurve::Boolean.evaluate(5.0), 1.0);
}
#[test]
fn constant_curve_ignores_input() {
let curve = ResponseCurve::Constant(0.7);
assert_eq!(curve.evaluate(0.0), 0.7);
assert_eq!(curve.evaluate(999.0), 0.7);
}
#[test]
fn identity_curve_passes_through() {
assert_eq!(ResponseCurve::Identity.evaluate(3.14), 3.14);
assert_eq!(ResponseCurve::Identity.evaluate(-5.0), -5.0);
}
#[test]
fn custom_curve() {
let curve = ResponseCurve::Custom(std::sync::Arc::new(|v| v * v));
assert_eq!(curve.evaluate(3.0), 9.0);
assert_eq!(curve.evaluate(0.0), 0.0);
}
struct Ctx {
value: f64,
distance: f64,
threat: f64,
}
#[test]
fn additive_scoring() {
let action = UtilityAction::new("test")
.with_base(1.0)
.with_mode(ScoringMode::Additive)
.consider("value", |ctx: &Ctx| ctx.value, ResponseCurve::Identity, 1.0)
.consider("threat", |ctx: &Ctx| ctx.threat, ResponseCurve::Identity, 0.5);
let ctx = Ctx { value: 10.0, distance: 0.0, threat: 4.0 };
assert_eq!(action.score(&ctx), 13.0);
}
#[test]
fn multiplicative_scoring() {
let action = UtilityAction::new("test")
.with_base(10.0)
.with_mode(ScoringMode::Multiplicative)
.consider("factor", |ctx: &Ctx| ctx.value, ResponseCurve::Identity, 1.0);
let ctx = Ctx { value: 2.0, distance: 0.0, threat: 0.0 };
assert_eq!(action.score(&ctx), 20.0);
}
#[test]
fn multiplicative_weight_blends_toward_one() {
let action = UtilityAction::new("test")
.with_base(10.0)
.with_mode(ScoringMode::Multiplicative)
.consider("half_weight", |ctx: &Ctx| ctx.value, ResponseCurve::Identity, 0.5);
let ctx = Ctx { value: 0.0, distance: 0.0, threat: 0.0 };
assert_eq!(action.score(&ctx), 5.0);
}
#[test]
fn zero_consideration_kills_multiplicative() {
let action = UtilityAction::new("test")
.with_base(10.0)
.with_mode(ScoringMode::Multiplicative)
.consider("veto", |_: &Ctx| 0.0, ResponseCurve::Identity, 1.0)
.consider("bonus", |_: &Ctx| 100.0, ResponseCurve::Identity, 1.0);
let ctx = Ctx { value: 0.0, distance: 0.0, threat: 0.0 };
assert_eq!(action.score(&ctx), 0.0);
}
#[test]
fn trace_matches_score() {
let action = UtilityAction::new("traced")
.with_base(5.0)
.with_mode(ScoringMode::Additive)
.consider("a", |ctx: &Ctx| ctx.value, ResponseCurve::Identity, 1.0)
.consider("b", |ctx: &Ctx| ctx.distance, ResponseCurve::Identity, 2.0);
let ctx = Ctx { value: 3.0, distance: 4.0, threat: 0.0 };
let score = action.score(&ctx);
let trace = action.score_with_trace(&ctx);
assert_eq!(score, trace.total_score);
assert_eq!(trace.entries.len(), 2);
assert_eq!(trace.entries[0].name, "a");
assert_eq!(trace.entries[0].raw_value, 3.0);
assert_eq!(trace.entries[0].contribution, 3.0); assert_eq!(trace.entries[1].name, "b");
assert_eq!(trace.entries[1].contribution, 8.0); }
#[test]
fn rank_actions_sorts_descending() {
let actions = vec![
UtilityAction::new("low").with_base(1.0).with_mode(ScoringMode::Additive),
UtilityAction::new("high").with_base(10.0).with_mode(ScoringMode::Additive),
UtilityAction::new("mid").with_base(5.0).with_mode(ScoringMode::Additive),
];
let ranked = rank_actions::<()>(&actions, &());
assert_eq!(ranked[0].1, 1); assert_eq!(ranked[1].1, 2); assert_eq!(ranked[2].1, 0); }
#[test]
fn best_action_picks_highest() {
let actions: Vec<UtilityAction<()>> = vec![
UtilityAction::new("a").with_base(3.0),
UtilityAction::new("b").with_base(7.0),
];
let (score, idx) = best_action(&actions, &()).unwrap();
assert_eq!(idx, 1);
assert_eq!(score, 7.0);
}
#[test]
fn best_action_empty_returns_none() {
let actions: Vec<UtilityAction<()>> = vec![];
assert!(best_action(&actions, &()).is_none());
}
#[test]
fn greedy_assigns_best_pairs() {
let mut scores = vec![
vec![10.0, 5.0], vec![3.0, 8.0], ];
let result = Greedy::new().assign(&mut scores);
assert_eq!(result.len(), 2);
assert_eq!(result[0], (0, 0, 10.0));
assert_eq!(result[1], (1, 1, 8.0));
}
#[test]
fn greedy_coordination_callback_adjusts() {
let mut scores = vec![
vec![10.0],
vec![9.0],
];
let mut adjusted = false;
let result = Greedy::with_coordination(|_ei, _ti, scores: &mut Vec<Vec<f64>>| {
for row in scores.iter_mut() {
row[0] = f64::NEG_INFINITY;
}
adjusted = true;
}).assign(&mut scores);
assert!(adjusted);
assert_eq!(result.len(), 1); assert_eq!(result[0], (0, 0, 10.0));
}
#[test]
fn greedy_handles_contention() {
let mut scores = vec![
vec![10.0, 2.0],
vec![9.0, 3.0],
vec![8.0, 4.0],
];
let result = Greedy::with_coordination(|_ei, ti, scores: &mut Vec<Vec<f64>>| {
if ti == 0 {
for row in scores.iter_mut() {
row[0] *= 0.5;
}
}
}).assign(&mut scores);
assert_eq!(result.len(), 3);
assert_eq!(result[0].1, 0); assert_eq!(result[1].1, 0); assert_eq!(result[2].1, 1); }
#[test]
fn round_robin_picks_in_entity_order() {
let mut scores = vec![
vec![10.0, 5.0], vec![3.0, 8.0], vec![9.0, 2.0], ];
let result = RoundRobin::new().assign(&mut scores);
assert_eq!(result.len(), 3);
assert_eq!(result[0], (0, 0, 10.0));
assert_eq!(result[1], (1, 1, 8.0));
assert_eq!(result[2], (2, 0, 9.0));
}
#[test]
fn round_robin_respects_priority_order() {
let mut scores = vec![
vec![10.0, 5.0],
vec![3.0, 8.0],
vec![9.0, 2.0],
];
let result = RoundRobin::with_order(vec![2, 0, 1]).assign(&mut scores);
assert_eq!(result.len(), 3);
assert_eq!(result[0], (2, 0, 9.0));
assert_eq!(result[1], (0, 0, 10.0));
assert_eq!(result[2], (1, 1, 8.0));
}
#[test]
fn round_robin_coordination_prevents_reuse() {
let mut scores = vec![
vec![10.0, 5.0],
vec![9.0, 8.0], ];
let result = RoundRobin::with_coordination(None, |_ei, ti, scores: &mut Vec<Vec<f64>>| {
for row in scores.iter_mut() {
row[ti] = f64::NEG_INFINITY;
}
}).assign(&mut scores);
assert_eq!(result.len(), 2);
assert_eq!(result[0], (0, 0, 10.0));
assert_eq!(result[1], (1, 1, 8.0));
}
fn sum_scores(assignments: &[(usize, usize, f64)]) -> f64 {
assignments.iter().map(|(_, _, s)| *s).sum()
}
fn greedy_one_to_one(scores: &mut Vec<Vec<f64>>) -> Vec<(usize, usize, f64)> {
Greedy::with_coordination(|_ei, ti, s: &mut Vec<Vec<f64>>| {
for row in s.iter_mut() {
row[ti] = f64::NEG_INFINITY;
}
}).assign(scores)
}
#[test]
fn hungarian_beats_one_to_one_greedy() {
let mut scores = vec![
vec![10.0, 9.0],
vec![9.0, 0.0],
];
let greedy = greedy_one_to_one(&mut scores.clone());
let hungarian = Hungarian::new().assign(&mut scores);
assert_eq!(greedy.len(), 2);
assert_eq!(hungarian.len(), 2);
assert!((sum_scores(&hungarian) - 18.0).abs() < 1e-9);
assert!((sum_scores(&greedy) - 10.0).abs() < 1e-9);
assert!(sum_scores(&hungarian) > sum_scores(&greedy));
}
#[test]
fn hungarian_matches_greedy_when_non_conflicting() {
let scores = vec![
vec![10.0, 1.0],
vec![1.0, 10.0],
];
let hungarian = Hungarian::new().assign(&mut scores.clone());
let greedy = greedy_one_to_one(&mut scores.clone());
assert!((sum_scores(&hungarian) - sum_scores(&greedy)).abs() < 1e-9);
}
#[test]
fn hungarian_handles_rectangular_more_tasks() {
let mut scores = vec![
vec![5.0, 1.0, 10.0],
vec![8.0, 3.0, 2.0],
];
let result = Hungarian::new().assign(&mut scores);
assert_eq!(result.len(), 2);
assert!((sum_scores(&result) - 18.0).abs() < 1e-9);
let mut entities: Vec<usize> = result.iter().map(|(e, _, _)| *e).collect();
entities.sort();
assert_eq!(entities, vec![0, 1]);
}
#[test]
fn hungarian_handles_rectangular_more_entities() {
let mut scores = vec![
vec![5.0, 1.0],
vec![8.0, 3.0],
vec![2.0, 10.0],
];
let result = Hungarian::new().assign(&mut scores);
assert_eq!(result.len(), 2);
assert!((sum_scores(&result) - 18.0).abs() < 1e-9);
}
#[test]
fn hungarian_respects_forbidden_pairs() {
let mut scores = vec![
vec![10.0, f64::NEG_INFINITY],
vec![5.0, 5.0],
];
let result = Hungarian::new().assign(&mut scores);
assert_eq!(result.len(), 2);
assert!((sum_scores(&result) - 15.0).abs() < 1e-9);
assert!(result.iter().all(|&(_, _, s)| s != f64::NEG_INFINITY));
}
#[test]
fn hungarian_never_worse_than_one_to_one_greedy() {
let matrices = vec![
vec![vec![3.0, 1.0, 2.0], vec![1.0, 4.0, 5.0], vec![2.0, 6.0, 1.0]],
vec![vec![7.0, 3.0, 2.0, 1.0], vec![1.0, 8.0, 3.0, 2.0],
vec![2.0, 1.0, 9.0, 3.0], vec![3.0, 2.0, 1.0, 10.0]],
vec![vec![5.0, 5.0], vec![5.0, 5.0]],
vec![vec![10.0, 9.0, 8.0], vec![9.0, 8.0, 7.0], vec![8.0, 7.0, 6.0]],
];
for m in matrices {
let h = Hungarian::new().assign(&mut m.clone());
let g = greedy_one_to_one(&mut m.clone());
assert!(sum_scores(&h) + 1e-9 >= sum_scores(&g),
"Hungarian {} should be >= one-to-one Greedy {} for matrix {:?}",
sum_scores(&h), sum_scores(&g), m);
}
}
#[test]
fn weighted_random_produces_valid_assignments() {
let mut scores = vec![
vec![10.0, 1.0, 5.0],
vec![2.0, 8.0, 3.0],
vec![4.0, 6.0, 9.0],
];
let result = WeightedRandom::new(42).assign(&mut scores);
assert_eq!(result.len(), 3);
let mut es: Vec<usize> = result.iter().map(|(e, _, _)| *e).collect();
es.sort();
assert_eq!(es, vec![0, 1, 2]);
let mut ts: Vec<usize> = result.iter().map(|(_, t, _)| *t).collect();
ts.sort();
ts.dedup();
assert_eq!(ts.len(), result.len());
}
#[test]
fn weighted_random_is_deterministic_with_seed() {
let scores_template = vec![
vec![10.0, 5.0, 2.0],
vec![3.0, 8.0, 4.0],
vec![1.0, 2.0, 9.0],
];
let r1 = WeightedRandom::new(12345).assign(&mut scores_template.clone());
let r2 = WeightedRandom::new(12345).assign(&mut scores_template.clone());
assert_eq!(r1, r2);
}
#[test]
fn weighted_random_different_seeds_can_differ() {
let scores_template = vec![
vec![5.0, 5.0, 5.0],
vec![5.0, 5.0, 5.0],
vec![5.0, 5.0, 5.0],
];
let mut seen = std::collections::HashSet::new();
for seed in 1..20u64 {
let r = WeightedRandom::new(seed).assign(&mut scores_template.clone());
let tasks: Vec<usize> = r.iter().map(|(_, t, _)| *t).collect();
seen.insert(tasks);
}
assert!(seen.len() > 1, "expected seeds to produce different orderings");
}
#[test]
fn weighted_random_low_temperature_approaches_greedy() {
let scores_template = vec![
vec![100.0, 1.0, 1.0],
vec![1.0, 100.0, 1.0],
vec![1.0, 1.0, 100.0],
];
for seed in 1..10u64 {
let r = WeightedRandom::with_temperature(seed, 0.001).assign(&mut scores_template.clone());
let total: f64 = r.iter().map(|(_, _, s)| *s).sum();
assert!((total - 300.0).abs() < 1e-6, "low-T seed {seed} got {total}, expected 300");
}
}