hikmah_kernel/
consolidation.rs1use 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 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 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 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}