Skip to main content

scc_context/
pagerank.rs

1//! Personalized PageRank over the System IR reference graph (Wave 14B).
2//!
3//! Layering invariants: ranking NEVER creates architectural truth — it only
4//! normalizes relationships that already exist as trusted facts in the
5//! `TrustedGraphView` and walks them. `FlowGraph` remains the causal
6//! authority; this module never orders flow steps.
7//!
8//! Structure:
9//! - [`build_reference_graph`] normalizes existing trusted relationships into
10//!   [`ReferenceEdge`]s (never invents new relationships).
11//! - [`RankEdgeKind`] is the rank-edge ontology: CONTAINS/PARTICIPATES_IN/
12//!   HANDLES/DEFINES enter the PageRank adjacency with deliberate direction
13//!   and weight (membership evidence, the member→container, flow→
14//!   participant, route→handler and schema→definer RANKING TRANSITIONS,
15//!   invocation/definition edges) — the mechanism that connects components,
16//!   flows, routes and schemas to the rank graph.
17//! - [`SystemRanker`] runs personalized PageRank (power iteration, damping
18//!   0.85, 50 iterations) over a HETEROGENEOUS node universe — every
19//!   rankable entity (symbol, component, subsystem, service, flow,
20//!   contract, state, reactive, route, topic, queue, store, schema, file),
21//!   not symbols alone. Edges survive when either endpoint is a rankable
22//!   entity, so OWNS→STATE, REGISTERS→CONTRACT, PUBLISHES/SUBSCRIBES→TOPIC
23//!   and READS/WRITES→DATA_STORE feed PageRank directly. The global vector
24//!   is architecturally seeded; task vectors personalize from `TaskSeed`s.
25//! - [`SystemRanker::project_to_symbols`] projects the heterogeneous node
26//!   scores back to surface-relevant symbol scores (a symbol's score = its
27//!   own + 0.4 × the scores of the entities it owns/registers/publishes/
28//!   reads/writes/participates-in/handles/defines, bonus capped at 0.5) —
29//!   the mechanism that carries component/flow/contract/state/route/schema
30//!   importance to the surface.
31//! - [`architectural_specificity`] boosts public facades/entrypoints/state
32//!   owners/contract endpoints/flow participants and penalizes generic
33//!   utilities, generated/vendored code, and ubiquitous symbols — keeping
34//!   central utilities from dominating the ranking.
35//! - [`final_importance`] is the spec's blend of task/global PPR with
36//!   lexical, semantic, confidence, criticality, change-risk and novelty
37//!   signals.
38//!
39//! Everything is deterministic (fixed iteration count, sorted indices,
40//! id-ordered traversals) and no-panic (clamped weights, guarded
41//! division).
42
43use scc_core::{
44    kinds, predicates, Provenance, ReferenceEdge, ReferenceKind, Relationship, SourceRange,
45    SurfaceEntry, TaskSeed,
46};
47use scc_graph::TrustedGraphView;
48use std::collections::{HashMap, HashSet};
49
50/// Per-edge weight adjust hook (DoD 23): `adjust(subject, predicate,
51/// object, base) -> Option<(mode, value)>`, modes add | multiply |
52/// replace | veto. `None` keeps the base weight.
53// trace:exempt reason=internal-detail
54pub type EdgeAdjust = dyn for<'a, 'b, 'c> Fn(&'a str, &'b str, &'c str, f64) -> Option<(String, f64)>;
55
56// trace:exempt reason=internal-detail
57fn no_adjust(_: &str, _: &str, _: &str, _: f64) -> Option<(String, f64)> {
58    None
59}
60
61// ---------------------------------------------------------------------------
62// Edge weight constants (spec §2)
63// ---------------------------------------------------------------------------
64
65/// Weight category for flow-participation edges (flows are trusted facts;
66/// the ranker never invents flow ordering).
67pub const FLOW_PARTICIPATION: f64 = 1.5;
68/// Weight category for invocation edges (`invokes`, callbacks).
69pub const INVOCATION: f64 = 1.5;
70/// Weight category for public-API edges (`exports`).
71pub const PUBLIC_API: f64 = 1.4;
72/// Weight category for state/reactive ownership edges (`owns`).
73pub const OWNS_STATE: f64 = 1.4;
74/// Weight category for data-flow edges (`reads`/`writes`/`produces`/`consumes`).
75pub const PRODUCES_CONSUMES: f64 = 1.3;
76/// Weight category for `implements` edges.
77pub const IMPLEMENTS: f64 = 1.25;
78/// Weight category for `inherits` edges.
79pub const EXTENDS: f64 = 1.2;
80/// Weight category for symbol-resolved `calls` edges.
81pub const CALLS_RESOLVED: f64 = 1.2;
82/// Weight category for contract-registration edges (`registers`).
83pub const CONTRACT_PARTICIPATION: f64 = 1.2;
84/// Weight category for `publishes`/`subscribes` edges.
85pub const SUBSCRIBES_PUBLISHES: f64 = 1.2;
86/// Neutral dependency weight (unmapped predicates, `depends_on`).
87pub const DEPENDS_ON: f64 = 1.0;
88/// Weight category for text-extracted (unresolved) `calls` edges.
89pub const CALLS_EXTRACTED: f64 = 0.9;
90/// Weight category for `imports` edges.
91pub const IMPORTS: f64 = 0.6;
92/// Rank-edge ontology weights — deliberate direction/weight pairs for the
93/// predicates that carry membership/participation/invocation/definition
94/// evidence into PageRank (see [`RankEdgeKind`]): `contains` container →
95/// member membership evidence (0.4); the reverse member → container
96/// RANKING TRANSITION (0.8) that derives container importance from its
97/// members; `participates_in` symbol → flow (0.6) plus the reverse flow →
98/// participant RANKING TRANSITION (1.2) that lets flow importance reach
99/// the participants (the mechanism that connects flow nodes into the rank
100/// graph); `handles` symbol → route (1.5, invocation-strong) plus the
101/// reverse route → handler RANKING TRANSITION (1.5); `defines` symbol →
102/// schema (1.2) plus the reverse schema → definer RANKING TRANSITION
103/// (1.2). The reverse directions are pure ranking edges — NEVER Reality
104/// Graph relationships.
105pub const RANK_MEMBERSHIP: f64 = 0.4;
106pub const RANK_MEMBER_OF: f64 = 0.8;
107pub const RANK_PARTICIPATES: f64 = 0.6;
108pub const RANK_FLOW_REACHES_PARTICIPANT: f64 = 1.2;
109pub const RANK_HANDLES: f64 = 1.5;
110pub const RANK_DEFINES: f64 = 1.2;
111pub const RANK_HANDLED_BY: f64 = 1.5;
112pub const RANK_DEFINED_BY: f64 = 1.2;
113
114/// Provenance weights (spec §2): STALE facts weigh zero (the trusted view
115/// already excludes them; the zero is a belt-and-suspenders floor).
116pub const PROV_OBSERVED: f64 = 1.0;
117pub const PROV_RESOLVED: f64 = 1.0;
118pub const PROV_EXTRACTED: f64 = 0.85;
119pub const PROV_DECLARED: f64 = 0.8;
120pub const PROV_INFERRED: f64 = 0.6;
121pub const PROV_STALE: f64 = 0.0;
122
123/// PageRank damping factor.
124pub const DAMPING_FACTOR: f64 = 0.85;
125/// Fixed power-iteration count (deterministic).
126pub const POWER_ITERATIONS: usize = 50;
127/// Warm-start blend of the global vector into the task vector's start state.
128pub const WARM_START_GLOBAL_BLEND: f64 = 0.3;
129
130/// Ubiquity threshold: a symbol referenced by more distinct sources than
131/// this is "ubiquitous" and gets penalized by [`architectural_specificity`].
132pub const UBIQUITY_THRESHOLD: usize = 20;
133
134/// Generic utility name tokens that make a symbol architecturally generic.
135const GENERIC_NAMES: [&str; 7] = [
136    "utils", "logger", "errors", "common", "types", "helpers", "config",
137];
138
139/// Entity kinds that count as invocation surfaces (route/endpoint/event/
140/// topic/queue handlers are reachable from outside the process).
141const INVOCATION_KINDS: [&str; 5] = [
142    kinds::ROUTE,
143    kinds::ENDPOINT,
144    kinds::EVENT,
145    kinds::TOPIC,
146    kinds::QUEUE,
147];
148
149/// Entity kinds that count as contract endpoints for registration edges.
150const CONTRACT_KINDS: [&str; 2] = [kinds::CONTRACT, kinds::REGISTRY];
151
152/// The HETEROGENEOUS PageRank universe: every entity kind that can carry
153/// surface-relevant importance. Symbol-only ranking let component/flow/
154/// contract/state importance die at the boundary; these kinds participate
155/// as first-class nodes (real strings from `scc_core::kinds`). SYSTEM is
156/// included so the top of the containment hierarchy (System →
157/// Subsystem/…) is a first-class rank node, not a dead endpoint.
158const RANKABLE_KINDS: [&str; 15] = [
159    kinds::SYMBOL,
160    kinds::COMPONENT,
161    kinds::SUBSYSTEM,
162    kinds::SERVICE,
163    kinds::SYSTEM,
164    kinds::FLOW,
165    kinds::CONTRACT,
166    kinds::STATE,
167    kinds::REACTIVE,
168    kinds::ROUTE,
169    kinds::TOPIC,
170    kinds::QUEUE,
171    kinds::DATA_STORE,
172    kinds::SCHEMA,
173    kinds::FILE,
174];
175
176/// Predicates whose non-symbol targets a symbol projects onto: the
177/// entities whose PageRank score lifts the owning symbol's surface score.
178/// HANDLES/DEFINES are included so a hot Route/Schema node (task seed or
179/// PPR) reaches its handler/definer symbol — the reviewer's
180/// route-hot → handler-reached scenario.
181const PROJECTION_PREDICATES: [&str; 8] = [
182    predicates::OWNS,
183    predicates::REGISTERS,
184    predicates::PUBLISHES,
185    predicates::READS,
186    predicates::WRITES,
187    predicates::PARTICIPATES_IN,
188    predicates::HANDLES,
189    predicates::DEFINES,
190];
191
192/// Projection factor: a symbol's score += 0.4 × the sum of the scores of
193/// the entities it OWNS/REGISTERS/PUBLISHES/READS/WRITES/PARTICIPATES_IN/
194/// HANDLES/DEFINES.
195pub const PROJECTION_BONUS_FACTOR: f64 = 0.4;
196/// The projection bonus is capped at 0.5 per symbol (a state-heavy symbol
197/// cannot accumulate an unbounded lift).
198pub const PROJECTION_BONUS_CAP: f64 = 0.5;
199
200// ---------------------------------------------------------------------------
201// Weight helpers
202// ---------------------------------------------------------------------------
203
204/// Provenance weight per spec §2.
205// trace:exempt reason=internal-detail
206pub fn provenance_weight(provenance: Provenance) -> f64 {
207    match provenance {
208        Provenance::Observed => PROV_OBSERVED,
209        Provenance::Resolved => PROV_RESOLVED,
210        Provenance::Extracted => PROV_EXTRACTED,
211        Provenance::Declared => PROV_DECLARED,
212        Provenance::Inferred => PROV_INFERRED,
213        Provenance::Stale => PROV_STALE,
214    }
215}
216
217/// Rarity of a target symbol: `log(total_symbols / (1 + in_degree))`
218/// clamped to [0.25, 1.5]. Occurrence = in-degree count of the target
219/// (distinct referencing sources). A ubiquitous `logger`-ish symbol is
220/// rare-except-common and gets downweighted; a distinctive symbol is rare
221/// and gets boosted.
222// trace:exempt reason=internal-detail
223pub fn rarity(total_symbols: usize, in_degree: usize) -> f64 {
224    if total_symbols == 0 {
225        return 1.0;
226    }
227    let r = (total_symbols as f64 / (1 + in_degree) as f64).ln();
228    r.clamp(0.25, 1.5)
229}
230
231/// Predicate weight category for an SCC predicate. `calls` is refined by
232/// provenance: symbol-resolved calls weigh more than text-extracted ones.
233// trace:exempt reason=internal-detail
234pub fn predicate_weight(predicate: &str, provenance: Provenance) -> f64 {
235    match predicate {
236        predicates::CALLS => match provenance {
237            Provenance::Resolved => CALLS_RESOLVED,
238            _ => CALLS_EXTRACTED,
239        },
240        predicates::INVOKES | predicates::HANDLES_CALLBACK => INVOCATION,
241        predicates::EXPORTS => PUBLIC_API,
242        predicates::OWNS => OWNS_STATE,
243        predicates::READS | predicates::WRITES => PRODUCES_CONSUMES,
244        predicates::IMPLEMENTS | predicates::IMPLEMENTED_BY => IMPLEMENTS,
245        predicates::INHERITS => EXTENDS,
246        predicates::REGISTERS => CONTRACT_PARTICIPATION,
247        predicates::PUBLISHES | predicates::SUBSCRIBES => SUBSCRIBES_PUBLISHES,
248        predicates::IMPORTS => IMPORTS,
249        predicates::CONTAINS => RANK_MEMBERSHIP,
250        _ => DEPENDS_ON,
251    }
252}
253
254/// Full edge weight (spec §2): `predicate_weight * provenance_weight *
255/// confidence * rarity`. `target_in_degree` is the in-degree count of the
256/// target symbol (distinct referencing sources).
257// trace:exempt reason=internal-detail
258pub fn edge_weight(
259    predicate: &str,
260    provenance: Provenance,
261    confidence: f64,
262    total_symbols: usize,
263    target_in_degree: usize,
264) -> f64 {
265    predicate_weight(predicate, provenance)
266        * provenance_weight(provenance)
267        * confidence.clamp(0.0, 1.0)
268        * rarity(total_symbols, target_in_degree)
269}
270
271// ---------------------------------------------------------------------------
272// Reference normalization
273// ---------------------------------------------------------------------------
274
275/// Map an SCC predicate to a [`ReferenceKind`], given the target entity
276/// kind (needed to scope `owns` to state/reactive per the spec). `None`
277/// means the predicate is not part of the normalized reference surface —
278/// it is left alone, never invented.
279// trace:exempt reason=internal-detail
280fn reference_kind(predicate: &str, target_kind: &str) -> Option<ReferenceKind> {
281    match predicate {
282        predicates::CALLS | predicates::INVOKES | predicates::HANDLES_CALLBACK => {
283            Some(ReferenceKind::Call)
284        }
285        predicates::READS => Some(ReferenceKind::Read),
286        predicates::WRITES => Some(ReferenceKind::Write),
287        predicates::OWNS if target_kind == kinds::STATE || target_kind == kinds::REACTIVE => {
288            Some(ReferenceKind::Write)
289        }
290        predicates::IMPLEMENTS | predicates::IMPLEMENTED_BY => Some(ReferenceKind::Implement),
291        predicates::INHERITS => Some(ReferenceKind::Extend),
292        predicates::REGISTERS | predicates::PUBLISHES | predicates::SUBSCRIBES => {
293            Some(ReferenceKind::Register)
294        }
295        predicates::IMPORTS => Some(ReferenceKind::Import),
296        predicates::EXPORTS => Some(ReferenceKind::Export),
297        predicates::DECORATES | predicates::ANNOTATES => Some(ReferenceKind::Decorate),
298        _ => None,
299    }
300}
301
302/// Evidence file/line for a relationship, when available. The trusted view
303/// does not expose store evidence rows, so this uses the symbol entities'
304/// recorded `file`/`start_line`/`end_line` attributes (the same source
305/// evidence the extractors recorded at index time). Subject first, then
306/// object; deduplicated; empty when neither endpoint is a symbol.
307// trace:exempt reason=internal-detail
308fn locations_for(view: &TrustedGraphView, rel: &Relationship) -> Vec<SourceRange> {
309    let mut locs: Vec<SourceRange> = Vec::new();
310    for id in [&rel.subject, &rel.object] {
311        let Some(e) = view.entity(id) else { continue };
312        if e.kind != kinds::SYMBOL {
313            continue;
314        }
315        let Some(path) = e.attributes.get("file").and_then(|v| v.as_str()) else {
316            continue;
317        };
318        let start = e
319            .attributes
320            .get("start_line")
321            .and_then(|v| v.as_u64())
322            .unwrap_or(0) as u32;
323        let end = e
324            .attributes
325            .get("end_line")
326            .and_then(|v| v.as_u64())
327            .unwrap_or(0) as u32;
328        let start = start.max(1);
329        let loc = SourceRange::new(path, start, end.max(start));
330        if !locs.contains(&loc) {
331            locs.push(loc);
332        }
333    }
334    locs
335}
336
337/// Normalize existing trusted relationships into reference edges. Never
338/// invents new relationships: every edge traces to one trusted fact.
339///
340/// Mapping (actual predicates from `scc_core::predicates`):
341/// - `calls`/`invokes`/`handles_callback` → Call
342/// - `reads` → Read; `writes` → Write
343/// - `owns` (state/reactive entity) → Write
344/// - `implements`/`implemented_by` → Implement
345/// - `inherits` → Extend
346/// - `registers`/`publishes`/`subscribes` → Register
347/// - `imports` → Import; `exports` → Export
348/// - `decorates`/`annotates` → Decorate
349///
350/// Confidence passes through from the relationship (default 1.0); locations
351/// are the evidence file/line when available. Deterministic: the trusted
352/// view returns relationships sorted by id.
353// trace:v1 id=impl.scc.pagerank work=WORK-SCC-014 satisfies=REQ-SCC-IR
354pub fn build_reference_graph(view: &TrustedGraphView) -> Vec<ReferenceEdge> {
355    let mut out: Vec<ReferenceEdge> = Vec::new();
356    for rel in view.all_rels() {
357        let target_kind = view
358            .entity(&rel.object)
359            .map(|e| e.kind.as_str())
360            .unwrap_or("");
361        let kind = match reference_kind(&rel.predicate, target_kind) {
362            Some(k) => k,
363            None => continue,
364        };
365        // `implemented_by` is stored interface→class; normalize to class→interface.
366        let (source, target) = if rel.predicate == predicates::IMPLEMENTED_BY {
367            (rel.object.clone(), rel.subject.clone())
368        } else {
369            (rel.subject.clone(), rel.object.clone())
370        };
371        out.push(ReferenceEdge {
372            source_symbol: source,
373            target_symbol: target,
374            kind,
375            locations: locations_for(view, rel),
376            provenance: rel.provenance,
377            confidence: rel.confidence as f32,
378        });
379    }
380    out
381}
382
383// ---------------------------------------------------------------------------
384// Rank-edge ontology
385// ---------------------------------------------------------------------------
386
387/// A directed rank edge in the heterogeneous PageRank adjacency: which
388/// trusted predicates enter the rank graph and in which direction/with
389/// which weight. Distinct from [`ReferenceKind`] (the normalized
390/// reference surface — these edges are the RANK-edge ontology, never
391/// `reference_kind` doubling). Every edge traces to one trusted
392/// relationship; the reverse directions of `contains`/`participates_in`/
393/// `handles`/`defines` are explicit RANKING TRANSITIONS — edges that
394/// exist only so container/flow/route/schema importance reaches its
395/// members/participants/handlers/definers — and are labeled as such,
396/// never as Reality Graph relationships.
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398// trace:v1 id=impl.crates-scc-context-src-pagerank.rank-edge-kind work=WORK-rank-edge-ontology-c-o-n-t-a-i-n-s-p-a-r-t-i-c-i-p-a-t-e-s-i-n-h-a-n-d-l-e-s-d-e-f-i-n-e-s-enter-the-p-p-r-adjacency satisfies=REQ-rank-edges-heterogeneous-predicates-enter-page-rank
399pub enum RankEdgeKind {
400    /// Container → member: a kind earlier in [`CONTAINMENT_HIERARCHY`]
401    /// (System/Subsystem/Service/Component/File) CONTAINS a kind later in
402    /// it (Subsystem/Service/Component/File/Symbol) — membership
403    /// evidence, weight 0.4.
404    Contains,
405    /// RANKING TRANSITION (never a Reality Graph relationship): member →
406    /// its container, so container importance is derived from the
407    /// members' aggregate (weight 0.8).
408    MemberOf,
409    /// `Symbol` PARTICIPATES_IN a `Flow` (weight 0.6).
410    ParticipatesIn,
411    /// RANKING TRANSITION: `Flow` → its participant symbol, so flow
412    /// importance reaches the participants — the mechanism that connects
413    /// flow nodes into the rank graph (weight 1.2).
414    FlowReachesParticipant,
415    /// `Symbol` HANDLES a `Route` (invocation-strong, weight 1.5).
416    Handles,
417    /// RANKING TRANSITION: `Route` → its handler symbol, so route
418    /// importance reaches the handler — the reverse of `Handles`
419    /// (weight 1.5).
420    HandledBy,
421    /// `Symbol` DEFINES a `Schema` (weight 1.2).
422    Defines,
423    /// RANKING TRANSITION: `Schema` → its defining symbol, so schema
424    /// importance reaches the definer — the reverse of `Defines`
425    /// (weight 1.2).
426    DefinedBy,
427}
428
429// trace:exempt reason=internal-detail
430impl RankEdgeKind {
431    /// The reverse ranking transition of this edge: `Contains` ↔
432    /// `MemberOf`, `ParticipatesIn` ↔ `FlowReachesParticipant`,
433    /// `Handles` ↔ `HandledBy`, `Defines` ↔ `DefinedBy`. Every evidence
434    /// edge has a ranking-only reverse — it propagates container/flow/
435    /// route/schema importance back to members/participants/handlers/
436    /// definers and NEVER claims a Reality Graph relationship.
437    // trace:exempt reason=internal-detail
438    pub fn reverse_ranking_transition(self) -> Option<RankEdgeKind> {
439        match self {
440            RankEdgeKind::Contains => Some(RankEdgeKind::MemberOf),
441            RankEdgeKind::MemberOf => Some(RankEdgeKind::Contains),
442            RankEdgeKind::ParticipatesIn => Some(RankEdgeKind::FlowReachesParticipant),
443            RankEdgeKind::FlowReachesParticipant => Some(RankEdgeKind::ParticipatesIn),
444            RankEdgeKind::Handles => Some(RankEdgeKind::HandledBy),
445            RankEdgeKind::HandledBy => Some(RankEdgeKind::Handles),
446            RankEdgeKind::Defines => Some(RankEdgeKind::DefinedBy),
447            RankEdgeKind::DefinedBy => Some(RankEdgeKind::Defines),
448        }
449    }
450
451    /// Base weight of the rank edge (before provenance × confidence).
452    // trace:exempt reason=internal-detail
453    pub fn weight(self) -> f64 {
454        match self {
455            RankEdgeKind::Contains => RANK_MEMBERSHIP,
456            RankEdgeKind::MemberOf => RANK_MEMBER_OF,
457            RankEdgeKind::ParticipatesIn => RANK_PARTICIPATES,
458            RankEdgeKind::FlowReachesParticipant => RANK_FLOW_REACHES_PARTICIPANT,
459            RankEdgeKind::Handles => RANK_HANDLES,
460            RankEdgeKind::HandledBy => RANK_HANDLED_BY,
461            RankEdgeKind::Defines => RANK_DEFINES,
462            RankEdgeKind::DefinedBy => RANK_DEFINED_BY,
463        }
464    }
465}
466
467/// The full containment hierarchy, top → bottom, real strings from
468/// `scc_core::kinds` (all six kinds exist there). A CONTAINS edge whose
469/// subject precedes its object in this list is container → member
470/// membership evidence; the stored member → container direction is the
471/// reverse RANKING TRANSITION (member importance flows up). Covers every
472/// hierarchy pair — (System, Subsystem), (Subsystem, Service),
473/// (Subsystem, Component), (Service, Component), (Component, File),
474/// (Component, Symbol), (File, Symbol) and every transitive container
475/// pair — not just the adjacent ones.
476const CONTAINMENT_HIERARCHY: [&str; 6] = [
477    kinds::SYSTEM,
478    kinds::SUBSYSTEM,
479    kinds::SERVICE,
480    kinds::COMPONENT,
481    kinds::FILE,
482    kinds::SYMBOL,
483];
484
485/// Position of a kind in [`CONTAINMENT_HIERARCHY`]; `None` for kinds
486/// outside the containment hierarchy (they never fire containment edges).
487// trace:exempt reason=internal-detail
488fn containment_position(kind: &str) -> Option<usize> {
489    CONTAINMENT_HIERARCHY.iter().position(|k| *k == kind)
490}
491
492/// Map a (predicate, subject kind, target kind) triple to its rank-edge
493/// kind, when the relationship is one of the explicit rank-edge
494/// predicates (real strings from `scc_core::predicates`). Direction is
495/// deliberate: only the documented endpoint kinds fire — `contains`
496/// only between kinds in [`CONTAINMENT_HIERARCHY`] with the container
497/// before the member (the stored member → container direction maps to
498/// the `MemberOf` RANKING TRANSITION), `participates_in` only between a
499/// symbol and a flow (stored flow → symbol maps to
500/// `FlowReachesParticipant`), `handles` only symbol ↔ route (stored
501/// route → symbol maps to the `HandledBy` RANKING TRANSITION), `defines`
502/// only symbol ↔ schema (stored schema → symbol maps to `DefinedBy`).
503// trace:exempt reason=internal-detail
504pub fn rank_edge_kind(
505    predicate: &str,
506    subject_kind: &str,
507    target_kind: &str,
508) -> Option<RankEdgeKind> {
509    match predicate {
510        predicates::CONTAINS => {
511            let (Some(sp), Some(tp)) = (
512                containment_position(subject_kind),
513                containment_position(target_kind),
514            ) else {
515                return None;
516            };
517            if sp < tp {
518                Some(RankEdgeKind::Contains)
519            } else if tp < sp {
520                Some(RankEdgeKind::MemberOf)
521            } else {
522                None
523            }
524        }
525        predicates::PARTICIPATES_IN
526            if subject_kind == kinds::SYMBOL && target_kind == kinds::FLOW =>
527        {
528            Some(RankEdgeKind::ParticipatesIn)
529        }
530        predicates::PARTICIPATES_IN
531            if subject_kind == kinds::FLOW && target_kind == kinds::SYMBOL =>
532        {
533            Some(RankEdgeKind::FlowReachesParticipant)
534        }
535        predicates::HANDLES if subject_kind == kinds::SYMBOL && target_kind == kinds::ROUTE => {
536            Some(RankEdgeKind::Handles)
537        }
538        // Reverse of `handles`: RANKING TRANSITION route → handler symbol.
539        predicates::HANDLES if subject_kind == kinds::ROUTE && target_kind == kinds::SYMBOL => {
540            Some(RankEdgeKind::HandledBy)
541        }
542        predicates::DEFINES if subject_kind == kinds::SYMBOL && target_kind == kinds::SCHEMA => {
543            Some(RankEdgeKind::Defines)
544        }
545        // Reverse of `defines`: RANKING TRANSITION schema → definer symbol.
546        predicates::DEFINES if subject_kind == kinds::SCHEMA && target_kind == kinds::SYMBOL => {
547            Some(RankEdgeKind::DefinedBy)
548        }
549        _ => None,
550    }
551}
552
553// ---------------------------------------------------------------------------
554// SystemRanker
555// ---------------------------------------------------------------------------
556
557/// Personalized PageRank over the HETEROGENEOUS entity reference graph.
558///
559/// Nodes are every rankable entity (id-sorted for determinism): symbols,
560/// components, subsystems, services, flows, contracts, state, reactive,
561/// routes, topics, queues, stores, schemas and files. Edges are trusted
562/// relationships whose both endpoints are rankable entities and whose
563/// predicate normalizes to a reference kind; weights follow the spec
564/// formula. The global vector is computed once per epoch (Main caches the
565/// `SystemRanker`); task vectors personalize from `TaskSeed`s with a
566/// warm start blended from the global vector.
567// trace:exempt reason=internal-detail
568pub struct SystemRanker<'a> {
569    view: &'a TrustedGraphView<'a>,
570    /// Sorted rankable entity ids; index i in every vector == `nodes[i]`.
571    nodes: Vec<String>,
572    /// Entity kinds, parallel to `nodes`.
573    kinds: Vec<String>,
574    index: HashMap<String, usize>,
575    /// Row-normalized out-edge adjacency: (target index, weight).
576    adjacency: Vec<Vec<(usize, f64)>>,
577    /// Distinct referencing sources per node (rarity/ubiquity input).
578    in_degree: Vec<usize>,
579    /// For each node: indices of the non-symbol entities it projects onto
580    /// (OWNS/REGISTERS/PUBLISHES/READS/WRITES/PARTICIPATES_IN/HANDLES/
581    /// DEFINES targets — HANDLES/DEFINES so a hot Route/Schema node
582    /// reaches its handler/definer symbol).
583    projection: Vec<Vec<usize>>,
584    /// Precomputed global (architecturally seeded) PageRank vector.
585    global: Vec<f64>,
586}
587
588// trace:exempt reason=internal-detail
589impl<'a> SystemRanker<'a> {
590    /// Build the ranker from the trusted view. Deterministic; O(E) edges.
591// trace:exempt reason=internal-detail
592    pub fn new(view: &'a TrustedGraphView<'a>) -> SystemRanker<'a> {
593        Self::with_edge_adjust(view, no_adjust)
594    }
595
596    /// Build the ranker with a per-edge weight adjust hook (DoD 23):
597    /// `adjust(subject, predicate, object, base) -> Option<(mode, value)>`.
598    /// Modes: add | multiply | replace | veto. `None` keeps the base weight.
599    /// Veto (or non-positive/non-finite result) drops the edge. Deterministic
600    /// for a deterministic hook; every other construction step is unchanged.
601// trace:exempt reason=internal-detail
602    pub fn with_edge_adjust<F>(
603        view: &'a TrustedGraphView<'a>,
604        adjust: F,
605    ) -> SystemRanker<'a>
606    where
607        F: for<'x, 'y, 'z> Fn(&'x str, &'y str, &'z str, f64) -> Option<(String, f64)>,
608    {
609        let mut pairs: Vec<(String, String)> = view
610            .entities()
611            .filter(|e| RANKABLE_KINDS.contains(&e.kind.as_str()))
612            .map(|e| (e.id.clone(), e.kind.clone()))
613            .collect();
614        pairs.sort_by(|a, b| a.0.cmp(&b.0));
615        let nodes: Vec<String> = pairs.iter().map(|(id, _)| id.clone()).collect();
616        let kinds: Vec<String> = pairs.iter().map(|(_, k)| k.clone()).collect();
617        let index: HashMap<String, usize> = nodes
618            .iter()
619            .enumerate()
620            .map(|(i, s)| (s.clone(), i))
621            .collect();
622        let n = nodes.len();
623
624        // Pass 1: collect edges + per-target distinct sources. An edge
625        // survives when BOTH endpoints are rankable entities (the universe
626        // is heterogeneous: non-symbol endpoints are first-class nodes) and
627        // the predicate normalizes to a reference kind OR carries a
628        // rank-edge transition (the deliberate direction/weight ontology —
629        // CONTAINS/PARTICIPATES_IN/HANDLES/DEFINES; see [`RankEdgeKind`]).
630        let mut edges: Vec<(usize, usize, f64)> = Vec::new();
631        let mut in_sources: Vec<HashSet<usize>> = vec![HashSet::new(); n];
632        for rel in view.all_rels() {
633            let (Some(&si), Some(&ti)) = (index.get(&rel.subject), index.get(&rel.object)) else {
634                continue;
635            };
636            let subject_kind = view
637                .entity(&rel.subject)
638                .map(|e| e.kind.as_str())
639                .unwrap_or("");
640            let target_kind = view
641                .entity(&rel.object)
642                .map(|e| e.kind.as_str())
643                .unwrap_or("");
644            // Normalized reference-surface edges (the existing surface:
645            // calls/reads/writes/owns/…).
646            let reference_weight = if reference_kind(&rel.predicate, target_kind).is_some() {
647                Some(
648                    predicate_weight(&rel.predicate, rel.provenance)
649                        * provenance_weight(rel.provenance)
650                        * rel.confidence.clamp(0.0, 1.0),
651                )
652            } else {
653                None
654            };
655            // Rank-edge transitions (the deliberate direction/weight
656            // ontology): the stored direction plus the reverse ranking
657            // transition when the ontology defines one.
658            if let Some(kind) = rank_edge_kind(&rel.predicate, subject_kind, target_kind) {
659                let w = kind.weight()
660                    * provenance_weight(rel.provenance)
661                    * rel.confidence.clamp(0.0, 1.0);
662                if let Some(w) = Self::adjust_edge(&adjust, &rel.subject, &rel.predicate, &rel.object, w) {
663                    in_sources[ti].insert(si);
664                    edges.push((si, ti, w));
665                }
666                if let Some(rev) = kind.reverse_ranking_transition() {
667                    let rw = rev.weight()
668                        * provenance_weight(rel.provenance)
669                        * rel.confidence.clamp(0.0, 1.0);
670                    if let Some(rw) = Self::adjust_edge(&adjust, &rel.object, &rel.predicate, &rel.subject, rw) {
671                        in_sources[si].insert(ti);
672                        edges.push((ti, si, rw));
673                    }
674                }
675            }
676            if let Some(w) = reference_weight {
677                if let Some(w) = Self::adjust_edge(&adjust, &rel.subject, &rel.predicate, &rel.object, w) {
678                    in_sources[ti].insert(si);
679                    edges.push((si, ti, w));
680                }
681            }
682        }
683        let in_degree: Vec<usize> = in_sources.iter().map(|s| s.len()).collect();
684
685        // Aggregate parallel edges (sum weights), apply rarity, row-normalize.
686        let mut agg: Vec<HashMap<usize, f64>> = vec![HashMap::new(); n];
687        for (si, ti, w) in edges {
688            let r = rarity(n, in_degree[ti]);
689            *agg[si].entry(ti).or_insert(0.0) += w * r;
690        }
691        let mut adjacency: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
692        for (i, row) in agg.iter().enumerate() {
693            let sum: f64 = row.values().sum();
694            if sum > 0.0 {
695                let mut v: Vec<(usize, f64)> =
696                    row.iter().map(|(j, w)| (*j, w / sum)).collect();
697                v.sort_by_key(|(j, _)| *j);
698                adjacency[i] = v;
699            }
700        }
701
702        // Projection edges: non-symbol targets of the symbol's
703        // OWNS/REGISTERS/PUBLISHES/READS/WRITES/PARTICIPATES_IN/HANDLES/
704        // DEFINES rels (HANDLES/DEFINES so a hot Route/Schema node
705        // reaches its handler/definer symbol).
706        let mut projection: Vec<Vec<usize>> = vec![Vec::new(); n];
707        for rel in view.all_rels() {
708            if !PROJECTION_PREDICATES.contains(&rel.predicate.as_str()) {
709                continue;
710            }
711            let (Some(&si), Some(&ti)) = (index.get(&rel.subject), index.get(&rel.object)) else {
712                continue;
713            };
714            if kinds[ti] == kinds::SYMBOL {
715                continue; // the bonus carries entity importance, not symbol
716            }
717            projection[si].push(ti);
718        }
719        for p in projection.iter_mut() {
720            p.sort_unstable();
721            p.dedup();
722        }
723
724        let ranker = SystemRanker {
725            view,
726            nodes,
727            kinds,
728            index,
729            adjacency,
730            in_degree,
731            projection,
732            global: Vec::new(),
733        };
734        let global = Self::ppr(&ranker.adjacency, &ranker.global_personalization());
735        let mut ranker = ranker;
736        ranker.global = global;
737        ranker
738    }
739
740    /// Apply one adjust hook to a raw edge weight. `None` keeps base;
741    /// veto/non-positive/non-finite drops the edge (`None` here = drop).
742// trace:exempt reason=internal-detail
743    fn adjust_edge(
744        adjust: &impl for<'x, 'y, 'z> Fn(&'x str, &'y str, &'z str, f64) -> Option<(String, f64)>,
745        subject: &str,
746        predicate: &str,
747        object: &str,
748        base: f64,
749    ) -> Option<f64> {
750        match adjust(subject, predicate, object, base) {
751            None => Some(base),
752            Some((mode, value)) => match mode.as_str() {
753                "add" => Some(base + value),
754                "multiply" => Some(base * value),
755                "replace" => Some(value),
756                "veto" => None,
757                _ => Some(base),
758            }
759            .filter(|w| w.is_finite() && *w > 0.0),
760        }
761    }
762
763    /// Rankable entity ids in rank order (index i in every vector maps to
764    /// `nodes()[i]`; symbols are a subset).
765// trace:exempt reason=internal-detail
766    pub fn nodes(&self) -> &[String] {
767        &self.nodes
768    }
769
770    /// Ranked symbol entity ids (subset of [`SystemRanker::nodes`]),
771    /// id-sorted. Symbol-only callers use this to walk the symbol slice of
772    /// the heterogeneous vectors.
773// trace:exempt reason=internal-detail
774    pub fn symbols(&self) -> Vec<String> {
775        self.nodes
776            .iter()
777            .zip(self.kinds.iter())
778            .filter(|(_, k)| k.as_str() == kinds::SYMBOL)
779            .map(|(id, _)| id.clone())
780            .collect()
781    }
782
783    /// Node index for a rankable entity id, if it is in the universe.
784// trace:exempt reason=internal-detail
785    pub fn index_of(&self, id: &str) -> Option<usize> {
786        self.index.get(id).copied()
787    }
788
789    /// Distinct referencing sources of a node id (0 when absent).
790// trace:exempt reason=internal-detail
791    pub fn in_degree(&self, id: &str) -> usize {
792        self.index
793            .get(id)
794            .map(|i| self.in_degree[*i])
795            .unwrap_or(0)
796    }
797
798    /// Precomputed global PageRank vector (architecturally seeded; see
799    /// [`SystemRanker::global_personalization`]). Main caches the ranker per
800    /// epoch and reads this.
801// trace:exempt reason=internal-detail
802    pub fn global_vector(&self) -> Vec<f64> {
803        self.global.clone()
804    }
805
806    /// Project the heterogeneous node scores to surface-relevant symbol
807    /// scores: a symbol's score = its own score + 0.4 × the sum of the
808    /// scores of the entities it OWNS/REGISTERS/PUBLISHES/READS/WRITES/
809    /// PARTICIPATES_IN/HANDLES/DEFINES, with the total bonus capped at
810    /// 0.5. This is how component/flow/contract/state importance reaches
811    /// the surface — and, via the HANDLES/DEFINES projection, how a hot
812    /// Route/Schema node (task seed or PPR) reaches its handler/definer
813    /// symbol. Deterministic: nodes and projection edges are id-sorted.
814    ///
815    /// Returns `(symbol id, projected score)` pairs, id-sorted.
816// trace:v1 id=impl.scc.pagerank.project-to-symbols work=WORK-SCC-014 satisfies=REQ-SCC-IR
817    pub fn project_to_symbols(&self, vector: &[f64]) -> Vec<(String, f64)> {
818        let mut scores: Vec<f64> = Vec::with_capacity(self.nodes.len());
819        for (i, _id) in self.nodes.iter().enumerate() {
820            let own = vector.get(i).copied().unwrap_or(0.0);
821            let mut bonus = 0.0;
822            for &j in self.projection[i].iter() {
823                bonus += vector.get(j).copied().unwrap_or(0.0);
824            }
825            scores.push(own + (PROJECTION_BONUS_FACTOR * bonus).min(PROJECTION_BONUS_CAP));
826        }
827        let mut out: Vec<(String, f64)> = Vec::new();
828        for (i, id) in self.nodes.iter().enumerate() {
829            if self.kinds[i] == kinds::SYMBOL {
830                out.push((id.clone(), scores[i]));
831            }
832        }
833        out
834    }
835
836    /// Task-personalized PageRank vector. Seeds come from `TaskSeed`s whose
837    /// `id` is a rankable entity id; the seed vector carries `weight` at
838    /// each matched index. Warm start: 0.3 × global + 0.7 × normalized
839    /// seeds. Unresolvable seed sets fall back to the global vector (never
840    /// panic).
841// trace:exempt reason=internal-detail
842    pub fn task_vector(&self, seeds: &[TaskSeed]) -> Vec<f64> {
843        let n = self.nodes.len();
844        if n == 0 {
845            return Vec::new();
846        }
847        let mut s = vec![0.0; n];
848        let mut found = false;
849        for seed in seeds {
850            if let Some(&i) = self.index.get(&seed.id) {
851                s[i] += seed.weight.max(0.0);
852                found = true;
853            }
854        }
855        if !found {
856            return self.global.clone();
857        }
858        let sum: f64 = s.iter().sum();
859        if sum <= 0.0 {
860            return self.global.clone();
861        }
862        let s: Vec<f64> = s.iter().map(|v| v / sum).collect();
863        let mut start = vec![0.0; n];
864        for i in 0..n {
865            start[i] = WARM_START_GLOBAL_BLEND * self.global[i] + (1.0 - WARM_START_GLOBAL_BLEND) * s[i];
866        }
867        Self::ppr_with(&self.adjacency, &s, &start)
868    }
869
870    /// Architectural seed vector (weight 1.0 per seed): exported symbols,
871    /// symbols with entrypoint attributes, invocation-surface handlers,
872    /// primary entrypoints, state owners, contract producers, and flow
873    /// entrypoints. Applied to every node id (non-symbol nodes simply do
874    /// not carry symbol attributes).
875// trace:exempt reason=internal-detail
876    fn global_personalization(&self) -> Vec<f64> {
877        let n = self.nodes.len();
878        let mut seeds = vec![0.0; n];
879        // Flow entrypoints: flows record the entry symbol id in attributes.
880        let mut flow_eps: HashSet<String> = HashSet::new();
881        for f in self.view.flows() {
882            if let Some(ep) = f.attributes.get("entrypoint").and_then(|v| v.as_str()) {
883                flow_eps.insert(ep.to_string());
884            }
885        }
886        for (i, id) in self.nodes.iter().enumerate() {
887            let mut seed = false;
888            if let Some(e) = self.view.entity(id) {
889                let exported = e
890                    .attributes
891                    .get("exported")
892                    .and_then(|v| v.as_bool())
893                    .unwrap_or(false);
894                let has_entrypoints = e
895                    .attributes
896                    .get("entrypoints")
897                    .and_then(|v| v.as_array())
898                    .map(|a| !a.is_empty())
899                    .unwrap_or(false);
900                seed = seed || exported || has_entrypoints;
901            }
902            if flow_eps.contains(id.as_str()) {
903                seed = true;
904            }
905            for r in self.view.out_edges(id) {
906                let tk = self
907                    .view
908                    .entity(&r.object)
909                    .map(|e| e.kind.as_str())
910                    .unwrap_or("");
911                match r.predicate.as_str() {
912                    predicates::HANDLES if INVOCATION_KINDS.contains(&tk) => seed = true,
913                    predicates::OWNS if tk == kinds::STATE || tk == kinds::REACTIVE => seed = true,
914                    predicates::REGISTERS | predicates::PUBLISHES
915                        if CONTRACT_KINDS.contains(&tk) =>
916                    {
917                        seed = true;
918                    }
919                    _ => {}
920                }
921            }
922            if seed {
923                seeds[i] = 1.0;
924            }
925        }
926        seeds
927    }
928
929    /// Personalized power iteration: `r' = (1-d)·s + d·(M^T r +
930    /// dangling/n)`, `POWER_ITERATIONS` times, starting from `start`.
931// trace:exempt reason=internal-detail
932    fn ppr_with(adjacency: &[Vec<(usize, f64)>], personalization: &[f64], start: &[f64]) -> Vec<f64> {
933        let n = adjacency.len();
934        if n == 0 {
935            return Vec::new();
936        }
937        let mut r = start.to_vec();
938        let d = DAMPING_FACTOR;
939        for _ in 0..POWER_ITERATIONS {
940            let mut nr = vec![0.0; n];
941            let mut dangling = 0.0;
942            for (i, row) in adjacency.iter().enumerate() {
943                let ri = r[i];
944                if row.is_empty() {
945                    dangling += ri;
946                } else {
947                    for (j, w) in row {
948                        nr[*j] += ri * w;
949                    }
950                }
951            }
952            let d_mass = d * dangling / n as f64;
953            for k in 0..n {
954                nr[k] = (1.0 - d) * personalization[k] + d * nr[k] + d_mass;
955            }
956            r = nr;
957        }
958        r
959    }
960
961    /// Global run: personalization = normalized architectural seeds; start
962    /// state = the same seed distribution (uniform when no seed fires).
963// trace:exempt reason=internal-detail
964    fn ppr(adjacency: &[Vec<(usize, f64)>], seeds: &[f64]) -> Vec<f64> {
965        let n = adjacency.len();
966        if n == 0 {
967            return Vec::new();
968        }
969        let sum: f64 = seeds.iter().sum();
970        let s: Vec<f64> = if sum > 0.0 {
971            seeds.iter().map(|v| v / sum).collect()
972        } else {
973            vec![1.0 / n as f64; n]
974        };
975        Self::ppr_with(adjacency, &s, &s)
976    }
977}
978
979// ---------------------------------------------------------------------------
980// Architectural specificity
981// ---------------------------------------------------------------------------
982
983/// Is the qualified name or path segment a generic utility token?
984// trace:exempt reason=internal-detail
985fn is_generic_utility(qualified_name: &str, path: &str) -> bool {
986    let mut segs: Vec<String> = Vec::new();
987    for part in path.split('/') {
988        for piece in part.split('.') {
989            if !piece.is_empty() {
990                segs.push(piece.to_lowercase());
991            }
992        }
993    }
994    for part in qualified_name.split(['.', ':']) {
995        if !part.is_empty() {
996            segs.push(part.to_lowercase());
997        }
998    }
999    segs.iter().any(|s| GENERIC_NAMES.contains(&s.as_str()))
1000}
1001
1002/// Does the path point into test code (`test`/`tests`/`spec` segments,
1003/// `test_*`/`*_test` files)?
1004// trace:exempt reason=internal-detail
1005fn path_has_test(path: &str) -> bool {
1006    path.split('/').any(|seg| {
1007        let seg = seg.to_lowercase();
1008        seg == "test"
1009            || seg == "tests"
1010            || seg == "spec"
1011            || seg.starts_with("test_")
1012            || seg.ends_with("_test")
1013    })
1014}
1015
1016/// Does the path point at generated code (`generated`/`gen_*` segments,
1017/// protobuf markers)?
1018// trace:exempt reason=internal-detail
1019fn is_generated(path: &str) -> bool {
1020    path.split('/').any(|seg| {
1021        let seg = seg.to_lowercase();
1022        seg.contains("generated")
1023            || seg.starts_with("gen_")
1024            || seg.ends_with("_pb2.py")
1025            || seg.ends_with("_pb.go")
1026    })
1027}
1028
1029/// Does the path point into vendored code?
1030// trace:exempt reason=internal-detail
1031fn is_vendored(path: &str) -> bool {
1032    path.split('/').any(|seg| {
1033        matches!(
1034            seg,
1035            "vendor" | "third_party" | "node_modules" | ".venv" | "site-packages" | "bower_components"
1036        )
1037    })
1038}
1039
1040/// Ubiquity: distinct referencing sources of the symbol in the reference
1041/// graph (only predicates that normalize to a reference kind).
1042// trace:exempt reason=internal-detail
1043fn ubiquity(view: &TrustedGraphView, symbol_id: &str) -> usize {
1044    let mut sources: HashSet<&str> = HashSet::new();
1045    for r in view.in_edges(symbol_id) {
1046        let tk = view
1047            .entity(&r.object)
1048            .map(|e| e.kind.as_str())
1049            .unwrap_or("");
1050        if reference_kind(&r.predicate, tk).is_some() {
1051            sources.insert(&r.subject);
1052        }
1053    }
1054    sources.len()
1055}
1056
1057/// Architectural specificity of a surface entry: a multiplier in
1058/// [0.25, 1.25] that boosts public facades/entrypoints/state owners/
1059/// contract endpoints/flow participants and penalizes generic utilities,
1060/// test/generated/vendored code, and ubiquitous symbols (in-degree above
1061/// [`UBIQUITY_THRESHOLD`]). Apply it to PPR values before blending so
1062/// central utilities never dominate the ranking.
1063// trace:exempt reason=internal-detail
1064pub fn architectural_specificity(entry: &SurfaceEntry, view: &TrustedGraphView) -> f64 {
1065    let mut score: f64 = 1.0;
1066    if entry.exported {
1067        score += 0.15;
1068    }
1069    if !entry.invocation_surfaces.is_empty() {
1070        score += 0.10;
1071    }
1072    if !entry.state_authorities.is_empty() {
1073        score += 0.10;
1074    }
1075    if !entry.contracts.is_empty() {
1076        score += 0.10;
1077    }
1078    if !entry.flows.is_empty() {
1079        score += 0.05;
1080    }
1081    // Public facade: exported member of a component.
1082    if entry.exported && entry.component.is_some() {
1083        score += 0.05;
1084    }
1085
1086    let mut factor = 1.0;
1087    if is_generic_utility(&entry.qualified_name, &entry.path) {
1088        factor *= 0.5;
1089    }
1090    if path_has_test(&entry.path) {
1091        factor *= 0.5;
1092    }
1093    if is_generated(&entry.path) {
1094        factor *= 0.5;
1095    }
1096    if is_vendored(&entry.path) {
1097        factor *= 0.5;
1098    }
1099    if ubiquity(view, &entry.symbol_id) > UBIQUITY_THRESHOLD {
1100        factor *= 0.5;
1101    }
1102
1103    (score * factor).clamp(0.25, 1.25)
1104}
1105
1106// ---------------------------------------------------------------------------
1107// Final importance blend
1108// ---------------------------------------------------------------------------
1109
1110/// 30% task PPR.
1111pub const TASK_PPR_WEIGHT: f64 = 0.30;
1112/// 20% global PPR (50% when no task focus).
1113pub const GLOBAL_PPR_WEIGHT: f64 = 0.20;
1114/// 15% lexical overlap.
1115pub const LEXICAL_WEIGHT: f64 = 0.15;
1116/// 10% semantic relevance.
1117pub const SEMANTIC_WEIGHT: f64 = 0.10;
1118/// 10% evidence confidence.
1119pub const CONFIDENCE_WEIGHT: f64 = 0.10;
1120/// 10% criticality.
1121pub const CRITICALITY_WEIGHT: f64 = 0.10;
1122/// 5% change/risk.
1123pub const CHANGE_RISK_WEIGHT: f64 = 0.05;
1124/// Novelty bonus weight (additive term).
1125pub const NOVELTY_WEIGHT: f64 = 0.05;
1126/// Global PPR weight when there is no task focus (task share moves to
1127/// global: 20% + 30% = 50%).
1128pub const NO_TASK_GLOBAL_WEIGHT: f64 = 0.50;
1129
1130/// The spec's final importance blend. All inputs are expected in [0, 1];
1131/// the novelty term is additive on top. With no task focus, the task-PPR
1132/// share moves to the global vector (global gets 50%).
1133///
1134/// `has_task` — is this ranking task-focused (true) or the startup/global
1135/// surface (false)?
1136#[allow(clippy::too_many_arguments)]
1137// trace:exempt reason=internal-detail
1138pub fn final_importance(
1139    task_ppr: f64,
1140    global_ppr: f64,
1141    lexical: f64,
1142    semantic: f64,
1143    confidence: f64,
1144    criticality: f64,
1145    change_risk: f64,
1146    novelty: f64,
1147    has_task: bool,
1148) -> f64 {
1149    let (tw, gw) = if has_task {
1150        (TASK_PPR_WEIGHT, GLOBAL_PPR_WEIGHT)
1151    } else {
1152        (0.0, NO_TASK_GLOBAL_WEIGHT)
1153    };
1154    tw * task_ppr
1155        + gw * global_ppr
1156        + LEXICAL_WEIGHT * lexical
1157        + SEMANTIC_WEIGHT * semantic
1158        + CONFIDENCE_WEIGHT * confidence
1159        + CRITICALITY_WEIGHT * criticality
1160        + CHANGE_RISK_WEIGHT * change_risk
1161        + NOVELTY_WEIGHT * novelty
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167    use scc_core::{entity_id, Entity, Relationship, Visibility};
1168    use scc_graph::{RealityGraph, TrustPolicy};
1169    use scc_store::Store;
1170    use std::collections::HashMap;
1171
1172// trace:exempt reason=internal-detail
1173    fn fixture(
1174        entities: Vec<Entity>,
1175        rels: Vec<Relationship>,
1176    ) -> (tempfile::TempDir, Store, RealityGraph) {
1177        let dir = tempfile::TempDir::new().unwrap();
1178        let root = dir.path().join("repo");
1179        std::fs::create_dir_all(&root).unwrap();
1180        let store = Store::open(&dir.path().join("scc.db"), &root).unwrap();
1181        for e in &entities {
1182            store.insert_entity(e, &["src/fixture.ts".to_string()]).unwrap();
1183        }
1184        for r in &rels {
1185            store.insert_relationship(r, "src/fixture.ts").unwrap();
1186        }
1187        let mut out: HashMap<String, Vec<Relationship>> = HashMap::new();
1188        let mut inn: HashMap<String, Vec<Relationship>> = HashMap::new();
1189        for r in &rels {
1190            out.entry(r.subject.clone()).or_default().push(r.clone());
1191            inn.entry(r.object.clone()).or_default().push(r.clone());
1192        }
1193        let graph = RealityGraph {
1194            repo_id: "r".into(),
1195            entities: entities.into_iter().map(|e| (e.id.clone(), e)).collect(),
1196            out,
1197            inn,
1198            components: vec![],
1199            flows: vec![],
1200            invariants: vec![],
1201        };
1202        (dir, store, graph)
1203    }
1204
1205// trace:exempt reason=internal-detail
1206    fn sym(repo: &str, path: &str, name: &str) -> Entity {
1207        let mut e = Entity::new(
1208            scc_core::symbol_id(repo, path, name),
1209            kinds::SYMBOL,
1210            name,
1211        );
1212        e.attr("file", serde_json::json!(path));
1213        e.attr("exported", serde_json::json!(false));
1214        e.attr("start_line", serde_json::json!(1));
1215        e.attr("end_line", serde_json::json!(10));
1216        e
1217    }
1218
1219// trace:exempt reason=internal-detail
1220    fn entity(id: &str, kind: &str, name: &str) -> Entity {
1221        Entity::new(id, kind, name)
1222    }
1223
1224// trace:exempt reason=internal-detail
1225    fn rel(n: u64, subject: &str, pred: &str, object: &str, prov: Provenance) -> Relationship {
1226        Relationship::new(format!("rel:{n}"), subject, pred, object, prov)
1227    }
1228
1229    // ---- (a) reference normalization maps the right kinds ----
1230
1231    #[test]
1232// trace:exempt reason=internal-detail
1233    fn reference_normalization_maps_kinds() {
1234        let (a, b, i, c, s, t, m, x, d) = (
1235            scc_core::symbol_id("r", "src/a.ts", "A"),
1236            scc_core::symbol_id("r", "src/b.ts", "B"),
1237            scc_core::symbol_id("r", "src/i.ts", "I"),
1238            "repo://r/contract/c",
1239            "repo://r/state/s",
1240            "repo://r/topic/t",
1241            "repo://r/module/m",
1242            "repo://r/export/x",
1243            "repo://r/annotation/d",
1244        );
1245        let mut a_ent = sym("r", "src/a.ts", "A");
1246        a_ent.attr("file", serde_json::json!("src/a.ts"));
1247        a_ent.attr("start_line", serde_json::json!(10));
1248        a_ent.attr("end_line", serde_json::json!(20));
1249        let b_ent = sym("r", "src/b.ts", "B");
1250        let i_ent = sym("r", "src/i.ts", "I");
1251        let c_ent = entity(c, kinds::CONTRACT, "c");
1252        let s_ent = entity(s, kinds::STATE, "s");
1253        let t_ent = entity(t, kinds::TOPIC, "t");
1254        let m_ent = entity(m, kinds::MODULE, "m");
1255        let x_ent = entity(x, kinds::EXPORT, "x");
1256        let d_ent = entity(d, kinds::ANNOTATION, "d");
1257
1258        let mut rels = vec![
1259            rel(1, &a, predicates::CALLS, &b, Provenance::Extracted),
1260            rel(2, &a, predicates::INVOKES, &b, Provenance::Extracted),
1261            rel(3, &a, predicates::HANDLES_CALLBACK, &b, Provenance::Extracted),
1262            rel(4, &a, predicates::IMPLEMENTS, &i, Provenance::Extracted),
1263            rel(5, &i, predicates::IMPLEMENTED_BY, &a, Provenance::Extracted),
1264            rel(6, &a, predicates::INHERITS, &b, Provenance::Extracted),
1265            rel(7, &a, predicates::REGISTERS, c, Provenance::Extracted),
1266            rel(8, &a, predicates::PUBLISHES, t, Provenance::Extracted),
1267            rel(9, &b, predicates::SUBSCRIBES, t, Provenance::Extracted),
1268            rel(10, &a, predicates::OWNS, s, Provenance::Extracted),
1269            rel(11, &a, predicates::READS, s, Provenance::Extracted),
1270            rel(12, &b, predicates::WRITES, s, Provenance::Extracted),
1271            rel(13, &a, predicates::IMPORTS, m, Provenance::Extracted),
1272            rel(14, &a, predicates::EXPORTS, x, Provenance::Extracted),
1273            rel(15, &a, predicates::DECORATES, d, Provenance::Extracted),
1274            // OWNS of a non-state entity must NOT normalize to Write.
1275            rel(16, &a, predicates::OWNS, c, Provenance::Extracted),
1276            // Confidence passthrough.
1277            rel(17, &a, predicates::CALLS, d, Provenance::Extracted),
1278        ];
1279        rels[16].confidence = 0.7;
1280        let (_dir, store, graph) = fixture(
1281            vec![a_ent.clone(), b_ent, i_ent, c_ent, s_ent, t_ent, m_ent, x_ent, d_ent],
1282            rels,
1283        );
1284        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1285        let edges = build_reference_graph(&view);
1286
1287        let kinds_between = |src: &str, tgt: &str| -> Vec<ReferenceKind> {
1288            let mut v: Vec<ReferenceKind> = edges
1289                .iter()
1290                .filter(|e| e.source_symbol == src && e.target_symbol == tgt)
1291                .map(|e| e.kind)
1292                .collect();
1293            v.sort();
1294            v
1295        };
1296
1297        // calls/invokes/handles_callback → Call (3 edges) and
1298        // inherits → Extend (all A -> B edges).
1299        assert_eq!(
1300            kinds_between(&a, &b),
1301            vec![
1302                ReferenceKind::Call,
1303                ReferenceKind::Call,
1304                ReferenceKind::Call,
1305                ReferenceKind::Extend
1306            ]
1307        );
1308        // implements + implemented_by → Implement (both directions normalize
1309        // to A -> I).
1310        assert_eq!(kinds_between(&a, &i), vec![ReferenceKind::Implement; 2]);
1311        // registers/publishes/subscribes → Register.
1312        assert_eq!(kinds_between(&a, c), vec![ReferenceKind::Register]);
1313        assert_eq!(kinds_between(&a, t), vec![ReferenceKind::Register]);
1314        assert_eq!(kinds_between(&b, t), vec![ReferenceKind::Register]);
1315        // owns(state) → Write; reads → Read; writes → Write.
1316        let mut as_edges = kinds_between(&a, s);
1317        as_edges.sort();
1318        assert_eq!(as_edges, vec![ReferenceKind::Read, ReferenceKind::Write]);
1319        assert_eq!(kinds_between(&b, s), vec![ReferenceKind::Write]);
1320        // imports → Import; exports → Export; decorates → Decorate.
1321        assert_eq!(kinds_between(&a, m), vec![ReferenceKind::Import]);
1322        assert_eq!(kinds_between(&a, x), vec![ReferenceKind::Export]);
1323        assert_eq!(kinds_between(&a, d), vec![ReferenceKind::Call, ReferenceKind::Decorate]);
1324        // OWNS of a contract is NOT normalized (no Write A->C).
1325        assert!(!kinds_between(&a, c).contains(&ReferenceKind::Write));
1326
1327        // Locations: evidence file/line from the subject symbol attrs.
1328        let call = edges
1329            .iter()
1330            .find(|e| e.source_symbol == a && e.target_symbol == b && e.kind == ReferenceKind::Call)
1331            .unwrap();
1332        assert!(call.locations.contains(&SourceRange::new("src/a.ts", 10, 20)));
1333        assert!(call.locations.contains(&SourceRange::new("src/b.ts", 1, 10)));
1334
1335        // Confidence passthrough: default 1.0; explicit 0.7 preserved.
1336        assert_eq!(call.confidence, 1.0);
1337        let low = edges
1338            .iter()
1339            .find(|e| e.source_symbol == a && e.target_symbol == d && e.kind == ReferenceKind::Call)
1340            .unwrap();
1341        assert!((low.confidence - 0.7).abs() < 1e-6);
1342    }
1343
1344    // ---- (b) rarity downweights ubiquitous vs distinctive ----
1345
1346    #[test]
1347// trace:exempt reason=internal-detail
1348    fn rarity_downweights_ubiquitous() {
1349        // Distinctive target (in-degree 1) is far rarer than a ubiquitous
1350        // logger-ish target (in-degree 50).
1351        let logger = rarity(100, 50);
1352        let distinctive = rarity(100, 1);
1353        assert!(logger < distinctive);
1354        assert!((logger - (100.0_f64 / 51.0).ln()).abs() < 1e-9);
1355
1356        // Clamp bounds.
1357        assert_eq!(rarity(100, 10_000), 0.25);
1358        assert_eq!(rarity(100, 0), 1.5);
1359        assert_eq!(rarity(0, 0), 1.0);
1360
1361        // Full edge weight: ubiquitous target downweights the edge.
1362        let w_common = edge_weight(predicates::CALLS, Provenance::Extracted, 1.0, 100, 50);
1363        let w_rare = edge_weight(predicates::CALLS, Provenance::Extracted, 1.0, 100, 1);
1364        assert!(w_common < w_rare);
1365
1366        // Provenance: STALE weighs zero; RESOLVED calls weigh more than
1367        // EXTRACTED calls.
1368        assert_eq!(edge_weight(predicates::CALLS, Provenance::Stale, 1.0, 100, 1), 0.0);
1369        let resolved = edge_weight(predicates::CALLS, Provenance::Resolved, 1.0, 100, 1);
1370        let extracted = edge_weight(predicates::CALLS, Provenance::Extracted, 1.0, 100, 1);
1371        assert!(resolved > extracted);
1372    }
1373
1374    // ---- (c) central utility pollution is corrected by specificity ----
1375
1376    #[test]
1377// trace:exempt reason=internal-detail
1378    fn utility_pollution_ranks_below_public_api() {
1379        let api_id = scc_core::symbol_id("r", "src/api/order.ts", "OrderApi");
1380        let util_id = scc_core::symbol_id("r", "src/utils/helpers.ts", "helpers");
1381        let mut api_ent = sym("r", "src/api/order.ts", "OrderApi");
1382        api_ent.attr("exported", serde_json::json!(true));
1383        api_ent.attr("entrypoints", serde_json::json!(["http"]));
1384        let util_ent = sym("r", "src/utils/helpers.ts", "helpers");
1385
1386        let mut entities = vec![api_ent.clone(), util_ent.clone()];
1387        let mut rels: Vec<Relationship> = Vec::new();
1388        let mut n = 1u64;
1389        // 40 callers all invoke the util symbol.
1390        for i in 0..40 {
1391            let caller = scc_core::symbol_id("r", "src/callers/c.rs", &format!("c{i}"));
1392            entities.push(sym("r", "src/callers/c.rs", &format!("c{i}")));
1393            rels.push(rel(
1394                n,
1395                &caller,
1396                predicates::CALLS,
1397                &util_id,
1398                Provenance::Extracted,
1399            ));
1400            n += 1;
1401        }
1402        // Two callers reach the public API.
1403        let c1 = scc_core::symbol_id("r", "src/callers/c.rs", "c0");
1404        let c2 = scc_core::symbol_id("r", "src/callers/c.rs", "c1");
1405        rels.push(rel(n, &c1, predicates::CALLS, &api_id, Provenance::Extracted));
1406        n += 1;
1407        rels.push(rel(n, &c2, predicates::CALLS, &api_id, Provenance::Extracted));
1408
1409        let (_dir, store, graph) = fixture(entities, rels);
1410        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1411        let ranker = SystemRanker::new(&view);
1412        let g = ranker.global_vector();
1413        let util_idx = ranker.index_of(&util_id).unwrap();
1414        let api_idx = ranker.index_of(&api_id).unwrap();
1415
1416        // Specificity: util is generic + ubiquitous (40 distinct callers >
1417        // threshold) → floor; api is an exported entrypoint facade → cap.
1418        let util_entry = surface_entry(&util_id, "helpers", "src/utils/helpers.ts", false);
1419        let api_entry = surface_entry(&api_id, "OrderApi", "src/api/order.ts", true);
1420        let spec_util = architectural_specificity(&util_entry, &view);
1421        let spec_api = architectural_specificity(&api_entry, &view);
1422        assert_eq!(spec_util, 0.25);
1423        assert_eq!(spec_api, 1.25);
1424        assert!(spec_api > spec_util);
1425
1426        // After specificity, the public API ranks above the central utility
1427        // even though the utility is referenced by far more callers.
1428        assert!(g[api_idx] * spec_api > g[util_idx] * spec_util);
1429    }
1430
1431    // ---- (d) task personalization lifts billing seeds above unrelated ----
1432
1433    #[test]
1434// trace:exempt reason=internal-detail
1435    fn task_personalization_ranks_billing_above_unrelated() {
1436        let billing_client = scc_core::symbol_id("r", "src/billing/client.ts", "BillingClient");
1437        let billing_retry = scc_core::symbol_id("r", "src/billing/client.ts", "BillingClient.retry");
1438        let billing_worker = scc_core::symbol_id("r", "src/billing/worker.ts", "BillingWorker");
1439        let billing_process = scc_core::symbol_id("r", "src/billing/worker.ts", "BillingWorker.process");
1440        let auth_service = scc_core::symbol_id("r", "src/auth/service.ts", "AuthService");
1441        let auth_login = scc_core::symbol_id("r", "src/auth/service.ts", "AuthService.login");
1442        let logger = scc_core::symbol_id("r", "src/logger.ts", "Logger");
1443        let logger_log = scc_core::symbol_id("r", "src/logger.ts", "Logger.log");
1444
1445        let entities = vec![
1446            sym("r", "src/billing/client.ts", "BillingClient"),
1447            sym("r", "src/billing/client.ts", "BillingClient.retry"),
1448            sym("r", "src/billing/worker.ts", "BillingWorker"),
1449            sym("r", "src/billing/worker.ts", "BillingWorker.process"),
1450            sym("r", "src/auth/service.ts", "AuthService"),
1451            sym("r", "src/auth/service.ts", "AuthService.login"),
1452            sym("r", "src/logger.ts", "Logger"),
1453            sym("r", "src/logger.ts", "Logger.log"),
1454        ];
1455        let rels = vec![
1456            rel(1, &billing_client, predicates::CALLS, &billing_retry, Provenance::Extracted),
1457            rel(2, &billing_worker, predicates::CALLS, &billing_process, Provenance::Extracted),
1458            rel(3, &billing_process, predicates::CALLS, &billing_retry, Provenance::Extracted),
1459            rel(4, &auth_service, predicates::CALLS, &auth_login, Provenance::Extracted),
1460            rel(5, &logger, predicates::CALLS, &logger_log, Provenance::Extracted),
1461        ];
1462
1463        let (_dir, store, graph) = fixture(entities, rels);
1464        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1465        let ranker = SystemRanker::new(&view);
1466
1467        let seeds = vec![
1468            TaskSeed {
1469                kind: "symbol".into(),
1470                id: billing_retry.clone(),
1471                weight: 1.0,
1472            },
1473            TaskSeed {
1474                kind: "symbol".into(),
1475                id: billing_process.clone(),
1476                weight: 1.0,
1477            },
1478        ];
1479        let tv = ranker.task_vector(&seeds);
1480        let i_retry = ranker.index_of(&billing_retry).unwrap();
1481        let i_process = ranker.index_of(&billing_process).unwrap();
1482        let i_login = ranker.index_of(&auth_login).unwrap();
1483        let i_log = ranker.index_of(&logger_log).unwrap();
1484
1485        // Billing seeds rank above unrelated symbols.
1486        assert!(tv[i_retry] > tv[i_login]);
1487        assert!(tv[i_retry] > tv[i_log]);
1488        assert!(tv[i_process] > tv[i_login]);
1489        assert!(tv[i_process] > tv[i_log]);
1490        // Both seeds beat a symbol that merely calls into the unrelated
1491        // cluster's leaf.
1492        assert!(tv[i_retry] > tv[ranker.index_of(&logger).unwrap()]);
1493
1494        // Unresolvable seeds degrade to the global vector (no panic).
1495        let junk = vec![TaskSeed { kind: "symbol".into(), id: "repo://r/symbol/nope/Nope".into(), weight: 1.0 }];
1496        let fallback = ranker.task_vector(&junk);
1497        let g = ranker.global_vector();
1498        assert_eq!(fallback, g);
1499        assert_eq!(fallback.len(), tv.len());
1500    }
1501
1502    // ---- helper for SurfaceEntry fixtures ----
1503
1504// trace:exempt reason=internal-detail
1505    fn surface_entry(symbol_id: &str, name: &str, path: &str, exported: bool) -> SurfaceEntry {
1506        let mut entry = SurfaceEntry {
1507            id: symbol_id.to_string(),
1508            symbol_id: symbol_id.to_string(),
1509            qualified_name: name.to_string(),
1510            kind: scc_core::SurfaceKind::Function,
1511            path: path.to_string(),
1512            range: SourceRange::new(path, 1, 1),
1513            source_signature: String::new(),
1514            canonical_signature: String::new(),
1515            semantic_signature: scc_core::SemanticSignature::default(),
1516            visibility: Visibility::Public,
1517            exported,
1518            modifiers: vec![],
1519            annotations: vec![],
1520            component: Some("Order".to_string()),
1521            subsystem: None,
1522            flows: vec![],
1523            contracts: vec![],
1524            state_authorities: vec![],
1525            invocation_surfaces: vec![],
1526            callers: vec![],
1527            callees: vec![],
1528            caller_count: 0,
1529            callee_count: 0,
1530            importance: None,
1531            provenance: Provenance::Extracted,
1532            confidence: 1.0,
1533            rank: scc_core::SurfaceRank::default(),
1534        };
1535        if exported {
1536            entry.invocation_surfaces.push("http".into());
1537            entry.flows.push("f1".into());
1538            entry.contracts.push("c1".into());
1539        }
1540        entry
1541    }
1542
1543    // ---- final_importance blend ----
1544
1545    #[test]
1546// trace:exempt reason=internal-detail
1547    fn final_importance_blend_constants() {
1548        // All signals at 1.0 with task focus: 0.30+0.20+0.15+0.10+0.10+
1549        // 0.10+0.05+0.05 = 1.05 (novelty is additive).
1550        let v = final_importance(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, true);
1551        assert!((v - 1.05).abs() < 1e-9);
1552
1553        // Task focus weights task PPR over global.
1554        let tasky = final_importance(1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true);
1555        assert!((tasky - 0.30).abs() < 1e-9);
1556
1557        // No task: task share moves to global (50%).
1558        let globaly = final_importance(1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, false);
1559        assert!((globaly - 0.25).abs() < 1e-9);
1560
1561        // No task with full global: 0.50 + 0.15 + 0.10 + 0.10 + 0.10 + 0.05
1562        // + 0.05 = 1.05.
1563        let full = final_importance(1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, false);
1564        assert!((full - 1.05).abs() < 1e-9);
1565    }
1566
1567    // ---- architectural_specificity penalties ----
1568
1569    #[test]
1570// trace:exempt reason=internal-detail
1571    fn specificity_penalizes_generic_test_and_vendored() {
1572        let (_dir, store, graph) = fixture(vec![sym("r", "src/x.ts", "X")], vec![]);
1573        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1574
1575        let util = surface_entry("s1", "logger", "src/utils/logger.ts", false);
1576        // Generic utility name/path → 0.5 (symbol "s1" is not in the graph,
1577        // so no ubiquity penalty; 0.5 is the single-penalty floor here).
1578        assert_eq!(architectural_specificity(&util, &view), 0.5);
1579
1580        let testy = surface_entry("s2", "TestHelper", "tests/unit/helper_test.rs", false);
1581        assert_eq!(architectural_specificity(&testy, &view), 0.5);
1582
1583        let vendored = surface_entry("s3", "Dep", "vendor/dep/src/lib.rs", false);
1584        assert_eq!(architectural_specificity(&vendored, &view), 0.5);
1585
1586        let generated = surface_entry("s4", "Proto", "src/generated/models.rs", false);
1587        assert_eq!(architectural_specificity(&generated, &view), 0.5);
1588
1589        // Plain exported facade with no penalties hits the cap.
1590        let facade = surface_entry("s5", "OrderApi", "src/api/order.ts", true);
1591        assert_eq!(architectural_specificity(&facade, &view), 1.25);
1592    }
1593
1594    // ---- heterogeneous universe: non-symbol entities participate ----
1595
1596    #[test]
1597// trace:exempt reason=internal-detail
1598    fn ranker_universe_is_heterogeneous() {
1599        let owner = scc_core::symbol_id("r", "src/a.ts", "A");
1600        let state_id = entity_id("r", kinds::STATE, "sessions");
1601        let contract_id = entity_id("r", kinds::CONTRACT, "c1");
1602        let topic_id = entity_id("r", kinds::TOPIC, "orders");
1603        let store_id = entity_id("r", kinds::DATA_STORE, "pg");
1604        let flow_id = entity_id("r", kinds::FLOW, "signup");
1605        let file_id = entity_id("r", kinds::FILE, "src/a.ts");
1606
1607        let mut owner_ent = sym("r", "src/a.ts", "A");
1608        owner_ent.attr("exported", serde_json::json!(true));
1609        owner_ent.attr("entrypoints", serde_json::json!(["http"]));
1610        let entities = vec![
1611            owner_ent,
1612            entity(&state_id, kinds::STATE, "sessions"),
1613            entity(&contract_id, kinds::CONTRACT, "c1"),
1614            entity(&topic_id, kinds::TOPIC, "orders"),
1615            entity(&store_id, kinds::DATA_STORE, "pg"),
1616            entity(&flow_id, kinds::FLOW, "signup"),
1617            entity(&file_id, kinds::FILE, "src/a.ts"),
1618        ];
1619        let n = 1u64;
1620        let rels = vec![
1621            rel(n, &owner, predicates::OWNS, &state_id, Provenance::Extracted),
1622            rel(n + 1, &owner, predicates::REGISTERS, &contract_id, Provenance::Extracted),
1623            rel(n + 2, &owner, predicates::PUBLISHES, &topic_id, Provenance::Extracted),
1624            rel(n + 3, &owner, predicates::READS, &store_id, Provenance::Extracted),
1625            rel(n + 4, &owner, predicates::WRITES, &store_id, Provenance::Extracted),
1626            rel(n + 5, &owner, predicates::PARTICIPATES_IN, &flow_id, Provenance::Extracted),
1627        ];
1628
1629        let (_dir, store, graph) = fixture(entities, rels);
1630        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1631        let ranker = SystemRanker::new(&view);
1632
1633        // Every rankable entity kind is a node.
1634        for id in [
1635            owner.as_str(),
1636            state_id.as_str(),
1637            contract_id.as_str(),
1638            topic_id.as_str(),
1639            store_id.as_str(),
1640            flow_id.as_str(),
1641            file_id.as_str(),
1642        ] {
1643            assert!(ranker.index_of(id).is_some(), "{id} must be a node");
1644        }
1645        // Non-rankable kinds (module) are not nodes.
1646        assert!(ranker.index_of(&entity_id("r", kinds::MODULE, "m")).is_none());
1647
1648        // The heterogeneous vectors are indexed over all nodes.
1649        let g = ranker.global_vector();
1650        assert_eq!(g.len(), ranker.nodes().len());
1651
1652        // Non-symbol nodes carry real PPR mass (the edges feed them).
1653        let owner_i = ranker.index_of(&owner).unwrap();
1654        let state_i = ranker.index_of(&state_id).unwrap();
1655        assert!(g[state_i] > 0.0, "state node must receive PPR mass");
1656        assert!(g[owner_i] > 0.0);
1657
1658        // Symbols() returns exactly the symbol nodes.
1659        let syms = ranker.symbols();
1660        assert_eq!(syms, vec![owner.clone()]);
1661    }
1662
1663    #[test]
1664// trace:exempt reason=internal-detail
1665    fn project_to_symbols_lifts_owned_entity_scores() {
1666        let owner = scc_core::symbol_id("r", "src/a.ts", "A");
1667        let other = scc_core::symbol_id("r", "src/b.ts", "B");
1668        let state_id = entity_id("r", kinds::STATE, "sessions");
1669        let contract_id = entity_id("r", kinds::CONTRACT, "c1");
1670
1671        let mut owner_ent = sym("r", "src/a.ts", "A");
1672        owner_ent.attr("exported", serde_json::json!(true));
1673        owner_ent.attr("entrypoints", serde_json::json!(["http"]));
1674        let entities = vec![
1675            owner_ent,
1676            sym("r", "src/b.ts", "B"),
1677            entity(&state_id, kinds::STATE, "sessions"),
1678            entity(&contract_id, kinds::CONTRACT, "c1"),
1679        ];
1680        let rels = vec![
1681            rel(1, &owner, predicates::OWNS, &state_id, Provenance::Extracted),
1682            rel(2, &owner, predicates::REGISTERS, &contract_id, Provenance::Extracted),
1683            // B's OWNS of the same state also projects (both owners lift)
1684            rel(3, &other, predicates::OWNS, &state_id, Provenance::Extracted),
1685        ];
1686        let (_dir, store, graph) = fixture(entities, rels);
1687        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1688        let ranker = SystemRanker::new(&view);
1689
1690        let g = ranker.global_vector();
1691        let projected = ranker.project_to_symbols(&g);
1692        let score_of = |id: &str| -> f64 {
1693            projected
1694                .iter()
1695                .find(|(s, _)| s == id)
1696                .map(|(_, v)| *v)
1697                .unwrap_or(f64::NAN)
1698        };
1699
1700        let own_a = score_of(&owner);
1701        let own_b = score_of(&other);
1702        let state_score = g[ranker.index_of(&state_id).unwrap()];
1703        let contract_score = g[ranker.index_of(&contract_id).unwrap()];
1704
1705        // A owns state + registers contract → bonus = 0.4 × (state+contract),
1706        // capped at 0.5. B only owns the state → smaller bonus.
1707        let bonus_a = (0.4 * (state_score + contract_score)).min(0.5);
1708        let bonus_b = (0.4 * state_score).min(0.5);
1709        assert!((own_a - (g[ranker.index_of(&owner).unwrap()] + bonus_a)).abs() < 1e-9);
1710        assert!((own_b - (g[ranker.index_of(&other).unwrap()] + bonus_b)).abs() < 1e-9);
1711        // The owner with the richer entity set ranks above the plain owner.
1712        assert!(own_a > own_b);
1713
1714        // Cap: a symbol owning a huge-massed state cannot exceed own + 0.5.
1715        let mut big_v = vec![0.0; g.len()];
1716        big_v[ranker.index_of(&state_id).unwrap()] = 100.0;
1717        big_v[ranker.index_of(&contract_id).unwrap()] = 100.0;
1718        let capped = ranker.project_to_symbols(&big_v);
1719        let cap_a = capped.iter().find(|(s, _)| s == &owner).unwrap().1;
1720        let own_a0 = big_v[ranker.index_of(&owner).unwrap()];
1721        assert!((cap_a - (own_a0 + 0.5)).abs() < 1e-9);
1722
1723        // Deterministic: same input → identical output.
1724        let again = ranker.project_to_symbols(&g);
1725        assert_eq!(again, projected);
1726    }
1727
1728    // ---- (e) rank-edge ontology: CONTAINS/PARTICIPATES_IN/HANDLES/DEFINES ----
1729
1730    #[test]
1731// trace:exempt reason=internal-detail
1732    fn rank_edge_kind_direction_and_weights() {
1733        // Deliberate direction mapping (real predicate strings):
1734        // contains only between kinds in the containment hierarchy with
1735        // the container before the member (any ordered pair — adjacent or
1736        // transitive); participates_in only symbol ↔ flow; handles only
1737        // symbol ↔ route; defines only symbol ↔ schema.
1738        // Every reviewer-named hierarchy pair fires: System→Subsystem,
1739        // Subsystem→Service, Subsystem→Component, Service→Component,
1740        // Component→File, Component→Symbol, File→Symbol — plus every
1741        // transitive container pair — with the stored member → container
1742        // direction mapping to the MemberOf ranking transition.
1743        for (container, member) in [
1744            (kinds::SYSTEM, kinds::SUBSYSTEM),
1745            (kinds::SUBSYSTEM, kinds::SERVICE),
1746            (kinds::SUBSYSTEM, kinds::COMPONENT),
1747            (kinds::SERVICE, kinds::COMPONENT),
1748            (kinds::COMPONENT, kinds::FILE),
1749            (kinds::COMPONENT, kinds::SYMBOL),
1750            (kinds::FILE, kinds::SYMBOL),
1751            // Transitive container pairs (the generalization covers ANY
1752            // ordered pair, not a hardcoded adjacency set).
1753            (kinds::SYSTEM, kinds::SERVICE),
1754            (kinds::SYSTEM, kinds::FILE),
1755            (kinds::SUBSYSTEM, kinds::SYMBOL),
1756            (kinds::SERVICE, kinds::FILE),
1757        ] {
1758            assert_eq!(
1759                rank_edge_kind(predicates::CONTAINS, container, member),
1760                Some(RankEdgeKind::Contains)
1761            );
1762            assert_eq!(
1763                rank_edge_kind(predicates::CONTAINS, member, container),
1764                Some(RankEdgeKind::MemberOf)
1765            );
1766        }
1767        // Stored member → container direction maps to the ranking transition.
1768        assert_eq!(
1769            rank_edge_kind(predicates::CONTAINS, kinds::SYMBOL, kinds::COMPONENT),
1770            Some(RankEdgeKind::MemberOf)
1771        );
1772        assert_eq!(
1773            rank_edge_kind(predicates::PARTICIPATES_IN, kinds::SYMBOL, kinds::FLOW),
1774            Some(RankEdgeKind::ParticipatesIn)
1775        );
1776        assert_eq!(
1777            rank_edge_kind(predicates::PARTICIPATES_IN, kinds::FLOW, kinds::SYMBOL),
1778            Some(RankEdgeKind::FlowReachesParticipant)
1779        );
1780        assert_eq!(
1781            rank_edge_kind(predicates::HANDLES, kinds::SYMBOL, kinds::ROUTE),
1782            Some(RankEdgeKind::Handles)
1783        );
1784        assert_eq!(
1785            rank_edge_kind(predicates::DEFINES, kinds::SYMBOL, kinds::SCHEMA),
1786            Some(RankEdgeKind::Defines)
1787        );
1788        // Reverse transitions: stored route → symbol maps to HandledBy,
1789        // stored schema → symbol maps to DefinedBy (ranking-only).
1790        assert_eq!(
1791            rank_edge_kind(predicates::HANDLES, kinds::ROUTE, kinds::SYMBOL),
1792            Some(RankEdgeKind::HandledBy)
1793        );
1794        assert_eq!(
1795            rank_edge_kind(predicates::DEFINES, kinds::SCHEMA, kinds::SYMBOL),
1796            Some(RankEdgeKind::DefinedBy)
1797        );
1798
1799        // Non-target endpoint kinds never fire (no `reference_kind` drift).
1800        assert_eq!(rank_edge_kind(predicates::CONTAINS, kinds::COMPONENT, kinds::FLOW), None);
1801        assert_eq!(rank_edge_kind(predicates::CONTAINS, kinds::SYMBOL, kinds::CONTRACT), None);
1802        assert_eq!(rank_edge_kind(predicates::CONTAINS, kinds::SYMBOL, kinds::FLOW), None);
1803        // Same-kind containment is not a hierarchy edge.
1804        assert_eq!(rank_edge_kind(predicates::CONTAINS, kinds::COMPONENT, kinds::COMPONENT), None);
1805        assert_eq!(rank_edge_kind(predicates::CONTAINS, kinds::SYMBOL, kinds::SYMBOL), None);
1806        assert_eq!(rank_edge_kind(predicates::PARTICIPATES_IN, kinds::SYMBOL, kinds::CONTRACT), None);
1807        assert_eq!(rank_edge_kind(predicates::HANDLES, kinds::SYMBOL, kinds::ENDPOINT), None);
1808        assert_eq!(rank_edge_kind(predicates::DEFINES, kinds::SYMBOL, kinds::CONTRACT), None);
1809        assert_eq!(rank_edge_kind(predicates::CALLS, kinds::SYMBOL, kinds::SYMBOL), None);
1810
1811        // Deliberate weights.
1812        assert_eq!(RankEdgeKind::Contains.weight(), RANK_MEMBERSHIP);
1813        assert_eq!(RankEdgeKind::MemberOf.weight(), RANK_MEMBER_OF);
1814        assert_eq!(RankEdgeKind::ParticipatesIn.weight(), RANK_PARTICIPATES);
1815        assert_eq!(RankEdgeKind::FlowReachesParticipant.weight(), RANK_FLOW_REACHES_PARTICIPANT);
1816        assert_eq!(RankEdgeKind::Handles.weight(), RANK_HANDLES);
1817        assert_eq!(RankEdgeKind::HandledBy.weight(), RANK_HANDLED_BY);
1818        assert_eq!(RankEdgeKind::Defines.weight(), RANK_DEFINES);
1819        assert_eq!(RankEdgeKind::DefinedBy.weight(), RANK_DEFINED_BY);
1820
1821        // The reverse edges are RANKING transitions (explicitly labeled,
1822        // never Reality Graph relationships): every evidence edge —
1823        // contains, participates_in, handles, defines — has a ranking-only
1824        // reverse that propagates container/flow/route/schema importance
1825        // back to members/participants/handlers/definers.
1826        assert_eq!(RankEdgeKind::Contains.reverse_ranking_transition(), Some(RankEdgeKind::MemberOf));
1827        assert_eq!(RankEdgeKind::MemberOf.reverse_ranking_transition(), Some(RankEdgeKind::Contains));
1828        assert_eq!(RankEdgeKind::ParticipatesIn.reverse_ranking_transition(), Some(RankEdgeKind::FlowReachesParticipant));
1829        assert_eq!(RankEdgeKind::FlowReachesParticipant.reverse_ranking_transition(), Some(RankEdgeKind::ParticipatesIn));
1830        assert_eq!(RankEdgeKind::Handles.reverse_ranking_transition(), Some(RankEdgeKind::HandledBy));
1831        assert_eq!(RankEdgeKind::HandledBy.reverse_ranking_transition(), Some(RankEdgeKind::Handles));
1832        assert_eq!(RankEdgeKind::Defines.reverse_ranking_transition(), Some(RankEdgeKind::DefinedBy));
1833        assert_eq!(RankEdgeKind::DefinedBy.reverse_ranking_transition(), Some(RankEdgeKind::Defines));
1834    }
1835
1836    #[test]
1837// trace:exempt reason=internal-detail
1838    fn contains_edges_spread_component_importance_both_ways() {
1839        // A component with two members: the membership edges (comp → m1,
1840        // comp → m2) spread component importance DOWN to the members, and
1841        // the reverse RANKING TRANSITIONS (m1 → comp, m2 → comp) derive
1842        // the component's importance UP from its members. An isolated
1843        // symbol (x) sits at the dangling floor for contrast.
1844        let comp_id = entity_id("r", kinds::COMPONENT, "orders");
1845        let m1 = scc_core::symbol_id("r", "src/orders/a.ts", "OrderService");
1846        let m2 = scc_core::symbol_id("r", "src/orders/b.ts", "OrderRepo");
1847        let x = scc_core::symbol_id("r", "src/other.ts", "Unrelated");
1848
1849        let entities = vec![
1850            entity(&comp_id, kinds::COMPONENT, "orders"),
1851            sym("r", "src/orders/a.ts", "OrderService"),
1852            sym("r", "src/orders/b.ts", "OrderRepo"),
1853            sym("r", "src/other.ts", "Unrelated"),
1854        ];
1855        let rels = vec![
1856            rel(1, &comp_id, predicates::CONTAINS, &m1, Provenance::Extracted),
1857            rel(2, &comp_id, predicates::CONTAINS, &m2, Provenance::Extracted),
1858        ];
1859        let (_dir, store, graph) = fixture(entities, rels);
1860        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1861        let ranker = SystemRanker::new(&view);
1862        let g = ranker.global_vector();
1863
1864        let c = g[ranker.index_of(&comp_id).unwrap()];
1865        let s1 = g[ranker.index_of(&m1).unwrap()];
1866        let s2 = g[ranker.index_of(&m2).unwrap()];
1867        let sx = g[ranker.index_of(&x).unwrap()];
1868
1869        // Both ways: the component ranks above its members (derived from
1870        // them via the ranking transitions — a dangling-only component
1871        // would sit at the uniform floor), and the members rank above the
1872        // isolated symbol (component importance reaches them via the
1873        // membership edges).
1874        assert!(c > 0.3, "component importance derived from members: {c}");
1875        assert!(s1 > sx && s2 > sx, "members lifted above the isolated symbol");
1876        assert!(c > s1 && c > s2, "component spreads importance down to members");
1877    }
1878
1879    #[test]
1880// trace:exempt reason=internal-detail
1881    fn participates_in_connects_flow_nodes() {
1882        // A flow with a participant symbol becomes CONNECTED: the
1883        // symbol → flow edge feeds the flow node, and the reverse
1884        // flow → participant RANKING TRANSITION carries flow importance
1885        // back to the participant. A bare flow (no participants) stays at
1886        // the dangling floor.
1887        let s = scc_core::symbol_id("r", "src/a.ts", "A");
1888        let f1 = entity_id("r", kinds::FLOW, "signup");
1889        let f2 = entity_id("r", kinds::FLOW, "checkout");
1890
1891        let entities = vec![
1892            sym("r", "src/a.ts", "A"),
1893            entity(&f1, kinds::FLOW, "signup"),
1894            entity(&f2, kinds::FLOW, "checkout"),
1895        ];
1896        let rels = vec![rel(1, &s, predicates::PARTICIPATES_IN, &f1, Provenance::Extracted)];
1897        let (_dir, store, graph) = fixture(entities, rels);
1898        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1899        let ranker = SystemRanker::new(&view);
1900        let g = ranker.global_vector();
1901
1902        let f1v = g[ranker.index_of(&f1).unwrap()];
1903        let f2v = g[ranker.index_of(&f2).unwrap()];
1904        let sv = g[ranker.index_of(&s).unwrap()];
1905
1906        // Previously the flow node was disconnected (no adjacency) and sat
1907        // at the dangling floor; with the rank edges it receives directed
1908        // mass from its participant.
1909        assert!(f1v > 0.3, "flow with a participant receives PPR mass: {f1v}");
1910        assert!(f1v > f2v, "connected flow ranks above the bare flow");
1911        // Flow importance reaches the participant (reverse transition).
1912        assert!(sv > f2v, "flow importance reaches the participant: {sv} vs {f2v}");
1913    }
1914
1915    #[test]
1916// trace:exempt reason=internal-detail
1917    fn handles_and_defines_edges_feed_ppr() {
1918        // HANDLES (symbol → route) and DEFINES (symbol → schema) edges
1919        // feed PageRank: the route and schema nodes receive directed mass
1920        // from their handling/defining symbols, above the isolated
1921        // baseline. The handler is an invocation-surface seed (HANDLES →
1922        // ROUTE) and the definer is an exported symbol — exactly the
1923        // production shape — so both cycles receive inflow; the reverse
1924        // RANKING TRANSITIONS (route → handler, schema → definer) make
1925        // both pairs connected nodes rather than dangling sinks.
1926        let h = scc_core::symbol_id("r", "src/api.ts", "OrderHandler");
1927        let route = entity_id("r", kinds::ROUTE, "/orders");
1928        let d = scc_core::symbol_id("r", "src/models.ts", "Order");
1929        let schema = entity_id("r", kinds::SCHEMA, "order");
1930        let x = scc_core::symbol_id("r", "src/util.ts", "Helper");
1931
1932        let mut d_ent = sym("r", "src/models.ts", "Order");
1933        d_ent.attr("exported", serde_json::json!(true));
1934        let entities = vec![
1935            sym("r", "src/api.ts", "OrderHandler"),
1936            entity(&route, kinds::ROUTE, "/orders"),
1937            d_ent,
1938            entity(&schema, kinds::SCHEMA, "order"),
1939            sym("r", "src/util.ts", "Helper"),
1940        ];
1941        let rels = vec![
1942            rel(1, &h, predicates::HANDLES, &route, Provenance::Extracted),
1943            rel(2, &d, predicates::DEFINES, &schema, Provenance::Extracted),
1944        ];
1945        let (_dir, store, graph) = fixture(entities, rels);
1946        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1947        let ranker = SystemRanker::new(&view);
1948        let g = ranker.global_vector();
1949
1950        let rv = g[ranker.index_of(&route).unwrap()];
1951        let sv = g[ranker.index_of(&schema).unwrap()];
1952        let xv = g[ranker.index_of(&x).unwrap()];
1953
1954        assert!(rv > xv, "route receives mass via the HANDLES edge: {rv} vs {xv}");
1955        assert!(sv > xv, "schema receives mass via the DEFINES edge: {sv} vs {xv}");
1956    }
1957
1958    // ---- (f) full containment hierarchy: File→Symbol, Subsystem→Component ----
1959
1960    #[test]
1961// trace:exempt reason=internal-detail
1962    fn file_contains_symbol_lifts_member() {
1963        // The reviewer's fracture: a FILE → SYMBOL containment pair was a
1964        // dead node — `contains` only fired between Component/Subsystem/
1965        // Service containers and their members, so a lone file (no
1966        // component) never connected its symbol. With the full hierarchy,
1967        // the file receives the symbol's importance (MemberOf) and the
1968        // symbol receives the file's (Contains): the member is lifted
1969        // above the isolated floor.
1970        let file_id = entity_id("r", kinds::FILE, "src/orders.ts");
1971        let sym_id = scc_core::symbol_id("r", "src/orders.ts", "OrderService");
1972        let x = scc_core::symbol_id("r", "src/other.ts", "Unrelated");
1973
1974        let entities = vec![
1975            entity(&file_id, kinds::FILE, "src/orders.ts"),
1976            sym("r", "src/orders.ts", "OrderService"),
1977            sym("r", "src/other.ts", "Unrelated"),
1978        ];
1979        let rels = vec![rel(1, &file_id, predicates::CONTAINS, &sym_id, Provenance::Extracted)];
1980        let (_dir, store, graph) = fixture(entities, rels);
1981        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
1982        let ranker = SystemRanker::new(&view);
1983        let g = ranker.global_vector();
1984
1985        let fv = g[ranker.index_of(&file_id).unwrap()];
1986        let sv = g[ranker.index_of(&sym_id).unwrap()];
1987        let xv = g[ranker.index_of(&x).unwrap()];
1988
1989        // The file is no longer a dead node (it derives mass from its
1990        // symbol via the MemberOf ranking transition) and the contained
1991        // symbol ranks above the isolated symbol.
1992        assert!(fv > 0.3, "file node derives importance from its symbol: {fv}");
1993        assert!(sv > xv, "contained symbol lifted above the isolated one: {sv} vs {xv}");
1994        assert!(fv > xv, "file lifted above the isolated symbol: {fv} vs {xv}");
1995    }
1996
1997    #[test]
1998// trace:exempt reason=internal-detail
1999    fn subsystem_component_containment_spreads_both_ways() {
2000        // A Subsystem CONTAINS a Component: membership evidence flows
2001        // subsystem → component (down) and the reverse RANKING TRANSITION
2002        // component → subsystem derives the subsystem's importance from
2003        // its component (up). A bare component with no subsystem sits at
2004        // the dangling floor.
2005        let sub_id = entity_id("r", kinds::SUBSYSTEM, "billing");
2006        let comp_id = entity_id("r", kinds::COMPONENT, "orders");
2007        let bare = entity_id("r", kinds::COMPONENT, "auth");
2008
2009        let entities = vec![
2010            entity(&sub_id, kinds::SUBSYSTEM, "billing"),
2011            entity(&comp_id, kinds::COMPONENT, "orders"),
2012            entity(&bare, kinds::COMPONENT, "auth"),
2013        ];
2014        let rels = vec![rel(1, &sub_id, predicates::CONTAINS, &comp_id, Provenance::Extracted)];
2015        let (_dir, store, graph) = fixture(entities, rels);
2016        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
2017        let ranker = SystemRanker::new(&view);
2018        let g = ranker.global_vector();
2019
2020        let subv = g[ranker.index_of(&sub_id).unwrap()];
2021        let compv = g[ranker.index_of(&comp_id).unwrap()];
2022        let barev = g[ranker.index_of(&bare).unwrap()];
2023
2024        // Both ways: the contained component receives the subsystem's
2025        // membership evidence (compv > barev) and the subsystem derives
2026        // importance from its component (subv > barev).
2027        assert!(compv > barev, "contained component ranks above the bare one: {compv} vs {barev}");
2028        assert!(subv > barev, "subsystem derives importance from its component: {subv} vs {barev}");
2029        assert!(subv > 0.3, "subsystem is a first-class rank node: {subv}");
2030    }
2031
2032    // ---- (g) projection through HandledBy/DefinedBy ----
2033
2034    #[test]
2035// trace:exempt reason=internal-detail
2036    fn hot_route_projects_to_handler_symbol() {
2037        // The reviewer's exact scenario: the task mentions /health → the
2038        // route node is hot (task seed) → its handler symbol is reached.
2039        // Two mechanisms fire: the HandledBy ranking transition carries
2040        // route mass to the handler in the PPR vector, and the HANDLES
2041        // projection adds 0.4 × the hot route's score (capped at 0.5) to
2042        // the handler's surface score.
2043        let handler = scc_core::symbol_id("r", "src/router.ts", "build_router");
2044        let route = entity_id("r", kinds::ROUTE, "/health");
2045        let other = scc_core::symbol_id("r", "src/util.ts", "Helper");
2046
2047        let entities = vec![
2048            sym("r", "src/router.ts", "build_router"),
2049            entity(&route, kinds::ROUTE, "/health"),
2050            sym("r", "src/util.ts", "Helper"),
2051        ];
2052        let rels = vec![rel(1, &handler, predicates::HANDLES, &route, Provenance::Extracted)];
2053        let (_dir, store, graph) = fixture(entities, rels);
2054        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
2055        let ranker = SystemRanker::new(&view);
2056
2057        // Hot route: the task seed lands on the route node itself.
2058        let seeds = vec![TaskSeed {
2059            kind: "route".into(),
2060            id: route.clone(),
2061            weight: 1.0,
2062        }];
2063        let tv = ranker.task_vector(&seeds);
2064        let projected = ranker.project_to_symbols(&tv);
2065        let score_of = |id: &str| -> f64 {
2066            projected
2067                .iter()
2068                .find(|(s, _)| s == id)
2069                .map(|(_, v)| *v)
2070                .unwrap_or(f64::NAN)
2071        };
2072
2073        let route_mass = tv[ranker.index_of(&route).unwrap()];
2074        let handler_own = tv[ranker.index_of(&handler).unwrap()];
2075        let handler_score = score_of(&handler);
2076        let other_score = score_of(&other);
2077
2078        // The handler's surface score = its own PPR (which already grew
2079        // via the HandledBy transition) + 0.4 × the hot route's score,
2080        // capped at 0.5 — the exact projection pattern.
2081        let expected_bonus = (PROJECTION_BONUS_FACTOR * route_mass).min(PROJECTION_BONUS_CAP);
2082        assert!((handler_score - (handler_own + expected_bonus)).abs() < 1e-9);
2083        assert!(route_mass > 0.1, "seeded route is hot: {route_mass}");
2084        assert!(handler_score > other_score, "hot route lifts its handler above unrelated symbols");
2085    }
2086
2087    #[test]
2088// trace:exempt reason=internal-detail
2089    fn hot_schema_projects_to_definer_symbol() {
2090        // A hot Schema node (task seed) reaches its defining symbol: the
2091        // DefinedBy ranking transition feeds the definer in the PPR vector
2092        // and the DEFINES projection adds 0.4 × the schema's score (capped
2093        // at 0.5) to the definer's surface score.
2094        let definer = scc_core::symbol_id("r", "src/models.ts", "Order");
2095        let schema = entity_id("r", kinds::SCHEMA, "order");
2096        let other = scc_core::symbol_id("r", "src/util.ts", "Helper");
2097
2098        let entities = vec![
2099            sym("r", "src/models.ts", "Order"),
2100            entity(&schema, kinds::SCHEMA, "order"),
2101            sym("r", "src/util.ts", "Helper"),
2102        ];
2103        let rels = vec![rel(1, &definer, predicates::DEFINES, &schema, Provenance::Extracted)];
2104        let (_dir, store, graph) = fixture(entities, rels);
2105        let view = TrustedGraphView::new(&graph, &store, &[], TrustPolicy::default());
2106        let ranker = SystemRanker::new(&view);
2107
2108        let seeds = vec![TaskSeed {
2109            kind: "schema".into(),
2110            id: schema.clone(),
2111            weight: 1.0,
2112        }];
2113        let tv = ranker.task_vector(&seeds);
2114        let projected = ranker.project_to_symbols(&tv);
2115        let score_of = |id: &str| -> f64 {
2116            projected
2117                .iter()
2118                .find(|(s, _)| s == id)
2119                .map(|(_, v)| *v)
2120                .unwrap_or(f64::NAN)
2121        };
2122
2123        let schema_mass = tv[ranker.index_of(&schema).unwrap()];
2124        let definer_own = tv[ranker.index_of(&definer).unwrap()];
2125        let definer_score = score_of(&definer);
2126        let other_score = score_of(&other);
2127
2128        let expected_bonus = (PROJECTION_BONUS_FACTOR * schema_mass).min(PROJECTION_BONUS_CAP);
2129        assert!((definer_score - (definer_own + expected_bonus)).abs() < 1e-9);
2130        assert!(schema_mass > 0.1, "seeded schema is hot: {schema_mass}");
2131        assert!(definer_score > other_score, "hot schema lifts its definer above unrelated symbols");
2132    }
2133}