Skip to main content

scc_context/
lib.rs

1//! Context Compiler (EPIC-060, docs/CONTEXT_COMPILER.md).
2//!
3//! Converts a large System IR into the smallest high-recall context pack for
4//! an agent task. Packs are structured text with bounded token budgets,
5//! evidence status, and warnings. STALE facts never enter trusted sections.
6
7pub mod atlas;
8pub mod budget;
9pub mod context_ledger;
10pub mod packs;
11pub mod pagerank;
12pub mod rank;
13pub mod relevance;
14mod repo_path;
15pub mod selector;
16pub mod skeleton;
17pub mod startup;
18pub mod structural_source;
19pub mod surface;
20
21// The one authoritative surface service (Wave 15.1): re-exported at the
22// crate root so every consumer (CLI, MCP, plugin, benchmark ablations)
23// routes through `scc_context::build_surface` without reimplementing
24// ranking. The `scc_context::surface` module path works identically.
25pub use surface::{
26    build_surface, build_surface_staged, SurfaceMode, SurfacePipelineStages, SurfacePolicy,
27    SurfaceRequest,
28};
29
30use scc_core::estimate_tokens;
31use scc_graph::{RealityGraph, TrustedGraphView, TrustPolicy};
32use scc_store::Store;
33use serde::{Deserialize, Serialize};
34use std::collections::BTreeMap;
35
36#[derive(Debug, Clone)]
37// trace:exempt reason=internal-detail
38// trace:v1 id=impl.scc.context work=WORK-SCC-014 satisfies=REQ-SCC-IR
39pub struct ContextSettings {
40    pub startup_tokens: usize,
41    pub task_tokens: usize,
42    pub atlas_tokens: usize,
43    /// Hard budget for the on-demand detail packs (component/flow/impact/
44    /// verify): standalone agent calls, not fused, so a fixed share of a
45    /// default startup total keeps them rich but bounded. Receipt: 6000
46    /// ≈ 30% of the 20k default total; every pack render guarantees fit.
47    pub detail_tokens: usize,
48    pub include_low_confidence_inference: bool,
49    /// Salt for the task-pack cache: derived from the active ranker
50    /// configuration so enabling/disabling embeddings invalidates cached
51    /// packs.
52    pub rank_salt: String,
53    /// Production default is adaptive-priority dropping. FixedRollover is
54    /// the inspectable Ripwire-style allocator; do not switch default
55    /// without ablation.
56    pub pack_allocator: PackAllocator,
57}
58
59/// How task packs spend their token budget.
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61// trace:exempt reason=internal-detail
62pub enum PackAllocator {
63    #[default]
64    AdaptivePriority,
65    FixedRollover,
66}
67
68// trace:exempt reason=internal-detail
69impl Default for ContextSettings {
70    // trace:exempt reason=internal-detail
71    fn default() -> Self {
72        ContextSettings {
73            startup_tokens: 6000,
74            task_tokens: 10000,
75            atlas_tokens: 15000,
76            detail_tokens: 6000,
77            include_low_confidence_inference: false,
78            rank_salt: String::new(),
79            pack_allocator: PackAllocator::AdaptivePriority,
80        }
81    }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
85// trace:exempt reason=internal-detail
86pub struct ContextPack {
87    pub kind: String,
88    pub repository_revision: String,
89    pub content: String,
90    #[serde(default)]
91    pub entity_ids: Vec<String>,
92    #[serde(default)]
93    pub evidence_summary: BTreeMap<String, usize>,
94    #[serde(default)]
95    pub warnings: Vec<String>,
96    #[serde(default)]
97    pub tokens: usize,
98    #[serde(default)]
99    pub budget: usize,
100    /// Token count of the full (untruncated) pack, before budget dropping.
101    #[serde(default)]
102    pub original_tokens: usize,
103    /// Sections dropped for budget, by title (never critical sections).
104    #[serde(default)]
105    pub dropped_sections: Vec<String>,
106    /// Content was cut mid-section (never happens for critical sections).
107    #[serde(default)]
108    pub hard_truncated: bool,
109    /// The minimum safe pack exceeds the soft budget: content was NOT
110    /// silently truncated; the pack is complete and over budget.
111    #[serde(default)]
112    pub exceeded_soft_budget: bool,
113    /// True when the delivered content differs from the full pack
114    /// (dropped sections, hard truncation, or budget overshoot).
115    #[serde(default)]
116    pub truncated: bool,
117    /// RTK output-compression policy hints (docs/API_AND_INTEGRATIONS.md §11):
118    /// what to preserve when compressing shell output for the agent.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub compression_policy: Option<serde_json::Value>,
121    /// Compact analyzer-health summary (machine-readable). Absent when the
122    /// index has not persisted gauges yet.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub analysis_quality: Option<scc_core::AnalysisQuality>,
125}
126
127impl ContextPack {
128    fn new(kind: &str, revision: &str) -> Self {
129        ContextPack {
130            kind: kind.to_string(),
131            repository_revision: revision.to_string(),
132            content: String::new(),
133            entity_ids: Vec::new(),
134            evidence_summary: BTreeMap::new(),
135            warnings: Vec::new(),
136            tokens: 0,
137            budget: 0,
138            original_tokens: 0,
139            dropped_sections: Vec::new(),
140            hard_truncated: false,
141            exceeded_soft_budget: false,
142            truncated: false,
143            compression_policy: None,
144            analysis_quality: None,
145        }
146    }
147}
148
149// trace:exempt reason=internal-detail
150pub struct ContextCompiler<'a> {
151    pub store: &'a Store,
152    /// Trusted view: the only way this compiler may query the reality graph.
153    /// STALE facts are excluded and surfaced as warnings; INFERRED facts
154    /// below the confidence floor are excluded unless explicitly allowed.
155    pub view: TrustedGraphView<'a>,
156    pub settings: ContextSettings,
157    /// Repository-relative paths whose content hash no longer matches the
158    /// indexed snapshot (mirrored from the view for cache-key hashing).
159    pub stale_paths: Vec<String>,
160    /// All evidence loaded once (single query) — the pack builders consult
161    /// this instead of running one SQLite query per evidence id (the atlas
162    /// on a 500k-LOC repo has hundreds of thousands of evidence rows).
163    evidence: std::cell::RefCell<Option<std::collections::HashMap<String, scc_core::Evidence>>>,
164}
165
166// trace:exempt reason=internal-detail
167impl<'a> ContextCompiler<'a> {
168    pub fn new(
169        store: &'a Store,
170        graph: &'a RealityGraph,
171        settings: ContextSettings,
172        stale_paths: Vec<String>,
173    ) -> Self {
174        // The inferred-confidence floor is governed by
175        // `include_low_confidence_inference`: low-confidence labeled
176        // inference is excluded from trusted context by default.
177        let floor = if settings.include_low_confidence_inference {
178            0.0
179        } else {
180            0.85
181        };
182        let view = TrustedGraphView::new(
183            graph,
184            store,
185            &stale_paths,
186            TrustPolicy::default().with_inferred_floor(floor),
187        );
188        ContextCompiler {
189            store,
190            view,
191            settings,
192            stale_paths,
193            evidence: std::cell::RefCell::new(None),
194        }
195    }
196
197    /// One-query evidence index (id -> Evidence), built lazily. Returns a
198    /// borrow so callers never copy the full map (hundreds of thousands of
199    /// rows on large repos).
200    pub fn evidence_map(&self) -> std::cell::Ref<'_, std::collections::HashMap<String, scc_core::Evidence>> {
201        if self.evidence.borrow().is_none() {
202            *self.evidence.borrow_mut() = Some(
203                self.store
204                    .all_evidence()
205                    .ok()
206                    .map(|evs| evs.into_iter().map(|e| (e.id.clone(), e)).collect())
207                    .unwrap_or_default(),
208            );
209        }
210        std::cell::Ref::map(self.evidence.borrow(), |slot| {
211            slot.as_ref().expect("set above")
212        })
213    }
214
215    pub fn revision(&self) -> String {
216        self.store
217            .latest_snapshot()
218            .ok()
219            .flatten()
220            .map(|s| s.revision)
221            .unwrap_or_else(|| "not-indexed".to_string())
222    }
223
224    pub fn is_stale_path(&self, path: &str) -> bool {
225        self.stale_paths.iter().any(|p| p == path)
226    }
227
228    /// Provenance accounting for a set of entity ids.
229    pub fn evidence_summary(&self, entity_ids: &[String]) -> BTreeMap<String, usize> {
230        let mut m: BTreeMap<String, usize> = BTreeMap::new();
231        let evmap = self.evidence_map();
232        for id in entity_ids {
233            if let Some(e) = self.view.entity(id) {
234                for ev_id in &e.evidence {
235                    if let Some(ev) = evmap.get(ev_id) {
236                        let path = ev.path.clone().unwrap_or_default();
237                        if self.is_stale_path(&path) {
238                            *m.entry("STALE".into()).or_insert(0) += 1;
239                            continue;
240                        }
241                        let et = match ev.r#type {
242                            scc_core::EvidenceType::Source => "SOURCE",
243                            scc_core::EvidenceType::Config => "CONFIG",
244                            scc_core::EvidenceType::Runtime => "RUNTIME",
245                            scc_core::EvidenceType::Test => "TEST",
246                            scc_core::EvidenceType::Intent => "INTENT",
247                            scc_core::EvidenceType::History => "HISTORY",
248                        };
249                        *m.entry(et.to_string()).or_insert(0) += 1;
250                    }
251                }
252            }
253        }
254        for r in self.view.all_rels() {
255            if entity_ids.contains(&r.subject)
256                && !r.evidence.is_empty() {
257                    let key = r.provenance.as_str().to_string();
258                    *m.entry(key).or_insert(0) += 1;
259                }
260        }
261        m
262    }
263
264    /// Apply the token budget to a pack (P0 rendering contract): the pack
265    /// builder has already dropped lowest-priority sections; this records
266    /// honest accounting. Critical content is never hard-truncated — if the
267    /// minimum safe pack still exceeds the budget, the pack stays complete
268    /// and `exceeded_soft_budget` is set instead of silently cutting facts.
269    pub fn apply_budget(&mut self, pack: &mut ContextPack, budget: usize) {
270        pack.budget = budget;
271        if pack.original_tokens == 0 {
272            pack.original_tokens = estimate_tokens(&pack.content);
273        }
274        pack.tokens = estimate_tokens(&pack.content);
275        let over = pack.tokens > budget;
276        pack.truncated = pack.hard_truncated || !pack.dropped_sections.is_empty() || over;
277        if over && !pack.hard_truncated {
278            pack.exceeded_soft_budget = true;
279        }
280    }
281
282    // ---- top-level operations ----
283
284    pub fn system_overview(&self) -> ContextPack {
285        packs::overview(self)
286    }
287
288    /// Full System Atlas (Wave 2): the startup architecture artifact.
289    /// Cached under the model epoch + stale set like task packs — a stale
290    /// atlas is never served. The pack render guarantees fit: dropped and
291    /// line-truncated sections are recorded, never silent.
292    // trace:exempt reason=internal-detail
293    pub fn system_atlas(&self, budget: Option<usize>) -> ContextPack {
294        self.system_atlas_scoped(budget, atlas::AtlasScope::Production, false)
295    }
296
297    /// [`system_atlas`] with an explicit scope. The scope is part of the
298    /// cache key: production and full compilations never share entries.
299    // trace:v1 id=impl.scc.context.system-atlas-scoped work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
300    pub fn system_atlas_scoped(
301        &self,
302        budget: Option<usize>,
303        scope: atlas::AtlasScope,
304        full: bool,
305    ) -> ContextPack {
306        let budget = budget.unwrap_or(self.settings.atlas_tokens);
307        let epoch = self.store.cache_epoch().unwrap_or_else(|_| "no-epoch".into());
308        let key = {
309            let mut h = blake3::Hasher::new();
310            h.update(b"atlas");
311            h.update(budget.to_string().as_bytes());
312            h.update(format!("{scope:?}").as_bytes());
313            h.update(if full { b"full" } else { b"hard" });
314            h.update(self.settings.rank_salt.as_bytes());
315            h.update(epoch.as_bytes());
316            let mut stale: Vec<&String> = self.stale_paths.iter().collect();
317            stale.sort();
318            for p in stale {
319                h.update(p.as_bytes());
320                h.update(b"\0");
321            }
322            format!("atlas:{}", &h.finalize().to_hex()[..20])
323        };
324        if let Ok(Some(cached)) = self.store.cache_get(&key, &epoch) {
325            if let Ok(pack) = serde_json::from_str::<ContextPack>(&cached) {
326                return pack;
327            }
328        }
329        let atlas = atlas::build_atlas_scoped(self, scope);
330        let pack = atlas::render_atlas(self, &atlas, budget, full);
331        if let Ok(json) = serde_json::to_string(&pack) {
332            let _ = self.store.cache_put(&key, &json, &epoch);
333        }
334        pack
335    }
336
337    pub fn task_context(
338        &self,
339        goal: &str,
340        files: &[String],
341        symbols: &[String],
342        token_budget: Option<usize>,
343    ) -> ContextPack {
344        self.task_context_with_rankers(goal, files, symbols, token_budget, None, None)
345    }
346
347    /// `task_context` with optional semantic scorer and reranker (SCC-071).
348    // trace:exempt reason=internal-detail
349    pub fn task_context_with_rankers(
350        &self,
351        goal: &str,
352        files: &[String],
353        symbols: &[String],
354        token_budget: Option<usize>,
355        scorer: Option<&dyn crate::rank::SemanticScorer>,
356        reranker: Option<&dyn crate::rank::Reranker>,
357    ) -> ContextPack {
358        let budget = token_budget.unwrap_or(self.settings.task_tokens).max(512);
359        // Cache (P0 trust contract): keyed by (goal, inputs, budget,
360        // ranker salt) + the model epoch + the stale-path set. A file that
361        // changed since indexing makes the key differ even without a
362        // re-index, so a previously fresh pack can never be served after
363        // its evidence is stale.
364        let epoch = self.store.cache_epoch().unwrap_or_else(|_| "no-epoch".into());
365        let key = {
366            let mut h = blake3::Hasher::new();
367            h.update(goal.as_bytes());
368            for f in files {
369                h.update(f.as_bytes());
370            }
371            for s in symbols {
372                h.update(s.as_bytes());
373            }
374            h.update(budget.to_string().as_bytes());
375            h.update(self.settings.rank_salt.as_bytes());
376            h.update(match self.settings.pack_allocator {
377                PackAllocator::AdaptivePriority => b"alloc:adaptive",
378                PackAllocator::FixedRollover => b"alloc:rollover",
379            });
380            h.update(epoch.as_bytes());
381            let mut stale: Vec<&String> = self.stale_paths.iter().collect();
382            stale.sort();
383            for p in stale {
384                h.update(p.as_bytes());
385                h.update(b"\0");
386            }
387            format!("task:{}", &h.finalize().to_hex()[..20])
388        };
389        if let Ok(Some(cached)) = self.store.cache_get(&key, &epoch) {
390            if let Ok(pack) = serde_json::from_str::<ContextPack>(&cached) {
391                return pack;
392            }
393        }
394        let pack =
395            packs::task_with_rankers(self, goal, files, symbols, budget, scorer, reranker);
396        if let Ok(json) = serde_json::to_string(&pack) {
397            let _ = self.store.cache_put(&key, &json, &epoch);
398        }
399        pack
400    }
401
402    // trace:exempt reason=internal-detail
403    pub fn component_context_full(&self, id: &str) -> ContextPack {
404        packs::component(self, id, self.settings.detail_tokens, true)
405    }
406
407// trace:exempt reason=internal-detail
408    pub fn component_context(&self, id: &str) -> ContextPack {
409        packs::component(self, id, self.settings.detail_tokens, false)
410    }
411
412    // trace:exempt reason=internal-detail
413    pub fn flow_context_full(&self, id: &str) -> ContextPack {
414        packs::flow(self, id, self.settings.detail_tokens, true)
415    }
416
417// trace:exempt reason=internal-detail
418    pub fn flow_context(&self, id: &str) -> ContextPack {
419        packs::flow(self, id, self.settings.detail_tokens, false)
420    }
421
422    // trace:exempt reason=internal-detail
423    pub fn impact_context(
424        &self,
425        files: &[String],
426        symbols: &[String],
427        diff_base: Option<&str>,
428    ) -> ContextPack {
429        packs::impact(self, files, symbols, diff_base, self.settings.detail_tokens, false)
430    }
431
432// trace:exempt reason=internal-detail
433    pub fn impact_context_full(
434        &self,
435        files: &[String],
436        symbols: &[String],
437        diff_base: Option<&str>,
438    ) -> ContextPack {
439        packs::impact(self, files, symbols, diff_base, self.settings.detail_tokens, true)
440    }
441
442    // trace:exempt reason=internal-detail
443    pub fn verify_context_full(&self) -> ContextPack {
444        packs::verify(self, self.settings.detail_tokens, true)
445    }
446
447// trace:exempt reason=internal-detail
448    pub fn verify_context(&self) -> ContextPack {
449        packs::verify(self, self.settings.detail_tokens, false)
450    }
451}