use crate::context_graft::{NodeState, SessionView};
#[derive(Debug, Clone, PartialEq)]
pub enum PromotionReason {
Important,
ReferenceCount(u32),
TaskScore(f32),
}
#[derive(Debug, Clone)]
pub struct PromotionCandidate {
pub chain_seq: u64,
pub content_hash: String,
pub reasons: Vec<PromotionReason>,
}
#[derive(Debug, Clone)]
pub struct PromotionSignals {
pub task_score: f32,
pub task_threshold: f32,
pub min_refs: u32,
}
impl Default for PromotionSignals {
fn default() -> Self {
Self {
task_score: 0.0,
task_threshold: 0.7,
min_refs: 2,
}
}
}
pub fn postmortem(view: &SessionView, signals: &PromotionSignals) -> Vec<PromotionCandidate> {
let task_ok = signals.task_score >= signals.task_threshold;
let effective_min_refs = if task_ok { 1 } else { signals.min_refs };
let mut out = Vec::new();
for seq in view.chain_seqs() {
let Some(meta) = view.chunk(seq) else {
continue;
};
if meta.state == NodeState::Pruned {
continue;
}
let mut reasons = Vec::new();
if view.is_important(seq) {
reasons.push(PromotionReason::Important);
}
let rc = view.ref_count(seq);
if rc > 0 && rc >= effective_min_refs {
reasons.push(PromotionReason::ReferenceCount(rc));
}
if reasons.is_empty() {
continue; }
if task_ok {
reasons.push(PromotionReason::TaskScore(signals.task_score));
}
out.push(PromotionCandidate {
chain_seq: seq,
content_hash: meta.content_hash,
reasons,
});
}
out
}
#[cfg(feature = "exochain")]
pub struct MemoryPromote {
pub source_chain_seqs: Vec<u64>,
pub content_hashes: Vec<String>,
pub durable_fact: String,
}
#[cfg(feature = "exochain")]
impl crate::chain::ChainLoggable for MemoryPromote {
fn chain_event_source(&self) -> &str {
"memory"
}
fn chain_event_kind(&self) -> &str {
"memory.promote"
}
fn chain_event_payload(&self) -> serde_json::Value {
serde_json::json!({
"source_chain_seqs": self.source_chain_seqs,
"content_hashes": self.content_hashes,
"durable_fact": self.durable_fact,
})
}
}
#[cfg(feature = "exochain")]
pub fn promote_to_chain(
view: &SessionView,
candidates: &[PromotionCandidate],
durable_fact: impl Into<String>,
chain: &crate::chain::ChainManager,
) -> Option<crate::chain::ChainEvent> {
if candidates.is_empty() {
return None;
}
let source_chain_seqs: Vec<u64> = candidates.iter().map(|c| c.chain_seq).collect();
let content_hashes: Vec<String> = candidates.iter().map(|c| c.content_hash.clone()).collect();
let event = MemoryPromote {
source_chain_seqs: source_chain_seqs.clone(),
content_hashes,
durable_fact: durable_fact.into(),
};
let logged = chain.append_loggable(&event);
for seq in source_chain_seqs {
view.set_state(seq, NodeState::Committed);
}
Some(logged)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context_graft::{ChunkMeta, SessionView};
fn axis_vec(dims: usize, axis: usize) -> Vec<f32> {
let mut v = vec![0.01_f32; dims];
if axis < dims {
v[axis] = 1.0;
}
v
}
fn insert(view: &SessionView, seq: u64, axis: usize, text: &str) {
let meta = ChunkMeta {
chain_seq: seq,
content_hash: crate::context_graft::content_hash(text),
state: NodeState::Frontier,
kind: "agent.chat.turn".into(),
blob_hash: None,
inline: Some(text.into()),
};
assert!(view.insert_vector(axis_vec(8, axis), meta));
}
#[test]
fn postmortem_nominates_important_and_recalled() {
let view = SessionView::new("s1", 8);
insert(&view, 1, 0, "important fact");
insert(&view, 2, 1, "recalled fact");
insert(&view, 3, 2, "ignored fact");
view.mark_important(1);
let _ = view.graft(&axis_vec(8, 1), 1);
let _ = view.graft(&axis_vec(8, 1), 1);
assert_eq!(view.ref_count(2), 2);
let cands = postmortem(&view, &PromotionSignals::default());
let seqs: Vec<u64> = cands.iter().map(|c| c.chain_seq).collect();
assert!(seqs.contains(&1), "important chunk nominated");
assert!(seqs.contains(&2), "recalled chunk nominated");
assert!(!seqs.contains(&3), "untouched chunk not nominated");
let c1 = cands.iter().find(|c| c.chain_seq == 1).unwrap();
assert!(c1.reasons.contains(&PromotionReason::Important));
let c2 = cands.iter().find(|c| c.chain_seq == 2).unwrap();
assert!(c2.reasons.contains(&PromotionReason::ReferenceCount(2)));
}
#[test]
fn successful_task_lowers_recall_threshold() {
let view = SessionView::new("s1", 8);
insert(&view, 5, 0, "recalled once");
let _ = view.graft(&axis_vec(8, 0), 1);
assert!(postmortem(&view, &PromotionSignals::default()).is_empty());
let signals = PromotionSignals {
task_score: 0.9,
task_threshold: 0.7,
min_refs: 2,
};
let cands = postmortem(&view, &signals);
assert_eq!(cands.len(), 1);
assert!(
cands[0]
.reasons
.iter()
.any(|r| matches!(r, PromotionReason::TaskScore(_)))
);
}
#[test]
fn pruned_chunks_are_not_promoted() {
let view = SessionView::new("s1", 8);
insert(&view, 1, 0, "fact");
view.mark_important(1);
view.prune(1); view.set_state(1, NodeState::Pruned);
assert!(postmortem(&view, &PromotionSignals::default()).is_empty());
}
#[cfg(feature = "exochain")]
#[test]
fn promote_emits_memory_promote_event_with_lineage() {
use crate::chain::ChainManager;
let view = SessionView::new("s1", 8);
insert(&view, 11, 0, "durable A");
insert(&view, 12, 1, "durable B");
view.mark_important(11);
view.mark_important(12);
let cands = postmortem(&view, &PromotionSignals::default());
assert_eq!(cands.len(), 2);
let chain = ChainManager::new(0, 1000);
let before = chain.head_sequence();
let event = promote_to_chain(&view, &cands, "A and B are durable", &chain)
.expect("promotion event emitted");
assert_eq!(event.kind, "memory.promote");
assert!(event.sequence > before);
let payload = event.payload.expect("payload present");
let seqs = payload["source_chain_seqs"].as_array().unwrap();
let seqs: Vec<u64> = seqs.iter().map(|v| v.as_u64().unwrap()).collect();
assert!(seqs.contains(&11) && seqs.contains(&12));
assert_eq!(payload["durable_fact"], "A and B are durable");
assert_eq!(view.chunk(11).unwrap().state, NodeState::Committed);
assert_eq!(view.chunk(12).unwrap().state, NodeState::Committed);
}
#[cfg(feature = "exochain")]
#[test]
fn promote_empty_candidates_is_noop() {
use crate::chain::ChainManager;
let view = SessionView::new("s1", 8);
let chain = ChainManager::new(0, 1000);
assert!(promote_to_chain(&view, &[], "nothing", &chain).is_none());
}
}