Skip to main content

hikmah_kernel/
policy.rs

1//! Kernel policy: memory limits, recall and consolidation thresholds, every recall weight, and
2//! the calibration sample minimum, as data. The decision, council, planner, and statistical
3//! constants (for example `decision::REVERSIBILITY_BAND` or `calibration::Z_CRITICAL`) are
4//! code, not policy.
5//!
6//! The defaults are the shipped design choices (none of them is calibrated). A JSON policy file
7//! (`hikmah --policy <file>` or `HIKMAH_POLICY`) may override any subset of fields; missing fields
8//! keep their defaults and unknown fields are rejected, so a typo cannot silently do nothing.
9//! `hikmah policy --print-defaults` prints the full default policy.
10use crate::error::{KernelError, Result};
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(default, deny_unknown_fields)]
16pub struct KernelPolicy {
17    pub working_set_limit: usize,
18    pub recall_limit: usize,
19    /// Minimum relevance (`cue`) a trace needs to be recalled.
20    pub minimum_recall_score: f32,
21    pub allow_sensitive_persistence: bool,
22    pub consolidation_min_support: usize,
23    pub consolidation_min_independent_sources: usize,
24    /// Consolidation proposals below this blended confidence are never eligible for promotion.
25    pub consolidation_min_confidence: f32,
26    /// Scored outcomes a calibration row needs to be `measurable` (see `calibration.rs`); below
27    /// it, its Brier is reported as `anecdotal`.
28    pub calibration_min_outcomes: usize,
29    /// Recall scoring weights (see `recall.rs`).
30    pub recall: RecallWeights,
31}
32
33impl Default for KernelPolicy {
34    fn default() -> Self {
35        Self {
36            working_set_limit: 12,
37            recall_limit: 8,
38            minimum_recall_score: 0.12,
39            allow_sensitive_persistence: false,
40            consolidation_min_support: 2,
41            consolidation_min_independent_sources: 2,
42            consolidation_min_confidence: 0.6,
43            calibration_min_outcomes: crate::calibration::MIN_OUTCOMES,
44            recall: RecallWeights::default(),
45        }
46    }
47}
48
49/// Weights and scales of the recall score:
50///
51/// - `lexical = max(lexical_coverage × coverage + lexical_jaccard × jaccard, match_floor)` when
52///   any query term matches;
53/// - `cue = cue_lexical × lexical + cue_tag × tag` (only one of them when the query has only
54///   terms or only tags);
55/// - `meta = meta_recency × recency + meta_salience × salience + meta_confidence × confidence
56///   + meta_provenance × provenance + meta_prospective × prospective`, clamped to [0, 1];
57/// - `score = cue × (relevance_base + metadata_share × meta)`; an overdue commitment scores at
58///   least `overdue_floor`; with no cue at all, `score = listing_scale × meta`.
59///
60/// `recency = 1 / (1 + age_days / recency_scale_days)`; `provenance = authority ×
61/// (1 if verified, else unverified_provenance_factor)`; a commitment's `prospective = 1 /
62/// (1 + days_left / prospective_scale_days)`, 1 when overdue, `undated_commitment_urgency`
63/// without a deadline. Redundancy suppression folds a result whose word overlap with a
64/// better one is at least `redundant_at` and otherwise multiplies its score by
65/// `1 − redundancy_penalty × overlap`.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(default, deny_unknown_fields)]
68pub struct RecallWeights {
69    pub lexical_coverage: f32,
70    pub lexical_jaccard: f32,
71    /// Relevance given to any trace that shares at least one content term with the query.
72    pub match_floor: f32,
73    pub cue_lexical: f32,
74    pub cue_tag: f32,
75    pub meta_recency: f32,
76    pub meta_salience: f32,
77    pub meta_confidence: f32,
78    pub meta_provenance: f32,
79    pub meta_prospective: f32,
80    pub relevance_base: f32,
81    pub metadata_share: f32,
82    pub recency_scale_days: f64,
83    pub unverified_provenance_factor: f32,
84    pub prospective_scale_days: f64,
85    pub undated_commitment_urgency: f32,
86    pub overdue_floor: f32,
87    pub listing_scale: f32,
88    pub redundant_at: f32,
89    pub redundancy_penalty: f32,
90}
91
92impl Default for RecallWeights {
93    fn default() -> Self {
94        Self {
95            lexical_coverage: 0.7,
96            lexical_jaccard: 0.3,
97            match_floor: 0.15,
98            cue_lexical: 0.8,
99            cue_tag: 0.2,
100            meta_recency: 0.25,
101            meta_salience: 0.20,
102            meta_confidence: 0.20,
103            meta_provenance: 0.25,
104            meta_prospective: 0.10,
105            relevance_base: 0.55,
106            metadata_share: 0.45,
107            recency_scale_days: 30.0,
108            unverified_provenance_factor: 0.65,
109            prospective_scale_days: 7.0,
110            undated_commitment_urgency: 0.35,
111            overdue_floor: 0.15,
112            listing_scale: 0.5,
113            redundant_at: 0.8,
114            redundancy_penalty: 0.35,
115        }
116    }
117}
118
119impl KernelPolicy {
120    /// Load a JSON policy file: missing fields keep their defaults, unknown fields are errors,
121    /// and the result is validated.
122    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self> {
123        let path = path.as_ref();
124        let text = std::fs::read_to_string(path).map_err(|error| {
125            KernelError::Invalid(format!("cannot read policy {}: {error}", path.display()))
126        })?;
127        Self::from_json(&text)
128            .map_err(|error| KernelError::Invalid(format!("policy {}: {error}", path.display())))
129    }
130
131    /// Load a policy from JSON data (a `--policy` file or `HIKMAH_POLICY`). Data can tune
132    /// weights and thresholds but cannot lift the sensitive-persistence hard block: the
133    /// append-only reference ledger cannot delete, so enabling it belongs in code, next to an
134    /// encrypted, deletion-capable storage adapter, not in an ambient file or variable.
135    pub fn from_json(text: &str) -> Result<Self> {
136        let policy: Self = serde_json::from_str(text)?;
137        if policy.allow_sensitive_persistence {
138            return Err(KernelError::Invalid(
139                "policy field allow_sensitive_persistence cannot be enabled from a policy file; the append-only ledger cannot delete sensitive data. Enable it in code alongside an encrypted, deletion-capable store".into(),
140            ));
141        }
142        policy.validate()?;
143        Ok(policy)
144    }
145
146    /// Reject values that would break recall's invariants (relevance gates, bounded scores).
147    pub fn validate(&self) -> Result<()> {
148        let bad = |name: &str, rule: &str| {
149            Err(KernelError::Invalid(format!("policy field {name} {rule}")))
150        };
151        if self.working_set_limit == 0 || self.recall_limit == 0 {
152            return bad("working_set_limit / recall_limit", "must be at least 1");
153        }
154        let r = &self.recall;
155        let unit = [
156            ("minimum_recall_score", self.minimum_recall_score),
157            (
158                "consolidation_min_confidence",
159                self.consolidation_min_confidence,
160            ),
161            ("recall.lexical_coverage", r.lexical_coverage),
162            ("recall.lexical_jaccard", r.lexical_jaccard),
163            ("recall.match_floor", r.match_floor),
164            ("recall.cue_lexical", r.cue_lexical),
165            ("recall.cue_tag", r.cue_tag),
166            ("recall.meta_recency", r.meta_recency),
167            ("recall.meta_salience", r.meta_salience),
168            ("recall.meta_confidence", r.meta_confidence),
169            ("recall.meta_provenance", r.meta_provenance),
170            ("recall.meta_prospective", r.meta_prospective),
171            ("recall.relevance_base", r.relevance_base),
172            ("recall.metadata_share", r.metadata_share),
173            (
174                "recall.unverified_provenance_factor",
175                r.unverified_provenance_factor,
176            ),
177            (
178                "recall.undated_commitment_urgency",
179                r.undated_commitment_urgency,
180            ),
181            ("recall.overdue_floor", r.overdue_floor),
182            ("recall.listing_scale", r.listing_scale),
183            ("recall.redundant_at", r.redundant_at),
184            ("recall.redundancy_penalty", r.redundancy_penalty),
185        ];
186        for (name, value) in unit {
187            if !(0.0..=1.0).contains(&value) {
188                return bad(name, "must be between 0 and 1");
189            }
190        }
191        if self.minimum_recall_score <= 0.0 {
192            return bad(
193                "minimum_recall_score",
194                "must be above 0 (at 0, a trace with no matching cue would be recalled)",
195            );
196        }
197        if self.calibration_min_outcomes == 0 {
198            return bad(
199                "calibration_min_outcomes",
200                "must be at least 1 (at 0, a family with no outcomes would be measurable)",
201            );
202        }
203        if self.consolidation_min_support == 0 || self.consolidation_min_independent_sources == 0 {
204            return bad(
205                "consolidation_min_support / consolidation_min_independent_sources",
206                "must be at least 1",
207            );
208        }
209        // Each pair blends two shares of one score; above 1 the score saturates its clamp and
210        // ties are broken by recency instead of relevance.
211        for (name, a, b) in [
212            (
213                "recall.lexical_coverage + recall.lexical_jaccard",
214                r.lexical_coverage,
215                r.lexical_jaccard,
216            ),
217            (
218                "recall.cue_lexical + recall.cue_tag",
219                r.cue_lexical,
220                r.cue_tag,
221            ),
222            (
223                "recall.relevance_base + recall.metadata_share",
224                r.relevance_base,
225                r.metadata_share,
226            ),
227        ] {
228            if a + b > 1.0 + 1e-9 {
229                return bad(name, "must sum to at most 1");
230            }
231        }
232        if r.relevance_base <= 0.0 {
233            return bad(
234                "recall.relevance_base",
235                "must be above 0 (relevance must always count)",
236            );
237        }
238        for (name, value) in [
239            ("recall.recency_scale_days", r.recency_scale_days),
240            ("recall.prospective_scale_days", r.prospective_scale_days),
241        ] {
242            if !value.is_finite() || value <= 0.0 {
243                return bad(name, "must be a positive number of days");
244            }
245        }
246        Ok(())
247    }
248}