Skip to main content

scc_context/
startup.rs

1//! Startup context (Wave 14C): the deterministic Atlas + Surface fusion
2//! handed to agents at session start, plus the task delta (Wave 14E) that
3//! renders only *new* relevant APIs against the context ledger.
4//!
5//! Prompt-cache stability: the artifact hash is a pure function of
6//! `(epoch, renderer_version, trust_policy, budget)` — no timestamps — so
7//! the same epoch + config always yields byte-identical startup text.
8
9use crate::surface::{build_surface, build_surface_cached, SurfaceMode, SurfacePolicy, SurfaceRequest};
10use crate::ContextCompiler;
11use scc_core::kinds;
12use scc_core::{estimate_tokens, ContextArtifact, ContextBudget, ContextLedger};
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, BTreeSet};
15
16/// Renderer version: part of the artifact hash. Bump when the startup
17/// renderer's output format changes (invalidates prompt-cache keys).
18pub const RENDERER_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20/// The startup artifact: atlas + surface + coverage + omissions, with the
21/// deterministic artifact hash. `surface_render` is the SAME render the
22/// artifact printed — ledger recording derives visible ids from it, so
23/// the surface is never computed twice per startup.
24// trace:exempt reason=internal-detail
25pub struct StartupContext {
26    pub atlas: String,
27    /// Atlas budget the DELIVERED atlas text was rendered under (normally
28    /// the loop's final `atlas_budget`, or the 256-token essentials budget
29    /// on the emergency floor). The ledger rebuilds the same pack, so
30    /// recorded atlas ids never describe undelivered text (§53 coupling).
31    pub atlas_budget_used: usize,
32    /// Deterministic physical-layout evidence (repository skeleton),
33    /// built from the indexed file inventory under its own hard budget.
34    pub skeleton: String,
35    pub surface: String,
36    /// IMPORTANT SYMBOLS section (audit item 3): the fast "where do I pay
37    /// attention first" answer, rendered before the long Surface Map
38    /// detail. Computed from the same global rank the surface used.
39    pub important: String,
40    pub surface_render: scc_core::SurfaceRenderResult,
41    pub coverage: Vec<String>,
42    pub omissions: Vec<String>,
43    pub artifact: ContextArtifact,
44}
45/// Global-rank cache (Wave 15.2, per-ModelEpoch rank caching): the
46/// expensive, epoch-stable parts of a global Surface build —
47/// `SystemRanker::new` (heterogeneous node graph + adjacency + rarity),
48/// the 50-iteration global PageRank vector, and the projection to symbol
49/// scores — serialized to the store cache so consecutive startups in the
50/// same model epoch skip the rank build entirely.
51///
52/// Scope evidence (Part F profiling, cli-service fixture, budget 7000):
53/// cold rebuild 44-242µs vs 25µs cache hit — the rebuild is sub-millisecond
54/// at fixture scale, so the seam stays STARTUP-ONLY; plain `scc surface` /
55/// MCP `surface_map` keep the uncached `build_surface` (identical output,
56/// measured cost negligible). Re-measure before extending the seam to
57/// larger corpora. Key:
58/// `rank:global:<blake3(epoch, policy, salt)[..20]>` (mirrors the
59/// `system_atlas` pack-cache pattern in `ContextCompiler::system_atlas`).
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61// trace:exempt reason=internal-detail
62pub struct GlobalRankCache {
63    /// The composite cache epoch the entry was computed under.
64    pub epoch: String,
65    /// The TrustPolicy fingerprint (`trust_policy_str`) the rank used.
66    pub policy: String,
67    /// The active rank salt (`ContextSettings::rank_salt`).
68    pub salt: String,
69    /// The heterogeneous global PageRank vector (index i == `nodes()[i]`).
70    /// Retained so a future task-PPR path can warm-start from it.
71    pub global_vector: Vec<f64>,
72    /// Symbol id -> projected global score (`project_to_symbols` output) —
73    /// exactly what the surface pipeline consumes as `global_of`.
74    pub node_symbol_map: BTreeMap<String, f64>,
75    /// The epoch the candidate entry list came from (epoch-stability
76    /// marker; the cache key already pins the epoch).
77    pub candidates_epoch: String,
78    /// Epoch-stable candidate entry ids (cheap accounting — avoids a
79    /// `compile_surface_map` walk for the surface accounting line).
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub candidate_ids: Vec<String>,
82    /// How many times this entry has been reused (deterministic
83    /// cache-hit marker for tests).
84    #[serde(default)]
85    pub hits: u64,
86}
87
88/// THE one startup-budget allocator (transport parity): every transport
89/// that builds a startup artifact — CLI `context startup`, MCP
90/// `system_context`, HTTP, Hermes, the SDKs, the Claude SessionStart /
91/// PreCompact hooks, and the benchmark harness — resolves its budget
92/// through this function and never derives an Atlas:Surface split itself.
93///
94/// `target_tokens == None` does NOT bypass adaptation: it selects the
95/// configured startup ceiling (`compiler.settings.startup_tokens`), which
96/// then goes through the SAME adaptive complexity split. An explicit target
97/// scales the total; repo complexity decides the split — in both cases via
98/// [`scc_core::ContextBudget::adaptive`]. Deterministic per (target,
99/// view): same inputs, same budget.
100// trace:v1 id=impl.scc.startup.allocate-budget work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-adaptive-startup-budgets
101pub fn allocate_startup_budget(
102    compiler: &ContextCompiler,
103    target_tokens: Option<usize>,
104) -> ContextBudget {
105    let total = target_tokens.unwrap_or(compiler.settings.startup_tokens);
106    let view = &compiler.view;
107    let entity_count = view.entities().count();
108    let component_count = view.components().len();
109    let flow_count = view.flows().len();
110    let surface_candidates = view.entities_of_kind(scc_core::kinds::SYMBOL).len();
111    scc_core::ContextBudget::adaptive(total, entity_count, component_count, flow_count, surface_candidates)
112}
113
114// trace:v1 id=impl.crates-scc-context-src-startup.coverage-lines work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-hard-max-invariant-on-rendered-text
115fn coverage_lines(
116    compiler: &ContextCompiler,
117    render_ids: usize,
118    candidates: usize,
119    render_tokens: usize,
120    surface_budget: usize,
121) -> Vec<String> {
122    let mut coverage = Vec::new();
123    for w in compiler_warnings(compiler) {
124        coverage.push(w);
125    }
126    let mut stale: Vec<String> = compiler.stale_paths.clone();
127    stale.sort();
128    stale.dedup();
129    // Bounded: per-path lines aid small repos; a 10k-stale repo reports
130    // counts, never 10k lines (the emergency floor omits paths entirely).
131    const MAX_STALE_LINES: usize = 10;
132    for p in stale.iter().take(MAX_STALE_LINES) {
133        coverage.push(format!("stale: {p}"));
134    }
135    if stale.len() > MAX_STALE_LINES {
136        coverage.push(format!(
137            "stale: …and {} more changed file(s) not yet re-indexed",
138            stale.len() - MAX_STALE_LINES
139        ));
140    }
141    coverage.push(format!(
142        "surface map: {} of {} entries rendered, {} tokens (budget {})",
143        render_ids,
144        candidates,
145        render_tokens,
146        surface_budget
147    ));
148    coverage
149}
150
151// OMISSIONS helper (module-level so its trace marker attaches): the render
152// result's per-kind cuts + omitted-id count + the
153// atlas's dropped sections (honest: omitted ids are never silent).
154// trace:v1 id=impl.crates-scc-context-src-startup.omission-lines
155fn omission_lines(
156    render_omissions: &[scc_core::SurfaceOmission],
157    omitted_len: usize,
158    atlas_dropped: &[String],
159    atlas_hard_truncated: bool,
160    atlas_exceeded: bool,
161) -> Vec<String> {
162    let mut omissions = Vec::new();
163    for o in render_omissions {
164        omissions.push(format!("surface: {} ({})", o.kind, o.reason));
165    }
166    if omitted_len > 0 {
167        omissions.push(format!(
168            "surface: {} lower-ranked definitions omitted",
169            omitted_len
170        ));
171    }
172    for d in atlas_dropped {
173        omissions.push(format!("atlas section dropped: {d}"));
174    }
175    if atlas_hard_truncated {
176        omissions.push("atlas hard-truncated mid-section".into());
177    }
178    if atlas_exceeded {
179        omissions.push("atlas exceeded soft budget: kept complete, over budget".into());
180    }
181    if omissions.is_empty() {
182        omissions.push("none".into());
183    }
184    omissions
185}
186
187/// Build the deterministic startup artifact: the existing System Atlas
188/// content (capped at `budget.atlas`) fused with the budget-selected
189/// System Surface Map (the production `select_and_render_global` pipeline),
190/// coverage warnings, and honest omissions. The surface's omitted ids
191/// populate the OMISSIONS section; the ledger records ONLY rendered ids.
192// trace:v1 id=impl.scc.context.startup work=WORK-SCC-014 satisfies=REQ-SCC-IR
193pub fn build_startup(
194    compiler: &ContextCompiler,
195    budget: &ContextBudget,
196    renderer_version: &str,
197) -> StartupContext {
198    let epoch = compiler
199        .store
200        .cache_epoch()
201        .unwrap_or_else(|_| "no-epoch".into());
202
203    // Atlas: reuse the existing cached-atlas pipeline (system_atlas is
204    // cache-keyed by epoch + stale set; a stale atlas is never served).
205    let atlas_pack = compiler.system_atlas(Some(budget.atlas));
206    let atlas = atlas_pack.content.clone();
207
208    // Repository Skeleton: physical-layout evidence from the indexed file
209    // inventory, under its own hard budget. Pre-bounded (never rebalanced
210    // by the corrective loop below), deterministic per epoch, and counted
211    // in every fused hard-max probe.
212    let paths: Vec<String> = compiler
213        .store
214        .all_files()
215        .unwrap_or_default()
216        .into_iter()
217        .map(|(p, _, _, _, _)| p)
218        .collect();
219    let mut skeleton =
220        crate::skeleton::build_skeleton(&paths, crate::skeleton::skeleton_budget(budget.total)).text;
221
222    // Surface: the FULL production pipeline — the one authoritative
223    // [`build_surface`] service in Global mode (heterogeneous global PPR,
224    // required coverage, MMR diversity, token-aware quotas, soft/hard
225    // budget selection, render) — built EXACTLY ONCE per startup. The
226    // per-ModelEpoch global-rank cache supplies the PPR vector + symbol
227    // projection on hit (skipping SystemRanker::new + the 50 power
228    // iterations); on miss the render fills the cache, persisted below.
229    let mut rank_cache = load_global_rank_cache(compiler);
230    let cache_hit = rank_cache.is_some();
231    let render = build_surface_cached(
232        compiler,
233        SurfaceRequest {
234            mode: SurfaceMode::Global,
235            budget: budget.surface,
236            explain: false,
237            policy: SurfacePolicy::defaults(budget.surface),
238            semantic: None,
239        },
240        &mut rank_cache,
241    );
242    // Startup hard-max semantics (Part E): `budget.total` is the SOFT
243    // target; the hard maximum is target + 20% (min +500). Atlas and
244    // Surface may borrow from each other within it: if the FUSED body
245    // (atlas + coverage + omissions + surface) exceeds the hard max, the
246    // surface slice is re-selected against whatever room the atlas's
247    // ACTUAL size left — never by silently cutting the artifact.
248    let startup_hard_max = budget.total.saturating_add((budget.total / 5).max(500));
249
250    let mut render = render;
251    let mut atlas_pack = atlas_pack;
252    let mut atlas = atlas;
253    let mut atlas_budget = budget.atlas;
254    // MODEL COVERAGE: compiler warnings + stale paths + a surface accounting
255    // line, derived from the render itself (rendered ∪ omitted == every
256    // candidate) — never a second compile_surface_map walk. Recomputed
257    // after any hard-max rebalance below.
258    let mut coverage = coverage_lines(
259        compiler,
260        render.rendered_ids.len(),
261        render.rendered_ids.len() + render.omitted_ids.len(),
262        render.token_count,
263        budget.surface,
264    );
265
266    // Startup hard-max CORRECTIVE LOOP (Part 4): the invariant is on the
267    // FINAL assembled text — `estimate_tokens(assemble_body(atlas, surface,
268    // coverage, omissions)) <= startup_hard_max`. Headers, coverage lines,
269    // omission text, and the atlas's actual size can each push the fused
270    // body past the room estimated pre-assembly, so the ONE-SHOT rebalance
271    // is not sufficient. This loop reassembles and recounts after every
272    // change; first it shrinks the Surface (re-selected with hard_max = the
273    // room the current atlas/coverage/omissions actually leave), then, when
274    // the Surface is at its floor (no room left), it shrinks the Atlas
275    // budget in 25% steps — dropping lower-priority sections that are
276    // RECORDED via atlas_pack.dropped_sections and surfaced in OMISSIONS.
277    // Deterministic convergence, not a fixed round cap: each round either
278    // shrinks the Surface (re-selected with hard_max = the room the current
279    // atlas/coverage/omissions actually leave) or, once the surface is at
280    // its 64-token floor, shrinks the Atlas budget by 25% (dropping
281    // lower-priority sections that are RECORDED via dropped_sections and
282    // surfaced in OMISSIONS). Both steps are monotone, so the loop
283    // terminates at the natural floor in a bounded number of rounds (well
284    // under the 64-round safety net). The fit check counts the FULL
285    // assembled block (the "# SCC SYSTEM CONTEXT" header + artifact comment
286    // are part of the final text), so the invariant holds on artifact.text.
287    const BLOCK_HEADER_OVERHEAD: usize = 40; // header + metadata comment, chars/4
288    let mut iterations = 0;
289    loop {
290        let omissions_probe = omission_lines(
291            &render.omissions,
292            render.omitted_ids.len(),
293            &atlas_pack.dropped_sections,
294            atlas_pack.hard_truncated,
295            atlas_pack.exceeded_soft_budget,
296        );
297        let fused = assemble_body(&atlas, &skeleton, &render.text, "", &coverage, &omissions_probe);
298        if BLOCK_HEADER_OVERHEAD + estimate_tokens(&fused) <= startup_hard_max {
299            break;
300        }
301        iterations += 1;
302        if iterations > 64 {
303            // Safety net: the natural floors (surface < 64 tokens + atlas
304            // floor) always terminate well before this; if both floors are
305            // exhausted and the artifact STILL exceeds the hard max, the
306            // final assert below documents the pathological residual rather
307            // than looping forever.
308            break;
309        }
310        let overhead = estimate_tokens(&assemble_body(&atlas, &skeleton, "", "", &coverage, &omissions_probe));
311        let room = startup_hard_max.saturating_sub(overhead);
312        if room >= 64 {
313            // Surface still has room: shrink it to the room the current
314            // atlas + coverage + omissions ACTUALLY leave (headers included).
315            let policy = SurfacePolicy {
316                quotas: true,
317                mmr: true,
318                coverage: true,
319                hard_max: room,
320            };
321            render = build_surface_cached(
322                compiler,
323                SurfaceRequest {
324                    mode: SurfaceMode::Global,
325                    budget: room,
326                    explain: false,
327                    policy,
328                    semantic: None,
329                },
330                &mut rank_cache,
331            );
332            coverage = coverage_lines(
333                compiler,
334                render.rendered_ids.len(),
335                render.rendered_ids.len() + render.omitted_ids.len(),
336                render.token_count,
337                room,
338            );
339        } else {
340            // Surface at its floor: shrink the Atlas in 25% steps.
341            let smaller = atlas_budget.saturating_mul(3) / 4;
342            if smaller == atlas_budget {
343                break; // atlas floor reached — nothing more to give.
344            }
345            atlas_budget = smaller;
346            atlas_pack = compiler.system_atlas(Some(atlas_budget));
347            atlas = atlas_pack.content.clone();
348        }
349    }
350
351    // Best-effort persistence AFTER the corrective loop (the final surface
352    // render also feeds the cache): a cache failure never fails startup.
353    // The hit counter is the deterministic "reused on run 2" marker.
354    if let Some(c) = &mut rank_cache {
355        if cache_hit {
356            c.hits += 1;
357        }
358        store_global_rank_cache(compiler, c);
359    }
360    let mut surface = render.text.clone();
361    let mut atlas_budget_used = atlas_budget;
362
363    // Emergency floor (Part 4): the corrective loop guarantees fit
364    // whenever the Surface/Atlas floors leave room. When even the floors
365    // overflow (a tiny budget on a large repository), degrade
366    // structurally instead of escaping the cap: atlas essentials at a
367    // fixed 256-token budget, surface omitted (ledger ids cleared so §53
368    // coupling holds), skeleton shrunk until the fused receipt fits. The
369    // delivered text ALWAYS satisfies the hard max — there is no overflow
370    // escape hatch. `atlas_budget_used` tracks the delivered atlas so the
371    // ledger rebuilds the same pack it describes.
372    let emergency_omissions: Option<Vec<String>>;
373    {
374        let omissions_probe0 = omission_lines(
375            &render.omissions,
376            render.omitted_ids.len(),
377            &atlas_pack.dropped_sections,
378            atlas_pack.hard_truncated,
379            atlas_pack.exceeded_soft_budget,
380        );
381        if BLOCK_HEADER_OVERHEAD
382            + estimate_tokens(&assemble_body(
383                &atlas,
384                &skeleton,
385                &surface,
386                "",
387                &coverage,
388                &omissions_probe0,
389            ))
390            <= startup_hard_max
391        {
392            emergency_omissions = None;
393        } else {
394
395        atlas_budget_used = 256;
396        atlas_pack = compiler.system_atlas(Some(atlas_budget_used));
397        atlas = atlas_pack.content.clone();
398        render.rendered_ids.clear();
399        render.omitted_ids.clear();
400        render.omissions.clear();
401        render.text = String::new();
402        render.token_count = 0;
403        surface = String::new();
404        // Minimal emergency coverage: warnings + counts, never per-path
405        // lines (a 10k-stale repo must still fit a tiny budget).
406        coverage = compiler_warnings(compiler);
407        if compiler.stale_paths.is_empty() {
408            coverage.push("model: current".into());
409        } else {
410            coverage.push(format!(
411                "model stale: {} changed file(s) not yet re-indexed (paths omitted over hard max)",
412                compiler.stale_paths.len()
413            ));
414        }
415        coverage.push("surface map: omitted over hard max (0 rendered)".into());
416        let mut skel_budget = crate::skeleton::skeleton_budget(budget.total);
417        loop {
418            let probe = crate::skeleton::build_skeleton(&paths, skel_budget);
419            let omissions_probe = {
420            let mut o = omission_lines(
421                &[],
422                0,
423                &atlas_pack.dropped_sections,
424                atlas_pack.hard_truncated,
425                atlas_pack.exceeded_soft_budget,
426            );
427            o.push(
428                "startup emergency compression: atlas essentials + skeleton only (surface omitted over hard max)".into(),
429            );
430            o };
431            let fused = assemble_body(&atlas, &probe.text, "", "", &coverage, &omissions_probe);
432            if BLOCK_HEADER_OVERHEAD + estimate_tokens(&fused) <= startup_hard_max
433                || skel_budget == 0
434            {
435                skeleton = probe.text;
436                emergency_omissions = Some(omissions_probe);
437                break;
438            }
439            skel_budget /= 2;
440        }
441    }
442    }
443
444    // IMPORTANT FUNDED (audit item 3): the section joins the fused body,
445    // so it must fit the hard max like every other section. If the
446    // post-loop important text pushes the fused artifact over hard_max,
447    // shrink the surface once more by exactly that overrun and recompute
448    // the section from the new render (monotone: at most one extra pass).
449    // Section budget: ~8% of the hard max at ~40 tokens/symbol,
450    // floored to 1 symbol and capped at 15. Tiny totals list fewer
451    // symbols instead of either blowing the hard max or flooring to
452    // nothing. Receipt: 15 symbols render ~550 tokens; a 1024-total
453    // startup (hard max ~1500) funds ~120 section tokens ≈ 3 symbols.
454    let important_n = (startup_hard_max / 500).clamp(1, 15);
455    let mut important = if render.rendered_ids.is_empty() {
456        "## SYSTEM-CRITICAL SYMBOLS\n(surface omitted over hard max)\n".to_string()
457    } else {
458        let top = crate::surface::important_symbols(
459            compiler,
460            crate::surface::SurfaceMode::Global,
461            important_n,
462        );
463        crate::surface::render_important(&top, false)
464    };
465    {
466        let probe_om = omission_lines(
467            &render.omissions,
468            render.omitted_ids.len(),
469            &atlas_pack.dropped_sections,
470            atlas_pack.hard_truncated,
471            atlas_pack.exceeded_soft_budget,
472        );
473        let fused = assemble_body(&atlas, &skeleton, &render.text, &important, &coverage, &probe_om);
474        if BLOCK_HEADER_OVERHEAD + estimate_tokens(&fused) > startup_hard_max && !render.rendered_ids.is_empty() {
475            let over = BLOCK_HEADER_OVERHEAD + estimate_tokens(&fused) - startup_hard_max;
476            let room = render
477                .token_count
478                .saturating_sub(over)
479                .saturating_sub(estimate_tokens(&important));
480            if room >= 64 {
481                let policy = SurfacePolicy {
482                    quotas: true,
483                    mmr: true,
484                    coverage: true,
485                    hard_max: room,
486                };
487                render = build_surface_cached(
488                    compiler,
489                    SurfaceRequest {
490                        mode: SurfaceMode::Global,
491                        budget: room,
492                        explain: false,
493                        policy,
494                        semantic: None,
495                    },
496                    &mut rank_cache,
497                );
498                coverage = coverage_lines(
499                    compiler,
500                    render.rendered_ids.len(),
501                    render.rendered_ids.len() + render.omitted_ids.len(),
502                    render.token_count,
503                    room,
504                );
505                important = if render.rendered_ids.is_empty() {
506                    "## SYSTEM-CRITICAL SYMBOLS\n(surface omitted over hard max)\n".to_string()
507                } else {
508                    let top = crate::surface::important_symbols(
509                        compiler,
510                        crate::surface::SurfaceMode::Global,
511                        important_n,
512                    );
513                    crate::surface::render_important(&top, false)
514                };
515                // Final probe: the recomputed section + smaller surface
516                // may still exceed (tiny total, huge repo) — floor it.
517                let probe_om2 = omission_lines(
518                    &render.omissions,
519                    render.omitted_ids.len(),
520                    &atlas_pack.dropped_sections,
521                    atlas_pack.hard_truncated,
522                    atlas_pack.exceeded_soft_budget,
523                );
524                let fused2 = assemble_body(&atlas, &skeleton, &render.text, &important, &coverage, &probe_om2);
525                if BLOCK_HEADER_OVERHEAD + estimate_tokens(&fused2) > startup_hard_max {
526                    important = "## SYSTEM-CRITICAL SYMBOLS\n(omitted over hard max)\n".to_string();
527                }
528            }
529        } else {
530            // No room to fund the section: floor it to one line.
531            important = "## SYSTEM-CRITICAL SYMBOLS\n(omitted over hard max)\n".to_string();
532        }
533    }
534
535    // OMISSIONS (final render — after the corrective loop): the render
536    // result's per-kind cuts + omitted-id count + the atlas's dropped
537    // sections (honest: omitted ids are never silent).
538    let omissions = emergency_omissions.unwrap_or_else(|| {
539        omission_lines(
540            &render.omissions,
541            render.omitted_ids.len(),
542            &atlas_pack.dropped_sections,
543            atlas_pack.hard_truncated,
544            atlas_pack.exceeded_soft_budget,
545        )
546    });
547
548    let trust_policy = trust_policy_str(compiler.view.policy());
549
550    // Deterministic artifact hash: blake3 over (epoch + renderer_version +
551    // trust_policy + budget fields). The preimage never contains
552    // timestamps or volatile state, so the hash is stable per epoch.
553    let mut h = blake3::Hasher::new();
554    h.update(b"startup-artifact-v1");
555    h.update(epoch.as_bytes());
556    h.update(renderer_version.as_bytes());
557    h.update(trust_policy.as_bytes());
558    h.update(budget.total.to_string().as_bytes());
559    h.update(budget.atlas.to_string().as_bytes());
560    h.update(budget.surface.to_string().as_bytes());
561    h.update(budget.task_delta.to_string().as_bytes());
562    h.update(budget.structural_source.to_string().as_bytes());
563    let sha256 = h.finalize().to_hex().to_string();
564
565    // CONTENT hash: the same config preimage PLUS the actual rendered text
566    // (atlas + surface + coverage + omissions, without the artifact metadata
567    // comment). A content change that keeps the config identical now
568    // changes the hash — the audit's name/content mismatch fix.
569    let body = assemble_body(&atlas, &skeleton, &surface, &important, &coverage, &omissions);
570    let mut ch = blake3::Hasher::new();
571    ch.update(b"startup-content-v1");
572    ch.update(epoch.as_bytes());
573    ch.update(renderer_version.as_bytes());
574    ch.update(trust_policy.as_bytes());
575    ch.update(budget.total.to_string().as_bytes());
576    ch.update(budget.atlas.to_string().as_bytes());
577    ch.update(budget.surface.to_string().as_bytes());
578    ch.update(budget.task_delta.to_string().as_bytes());
579    ch.update(budget.structural_source.to_string().as_bytes());
580    ch.update(body.as_bytes());
581    let content_hash = ch.finalize().to_hex().to_string();
582
583    let mut artifact = ContextArtifact {
584        kind: "startup".into(),
585        epoch,
586        renderer_version: renderer_version.to_string(),
587        trust_policy,
588        budget: budget.clone(),
589        sha256,
590        content_hash,
591        text: String::new(),
592    };
593    artifact.text = assemble_block(&atlas, &skeleton, &surface, &important, &coverage, &omissions, &artifact);
594
595    StartupContext {
596        atlas,
597        atlas_budget_used,
598        skeleton,
599        surface,
600        important,
601        surface_render: render,
602        coverage,
603        omissions,
604        artifact,
605    }
606}
607/// The store-cache key for the global rank cache: `rank:global:` +
608/// blake3 over the composite cache epoch, the TrustPolicy fingerprint,
609/// and the rank salt (truncated to 20 hex chars, mirroring the
610/// `system_atlas` pack-cache key pattern). A changed epoch, policy, or
611/// salt yields a different key — a stale entry is never served.
612// trace:exempt reason=internal-detail
613fn global_rank_key(epoch: &str, policy: &str, salt: &str, pipeline: &str) -> String {
614    let mut h = blake3::Hasher::new();
615    h.update(b"rank:global:v1");
616    h.update(epoch.as_bytes());
617    h.update(b"\0");
618    h.update(policy.as_bytes());
619    h.update(b"\0");
620    h.update(salt.as_bytes());
621    h.update(b"\0");
622    h.update(pipeline.as_bytes());
623    format!("rank:global:{}", &h.finalize().to_hex()[..20])
624}
625
626
627/// Load the per-ModelEpoch global rank cache from the store cache
628/// (key `rank:global:<hash>` over epoch + policy + salt). `None` on any
629/// miss/error — never panics, never fabricates. The entry is validated
630/// against the current epoch/policy/salt (belt-and-suspenders: the key
631/// already pins them).
632/// Pipeline-aware variant (spec §58): `pipeline` is the ranking-pipeline
633/// hash (e.g. the engine plugin cache-key fragment). "" preserves the
634/// legacy key exactly — no-plugin callers see byte-identical keys.
635/// Supersedes impl.scc.startup.rank-cache-load (same body + pipeline).
636// trace:v1 id=impl.scc.startup.rank-cache-pipeline work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
637pub fn load_global_rank_cache_with_pipeline(compiler: &ContextCompiler, pipeline: &str) -> Option<GlobalRankCache> {
638    let epoch = compiler.store.cache_epoch().ok()?;
639    let policy = trust_policy_str(compiler.view.policy());
640    let salt = &compiler.settings.rank_salt;
641    let key = global_rank_key(&epoch, &policy, salt, pipeline);
642    let cached = compiler.store.cache_get(&key, &epoch).ok().flatten()?;
643    let c: GlobalRankCache = serde_json::from_str(&cached).ok()?;
644    if c.epoch != epoch || c.policy != policy || c.salt != *salt {
645        return None;
646    }
647    Some(c)
648}
649
650// trace:exempt reason=internal-detail
651pub fn load_global_rank_cache(compiler: &ContextCompiler) -> Option<GlobalRankCache> {
652    load_global_rank_cache_with_pipeline(compiler, "")
653}
654
655/// Persist the per-ModelEpoch global rank cache (best-effort: cache
656/// failures never fail the caller).
657// trace:v1 id=impl.scc.startup.rank-cache-store work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-global-rank-cached-per-model-epoch
658// trace:exempt reason=internal-detail
659pub fn store_global_rank_cache_with_pipeline(compiler: &ContextCompiler, cache: &GlobalRankCache, pipeline: &str) {
660    let epoch = compiler
661        .store
662        .cache_epoch()
663        .unwrap_or_else(|_| "no-epoch".into());
664    let policy = trust_policy_str(compiler.view.policy());
665    let salt = &compiler.settings.rank_salt;
666    let key = global_rank_key(&epoch, &policy, salt, pipeline);
667    if let Ok(json) = serde_json::to_string(cache) {
668        let _ = compiler.store.cache_put(&key, &json, &epoch);
669    }
670}
671
672// trace:exempt reason=internal-detail
673pub fn store_global_rank_cache(compiler: &ContextCompiler, cache: &GlobalRankCache) {
674    store_global_rank_cache_with_pipeline(compiler, cache, "")
675}
676
677/// The logical symbol entity id of a rendered entry id: overload-sensitive
678/// entry ids carry a `#overload{N}` suffix (`{symbol}#overload{N}`) that
679/// is stripped to recover the symbol id. Non-overload entry ids ARE the
680/// symbol entity id.
681// trace:exempt reason=internal-detail
682fn symbol_id_of(entry_id: &str) -> String {
683    entry_id
684        .rsplit_once("#overload")
685        .map(|(logical, _)| logical)
686        .unwrap_or(entry_id)
687        .to_string()
688}
689
690/// The spec's startup block format. Pure function of the context struct, so
691/// `build_startup(..).artifact.text == render_startup(&startup)` always.
692// trace:exempt reason=internal-detail
693pub fn render_startup(s: &StartupContext) -> String {
694    assemble_block(&s.atlas, &s.skeleton, &s.surface, &s.important, &s.coverage, &s.omissions, &s.artifact)
695}
696
697/// The startup body (all content sections, no artifact metadata comment) —
698/// the preimage of `content_hash`.
699// trace:exempt reason=internal-detail
700fn assemble_body(atlas: &str, skeleton: &str, surface: &str, important: &str, coverage: &[String], omissions: &[String]) -> String {
701    let mut out = String::new();
702    out.push_str("## HOW TO READ THIS PACK\n");
703    out.push_str("Machine-generated system context; work within it, do not re-derive.\n");
704    out.push_str("ATLAS=architecture, SKELETON=file layout, SURFACE=ranked APIs, SYMBOLS=top attention, COVERAGE=warnings, OMISSIONS=budget cuts.\n");
705    out.push_str("Authority: source/runtime > pack > checkpoint > hindsight > assumption. DOCUMENTATION labels are unverified. Verify: `scc verify`; stale: `scc index`.\n");
706    out.push_str("Next: `scc context task <goal>`, `scc impact <files>`, `scc drift`, `scc ci check`.\n");
707    out.push_str("\n## SYSTEM ATLAS\n");
708    out.push_str(atlas.trim_end());
709    out.push_str("\n\n## REPOSITORY SKELETON\n");
710    out.push_str(skeleton.trim_end());
711    out.push_str("\n\n## SYSTEM SURFACE MAP\n");
712    out.push_str(surface.trim_end());
713    out.push_str("\n\n");
714    out.push_str(important.trim_end());
715    out.push_str("\n\n## MODEL COVERAGE\n");
716    if coverage.is_empty() {
717        out.push_str("(no warnings)\n");
718    } else {
719        for c in coverage {
720            out.push_str(c);
721            out.push('\n');
722        }
723    }
724    out.push_str("\n## OMISSIONS\n");
725    for o in omissions {
726        out.push_str(o);
727        out.push('\n');
728    }
729    out
730}
731
732// trace:exempt reason=internal-detail
733fn assemble_block(
734    atlas: &str,
735    skeleton: &str,
736    surface: &str,
737    important: &str,
738    coverage: &[String],
739    omissions: &[String],
740    artifact: &ContextArtifact,
741) -> String {
742    let mut out = String::new();
743    out.push_str("# SCC SYSTEM CONTEXT\n");
744    out.push_str(&format!(
745        "<!-- artifact sha256:{} content_hash:{} epoch:{} renderer:{} -->\n\n",
746        artifact.sha256, artifact.content_hash, artifact.epoch, artifact.renderer_version
747    ));
748    out.push_str(&assemble_body(atlas, skeleton, surface, important, coverage, omissions));
749    out
750}
751
752/// Coverage warnings mirroring the compiler's pack warnings (deterministic):
753/// not-indexed, stale count, high/critical drift.
754// trace:exempt reason=internal-detail
755fn compiler_warnings(compiler: &ContextCompiler) -> Vec<String> {
756    let mut w = Vec::new();
757    if compiler.store.snapshot_status().ok().flatten().is_none() {
758        w.push("Repository is not indexed — run `scc index`.".into());
759    }
760    if !compiler.stale_paths.is_empty() {
761        w.push(format!(
762            "Model is stale: {} changed file(s) not yet re-indexed.",
763            compiler.stale_paths.len()
764        ));
765    }
766    if let Ok(findings) = compiler.store.drift_findings(true) {
767        for (_, kind, sev, msg, _) in findings {
768            if sev == "high" || sev == "critical" {
769                w.push(format!("Drift [{kind}]: {msg}"));
770            }
771        }
772    }
773    w.truncate(6);
774    w
775}
776
777// trace:exempt reason=internal-detail
778// trace:exempt reason=internal-detail
779pub fn trust_policy_str(p: &scc_graph::TrustPolicy) -> String {
780    format!(
781        "extracted={} resolved={} observed={} declared={} inferred={} floor={}",
782        p.allow_extracted,
783        p.allow_resolved,
784        p.allow_observed,
785        p.allow_declared,
786        p.allow_inferred,
787        p.min_inferred_confidence
788    )
789}
790
791/// The kind-scoped id sets shown by the startup artifact (for ledger
792/// recording): `(symbols, files, components, flows)`. The surface side
793/// records ONLY the render result's `rendered_ids` — budget-omitted
794/// candidates are never marked visible (audit fix: the ledger must
795/// describe what the agent actually saw). Consumes the ALREADY-PRODUCED
796/// render (`startup.surface_render`) — the surface is built exactly once
797/// per startup; this function never rebuilds it.
798// trace:exempt reason=internal-detail
799pub fn visible_ids_from_startup(
800    compiler: &ContextCompiler,
801    startup: &StartupContext,
802) -> (BTreeSet<String>, BTreeSet<String>, BTreeSet<String>, BTreeSet<String>) {
803    let mut symbols = BTreeSet::new();
804    let mut files = BTreeSet::new();
805    let mut components = BTreeSet::new();
806    let mut flows = BTreeSet::new();
807
808    // Cache-hit in the CLI flow (build_startup already ran system_atlas
809    // at this budget). The DELIVERED budget — not the requested one — so
810    // emergency-floor artifacts record only delivered atlas ids (§53).
811    let atlas_pack = compiler.system_atlas(Some(startup.atlas_budget_used));
812
813    // Surface: rendered entries ONLY — the SAME render the artifact
814    // printed, so the ledger exactly matches the artifact text. Entry
815    // metadata (symbol id, file path, component) is derived from the
816    // trusted view per rendered id (cheap: rendered ids only, never the
817    // candidate pool) — no compile_surface_map walk.
818    let view = &compiler.view;
819    let rendered_symbols: BTreeSet<String> = startup
820        .surface_render
821        .rendered_ids
822        .iter()
823        .map(|id| symbol_id_of(id))
824        .collect();
825    // Component attribution mirrors compile_surface_map's containment
826    // walk (component CONTAINS file CONTAINS symbol; first component in
827    // name order wins) but only for the rendered symbol ids.
828    let mut comp_of: BTreeMap<String, String> = BTreeMap::new();
829    for c in view.components() {
830        for r in sorted_rels(view.out_pred(&c.id, scc_core::predicates::CONTAINS)) {
831            for sr in sorted_rels(view.out_pred(&r.object, scc_core::predicates::CONTAINS)) {
832                if rendered_symbols.contains(&sr.object) {
833                    comp_of
834                        .entry(sr.object.clone())
835                        .or_insert_with(|| c.name.clone());
836                }
837            }
838        }
839    }
840    for id in &startup.surface_render.rendered_ids {
841        let symbol_id = symbol_id_of(id);
842        symbols.insert(symbol_id.clone());
843        // The entry id is the entity id for non-overload entries; overload
844        // entries (n >= 1) resolve through the logical symbol id.
845        let ent = view.entity(id).or_else(|| view.entity(&symbol_id));
846        if let Some(e) = ent {
847            if let Some(f) = e.attributes.get("file").and_then(|v| v.as_str()) {
848                files.insert(f.to_string());
849            }
850        }
851        if let Some(c) = comp_of.get(&symbol_id) {
852            components.insert(c.clone());
853        }
854    }
855    // Classify the atlas pack's entity ids by kind (deterministic).
856    for id in &atlas_pack.entity_ids {
857        if let Some(e) = compiler.view.entity(id) {
858            match e.kind.as_str() {
859                kinds::SYMBOL => {
860                    symbols.insert(id.clone());
861                }
862                kinds::FILE => {
863                    files.insert(e.name.clone());
864                }
865                kinds::COMPONENT => {
866                    components.insert(id.clone());
867                }
868                kinds::FLOW => {
869                    flows.insert(id.clone());
870                }
871                _ => {}
872            }
873        }
874    }
875    (symbols, files, components, flows)
876}
877
878/// Deterministic relationship sort (id, subject, object) — mirrors the
879/// surface compiler's traversal order so attribution walks are stable.
880// trace:exempt reason=internal-detail
881fn sorted_rels(rels: Vec<&scc_core::Relationship>) -> Vec<&scc_core::Relationship> {
882    let mut v = rels;
883    v.sort_by(|a, b| {
884        a.id.cmp(&b.id)
885            .then_with(|| a.subject.cmp(&b.subject))
886            .then_with(|| a.object.cmp(&b.object))
887    });
888    v
889}
890
891/// `task_delta` plus the ids it rendered (for ledger recording). Routed
892/// through the one authoritative [`build_surface`] service in Task mode;
893/// never re-dumps the Atlas — the custom task/PPR selection implementation
894/// was deleted, [`build_surface`] is the only ranking pipeline. `semantic`
895/// is the caller-resolved scorer — transport parity: every transport with
896/// inference enabled passes the SAME scorer it passed to the task pack
897/// (interface choice must not change ranking quality). `None` redistributes
898/// the 10% share explicitly.
899// trace:v1 id=impl.scc.startup.task-delta-with-ids work=WORK-SCC-014 satisfies=REQ-SCC-IR
900pub fn task_delta_with_ids(
901    compiler: &ContextCompiler,
902    goal: &str,
903    visible: &ContextLedger,
904    budget_tokens: usize,
905    semantic: Option<&dyn crate::rank::SemanticScorer>,
906) -> (String, Vec<String>) {
907    let render = build_surface(
908        compiler,
909        SurfaceRequest {
910            mode: SurfaceMode::Task {
911                goal,
912                visible: Some(visible),
913            },
914            budget: budget_tokens,
915            explain: false,
916            policy: SurfacePolicy::defaults(budget_tokens),
917            semantic,
918        },
919    );
920    // TASK-CRITICAL SYMBOLS (audit item 3): top-8 task-ranked entries
921    // with badges + exact counts, ahead of the delta detail.
922    let critical = crate::surface::important_symbols(
923        compiler,
924        SurfaceMode::Task { goal, visible: Some(visible) },
925        8,
926    );
927    let mut out = String::new();
928    out.push_str("# SCC TASK DELTA\n");
929    out.push_str(&format!("TASK-FOCUS: {goal}\n"));
930    out.push_str(crate::surface::render_important(&critical, true).as_str());
931    out.push_str("Relevant APIs not already visible:\n");
932    let body = render
933        .text
934        .strip_prefix("SCC SYSTEM SURFACE MAP\n\n")
935        .unwrap_or(&render.text);
936    out.push_str(body.trim_end());
937    out.push('\n');
938    (out, render.rendered_ids)
939}
940
941/// Task-personalized surface map (full map, no novelty filter — this is a
942/// map, not a delta): entries re-ranked by task PPR + importance via the
943/// one authoritative [`build_surface`] service in Task mode.
944// trace:exempt reason=internal-detail
945pub fn task_surface(
946    compiler: &ContextCompiler,
947    goal: &str,
948    budget_tokens: usize,
949    explain: bool,
950    semantic: Option<&dyn crate::rank::SemanticScorer>,
951) -> String {
952    task_surface_with_ids(compiler, goal, budget_tokens, explain, semantic).0
953}
954
955/// `task_surface` plus the rendered entry ids (for ledger recording).
956/// `semantic` is the caller-resolved scorer — transport parity, same rule
957/// as [`task_delta_with_ids`].
958// trace:v1 id=impl.scc.startup.task-surface-with-ids work=WORK-SCC-014 satisfies=REQ-SCC-IR
959pub fn task_surface_with_ids(
960    compiler: &ContextCompiler,
961    goal: &str,
962    budget_tokens: usize,
963    explain: bool,
964    semantic: Option<&dyn crate::rank::SemanticScorer>,
965) -> (String, Vec<String>) {
966    let render = build_surface(
967        compiler,
968        SurfaceRequest {
969            mode: SurfaceMode::Task {
970                goal,
971                visible: None,
972            },
973            budget: budget_tokens,
974            explain,
975            policy: SurfacePolicy::defaults(budget_tokens),
976            semantic,
977        },
978    );
979    let mut out = String::new();
980    out.push_str(&format!("# SYSTEM SURFACE MAP (task-personalized: {goal})\n"));
981    let body = render
982        .text
983        .strip_prefix("SCC SYSTEM SURFACE MAP\n\n")
984        .unwrap_or(&render.text);
985    out.push_str(body.trim_end());
986    out.push('\n');
987    (out, render.rendered_ids)
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993
994// trace:exempt reason=internal-detail
995    fn fixture_compiler() -> (tempfile::TempDir, crate::ContextCompiler<'static>) {
996        let dir = tempfile::TempDir::new().unwrap();
997        let root = dir.path().join("repo");
998        std::fs::create_dir_all(&root).unwrap();
999        let store = Box::leak(Box::new(
1000            scc_store::Store::open(&dir.path().join("scc.db"), &root).unwrap(),
1001        ));
1002        let graph = Box::leak(Box::new(scc_graph::RealityGraph::load(store).unwrap()));
1003        let settings = crate::ContextSettings::default();
1004        let comp = crate::ContextCompiler::new(store, graph, settings, Vec::new());
1005        (dir, comp)
1006    }
1007
1008    #[test]
1009// trace:exempt reason=internal-detail
1010    fn render_startup_emits_spec_headers() {
1011        let sc = StartupContext {
1012            atlas: "ATLAS-BODY".into(),
1013            atlas_budget_used: 6000,
1014            skeleton: "SKELETON-BODY".into(),
1015            surface: "SURFACE-BODY".into(),
1016            important: "IMPORTANT-BODY".into(),
1017            surface_render: scc_core::SurfaceRenderResult {
1018                text: "SURFACE-BODY".into(),
1019                rendered_ids: vec![],
1020                rendered_entries: vec![],
1021                omitted_ids: vec![],
1022                omissions: vec![],
1023                token_count: 0,
1024                critical_drops: vec![],
1025            },
1026            coverage: vec!["stale: a.py".into()],
1027            omissions: vec!["none".into()],
1028            artifact: ContextArtifact {
1029                kind: "startup".into(),
1030                epoch: "epoch:test".into(),
1031                renderer_version: "test".into(),
1032                trust_policy: "floor=0.85".into(),
1033                budget: ContextBudget::default(),
1034                sha256: "abc".into(),
1035                content_hash: "def".into(),
1036                text: String::new(),
1037            },
1038        };
1039        let out = render_startup(&sc);
1040        assert!(out.contains("# SCC SYSTEM CONTEXT"));
1041        assert!(out.contains("## SYSTEM ATLAS"));
1042        assert!(out.contains("## REPOSITORY SKELETON"));
1043        assert!(out.contains("## SYSTEM SURFACE MAP"));
1044        assert!(out.contains("ATLAS-BODY"));
1045        assert!(out.contains("SURFACE-BODY"));
1046        assert!(out.contains("SKELETON-BODY"));
1047        assert!(out.contains("## SYSTEM-CRITICAL SYMBOLS") || out.contains("IMPORTANT-BODY"));
1048        let (ia, ik, is) = (
1049            out.find("## SYSTEM ATLAS").unwrap(),
1050            out.find("## REPOSITORY SKELETON").unwrap(),
1051            out.find("## SYSTEM SURFACE MAP").unwrap(),
1052        );
1053        assert!(ia < ik && ik < is, "skeleton grounds before architecture");
1054        assert!(out.contains("stale: a.py"));
1055    }
1056
1057    #[test]
1058// trace:exempt reason=internal-detail
1059    fn artifact_text_equals_rendered_block() {
1060        let (_dir, comp) = fixture_compiler();
1061        let budget = ContextBudget::default();
1062        let sc = build_startup(&comp, &budget, "test-renderer");
1063        assert_eq!(sc.artifact.text, render_startup(&sc));
1064        assert_eq!(sc.artifact.sha256.len(), 64);
1065        assert_eq!(sc.artifact.content_hash.len(), 64);
1066        assert_ne!(sc.artifact.content_hash, sc.artifact.sha256);
1067        assert!(render_startup(&sc).contains("content_hash:"));
1068        assert_eq!(sc.artifact.epoch, comp.store.cache_epoch().unwrap_or_default());
1069    }
1070
1071    #[test]
1072// trace:exempt reason=internal-detail
1073    fn global_rank_cache_roundtrips_through_the_store() {
1074        let (_dir, comp) = fixture_compiler();
1075        // miss on a cold store
1076        assert!(load_global_rank_cache(&comp).is_none());
1077        let cache = GlobalRankCache {
1078            epoch: comp.store.cache_epoch().unwrap_or_else(|_| "no-epoch".into()),
1079            policy: trust_policy_str(comp.view.policy()),
1080            salt: comp.settings.rank_salt.clone(),
1081            global_vector: vec![0.1, 0.2, 0.3],
1082            node_symbol_map: BTreeMap::from([("repo://r/symbol/a.py/serve".into(), 0.42)]),
1083            candidates_epoch: comp.store.cache_epoch().unwrap_or_default(),
1084            candidate_ids: vec!["repo://r/symbol/a.py/serve".into()],
1085            hits: 1,
1086        };
1087        store_global_rank_cache(&comp, &cache);
1088        let loaded = load_global_rank_cache(&comp).expect("cache entry present after store");
1089        assert_eq!(loaded, cache);
1090        assert_eq!(loaded.hits, 1);
1091        assert_eq!(
1092            loaded.node_symbol_map.get("repo://r/symbol/a.py/serve"),
1093            Some(&0.42)
1094        );
1095        assert_eq!(loaded.candidates_epoch, loaded.epoch);
1096    }
1097
1098    #[test]
1099// trace:exempt reason=internal-detail
1100    fn visible_ids_consume_the_same_render_the_artifact_printed() {
1101        // The ledger-visible surface ids MUST be exactly the render's
1102        // rendered_ids (never a rebuild, never the candidate pool): every
1103        // rendered entry's logical symbol is visible. The inverse
1104        // direction (omitted candidates never visible) is asserted in the
1105        // indexed CLI fixture (surface_startup.rs) where the atlas symbol
1106        // set is controlled.
1107        let (_dir, comp) = fixture_compiler();
1108        let budget = ContextBudget::default();
1109        let sc = build_startup(&comp, &budget, "test-renderer");
1110        assert!(sc.artifact.text.contains(&sc.surface));
1111        let (syms, _files, _comps, _flows) = visible_ids_from_startup(&comp, &sc);
1112        for id in &sc.surface_render.rendered_ids {
1113            let symbol_id = symbol_id_of(id);
1114            assert!(
1115                syms.contains(&symbol_id),
1116                "rendered entry {id} must be marked visible (symbol {symbol_id})"
1117            );
1118        }
1119    }
1120}