Skip to main content

scc_context/
surface.rs

1//! System Surface Map compiler (Wave 14, Level 1): the actual callable
2//! code surface built from the TrustedGraphView — an Aider RepoMap
3//! equivalent on System IR.
4//!
5//! Every SYMBOL entity in the trusted view becomes a [`SurfaceEntry`]
6//! carrying exact signatures (source + canonical + semantic), visibility,
7//! modifiers/annotations, component attribution, and the architectural
8//! meaning attached to the symbol (flows, contracts, state ownership,
9//! invocation surfaces, callers/callees). Deterministic and no-panic:
10//! unparseable signatures degrade to name-only [`SemanticSignature`]s.
11
12use crate::context_ledger::novelty_penalty;
13use crate::rank::{term_match, terms};
14use crate::ContextCompiler;
15use scc_core::{
16    estimate_tokens, kinds, predicates, ContextItem, ContextLedger, Provenance,
17    SemanticParameter, SemanticSignature, SourceRange, SurfaceEntry, SurfaceKind,
18    SurfaceOmission, SurfaceRank, SystemSurfaceMap, TaskSeed, Visibility,
19};
20use std::collections::{BTreeMap, BTreeSet, HashMap};
21
22/// Symbol-kind strings the indexer emits (write.rs core_symbol_kind),
23/// plus "trait" for tolerance.
24const SYMBOL_KINDS: [&str; 9] = [
25    "function", "method", "class", "interface", "trait", "type", "const", "enum", "module",
26];
27
28/// When no semantic scorer is configured (`SurfaceRequest.semantic` is
29/// `None`), the 10% semantic share of `final_importance` must not silently
30/// vanish (the reviewer's phantom-weight complaint). It is reallocated
31/// proportionally across the other BLEND weights: `final_importance` is
32/// computed with `semantic = 0.0` and the blend renormalized by
33/// `1 / (1 - SEMANTIC_WEIGHT)`, so a full-strength entry still totals 1.0
34/// exactly as the advertised blend promises (the additive novelty term is
35/// untouched). Deterministic and no-panic.
36const REDISTRIBUTION_SCALE: f64 = 1.0 / (1.0 - crate::pagerank::SEMANTIC_WEIGHT);
37
38/// The deepest structural compression level for the hard-max invariant:
39/// 0 = full entry, 1 = first signature line only, 2 = canonical
40/// abbreviated signature, 3 = symbol identity only (kind + name). Level 3
41/// always fits any realistic hard max (a kind + name is a few tokens), so
42/// the progressive-compression loop terminates; entries are never dropped.
43const MAX_COMPRESSION: u8 = 3;
44
45// ---------------------------------------------------------------------------
46// Top-level API
47// ---------------------------------------------------------------------------
48
49/// Build the System Surface Map (Level 1) from the trusted view.
50// trace:v1 id=impl.scc.surface work=WORK-SCC-014 satisfies=REQ-SCC-IR
51pub fn compile_surface_map(compiler: &ContextCompiler) -> SystemSurfaceMap {
52    let view = &compiler.view;
53
54    // ---- attribution tables ----
55    let mut symbol_comp_id: BTreeMap<String, String> = BTreeMap::new();
56    let mut comp_names: BTreeMap<String, String> = BTreeMap::new();
57    for c in view.components() {
58        comp_names.insert(c.id.clone(), c.name.clone());
59        for r in sorted_rels(view.out_pred(&c.id, predicates::CONTAINS)) {
60            for sr in sorted_rels(view.out_pred(&r.object, predicates::CONTAINS)) {
61                symbol_comp_id.insert(sr.object.clone(), c.id.clone());
62            }
63        }
64    }
65    let mut subsys_of_comp: BTreeMap<String, String> = BTreeMap::new();
66    for kind in [kinds::SUBSYSTEM, kinds::SERVICE] {
67        for e in view.entities_of_kind(kind) {
68            for r in sorted_rels(view.out_pred(&e.id, predicates::CONTAINS)) {
69                subsys_of_comp
70                    .entry(r.object.clone())
71                    .or_insert_with(|| e.name.clone());
72            }
73        }
74    }
75
76    // invocation surfaces
77    let surfaces = scc_graph::flows::invocation_surfaces(view.graph);
78    let mut surface_by_symbol: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
79    for s in surfaces {
80        surface_by_symbol
81            .entry(s.symbol.clone())
82            .or_default()
83            .push((s.kind.as_str().to_string(), s.trigger.clone()));
84    }
85
86    // Hoisted per-map tables (profiler receipt 2026-09-11: `view.flows()`
87    // clones all flows per entry and the ROUTE scan re-walks all entities
88    // per entry — ~15% of the render on system_ir). Computed once here;
89    // entry output is byte-identical.
90    let all_flows: Vec<scc_core::Flow> = view.flows();
91    // Actor index (profiler receipt 2026-09-21: `step_matches` ran a
92    // substring search per (symbol x flow x step) — ~10M `contains` per
93    // map on system_ir, ~2/3 of compile_surface_map). Exact actor ->
94    // flow names plus the distinct actor set for the file fallback;
95    // entry output is byte-identical.
96    let mut flows_by_actor: HashMap<&str, Vec<&str>> = HashMap::new();
97    let mut all_actors: Vec<&str> = Vec::new();
98    for f in &all_flows {
99        for s in &f.steps {
100            if let Some(v) = flows_by_actor.get_mut(s.actor.as_str()) {
101                if !v.contains(&f.name.as_str()) {
102                    v.push(f.name.as_str());
103                }
104            } else {
105                all_actors.push(s.actor.as_str());
106                flows_by_actor.insert(s.actor.as_str(), vec![f.name.as_str()]);
107            }
108        }
109    }
110    // Per-file fallback cache: actors containing the entry's file path
111    // (the fuzzy arm of `step_matches`). One pass over the distinct actor
112    // set per distinct file — hundreds of files, not thousands of symbols.
113    let mut flows_by_actor_file: HashMap<String, Vec<String>> = HashMap::new();
114    let mut routes_by_handler: HashMap<&str, Vec<(String, String)>> = HashMap::new();
115    for r in view.entities_of_kind(kinds::ROUTE) {
116        if let (Some(h), Some(m), Some(path)) = (
117            r.attributes.get("handler").and_then(|v| v.as_str()),
118            r.attributes.get("method").and_then(|v| v.as_str()),
119            r.attributes.get("path").and_then(|v| v.as_str()),
120        ) {
121            if !path.is_empty() {
122                routes_by_handler
123                    .entry(h)
124                    .or_default()
125                    .push((m.to_string(), path.to_string()));
126            }
127        }
128    }
129
130    let mut entries: Vec<SurfaceEntry> = Vec::new();
131    for e in view.entities_of_kind(kinds::SYMBOL) {
132        let Some(kind_str) = e.attributes.get("kind").and_then(|v| v.as_str()) else {
133            continue;
134        };
135        if !SYMBOL_KINDS.contains(&kind_str) {
136            continue;
137        }
138        entries.push(build_entry(
139            compiler,
140            e,
141            kind_str,
142            &symbol_comp_id,
143            &comp_names,
144            &subsys_of_comp,
145            &surface_by_symbol,
146            &flows_by_actor,
147            &all_actors,
148            &mut flows_by_actor_file,
149            &routes_by_handler,
150        ));
151    }
152    entries.sort_by(|a, b| {
153        a.qualified_name
154            .cmp(&b.qualified_name)
155            .then_with(|| a.id.cmp(&b.id))
156    });
157
158    let store = compiler.store;
159    let mut map = SystemSurfaceMap {
160        repository: store.repository().name,
161        revision: compiler.revision(),
162        epoch: store
163            .cache_epoch()
164            .unwrap_or_else(|_| "no-epoch".into()),
165        entries,
166        token_count: 0,
167        omitted: Vec::new(),
168    };
169    let full = render_surface_map(&map, None);
170    map.token_count = estimate_tokens(&full);
171    map
172}
173
174/// Deterministic, budget-capped render of a [`SystemSurfaceMap`].
175// trace:exempt reason=internal-detail
176pub fn render_surface_map(map: &SystemSurfaceMap, budget_tokens: Option<usize>) -> String {
177    let budget_chars = budget_tokens.map(|t| t.saturating_mul(4));
178    let (body, omitted) = render_entry_groups(&map.entries, budget_chars);
179    let mut out = String::from("SCC SYSTEM SURFACE MAP\n\n");
180    out.push_str(&body);
181    if !omitted.is_empty() {
182        out.push('\n');
183        out.push_str("OMITTED (token budget exceeded):\n");
184        for (kind, count) in &omitted {
185            out.push_str(&format!("  {count} lower-ranked {kind} definitions\n"));
186        }
187    }
188    out
189}
190
191/// Render a set of entries grouped by (component, subsystem, path) — the
192/// shared core of [`render_surface_map`] and the budget-selected subset
193/// renderer. Returns the body text plus per-kind counts of entries cut by
194/// the char budget (empty when no budget or nothing was cut).
195// trace:exempt reason=internal-detail
196fn render_entry_groups(
197    entries: &[SurfaceEntry],
198    budget_chars: Option<usize>,
199) -> (String, BTreeMap<String, usize>) {
200    group_and_render(entries, budget_chars, 0, false)
201}
202
203/// [`render_entry_groups`] with the pipeline's render options: the
204/// structural compression level (0 full .. 3 identity-only) and per-entry
205/// score decomposition (explain mode). `budget_chars` is the char ceiling
206/// for the plain map render; the selected-subset render passes `None` (the
207/// selection already enforced the token budget).
208// trace:exempt reason=internal-detail
209fn group_and_render(
210    entries: &[SurfaceEntry],
211    budget_chars: Option<usize>,
212    level: u8,
213    explain: bool,
214) -> (String, BTreeMap<String, usize>) {
215    let mut groups: BTreeMap<(String, String, String), Vec<&SurfaceEntry>> = BTreeMap::new();
216    for e in entries {
217        let comp = e.component.clone().unwrap_or_else(|| "(unattributed)".to_string());
218        let sub = e.subsystem.clone().unwrap_or_default();
219        groups.entry((comp, sub, e.path.clone())).or_default().push(e);
220    }
221
222    let mut out = String::new();
223    let mut total_chars = 0usize;
224    let mut omitted: BTreeMap<String, usize> = BTreeMap::new();
225    let mut cut = false;
226
227    for ((comp, sub, path), mut es) in groups {
228        es.sort_by(|a, b| entry_order(a, b));
229        let header = group_header(&comp, &sub, &path);
230        let mut blocks: Vec<String> = Vec::new();
231        let mut block_chars: usize = 0;
232        for e in es {
233            if cut {
234                *omitted.entry(e.kind.as_str().to_string()).or_insert(0) += 1;
235                continue;
236            }
237            let rank = if explain { Some(&e.rank) } else { None };
238            let block = render_entry_opt(e, level, rank);
239            let bc = block.chars().count();
240            if fits(total_chars + header.chars().count() + block_chars + bc, budget_chars) {
241                blocks.push(block);
242                block_chars += bc;
243            } else {
244                cut = true;
245                *omitted.entry(e.kind.as_str().to_string()).or_insert(0) += 1;
246            }
247        }
248        if !blocks.is_empty() {
249            out.push_str(&header);
250            for b in blocks {
251                out.push_str(&b);
252            }
253            total_chars += header.chars().count() + block_chars;
254        }
255    }
256    (out, omitted)
257}
258
259// ---------------------------------------------------------------------------
260// Production selection pipeline (Wave 14F)
261// ---------------------------------------------------------------------------
262
263/// The spec's global surface budget quotas (keys consumed by
264/// `selector::enforce_quotas`): 30% public/entrypoint, 25% core impl, 15%
265/// types/interfaces, 10% state owners, 10% contract APIs, 10% flow-critical.
266// trace:exempt reason=internal-detail
267pub fn surface_quotas() -> Vec<(String, f64)> {
268    vec![
269        ("public".to_string(), 0.30),
270        ("core".to_string(), 0.25),
271        ("types".to_string(), 0.15),
272        ("state".to_string(), 0.10),
273        ("contract".to_string(), 0.10),
274        ("flow".to_string(), 0.10),
275    ]
276}
277
278/// The quota bucket of an entry: public/entrypoint surfaces first, then
279/// architectural meaning (state owners, contract APIs, flow participants),
280/// then types, then core implementation.
281// trace:exempt reason=internal-detail
282fn quota_kind(e: &SurfaceEntry) -> &'static str {
283    if e.exported || e.visibility == Visibility::Public || !e.invocation_surfaces.is_empty() {
284        "public"
285    } else if !e.state_authorities.is_empty() {
286        "state"
287    } else if !e.contracts.is_empty() {
288        "contract"
289    } else if !e.flows.is_empty() {
290        "flow"
291    } else if matches!(
292        e.kind,
293        SurfaceKind::Class
294            | SurfaceKind::Interface
295            | SurfaceKind::Trait
296            | SurfaceKind::Enum
297            | SurfaceKind::Type
298    ) {
299        "types"
300    } else {
301        "core"
302    }
303}
304
305/// The names/statements of every invariant in the view — shared by the
306/// required-coverage check (contracts naming an invariant are critical)
307/// and by the explain reasons (`invariant-enforcing`).
308// trace:exempt reason=internal-detail
309fn invariant_names(view: &scc_graph::TrustedGraphView) -> Vec<String> {
310    let mut names: Vec<String> = Vec::new();
311    for inv in view.invariants() {
312        let name = inv.id.rsplit('/').next().unwrap_or(&inv.id).to_string();
313        names.push(name);
314        names.push(inv.statement.clone());
315    }
316    names
317}
318
319/// Entries the pipeline MUST never omit: critical invocation surfaces
320/// (non-empty `invocation_surfaces`), invariant-enforcing APIs (contracts
321/// containing an invariant name), primary flow entrypoints (entrypoint of a
322/// triggered flow), and state owners of critical (invariant-scoped) state.
323/// Entries the pipeline MUST never omit (engine ranking seam: same set
324/// `build_surface` partitions on, so `ranking.symbols` criticality matches).
325// trace:v1 id=impl.scc.surface.required-ids work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
326pub fn required_ids(map: &SystemSurfaceMap, compiler: &ContextCompiler) -> BTreeSet<String> {
327    let view = &compiler.view;
328    let mut required: BTreeSet<String> = BTreeSet::new();
329
330    // Invariant names + critical state ids (state entities named in an
331    // invariant's scope are guarded by the invariant → critical).
332    let inv_names = invariant_names(view);
333    let mut critical_state: BTreeSet<String> = BTreeSet::new();
334    for inv in view.invariants() {
335        for scope_id in &inv.scope {
336            if let Some(e) = view.entity(scope_id) {
337                if e.kind == kinds::STATE {
338                    critical_state.insert(scope_id.clone());
339                }
340            }
341        }
342    }
343    let state_name_to_id: BTreeMap<String, String> = view
344        .entities_of_kind(kinds::STATE)
345        .into_iter()
346        .map(|e| (e.name.clone(), e.id.clone()))
347        .collect();
348
349    // Primary flow entrypoints: entrypoint symbol of a triggered flow
350    // (externally initiated flows are the user-facing entry flows).
351    let mut primary_eps: BTreeSet<String> = BTreeSet::new();
352    for f in view.flows() {
353        let triggered = f.trigger.as_deref().map(|t| !t.is_empty()).unwrap_or(false);
354        if triggered {
355            if let Some(ep) = f.attributes.get("entrypoint").and_then(|v| v.as_str()) {
356                primary_eps.insert(ep.to_string());
357            }
358        }
359    }
360
361    for e in &map.entries {
362        // A plain public-API export is NOT a critical coverage item — the
363        // ranker decides whether it earns context. Only concrete
364        // invocation surfaces (http/cli/queue/process/schedule/plugin/
365        // lifecycle/event...) are critical coverage; otherwise every
366        // exported symbol of a large repo becomes 'required' and the
367        // budget never bites (the reviewer's quota/coverage stress case).
368        let mut req = e
369            .invocation_surfaces
370            .iter()
371            .any(|s| !s.starts_with("public_api:"));
372        if !req && primary_eps.contains(&e.symbol_id) {
373            req = true;
374        }
375        if !req {
376            req = e.contracts.iter().any(|c| {
377                inv_names
378                    .iter()
379                    .any(|n| !n.is_empty() && c.to_lowercase().contains(&n.to_lowercase()))
380            });
381        }
382        if !req {
383            req = e.state_authorities.iter().any(|auth| {
384                state_name_to_id
385                    .get(auth)
386                    .map(|id| critical_state.contains(id))
387                    .unwrap_or(false)
388            });
389        }
390        if req {
391            required.insert(e.id.clone());
392        }
393    }
394    required
395}
396
397/// The explain reasons for one entry, populated from the evidence the
398/// entry carries (seeds, flows, visibility, state ownership, invariant
399/// contracts, invocation surfaces, staleness) — the same signals that
400/// drive the required-coverage and criticality decisions. Deterministic
401/// fixed order.
402// trace:exempt reason=internal-detail
403fn entry_reasons(
404    e: &SurfaceEntry,
405    goal: Option<&str>,
406    seed_ids: &BTreeSet<String>,
407    inv_names: &[String],
408    changed: bool,
409) -> Vec<String> {
410    let mut reasons: Vec<String> = Vec::new();
411    if seed_ids.contains(&e.symbol_id) {
412        let label = e
413            .component
414            .as_deref()
415            .filter(|c| !c.is_empty())
416            .unwrap_or(goal.unwrap_or("task"));
417        reasons.push(format!("task seed: {label}"));
418    }
419    if !e.flows.is_empty() {
420        reasons.push("primary flow participant".into());
421    }
422    if e.exported || e.visibility == Visibility::Public {
423        reasons.push("public component surface".into());
424    }
425    for s in &e.state_authorities {
426        reasons.push(format!("owns {s}"));
427    }
428    if e.contracts.iter().any(|c| {
429        inv_names
430            .iter()
431            .any(|n| !n.is_empty() && c.to_lowercase().contains(&n.to_lowercase()))
432    }) {
433        reasons.push("invariant-enforcing".into());
434    }
435    if !e.invocation_surfaces.is_empty() {
436        reasons.push("concrete invocation surface".into());
437    }
438    if changed {
439        reasons.push("change risk: modified path".into());
440    }
441    reasons
442}
443
444/// Render the selected subset. No budget cut here — the selection already
445/// enforced the budget, and every selected entry must render so
446/// `rendered_ids` matches the text exactly. `level` is the structural
447/// compression level (0 full .. 3 identity-only, hard-max overflow never
448/// drops entries); `explain` renders each entry's full score
449/// decomposition from its populated [`SurfaceRank`].
450// trace:exempt reason=internal-detail
451fn render_selected(entries: &[SurfaceEntry], level: u8, explain: bool) -> String {
452    let (body, _) = group_and_render(entries, None, level, explain);
453    let mut out = String::from("SCC SYSTEM SURFACE MAP\n\n");
454    if explain {
455        out.push_str("selection scores shown per entry\n\n");
456    }
457    out.push_str(&body);
458    out
459}
460
461
462/// §41 important-file evidence: a symbol whose file is a package/workspace
463/// manifest, Docker/Compose, CI entrypoint, framework bootstrap, or main
464/// binary is architecturally load-bearing — importance EVIDENCE only, never
465/// raw inclusion (the file's text is not injected). Returns 1.0 for an
466/// important file, 0.0 otherwise. Deterministic, path-based.
467// trace:v1 id=impl.scc.surface.file-importance work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-SCC-IR
468pub(crate) fn file_importance(path: &str) -> f64 {
469    let base = path.rsplit('/').next().unwrap_or(path);
470    let important = [
471        // package / workspace manifests
472        "package.json", "pnpm-workspace.yaml", "yarn.lock", "Cargo.toml",
473        "Cargo.lock", "go.mod", "pyproject.toml", "setup.py", "setup.cfg",
474        "requirements.txt", "pom.xml", "build.gradle", "build.gradle.kts",
475        "settings.gradle", "settings.gradle.kts", "gradlew", "Makefile",
476        "CMakeLists.txt", "mix.exs", "Gemfile", "composer.json",
477        // Docker / orchestration
478        "Dockerfile", "docker-compose.yml", "docker-compose.yaml",
479        "compose.yml", "compose.yaml", ".dockerignore",
480        // CI entrypoints
481        ".github/workflows/ci.yml", ".github/workflows/main.yml",
482        ".gitlab-ci.yml", "Jenkinsfile", "azure-pipelines.yml",
483        ".circleci/config.yml", "buildkite.yml",
484        // framework bootstrap / main binaries
485        "main.py", "main.go", "main.ts", "index.ts", "index.js",
486        "app.py", "server.py", "server.ts", "server.js", "cli.py",
487        "cli.ts", "cli.go", "src/main.rs", "bin/main.rs", "app.js",
488        "app.ts",
489    ];
490    if important.contains(&base) || important.contains(&path) {
491        return 1.0;
492    }
493    // CI workflow dirs: any file under .github/workflows/ is a CI entrypoint
494    if path.starts_with(".github/workflows/") {
495        return 1.0;
496    }
497    0.0
498}
499
500/// Lexical relevance of an entry to the goal terms: name hits count double,
501/// signature hits count single (shared with the task-delta pipeline).
502// trace:exempt reason=internal-detail
503/// Lexical relevance of an entry to the goal terms (engine ranking seam).
504// trace:v1 id=impl.scc.surface.entry-lexical work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
505pub fn entry_lexical(e: &SurfaceEntry, goal_terms: &BTreeSet<String>) -> f64 {
506    if goal_terms.is_empty() {
507        return 0.0;
508    }
509    let name_terms = terms(&e.qualified_name);
510    let sig_terms = terms(&e.source_signature);
511    let name_hits = goal_terms
512        .iter()
513        .filter(|g| name_terms.iter().any(|n| term_match(g, n)))
514        .count();
515    let sig_hits = goal_terms
516        .iter()
517        .filter(|g| sig_terms.iter().any(|n| term_match(g, n)))
518        .count();
519    (name_hits * 2 + sig_hits) as f64
520}
521
522/// The shared pipeline tail (MMR → token-aware quotas → soft/hard budget
523/// selection → render) over ranked candidates. Required entries bypass
524/// MMR/quotas (never dropped within the hard max — `policy.coverage`) but
525/// still pay their token cost in the final budget selection; when
526/// required entries alone exceed `policy.hard_max` the render is
527/// structurally compressed (annotations/modifiers/doc lines dropped,
528/// entries never dropped). Returns the render result with rendered/
529/// omitted ids and per-kind omission summaries.
530// trace:exempt reason=internal-detail
531fn finish_selection(
532    map: &SystemSurfaceMap,
533    ranked: Vec<(String, f64)>,
534    required: &BTreeSet<String>,
535    budget: usize,
536    policy: &SurfacePolicy,
537    stages: &SurfacePipelineStages,
538    explain: bool,
539) -> scc_core::SurfaceRenderResult {
540    let entry_of: BTreeMap<String, &SurfaceEntry> =
541        map.entries.iter().map(|e| (e.id.clone(), e)).collect();
542
543    // Partition: required entries survive MMR/quotas unconditionally when
544    // coverage is on; with coverage off they are ordinary candidates.
545    let mut required_items: Vec<(String, f64)> = Vec::new();
546    let mut pool: Vec<(String, f64)> = Vec::new();
547    for (id, imp) in ranked {
548        if policy.coverage && required.contains(&id) {
549            required_items.push((id, imp));
550        } else {
551            pool.push((id, imp));
552        }
553    }
554
555    // Compression decision: required entries alone exceeding the hard max
556    // render structurally compressed (never dropped). Once triggered, the
557    // whole render uses the compressed form so token accounting matches
558    // the delivered text.
559    let required_tokens_full: usize = required_items
560        .iter()
561        .filter_map(|(id, _)| entry_of.get(id))
562        .map(|e| estimate_tokens(&render_entry(e)))
563        .sum();
564    let compress = policy.coverage && required_tokens_full > policy.hard_max;
565
566    // Even compressed, an enormous required set must not blow the hard max
567    // (the reviewer's hard-max semantics: critical facts may exceed the
568    // TARGET but never the hard maximum). When the required set alone
569    // exceeds hard_max, keep the highest-importance required entries that
570    // fit in hard_max — never drop them silently, the omissions block
571    // reports the rest. The single highest-importance required entry is
572    // always kept (its signature compresses to fit).
573    let mut pre_cap_critical_drops: Vec<String> = Vec::new();
574    if policy.coverage {
575        let cost_of_c = |id: &str| -> usize {
576            entry_of
577                .get(id)
578                .map(|e| estimate_tokens(&render_entry_compressed(e)))
579                .unwrap_or(1)
580        };
581        let mut spent_c: usize = 0;
582        let mut capped: Vec<(String, f64)> = Vec::new();
583        for (id, imp) in &required_items {
584            let c = cost_of_c(id);
585            if spent_c + c > policy.hard_max && !capped.is_empty() {
586                // Required entry that cannot fit even compressed: an
587                // explicit CRITICAL drop, never a silent skip.
588                pre_cap_critical_drops.push(id.clone());
589                continue;
590            }
591            spent_c += c;
592            capped.push((id.clone(), *imp));
593        }
594        if capped.is_empty() && !required_items.is_empty() {
595            // pathological: even the first compressed entry alone exceeds
596            // hard_max — keep the single most important one (its metadata
597            // is already stripped; the identity line always remains).
598            capped.push(required_items[0].clone());
599            for (id, _) in &required_items[1..] {
600                if !pre_cap_critical_drops.contains(id) {
601                    pre_cap_critical_drops.push(id.clone());
602                }
603            }
604        }
605        if capped.len() < required_items.len() {
606            required_items = capped;
607        }
608    }
609
610    let cost_of = |id: &str| -> usize {
611        match (entry_of.get(id), compress) {
612            (Some(e), true) => estimate_tokens(&render_entry_compressed(e)),
613            (Some(e), false) => estimate_tokens(&render_entry(e)),
614            _ => 1,
615        }
616    };
617    let required_spent: usize = required_items.iter().map(|(id, _)| cost_of(id)).sum();
618    // The pool's soft token room: what the budget leaves after required.
619    let available = budget.saturating_sub(required_spent);
620
621    // MMR budget: how many average-pool entries the remaining tokens afford
622    // (diversity caps same-component crowding before the exact token cut).
623    let pool_avg = if pool.is_empty() {
624        1
625    } else {
626        (pool.iter().map(|(id, _)| cost_of(id)).sum::<usize>() / pool.len()).max(1)
627    };
628    let mmr_budget = available
629        .saturating_div(pool_avg)
630        .max(1)
631        .min(pool.len());
632    let diversified: Vec<(String, f64)> = if stages.mmr && policy.mmr {
633        // Integer group keys (profiler receipt 2026-09-11: the similarity
634        // closure's per-call BTree lookups + String compares over ~1e8
635        // pairs were ~24% of the render). Same 1.0/0.0 semantics.
636        let mut comp_ids: HashMap<&str, u32> = HashMap::new();
637        let mut path_ids: HashMap<&str, u32> = HashMap::new();
638        let mut group_of: HashMap<&str, (u32, u32)> = HashMap::new();
639        for (id, e) in &entry_of {
640            let c = match &e.component {
641                Some(c) => {
642                    let n = comp_ids.len() as u32;
643                    *comp_ids.entry(c.as_str()).or_insert(n + 1)
644                }
645                None => 0,
646            };
647            let g = if e.path.is_empty() {
648                0
649            } else {
650                let n = path_ids.len() as u32;
651                *path_ids.entry(e.path.as_str()).or_insert(n + 1)
652            };
653            group_of.insert(id.as_str(), (c, g));
654        }
655        let sim = |a: &str, b: &str| -> f64 {
656            match (group_of.get(a), group_of.get(b)) {
657                (Some((ca, pa)), Some((cb, pb)))
658                    if (*ca != 0 && *ca == *cb) || (*pa != 0 && *pa == *pb) =>
659                {
660                    1.0
661                }
662                _ => 0.0,
663            }
664        };
665        let pool_by_id: BTreeMap<String, f64> = pool.iter().cloned().collect();
666        crate::selector::mmr_diversify(&pool, sim, 0.5, mmr_budget)
667            .into_iter()
668            .filter_map(|id| pool_by_id.get(&id).map(|v| (id.clone(), *v)))
669            .collect()
670    } else {
671        pool.clone()
672    };
673
674    // Token-aware quotas: caps are fractions of the pool's available
675    // tokens, so the composition adapts to the budget (reviewer item 6).
676    // Required entries were partitioned out and may exceed their group
677    // allocation; the leftover room rebalances across the pool.
678    let quota_filtered: Vec<(String, f64)> = if stages.quotas && policy.quotas {
679        let kind_of: BTreeMap<String, &'static str> = map
680            .entries
681            .iter()
682            .map(|e| (e.id.clone(), quota_kind(e)))
683            .collect();
684        let ids = crate::selector::enforce_quotas(
685            &diversified,
686            |id| kind_of.get(id).copied().unwrap_or("core"),
687            &surface_quotas(),
688            available,
689            |id| cost_of(id),
690        );
691        let id_set: BTreeSet<String> = ids.into_iter().collect();
692        diversified
693            .into_iter()
694            .filter(|(id, _)| id_set.contains(id))
695            .collect()
696    } else {
697        diversified
698    };
699
700    // Budget selection: required first (never dropped, even when they
701    // alone exceed the budget), then the quota-balanced pool by
702    // value/token (or rank order with the optimizer stage off).
703    let mut items: Vec<ContextItem> = Vec::new();
704    for (id, imp) in &required_items {
705        items.push(ContextItem {
706            id: id.clone(),
707            value: *imp,
708            token_cost: cost_of(id),
709            required: true,
710            group: Some("api".into()),
711        });
712    }
713    for (id, imp) in &quota_filtered {
714        items.push(ContextItem {
715            id: id.clone(),
716            value: *imp,
717            token_cost: cost_of(id),
718            required: false,
719            group: Some("api".into()),
720        });
721    }
722    let selected = if stages.optimizer {
723        crate::selector::select_with_budget(&items, budget, policy.hard_max)
724    } else {
725        crate::selector::select_in_order(&items, budget, policy.hard_max)
726    };
727
728    let mut rendered_ids: Vec<String> = Vec::new();
729    let mut selected_entries: Vec<SurfaceEntry> = Vec::new();
730    for &idx in &selected {
731        if idx >= items.len() {
732            continue; // defensive: never panic on a misbehaving selector
733        }
734        let id = items[idx].id.clone();
735        if let Some(e) = entry_of.get(&id) {
736            rendered_ids.push(id.clone());
737            selected_entries.push((*e).clone());
738        }
739    }
740
741    // Hard-max invariant against the ACTUAL rendered text — headers
742    // included, because `render_selected` re-renders the full block each
743    // iteration. The ladder (spec Part E): escalate structural compression
744    // first (first signature line → abbreviated → identity-only), then drop
745    // the lowest-value NON-required entries as honest omissions, then — and
746    // only then — drop the lowest-value REQUIRED entries as explicit
747    // CRITICAL drops (`critical_drops`), preserving the highest-ranked
748    // required entry whenever anything fits. Terminates: each step either
749    // raises the bounded level or shrinks the selected set; an empty
750    // selection renders header-only.
751    let mut level: u8 = if compress { 1 } else { 0 };
752    let mut critical_drops: Vec<String> = pre_cap_critical_drops;
753    let mut overflow_drops: usize = 0;
754    let mut text = render_selected(&selected_entries, level, explain);
755    let mut token_count = estimate_tokens(&text);
756    while token_count > policy.hard_max {
757        if level < MAX_COMPRESSION {
758            level += 1;
759        } else {
760            let Some(dropped) = selected_entries.pop() else {
761                break; // nothing left to drop: impossible-minimum floor
762            };
763            rendered_ids.retain(|id| id != &dropped.id);
764            if required.contains(&dropped.id) {
765                critical_drops.push(dropped.id.clone());
766            } else {
767                overflow_drops += 1;
768            }
769        }
770        text = render_selected(&selected_entries, level, explain);
771        token_count = estimate_tokens(&text);
772    }
773
774    // Omissions: every candidate not rendered, summarized per kind (honest —
775    // the artifact never silently implies completeness). Overflow drops get
776    // their own reason line so the artifact states WHY they left; critical
777    // drops are listed by id in `critical_drops`.
778    let rendered_set: BTreeSet<String> = rendered_ids.iter().cloned().collect();
779    let mut omitted_ids: Vec<String> = Vec::new();
780    let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
781    for e in &map.entries {
782        if rendered_set.contains(&e.id) {
783            continue;
784        }
785        omitted_ids.push(e.id.clone());
786        *by_kind.entry(e.kind.as_str().to_string()).or_insert(0) += 1;
787    }
788    let mut omissions: Vec<SurfaceOmission> = by_kind
789        .into_iter()
790        .map(|(kind, count)| SurfaceOmission {
791            count,
792            kind,
793            reason: "not selected within budget (diversity/quotas/token budget)".into(),
794        })
795        .collect();
796    if overflow_drops > 0 {
797        omissions.push(SurfaceOmission {
798            count: overflow_drops,
799            kind: "required-overflow".into(),
800            reason: "hard-max invariant on final rendered text (lowest-value entries dropped after full compression)".into(),
801        });
802    }
803    scc_core::SurfaceRenderResult {
804        text,
805        rendered_ids,
806        rendered_entries: selected_entries,
807        omitted_ids,
808        omissions,
809        token_count,
810        critical_drops,
811    }
812}
813
814// ---------------------------------------------------------------------------
815// The one authoritative surface service (Wave 15.1)
816// ---------------------------------------------------------------------------
817
818/// The surface mode: global (the historical production pipeline) or task
819/// (task PPR + novelty suppression with the same pipeline tail).
820#[derive(Debug, Clone, Copy)]
821// trace:v1 id=impl.scc.surface.mode work=WORK-SCC-015 satisfies=REQ-SCC-IR
822pub enum SurfaceMode<'a> {
823    Global,
824    Task {
825        goal: &'a str,
826        /// The context ledger for novelty suppression: entries already
827        /// visible AND unchanged are not re-injected. `None` disables
828        /// suppression (full task-personalized map).
829        visible: Option<&'a ContextLedger>,
830    },
831}
832
833/// One surface render request: the mode, the token budget, whether to
834/// explain selection scores, the pipeline policy, and the optional
835/// semantic scorer.
836#[derive(Clone, Copy)]
837// trace:v1 id=impl.scc.surface.request work=WORK-SCC-015 satisfies=REQ-SCC-IR
838pub struct SurfaceRequest<'a> {
839    pub mode: SurfaceMode<'a>,
840    pub budget: usize,
841    pub explain: bool,
842    pub policy: SurfacePolicy,
843    /// The optional semantic scorer (SCC-071, e.g. an embedding model):
844    /// when present, its per-entity score feeds the REAL 10% semantic
845    /// share of `final_importance`. When `None`, the 10% share is
846    /// explicitly redistributed across the other blend weights
847    /// ([`REDISTRIBUTION_SCALE`]) — never a phantom weight.
848    pub semantic: Option<&'a dyn crate::rank::SemanticScorer>,
849}
850
851/// Pipeline policy knobs for one surface render.
852#[derive(Debug, Clone, Copy, PartialEq, Eq)]
853// trace:v1 id=impl.scc.surface.policy work=WORK-SCC-015 satisfies=REQ-SCC-IR
854pub struct SurfacePolicy {
855    /// Token-aware per-kind quotas (default true).
856    pub quotas: bool,
857    /// MMR diversity across components/paths (default true).
858    pub mmr: bool,
859    /// Required coverage never dropped within the hard max (default true).
860    pub coverage: bool,
861    /// Absolute token ceiling: required facts may exceed `budget` but
862    /// never `hard_max`; required entries alone over the hard max are
863    /// structurally compressed (annotations/modifiers/doc lines dropped —
864    /// the entry is never dropped).
865    pub hard_max: usize,
866}
867
868// trace:exempt reason=internal-detail  # impl grouping; the constructor below is traced
869impl SurfacePolicy {
870    /// The default policy for a soft `budget`: quotas/MMR/coverage on and
871    /// `hard_max` = budget + 20% (min +500).
872    // trace:v1 id=impl.scc.surface.policy.defaults work=WORK-SCC-015 satisfies=REQ-SCC-IR
873    pub fn defaults(budget: usize) -> Self {
874        SurfacePolicy {
875            quotas: true,
876            mmr: true,
877            coverage: true,
878            hard_max: budget
879                .saturating_add(budget / 5)
880                .max(budget.saturating_add(500)),
881        }
882    }
883}
884
885/// Stage toggles for the ablation matrix ([`build_surface_staged`]): the
886/// same pipeline with one stage switched off. All on = [`build_surface`].
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888// trace:v1 id=impl.scc.surface.stages work=WORK-SCC-015 satisfies=REQ-SCC-IR
889pub struct SurfacePipelineStages {
890    /// Lexical stage: on = PPR-blended importance; off = pure lexical
891    /// scores, no PPR at all.
892    pub lexical: bool,
893    /// Global PPR stage: off = no global vector in the blend.
894    pub global_ppr: bool,
895    /// Task PPR stage: off = no task seeds (global-only blend).
896    pub task_ppr: bool,
897    /// MMR diversity stage: off = rank order preserved, no diversification.
898    pub mmr: bool,
899    /// Quota stage: off = no per-kind caps.
900    pub quotas: bool,
901    /// Optimizer stage: off = rank-order budget cut, no value/token
902    /// reordering.
903    pub optimizer: bool,
904}
905
906// trace:exempt reason=internal-detail  # Default impl grouping; the fn below is traced
907impl Default for SurfacePipelineStages {
908    /// All stages on: `build_surface_staged(.., &SurfacePipelineStages::default())`
909    /// is exactly [`build_surface`].
910    // trace:v1 id=impl.scc.surface.stages.default work=WORK-SCC-015 satisfies=REQ-SCC-IR
911    fn default() -> Self {
912        SurfacePipelineStages {
913            lexical: true,
914            global_ppr: true,
915            task_ppr: true,
916            mmr: true,
917            quotas: true,
918            optimizer: true,
919        }
920    }
921}
922
923/// THE one authoritative surface pipeline: compile candidates →
924/// heterogeneous PPR (global or task) →
925/// [`pagerank::SystemRanker::project_to_symbols`] → per-entry importance
926/// (`final_importance`) → required coverage → MMR diversify → token-aware
927/// quotas → soft/hard budget selection → render. Global mode is the
928/// historical `select_and_render_global` pipeline; Task mode adds task
929/// PPR (lexical seeds, warm global start), novelty suppression against
930/// the ledger, and the same MMR/quotas/selector/render tail. Every
931/// surface consumer (production, CLI, MCP, plugin, benchmark ablations)
932/// routes through this service — no consumer reimplements ranking.
933/// Deterministic and no-panic.
934// trace:v1 id=impl.scc.surface.build work=WORK-SCC-015 satisfies=REQ-SCC-IR
935pub fn build_surface(
936    compiler: &ContextCompiler,
937    request: SurfaceRequest<'_>,
938) -> scc_core::SurfaceRenderResult {
939    build_surface_staged(compiler, request, &SurfacePipelineStages::default())
940}
941
942/// [`build_surface`] with stage toggles for the ablation matrix
943/// (benchmark ablations toggle stages here — they never reimplement
944/// ranking). `lexical` off skips PPR entirely and scores by lexical
945/// match; `global_ppr` off drops the global vector; `task_ppr` off seeds
946/// no task vector; `mmr` off skips diversification; `quotas` off skips
947/// per-kind caps; `optimizer` off renders in importance order up to the
948/// budget. Deterministic and no-panic.
949// trace:v1 id=impl.scc.surface.build-staged work=WORK-SCC-015 satisfies=REQ-SCC-IR,REQ-semantic-10-live-in-final-importance,REQ-explain-renders-score-decomposition,REQ-hard-max-invariant-on-rendered-text
950pub fn build_surface_staged(
951    compiler: &ContextCompiler,
952    request: SurfaceRequest<'_>,
953    stages: &SurfacePipelineStages,
954) -> scc_core::SurfaceRenderResult {
955    build_surface_staged_inner(compiler, request, stages, &mut None)
956}
957
958/// 15.2-cache-seam: cache-aware sibling of [`build_surface`] (Wave 15.2,
959/// REQ-global-rank-cached-per-model-epoch). When `cache` holds a valid
960/// [`crate::startup::GlobalRankCache`] (loaded by
961/// `crate::startup::load_global_rank_cache`), the global PPR vector +
962/// symbol projection come from the cache — skipping
963/// `SystemRanker::new` (the heterogeneous node graph + adjacency +
964/// rarity build) and the 50 power iterations of `global_vector()`. The
965/// pipeline tail (required coverage, MMR, quotas, budget selection,
966/// render) runs identically, so the output is byte-identical to
967/// [`build_surface`]. On a miss (`cache` is `None`) the ranker is built
968/// once and the cache is FILLED (the caller persists it via
969/// `crate::startup::store_global_rank_cache`). Additive: B's `semantic`
970/// field lands on [`SurfaceRequest`]/[`build_surface`] independently.
971// trace:v1 id=impl.scc.surface.build-cached work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching satisfies=REQ-global-rank-cached-per-model-epoch
972pub fn build_surface_cached(
973    compiler: &ContextCompiler,
974    request: SurfaceRequest<'_>,
975    cache: &mut Option<crate::startup::GlobalRankCache>,
976) -> scc_core::SurfaceRenderResult {
977    build_surface_staged_inner(
978        compiler,
979        request,
980        &SurfacePipelineStages::default(),
981        cache,
982    )
983}
984
985/// The shared pipeline body of [`build_surface_staged`] /
986/// [`build_surface_cached`]: `cache` supplies the global PPR vector +
987/// projection on hit; on miss the ranker is built once and the cache
988/// filled (the caller persists it). Task PPR still constructs the
989/// ranker when the cache lacks the adjacency (documented ceiling — the
990/// rank cache stores the global vector + projection only).
991// trace:exempt reason=internal-detail  # shared body; the traced entries are build_surface_staged / build_surface_cached
992fn build_surface_staged_inner(
993    compiler: &ContextCompiler,
994    request: SurfaceRequest<'_>,
995    stages: &SurfacePipelineStages,
996    cache: &mut Option<crate::startup::GlobalRankCache>,
997) -> scc_core::SurfaceRenderResult {
998    let (goal, visible): (Option<&str>, Option<&ContextLedger>) = match &request.mode {
999        SurfaceMode::Global => (None, None),
1000        SurfaceMode::Task { goal, visible } => (Some(goal), *visible),
1001    };
1002    let goal_terms = goal.map(terms).unwrap_or_default();
1003
1004    // Task seeds: lexical candidate resolution (task mode only, and only
1005    // while the task-ppr stage is on).
1006    let mut seeds: Vec<TaskSeed> = Vec::new();
1007    let mut seed_ids: BTreeSet<String> = BTreeSet::new();
1008    if stages.task_ppr {
1009        if let Some(g) = goal {
1010            let candidates = crate::rank::collect_lexical_candidates(
1011                compiler.store,
1012                &compiler.view,
1013                g,
1014                &[],
1015                16,
1016            );
1017            seeds = candidates
1018                .iter()
1019                .map(|c| TaskSeed {
1020                    kind: c.kind.clone(),
1021                    id: c.id.clone(),
1022                    weight: c.score,
1023                })
1024                .collect();
1025            seed_ids = seeds.iter().map(|s| s.id.clone()).collect();
1026        }
1027    }
1028
1029    let mut map = compile_surface_map(compiler);
1030    // 15.2-cache-seam: on hit, the global vector + projection come from
1031    // the cache (no SystemRanker::new, no 50 power iterations); on miss
1032    // the ranker is built once and the cache filled — node_symbol_map is
1033    // exactly what the pipeline consumes as `global_of`, so the render is
1034    // byte-identical either way. `task_ranker` carries the built ranker
1035    // to the task-vector step when both are needed.
1036    let mut global_of: BTreeMap<String, f64> = BTreeMap::new();
1037    let mut task_ranker: Option<crate::pagerank::SystemRanker<'_>> = None;
1038    if stages.global_ppr {
1039        if let Some(c) = cache.as_ref() {
1040            global_of = c.node_symbol_map.clone();
1041        } else {
1042            let ranker = crate::pagerank::SystemRanker::new(&compiler.view);
1043            let gv = ranker.global_vector();
1044            global_of = ranker.project_to_symbols(&gv).into_iter().collect();
1045            *cache = Some(crate::startup::GlobalRankCache {
1046                epoch: compiler
1047                    .store
1048                    .cache_epoch()
1049                    .unwrap_or_else(|_| "no-epoch".into()),
1050                policy: crate::startup::trust_policy_str(compiler.view.policy()),
1051                salt: compiler.settings.rank_salt.clone(),
1052                global_vector: gv,
1053                node_symbol_map: global_of.clone(),
1054                candidates_epoch: map.epoch.clone(),
1055                candidate_ids: map.entries.iter().map(|e| e.id.clone()).collect(),
1056                hits: 0,
1057            });
1058            task_ranker = Some(ranker);
1059        }
1060    }
1061    let task_of: BTreeMap<String, f64> = if stages.task_ppr && !seeds.is_empty() {
1062        // ponytail: task PPR still builds the ranker when the cache hit
1063        // path is active (the cache stores the global vector + projection
1064        // only, not the adjacency); cache the adjacency too if task-mode
1065        // startup latency ever matters.
1066        let ranker = match task_ranker {
1067            Some(r) => r,
1068            None => crate::pagerank::SystemRanker::new(&compiler.view),
1069        };
1070        ranker
1071            .project_to_symbols(&ranker.task_vector(&seeds))
1072            .into_iter()
1073            .collect()
1074    } else {
1075        BTreeMap::new()
1076    };
1077
1078    let required = required_ids(&map, compiler);
1079    let has_task = !seeds.is_empty();
1080    let inv_names = invariant_names(&compiler.view);
1081    let mut ranked: Vec<(String, f64)> = Vec::new();
1082    let mut ranks: BTreeMap<String, SurfaceRank> = BTreeMap::new();
1083    for e in &map.entries {
1084        let changed = compiler.is_stale_path(&e.path);
1085        let novelty = match visible {
1086            Some(ledger) => novelty_penalty(ledger, &e.symbol_id, changed),
1087            None => 1.0,
1088        };
1089        if novelty < 1.0 {
1090            // already visible AND unchanged: not re-injected (spec)
1091            continue;
1092        }
1093        let task_ppr = task_of.get(&e.symbol_id).copied().unwrap_or(0.0);
1094        let global_ppr = global_of.get(&e.symbol_id).copied().unwrap_or(0.0);
1095        let lexical = entry_lexical(e, &goal_terms);
1096        let criticality = if seed_ids.contains(&e.symbol_id) || required.contains(&e.id) {
1097            1.0
1098        } else {
1099            // §41: important-file evidence (manifests, Docker, CI, bootstrap,
1100            // main binaries) is a criticality signal — importance evidence,
1101            // never raw file text injection.
1102            crate::surface::file_importance(&e.path) * 0.5
1103        };
1104        let change_risk = if changed { 1.0 } else { 0.0 };
1105        // The semantic score is the REAL 10% share: the scorer rates the
1106        // entry's logical symbol entity against the goal. Absent a scorer
1107        // the share is zero and the blend is renormalized (see below) —
1108        // never a phantom weight.
1109        let semantic = match request.semantic {
1110            Some(scorer) => compiler
1111                .view
1112                .entity(&e.symbol_id)
1113                .map(|en| scorer.score(goal.unwrap_or(""), en))
1114                .unwrap_or(0.0)
1115                .clamp(0.0, 1.0),
1116            None => 0.0,
1117        };
1118        let (importance, semantic_component) = if stages.lexical {
1119            // final_importance is linear in every input, so computing the
1120            // blend with novelty = 0.0 and adding NOVELTY_WEIGHT * novelty
1121            // reproduces the documented blend exactly while keeping the
1122            // novelty term additive on top (final_importance's own
1123            // contract — the six blend weights sum to 1.0, novelty is the
1124            // documented +0.05 bonus).
1125            let blend = crate::pagerank::final_importance(
1126                task_ppr,
1127                global_ppr,
1128                lexical,
1129                semantic,
1130                e.confidence as f64,
1131                criticality,
1132                change_risk,
1133                0.0,
1134                has_task,
1135            );
1136            let total = match request.semantic {
1137                // Scorer present: the 10% semantic share is real.
1138                Some(_) => blend + crate::pagerank::NOVELTY_WEIGHT * novelty,
1139                // No scorer: the 10% share is reallocated proportionally
1140                // across the other blend weights (REDISTRIBUTION_SCALE) so
1141                // the total still reflects the advertised blend — a
1142                // full-strength entry still totals 1.0 + novelty instead
1143                // of the phantom-hole 0.9 + novelty.
1144                None => blend * REDISTRIBUTION_SCALE + crate::pagerank::NOVELTY_WEIGHT * novelty,
1145            };
1146            (total, semantic)
1147        } else {
1148            // lexical stage off: pure lexical scores, no PPR blend
1149            (lexical, 0.0)
1150        };
1151        if importance <= 0.0 && !required.contains(&e.id) {
1152            continue;
1153        }
1154        ranks.insert(
1155            e.id.clone(),
1156            SurfaceRank {
1157                task_ppr,
1158                global_ppr,
1159                lexical,
1160                semantic: semantic_component,
1161                confidence: e.confidence as f64,
1162                criticality,
1163                change_risk,
1164                novelty,
1165                total: importance,
1166                reasons: entry_reasons(e, goal, &seed_ids, &inv_names, changed),
1167            },
1168        );
1169        ranked.push((e.id.clone(), importance));
1170    }
1171    // The compiled map carries the per-entry SurfaceRank so every consumer
1172    // (render, MCP JSON, tests) sees the decomposition; the selected
1173    // clones inherit it into the render. Importance profiles ride along:
1174    // derived badges + exact counts over data already on the entry — no
1175    // new scoring, no rank change.
1176    for e in &mut map.entries {
1177        if let Some(r) = ranks.get(&e.id) {
1178            e.rank = r.clone();
1179            e.importance = Some(importance_profile(e, r));
1180        }
1181    }
1182    ranked.sort_by(|a, b| {
1183        b.1.partial_cmp(&a.1)
1184            .unwrap_or(std::cmp::Ordering::Equal)
1185            .then_with(|| a.0.cmp(&b.0))
1186    });
1187    finish_selection(
1188        &map,
1189        ranked,
1190        &required,
1191        request.budget,
1192        &request.policy,
1193        stages,
1194        request.explain,
1195    )
1196}
1197
1198/// The FULL production global surface pipeline (historical entry point —
1199/// kept as a thin wrapper over [`build_surface`] so the traced contract
1200/// and legacy callers stay stable): global heterogeneous PPR, required
1201/// coverage, MMR, quotas, budget selection, render.
1202// trace:v1 id=impl.scc.surface.select-and-render-global work=WORK-SCC-014 satisfies=REQ-SCC-IR
1203pub fn select_and_render_global(
1204    compiler: &ContextCompiler,
1205    budget: usize,
1206) -> scc_core::SurfaceRenderResult {
1207    build_surface(
1208        compiler,
1209        SurfaceRequest {
1210            mode: SurfaceMode::Global,
1211            budget,
1212            explain: false,
1213            policy: SurfacePolicy::defaults(budget),
1214                    semantic: None,
1215        },
1216    )
1217}
1218
1219/// Top-N important symbols (audit item 3): the ranked map's entries
1220/// sorted by the mode's score (task_ppr for Task, global-weighted total
1221/// for Global), each carrying its derived [`scc_core::ImportanceProfile`].
1222/// READ-ONLY ranking view: no rescoring, no budget, no render — callers
1223/// (startup sections, `scc important`) decide presentation. `limit`
1224/// caps the returned entries (0 = all).
1225// trace:v1 id=impl.scc.surface.important-symbols work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1226pub fn important_symbols(
1227    compiler: &ContextCompiler,
1228    mode: SurfaceMode<'_>,
1229    limit: usize,
1230) -> Vec<SurfaceEntry> {
1231    let mut map = compile_surface_map(compiler);
1232    // Rank exactly like the production pipeline: task seeds when tasked,
1233    // global PPR otherwise. Scores attach to entries via the shared tail.
1234    // No throwaway pipeline build: the old code ran a full
1235    // build_surface_staged and discarded the result, paying for a second
1236    // SystemRanker build. Rank directly over the same signals
1237    // (global/task projection) — deterministic, same order as before.
1238    let ranker = crate::pagerank::SystemRanker::new(&compiler.view);
1239    let gv = ranker.global_vector();
1240    let global_of: BTreeMap<String, f64> =
1241        ranker.project_to_symbols(&gv).into_iter().collect();
1242    let task_of: BTreeMap<String, f64> = match &mode {
1243        SurfaceMode::Task { goal, .. } => {
1244            let cands =
1245                crate::rank::collect_lexical_candidates(compiler.store, &compiler.view, goal, &[], 16);
1246            let seeds: Vec<TaskSeed> = cands
1247                .iter()
1248                .map(|c| TaskSeed {
1249                    kind: c.kind.clone(),
1250                    id: c.id.clone(),
1251                    weight: c.score,
1252                })
1253                .collect();
1254            if seeds.is_empty() {
1255                BTreeMap::new()
1256            } else {
1257                ranker
1258                    .project_to_symbols(&ranker.task_vector(&seeds))
1259                    .into_iter()
1260                    .collect()
1261            }
1262        }
1263        SurfaceMode::Global => BTreeMap::new(),
1264    };
1265    let tasked = !task_of.is_empty();
1266    for e in &mut map.entries {
1267        let task_ppr = task_of.get(&e.symbol_id).copied().unwrap_or(0.0);
1268        let global_ppr = global_of.get(&e.symbol_id).copied().unwrap_or(0.0);
1269        let score = if tasked { task_ppr } else { global_ppr };
1270        let r = SurfaceRank {
1271            task_ppr,
1272            global_ppr,
1273            lexical: 0.0,
1274            semantic: 0.0,
1275            confidence: e.confidence as f64,
1276            criticality: 0.0,
1277            change_risk: 0.0,
1278            novelty: 1.0,
1279            total: score,
1280            reasons: Vec::new(),
1281        };
1282        e.rank = r.clone();
1283        e.importance = Some(importance_profile(e, &r));
1284    }
1285    map.entries
1286        .sort_by(|a, b| {
1287            b.rank
1288                .total
1289                .partial_cmp(&a.rank.total)
1290                .unwrap_or(std::cmp::Ordering::Equal)
1291                .then_with(|| a.id.cmp(&b.id))
1292        });
1293    if limit > 0 {
1294        map.entries.truncate(limit);
1295    }
1296    map.entries
1297}
1298
1299/// Render the IMPORTANT SYMBOLS section (audit item 3): numbered entries
1300/// with badges, exact fan-in/fan-out, flow/contract counts, and the
1301/// overall score — the fast "where do I pay attention first" answer
1302/// before the long Surface Map detail.
1303// trace:v1 id=impl.scc.surface.render-important work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1304pub fn render_important(entries: &[SurfaceEntry], task_mode: bool) -> String {
1305    let mut out = String::new();
1306    out.push_str(if task_mode {
1307        "## TASK-CRITICAL SYMBOLS
1308"
1309    } else {
1310        "## SYSTEM-CRITICAL SYMBOLS
1311"
1312    });
1313    if entries.is_empty() {
1314        out.push_str("(no ranked symbols)
1315");
1316        return out;
1317    }
1318    for (i, e) in entries.iter().enumerate() {
1319        let imp = e.importance.as_ref();
1320        let badges = imp
1321            .map(|p| {
1322                if p.badges.is_empty() {
1323                    String::new()
1324                } else {
1325                    format!("   {}", p.badges.join(" \u{00B7} "))
1326                }
1327            })
1328            .unwrap_or_default();
1329        let name = &e.qualified_name;
1330        out.push_str(&format!("{}. {name}{}
1331", i + 1, badges));
1332        out.push_str(&format!("   {}:{}-{}
1333", e.path, e.range.start_line, e.range.end_line));
1334        let cc = imp.map(|p| p.caller_count).unwrap_or(e.caller_count);
1335        let ce = imp.map(|p| p.callee_count).unwrap_or(e.callee_count);
1336        out.push_str(&format!(
1337            "   Called by {cc} symbols \u{00B7} calls {ce} \u{00B7} flows {} \u{00B7} contracts {} \u{00B7} importance {:.2}
1338",
1339            e.flows.len(),
1340            e.contracts.len(),
1341            e.rank.total
1342        ));
1343    }
1344    out
1345}
1346
1347/// The FULL production task surface pipeline (historical entry point —
1348/// kept as a thin wrapper over [`build_surface`] so the traced contract
1349/// and legacy callers stay stable): task PPR + novelty suppression
1350/// against the ledger + the same MMR/quotas/selector/render tail.
1351// trace:v1 id=impl.scc.surface.select-and-render-task work=WORK-SCC-014 satisfies=REQ-SCC-IR
1352pub fn select_and_render_task(
1353    compiler: &ContextCompiler,
1354    goal: &str,
1355    budget: usize,
1356    visible: &ContextLedger,
1357) -> scc_core::SurfaceRenderResult {
1358    build_surface(
1359        compiler,
1360        SurfaceRequest {
1361            mode: SurfaceMode::Task {
1362                goal,
1363                visible: Some(visible),
1364            },
1365            budget,
1366            explain: false,
1367            policy: SurfacePolicy::defaults(budget),
1368                    semantic: None,
1369        },
1370    )
1371}
1372
1373// ---------------------------------------------------------------------------
1374// Entry builder
1375// ---------------------------------------------------------------------------
1376
1377#[allow(clippy::too_many_arguments)]
1378// trace:exempt reason=internal-detail
1379/// Derived importance explanation for one ranked entry (audit item 3):
1380/// exact fan-in/fan-out, PPR centrality, architecture signals, and change
1381/// impact collapse into labels. READ-ONLY over the entry + rank: the
1382/// overall score is `rank.total` verbatim; badges explain, never rescore.
1383// trace:v1 id=impl.scc.surface.importance-profile work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
1384fn importance_profile(e: &SurfaceEntry, r: &SurfaceRank) -> scc_core::ImportanceProfile {
1385    let mut badges: Vec<String> = Vec::new();
1386    let entrypoint = !e.invocation_surfaces.is_empty();
1387    // Blast radius: exact dependents ≈ distinct callers (the file-level
1388    // importer BFS measures the same closure at query time; here the
1389    // static fan-in is the honest proxy).
1390    let dependent_count = e.caller_count;
1391    if entrypoint {
1392        badges.push("ENTRYPOINT".into());
1393    }
1394    if e.exported {
1395        badges.push("PUBLIC API".into());
1396    }
1397    if !e.state_authorities.is_empty() {
1398        badges.push("STATE OWNER".into());
1399    }
1400    if !e.contracts.is_empty() {
1401        badges.push("CONTRACT BOUNDARY".into());
1402    }
1403    if !e.flows.is_empty() {
1404        badges.push("FLOW CRITICAL".into());
1405    }
1406    if e.caller_count >= 50 {
1407        badges.push("HIGH FAN-IN".into());
1408    }
1409    if e.callee_count >= 10 {
1410        badges.push("HUB".into());
1411    }
1412    if r.global_ppr >= 0.01 {
1413        badges.push("HIGH IMPACT".into());
1414    }
1415    if r.change_risk >= 0.5 {
1416        badges.push("CHANGE HOTSPOT".into());
1417    }
1418    // CORE = architectural importance, not raw frequency: an entrypoint /
1419    // exported / state-owning / contract/flow symbol with real centrality.
1420    // A 400-caller utility with none of those stays HOT UTILITY territory.
1421    if r.global_ppr >= 0.005
1422        && (entrypoint || e.exported || !e.state_authorities.is_empty() || !e.contracts.is_empty() || !e.flows.is_empty())
1423    {
1424        badges.insert(0, "CORE".into());
1425    }
1426    scc_core::ImportanceProfile {
1427        overall: r.total,
1428        caller_count: e.caller_count,
1429        callee_count: e.callee_count,
1430        global_ppr: r.global_ppr,
1431        task_ppr: r.task_ppr,
1432        entrypoint,
1433        exported: e.exported,
1434        flow_count: e.flows.len(),
1435        contract_count: e.contracts.len(),
1436        state_read_count: 0,
1437        state_write_count: e.state_authorities.len(),
1438        dependent_count,
1439        change_risk: r.change_risk,
1440        badges,
1441    }
1442}
1443
1444
1445#[allow(clippy::too_many_arguments)]
1446// trace:v1 id=impl.scc.surface.build-entry work=WORK-SCC-014 satisfies=REQ-SCC-IR
1447fn build_entry(
1448    compiler: &ContextCompiler,
1449    e: &scc_core::Entity,
1450    kind_str: &str,
1451    symbol_comp_id: &BTreeMap<String, String>,
1452    comp_names: &BTreeMap<String, String>,
1453    subsys_of_comp: &BTreeMap<String, String>,
1454    surface_by_symbol: &BTreeMap<String, Vec<(String, String)>>,
1455    flows_by_actor: &HashMap<&str, Vec<&str>>,
1456    all_actors: &[&str],
1457    flows_by_actor_file: &mut HashMap<String, Vec<String>>,
1458    routes_by_handler: &HashMap<&str, Vec<(String, String)>>,
1459) -> SurfaceEntry {
1460    let view = &compiler.view;
1461    let name = e.name.clone();
1462    let simple = name.rsplit('.').next().unwrap_or(&name).to_string();
1463    let parent = attr_str(e, "parent");
1464    let exported = e.attributes.get("exported").and_then(|v| v.as_bool()) == Some(true);
1465    let file = attr_str(e, "file").unwrap_or_default();
1466    let start = attr_u32(e, "start_line");
1467    let end = attr_u32(e, "end_line");
1468
1469    // Exact declaration header wins: the indexer's `decl_header` attr is
1470    // the full header as written (untruncated, multi-line preserved);
1471    // falls back to the legacy `signature` attr, then a synthesized
1472    // name-only signature.
1473    let decl = attr_str(e, "decl_header").unwrap_or_default();
1474    let source_sig = if decl.trim().is_empty() {
1475        attr_str(e, "signature").unwrap_or_default()
1476    } else {
1477        decl
1478    };
1479    let source_signature = if source_sig.trim().is_empty() {
1480        synthesized_signature(kind_str, &simple)
1481    } else {
1482        source_sig
1483    };
1484    let parsed_inner = parse_sig_inner(&source_signature);
1485    let parsed_sig = parse_signature(&source_signature, &name, parent.as_deref());
1486
1487    // Overload-sensitive entry id: when the indexer recorded an
1488    // `overload_index` attr (0-based per same-name-in-file), the entry id
1489    // carries `#overload<N>` so same-name overloads stay separate entries;
1490    // `symbol_id` keeps pointing at the logical symbol (indexer overload
1491    // entity ids are `{symbol_id}` for index 0 and `{symbol_id}#{N}` for
1492    // N >= 1, so the suffix is stripped to recover the logical id).
1493    let overload = e
1494        .attributes
1495        .get("overload_index")
1496        .and_then(|v| v.as_u64())
1497        .map(|n| n as usize);
1498    let (entry_id, symbol_id) = match overload {
1499        Some(n) => {
1500            let logical = if n >= 1 {
1501                e.id.trim_end_matches(&format!("#{n}")).to_string()
1502            } else {
1503                e.id.clone()
1504            };
1505            (format!("{}#overload{}", logical, n), logical)
1506        }
1507        None => (e.id.clone(), e.id.clone()),
1508    };
1509
1510    let kind = map_kind(kind_str, &name, parent.as_deref());
1511    let qualified = if kind_str == "method" {
1512        match &parent {
1513            Some(p) if !p.is_empty() => format!("{}.{}", p, simple),
1514            _ => name.clone(),
1515        }
1516    } else {
1517        name.clone()
1518    };
1519
1520    let visibility = entry_visibility(
1521        exported,
1522        parent.as_deref(),
1523        &parsed_inner.modifiers,
1524        parsed_inner.ret_before_name.as_deref(),
1525    );
1526
1527    // modifiers: async/static/final/abstract/readonly (+variadic)
1528    const SURFACE_MODIFIERS: [&str; 5] = ["async", "static", "final", "abstract", "readonly"];
1529    let mut modifiers: Vec<String> = parsed_inner
1530        .modifiers
1531        .iter()
1532        .filter(|m| SURFACE_MODIFIERS.contains(&m.as_str()))
1533        .cloned()
1534        .collect();
1535    if parsed_sig.parameters.iter().any(|p| p.variadic)
1536        && !modifiers.iter().any(|m| m == "variadic")
1537    {
1538        modifiers.push("variadic".into());
1539    }
1540
1541    // annotations
1542    let mut annotations: Vec<String> = Vec::new();
1543    for r in sorted_rels(view.in_pred(&e.id, predicates::ANNOTATES)) {
1544        if let Some(a) = view.entity(&r.subject) {
1545            annotations.push(a.name.clone());
1546        }
1547    }
1548    annotations.sort();
1549    annotations.dedup();
1550
1551    // flows via the per-map actor index: exact actor lookups for
1552    // id/name/qualified plus the cached file-path fallback (same
1553    // predicate as `step_matches`, precomputed — no per-step scan).
1554    let mut flows: BTreeSet<String> = BTreeSet::new();
1555    for key in [&e.id, &name, &qualified] {
1556        if let Some(names) = flows_by_actor.get(key.as_str()) {
1557            flows.extend(names.iter().map(|s| s.to_string()));
1558        }
1559    }
1560    if !file.is_empty() {
1561        let cached = flows_by_actor_file.entry(file.clone()).or_insert_with(|| {
1562            let mut names: Vec<String> = Vec::new();
1563            for actor in all_actors.iter() {
1564                if actor.contains(file.as_str()) {
1565                    if let Some(fs) = flows_by_actor.get(*actor) {
1566                        names.extend(fs.iter().map(|s| s.to_string()));
1567                    }
1568                }
1569            }
1570            names.sort();
1571            names.dedup();
1572            names
1573        });
1574        flows.extend(cached.iter().cloned());
1575    }
1576
1577    // contracts
1578    let mut contracts: BTreeSet<String> = BTreeSet::new();
1579    // http: ROUTE handler == this symbol (precomputed per map)
1580    if let Some(routes) = routes_by_handler.get(e.id.as_str()) {
1581        for (m, path) in routes {
1582            contracts.insert(format!("http: {}", format!("{m} {path}").trim()));
1583        }
1584    }
1585    // cli flags
1586    if let Some(flags) = e.attributes.get("cli_flags").and_then(|v| v.as_array()) {
1587        for f in flags {
1588            if let Some(s) = f.as_str() {
1589                contracts.insert(format!("cli: {}", s));
1590            }
1591        }
1592    }
1593    // event topics
1594    for pred in [predicates::CONSUMES, predicates::PUBLISHES] {
1595        for rel in sorted_rels(view.out_pred(&e.id, pred)) {
1596            if let Some(t) = view.entity(&rel.object) {
1597                if t.kind == kinds::TOPIC {
1598                    contracts.insert(format!("event: {}", t.name));
1599                }
1600            }
1601        }
1602    }
1603    // REGISTERS -> CONTRACT entities
1604    for rel in sorted_rels(view.out_pred(&e.id, predicates::REGISTERS)) {
1605        if let Some(t) = view.entity(&rel.object) {
1606            if t.kind == kinds::CONTRACT {
1607                contracts.insert(format!("register:{}", view.name_of(&t.id)));
1608            }
1609        }
1610    }
1611    // DEFINES -> SCHEMA entities
1612    for rel in sorted_rels(view.out_pred(&e.id, predicates::DEFINES)) {
1613        if let Some(t) = view.entity(&rel.object) {
1614            if t.kind == kinds::SCHEMA {
1615                contracts.insert(format!("schema:{}", t.name));
1616            }
1617        }
1618    }
1619
1620    // state authorities
1621    let mut state_authorities: Vec<String> = Vec::new();
1622    for rel in sorted_rels(view.out_pred(&e.id, predicates::OWNS)) {
1623        if let Some(t) = view.entity(&rel.object) {
1624            if t.kind == kinds::STATE || t.kind == kinds::REACTIVE {
1625                state_authorities.push(t.name.clone());
1626            }
1627        }
1628    }
1629    state_authorities.sort();
1630    state_authorities.dedup();
1631
1632    // invocation surfaces
1633    let mut inv: Vec<String> = Vec::new();
1634    if let Some(surfs) = surface_by_symbol.get(&e.id) {
1635        for (kind, trigger) in surfs {
1636            inv.push(format!("{}: {}", kind, trigger));
1637        }
1638    }
1639    if let Some(eps) = e.attributes.get("entrypoints").and_then(|v| v.as_array()) {
1640        for ep in eps {
1641            if let Some(s) = ep.as_str() {
1642                inv.push(format!("entrypoint:{}", s));
1643            }
1644        }
1645    }
1646    inv.sort();
1647
1648    // callers / callees: exact counts are first-class (audit item 3);
1649    // the displayed name lists stay capped at 12, the counts never are.
1650    let mut all_callers: Vec<String> = Vec::new();
1651    for r in sorted_rels(view.in_pred(&e.id, predicates::CALLS)) {
1652        all_callers.push(view.name_of(&r.subject));
1653    }
1654    all_callers.sort();
1655    all_callers.dedup();
1656    let caller_count = all_callers.len();
1657    let mut callers = all_callers;
1658    callers.truncate(12);
1659    let mut all_callees: Vec<String> = Vec::new();
1660    for r in sorted_rels(view.out_pred(&e.id, predicates::CALLS)) {
1661        all_callees.push(view.name_of(&r.object));
1662    }
1663    all_callees.sort();
1664    all_callees.dedup();
1665    let callee_count = all_callees.len();
1666    let mut callees = all_callees;
1667    callees.truncate(12);
1668
1669    // provenance
1670    let has_resolved_call = view
1671        .out_pred(&e.id, predicates::CALLS)
1672        .iter()
1673        .any(|r| r.provenance == Provenance::Resolved);
1674    let provenance = if has_resolved_call {
1675        Provenance::Resolved
1676    } else {
1677        Provenance::Extracted
1678    };
1679    let confidence: f32 = if has_resolved_call { 1.0 } else { 0.85 };
1680
1681    // component / subsystem
1682    let component = symbol_comp_id
1683        .get(&e.id)
1684        .and_then(|cid| comp_names.get(cid))
1685        .cloned();
1686    let subsystem = symbol_comp_id
1687        .get(&e.id)
1688        .and_then(|cid| subsys_of_comp.get(cid))
1689        .cloned();
1690
1691    SurfaceEntry {
1692        id: entry_id,
1693        symbol_id,
1694        qualified_name: qualified,
1695        kind,
1696        path: file.clone(),
1697        range: SourceRange::new(file, start, end),
1698        source_signature: source_signature.clone(),
1699        canonical_signature: canonicalize(&source_signature),
1700        semantic_signature: parsed_sig,
1701        visibility,
1702        exported,
1703        modifiers,
1704        annotations,
1705        component,
1706        subsystem,
1707        flows: flows.into_iter().collect(),
1708        contracts: contracts.into_iter().collect(),
1709        state_authorities,
1710        invocation_surfaces: inv,
1711        callers,
1712        callees,
1713        caller_count,
1714        callee_count,
1715        importance: None,
1716        provenance,
1717        confidence,
1718        rank: SurfaceRank::default(),
1719    }
1720}
1721
1722// ---------------------------------------------------------------------------
1723// Semantic signature parser
1724// ---------------------------------------------------------------------------
1725
1726#[derive(Debug, Default)]
1727// trace:exempt reason=internal-detail
1728struct ParsedSignature {
1729    modifiers: Vec<String>,
1730    async_: bool,
1731    name: String,
1732    owner: Option<String>,
1733    ret_before_name: Option<String>,
1734    params: Vec<String>,
1735    generic_parameters: Vec<String>,
1736    returns: Option<String>,
1737    constraints: Vec<String>,
1738}
1739
1740// trace:exempt reason=internal-detail
1741fn parse_signature(sig: &str, fallback_name: &str, parent: Option<&str>) -> SemanticSignature {
1742    let p = parse_sig_inner(sig);
1743    let name = if p.name.is_empty() {
1744        fallback_name.to_string()
1745    } else {
1746        p.name
1747    };
1748    let mut parameters: Vec<SemanticParameter> = Vec::new();
1749    for raw in &p.params {
1750        if let Some(sp) = parse_param(raw) {
1751            parameters.push(sp);
1752        }
1753    }
1754    let mut generic_parameters = p.generic_parameters;
1755    generic_parameters.sort();
1756    generic_parameters.dedup();
1757    SemanticSignature {
1758        name,
1759        owner: p.owner.or_else(|| parent.map(|s| s.to_string())),
1760        visibility: visibility_from_modifiers(&p.modifiers),
1761        async_: p.async_,
1762        generic_parameters,
1763        parameters,
1764        returns: p.returns,
1765        constraints: p.constraints,
1766    }
1767}
1768
1769// trace:exempt reason=internal-detail
1770fn parse_sig_inner(sig: &str) -> ParsedSignature {
1771    let mut p = ParsedSignature::default();
1772    let s = sig.trim();
1773    if s.is_empty() {
1774        return p;
1775    }
1776
1777    // 1. modifier + callable-keyword prefix
1778    let mut rest: &str = s;
1779    let mut consumed_keyword: Option<String> = None;
1780    while let Some((word, after)) = leading_word(rest) {
1781        let is_mod = is_modifier_word(&word);
1782        let is_kw = !is_mod && is_callable_keyword(&word);
1783        if !is_mod && !is_kw {
1784            break;
1785        }
1786        let mut after = after;
1787        let token = if after.starts_with('(') {
1788            match take_group(after, '(', ')') {
1789                Some((g, r)) => {
1790                    after = r;
1791                    format!("{}({})", word, g)
1792                }
1793                None => word.clone(),
1794            }
1795        } else {
1796            word.clone()
1797        };
1798        if is_mod {
1799            if word == "async" {
1800                p.async_ = true;
1801            }
1802            if !p.modifiers.iter().any(|m| m == &token) {
1803                p.modifiers.push(token);
1804            }
1805        } else {
1806            consumed_keyword = Some(word);
1807        }
1808        rest = after.trim_start();
1809    }
1810
1811    // 2. Go receiver group
1812    let is_go = matches!(consumed_keyword.as_deref(), Some("func") | Some("function"));
1813    if is_go && rest.starts_with('(') {
1814        if let Some((group, after)) = take_group(rest, '(', ')') {
1815            p.owner = receiver_owner(&group);
1816            rest = after.trim_start();
1817        }
1818    }
1819
1820    // 3. Parameter list
1821    let (prefix, params, tail) = match split_params(rest) {
1822        Some(x) => (x.0, x.1, x.2),
1823        None => {
1824            if let Some((w, _)) = leading_word(rest) {
1825                if is_identifier(&w) {
1826                    p.name = w;
1827                }
1828            }
1829            return p;
1830        }
1831    };
1832    p.params = params;
1833
1834    // 4. Name + generics + java-style return type
1835    let prefix = prefix.trim();
1836    let (name_region, generics) = if prefix.ends_with('>') {
1837        match find_matching_open(prefix, '<', '>') {
1838            Some(idx) => (&prefix[..idx], Some(&prefix[idx..])),
1839            None => (prefix, None),
1840        }
1841    } else {
1842        (prefix, None)
1843    };
1844    let words: Vec<&str> = name_region.split_whitespace().collect();
1845    if let Some(last) = words.last() {
1846        if is_identifier(last) {
1847            p.name = last.to_string();
1848            if words.len() > 1 {
1849                p.ret_before_name = Some(words[..words.len() - 1].join(" "));
1850            }
1851        } else if let Some(first) = words.first() {
1852            if is_identifier(first) {
1853                p.name = first.to_string();
1854            }
1855        }
1856    }
1857
1858    if let Some(g) = generics {
1859        let inner = g.trim_start_matches('<').trim_end_matches('>');
1860        let mut seen: BTreeSet<String> = BTreeSet::new();
1861        for item in split_top(inner, ',') {
1862            let item = item.trim();
1863            if item.is_empty() {
1864                continue;
1865            }
1866            let (name_part, bound) = match item.find(':') {
1867                Some(idx) => (&item[..idx], Some(item[idx + 1..].trim())),
1868                None => (item, None),
1869            };
1870            let gn = name_part.trim();
1871            if gn.is_empty() {
1872                continue;
1873            }
1874            if seen.insert(gn.to_string()) {
1875                p.generic_parameters.push(gn.to_string());
1876            }
1877            if let Some(b) = bound {
1878                let c = format!("{}: {}", gn, b);
1879                if !c.is_empty() && seen.insert(c.clone()) {
1880                    p.constraints.push(c);
1881                }
1882            }
1883        }
1884    }
1885
1886    // 5. Tail: returns + where/throws constraints
1887    let (ret_text, constraints) = split_tail(tail.trim());
1888    p.returns = parse_return(&ret_text, p.ret_before_name.as_deref());
1889    if let Some(cs) = constraints {
1890        for c in split_top(&cs, ',') {
1891            let c = c.trim();
1892            if !c.is_empty() && !p.constraints.iter().any(|x| x == c) {
1893                p.constraints.push(c.to_string());
1894            }
1895        }
1896    }
1897    p
1898}
1899
1900// trace:exempt reason=internal-detail
1901fn split_tail(tail: &str) -> (String, Option<String>) {
1902    let t = tail.trim();
1903    for marker in ["where ", "throws ", " where ", " throws "] {
1904        if let Some(idx) = find_depth0_pattern(t, marker) {
1905            let before = if idx == 0 {
1906                String::new()
1907            } else {
1908                t[..idx].trim().to_string()
1909            };
1910            let after = t[idx + marker.len()..]
1911                .trim()
1912                .trim_end_matches(',')
1913                .trim()
1914                .to_string();
1915            return (before, Some(after));
1916        }
1917    }
1918    (t.to_string(), None)
1919}
1920
1921// trace:exempt reason=internal-detail
1922fn parse_return(text: &str, ret_before: Option<&str>) -> Option<String> {
1923    let t = text
1924        .trim()
1925        .trim_start_matches(':')
1926        .trim()
1927        .trim_end_matches(';')
1928        .trim()
1929        .to_string();
1930    if t.is_empty() {
1931        return ret_before.map(|s| s.to_string());
1932    }
1933    for arrow in ["->", "=>"] {
1934        if let Some(idx) = find_depth0_pattern(&t, arrow) {
1935            let mut r = t[idx + arrow.len()..]
1936                .trim()
1937                .trim_end_matches(':')
1938                .trim()
1939                .trim_end_matches(';')
1940                .trim()
1941                .to_string();
1942            // unwrap multi-parenthesized return `(A, B)`
1943            if let Some((g, _)) = take_group(&r, '(', ')') {
1944                r = g;
1945            }
1946            if !r.is_empty() {
1947                // strip leading pointer/reference markers
1948                while let Some(stripped) = r.strip_prefix('*').or_else(|| r.strip_prefix('&')) {
1949                    r = stripped.trim().to_string();
1950                }
1951                return Some(r);
1952            }
1953            return ret_before.map(|s| s.to_string());
1954        }
1955    }
1956    if t.starts_with('(') {
1957        if let Some((g, _)) = take_group(&t, '(', ')') {
1958            return Some(g);
1959        }
1960    }
1961    if let Some(rb) = ret_before {
1962        return Some(rb.to_string());
1963    }
1964    if t.chars().all(|c| c.is_whitespace() || matches!(c, ':' | ';' | ',')) {
1965        return None;
1966    }
1967    // strip leading pointer/reference markers
1968    let mut r = t;
1969    while let Some(stripped) = r.strip_prefix('*').or_else(|| r.strip_prefix('&')) {
1970        r = stripped.trim().to_string();
1971    }
1972    if r.is_empty() { None } else { Some(r) }
1973}
1974
1975// trace:exempt reason=internal-detail
1976fn parse_param(raw: &str) -> Option<SemanticParameter> {
1977    let mut s = raw.trim().to_string();
1978    if s.is_empty() {
1979        return None;
1980    }
1981    let variadic = s.contains("...") || s.starts_with('*') || s.starts_with("**");
1982
1983    for pfx in ["&mut ", "&", "*", "mut ", "ref ", "..."] {
1984        if let Some(after) = s.strip_prefix(pfx) {
1985            s = after.trim().to_string();
1986            break;
1987        }
1988    }
1989    let head = leading_word(&s).map(|(w, _)| w).unwrap_or_default();
1990    if head == "self" || head == "this" || head == "Self" {
1991        return Some(SemanticParameter {
1992            name: head,
1993            ty: None,
1994            receiver: true,
1995            default: None,
1996            variadic,
1997        });
1998    }
1999
2000    let (left, default) = match find_depth0_char(&s, '=') {
2001        Some(idx) => (s[..idx].trim().to_string(), Some(s[idx + 1..].trim().to_string())),
2002        None => (s, None),
2003    };
2004    if left.is_empty() {
2005        return None;
2006    }
2007    let (mut name, ty) = if let Some(idx) = find_depth0_char(&left, ':') {
2008        (left[..idx].trim().to_string(), Some(left[idx + 1..].trim().to_string()))
2009    } else {
2010        split_name_type(&left)
2011    };
2012    while let Some(stripped) = name.strip_prefix('&').or_else(|| name.strip_prefix('*')) {
2013        name = stripped.trim().to_string();
2014    }
2015    if let Some(stripped) = name.strip_prefix("mut ") {
2016        name = stripped.trim().to_string();
2017    }
2018    name = name.trim_end_matches('?').trim().to_string();
2019    if name.is_empty() {
2020        return None;
2021    }
2022    let receiver = name == "self" || name == "this";
2023    Some(SemanticParameter {
2024        name,
2025        ty,
2026        receiver,
2027        default,
2028        variadic,
2029    })
2030}
2031
2032// trace:exempt reason=internal-detail
2033fn split_name_type(left: &str) -> (String, Option<String>) {
2034    let words: Vec<&str> = left.split_whitespace().collect();
2035    if words.is_empty() {
2036        return (String::new(), None);
2037    }
2038    if words.len() == 1 {
2039        return (words[0].to_string(), None);
2040    }
2041    let first = words[0];
2042    let last = words[words.len() - 1];
2043    let first_is_type = is_type_word(first)
2044        || first.chars().next().map(|c| !c.is_ascii_lowercase()).unwrap_or(false)
2045        || first.contains('<')
2046        || first.contains('[')
2047        || first.contains('.')
2048        || first.ends_with("[]");
2049    let last_is_plain = is_identifier(last) && !is_type_word(last);
2050    if first_is_type && last_is_plain {
2051        (last.to_string(), Some(words[..words.len() - 1].join(" ")))
2052    } else {
2053        (first.to_string(), Some(words[1..].join(" ")))
2054    }
2055}
2056
2057// ---------------------------------------------------------------------------
2058// Visibility helpers
2059// ---------------------------------------------------------------------------
2060
2061// trace:exempt reason=internal-detail
2062fn visibility_from_modifiers(mods: &[String]) -> Option<Visibility> {
2063    if mods.iter().any(|m| m == "pub" || m == "public") {
2064        Some(Visibility::Public)
2065    } else if mods.iter().any(|m| m == "protected") {
2066        Some(Visibility::Protected)
2067    } else if mods.iter().any(|m| m == "private") {
2068        Some(Visibility::Private)
2069    } else {
2070        None
2071    }
2072}
2073
2074// trace:exempt reason=internal-detail
2075fn entry_visibility(
2076    exported: bool,
2077    parent: Option<&str>,
2078    mods: &[String],
2079    ret_before_name: Option<&str>,
2080) -> Visibility {
2081    if exported {
2082        return Visibility::Public;
2083    }
2084    if let Some(v) = visibility_from_modifiers(mods) {
2085        return v;
2086    }
2087    if parent.is_some() && ret_before_name.is_some() {
2088        return Visibility::Package;
2089    }
2090    Visibility::Private
2091}
2092
2093// ---------------------------------------------------------------------------
2094// Kind mapping
2095// ---------------------------------------------------------------------------
2096
2097// trace:exempt reason=internal-detail
2098fn map_kind(kind_str: &str, name: &str, parent: Option<&str>) -> SurfaceKind {
2099    match kind_str {
2100        "method" => {
2101            let simple = name.rsplit('.').next().unwrap_or(name);
2102            if parent.map(|p| simple == p).unwrap_or(false) || simple == "__init__" {
2103                SurfaceKind::Constructor
2104            } else {
2105                SurfaceKind::Method
2106            }
2107        }
2108        "function" => SurfaceKind::Function,
2109        "class" => SurfaceKind::Class,
2110        "interface" => SurfaceKind::Interface,
2111        "trait" => SurfaceKind::Trait,
2112        "enum" => SurfaceKind::Enum,
2113        "type" => SurfaceKind::Type,
2114        "const" => SurfaceKind::Const,
2115        "module" => SurfaceKind::Module,
2116        _ => SurfaceKind::Function,
2117    }
2118}
2119
2120// ---------------------------------------------------------------------------
2121// Miscellaneous helpers
2122// ---------------------------------------------------------------------------
2123
2124// trace:exempt reason=internal-detail
2125fn canonicalize(sig: &str) -> String {
2126    sig.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
2127}
2128
2129// trace:exempt reason=internal-detail
2130fn synthesized_signature(kind_str: &str, simple: &str) -> String {
2131    match kind_str {
2132        "class" | "interface" | "trait" | "enum" | "module" => format!("{} {}", kind_str, simple),
2133        "type" => format!("type {}", simple),
2134        _ => simple.to_string(),
2135    }
2136}
2137
2138// trace:exempt reason=internal-detail
2139fn sorted_rels(rels: Vec<&scc_core::Relationship>) -> Vec<&scc_core::Relationship> {
2140    let mut v = rels;
2141    v.sort_by(|a, b| {
2142        a.id.cmp(&b.id)
2143            .then_with(|| a.subject.cmp(&b.subject))
2144            .then_with(|| a.object.cmp(&b.object))
2145    });
2146    v
2147}
2148
2149// trace:exempt reason=internal-detail
2150fn attr_str(e: &scc_core::Entity, key: &str) -> Option<String> {
2151    e.attributes.get(key).and_then(|v| match v {
2152        serde_json::Value::String(s) => Some(s.clone()),
2153        _ => None,
2154    })
2155}
2156
2157// trace:exempt reason=internal-detail
2158fn attr_u32(e: &scc_core::Entity, key: &str) -> u32 {
2159    e.attributes
2160        .get(key)
2161        .and_then(|v| v.as_u64())
2162        .map(|n| n.min(u32::MAX as u64) as u32)
2163        .unwrap_or(0)
2164}
2165
2166// trace:exempt reason=internal-detail
2167fn fits(total_chars: usize, budget_chars: Option<usize>) -> bool {
2168    budget_chars.is_none_or(|b| total_chars <= b)
2169}
2170
2171// trace:exempt reason=internal-detail
2172fn entry_tier(e: &SurfaceEntry) -> u8 {
2173    if e.exported || e.visibility == Visibility::Public || !e.invocation_surfaces.is_empty() {
2174        0
2175    } else if e.rank.total > 0.0 {
2176        1
2177    } else {
2178        2
2179    }
2180}
2181
2182// trace:exempt reason=internal-detail
2183fn entry_order(a: &SurfaceEntry, b: &SurfaceEntry) -> std::cmp::Ordering {
2184    entry_tier(a)
2185        .cmp(&entry_tier(b))
2186        .then_with(|| b.rank.total.partial_cmp(&a.rank.total).unwrap_or(std::cmp::Ordering::Equal))
2187        .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
2188        .then_with(|| a.qualified_name.cmp(&b.qualified_name))
2189        .then_with(|| a.id.cmp(&b.id))
2190}
2191
2192// ---------------------------------------------------------------------------
2193// Renderer
2194// ---------------------------------------------------------------------------
2195
2196// trace:exempt reason=internal-detail
2197fn render_entry(e: &SurfaceEntry) -> String {
2198    render_entry_opt(e, 0, None)
2199}
2200
2201/// The structurally compressed entry block (hard-max overflow, never drops
2202/// the entry): kind + name + the first signature line only. The metadata
2203/// sections (Used by / Calls / Participates in / Contracts / Owns /
2204/// Invocation — the lines that carry annotation/modifier-rich detail) and
2205/// multi-line signature continuations / doc lines are dropped, so the
2206/// required set fits under the hard max.
2207// trace:exempt reason=internal-detail
2208fn render_entry_compressed(e: &SurfaceEntry) -> String {
2209    render_entry_opt(e, 1, None)
2210}
2211
2212/// One entry block with the pipeline's render options. `level` is the
2213/// structural compression ladder: 0 = full entry, 1 = first signature
2214/// line only, 2 = canonical abbreviated signature, 3 = symbol identity
2215/// only (kind + name, always fits any realistic hard max). Levels >= 1
2216/// drop the metadata sections and the signature continuation/doc lines.
2217/// `rank` (explain mode) appends the entry's full score decomposition
2218/// (all eight components + total + reasons) instead of a bare importance.
2219// trace:exempt reason=internal-detail
2220fn render_entry_opt(e: &SurfaceEntry, level: u8, rank: Option<&SurfaceRank>) -> String {
2221    let mut out = String::new();
2222    let name = e.qualified_name.rsplit('.').next().unwrap_or(&e.qualified_name);
2223    out.push_str(&format!("  {} {}\n\n", e.kind.as_str(), name));
2224    if (1..3).contains(&level) {
2225        // level 2 uses the canonical (whitespace-normalized) signature,
2226        // truncated to a fixed width as an abbreviation; level 1 uses the
2227        // first source line; level 3 drops the signature entirely
2228        // (symbol identity only — always fits any realistic hard max).
2229        let sig = match level {
2230            2 => e.canonical_signature.split('\n').next().unwrap_or(""),
2231            _ => e.source_signature.split('\n').next().unwrap_or(""),
2232        };
2233        if !sig.is_empty() {
2234            let sig: String = sig.chars().take(160).collect();
2235            out.push_str("    ");
2236            out.push_str(&sig);
2237            out.push('\n');
2238        }
2239    } else if level == 0 {
2240        let sig_lines: Vec<&str> = e.source_signature.split('\n').collect();
2241        for line in &sig_lines {
2242            out.push_str("    ");
2243            out.push_str(line);
2244            out.push('\n');
2245        }
2246    }
2247    if level == 0 {
2248        let sections: [(&str, &[String]); 6] = [
2249            ("Used by", &e.callers),
2250            ("Calls", &e.callees),
2251            ("Participates in", &e.flows),
2252            ("Contracts", &e.contracts),
2253            ("Owns", &e.state_authorities),
2254            ("Invocation", &e.invocation_surfaces),
2255        ];
2256        for (label, vals) in sections {
2257            if vals.is_empty() {
2258                continue;
2259            }
2260            out.push('\n');
2261            out.push_str(&format!("  {label}:\n    {}\n", vals.join(", ")));
2262        }
2263        // Exact fan-in/fan-out: the name lists above stay capped at 12;
2264        // the counts never are (audit item 3).
2265        if e.caller_count > e.callers.len() || e.callee_count > e.callees.len() {
2266            out.push('\n');
2267            if e.caller_count > e.callers.len() {
2268                out.push_str(&format!("  Called by {} symbols (showing {})\n", e.caller_count, e.callers.len()));
2269            }
2270            if e.callee_count > e.callees.len() {
2271                out.push_str(&format!("  Calls {} symbols (showing {})\n", e.callee_count, e.callees.len()));
2272            }
2273        }
2274        if let Some(imp) = &e.importance {
2275            if !imp.badges.is_empty() {
2276                out.push('\n');
2277                out.push_str(&format!("  Badges: {}\n", imp.badges.join(" \u{00B7} ")));
2278            }
2279        }
2280    }
2281    if let Some(rank) = rank {
2282        out.push('\n');
2283        out.push_str(&format!("  importance: {:.3}\n", rank.total));
2284        out.push_str(&format!("  task_ppr: {:.3}\n", rank.task_ppr));
2285        out.push_str(&format!("  global_ppr: {:.3}\n", rank.global_ppr));
2286        out.push_str(&format!("  lexical: {:.3}\n", rank.lexical));
2287        out.push_str(&format!("  semantic: {:.3}\n", rank.semantic));
2288        out.push_str(&format!("  confidence: {:.3}\n", rank.confidence));
2289        out.push_str(&format!("  criticality: {:.3}\n", rank.criticality));
2290        out.push_str(&format!("  change_risk: {:.3}\n", rank.change_risk));
2291        out.push_str(&format!("  novelty: {:.3}\n", rank.novelty));
2292        if !rank.reasons.is_empty() {
2293            out.push_str("  because:\n");
2294            for r in &rank.reasons {
2295                out.push_str(&format!("    {r}\n"));
2296            }
2297        }
2298    }
2299    out.push('\n');
2300    out
2301}
2302
2303// trace:exempt reason=internal-detail
2304fn group_header(comp: &str, sub: &str, path: &str) -> String {
2305    let mut h = String::new();
2306    h.push('\n');
2307    h.push_str(&comp.to_uppercase());
2308    h.push_str("\n\n");
2309    if sub.is_empty() {
2310        h.push_str(path);
2311    } else {
2312        h.push_str(&format!("{}  [{}]", path, sub));
2313    }
2314    h.push_str("\n\n");
2315    h
2316}
2317
2318// ---------------------------------------------------------------------------
2319// Token-level helpers
2320// ---------------------------------------------------------------------------
2321
2322// trace:exempt reason=internal-detail
2323fn is_modifier_word(w: &str) -> bool {
2324    matches!(
2325        w,
2326        "async" | "await" | "static" | "final" | "abstract" | "readonly"
2327            | "sealed" | "override" | "virtual" | "synchronized" | "native"
2328            | "extern" | "unsafe" | "inline" | "const" | "var" | "let" | "mutable"
2329            | "pub" | "public" | "private" | "protected" | "package" | "internal"
2330            | "open" | "suspend" | "operator" | "export" | "default" | "declare"
2331            | "data" | "value"
2332    )
2333}
2334
2335// trace:exempt reason=internal-detail
2336fn is_callable_keyword(w: &str) -> bool {
2337    matches!(
2338        w,
2339        "fn" | "def" | "func" | "function" | "class" | "struct" | "interface"
2340            | "trait" | "enum" | "type" | "module"
2341    )
2342}
2343
2344// trace:exempt reason=internal-detail
2345fn is_type_word(w: &str) -> bool {
2346    matches!(
2347        w,
2348        "int" | "long" | "short" | "byte" | "char" | "float" | "double"
2349            | "bool" | "boolean" | "string" | "str" | "void" | "unsigned"
2350            | "signed" | "usize" | "isize" | "u8" | "u16" | "u32" | "u64"
2351            | "u128" | "i8" | "i16" | "i32" | "i64" | "i128" | "any"
2352            | "object" | "Option" | "Result" | "Vec" | "Map" | "List" | "Set"
2353    )
2354}
2355
2356// trace:exempt reason=internal-detail
2357fn is_identifier(w: &str) -> bool {
2358    let mut chars = w.chars();
2359    match chars.next() {
2360        Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {}
2361        _ => return false,
2362    }
2363    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$' || c == '?')
2364}
2365
2366// trace:exempt reason=internal-detail
2367fn leading_word(s: &str) -> Option<(String, &str)> {
2368    let s = s.trim_start();
2369    let mut end = 0;
2370    for (i, ch) in s.char_indices() {
2371        if ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' || ch == '?' {
2372            end = i + ch.len_utf8();
2373        } else {
2374            break;
2375        }
2376    }
2377    if end == 0 {
2378        return None;
2379    }
2380    Some((s[..end].to_string(), &s[end..]))
2381}
2382
2383// trace:exempt reason=internal-detail
2384fn take_group(text: &str, open: char, close: char) -> Option<(String, &str)> {
2385    if !text.starts_with(open) {
2386        return None;
2387    }
2388    let mut depth = 0i32;
2389    let mut quote: Option<char> = None;
2390    for (i, ch) in text.char_indices() {
2391        if let Some(q) = quote {
2392            if ch == q {
2393                quote = None;
2394            }
2395            continue;
2396        }
2397        match ch {
2398            '"' | '\'' | '`' => quote = Some(ch),
2399            c if c == open => depth += 1,
2400            c if c == close => {
2401                depth -= 1;
2402                if depth == 0 {
2403                    return Some((text[1..i].to_string(), &text[i + ch.len_utf8()..]));
2404                }
2405            }
2406            _ => {}
2407        }
2408    }
2409    None
2410}
2411
2412// trace:exempt reason=internal-detail
2413fn find_matching_open(text: &str, open: char, close: char) -> Option<usize> {
2414    let mut depth = 0i32;
2415    for (i, ch) in text.char_indices().rev() {
2416        if ch == close {
2417            depth += 1;
2418        } else if ch == open {
2419            depth -= 1;
2420            if depth == 0 {
2421                return Some(i);
2422            }
2423        }
2424    }
2425    None
2426}
2427
2428// trace:exempt reason=internal-detail
2429fn split_params(rest: &str) -> Option<(String, Vec<String>, String)> {
2430    let mut depth = 0i32;
2431    let mut quote: Option<char> = None;
2432    let mut open: Option<usize> = None;
2433    for (i, ch) in rest.char_indices() {
2434        if let Some(q) = quote {
2435            if ch == q {
2436                quote = None;
2437            }
2438            continue;
2439        }
2440        match ch {
2441            '"' | '\'' | '`' => quote = Some(ch),
2442            '(' if depth == 0 => {
2443                open = Some(i);
2444                break;
2445            }
2446            ')' if depth == 0 => return None,
2447            '(' => depth += 1,
2448            ')' => depth -= 1,
2449            _ => {}
2450        }
2451    }
2452    let i0 = open?;
2453    let (inner, after) = take_group(&rest[i0..], '(', ')')?;
2454    let prefix = rest[..i0].to_string();
2455    let params = split_top(&inner, ',');
2456    Some((prefix, params, after.trim().to_string()))
2457}
2458
2459// trace:exempt reason=internal-detail
2460fn split_top(text: &str, sep: char) -> Vec<String> {
2461    let mut out: Vec<String> = Vec::new();
2462    let mut paren_depth = 0i32;
2463    let mut angle_depth = 0i32;
2464    let mut quote: Option<char> = None;
2465    let mut prev: Option<char> = None;
2466    let mut cur = String::new();
2467    for ch in text.chars() {
2468        if let Some(q) = quote {
2469            cur.push(ch);
2470            if ch == q {
2471                quote = None;
2472            }
2473            prev = Some(ch);
2474            continue;
2475        }
2476        match ch {
2477            '"' | '\'' | '`' => {
2478                quote = Some(ch);
2479                cur.push(ch);
2480            }
2481            '(' | '[' | '{' => {
2482                paren_depth += 1;
2483                cur.push(ch);
2484            }
2485            ')' | ']' | '}' => {
2486                paren_depth -= 1;
2487                cur.push(ch);
2488            }
2489            '<' if angle_depth == 0 && prev.map(|p| p.is_alphanumeric() || p == '_' || p == '>').unwrap_or(false) => {
2490                angle_depth += 1;
2491                cur.push(ch);
2492            }
2493            '>' if angle_depth > 0 && prev != Some('-') => {
2494                angle_depth -= 1;
2495                cur.push(ch);
2496            }
2497            c if c == sep && paren_depth == 0 && angle_depth == 0 => {
2498                out.push(std::mem::take(&mut cur));
2499            }
2500            _ => cur.push(ch),
2501        }
2502        prev = Some(ch);
2503    }
2504    out.push(cur);
2505    out.into_iter().map(|s| s.trim().to_string()).collect()
2506}
2507
2508// trace:exempt reason=internal-detail
2509fn find_depth0_pattern(text: &str, pat: &str) -> Option<usize> {
2510    let mut depth = 0i32;
2511    let mut quote: Option<char> = None;
2512    for (i, ch) in text.char_indices() {
2513        if let Some(q) = quote {
2514            if ch == q {
2515                quote = None;
2516            }
2517            continue;
2518        }
2519        match ch {
2520            '"' | '\'' | '`' => quote = Some(ch),
2521            '(' | '[' | '{' => depth += 1,
2522            ')' | ']' | '}' => depth -= 1,
2523            _ => {}
2524        }
2525        if depth == 0 && text[i..].starts_with(pat) {
2526            return Some(i);
2527        }
2528    }
2529    None
2530}
2531
2532// trace:exempt reason=internal-detail
2533fn find_depth0_char(text: &str, target: char) -> Option<usize> {
2534    let mut paren_depth = 0i32;
2535    let mut angle_depth = 0i32;
2536    let mut quote: Option<char> = None;
2537    let mut prev: Option<char> = None;
2538    for (i, ch) in text.char_indices() {
2539        if let Some(q) = quote {
2540            if ch == q {
2541                quote = None;
2542            }
2543            prev = Some(ch);
2544            continue;
2545        }
2546        match ch {
2547            '"' | '\'' | '`' => quote = Some(ch),
2548            '(' | '[' | '{' => paren_depth += 1,
2549            ')' | ']' | '}' => paren_depth -= 1,
2550            '<' if angle_depth == 0 && prev.map(|p| p.is_alphanumeric() || p == '_' || p == '>').unwrap_or(false) => angle_depth += 1,
2551            '>' if angle_depth > 0 && prev != Some('-') => angle_depth -= 1,
2552            c if c == target && paren_depth == 0 && angle_depth == 0 => return Some(i),
2553            _ => {}
2554        }
2555        prev = Some(ch);
2556    }
2557    None
2558}
2559
2560// trace:exempt reason=internal-detail
2561fn receiver_owner(group: &str) -> Option<String> {
2562    let inner = group.trim();
2563    if inner.is_empty() {
2564        return None;
2565    }
2566    let words: Vec<&str> = inner.split_whitespace().collect();
2567    if words.is_empty() {
2568        return None;
2569    }
2570    let t = if words.len() <= 1 {
2571        words[0]
2572    } else {
2573        words[words.len() - 1]
2574    };
2575    let t = t.trim_start_matches('*').trim_start_matches('&');
2576    let seg = t.rsplit('.').next().unwrap_or(t);
2577    let seg = seg.trim().trim_start_matches('*');
2578    if seg.is_empty() {
2579        None
2580    } else {
2581        Some(seg.to_string())
2582    }
2583}
2584
2585// ---------------------------------------------------------------------------
2586// Tests
2587// ---------------------------------------------------------------------------
2588
2589#[cfg(test)]
2590mod tests {
2591    use super::*;
2592    use scc_core::{
2593        entity_id, relationship_id, symbol_id, ContextLedger, Entity, Flow, FlowKind,
2594        FlowStep, Relationship,
2595    };
2596    use scc_store::Store;
2597
2598// trace:exempt reason=internal-detail
2599    fn fixture_store() -> (tempfile::TempDir, Store) {
2600        let dir = tempfile::TempDir::new().unwrap();
2601        let root = dir.path().join("repo");
2602        std::fs::create_dir_all(&root).unwrap();
2603        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2604        let repo = store.repo_id.clone();
2605        let path = "api/app.py";
2606
2607        let fid = entity_id(&repo, kinds::FILE, path);
2608        store.insert_entity(&Entity::new(fid.clone(), kinds::FILE, path), &[path.to_string()]).unwrap();
2609
2610        let comp_id = entity_id(&repo, kinds::COMPONENT, "api");
2611        store.replace_components(&[Entity::new(comp_id.clone(), kinds::COMPONENT, "api")]).unwrap();
2612        store.insert_relationship(
2613            &Relationship::new(relationship_id(1), comp_id.clone(), predicates::CONTAINS, fid.clone(), Provenance::Extracted),
2614            path,
2615        ).unwrap();
2616
2617        let svc_id = entity_id(&repo, kinds::SERVICE, "core");
2618        store.insert_entity(&Entity::new(svc_id.clone(), kinds::SERVICE, "core"), &[path.to_string()]).unwrap();
2619        store.insert_relationship(
2620            &Relationship::new(relationship_id(2), svc_id, predicates::CONTAINS, comp_id.clone(), Provenance::Extracted),
2621            path,
2622        ).unwrap();
2623
2624        let mut rid: u64 = 3;
2625        let mut sym = |name: &str, kind: &str, sig: Option<&str>, exported: bool, parent: Option<&str>| -> String {
2626            let id = symbol_id(&repo, path, name);
2627            let mut e = Entity::new(id.clone(), kinds::SYMBOL, name);
2628            e.attr("kind", serde_json::json!(kind));
2629            e.attr("file", serde_json::json!(path));
2630            if let Some(s) = sig { e.attr("signature", serde_json::json!(s)); }
2631            e.attr("exported", serde_json::json!(exported));
2632            e.attr("start_line", serde_json::json!(1u32));
2633            e.attr("end_line", serde_json::json!(10u32));
2634            if let Some(p) = parent { e.attr("parent", serde_json::json!(p)); }
2635            store.insert_entity(&e, &[path.to_string()]).unwrap();
2636            rid += 1;
2637            store.insert_relationship(
2638                &Relationship::new(relationship_id(rid), fid.clone(), predicates::CONTAINS, id.clone(), Provenance::Extracted),
2639                path,
2640            ).unwrap();
2641            id
2642        };
2643
2644        let _ = sym("UserService", "class", None, true, None);
2645        let _ = sym("UserService.UserService", "method", Some("public UserService(String name)"), false, Some("UserService"));
2646        let get_id = sym("UserService.get", "method", Some("public User get(String id) throws NotFound"), false, Some("UserService"));
2647        let _ = sym("UserService.hash", "method", Some("String hash()"), false, Some("UserService"));
2648        let update_id = sym("UserService.update", "method", Some("async fn update(&mut self, patch: Json) -> bool"), false, Some("UserService"));
2649        let create_id = sym("create_user", "function", Some("def create_user(name: str, age: int = 0) -> User"), true, None);
2650        let db_id = sym("db", "function", Some("func db() *DB"), false, None);
2651
2652        // annotation RestController ANNOTATES get
2653        let ann_id = entity_id(&repo, kinds::ANNOTATION, "RestController");
2654        store.insert_entity(&Entity::new(ann_id.clone(), kinds::ANNOTATION, "RestController"), &[path.to_string()]).unwrap();
2655        rid += 1;
2656        store.insert_relationship(
2657            &Relationship::new(relationship_id(rid), ann_id, predicates::ANNOTATES, get_id.clone(), Provenance::Extracted),
2658            path,
2659        ).unwrap();
2660
2661        // route: GET /api/users -> create_user
2662        let route_id = entity_id(&repo, kinds::ROUTE, "GET /api/users");
2663        let mut re = Entity::new(route_id.clone(), kinds::ROUTE, "GET /api/users");
2664        re.attr("method", serde_json::json!("GET"));
2665        re.attr("path", serde_json::json!("/api/users"));
2666        re.attr("handler", serde_json::json!(create_id.clone()));
2667        store.insert_entity(&re, &[path.to_string()]).unwrap();
2668
2669        // CALLS
2670        rid += 1;
2671        store.insert_relationship(
2672            &Relationship::new(relationship_id(rid), create_id.clone(), predicates::CALLS, get_id.clone(), Provenance::Extracted),
2673            path,
2674        ).unwrap();
2675        rid += 1;
2676        store.insert_relationship(
2677            &Relationship::new(relationship_id(rid), db_id.clone(), predicates::CALLS, create_id.clone(), Provenance::Resolved),
2678            path,
2679        ).unwrap();
2680
2681        // STATE: db OWNS sessions
2682        let state_id = entity_id(&repo, kinds::STATE, "sessions");
2683        store.insert_entity(&Entity::new(state_id.clone(), kinds::STATE, "sessions"), &[path.to_string()]).unwrap();
2684        rid += 1;
2685        store.insert_relationship(
2686            &Relationship::new(relationship_id(rid), db_id.clone(), predicates::OWNS, state_id, Provenance::Extracted),
2687            path,
2688        ).unwrap();
2689
2690        // REACTIVE: update OWNS cursor
2691        let rx_id = entity_id(&repo, kinds::REACTIVE, "cursor");
2692        store.insert_entity(&Entity::new(rx_id.clone(), kinds::REACTIVE, "cursor"), &[path.to_string()]).unwrap();
2693        rid += 1;
2694        store.insert_relationship(
2695            &Relationship::new(relationship_id(rid), update_id.clone(), predicates::OWNS, rx_id, Provenance::Extracted),
2696            path,
2697        ).unwrap();
2698
2699        // Flow signup
2700        store.replace_flows(&[Flow {
2701            id: entity_id(&repo, kinds::FLOW, "signup"),
2702            kind: FlowKind::Workflow,
2703            name: "signup".into(),
2704            trigger: Some("http".into()),
2705            steps: vec![
2706                FlowStep {
2707                    id: "step:1".into(),
2708                    order: 1,
2709                    actor: get_id.clone(),
2710                    operation: "load user".into(),
2711                    condition: None,
2712                    r#async: None,
2713                    timeout_ms: None,
2714                    retry_policy: None,
2715                    failure_outcome: None,
2716                    provenance: Some(Provenance::Extracted),
2717                    evidence: vec![],
2718                },
2719                FlowStep {
2720                    id: "step:2".into(),
2721                    order: 2,
2722                    actor: "db".into(),
2723                    operation: "persist".into(),
2724                    condition: None,
2725                    r#async: None,
2726                    timeout_ms: None,
2727                    retry_policy: None,
2728                    failure_outcome: None,
2729                    provenance: Some(Provenance::Extracted),
2730                    evidence: vec![],
2731                },
2732            ],
2733            attributes: std::collections::BTreeMap::new(),
2734        }]).unwrap();
2735
2736        let _ = (get_id, create_id, db_id, update_id);
2737        (dir, store)
2738    }
2739
2740// trace:exempt reason=internal-detail
2741    fn entry<'a>(map: &'a SystemSurfaceMap, qn: &str) -> &'a SurfaceEntry {
2742        map.entries.iter().find(|e| e.qualified_name == qn).expect("entry not found")
2743    }
2744
2745// trace:exempt reason=internal-detail
2746    fn make_ctx<'a>(store: &'a Store) -> ContextCompiler<'a> {
2747        let graph = Box::leak(Box::new(scc_graph::RealityGraph::load(store).unwrap()));
2748        ContextCompiler::new(store, graph, crate::ContextSettings::default(), Vec::new())
2749    }
2750
2751    /// 40 private functions: 20 lexically matched by the goal term
2752    /// (`zeta_*` in `a/mod.py`) + 20 unmatched (`alpha_*` in `b/mod.py`),
2753    /// so MMR/path diversity and lexical vs PPR blending are observable.
2754// trace:exempt reason=internal-detail
2755    fn zeta_alpha_fixture() -> (tempfile::TempDir, Store) {
2756        let dir = tempfile::TempDir::new().unwrap();
2757        let root = dir.path().join("repo");
2758        std::fs::create_dir_all(&root).unwrap();
2759        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2760        let repo = store.repo_id.clone();
2761        for (path, prefix) in [("a/mod.py", "zeta"), ("b/mod.py", "alpha")] {
2762            for i in 0..20 {
2763                let name = format!("{prefix}_{i:02}");
2764                let id = symbol_id(&repo, path, &name);
2765                let mut e = Entity::new(id.clone(), kinds::SYMBOL, name);
2766                e.attr("kind", serde_json::json!("function"));
2767                e.attr("file", serde_json::json!(path));
2768                e.attr("signature", serde_json::json!("def f(x): ..."));
2769                e.attr("exported", serde_json::json!(false));
2770                e.attr("start_line", serde_json::json!(1u32));
2771                e.attr("end_line", serde_json::json!(10u32));
2772                store.insert_entity(&e, &[path.to_string()]).unwrap();
2773            }
2774        }
2775        (dir, store)
2776    }
2777
2778    /// One required entry (exported → public-api invocation surface) with a
2779    /// 400-line declaration header — its full render alone exceeds any
2780    /// realistic hard max, so compression is observable.
2781// trace:exempt reason=internal-detail
2782    fn huge_signature_fixture() -> (tempfile::TempDir, Store) {
2783        let dir = tempfile::TempDir::new().unwrap();
2784        let root = dir.path().join("repo");
2785        std::fs::create_dir_all(&root).unwrap();
2786        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
2787        let repo = store.repo_id.clone();
2788        let path = "api/app.py";
2789        let id = symbol_id(&repo, path, "big_fn");
2790        let mut e = Entity::new(id.clone(), kinds::SYMBOL, "big_fn".to_string());
2791        e.attr("kind", serde_json::json!("function"));
2792        e.attr("file", serde_json::json!(path));
2793        let mut decl = String::from("def big_fn(\n");
2794        for i in 0..400 {
2795            decl.push_str(&format!("    arg_{i:03}: str,\n"));
2796        }
2797        decl.push_str(") -> None:\n");
2798        e.attr("decl_header", serde_json::json!(decl));
2799        e.attr("exported", serde_json::json!(true));
2800        e.attr("start_line", serde_json::json!(1u32));
2801        e.attr("end_line", serde_json::json!(10u32));
2802        // A concrete invocation surface (http route handler) makes this a
2803        // CRITICAL coverage entry — the compression path under hard-max
2804        // overflow is what the test exercises. A plain public export is
2805        // not critical by design (the ranker decides its fate).
2806        e.attr("entrypoints", serde_json::json!(["http: POST /big"]));
2807        store.insert_entity(&e, &[path.to_string()]).unwrap();
2808        (dir, store)
2809    }
2810
2811    #[test]
2812// trace:exempt reason=internal-detail
2813    fn compiles_surface_map_with_attribution() {
2814        let (_dir, store) = fixture_store();
2815        let ctx = make_ctx(&store);
2816        let map = compile_surface_map(&ctx);
2817
2818        assert_eq!(map.entries.len(), 7);
2819        assert_eq!(map.repository, "repo");
2820        assert_eq!(map.revision, "not-indexed");
2821        assert!(map.token_count > 0);
2822
2823        let cls = entry(&map, "UserService");
2824        assert_eq!(cls.kind, SurfaceKind::Class);
2825        assert_eq!(cls.visibility, Visibility::Public);
2826        assert!(cls.exported);
2827        assert_eq!(cls.source_signature, "class UserService");
2828        assert_eq!(cls.range.path, "api/app.py");
2829        assert_eq!(cls.component.as_deref(), Some("api"));
2830        assert_eq!(cls.subsystem.as_deref(), Some("core"));
2831
2832        let ctor = entry(&map, "UserService.UserService");
2833        assert_eq!(ctor.kind, SurfaceKind::Constructor);
2834        assert_eq!(ctor.visibility, Visibility::Public);
2835        assert_eq!(ctor.semantic_signature.parameters[0].name, "name");
2836        assert_eq!(ctor.semantic_signature.parameters[0].ty.as_deref(), Some("String"));
2837
2838        let get = entry(&map, "UserService.get");
2839        assert_eq!(get.kind, SurfaceKind::Method);
2840        assert_eq!(get.visibility, Visibility::Public);
2841        assert_eq!(get.annotations, vec!["RestController"]);
2842        assert_eq!(get.flows, vec!["signup"]);
2843        assert_eq!(get.callers, vec!["create_user"]);
2844        assert!(get.callees.is_empty());
2845        let gs = &get.semantic_signature;
2846        assert_eq!(gs.parameters[0].name, "id");
2847        assert_eq!(gs.parameters[0].ty.as_deref(), Some("String"));
2848        assert!(!gs.parameters[0].receiver);
2849        assert_eq!(gs.returns.as_deref(), Some("User"));
2850        assert!(gs.constraints.iter().any(|c| c == "NotFound"));
2851        assert_eq!(get.provenance, Provenance::Extracted);
2852        assert_eq!(get.confidence, 0.85);
2853
2854        let hash = entry(&map, "UserService.hash");
2855        assert_eq!(hash.visibility, Visibility::Package);
2856
2857        let update = entry(&map, "UserService.update");
2858        assert_eq!(update.visibility, Visibility::Private);
2859        assert_eq!(update.modifiers, vec!["async"]);
2860        assert_eq!(update.state_authorities, vec!["cursor"]);
2861        let us = &update.semantic_signature;
2862        assert!(us.async_);
2863        assert!(us.parameters[0].receiver);
2864        assert_eq!(us.parameters[1].name, "patch");
2865        assert_eq!(us.returns.as_deref(), Some("bool"));
2866
2867        let create = entry(&map, "create_user");
2868        assert_eq!(create.kind, SurfaceKind::Function);
2869        assert_eq!(create.visibility, Visibility::Public);
2870        assert!(create.exported);
2871        assert_eq!(create.canonical_signature, "def create_user(name: str, age: int = 0) -> user");
2872        assert_eq!(create.contracts, vec!["http: GET /api/users"]);
2873        assert_eq!(create.callees, vec!["UserService.get"]);
2874        assert_eq!(create.invocation_surfaces, vec![
2875            "http: GET /api/users",
2876            "public_api: export:create_user (function)",
2877        ]);
2878        assert_eq!(create.semantic_signature.parameters[1].name, "age");
2879        assert_eq!(create.semantic_signature.parameters[1].default.as_deref(), Some("0"));
2880        assert_eq!(create.semantic_signature.returns.as_deref(), Some("User"));
2881
2882        let db = entry(&map, "db");
2883        assert_eq!(db.visibility, Visibility::Private);
2884        assert_eq!(db.state_authorities, vec!["sessions"]);
2885        assert_eq!(db.provenance, Provenance::Resolved);
2886        assert_eq!(db.confidence, 1.0);
2887        assert_eq!(db.semantic_signature.returns.as_deref(), Some("DB"));
2888        assert_eq!(db.flows, vec!["signup"]);
2889    }
2890
2891    #[test]
2892// trace:exempt reason=internal-detail
2893    fn renderer_groups_and_budget_cuts() {
2894        let (_dir, store) = fixture_store();
2895        let ctx = make_ctx(&store);
2896        let map = compile_surface_map(&ctx);
2897
2898        let full = render_surface_map(&map, None);
2899        assert!(full.starts_with("SCC SYSTEM SURFACE MAP\n\n"));
2900        assert!(full.contains("API\n\napi/app.py  [core]"));
2901        assert!(full.contains("  class UserService\n"));
2902        assert!(full.contains("  function create_user\n"));
2903        assert!(full.contains("  constructor UserService\n"));
2904        assert!(full.contains("Used by:"));
2905        assert!(full.contains("http: GET /api/users"));
2906        assert!(!full.contains("OMITTED"));
2907        assert_eq!(map.token_count, estimate_tokens(&full));
2908
2909        let tiny = render_surface_map(&map, Some(1));
2910        assert!(tiny.contains("OMITTED (token budget exceeded):"));
2911        assert!(tiny.contains("3 lower-ranked method definitions"));
2912        assert!(tiny.contains("1 lower-ranked class definitions"));
2913        assert!(tiny.contains("1 lower-ranked constructor definitions"));
2914        assert!(tiny.contains("2 lower-ranked function definitions"));
2915        assert!(!tiny.contains("  class UserService\n"));
2916    }
2917
2918    #[test]
2919// trace:exempt reason=internal-detail
2920    fn semantic_parser_never_panics() {
2921        for sig in &["", "   ", "(", ")", "()", "=>", "->", "(((", "fn", "public", "= 42"] {
2922            let s = parse_signature(sig, "fallback", None);
2923            assert_eq!(s.name, "fallback");
2924        }
2925    }
2926
2927    #[test]
2928// trace:exempt reason=internal-detail
2929    fn semantic_parser_language_tolerant() {
2930        // Rust with generics, where clause, receiver
2931        let s = parse_signature(
2932            "pub async fn render<T: Bound>(&self, x: T) -> String where T: Clone + Send",
2933            "render",
2934            Some("Widget"),
2935        );
2936        assert_eq!(s.name, "render");
2937        assert!(s.async_);
2938        assert!(s.generic_parameters.contains(&"T".to_string()));
2939        assert!(s.constraints.contains(&"T: Bound".to_string()));
2940        assert!(s.constraints.contains(&"T: Clone + Send".to_string()));
2941        assert_eq!(s.visibility, Some(Visibility::Public));
2942        assert_eq!(s.returns.as_deref(), Some("String"));
2943        assert!(s.parameters[0].receiver);
2944        assert_eq!(s.parameters[1].ty.as_deref(), Some("T"));
2945        assert_eq!(s.owner.as_deref(), Some("Widget"));
2946
2947        // Go receiver + multi-return
2948        let g = parse_signature(
2949            "func (s *Store) Get(ctx context.Context) (User, error)",
2950            "Get",
2951            Some("Store"),
2952        );
2953        assert_eq!(g.name, "Get");
2954        assert_eq!(g.owner.as_deref(), Some("Store"));
2955        assert_eq!(g.parameters[0].ty.as_deref(), Some("context.Context"));
2956        assert_eq!(g.returns.as_deref(), Some("User, error"));
2957    }
2958
2959    // ---- production selection pipeline ----
2960
2961    #[test]
2962// trace:exempt reason=internal-detail
2963    fn global_render_selects_within_budget_and_reports_omissions() {
2964        let (_dir, store) = fixture_store();
2965        let ctx = make_ctx(&store);
2966        let map = compile_surface_map(&ctx);
2967        let n = map.entries.len();
2968        assert!(n >= 5);
2969
2970        // Huge budget: everything renders; nothing omitted.
2971        let big = select_and_render_global(&ctx, 100_000);
2972        assert_eq!(big.rendered_ids.len(), n);
2973        assert!(big.omitted_ids.is_empty());
2974        assert!(big.omissions.is_empty());
2975        assert!(big.text.starts_with("SCC SYSTEM SURFACE MAP"));
2976        assert!(big.token_count > 0);
2977
2978        // Tiny budget: required entries survive; the rest are honestly
2979        // omitted with per-kind summaries.
2980        let tiny = select_and_render_global(&ctx, 1);
2981        assert!(
2982            !tiny.rendered_ids.is_empty(),
2983            "required (invocation-surface) entries must survive a tiny budget"
2984        );
2985        for id in &tiny.rendered_ids {
2986            assert!(map.entries.iter().any(|e| &e.id == id), "{id} must be a candidate");
2987        }
2988        // rendered + omitted == all candidates, disjoint
2989        assert_eq!(tiny.rendered_ids.len() + tiny.omitted_ids.len(), n);
2990        for id in &tiny.omitted_ids {
2991            assert!(!tiny.rendered_ids.contains(id), "{id} both rendered and omitted");
2992        }
2993        let omitted_total: usize = tiny.omissions.iter().map(|o| o.count).sum();
2994        assert_eq!(omitted_total, tiny.omitted_ids.len());
2995
2996        // Deterministic: same input → byte-identical text and ids.
2997        let tiny2 = select_and_render_global(&ctx, 1);
2998        assert_eq!(tiny2.text, tiny.text);
2999        assert_eq!(tiny2.rendered_ids, tiny.rendered_ids);
3000        assert_eq!(tiny2.omitted_ids, tiny.omitted_ids);
3001    }
3002
3003    #[test]
3004// trace:exempt reason=internal-detail
3005    fn task_render_skips_visible_unchanged_entries() {
3006        let (_dir, store) = fixture_store();
3007        let ctx = make_ctx(&store);
3008        let map = compile_surface_map(&ctx);
3009        let n = map.entries.len();
3010
3011        let create = entry(&map, "create_user");
3012        let mut visible = ContextLedger::default();
3013        visible.visible_symbols.insert(create.symbol_id.clone());
3014
3015        let out = select_and_render_task(&ctx, "create user", 100_000, &visible);
3016        // The already-visible-and-unchanged entry is not re-injected.
3017        assert!(
3018            !out.rendered_ids.contains(&create.id),
3019            "visible unchanged entries must not be re-injected"
3020        );
3021        // Everything else (novel) renders under a huge budget.
3022        assert_eq!(out.rendered_ids.len(), n - 1);
3023        assert_eq!(out.omitted_ids.len(), 1);
3024        assert!(out.omitted_ids.contains(&create.id));
3025        assert!(out.text.starts_with("SCC SYSTEM SURFACE MAP"));
3026    }
3027
3028    #[test]
3029// trace:exempt reason=internal-detail
3030    fn task_render_never_omits_required_even_at_zero_budget() {
3031        let (_dir, store) = fixture_store();
3032        let ctx = make_ctx(&store);
3033        let out = select_and_render_task(&ctx, "user", 0, &ContextLedger::default());
3034        // required (invocation-surface) entries survive a zero budget;
3035        // the rest are omitted honestly.
3036        assert!(!out.rendered_ids.is_empty());
3037        assert!(!out.omissions.is_empty() || out.omitted_ids.is_empty());
3038        let rendered: BTreeSet<String> = out.rendered_ids.iter().cloned().collect();
3039        for id in &out.omitted_ids {
3040            assert!(!rendered.contains(id));
3041        }
3042    }
3043
3044    // ---- Wave 15.1: the one authoritative surface service ----
3045
3046    #[test]
3047// trace:v1 id=impl.scc.surface.file-importance-boosts-manifest-symbols-without-injecting-text work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
3048    fn file_importance_boosts_manifest_symbols_without_injecting_text() {
3049        // §41: a symbol in a manifest/bootstrap file gets a criticality
3050        // bump (importance evidence) — and the file's TEXT never enters
3051        // the surface (we only score the path).
3052        assert_eq!(file_importance("package.json"), 1.0);
3053        assert_eq!(file_importance("Dockerfile"), 1.0);
3054        assert_eq!(file_importance(".github/workflows/ci.yml"), 1.0);
3055        assert_eq!(file_importance("src/main.rs"), 1.0);
3056        assert_eq!(file_importance("src/util.rs"), 0.0);
3057        assert_eq!(file_importance("src/main.py"), 1.0);
3058        assert_eq!(file_importance("Makefile"), 1.0);
3059        // non-important file stays neutral
3060        assert_eq!(file_importance("services/transcripts.py"), 0.0);
3061    }
3062
3063    #[test]
3064// trace:v1 id=impl.crates-scc-context-src-surface.build-surface-global-matches-historical-pipeline work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
3065    fn build_surface_global_matches_historical_pipeline() {
3066        let (_dir, store) = fixture_store();
3067        let ctx = make_ctx(&store);
3068        let map = compile_surface_map(&ctx);
3069        let n = map.entries.len();
3070        assert!(n >= 5);
3071
3072        let req = |budget: usize| SurfaceRequest {
3073            mode: SurfaceMode::Global,
3074            budget,
3075            explain: false,
3076            policy: SurfacePolicy::defaults(budget),
3077                    semantic: None,
3078        };
3079
3080        // Huge budget: the service renders exactly the historical global
3081        // pipeline output — every candidate, byte-identical to the full
3082        // map render (the old select_and_render_global invariant).
3083        let big = build_surface(&ctx, req(100_000));
3084        let legacy = select_and_render_global(&ctx, 100_000);
3085        assert_eq!(big.text, legacy.text);
3086        assert_eq!(big.rendered_ids, legacy.rendered_ids);
3087        assert_eq!(big.omitted_ids, legacy.omitted_ids);
3088        // Selection parity with the full map: every candidate renders under
3089        // a huge budget (omissions empty). Wave 15.2 populates per-entry
3090        // SurfaceRanks, so the selected render orders each group by
3091        // importance while the plain map render keeps the canonical
3092        // kind/name order — both carry every entry (same id set).
3093        let mut big_ids = big.rendered_ids.clone();
3094        big_ids.sort();
3095        let mut map_ids: Vec<String> = map.entries.iter().map(|e| e.id.clone()).collect();
3096        map_ids.sort();
3097        assert_eq!(big_ids, map_ids, "the selected subset must cover every candidate");
3098        assert_eq!(big.rendered_ids.len(), n);
3099        assert!(big.omitted_ids.is_empty());
3100
3101        // Tiny budget: required coverage survives; byte-identical across
3102        // runs (deterministic).
3103        let tiny = build_surface(&ctx, req(1));
3104        assert!(!tiny.rendered_ids.is_empty());
3105        assert_eq!(tiny.rendered_ids.len() + tiny.omitted_ids.len(), n);
3106        let tiny2 = build_surface(&ctx, req(1));
3107        assert_eq!(tiny.text, tiny2.text);
3108        assert_eq!(tiny.rendered_ids, tiny2.rendered_ids);
3109    }
3110
3111    #[test]
3112// trace:exempt reason=internal-detail
3113    fn build_surface_task_applies_novelty_suppression() {
3114        let (_dir, store) = fixture_store();
3115        let ctx = make_ctx(&store);
3116        let map = compile_surface_map(&ctx);
3117        let create = entry(&map, "create_user");
3118
3119        let mut visible = ContextLedger::default();
3120        visible.visible_symbols.insert(create.symbol_id.clone());
3121        let out = build_surface(
3122            &ctx,
3123            SurfaceRequest {
3124                mode: SurfaceMode::Task {
3125                    goal: "create user",
3126                    visible: Some(&visible),
3127                },
3128                budget: 100_000,
3129                explain: false,
3130                policy: SurfacePolicy::defaults(100_000),
3131                        semantic: None,
3132        },
3133        );
3134        // The already-visible-and-unchanged entry is not re-injected; the
3135        // rest render (huge budget); omitted ids are honest.
3136        assert!(!out.rendered_ids.contains(&create.id));
3137        assert_eq!(out.rendered_ids.len(), map.entries.len() - 1);
3138        assert!(out.omitted_ids.contains(&create.id));
3139        // Routing parity with the historical task entry point.
3140        let legacy = select_and_render_task(&ctx, "create user", 100_000, &visible);
3141        assert_eq!(out.text, legacy.text);
3142        assert_eq!(out.rendered_ids, legacy.rendered_ids);
3143
3144        // No ledger → the full task-personalized map (nothing suppressed).
3145        let full = build_surface(
3146            &ctx,
3147            SurfaceRequest {
3148                mode: SurfaceMode::Task {
3149                    goal: "create user",
3150                    visible: None,
3151                },
3152                budget: 100_000,
3153                explain: false,
3154                policy: SurfacePolicy::defaults(100_000),
3155                        semantic: None,
3156        },
3157        );
3158        assert_eq!(full.rendered_ids.len(), map.entries.len());
3159    }
3160
3161    #[test]
3162// trace:exempt reason=internal-detail
3163    fn token_aware_quotas_cap_the_dominant_kind() {
3164        // 1000 private functions: 900 plain "core" + 100 state owners. The
3165        // naive value/token pick fills the budget with the dominant kind;
3166        // token-aware quotas cap it at its share of the available TOKENS
3167        // and let the under-represented state kind in.
3168        let dir = tempfile::TempDir::new().unwrap();
3169        let root = dir.path().join("repo");
3170        std::fs::create_dir_all(&root).unwrap();
3171        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
3172        let repo = store.repo_id.clone();
3173        let path = "api/app.py";
3174        let mut rel_id: u64 = 10_000;
3175        for i in 0..900 {
3176            let name = format!("core_fn_{i:04}");
3177            let id = symbol_id(&repo, path, &name);
3178            let mut e = Entity::new(id.clone(), kinds::SYMBOL, name);
3179            e.attr("kind", serde_json::json!("function"));
3180            e.attr("file", serde_json::json!(path));
3181            e.attr("signature", serde_json::json!("def f(x): ..."));
3182            e.attr("exported", serde_json::json!(false));
3183            e.attr("start_line", serde_json::json!(1u32));
3184            e.attr("end_line", serde_json::json!(10u32));
3185            store.insert_entity(&e, &[path.to_string()]).unwrap();
3186        }
3187        for i in 0..100 {
3188            let name = format!("state_fn_{i:04}");
3189            let id = symbol_id(&repo, path, &name);
3190            let mut e = Entity::new(id.clone(), kinds::SYMBOL, name);
3191            e.attr("kind", serde_json::json!("function"));
3192            e.attr("file", serde_json::json!(path));
3193            e.attr("signature", serde_json::json!("def f(x): ..."));
3194            e.attr("exported", serde_json::json!(false));
3195            e.attr("start_line", serde_json::json!(1u32));
3196            e.attr("end_line", serde_json::json!(10u32));
3197            store.insert_entity(&e, &[path.to_string()]).unwrap();
3198            let state_id = entity_id(&repo, kinds::STATE, &format!("st{i:04}"));
3199            store
3200                .insert_entity(
3201                    &Entity::new(state_id.clone(), kinds::STATE, format!("st{i:04}")),
3202                    &[path.to_string()],
3203                )
3204                .unwrap();
3205            rel_id += 1;
3206            store
3207                .insert_relationship(
3208                    &Relationship::new(
3209                        relationship_id(rel_id),
3210                        id,
3211                        predicates::OWNS,
3212                        state_id,
3213                        Provenance::Extracted,
3214                    ),
3215                    path,
3216                )
3217                .unwrap();
3218        }
3219        let ctx = make_ctx(&store);
3220        let map = compile_surface_map(&ctx);
3221        assert_eq!(map.entries.len(), 1000);
3222
3223        let budget = 2000usize;
3224        let hard_max = SurfacePolicy::defaults(budget).hard_max;
3225        let req = |quotas: bool| SurfaceRequest {
3226            mode: SurfaceMode::Global,
3227            budget,
3228            explain: false,
3229            policy: SurfacePolicy {
3230                quotas,
3231                mmr: false,
3232                coverage: true,
3233                hard_max,
3234            },
3235                    semantic: None,
3236        };
3237        let naive = build_surface(&ctx, req(false));
3238        let balanced = build_surface(&ctx, req(true));
3239        let count = |r: &scc_core::SurfaceRenderResult, prefix: &str| {
3240            r.rendered_ids.iter().filter(|id| id.contains(prefix)).count()
3241        };
3242        let naive_core = count(&naive, "core_fn_");
3243        let bal_core = count(&balanced, "core_fn_");
3244        let bal_state = count(&balanced, "state_fn_");
3245        assert!(
3246            bal_core < naive_core,
3247            "quotas must cut the dominant kind: {bal_core} !< {naive_core}"
3248        );
3249        assert!(bal_state > 0, "quotas must admit the under-represented kind");
3250    }
3251
3252    #[test]
3253// trace:exempt reason=internal-detail
3254    fn hard_max_soft_overflow_renders_but_required_overflow_compresses() {
3255        // Soft overflow: required entries exceed the budget but stay under
3256        // the hard max → they render in FULL (metadata sections intact,
3257        // entries never dropped).
3258        let (_dir, store) = fixture_store();
3259        let ctx = make_ctx(&store);
3260        let soft = build_surface(
3261            &ctx,
3262            SurfaceRequest {
3263                mode: SurfaceMode::Global,
3264                budget: 5,
3265                explain: false,
3266                policy: SurfacePolicy::defaults(5), // hard_max = 505
3267                        semantic: None,
3268        },
3269        );
3270        assert!(!soft.rendered_ids.is_empty());
3271        assert!(
3272            soft.text.contains("Used by:"),
3273            "under the hard max the full entry blocks must render"
3274        );
3275        assert!(soft.token_count > 5, "required may exceed the soft budget");
3276        assert!(soft.token_count <= 505, "required never exceeds the hard max");
3277
3278        // Hard overflow: the required entry alone exceeds the hard max →
3279        // structurally compressed: the entry stays, metadata/annotation
3280        // lines are dropped.
3281        let (_dir2, store2) = huge_signature_fixture();
3282        let ctx2 = make_ctx(&store2);
3283        let hard = build_surface(
3284            &ctx2,
3285            SurfaceRequest {
3286                mode: SurfaceMode::Global,
3287                budget: 100,
3288                explain: false,
3289                policy: SurfacePolicy::defaults(100), // hard_max = 600
3290                        semantic: None,
3291        },
3292        );
3293        assert_eq!(hard.rendered_ids.len(), 1, "the required entry is never dropped");
3294        assert!(
3295            hard.text.contains("function big_fn"),
3296            "the compressed entry keeps its identity"
3297        );
3298        assert!(!hard.text.contains("Used by:"), "metadata sections are dropped");
3299        assert!(hard.token_count <= 600, "the compressed render fits the hard max");
3300    }
3301
3302    #[test]
3303// trace:exempt reason=internal-detail
3304    fn staged_toggles_change_output_deterministically() {
3305        let (_dir, store) = zeta_alpha_fixture();
3306        let ctx = make_ctx(&store);
3307
3308        // Tight budget: MMR on vs off changes the selection.
3309        let budget = 80usize;
3310        let hard_max = SurfacePolicy::defaults(budget).hard_max;
3311        let req = |_stages: &SurfacePipelineStages| SurfaceRequest {
3312            mode: SurfaceMode::Task {
3313                goal: "zeta",
3314                visible: None,
3315            },
3316            budget,
3317            explain: false,
3318            policy: SurfacePolicy {
3319                quotas: false,
3320                mmr: true,
3321                coverage: true,
3322                hard_max,
3323            },
3324                    semantic: None,
3325        };
3326        let render =
3327            |stages: &SurfacePipelineStages| build_surface_staged(&ctx, req(stages), stages);
3328        let has_b = |r: &scc_core::SurfaceRenderResult| {
3329            r.rendered_ids.iter().any(|id| id.contains("b/mod.py"))
3330        };
3331
3332        let full = render(&SurfacePipelineStages::default());
3333        // Determinism: identical stages → byte-identical output.
3334        let full2 = render(&SurfacePipelineStages::default());
3335        assert_eq!(full.text, full2.text);
3336        assert_eq!(full.rendered_ids, full2.rendered_ids);
3337
3338        // MMR off: the rank-order cut stays on the first path. MMR on:
3339        // diversity pulls the second path in.
3340        let no_mmr_stages = SurfacePipelineStages {
3341            mmr: false,
3342            ..SurfacePipelineStages::default()
3343        };
3344        let no_mmr = render(&no_mmr_stages);
3345        assert_ne!(full.text, no_mmr.text);
3346        assert!(has_b(&full), "MMR must diversify across paths");
3347        assert!(!has_b(&no_mmr), "rank-order cut must stay on the first path");
3348
3349        // Lexical stage off (skip PPR entirely): only lexically matched
3350        // entries rank; the PPR blend also admits low-lexical entries.
3351        let no_lex_stages = SurfacePipelineStages {
3352            lexical: false,
3353            ..SurfacePipelineStages::default()
3354        };
3355        let no_lex = build_surface_staged(
3356            &ctx,
3357            SurfaceRequest {
3358                mode: SurfaceMode::Task {
3359                    goal: "zeta",
3360                    visible: None,
3361                },
3362                budget: 100_000,
3363                explain: false,
3364                policy: SurfacePolicy::defaults(100_000),
3365                        semantic: None,
3366        },
3367            &no_lex_stages,
3368        );
3369        let lex_on = build_surface_staged(
3370            &ctx,
3371            SurfaceRequest {
3372                mode: SurfaceMode::Task {
3373                    goal: "zeta",
3374                    visible: None,
3375                },
3376                budget: 100_000,
3377                explain: false,
3378                policy: SurfacePolicy::defaults(100_000),
3379                        semantic: None,
3380        },
3381            &SurfacePipelineStages::default(),
3382        );
3383        assert_eq!(
3384            no_lex.rendered_ids.len(),
3385            20,
3386            "pure lexical skips unmatched entries"
3387        );
3388        assert_eq!(lex_on.rendered_ids.len(), 40, "the PPR blend ranks the full pool");
3389        assert_ne!(no_lex.text, lex_on.text);
3390    }
3391
3392    // ---- overload-sensitive entries + decl_header ----
3393
3394    #[test]
3395// trace:exempt reason=internal-detail
3396    fn overload_entries_get_distinct_ids_and_logical_symbol_id() {
3397        let dir = tempfile::TempDir::new().unwrap();
3398        let root = dir.path().join("repo");
3399        std::fs::create_dir_all(&root).unwrap();
3400        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
3401        let repo = store.repo_id.clone();
3402        let path = "api/app.py";
3403        let base = symbol_id(&repo, path, "handle");
3404        for (n, id) in [(0u64, base.clone()), (1, format!("{}#1", base))] {
3405            let mut e = Entity::new(id.clone(), kinds::SYMBOL, "handle");
3406            e.attr("kind", serde_json::json!("function"));
3407            e.attr("file", serde_json::json!(path));
3408            e.attr("signature", serde_json::json!("def handle(x): ..."));
3409            e.attr("exported", serde_json::json!(true));
3410            e.attr("start_line", serde_json::json!(1u32));
3411            e.attr("end_line", serde_json::json!(10u32));
3412            e.attr("overload_index", serde_json::json!(n));
3413            store.insert_entity(&e, &[path.to_string()]).unwrap();
3414        }
3415        let ctx = make_ctx(&store);
3416        let map = compile_surface_map(&ctx);
3417        let handles: Vec<&SurfaceEntry> = map
3418            .entries
3419            .iter()
3420            .filter(|e| e.qualified_name == "handle")
3421            .collect();
3422        assert_eq!(handles.len(), 2, "same-name overloads stay separate entries");
3423        let mut ids: Vec<String> = handles.iter().map(|e| e.id.clone()).collect();
3424        ids.sort();
3425        assert_eq!(
3426            ids,
3427            vec![format!("{}#overload0", base), format!("{}#overload1", base)]
3428        );
3429        for e in handles {
3430            assert_eq!(e.symbol_id, base, "symbol_id stays the logical symbol");
3431        }
3432    }
3433
3434    #[test]
3435// trace:exempt reason=internal-detail
3436    fn decl_header_wins_over_legacy_signature() {
3437        let dir = tempfile::TempDir::new().unwrap();
3438        let root = dir.path().join("repo");
3439        std::fs::create_dir_all(&root).unwrap();
3440        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
3441        let repo = store.repo_id.clone();
3442        let path = "api/app.py";
3443        let id = symbol_id(&repo, path, "process");
3444        let mut e = Entity::new(id.clone(), kinds::SYMBOL, "process");
3445        e.attr("kind", serde_json::json!("function"));
3446        e.attr("file", serde_json::json!(path));
3447        e.attr("signature", serde_json::json!("def process(x) -> str"));
3448        e.attr(
3449            "decl_header",
3450            serde_json::json!("async def process(\n    x: int,\n) -> str:"),
3451        );
3452        e.attr("exported", serde_json::json!(true));
3453        e.attr("start_line", serde_json::json!(1u32));
3454        e.attr("end_line", serde_json::json!(10u32));
3455        store.insert_entity(&e, &[path.to_string()]).unwrap();
3456        let ctx = make_ctx(&store);
3457        let map = compile_surface_map(&ctx);
3458        let proc = entry(&map, "process");
3459        assert_eq!(
3460            proc.source_signature,
3461            "async def process(\n    x: int,\n) -> str:",
3462            "decl_header (exact header) must win over the legacy signature attr"
3463        );
3464        assert_eq!(proc.modifiers, vec!["async"]);
3465    }
3466
3467    // ---- Wave 15.2: live semantic 10%, explain decomposition, hard-max ----
3468
3469    /// Two symmetric private functions (`alpha_fn`, `beta_fn`) in one file
3470    /// with identical signatures and no graph edges — the base blend is
3471    /// identical for both, so the semantic scorer is the ONLY differentiator
3472    /// and the redistribution rule is observable.
3473// trace:exempt reason=unit-test-fixture
3474    fn symmetric_two_fn_fixture() -> (tempfile::TempDir, Store) {
3475        let dir = tempfile::TempDir::new().unwrap();
3476        let root = dir.path().join("repo");
3477        std::fs::create_dir_all(&root).unwrap();
3478        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
3479        let repo = store.repo_id.clone();
3480        let path = "api/app.py";
3481        for name in ["alpha_fn", "beta_fn"] {
3482            let id = symbol_id(&repo, path, name);
3483            let mut e = Entity::new(id.clone(), kinds::SYMBOL, name.to_string());
3484            e.attr("kind", serde_json::json!("function"));
3485            e.attr("file", serde_json::json!(path));
3486            e.attr("signature", serde_json::json!("def f(x): ..."));
3487            e.attr("exported", serde_json::json!(false));
3488            e.attr("start_line", serde_json::json!(1u32));
3489            e.attr("end_line", serde_json::json!(10u32));
3490            store.insert_entity(&e, &[path.to_string()]).unwrap();
3491        }
3492        (dir, store)
3493    }
3494
3495    /// One required entry (concrete http invocation surface) whose
3496    /// declaration header is a SINGLE 400-argument line — even the
3497    /// level-1 compressed render (first signature line) exceeds any small
3498    /// hard max, so the progressive ladder must descend to symbol
3499    /// identity.
3500// trace:exempt reason=unit-test-fixture
3501    fn pathological_signature_fixture() -> (tempfile::TempDir, Store) {
3502        let dir = tempfile::TempDir::new().unwrap();
3503        let root = dir.path().join("repo");
3504        std::fs::create_dir_all(&root).unwrap();
3505        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
3506        let repo = store.repo_id.clone();
3507        let path = "api/app.py";
3508        let id = symbol_id(&repo, path, "big_fn");
3509        let mut e = Entity::new(id.clone(), kinds::SYMBOL, "big_fn".to_string());
3510        e.attr("kind", serde_json::json!("function"));
3511        e.attr("file", serde_json::json!(path));
3512        let mut decl = String::from("def big_fn(");
3513        for i in 0..400 {
3514            decl.push_str(&format!("arg_{i:03}: str, "));
3515        }
3516        decl.push_str(") -> None:");
3517        e.attr("decl_header", serde_json::json!(decl));
3518        e.attr("exported", serde_json::json!(true));
3519        e.attr("start_line", serde_json::json!(1u32));
3520        e.attr("end_line", serde_json::json!(10u32));
3521        e.attr("entrypoints", serde_json::json!(["http: POST /big"]));
3522        store.insert_entity(&e, &[path.to_string()]).unwrap();
3523        (dir, store)
3524    }
3525
3526    /// Scores nothing — the phantom-hole baseline for the redistribution
3527    /// rule (semantic = 0.0 with NO renormalization).
3528// trace:exempt reason=unit-test-mock
3529    struct ZeroScorer;
3530// trace:exempt reason=unit-test-mock
3531    impl crate::rank::SemanticScorer for ZeroScorer {
3532// trace:exempt reason=unit-test-mock
3533        fn score(&self, _goal: &str, _entity: &scc_core::Entity) -> f64 {
3534            0.0
3535        }
3536    }
3537
3538    /// Scores `score` for any entity whose name contains `target`.
3539// trace:exempt reason=unit-test-mock
3540    struct NameScorer {
3541        target: &'static str,
3542        score: f64,
3543    }
3544// trace:exempt reason=unit-test-mock
3545    impl crate::rank::SemanticScorer for NameScorer {
3546// trace:exempt reason=unit-test-mock
3547        fn score(&self, _goal: &str, entity: &scc_core::Entity) -> f64 {
3548            if entity.name.contains(self.target) {
3549                self.score
3550            } else {
3551                0.0
3552            }
3553        }
3554    }
3555
3556    /// Parse the explain render into per-entry (name, component) blocks:
3557    /// a block starts at a `  <kind> <name>` line where `<kind>` is a
3558    /// SurfaceKind word (signature lines like `    public User get(...)`
3559    /// are NOT headers — they carry no leading kind) and collects the
3560    /// following `  <key>: <value>` component lines. Deterministic.
3561// trace:exempt reason=unit-test-helper
3562    fn explain_blocks(text: &str) -> Vec<(String, BTreeMap<String, f64>)> {
3563        const KINDS: [&str; 11] = [
3564            "function", "method", "constructor", "class", "interface",
3565            "trait", "type", "enum", "const", "module", "record",
3566        ];
3567        let mut blocks: Vec<(String, BTreeMap<String, f64>)> = Vec::new();
3568        for line in text.lines() {
3569            // Entry headers carry EXACTLY two leading spaces (signature
3570            // lines and section content are indented deeper, so a
3571            // `    class UserService` signature can never be a header).
3572            if !line.starts_with("   ") {
3573                if let Some(rest) = line.strip_prefix("  ") {
3574                    let first = rest.split_whitespace().next().unwrap_or("");
3575                    if KINDS.contains(&first) {
3576                        let name = rest.split_whitespace().nth(1).unwrap_or("").to_string();
3577                        blocks.push((name, BTreeMap::new()));
3578                        continue;
3579                    }
3580                }
3581            }
3582            if let Some((_, comps)) = blocks.last_mut() {
3583                if let Some((k, v)) = line.trim_start().split_once(':') {
3584                    if let Ok(num) = v.trim().parse::<f64>() {
3585                        comps.insert(k.trim().to_string(), num);
3586                    }
3587                }
3588            }
3589        }
3590        blocks
3591    }
3592
3593    #[test]
3594// trace:exempt reason=unit-test
3595    fn explain_renders_full_score_decomposition() {
3596        let (_dir, store) = fixture_store();
3597        let ctx = make_ctx(&store);
3598        let request = SurfaceRequest {
3599            mode: SurfaceMode::Task {
3600                goal: "create user",
3601                visible: None,
3602            },
3603            budget: 100_000,
3604            explain: true,
3605            policy: SurfacePolicy::defaults(100_000),
3606            semantic: None,
3607        };
3608        let out = build_surface(&ctx, request);
3609        // All eight components + total + reasons render per entry — never
3610        // a bare `importance:` (the reviewer's --explain complaint).
3611        for key in [
3612            "importance:",
3613            "task_ppr:",
3614            "global_ppr:",
3615            "lexical:",
3616            "semantic:",
3617            "confidence:",
3618            "criticality:",
3619            "change_risk:",
3620            "novelty:",
3621            "because:",
3622        ] {
3623            assert!(out.text.contains(key), "explain must render {key:?}");
3624        }
3625        // Reasons populated from the entry's own evidence.
3626        assert!(out.text.contains("task seed:"), "seed evidence -> reason");
3627        assert!(out.text.contains("primary flow participant"), "flow evidence -> reason");
3628        assert!(out.text.contains("public component surface"), "visibility evidence -> reason");
3629        assert!(out.text.contains("owns "), "state-authority evidence -> reason");
3630        assert!(out.text.contains("concrete invocation surface"), "invocation evidence -> reason");
3631        // No phantom semantic: without a scorer the component is honestly 0.
3632        for (_, comps) in explain_blocks(&out.text) {
3633            assert_eq!(comps.get("semantic").copied().unwrap_or(-1.0), 0.0);
3634        }
3635        // Deterministic: same request -> byte-identical explain text.
3636        let out2 = build_surface(&ctx, request);
3637        assert_eq!(out.text, out2.text);
3638    }
3639
3640    #[test]
3641// trace:exempt reason=unit-test
3642    fn semantic_none_redistributes_and_some_reranks() {
3643        let (_dir, store) = symmetric_two_fn_fixture();
3644        let ctx = make_ctx(&store);
3645        let alpha = symbol_id(&store.repo_id, "api/app.py", "alpha_fn");
3646        let beta = symbol_id(&store.repo_id, "api/app.py", "beta_fn");
3647
3648        // (a) semantic=None: the 10% share is REALLOCATED, never a phantom.
3649        let none = build_surface(
3650            &ctx,
3651            SurfaceRequest {
3652                mode: SurfaceMode::Global,
3653                budget: 100_000,
3654                explain: true,
3655                policy: SurfacePolicy::defaults(100_000),
3656                semantic: None,
3657            },
3658        );
3659        let zero = build_surface(
3660            &ctx,
3661            SurfaceRequest {
3662                mode: SurfaceMode::Global,
3663                budget: 100_000,
3664                explain: true,
3665                policy: SurfacePolicy::defaults(100_000),
3666                semantic: Some(&ZeroScorer),
3667            },
3668        );
3669        let none_blocks = explain_blocks(&none.text);
3670        let zero_blocks = explain_blocks(&zero.text);
3671        assert!(!none_blocks.is_empty());
3672        assert_eq!(none_blocks.len(), zero_blocks.len());
3673        // The symmetric fixture renders the same entries in the same order
3674        // (the renormalization scales every total uniformly), so the
3675        // per-entry totals pair up.
3676        for ((nname, ncomps), (zname, zcomps)) in none_blocks.iter().zip(&zero_blocks) {
3677            assert_eq!(nname, zname);
3678            let n = ncomps.get("importance").copied().unwrap();
3679            let z = zcomps.get("importance").copied().unwrap();
3680            assert!(
3681                n > z,
3682                "renormalized total must exceed the phantom-hole total for {nname}"
3683            );
3684            // The full formula, from the rendered components: total ==
3685            // REDISTRIBUTION_SCALE * blend + NOVELTY_WEIGHT * novelty,
3686            // where blend = final_importance(..., semantic=0, novelty=0).
3687            let blend = crate::pagerank::final_importance(
3688                ncomps.get("task_ppr").copied().unwrap_or(0.0),
3689                ncomps.get("global_ppr").copied().unwrap_or(0.0),
3690                ncomps.get("lexical").copied().unwrap_or(0.0),
3691                0.0,
3692                ncomps.get("confidence").copied().unwrap_or(0.0),
3693                ncomps.get("criticality").copied().unwrap_or(0.0),
3694                ncomps.get("change_risk").copied().unwrap_or(0.0),
3695                0.0,
3696                false, // global mode: no task focus
3697            );
3698            let expected = blend * REDISTRIBUTION_SCALE
3699                + crate::pagerank::NOVELTY_WEIGHT
3700                    * ncomps.get("novelty").copied().unwrap_or(0.0);
3701            assert!(
3702                (n - expected).abs() < 0.002,
3703                "{nname}: rendered {n:.6} != renormalized {expected:.6} (blend {blend:.6})"
3704            );
3705        }
3706
3707        // (b) semantic=Some with a real scorer: the 10% is LIVE. The beta
3708        // scorer flips the tie — beta_fn overtakes alpha_fn by exactly the
3709        // semantic weight, and the rendered order changes.
3710        let real = build_surface(
3711            &ctx,
3712            SurfaceRequest {
3713                mode: SurfaceMode::Global,
3714                budget: 100_000,
3715                explain: true,
3716                policy: SurfacePolicy::defaults(100_000),
3717                semantic: Some(&NameScorer {
3718                    target: "beta_fn",
3719                    score: 1.0,
3720                }),
3721            },
3722        );
3723        let blocks = explain_blocks(&real.text);
3724        assert_eq!(blocks.len(), 2);
3725        assert_eq!(blocks[0].0, "beta_fn", "semantic 10% must rerank beta first");
3726        let a = blocks
3727            .iter()
3728            .find(|(n, _)| *n == "alpha_fn")
3729            .and_then(|(_, c)| c.get("importance").copied())
3730            .unwrap();
3731        let b = blocks
3732            .iter()
3733            .find(|(n, _)| *n == "beta_fn")
3734            .and_then(|(_, c)| c.get("importance").copied())
3735            .unwrap();
3736        assert!(
3737            (b - a - crate::pagerank::SEMANTIC_WEIGHT).abs() < 0.002,
3738            "beta must lead alpha by the real 10%: {b:.6} - {a:.6}"
3739        );
3740        // Tie-break check on the None run: alpha first (id order).
3741        assert_eq!(none_blocks[0].0, "alpha_fn");
3742        assert_eq!(none.rendered_ids[0], alpha);
3743        assert_eq!(real.rendered_ids[0], beta);
3744    }
3745
3746    #[test]
3747// trace:exempt reason=unit-test
3748    fn pathological_required_entry_compresses_to_symbol_identity() {
3749        // One required entry whose compressed form still exceeds the hard
3750        // max: the progressive ladder re-renders until it fits — the last
3751        // resort (symbol identity) always fits, and the hard-max invariant
3752        // holds on the ACTUAL rendered text.
3753        let (_dir, store) = pathological_signature_fixture();
3754        let ctx = make_ctx(&store);
3755        let hard_max = 20usize;
3756        let out = build_surface(
3757            &ctx,
3758            SurfaceRequest {
3759                mode: SurfaceMode::Global,
3760                budget: 1,
3761                explain: false,
3762                policy: SurfacePolicy {
3763                    quotas: true,
3764                    mmr: true,
3765                    coverage: true,
3766                    hard_max,
3767                },
3768                semantic: None,
3769            },
3770        );
3771        assert_eq!(out.rendered_ids.len(), 1, "the required entry is never dropped");
3772        assert!(out.text.contains("function big_fn"), "identity line must render");
3773        assert!(
3774            !out.text.contains("def big_fn("),
3775            "the 400-arg signature must be dropped at the identity level"
3776        );
3777        assert!(
3778            out.token_count <= hard_max,
3779            "hard-max invariant on the rendered text: {} > {hard_max}",
3780            out.token_count
3781        );
3782    }
3783}