Skip to main content

hikmah_kernel/
consolidation.rs

1use crate::claims::{normalize_key, normalize_value};
2use crate::ledger::MemoryStore;
3use crate::trace::TraceKind;
4
5const UNATTRIBUTED: &[&str] = &["", "unknown", "user"];
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ConsolidationProposal {
11    pub claim_key: String,
12    pub claim_value: String,
13    pub support_trace_ids: Vec<String>,
14    pub independent_sources: Vec<String>,
15    pub conflicting_values: Vec<String>,
16    pub confidence: f32,
17    pub eligible_for_promotion: bool,
18}
19
20impl MemoryStore {
21    /// Quiet Replay: generate evidence-preserving consolidation proposals.
22    /// This never writes a new belief automatically.
23    pub fn consolidation_proposals(&self) -> Vec<ConsolidationProposal> {
24        let mut grouped: BTreeMap<String, BTreeMap<String, Vec<_>>> = BTreeMap::new();
25        let mut display: BTreeMap<(String, String), (String, String)> = BTreeMap::new();
26        for trace in self.active_traces() {
27            // Model predictions are proposals, never supporting evidence for a belief.
28            if trace.kind == TraceKind::Prediction || trace.is_model_authored() {
29                continue;
30            }
31            let (Some(key), Some(value)) = (&trace.claim_key, &trace.claim_value) else {
32                continue;
33            };
34            let (nk, nv) = (normalize_key(key), normalize_value(value));
35            display
36                .entry((nk.clone(), nv.clone()))
37                .or_insert_with(|| (key.clone(), value.clone()));
38            grouped
39                .entry(nk)
40                .or_default()
41                .entry(nv)
42                .or_default()
43                .push(trace);
44        }
45
46        let mut proposals = Vec::new();
47        for (key, values) in grouped {
48            for (value, traces) in &values {
49                // Independence is judged on normalized source names, so `Config-A` and
50                // `config-a` count once. Unattributed writes (the CLI defaults `unknown`, and
51                // `user` from 3.0.0) all count as one source.
52                let sources: BTreeSet<String> = traces
53                    .iter()
54                    .map(|trace| {
55                        let source = trace.provenance.source.trim().to_lowercase();
56                        if UNATTRIBUTED.contains(&source.as_str()) {
57                            "unattributed".to_string()
58                        } else {
59                            source
60                        }
61                    })
62                    .collect();
63                let average_confidence = if traces.is_empty() {
64                    0.0
65                } else {
66                    traces.iter().map(|trace| trace.confidence).sum::<f32>() / traces.len() as f32
67                };
68                let verification_ratio = if traces.is_empty() {
69                    0.0
70                } else {
71                    traces
72                        .iter()
73                        .filter(|trace| trace.provenance.verified)
74                        .count() as f32
75                        / traces.len() as f32
76                };
77                let confidence =
78                    (0.7 * average_confidence + 0.3 * verification_ratio).clamp(0.0, 1.0);
79                let conflicting_values = values
80                    .keys()
81                    .filter(|other| *other != value)
82                    .map(|other| {
83                        display
84                            .get(&(key.clone(), other.clone()))
85                            .map(|(_, original)| original.clone())
86                            .unwrap_or_else(|| other.clone())
87                    })
88                    .collect::<Vec<_>>();
89                let eligible_for_promotion = traces.len()
90                    >= self.policy().consolidation_min_support
91                    && sources.len() >= self.policy().consolidation_min_independent_sources
92                    && confidence >= self.policy().consolidation_min_confidence
93                    && conflicting_values.is_empty();
94                let (shown_key, shown_value) = display
95                    .get(&(key.clone(), value.clone()))
96                    .cloned()
97                    .unwrap_or_else(|| (key.clone(), value.clone()));
98
99                proposals.push(ConsolidationProposal {
100                    claim_key: shown_key,
101                    claim_value: shown_value,
102                    support_trace_ids: traces.iter().map(|trace| trace.id.clone()).collect(),
103                    independent_sources: sources.into_iter().collect(),
104                    conflicting_values,
105                    confidence,
106                    eligible_for_promotion,
107                });
108            }
109        }
110        proposals.sort_by(|a, b| {
111            b.eligible_for_promotion
112                .cmp(&a.eligible_for_promotion)
113                .then_with(|| {
114                    b.confidence
115                        .partial_cmp(&a.confidence)
116                        .unwrap_or(std::cmp::Ordering::Equal)
117                })
118        });
119        proposals
120    }
121}