Skip to main content

clawft_kernel/
context_promote.rs

1//! Postmortem promotion + `memory.promote` chain event (ADR-058 Phase 4.2/4.3).
2//!
3//! Because the session view ([`SessionView`]) is discarded per run, a
4//! session-end rollup **must promote durable facts to the trunk** before the
5//! view is dropped — else "fresh each run" becomes "amnesia each run". This is
6//! the load-bearing L2→L3 boundary:
7//!
8//! - **4.2 [`postmortem`]** — a mandatory post-task review that nominates chunks
9//!   for promotion using multiple signals (explicit "important" marks, recall /
10//!   reference count, task-completion score). Not a single heuristic.
11//! - **4.3 [`MemoryPromote`] / `promote_to_chain`** — promotion is itself a
12//!   `memory.promote` [`ChainLoggable`](crate::chain::ChainLoggable) event
13//!   (ADR-020/022) recording which chain sequences were distilled, giving
14//!   auditable lineage of long-term memory. (Requires the `exochain` feature.)
15
16use crate::context_graft::{NodeState, SessionView};
17
18/// Why a chunk was nominated for promotion (auditable, multi-signal).
19#[derive(Debug, Clone, PartialEq)]
20pub enum PromotionReason {
21    /// Explicitly marked "important to remember" (e.g. a `memory_store` call).
22    Important,
23    /// Recalled/grafted this many times during the session.
24    ReferenceCount(u32),
25    /// The task completed with this score (a boosting signal).
26    TaskScore(f32),
27}
28
29/// A chunk nominated for L2→L3 promotion by the postmortem.
30#[derive(Debug, Clone)]
31pub struct PromotionCandidate {
32    /// Source ExoChain sequence (lineage key).
33    pub chain_seq: u64,
34    /// Content-addressed identity (provenance).
35    pub content_hash: String,
36    /// The signals that fired for this candidate.
37    pub reasons: Vec<PromotionReason>,
38}
39
40/// Multi-signal thresholds for the postmortem nominator.
41#[derive(Debug, Clone)]
42pub struct PromotionSignals {
43    /// Task-completion score in `[0,1]`.
44    pub task_score: f32,
45    /// At/above this, the task counts as successful: a single recall then
46    /// suffices to nominate, and a `TaskScore` reason is attached.
47    pub task_threshold: f32,
48    /// Minimum recall count to nominate on reference-count alone.
49    pub min_refs: u32,
50}
51
52impl Default for PromotionSignals {
53    fn default() -> Self {
54        Self {
55            task_score: 0.0,
56            task_threshold: 0.7,
57            min_refs: 2,
58        }
59    }
60}
61
62/// Postmortem promotion stage (ADR-058 Phase 4.2).
63///
64/// Examines the finished session and nominates chunks for durable promotion.
65/// A chunk is nominated if it was marked important **or** recalled at least
66/// `min_refs` times (relaxed to a single recall on a successful task).
67/// `Pruned`/contradicted chunks are never promoted. The reasons list records
68/// exactly which signals fired, for auditability.
69pub fn postmortem(view: &SessionView, signals: &PromotionSignals) -> Vec<PromotionCandidate> {
70    let task_ok = signals.task_score >= signals.task_threshold;
71    let effective_min_refs = if task_ok { 1 } else { signals.min_refs };
72
73    let mut out = Vec::new();
74    for seq in view.chain_seqs() {
75        let Some(meta) = view.chunk(seq) else {
76            continue;
77        };
78        if meta.state == NodeState::Pruned {
79            continue;
80        }
81
82        let mut reasons = Vec::new();
83        if view.is_important(seq) {
84            reasons.push(PromotionReason::Important);
85        }
86        let rc = view.ref_count(seq);
87        if rc > 0 && rc >= effective_min_refs {
88            reasons.push(PromotionReason::ReferenceCount(rc));
89        }
90        if reasons.is_empty() {
91            continue; // no firing signal — not durable-worthy
92        }
93        if task_ok {
94            reasons.push(PromotionReason::TaskScore(signals.task_score));
95        }
96        out.push(PromotionCandidate {
97            chain_seq: seq,
98            content_hash: meta.content_hash,
99            reasons,
100        });
101    }
102    out
103}
104
105// ── 4.3 promotion as a chain event (requires the chain) ───────────────
106
107/// A `memory.promote` chain event (ADR-058 Phase 4.3 / ADR-020 ChainLoggable).
108///
109/// Records which source chain sequences were distilled into a durable fact, so
110/// long-term memory carries auditable lineage back to its witnesses.
111#[cfg(feature = "exochain")]
112pub struct MemoryPromote {
113    /// Source ExoChain sequences distilled into the durable fact.
114    pub source_chain_seqs: Vec<u64>,
115    /// Their content-addressed identities (provenance).
116    pub content_hashes: Vec<String>,
117    /// The distilled durable fact promoted onto the trunk (L3).
118    pub durable_fact: String,
119}
120
121#[cfg(feature = "exochain")]
122impl crate::chain::ChainLoggable for MemoryPromote {
123    fn chain_event_source(&self) -> &str {
124        "memory"
125    }
126
127    fn chain_event_kind(&self) -> &str {
128        "memory.promote"
129    }
130
131    fn chain_event_payload(&self) -> serde_json::Value {
132        serde_json::json!({
133            "source_chain_seqs": self.source_chain_seqs,
134            "content_hashes": self.content_hashes,
135            "durable_fact": self.durable_fact,
136        })
137    }
138}
139
140/// Promote the nominated candidates to the durable trunk (ADR-058 Phase 4.3).
141///
142/// Emits a single `memory.promote` chain event recording the source chain
143/// sequences (lineage) and marks the promoted chunks [`NodeState::Committed`].
144/// The session view should be dropped **only after** this returns. No-op
145/// (`None`) when there are no candidates.
146#[cfg(feature = "exochain")]
147pub fn promote_to_chain(
148    view: &SessionView,
149    candidates: &[PromotionCandidate],
150    durable_fact: impl Into<String>,
151    chain: &crate::chain::ChainManager,
152) -> Option<crate::chain::ChainEvent> {
153    if candidates.is_empty() {
154        return None;
155    }
156    let source_chain_seqs: Vec<u64> = candidates.iter().map(|c| c.chain_seq).collect();
157    let content_hashes: Vec<String> = candidates.iter().map(|c| c.content_hash.clone()).collect();
158    let event = MemoryPromote {
159        source_chain_seqs: source_chain_seqs.clone(),
160        content_hashes,
161        durable_fact: durable_fact.into(),
162    };
163    let logged = chain.append_loggable(&event);
164    for seq in source_chain_seqs {
165        view.set_state(seq, NodeState::Committed);
166    }
167    Some(logged)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::context_graft::{ChunkMeta, SessionView};
174
175    fn axis_vec(dims: usize, axis: usize) -> Vec<f32> {
176        let mut v = vec![0.01_f32; dims];
177        if axis < dims {
178            v[axis] = 1.0;
179        }
180        v
181    }
182
183    fn insert(view: &SessionView, seq: u64, axis: usize, text: &str) {
184        let meta = ChunkMeta {
185            chain_seq: seq,
186            content_hash: crate::context_graft::content_hash(text),
187            state: NodeState::Frontier,
188            kind: "agent.chat.turn".into(),
189            blob_hash: None,
190            inline: Some(text.into()),
191        };
192        assert!(view.insert_vector(axis_vec(8, axis), meta));
193    }
194
195    #[test]
196    fn postmortem_nominates_important_and_recalled() {
197        let view = SessionView::new("s1", 8);
198        insert(&view, 1, 0, "important fact");
199        insert(&view, 2, 1, "recalled fact");
200        insert(&view, 3, 2, "ignored fact");
201
202        // Signal A: explicit important mark on seq 1.
203        view.mark_important(1);
204        // Signal B: recall seq 2 twice (ref_count = 2 = default min_refs).
205        let _ = view.graft(&axis_vec(8, 1), 1);
206        let _ = view.graft(&axis_vec(8, 1), 1);
207        assert_eq!(view.ref_count(2), 2);
208
209        let cands = postmortem(&view, &PromotionSignals::default());
210        let seqs: Vec<u64> = cands.iter().map(|c| c.chain_seq).collect();
211        assert!(seqs.contains(&1), "important chunk nominated");
212        assert!(seqs.contains(&2), "recalled chunk nominated");
213        assert!(!seqs.contains(&3), "untouched chunk not nominated");
214
215        let c1 = cands.iter().find(|c| c.chain_seq == 1).unwrap();
216        assert!(c1.reasons.contains(&PromotionReason::Important));
217        let c2 = cands.iter().find(|c| c.chain_seq == 2).unwrap();
218        assert!(c2.reasons.contains(&PromotionReason::ReferenceCount(2)));
219    }
220
221    #[test]
222    fn successful_task_lowers_recall_threshold() {
223        let view = SessionView::new("s1", 8);
224        insert(&view, 5, 0, "recalled once");
225        let _ = view.graft(&axis_vec(8, 0), 1); // ref_count = 1 (< default 2)
226
227        // Without task success: not nominated (1 < min_refs 2).
228        assert!(postmortem(&view, &PromotionSignals::default()).is_empty());
229
230        // With task success: single recall suffices, TaskScore reason attached.
231        let signals = PromotionSignals {
232            task_score: 0.9,
233            task_threshold: 0.7,
234            min_refs: 2,
235        };
236        let cands = postmortem(&view, &signals);
237        assert_eq!(cands.len(), 1);
238        assert!(
239            cands[0]
240                .reasons
241                .iter()
242                .any(|r| matches!(r, PromotionReason::TaskScore(_)))
243        );
244    }
245
246    #[test]
247    fn pruned_chunks_are_not_promoted() {
248        let view = SessionView::new("s1", 8);
249        insert(&view, 1, 0, "fact");
250        view.mark_important(1);
251        view.prune(1); // Stale is still promotable; Pruned is not.
252        view.set_state(1, NodeState::Pruned);
253        assert!(postmortem(&view, &PromotionSignals::default()).is_empty());
254    }
255
256    /// 4.3: promotion emits a `memory.promote` chain event with lineage and
257    /// commits the chunks. Requires the `exochain` feature.
258    #[cfg(feature = "exochain")]
259    #[test]
260    fn promote_emits_memory_promote_event_with_lineage() {
261        use crate::chain::ChainManager;
262
263        let view = SessionView::new("s1", 8);
264        insert(&view, 11, 0, "durable A");
265        insert(&view, 12, 1, "durable B");
266        view.mark_important(11);
267        view.mark_important(12);
268
269        let cands = postmortem(&view, &PromotionSignals::default());
270        assert_eq!(cands.len(), 2);
271
272        let chain = ChainManager::new(0, 1000);
273        let before = chain.head_sequence();
274        let event = promote_to_chain(&view, &cands, "A and B are durable", &chain)
275            .expect("promotion event emitted");
276
277        assert_eq!(event.kind, "memory.promote");
278        assert!(event.sequence > before);
279        let payload = event.payload.expect("payload present");
280        let seqs = payload["source_chain_seqs"].as_array().unwrap();
281        let seqs: Vec<u64> = seqs.iter().map(|v| v.as_u64().unwrap()).collect();
282        assert!(seqs.contains(&11) && seqs.contains(&12));
283        assert_eq!(payload["durable_fact"], "A and B are durable");
284
285        // Promoted chunks are now Committed (and dropped from the live window).
286        assert_eq!(view.chunk(11).unwrap().state, NodeState::Committed);
287        assert_eq!(view.chunk(12).unwrap().state, NodeState::Committed);
288    }
289
290    #[cfg(feature = "exochain")]
291    #[test]
292    fn promote_empty_candidates_is_noop() {
293        use crate::chain::ChainManager;
294        let view = SessionView::new("s1", 8);
295        let chain = ChainManager::new(0, 1000);
296        assert!(promote_to_chain(&view, &[], "nothing", &chain).is_none());
297    }
298}