Skip to main content

hikmah_kernel/
decision.rs

1//! Weighted multi-criteria decisions with hard blocks and a reversibility preference.
2//!
3//! Semantics:
4//! - A criterion with no score (neither evidence nor a model estimate) is *unknown*: it could
5//!   be anywhere on the score scale [0, 1]. It is not zero, and it is not the average of the
6//!   known criteria (that would be a guess).
7//! - `score_interval = [lo, hi]` is interval arithmetic over the weighted mean of every
8//!   criterion: `lo` puts each unknown criterion at the scale minimum (0) and `hi` at the scale
9//!   maximum (1), so `hi − lo` is the weight share still unknown. No prior is invented.
10//! - Admissible options rank by `lo` (the score they are guaranteed on the stated scores), then
11//!   by `hi`, then reversible first, then name. An option with one excellent score and three
12//!   unknowns therefore cannot outrank a fully evidenced option whose guaranteed score is higher.
13//! - `evidence_interval` is the same interval over evidence alone: model-estimated criteria
14//!   count as unknown there.
15//! - `decisive` is true only when the recommended option's evidence `lo` is strictly greater
16//!   than every other admissible option's evidence `hi`: no resolution of the unknowns, and no
17//!   engine estimate proving wrong, could change the winner. Exact ties are not decisive.
18//! - `coverage` counts only evidence-backed criteria (`scores`). Model-estimated criteria
19//!   (`model_scores`, filled through the typed decision port) are point values in
20//!   `score_interval`, so they can reorder the ranking, but they never raise coverage and never
21//!   make a result decisive.
22//! - `raw_score` (mean over scored criteria) and
23//!   `confidence_adjusted_score = raw_score × (0.5 + 0.5 × evidence_confidence × coverage)` are
24//!   still reported for comparison with 3.1.0; they no longer order the ranking.
25//! - Blocked options always rank after admissible ones; `recommended` is `None` when every
26//!   option is blocked.
27//! - When the best admissible option is irreversible, evidence is weak, and a reversible option's
28//!   `lo` is within `REVERSIBILITY_BAND` of the best `lo`, the reversible option is recommended.
29//!
30//! The band and the weak-evidence threshold are explicit heuristics.
31//!
32//! [`estimate_missing_criteria`] fills `model_scores` from a typed decision engine. Options with a
33//! hard block are never sent (they can never be recommended), and every other estimate goes into
34//! as few requests as the port's limits allow, one score question `o{option}_c{criterion}` each.
35use 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    /// Optional free-text description; a decision engine can estimate missing criteria from it.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub description: Option<String>,
67    #[serde(default)]
68    pub scores: BTreeMap<String, f64>,
69    /// Scores estimated by a decision engine. Used for ranking, never for coverage.
70    #[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    /// `[lo, hi]`: the weighted score with every unscored criterion at 0 and at 1.
83    pub score_interval: [f64; 2],
84    /// `[lo, hi]` over evidence alone: model-estimated criteria are unknown here, like unscored
85    /// ones. `decisive` is computed from this interval, so an engine guess can reorder options
86    /// but can never make a result look settled.
87    #[serde(default)]
88    pub evidence_interval: [f64; 2],
89    pub raw_score: f64,
90    pub confidence_adjusted_score: f64,
91    /// `evidence_confidence × coverage`.
92    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    /// Best admissible option, or `None` when every option is blocked.
107    pub recommended: Option<String>,
108    pub no_admissible_option: bool,
109    /// True when the reversibility preference changed the recommendation.
110    pub reversibility_preferred: bool,
111    /// True only when the recommended option's lower bound is at least every other admissible
112    /// option's upper bound, so no value of the unknown criteria could change the winner.
113    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            // Strict: on an exact tie only the name tie-break separates them.
272            .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
284/// Levels of the score question asked for each missing criterion, lowest first.
285pub const ESTIMATE_LEVELS: [&str; 5] = ["very poor", "poor", "fair", "good", "excellent"];
286
287/// One criterion an engine was asked to estimate for one option.
288#[derive(Debug, Clone, Serialize)]
289pub struct CriterionEstimate {
290    pub option: String,
291    pub criterion: String,
292    /// Question id in the engine request: `o{option index}_c{criterion index}`.
293    pub question_id: String,
294    /// Normalized score in [0, 1], or `None` when the engine abstained.
295    pub score: Option<f64>,
296    /// `model:<engine>@<version>`.
297    pub engine: String,
298    /// Always false: the kernel does not calibrate engine estimates (see `hikmah calibration`).
299    pub calibrated: bool,
300    /// Why there is no score.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub reason: Option<String>,
303}
304
305/// What [`estimate_missing_criteria`] asked and got back.
306#[derive(Debug, Clone)]
307pub struct Estimation {
308    pub estimates: Vec<CriterionEstimate>,
309    /// Options with a description and missing criteria that were not sent because they have a
310    /// hard block: they can never be recommended, so an estimate could not change the result.
311    pub skipped_blocked: Vec<String>,
312    /// Each request sent and its admitted decision, in order.
313    pub exchanges: Vec<(DecisionRequest, AdmittedDecision)>,
314}
315
316impl Estimation {
317    /// Every admitted estimate as an unverified `prediction` trace (family
318    /// `decide.<criterion id>`), for one batched write.
319    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
327/// Ask `engine` to estimate the criteria an option has no score for, from its description, and
328/// store the answers in `model_scores`. The frame is validated first, so an invalid frame never
329/// reaches the engine. Options without a description, and options with a hard block, are not
330/// sent. All other questions share one request, split only where a request would exceed
331/// [`MAX_QUESTIONS`] questions or [`MAX_STATE_CHARS`] characters of state.
332pub 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    // (option index, criterion index) pairs, grouped into requests.
343    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}