Skip to main content

memstead_base/
derivation.rs

1//! Derivation baselines — "my source changed" computed, never stamped
2//! (agent-trust plan 12).
3//!
4//! When a schema declares a rel-type `derivation: true`, explicitly
5//! writing such an edge records the TARGET's content hash at write
6//! time as the edge's baseline. The include-gated `stale_derivations`
7//! health axis compares each such edge's baseline against the
8//! target's CURRENT hash: differ → the source is stale against the
9//! target; no baseline recorded → `unbaselined`, distinctly — never
10//! fabricated as fresh or stale.
11//!
12//! Baselines are engine-owned sidecar state at
13//! [`DERIVATION_SIDECAR_PATH`] (`.memstead/derivations.json`) — the
14//! anchors-sidecar precedent: staged through the backend's normal
15//! entity-path write so it rides the SAME commit as the mutation that
16//! produced it, filtered from entity listings by the `.memstead/`
17//! rule, invisible in the mem's markdown, and excluded from `_hash`
18//! by construction. Export/import behaviour follows the anchors
19//! sidecar's decisions (the archive path carries `.memstead/` members
20//! as-is).
21//!
22//! Baseline hashes are the engine's per-entity `content_hash` —
23//! SHA-256 over the raw markdown truncated to 16 hex characters, the
24//! same 64-bit form optimistic locking uses.
25//!
26//! Only EXPLICITLY written edges record baselines (create
27//! `relations`, update `declare_relations`, `memstead_relate`) —
28//! alias-synthesized body-link edges and hierarchy edges are
29//! load-derived, not written, and never carry one. Rows whose edge
30//! has since been removed are inert (the axis walks live edges, so an
31//! orphaned row can never surface); relate-remove prunes its row
32//! eagerly.
33
34use std::collections::BTreeMap;
35
36use serde::{Deserialize, Serialize};
37
38/// Where the sidecar lives inside a mem — sibling of the anchors
39/// sidecar, inside the `.memstead/` prefix every backend filters from
40/// entity listings.
41pub const DERIVATION_SIDECAR_PATH: &str = ".memstead/derivations.json";
42
43/// One recorded baseline: the target's content hash when the edge was
44/// (last) asserted.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct DerivationBaseline {
47    pub rel_type: String,
48    pub target: String,
49    /// The target's `content_hash` (16-hex truncated SHA-256) at
50    /// assert time. Empty when the target was a stub with no body —
51    /// the real content landing later then reads as a change, which
52    /// is honest: the derivation was asserted against nothing.
53    pub target_hash: String,
54}
55
56/// The sidecar document: baselines keyed by source entity id.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct DerivationSidecar {
59    #[serde(default = "default_version")]
60    pub version: u32,
61    /// source id → recorded baselines for its derivation edges.
62    #[serde(default)]
63    pub baselines: BTreeMap<String, Vec<DerivationBaseline>>,
64}
65
66fn default_version() -> u32 {
67    1
68}
69
70impl Default for DerivationSidecar {
71    /// Fresh sidecars serialize `version: 1` — aligned with the
72    /// serde default for files missing the field, so a future
73    /// version-gated migration never reads a fresh file as pre-1.
74    fn default() -> Self {
75        Self {
76            version: default_version(),
77            baselines: BTreeMap::new(),
78        }
79    }
80}
81
82impl DerivationSidecar {
83    pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
84        serde_json::from_slice(bytes)
85    }
86
87    pub fn to_bytes(&self) -> Vec<u8> {
88        serde_json::to_vec_pretty(self).expect("derivation sidecar serialises")
89    }
90
91    /// Record (or refresh) the baseline for one edge.
92    pub fn set(&mut self, source: &str, rel_type: &str, target: &str, target_hash: &str) {
93        let list = self.baselines.entry(source.to_string()).or_default();
94        if let Some(existing) = list
95            .iter_mut()
96            .find(|b| b.rel_type == rel_type && b.target == target)
97        {
98            existing.target_hash = target_hash.to_string();
99        } else {
100            list.push(DerivationBaseline {
101                rel_type: rel_type.to_string(),
102                target: target.to_string(),
103                target_hash: target_hash.to_string(),
104            });
105        }
106    }
107
108    /// The recorded baseline hash for one edge, if any.
109    pub fn get(&self, source: &str, rel_type: &str, target: &str) -> Option<&str> {
110        self.baselines.get(source)?.iter().find_map(|b| {
111            (b.rel_type == rel_type && b.target == target).then_some(b.target_hash.as_str())
112        })
113    }
114
115    /// Drop the baseline for one edge (relate-remove's eager prune).
116    pub fn remove(&mut self, source: &str, rel_type: &str, target: &str) {
117        if let Some(list) = self.baselines.get_mut(source) {
118            list.retain(|b| !(b.rel_type == rel_type && b.target == target));
119            if list.is_empty() {
120                self.baselines.remove(source);
121            }
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn set_get_refresh_remove_round_trip() {
132        let mut s = DerivationSidecar::default();
133        assert_eq!(s.get("m--a", "DERIVED_FROM", "m--b"), None);
134        s.set("m--a", "DERIVED_FROM", "m--b", "hash1");
135        assert_eq!(s.get("m--a", "DERIVED_FROM", "m--b"), Some("hash1"));
136        // Refresh replaces in place, no duplicate row.
137        s.set("m--a", "DERIVED_FROM", "m--b", "hash2");
138        assert_eq!(s.get("m--a", "DERIVED_FROM", "m--b"), Some("hash2"));
139        assert_eq!(s.baselines["m--a"].len(), 1);
140        // Distinct edges coexist.
141        s.set("m--a", "SUMMARIZES", "m--b", "hash3");
142        assert_eq!(s.baselines["m--a"].len(), 2);
143        let bytes = s.to_bytes();
144        let back = DerivationSidecar::from_bytes(&bytes).unwrap();
145        assert_eq!(back.get("m--a", "SUMMARIZES", "m--b"), Some("hash3"));
146        let mut back = back;
147        back.remove("m--a", "DERIVED_FROM", "m--b");
148        assert_eq!(back.get("m--a", "DERIVED_FROM", "m--b"), None);
149        assert_eq!(back.get("m--a", "SUMMARIZES", "m--b"), Some("hash3"));
150    }
151}