1use crate::decision_port::{
36 ask, AdmittedDecision, DecisionEngine, DecisionRequest, Question, QuestionKind, MAX_QUESTIONS,
37 MAX_STATE_CHARS,
38};
39use crate::error::{KernelError, Result};
40use crate::trace::Trace;
41use serde::{Deserialize, Serialize};
42use std::collections::{BTreeMap, BTreeSet};
43
44pub const REVERSIBILITY_BAND: f64 = 0.02;
45pub const WEAK_EVIDENCE: f64 = 0.7;
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DecisionFrame {
49 pub question: String,
50 pub criteria: Vec<Criterion>,
51 pub options: Vec<DecisionOption>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Criterion {
56 pub id: String,
57 pub weight: f64,
58 pub description: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DecisionOption {
63 pub name: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub description: Option<String>,
67 #[serde(default)]
68 pub scores: BTreeMap<String, f64>,
69 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
71 pub model_scores: BTreeMap<String, f64>,
72 pub evidence_confidence: f64,
73 #[serde(default)]
74 pub hard_blocks: Vec<String>,
75 #[serde(default)]
76 pub reversible: bool,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RankedOption {
81 pub name: String,
82 pub score_interval: [f64; 2],
84 #[serde(default)]
88 pub evidence_interval: [f64; 2],
89 pub raw_score: f64,
90 pub confidence_adjusted_score: f64,
91 pub evidence_confidence: f64,
93 pub coverage: f64,
94 pub reversible: bool,
95 pub blocked: bool,
96 pub hard_blocks: Vec<String>,
97 pub missing_criteria: Vec<String>,
98 #[serde(default)]
99 pub model_estimated_criteria: Vec<String>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct DecisionResult {
104 pub question: String,
105 pub ranking: Vec<RankedOption>,
106 pub recommended: Option<String>,
108 pub no_admissible_option: bool,
109 pub reversibility_preferred: bool,
111 pub decisive: bool,
114}
115
116pub fn evaluate(frame: &DecisionFrame) -> Result<DecisionResult> {
117 if frame.criteria.is_empty() || frame.options.is_empty() {
118 return Err(KernelError::Invalid(
119 "decision frame requires at least one criterion and one option".into(),
120 ));
121 }
122 let mut ids = BTreeSet::new();
123 for criterion in &frame.criteria {
124 if !criterion.weight.is_finite() || criterion.weight < 0.0 {
125 return Err(KernelError::Invalid(format!(
126 "criterion {} needs a finite, non-negative weight",
127 criterion.id
128 )));
129 }
130 if !ids.insert(criterion.id.as_str()) {
131 return Err(KernelError::Invalid(format!(
132 "duplicate criterion id: {}",
133 criterion.id
134 )));
135 }
136 }
137 let total_weight: f64 = frame.criteria.iter().map(|c| c.weight).sum();
138 if !total_weight.is_finite() || total_weight <= f64::EPSILON {
139 return Err(KernelError::Invalid(
140 "decision criteria must contain positive, finite total weight".into(),
141 ));
142 }
143 let mut names = BTreeSet::new();
144 for option in &frame.options {
145 if !names.insert(option.name.as_str()) {
146 return Err(KernelError::Invalid(format!(
147 "duplicate option name: {}",
148 option.name
149 )));
150 }
151 for id in option.scores.keys().chain(option.model_scores.keys()) {
152 if !ids.contains(id.as_str()) {
153 return Err(KernelError::Invalid(format!(
154 "option {} scores unknown criterion {id}",
155 option.name
156 )));
157 }
158 }
159 }
160
161 let mut ranking = Vec::new();
162 for option in &frame.options {
163 if !(0.0..=1.0).contains(&option.evidence_confidence) {
164 return Err(KernelError::Invalid(format!(
165 "evidence_confidence for {} must be between 0 and 1",
166 option.name
167 )));
168 }
169 let mut weighted = 0.0;
170 let mut scored_weight = 0.0;
171 let mut evidence_weight = 0.0;
172 let mut evidence_sum = 0.0;
173 let mut missing_weight = 0.0;
174 let mut missing = Vec::new();
175 let mut estimated = Vec::new();
176 for criterion in &frame.criteria {
177 let (score, is_evidence) = match (
178 option.scores.get(&criterion.id),
179 option.model_scores.get(&criterion.id),
180 ) {
181 (Some(score), _) => (Some(*score), true),
182 (None, Some(score)) => (Some(*score), false),
183 (None, None) => (None, false),
184 };
185 match score {
186 Some(score) if (0.0..=1.0).contains(&score) => {
187 weighted += score * criterion.weight;
188 scored_weight += criterion.weight;
189 if is_evidence {
190 evidence_weight += criterion.weight;
191 evidence_sum += score * criterion.weight;
192 } else {
193 estimated.push(criterion.id.clone());
194 }
195 }
196 Some(_) => {
197 return Err(KernelError::Invalid(format!(
198 "score for {} / {} must be between 0 and 1",
199 option.name, criterion.id
200 )));
201 }
202 None => {
203 missing_weight += criterion.weight;
204 missing.push(criterion.id.clone());
205 }
206 }
207 }
208 let coverage = evidence_weight / total_weight;
209 let score_interval = [
210 (weighted / total_weight).clamp(0.0, 1.0),
211 ((weighted + missing_weight) / total_weight).clamp(0.0, 1.0),
212 ];
213 let evidence_interval = [
214 (evidence_sum / total_weight).clamp(0.0, 1.0),
215 ((evidence_sum + total_weight - evidence_weight) / total_weight).clamp(0.0, 1.0),
216 ];
217 let raw_score = if scored_weight > 0.0 {
218 weighted / scored_weight
219 } else {
220 0.0
221 };
222 let confidence = option.evidence_confidence * coverage;
223 let confidence_adjusted_score = raw_score * (0.5 + 0.5 * confidence);
224 ranking.push(RankedOption {
225 name: option.name.clone(),
226 score_interval,
227 evidence_interval,
228 raw_score,
229 confidence_adjusted_score,
230 evidence_confidence: confidence,
231 coverage,
232 reversible: option.reversible,
233 blocked: !option.hard_blocks.is_empty(),
234 hard_blocks: option.hard_blocks.clone(),
235 missing_criteria: missing,
236 model_estimated_criteria: estimated,
237 });
238 }
239
240 let by = |x: f64, y: f64| y.partial_cmp(&x).unwrap_or(std::cmp::Ordering::Equal);
241 ranking.sort_by(|a, b| {
242 a.blocked
243 .cmp(&b.blocked)
244 .then_with(|| by(a.score_interval[0], b.score_interval[0]))
245 .then_with(|| by(a.score_interval[1], b.score_interval[1]))
246 .then_with(|| b.reversible.cmp(&a.reversible))
247 .then_with(|| a.name.cmp(&b.name))
248 });
249
250 let mut reversibility_preferred = false;
251 if let Some(top) = ranking.first() {
252 if !top.blocked && !top.reversible && top.evidence_confidence < WEAK_EVIDENCE {
253 let cutoff = top.score_interval[0] - REVERSIBILITY_BAND;
254 if let Some(index) = ranking
255 .iter()
256 .position(|o| !o.blocked && o.reversible && o.score_interval[0] >= cutoff)
257 {
258 let preferred = ranking.remove(index);
259 ranking.insert(0, preferred);
260 reversibility_preferred = true;
261 }
262 }
263 }
264
265 let top = ranking.iter().find(|option| !option.blocked);
266 let recommended = top.map(|option| option.name.clone());
267 let decisive = top.is_some_and(|top| {
268 ranking
269 .iter()
270 .filter(|other| !other.blocked && other.name != top.name)
271 .all(|other| top.evidence_interval[0] > other.evidence_interval[1])
273 });
274 Ok(DecisionResult {
275 question: frame.question.clone(),
276 no_admissible_option: recommended.is_none(),
277 recommended,
278 ranking,
279 reversibility_preferred,
280 decisive,
281 })
282}
283
284pub const ESTIMATE_LEVELS: [&str; 5] = ["very poor", "poor", "fair", "good", "excellent"];
286
287#[derive(Debug, Clone, Serialize)]
289pub struct CriterionEstimate {
290 pub option: String,
291 pub criterion: String,
292 pub question_id: String,
294 pub score: Option<f64>,
296 pub engine: String,
298 pub calibrated: bool,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub reason: Option<String>,
303}
304
305#[derive(Debug, Clone)]
307pub struct Estimation {
308 pub estimates: Vec<CriterionEstimate>,
309 pub skipped_blocked: Vec<String>,
312 pub exchanges: Vec<(DecisionRequest, AdmittedDecision)>,
314}
315
316impl Estimation {
317 pub fn prediction_traces(&self) -> Vec<Trace> {
320 self.exchanges
321 .iter()
322 .flat_map(|(request, decision)| decision.prediction_traces(request))
323 .collect()
324 }
325}
326
327pub fn estimate_missing_criteria(
333 engine: &dyn DecisionEngine,
334 frame: &mut DecisionFrame,
335) -> Result<Estimation> {
336 evaluate(frame)?;
337 let header = format!("Decision: {}\nOptions:\n", frame.question.trim());
338 let option_line = |option: &DecisionOption, description: &str| {
339 format!("- {}: {}\n", option.name, description.trim())
340 };
341
342 let mut chunks: Vec<Vec<(usize, usize)>> = Vec::new();
344 let mut current: Vec<(usize, usize)> = Vec::new();
345 let mut in_current: BTreeSet<usize> = BTreeSet::new();
346 let mut state_chars = header.chars().count();
347 let mut skipped_blocked = Vec::new();
348 for (i, option) in frame.options.iter().enumerate() {
349 let Some(description) = option
350 .description
351 .as_deref()
352 .filter(|d| !d.trim().is_empty())
353 else {
354 continue;
355 };
356 let missing: Vec<usize> = frame
357 .criteria
358 .iter()
359 .enumerate()
360 .filter(|(_, c)| {
361 !option.scores.contains_key(&c.id) && !option.model_scores.contains_key(&c.id)
362 })
363 .map(|(j, _)| j)
364 .collect();
365 if missing.is_empty() {
366 continue;
367 }
368 if !option.hard_blocks.is_empty() {
369 skipped_blocked.push(option.name.clone());
370 continue;
371 }
372 let line_chars = option_line(option, description).chars().count();
373 for j in missing {
374 let adds_line = !in_current.contains(&i);
375 let full = current.len() == MAX_QUESTIONS
376 || (adds_line && state_chars + line_chars > MAX_STATE_CHARS);
377 if full && !current.is_empty() {
378 chunks.push(std::mem::take(&mut current));
379 in_current.clear();
380 state_chars = header.chars().count();
381 }
382 if in_current.insert(i) {
383 state_chars += line_chars;
384 }
385 current.push((i, j));
386 }
387 }
388 if !current.is_empty() {
389 chunks.push(current);
390 }
391
392 let mut estimates = Vec::new();
393 let mut exchanges = Vec::new();
394 for chunk in chunks {
395 let mut state = header.clone();
396 let mut listed = BTreeSet::new();
397 for &(i, _) in &chunk {
398 if listed.insert(i) {
399 let option = &frame.options[i];
400 state.push_str(&option_line(
401 option,
402 option.description.as_deref().unwrap_or_default(),
403 ));
404 }
405 }
406 let questions = chunk
407 .iter()
408 .map(|&(i, j)| {
409 let criterion = &frame.criteria[j];
410 Question {
411 id: format!("o{i}_c{j}"),
412 instructions: format!(
413 "Rate option \"{}\" on: {} ({})",
414 frame.options[i].name, criterion.description, criterion.id
415 ),
416 kind: QuestionKind::Score {
417 levels: ESTIMATE_LEVELS.iter().map(|s| s.to_string()).collect(),
418 },
419 family: Some(format!("decide.{}", criterion.id)),
420 }
421 })
422 .collect();
423 let request = DecisionRequest::new(state, questions)?;
424 let decision = ask(engine, &request)?;
425 for &(i, j) in &chunk {
426 let question_id = format!("o{i}_c{j}");
427 let criterion = frame.criteria[j].id.clone();
428 let option = &mut frame.options[i];
429 let score = decision
430 .answer(&question_id)
431 .and_then(|answer| answer.normalized_score());
432 if let Some(score) = score {
433 option.model_scores.insert(criterion.clone(), score);
434 }
435 estimates.push(CriterionEstimate {
436 option: option.name.clone(),
437 criterion,
438 question_id,
439 score,
440 engine: decision.engine.source(),
441 calibrated: false,
442 reason: score.is_none().then(|| {
443 decision
444 .rejected
445 .clone()
446 .unwrap_or_else(|| "abstained".into())
447 }),
448 });
449 }
450 exchanges.push((request, decision));
451 }
452 Ok(Estimation {
453 estimates,
454 skipped_blocked,
455 exchanges,
456 })
457}