Skip to main content

hikmah_kernel/
trace.rs

1use serde::{Deserialize, Serialize};
2use std::fmt::{Display, Formatter};
3use std::str::FromStr;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::error::{KernelError, Result};
7use crate::principal::AGENT_LOCATOR_PREFIX;
8use crate::secrets::contains_secret;
9
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
11#[serde(rename_all = "snake_case")]
12pub enum TraceKind {
13    Observation,
14    Episode,
15    Belief,
16    Procedure,
17    Commitment,
18    Preference,
19    Constraint,
20    Outcome,
21    Correction,
22    /// A forecast: a typed answer admitted from a decision engine (`model:` source), or a
23    /// forecast a person or agent recorded with `hikmah predict`. Never verified, never
24    /// evidence by itself; an `Outcome` resolves it.
25    Prediction,
26}
27
28impl Display for TraceKind {
29    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
30        let value = match self {
31            Self::Observation => "observation",
32            Self::Episode => "episode",
33            Self::Belief => "belief",
34            Self::Procedure => "procedure",
35            Self::Commitment => "commitment",
36            Self::Preference => "preference",
37            Self::Constraint => "constraint",
38            Self::Outcome => "outcome",
39            Self::Correction => "correction",
40            Self::Prediction => "prediction",
41        };
42        f.write_str(value)
43    }
44}
45
46impl FromStr for TraceKind {
47    type Err = KernelError;
48
49    fn from_str(value: &str) -> Result<Self> {
50        match value.trim().to_ascii_lowercase().as_str() {
51            "observation" => Ok(Self::Observation),
52            "episode" => Ok(Self::Episode),
53            "belief" => Ok(Self::Belief),
54            "procedure" => Ok(Self::Procedure),
55            "commitment" => Ok(Self::Commitment),
56            "preference" => Ok(Self::Preference),
57            "constraint" => Ok(Self::Constraint),
58            "outcome" => Ok(Self::Outcome),
59            "correction" => Ok(Self::Correction),
60            "prediction" => Ok(Self::Prediction),
61            other => Err(KernelError::Invalid(format!("unknown trace kind: {other}"))),
62        }
63    }
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
67#[serde(rename_all = "snake_case")]
68pub enum PrivacyClass {
69    Public,
70    #[default]
71    Private,
72    Sensitive,
73}
74
75impl FromStr for PrivacyClass {
76    type Err = KernelError;
77
78    fn from_str(value: &str) -> Result<Self> {
79        match value.trim().to_ascii_lowercase().as_str() {
80            "public" => Ok(Self::Public),
81            "private" => Ok(Self::Private),
82            "sensitive" => Ok(Self::Sensitive),
83            other => Err(KernelError::Invalid(format!(
84                "unknown privacy class: {other}"
85            ))),
86        }
87    }
88}
89
90#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
91#[serde(rename_all = "snake_case")]
92pub enum TraceStatus {
93    Active,
94    Superseded,
95    Fulfilled,
96    Purged,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100pub struct Provenance {
101    pub source: String,
102    pub locator: Option<String>,
103    pub observed_at_ms: u64,
104    pub authority: f32,
105    pub verified: bool,
106}
107
108impl Provenance {
109    pub fn new(source: impl Into<String>) -> Self {
110        Self {
111            source: source.into(),
112            locator: None,
113            observed_at_ms: now_ms(),
114            authority: 0.5,
115            verified: false,
116        }
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct Trace {
122    pub id: String,
123    pub kind: TraceKind,
124    pub content: String,
125    pub tags: Vec<String>,
126    pub created_at_ms: u64,
127    pub deadline_ms: Option<u64>,
128    pub salience: f32,
129    pub confidence: f32,
130    pub privacy: PrivacyClass,
131    pub provenance: Provenance,
132    pub claim_key: Option<String>,
133    pub claim_value: Option<String>,
134    pub supersedes: Option<String>,
135    /// Structured payload of a `Prediction` trace (typed decision port).
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub prediction: Option<PredictionRecord>,
138    /// Structured payload of an `Outcome` trace that resolves a prediction.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub outcome: Option<OutcomeRecord>,
141}
142
143/// A forecast: what a decision engine answered (as admitted by the kernel), or what a person or
144/// agent forecast.
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub struct PredictionRecord {
147    pub request_id: String,
148    pub question_id: String,
149    /// Calibration bucket; defaults to the question id.
150    pub family: String,
151    /// `noul`, `choice`, or `score`.
152    pub answer_kind: String,
153    /// Who forecast: the engine identity (`jev@jev-1.13.0`) for a `model:` source, otherwise
154    /// the source principal itself (`human:alex`). Calibration groups by it.
155    pub engine: String,
156    /// Noul: probability of `true`. Choice/score: probability of the reported value.
157    /// `None` when the engine reported neither a distribution nor a confidence.
158    pub p: Option<f64>,
159    /// Choice option id or score level index (as text); `true`/`false` for noul.
160    pub value: String,
161    #[serde(default)]
162    pub probabilities: std::collections::BTreeMap<String, f64>,
163    /// Every value an outcome may take (`true`/`false`, option ids, or level indices).
164    #[serde(default)]
165    pub answer_space: Vec<String>,
166    /// Always false until calibration for this family is measured from outcomes.
167    pub calibrated: bool,
168}
169
170/// An observed outcome for a prediction, written by a non-model principal.
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct OutcomeRecord {
173    pub prediction_id: String,
174    /// Same encoding as `PredictionRecord::value`.
175    pub observed: String,
176}
177
178/// Sources written by decision engines carry this prefix and can never be verified.
179pub const MODEL_SOURCE_PREFIX: &str = "model:";
180
181impl Trace {
182    pub fn new(kind: TraceKind, content: impl Into<String>, source: impl Into<String>) -> Self {
183        Self {
184            id: String::new(),
185            kind,
186            content: content.into(),
187            tags: Vec::new(),
188            created_at_ms: now_ms(),
189            deadline_ms: None,
190            salience: 0.5,
191            confidence: 0.5,
192            privacy: PrivacyClass::Private,
193            provenance: Provenance::new(source),
194            claim_key: None,
195            claim_value: None,
196            supersedes: None,
197            prediction: None,
198            outcome: None,
199        }
200    }
201
202    /// True when a decision engine, not a person or tool, wrote this trace.
203    pub fn is_model_authored(&self) -> bool {
204        self.provenance
205            .source
206            .trim()
207            .to_ascii_lowercase()
208            .starts_with(MODEL_SOURCE_PREFIX)
209    }
210
211    /// True when the trace was written from a detected AI agent session (see
212    /// [`crate::principal`]). `source` stays whatever the caller claimed.
213    pub fn is_from_agent_session(&self) -> bool {
214        self.provenance
215            .locator
216            .as_deref()
217            .is_some_and(|locator| locator.trim_start().starts_with(AGENT_LOCATOR_PREFIX))
218    }
219
220    pub fn validate(&self) -> Result<()> {
221        if self.content.trim().is_empty() {
222            return Err(KernelError::Invalid("trace content cannot be empty".into()));
223        }
224        self.refuse_secrets()?;
225        if !(0.0..=1.0).contains(&self.salience) {
226            return Err(KernelError::Invalid(
227                "salience must be between 0 and 1".into(),
228            ));
229        }
230        if !(0.0..=1.0).contains(&self.confidence) {
231            return Err(KernelError::Invalid(
232                "confidence must be between 0 and 1".into(),
233            ));
234        }
235        if !(0.0..=1.0).contains(&self.provenance.authority) {
236            return Err(KernelError::Invalid(
237                "provenance authority must be between 0 and 1".into(),
238            ));
239        }
240        if self.claim_key.is_some() != self.claim_value.is_some() {
241            return Err(KernelError::Invalid(
242                "claim_key and claim_value must be supplied together".into(),
243            ));
244        }
245        if self.provenance.source.trim().is_empty() {
246            return Err(KernelError::Invalid(
247                "provenance source cannot be empty".into(),
248            ));
249        }
250        if self.is_model_authored() && self.provenance.verified {
251            return Err(KernelError::Invalid(
252                "model-authored traces cannot be marked verified; record an outcome from a non-model principal instead"
253                    .into(),
254            ));
255        }
256        if self.provenance.verified && self.is_from_agent_session() {
257            return Err(KernelError::Invalid(
258                "a trace written from an AI agent session cannot be marked verified; a person must attest it from outside the agent session"
259                    .into(),
260            ));
261        }
262        if self.supersedes.as_deref() == Some(self.id.as_str()) && !self.id.is_empty() {
263            return Err(KernelError::Invalid(
264                "a trace cannot supersede itself".into(),
265            ));
266        }
267        match self.kind {
268            TraceKind::Prediction => {
269                let Some(record) = &self.prediction else {
270                    return Err(KernelError::Invalid(
271                        "prediction traces need a prediction record".into(),
272                    ));
273                };
274                // A forecast is never a verified fact, whoever made it; an outcome resolves it.
275                if self.provenance.verified {
276                    return Err(KernelError::Invalid(
277                        "prediction traces cannot be marked verified; record an outcome from a non-model principal instead"
278                            .into(),
279                    ));
280                }
281                // The record's `engine` is who forecast. A `model:` source is an engine answer
282                // and names that engine; any other source is a human or agent forecast and names
283                // that principal. The name alone does not say which class wrote it (a principal
284                // could call itself `jev@…`), so calibration also keys every row by
285                // `is_model_authored()`, and neither class can land in the other's row.
286                let principal = self.provenance.source.trim();
287                let expected = if self.is_model_authored() {
288                    &principal[MODEL_SOURCE_PREFIX.len()..]
289                } else {
290                    principal
291                };
292                if record.engine != expected {
293                    return Err(KernelError::Invalid(format!(
294                        "prediction record engine `{}` must match its source `{principal}`",
295                        record.engine
296                    )));
297                }
298                if self.supersedes.is_some() {
299                    return Err(KernelError::Invalid(
300                        "prediction traces cannot supersede other traces".into(),
301                    ));
302                }
303            }
304            _ if self.prediction.is_some() => {
305                return Err(KernelError::Invalid(
306                    "only prediction traces may carry a prediction record".into(),
307                ));
308            }
309            _ => {}
310        }
311        if self.outcome.is_some() {
312            if self.kind != TraceKind::Outcome {
313                return Err(KernelError::Invalid(
314                    "only outcome traces may carry an outcome record".into(),
315                ));
316            }
317            if self.is_model_authored() {
318                return Err(KernelError::Invalid(
319                    "outcomes must come from a non-model principal".into(),
320                ));
321            }
322        }
323        Ok(())
324    }
325
326    /// Memory never stores a credential merely because it appeared. Every free-text field is
327    /// checked with the detector that guards outbound engine requests ([`contains_secret`]); the
328    /// error names the field but never echoes the matched text.
329    fn refuse_secrets(&self) -> Result<()> {
330        let mut fields: Vec<(&str, &str)> = vec![
331            ("content", self.content.as_str()),
332            ("source", self.provenance.source.as_str()),
333        ];
334        fields.extend(self.tags.iter().map(|tag| ("tag", tag.as_str())));
335        fields.extend(self.claim_key.as_deref().map(|key| ("claim_key", key)));
336        fields.extend(
337            self.claim_value
338                .as_deref()
339                .map(|value| ("claim_value", value)),
340        );
341        fields.extend(
342            self.provenance
343                .locator
344                .as_deref()
345                .map(|locator| ("locator", locator)),
346        );
347        if let Some(record) = &self.prediction {
348            fields.push(("prediction family", record.family.as_str()));
349            fields.push(("prediction value", record.value.as_str()));
350            fields.extend(
351                record
352                    .answer_space
353                    .iter()
354                    .map(|value| ("prediction answer space", value.as_str())),
355            );
356        }
357        match fields.into_iter().find(|(_, text)| contains_secret(text)) {
358            Some((field, _)) => Err(KernelError::Invalid(format!(
359                "trace {field} appears to contain a credential; memory never stores secrets. Record where the secret is kept (for example a vault path), not its value"
360            ))),
361            None => Ok(()),
362        }
363    }
364}
365
366/// Parse a deadline: epoch milliseconds, `+<n>h`, `+<n>d`, or `YYYY-MM-DD[THH:MM[:SS]][Z]` in UTC.
367/// Impossible dates (2026-02-31), negative parts, and years outside 1970..=9999 are rejected.
368pub fn parse_deadline(value: &str, now_ms: u64) -> Result<u64> {
369    let v = value.trim();
370    let invalid = || KernelError::Invalid(format!("unrecognized deadline: {value}"));
371    if let Some(rest) = v.strip_prefix('+') {
372        let (number, unit_ms) = if let Some(n) = rest.strip_suffix('h') {
373            (n, 3_600_000_u64)
374        } else if let Some(n) = rest.strip_suffix('d') {
375            (n, 86_400_000_u64)
376        } else {
377            return Err(invalid());
378        };
379        let n: u64 = number.parse().map_err(|_| invalid())?;
380        return Ok(now_ms.saturating_add(n.saturating_mul(unit_ms)));
381    }
382    if !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()) {
383        return v.parse().map_err(|_| invalid());
384    }
385    let v = v.strip_suffix('Z').unwrap_or(v);
386    let (date, time) = v.split_once('T').unwrap_or((v, "00:00:00"));
387    let number = |part: &str| -> Result<u64> {
388        if part.is_empty() || !part.chars().all(|c| c.is_ascii_digit()) {
389            return Err(invalid());
390        }
391        part.parse::<u64>().map_err(|_| invalid())
392    };
393    let d: Vec<u64> = date.split('-').map(number).collect::<Result<_>>()?;
394    let t: Vec<u64> = time.split(':').map(number).collect::<Result<_>>()?;
395    if d.len() != 3 || !(1..=3).contains(&t.len()) {
396        return Err(invalid());
397    }
398    let (y, m, day) = (d[0], d[1], d[2]);
399    let (hh, mm, ss) = (t[0], *t.get(1).unwrap_or(&0), *t.get(2).unwrap_or(&0));
400    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
401    let days_in_month = match m {
402        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
403        4 | 6 | 9 | 11 => 30,
404        2 if leap => 29,
405        2 => 28,
406        _ => return Err(invalid()),
407    };
408    if !(1970..=9999).contains(&y)
409        || day == 0
410        || day > days_in_month
411        || hh > 23
412        || mm > 59
413        || ss > 59
414    {
415        return Err(invalid());
416    }
417    // Days from civil (Howard Hinnant's algorithm), UTC. All values are bounded above.
418    let (y, m, day) = (y as i64, m as i64, day as i64);
419    let y = if m <= 2 { y - 1 } else { y };
420    let era = y / 400;
421    let yoe = y - era * 400;
422    let mp = (m + 9) % 12;
423    let doy = (153 * mp + 2) / 5 + day - 1;
424    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
425    let days = era * 146_097 + doe - 719_468;
426    let secs = days * 86_400 + (hh * 3_600 + mm * 60 + ss) as i64;
427    Ok(secs as u64 * 1_000)
428}
429
430pub fn now_ms() -> u64 {
431    SystemTime::now()
432        .duration_since(UNIX_EPOCH)
433        .unwrap_or_default()
434        .as_millis() as u64
435}