Skip to main content

scc_context/
context_ledger.rs

1//! Context ledger (Wave 14E): what the agent has already seen, persisted in
2//! the store cache per model epoch. The ledger is the novelty-suppression
3//! source for task deltas: already-visible information is not re-injected
4//! unless it changed or is recontextualized (Aider's chat-file treatment,
5//! generalized to symbols/files/components/flows).
6
7use scc_core::ContextLedger;
8use scc_store::Store;
9use std::collections::BTreeSet;
10
11const LEDGER_KEY_PREFIX: &str = "ledger:";
12
13/// Loads/saves the per-epoch `scc_core::ContextLedger` in the store cache
14/// (key `ledger:<epoch>`, serde JSON).
15// trace:exempt reason=internal-detail
16pub struct ContextLedgerStore<'a> {
17    store: &'a Store,
18    epoch: String,
19}
20
21// trace:exempt reason=internal-detail
22impl<'a> ContextLedgerStore<'a> {
23    /// Construct the ledger store pinned to the current model epoch.
24    // trace:v1 id=impl.scc.context.ledger work=WORK-SCC-014 satisfies=REQ-SCC-IR
25    pub fn new(store: &'a Store) -> Self {
26        let epoch = store.cache_epoch().unwrap_or_else(|_| "no-epoch".into());
27        ContextLedgerStore { store, epoch }
28    }
29
30    /// The model epoch this ledger is pinned to.
31// trace:exempt reason=internal-detail
32    pub fn epoch(&self) -> &str {
33        &self.epoch
34    }
35
36// trace:exempt reason=internal-detail
37    fn key(&self) -> String {
38        format!("{LEDGER_KEY_PREFIX}{}", self.epoch)
39    }
40
41// trace:exempt reason=internal-detail
42    fn empty(&self) -> ContextLedger {
43        ContextLedger {
44            model_epoch: self.epoch.clone(),
45            ..Default::default()
46        }
47    }
48
49    /// Load the visible set for this epoch. An absent or unreadable cache
50    /// entry yields an empty ledger — never panics, never fabricates.
51// trace:exempt reason=internal-detail
52    pub fn load(&self) -> ContextLedger {
53        match self.store.cache_get(&self.key(), &self.epoch) {
54            Ok(Some(json)) => serde_json::from_str(&json).unwrap_or_else(|_| self.empty()),
55            _ => self.empty(),
56        }
57    }
58
59    /// Persist the ledger for this epoch (best-effort: cache failures never
60    /// fail the caller).
61// trace:exempt reason=internal-detail
62    pub fn save(&self, ledger: &ContextLedger) {
63        if let Ok(json) = serde_json::to_string(ledger) {
64            let _ = self.store.cache_put(&self.key(), &json, &self.epoch);
65        }
66    }
67
68    /// Kind-scoped id sets of everything visible this epoch:
69    /// `(symbols, files, components, flows)`.
70// trace:exempt reason=internal-detail
71    pub fn visible_ids(
72        &self,
73    ) -> (BTreeSet<String>, BTreeSet<String>, BTreeSet<String>, BTreeSet<String>) {
74        let led = self.load();
75        (
76            led.visible_symbols,
77            led.visible_files,
78            led.visible_components,
79            led.visible_flows,
80        )
81    }
82
83    /// Record what the agent actually saw this epoch. The caller MUST pass
84    /// kind-scoped sets derived from *rendered* ids only (audit fix: budget-
85    /// omitted candidates are never marked visible — the ledger describes
86    /// the delivered artifact, not the candidate pool). Merges into the
87    /// existing epoch ledger; best-effort persistence.
88    // trace:v1 id=impl.scc.context.ledger.record-visible work=WORK-SCC-014 satisfies=REQ-SCC-IR
89    pub fn record_visible(
90        &self,
91        symbols: &BTreeSet<String>,
92        files: &BTreeSet<String>,
93        components: &BTreeSet<String>,
94        flows: &BTreeSet<String>,
95    ) {
96        let mut led = self.load();
97        led.visible_symbols.extend(symbols.iter().cloned());
98        led.visible_files.extend(files.iter().cloned());
99        led.visible_components.extend(components.iter().cloned());
100        led.visible_flows.extend(flows.iter().cloned());
101        self.save(&led);
102    }
103}
104
105/// Novelty penalty for one symbol: how much re-injection should cost when
106/// the symbol was already shown this epoch.
107///
108/// Returns `0.1` when the symbol is already visible AND unchanged AND not a
109/// critical anchor; `1.0` otherwise. Multiplied into an entry's importance,
110/// the penalty sinks already-seen unchanged info below new/changed APIs in
111/// the task-delta budget — the spec's "already-visible info not re-injected
112/// unless changed/recontextualized". Critical anchors (ids recorded in the
113/// ledger's component/flow sets) always re-inject at full weight: the task
114/// delta never re-dumps the Atlas, and architecture anchors must stay
115/// available to consumers that need them unconditionally.
116///
117/// Consumed by the one authoritative surface service
118/// (`surface::build_surface` Task mode) via `SurfaceMode::Task { visible }`.
119// trace:v1 id=impl.scc.context.ledger.novelty work=WORK-SCC-014 satisfies=REQ-SCC-IR
120pub fn novelty_penalty(visible: &ContextLedger, symbol_id: &str, changed: bool) -> f64 {
121    let already_visible = visible.visible_symbols.contains(symbol_id)
122        || visible.visible_entities.contains(symbol_id);
123    let critical = visible.visible_components.contains(symbol_id)
124        || visible.visible_flows.contains(symbol_id);
125    if already_visible && !changed && !critical {
126        0.1
127    } else {
128        1.0
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136// trace:exempt reason=unit-test
137    fn test_store() -> (tempfile::TempDir, Store) {
138        let dir = tempfile::TempDir::new().unwrap();
139        let root = dir.path().join("repo");
140        std::fs::create_dir_all(&root).unwrap();
141        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
142        (dir, store)
143    }
144
145    #[test]
146// trace:exempt reason=internal-detail
147    fn novelty_penalty_suppresses_seen_unchanged_only() {
148        let mut led = ContextLedger::default();
149        led.visible_symbols.insert("repo://r/symbol/a.py/seen".into());
150
151        // already visible + unchanged -> suppressed (0.1)
152        assert_eq!(novelty_penalty(&led, "repo://r/symbol/a.py/seen", false), 0.1);
153        // visible_entities counts as visible too
154        led.visible_entities.insert("repo://r/symbol/b.py/ent".into());
155        assert_eq!(novelty_penalty(&led, "repo://r/symbol/b.py/ent", false), 0.1);
156        // never seen -> full weight (it is the new info the delta wants)
157        assert_eq!(novelty_penalty(&led, "repo://r/symbol/c.py/new", false), 1.0);
158        // seen but changed (recontextualized) -> full weight
159        assert_eq!(novelty_penalty(&led, "repo://r/symbol/a.py/seen", true), 1.0);
160    }
161
162    #[test]
163// trace:exempt reason=internal-detail
164    fn novelty_penalty_never_suppresses_critical_anchors() {
165        let mut led = ContextLedger::default();
166        led.visible_symbols.insert("repo://r/symbol/a.py/arch".into());
167        led.visible_components.insert("repo://r/symbol/a.py/arch".into());
168        led.visible_flows.insert("repo://r/symbol/b.py/flow".into());
169        // critical (component/flow anchor) even though seen + unchanged
170        assert_eq!(novelty_penalty(&led, "repo://r/symbol/a.py/arch", false), 1.0);
171        assert_eq!(novelty_penalty(&led, "repo://r/symbol/b.py/flow", false), 1.0);
172    }
173
174    #[test]
175// trace:exempt reason=internal-detail
176    fn ledger_roundtrip_via_store_cache() {
177        let (_dir, store) = test_store();
178        let ls = ContextLedgerStore::new(&store);
179        // empty on first load
180        let led = ls.load();
181        assert_eq!(led.model_epoch, ls.epoch());
182        assert!(led.visible_symbols.is_empty());
183
184        let mut led = led;
185        led.visible_symbols.insert("repo://r/symbol/a.py/x".into());
186        led.visible_files.insert("a.py".into());
187        led.visible_components.insert("repo://r/component/c".into());
188        led.visible_flows.insert("repo://r/flow/f".into());
189        led.last_task = Some("fix checkout".into());
190        ls.save(&led);
191
192        let ls2 = ContextLedgerStore::new(&store);
193        let loaded = ls2.load();
194        assert_eq!(loaded.visible_symbols, led.visible_symbols);
195        assert_eq!(loaded.visible_files, led.visible_files);
196        assert_eq!(loaded.visible_components, led.visible_components);
197        assert_eq!(loaded.visible_flows, led.visible_flows);
198        assert_eq!(loaded.last_task, led.last_task);
199
200        let (syms, files, comps, flows) = ls2.visible_ids();
201        assert_eq!(syms, led.visible_symbols);
202        assert_eq!(files, led.visible_files);
203        assert_eq!(comps, led.visible_components);
204        assert_eq!(flows, led.visible_flows);
205    }
206
207    #[test]
208// trace:exempt reason=internal-detail
209    fn record_visible_persists_rendered_ids_only() {
210        let (_dir, store) = test_store();
211        let ls = ContextLedgerStore::new(&store);
212        let mut syms = BTreeSet::new();
213        syms.insert("repo://r/symbol/a.py/rendered".into());
214        let mut comps = BTreeSet::new();
215        comps.insert("repo://r/component/c".into());
216        let mut flows = BTreeSet::new();
217        flows.insert("repo://r/flow/f".into());
218
219        ls.record_visible(&syms, &BTreeSet::new(), &comps, &flows);
220        let led = ls.load();
221        assert!(led.visible_symbols.contains("repo://r/symbol/a.py/rendered"));
222        assert!(led.visible_components.contains("repo://r/component/c"));
223        assert!(led.visible_flows.contains("repo://r/flow/f"));
224        // only rendered ids were recorded — nothing else leaked in
225        assert_eq!(led.visible_symbols.len(), 1);
226        assert!(led.visible_files.is_empty());
227
228        // A second recording merges (rendered ids accumulate per epoch).
229        let mut more = BTreeSet::new();
230        more.insert("repo://r/symbol/b.py/new".into());
231        ls.record_visible(&more, &BTreeSet::new(), &BTreeSet::new(), &BTreeSet::new());
232        let led = ls.load();
233        assert!(led.visible_symbols.contains("repo://r/symbol/a.py/rendered"));
234        assert!(led.visible_symbols.contains("repo://r/symbol/b.py/new"));
235        assert_eq!(led.visible_symbols.len(), 2);
236    }
237
238    #[test]
239// trace:exempt reason=internal-detail
240    fn ledger_is_epoch_scoped() {
241        let (_dir, store) = test_store();
242        let e0 = store.cache_epoch().unwrap();
243        let ls = ContextLedgerStore::new(&store);
244        let mut led = ls.load();
245        led.visible_symbols.insert("repo://r/symbol/a.py/x".into());
246        ls.save(&led);
247        assert!(ls.load().visible_symbols.contains("repo://r/symbol/a.py/x"));
248
249        // a new epoch (re-index) starts a fresh ledger; the old one is
250        // unreachable via the current epoch
251        store.bump_epoch(scc_store::ModelEpochKind::Source).unwrap();
252        let ls2 = ContextLedgerStore::new(&store);
253        assert_ne!(ls2.epoch(), e0);
254        assert!(ls2.load().visible_symbols.is_empty());
255    }
256}