1use crate::context_graft::{NodeState, SessionView};
17
18#[derive(Debug, Clone, PartialEq)]
20pub enum PromotionReason {
21 Important,
23 ReferenceCount(u32),
25 TaskScore(f32),
27}
28
29#[derive(Debug, Clone)]
31pub struct PromotionCandidate {
32 pub chain_seq: u64,
34 pub content_hash: String,
36 pub reasons: Vec<PromotionReason>,
38}
39
40#[derive(Debug, Clone)]
42pub struct PromotionSignals {
43 pub task_score: f32,
45 pub task_threshold: f32,
48 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
62pub 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; }
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#[cfg(feature = "exochain")]
112pub struct MemoryPromote {
113 pub source_chain_seqs: Vec<u64>,
115 pub content_hashes: Vec<String>,
117 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#[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 view.mark_important(1);
204 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); assert!(postmortem(&view, &PromotionSignals::default()).is_empty());
229
230 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); view.set_state(1, NodeState::Pruned);
253 assert!(postmortem(&view, &PromotionSignals::default()).is_empty());
254 }
255
256 #[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 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}