mod common;
use common::{line_count, temp_store};
use hikmah_kernel::decision::{
estimate_missing_criteria, evaluate, Criterion, DecisionFrame, DecisionOption,
};
use hikmah_kernel::decision_port::{
DecisionEngine, DecisionRequest, EngineDescriptor, RawAnswer, RawDecision, MAX_QUESTIONS,
MAX_STATE_CHARS,
};
use hikmah_kernel::policy::KernelPolicy;
use hikmah_kernel::trace::TraceKind;
use hikmah_kernel::MemoryStore;
use std::cell::RefCell;
use std::collections::BTreeMap;
#[derive(Default)]
struct Recording {
requests: RefCell<Vec<DecisionRequest>>,
}
impl DecisionEngine for Recording {
fn descriptor(&self) -> EngineDescriptor {
EngineDescriptor {
name: "fixture".into(),
version: "1".into(),
}
}
fn decide(&self, request: &DecisionRequest) -> hikmah_kernel::Result<RawDecision> {
self.requests.borrow_mut().push(request.clone());
Ok(RawDecision {
request_id: request.request_id(),
engine: self.descriptor(),
answers: request
.questions
.iter()
.map(|q| {
(
q.id.clone(),
RawAnswer::Score {
score: 3.0,
probabilities: Some(BTreeMap::from([("3".into(), 1.0)])),
confidence: None,
},
)
})
.collect(),
latency_ms: 0,
})
}
}
fn criterion(id: &str, weight: f64) -> Criterion {
Criterion {
id: id.into(),
weight,
description: format!("{id} of the layout"),
}
}
fn option(name: &str, description: Option<&str>, scores: &[(&str, f64)]) -> DecisionOption {
DecisionOption {
name: name.into(),
description: description.map(str::to_string),
scores: scores.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
model_scores: BTreeMap::new(),
evidence_confidence: 0.6,
hard_blocks: vec![],
reversible: true,
}
}
fn hero_frame() -> DecisionFrame {
let mut video = option(
"video-hero",
Some("Autoplay background video with a single CTA."),
&[],
);
video.hard_blocks = vec!["autoplay video violates the motion rules".into()];
DecisionFrame {
question: "Which hero layout should ship?".into(),
criteria: vec![
criterion("clarity", 0.4),
criterion("perf", 0.3),
criterion("brand", 0.3),
],
options: vec![
option(
"plan-finder-hero",
Some("Hero with an inline 3-question plan finder."),
&[("clarity", 0.7)],
),
option(
"price-table-hero",
Some("Hero showing the three cheapest plans."),
&[("clarity", 0.8), ("perf", 0.6), ("brand", 0.7)],
),
video,
],
}
}
#[test]
fn hard_blocked_options_are_never_sent_and_the_rest_share_one_request() {
let engine = Recording::default();
let mut frame = hero_frame();
let estimation = estimate_missing_criteria(&engine, &mut frame).unwrap();
let requests = engine.requests.borrow();
assert_eq!(requests.len(), 1, "one request for every option");
let ids: Vec<&str> = requests[0]
.questions
.iter()
.map(|q| q.id.as_str())
.collect();
assert_eq!(ids, vec!["o0_c1", "o0_c2"]);
assert!(
!requests[0].state.contains("video-hero")
&& requests[0]
.questions
.iter()
.all(|q| !q.instructions.contains("video-hero")),
"a hard-blocked option can never be recommended, so it is never asked about"
);
assert!(requests[0].state.contains("plan-finder-hero"));
assert_eq!(estimation.skipped_blocked, vec!["video-hero"]);
assert_eq!(estimation.exchanges.len(), 1);
assert_eq!(estimation.estimates.len(), 2);
for estimate in &estimation.estimates {
assert_eq!(estimate.option, "plan-finder-hero");
assert_eq!(estimate.score, Some(0.75));
assert_eq!(estimate.engine, "model:fixture@1");
assert!(!estimate.calibrated && estimate.reason.is_none());
}
assert_eq!(
frame.options[0].model_scores,
BTreeMap::from([("brand".to_string(), 0.75), ("perf".to_string(), 0.75)])
);
assert!(frame.options[2].model_scores.is_empty());
let result = evaluate(&frame).unwrap();
let blocked = result
.ranking
.iter()
.find(|o| o.name == "video-hero")
.unwrap();
assert!(blocked.blocked && blocked.model_estimated_criteria.is_empty());
let traces = estimation.prediction_traces();
let families: Vec<&str> = traces
.iter()
.map(|t| t.prediction.as_ref().unwrap().family.as_str())
.collect();
assert_eq!(families, vec!["decide.perf", "decide.brand"]);
assert!(traces
.iter()
.all(|t| t.is_model_authored() && !t.provenance.verified));
}
#[test]
fn requests_are_split_only_at_the_question_limit() {
let criteria: Vec<Criterion> = (0..12).map(|j| criterion(&format!("k{j}"), 1.0)).collect();
let options: Vec<DecisionOption> = (0..3)
.map(|i| {
let name = format!("layout-{i}");
let description = format!("Candidate layout number {i}.");
option(&name, Some(&description), &[])
})
.collect();
let mut frame = DecisionFrame {
question: "Which layout?".into(),
criteria,
options,
};
let engine = Recording::default();
let estimation = estimate_missing_criteria(&engine, &mut frame).unwrap();
let requests = engine.requests.borrow();
let sizes: Vec<usize> = requests.iter().map(|r| r.questions.len()).collect();
assert_eq!(sizes, vec![MAX_QUESTIONS, 36 - MAX_QUESTIONS]);
assert!(requests[1].state.contains("layout-2") && !requests[1].state.contains("layout-0"));
assert!(requests[1]
.questions
.iter()
.all(|q| q.id.starts_with("o2_")));
assert_eq!(estimation.estimates.len(), 36);
assert!(estimation.estimates.iter().all(|e| e.score == Some(0.75)));
assert_eq!(estimation.prediction_traces().len(), 36);
}
#[test]
fn requests_are_split_where_the_state_would_pass_its_limit() {
let long = |i: usize| {
let sentence = format!("Layout {i} keeps the plan finder above the fold. ");
sentence.repeat(MAX_STATE_CHARS / 2 / sentence.len() + 1)
};
let (first, second) = (long(0), long(1));
assert!(first.chars().count() < MAX_STATE_CHARS / 2 + 100);
let mut frame = DecisionFrame {
question: "Which layout?".into(),
criteria: vec![criterion("clarity", 0.5), criterion("perf", 0.5)],
options: vec![
option("layout-0", Some(&first), &[]),
option("layout-1", Some(&second), &[]),
option("layout-2", Some("A short third layout."), &[]),
],
};
let engine = Recording::default();
let estimation = estimate_missing_criteria(&engine, &mut frame).unwrap();
let requests = engine.requests.borrow();
let ids: Vec<Vec<&str>> = requests
.iter()
.map(|r| r.questions.iter().map(|q| q.id.as_str()).collect())
.collect();
assert_eq!(
ids,
vec![
vec!["o0_c0", "o0_c1"],
vec!["o1_c0", "o1_c1", "o2_c0", "o2_c1"]
],
"split only where the next option's line would not fit"
);
for request in requests.iter() {
request.validate().unwrap();
assert!(request.state.chars().count() <= MAX_STATE_CHARS);
}
assert!(requests[0].state.contains("layout-0") && !requests[0].state.contains("layout-1"));
assert!(!requests[1].state.contains("layout-0"));
assert!(requests[1].state.contains("layout-1") && requests[1].state.contains("layout-2"));
assert_eq!(estimation.estimates.len(), 6);
assert_eq!(estimation.exchanges.len(), 2);
}
#[test]
fn nothing_is_sent_for_an_invalid_frame_or_when_nothing_is_missing() {
let engine = Recording::default();
let mut complete = hero_frame();
complete.options.retain(|o| o.name == "price-table-hero");
let estimation = estimate_missing_criteria(&engine, &mut complete).unwrap();
assert!(estimation.estimates.is_empty() && estimation.exchanges.is_empty());
let mut duplicate = hero_frame();
duplicate.options[1].name = "plan-finder-hero".into();
assert!(estimate_missing_criteria(&engine, &mut duplicate).is_err());
assert!(engine.requests.borrow().is_empty());
}
#[test]
fn recorded_estimates_are_written_in_one_batch() {
let engine = Recording::default();
let mut frame = hero_frame();
let estimation = estimate_missing_criteria(&engine, &mut frame).unwrap();
let path = temp_store("decide-record");
let mut store = MemoryStore::open(&path, KernelPolicy::default()).unwrap();
let recorded = store.remember_many(estimation.prediction_traces()).unwrap();
assert_eq!(recorded.len(), 2);
assert_ne!(recorded[0].0.id, recorded[1].0.id);
assert_eq!(store.record_count(), 2);
assert_eq!(line_count(&path), 2);
let reopened = MemoryStore::open_existing(&path, KernelPolicy::default()).unwrap();
assert_eq!(
reopened
.all()
.filter(|e| e.trace.kind == TraceKind::Prediction)
.count(),
2
);
}