use std::collections::HashMap;
use cupel::{
ChronologicalPlacer, ContextBudget, ContextItemBuilder, ContextKind, CountQuotaEntry,
CountQuotaSlice, CupelError, GreedySlice, Pipeline, QuotaEntry, QuotaSlice, RecencyScorer,
};
fn greedy_pipeline() -> Pipeline {
Pipeline::builder()
.scorer(Box::new(RecencyScorer))
.slicer(Box::new(GreedySlice))
.placer(Box::new(ChronologicalPlacer))
.build()
.unwrap()
}
fn quota_pipeline() -> Pipeline {
let quotas = vec![QuotaEntry::new(ContextKind::new("msg").unwrap(), 10.0, 90.0).unwrap()];
Pipeline::builder()
.scorer(Box::new(RecencyScorer))
.slicer(Box::new(
QuotaSlice::new(quotas, Box::new(GreedySlice)).unwrap(),
))
.placer(Box::new(ChronologicalPlacer))
.build()
.unwrap()
}
fn count_quota_pipeline() -> Pipeline {
let entries = vec![CountQuotaEntry::new(ContextKind::new("msg").unwrap(), 1, 5).unwrap()];
Pipeline::builder()
.scorer(Box::new(RecencyScorer))
.slicer(Box::new(
CountQuotaSlice::new(entries, Box::new(GreedySlice), Default::default()).unwrap(),
))
.placer(Box::new(ChronologicalPlacer))
.build()
.unwrap()
}
#[test]
fn get_marginal_items_basic() {
let pipeline = greedy_pipeline();
let now = chrono::Utc::now();
let items = vec![
ContextItemBuilder::new("small", 50)
.timestamp(now)
.build()
.unwrap(),
ContextItemBuilder::new("medium", 150)
.timestamp(now - chrono::Duration::seconds(1))
.build()
.unwrap(),
ContextItemBuilder::new("large", 300)
.timestamp(now - chrono::Duration::seconds(2))
.build()
.unwrap(),
];
let budget = ContextBudget::new(500, 500, 0, HashMap::new(), 0.0).unwrap();
let marginal = pipeline.get_marginal_items(&items, &budget, 200).unwrap();
assert!(
!marginal.is_empty(),
"expected at least one marginal item when budget is reduced by 200"
);
let full_report = pipeline.dry_run(&items, &budget).unwrap();
let full_contents: Vec<&str> = full_report
.included
.iter()
.map(|i| i.item.content())
.collect();
for m in &marginal {
assert!(
full_contents.contains(&m.content()),
"marginal item '{}' should have been in full-budget result",
m.content()
);
}
}
#[test]
fn get_marginal_items_slack_zero() {
let pipeline = greedy_pipeline();
let items = vec![
ContextItemBuilder::new("item", 100)
.timestamp(chrono::Utc::now())
.build()
.unwrap(),
];
let budget = ContextBudget::new(500, 500, 0, HashMap::new(), 0.0).unwrap();
let marginal = pipeline.get_marginal_items(&items, &budget, 0).unwrap();
assert!(
marginal.is_empty(),
"slack_tokens == 0 should return empty vec"
);
}
#[test]
fn get_marginal_items_rejects_quota_slice() {
let pipeline = quota_pipeline();
let items = vec![
ContextItemBuilder::new("item", 100)
.kind(ContextKind::new("msg").unwrap())
.timestamp(chrono::Utc::now())
.build()
.unwrap(),
];
let budget = ContextBudget::new(500, 500, 0, HashMap::new(), 0.0).unwrap();
let result = pipeline.get_marginal_items(&items, &budget, 50);
match result {
Err(CupelError::PipelineConfig(msg)) => {
assert!(
msg.contains("QuotaSlice"),
"error message should mention QuotaSlice: {msg}"
);
}
other => panic!("expected Err(PipelineConfig), got {other:?}"),
}
}
#[test]
fn find_min_budget_basic() {
let pipeline = greedy_pipeline();
let now = chrono::Utc::now();
let target = ContextItemBuilder::new("target-item", 100)
.timestamp(now - chrono::Duration::seconds(10))
.build()
.unwrap();
let high_scorer = ContextItemBuilder::new("high-scorer", 80)
.timestamp(now)
.build()
.unwrap();
let items = vec![high_scorer, target.clone()];
let budget = ContextBudget::new(500, 500, 0, HashMap::new(), 0.0).unwrap();
let result = pipeline
.find_min_budget_for(&items, &budget, &target, 500)
.unwrap();
assert!(
result.is_some(),
"target should be findable within search ceiling"
);
let min_budget = result.unwrap();
assert!(
min_budget >= target.tokens() as i32,
"min budget ({min_budget}) must be >= target tokens ({})",
target.tokens()
);
let verify_budget =
ContextBudget::new(min_budget as i64, min_budget as i64, 0, HashMap::new(), 0.0).unwrap();
let report = pipeline.dry_run(&items, &verify_budget).unwrap();
let found = report
.included
.iter()
.any(|i| i.item.content() == "target-item");
assert!(
found,
"target should be included at min budget {min_budget}"
);
}
#[test]
fn find_min_budget_not_found() {
let pipeline = greedy_pipeline();
let now = chrono::Utc::now();
let target = ContextItemBuilder::new("target", 200)
.timestamp(now - chrono::Duration::seconds(10))
.build()
.unwrap();
let blocker = ContextItemBuilder::new("blocker", 150)
.timestamp(now) .build()
.unwrap();
let items = vec![blocker, target.clone()];
let budget = ContextBudget::new(1000, 1000, 0, HashMap::new(), 0.0).unwrap();
let result = pipeline
.find_min_budget_for(&items, &budget, &target, 200)
.unwrap();
assert!(
result.is_none(),
"target should not be findable at ceiling=200 because blocker takes priority"
);
}
#[test]
fn find_min_budget_rejects_quota_slice() {
let pipeline = quota_pipeline();
let target = ContextItemBuilder::new("target", 100)
.kind(ContextKind::new("msg").unwrap())
.timestamp(chrono::Utc::now())
.build()
.unwrap();
let items = vec![target.clone()];
let budget = ContextBudget::new(500, 500, 0, HashMap::new(), 0.0).unwrap();
let result = pipeline.find_min_budget_for(&items, &budget, &target, 500);
match result {
Err(CupelError::PipelineConfig(msg)) => {
assert!(
msg.contains("QuotaSlice"),
"error message should mention QuotaSlice: {msg}"
);
}
other => panic!("expected Err(PipelineConfig), got {other:?}"),
}
}
#[test]
fn find_min_budget_rejects_count_quota_slice() {
let pipeline = count_quota_pipeline();
let target = ContextItemBuilder::new("target", 100)
.kind(ContextKind::new("msg").unwrap())
.timestamp(chrono::Utc::now())
.build()
.unwrap();
let items = vec![target.clone()];
let budget = ContextBudget::new(500, 500, 0, HashMap::new(), 0.0).unwrap();
let result = pipeline.find_min_budget_for(&items, &budget, &target, 500);
match result {
Err(CupelError::PipelineConfig(msg)) => {
assert!(
msg.contains("CountQuotaSlice"),
"error message should mention CountQuotaSlice: {msg}"
);
}
other => panic!("expected Err(PipelineConfig), got {other:?}"),
}
}
#[test]
fn find_min_budget_target_not_in_items() {
let pipeline = greedy_pipeline();
let target = ContextItemBuilder::new("not-in-list", 100)
.timestamp(chrono::Utc::now())
.build()
.unwrap();
let items = vec![
ContextItemBuilder::new("other", 100)
.timestamp(chrono::Utc::now())
.build()
.unwrap(),
];
let budget = ContextBudget::new(500, 500, 0, HashMap::new(), 0.0).unwrap();
let result = pipeline.find_min_budget_for(&items, &budget, &target, 500);
assert!(
matches!(result, Err(CupelError::InvalidBudget(_))),
"should return InvalidBudget when target not in items: {result:?}"
);
}
#[test]
fn find_min_budget_ceiling_below_tokens() {
let pipeline = greedy_pipeline();
let target = ContextItemBuilder::new("big-item", 500)
.timestamp(chrono::Utc::now())
.build()
.unwrap();
let items = vec![target.clone()];
let budget = ContextBudget::new(1000, 1000, 0, HashMap::new(), 0.0).unwrap();
let result = pipeline.find_min_budget_for(&items, &budget, &target, 200);
assert!(
matches!(result, Err(CupelError::InvalidBudget(_))),
"should return InvalidBudget when ceiling < target tokens: {result:?}"
);
}