Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/knowledge/context.rs

//! Graph-RAG Context-Slicing API — the LLM-injectable code-slice surface over
//! Mimir (TASK #45).
//!
//! Given a semantic query (an embedding vector) + a repo + an optional
//! change-radius, this returns **ranked, AST-clean code slices ready for prompt
//! injection** by JOINing the REAL warehouse tables — no disk re-search, no
//! re-embed, no re-parse:
//!
//! ```text
//!   embeddings            (vector match — cosine top-k over the stored f32s)
//!     ⋈ embedding_manifest (content_hash → file + start_line..end_line span)
//!     ⋈ symbol_facts       (kind / name / visibility / signature at the span)
//!     [scoped by call_edges change-radius]     (call-chain trim/annotation)
//!     [scoped by dep_graph_edges affected set] (cross-repo blast-radius trim)
//! ```
//!
//! The join reuses the existing engine parts verbatim — [`crate::vector::store`]
//! (`embeddings ⋈ embedding_manifest` → [`VectorIndex`](crate::vector::VectorIndex)
//! search), [`crate::knowledge::query::KnowledgeView`] (`symbol_facts` +
//! `call_edges`), and the [`Warehouse`](nornir_warehouse_trait::Warehouse) trait.
//! **No new engine, no new table.**
//!
//! Output is a `Vec<`[`ContextSlice`]`>`, best-first by semantic score, each
//! carrying the exact span + the AST symbols that live inside it. The span text
//! itself is NOT in the warehouse (the manifest stores only the span, by design),
//! so [`hydrate_source`] is the opt-in step that reads the *exact known* spans
//! back from a provided repo root — a handful of targeted seeks, never a search.

use std::collections::HashMap;
#[cfg(feature = "vector")]
use std::path::Path;

#[cfg(feature = "vector")]
use anyhow::Result;
#[cfg(feature = "vector")]
use nornir_warehouse_trait::SymbolRow;

// `KnowledgeView` (the `symbol_facts` + `call_edges` view) is the substrate of the
// change-radius walk and is ALWAYS compiled; `load_latest` and the vector store are
// only pulled by the embedding-join path below.
use crate::knowledge::query::KnowledgeView;
// The frontier-parallel BFS primitive (znippy-zoomies gatling) that drives the
// change-radius walk — no rayon / no bare threads, the gatling pool does the fan-out.
#[cfg(feature = "vector")]
#[cfg(feature = "vector")]
use crate::vector::store::{Occurrence, search};
#[cfg(feature = "vector")]
use crate::warehouse::iceberg::IcebergWarehouse;
use znippy_zoomies::gatling_bfs::gatling_bfs;

/// One `symbol_facts` row whose definition line falls inside a slice's span —
/// the AST metadata that makes a slice "clean" (you know exactly which item(s)
/// the injected block defines, at what visibility).
#[cfg(feature = "vector")]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct SliceSymbol {
    pub item_kind: String,
    pub item_name: String,
    pub visibility: String,
    pub module_path: String,
    pub crate_name: String,
    pub line: u32,
    pub signature: Option<String>,
}

#[cfg(feature = "vector")]
impl SliceSymbol {
    fn from_row(s: &SymbolRow) -> Self {
        SliceSymbol {
            item_kind: s.item_kind.clone(),
            item_name: s.item_name.clone(),
            visibility: s.visibility.clone(),
            module_path: s.module_path.clone(),
            crate_name: s.crate_name.clone(),
            line: s.line,
            signature: s.signature.clone(),
        }
    }
}

/// One ranked, prompt-injectable code slice: a file span, the semantic score, the
/// content hash it matched, and the AST symbols defined inside it.
#[cfg(feature = "vector")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct ContextSlice {
    pub repo: String,
    pub file: String,
    pub start_line: usize,
    pub end_line: usize,
    /// Cosine similarity of the matched chunk to the query (best-first order).
    pub score: f32,
    /// The `embedding_manifest` content hash this span resolved to.
    pub content_hash: String,
    /// `symbol_facts` rows whose definition line is within `[start_line,end_line]`.
    pub symbols: Vec<SliceSymbol>,
    /// Minimum hop distance of a contained symbol from a change-radius seed over
    /// `call_edges` (`None` when no radius was applied). `Some(0)` = the seed
    /// itself; larger = further along the call chain.
    pub radius_hops: Option<usize>,
    /// Source text at the span — populated only by [`hydrate_source`] (an
    /// explicit, targeted read of the known span; `None` otherwise).
    pub source: Option<String>,
}

/// A call-chain change-radius over `call_edges`: expand outward from `seeds` (the
/// changed symbols) up to `hops` hops; a slice is kept only when it contains a
/// symbol within the radius, and is annotated with that symbol's hop distance.
///
/// Empty `seeds` ⇒ radius disabled (every semantic hit is kept). This is the
/// "trim to the affected set" lever the change-driven agent path uses.
#[derive(Debug, Clone, Default)]
pub struct ChangeRadius {
    /// Changed symbol names (bare item names; path-qualified names are matched by
    /// last segment, matching [`KnowledgeView::callers_of`]).
    pub seeds: Vec<String>,
    /// Maximum number of call-graph hops from a seed to keep.
    pub hops: usize,
    /// Also follow reverse edges (callers) when expanding — the true blast radius
    /// of a change (who could be affected), not just what the seed calls.
    pub include_callers: bool,
    /// Follow FORWARD edges (what a symbol calls) when expanding. The context-slice
    /// path leaves this on (a change radius annotates both what the seed calls and,
    /// optionally, who calls it). A pure blast-radius walk (the coderoom transitive
    /// what-breaks) turns it OFF so the closure is REVERSE-only — only crates whose
    /// surface transitively *reaches down to* a removed symbol are impacted, never
    /// the things the removed symbol happened to call.
    pub include_callees: bool,
}

/// The query envelope for [`context_slices`].
#[cfg(feature = "vector")]
pub struct ContextQuery<'a> {
    pub repo: &'a str,
    /// Point-in-time git SHA (`None` = latest indexed snapshot).
    pub git_sha: Option<&'a str>,
    /// The embedding model profile id the query vector was produced under.
    pub model_profile: &'a str,
    /// How many ranked slices to return.
    pub k: usize,
    /// Optional call-chain change-radius trim.
    pub radius: Option<ChangeRadius>,
}

/// **The Graph-RAG context slice.** Vector-match the repo's embeddings, join the
/// manifest span + the caller's symbol `view`, optionally trim to a call-edge
/// change radius, and return the top-`k` ranked slices ready for prompt injection.
/// (The view is an input since Mímir v2: its warehouse source has no producer.)
///
/// `query_vec` is a pre-embedded query in the same `model_profile` space (embed
/// the natural-language question with the same model, or pass a symbol's own
/// vector for "more like this"). Keeping the embed step outside this function
/// keeps the join pure and testable without loading a multi-GB ONNX model.
#[cfg(feature = "vector")]
pub fn context_slices(
    wh: &IcebergWarehouse,
    view: &KnowledgeView,
    q: &ContextQuery,
    query_vec: &[f32],
) -> Result<Vec<ContextSlice>> {
    // 1. embeddings ⋈ embedding_manifest — semantic hits (span + content_hash).
    //    Over-fetch when a radius trim is active so the post-filter still leaves k.
    let radius_active = q.radius.as_ref().is_some_and(|r| !r.seeds.is_empty());
    let fetch = if radius_active {
        (q.k * 4).max(32)
    } else {
        q.k
    };
    let hits = search(wh, q.repo, q.git_sha, q.model_profile, query_vec, fetch)?;

    // 2. The symbol + call view is an INPUT: it used to be read from
    //    `symbol_facts` / `call_edges`, which have no producer since Mímir v2.

    // 3. call-chain radius set (symbol name → min hop distance), if requested.
    let radius_set = q
        .radius
        .as_ref()
        .filter(|r| !r.seeds.is_empty())
        .map(|r| expand_call_radius(view, r));

    // 4. join + assemble, preserving the best-first semantic order.
    //    The symbol_facts JOIN (`symbols_in_span`) is the bulk hot path — a full
    //    linear scan of `symbol_facts` PER hit — so it is fanned across all cores
    //    ONCE, over the symbol rows, via the znippy-zoomies gatling pool (see
    //    `symbols_in_spans`). Results are collected in symbol order so each hit's
    //    list is byte-identical to the per-hit serial filter below.
    let per_hit_syms = symbols_in_spans(view, &hits);
    let mut slices = Vec::with_capacity(hits.len());
    for ((score, occ), symbols) in hits.into_iter().zip(per_hit_syms) {
        let radius_hops = match &radius_set {
            Some(set) => {
                // Keep only slices holding a symbol within the radius; annotate
                // with the closest hop. A slice with no in-radius symbol is out.
                match symbols
                    .iter()
                    .filter_map(|s| set.get(&s.item_name).copied())
                    .min()
                {
                    Some(h) => Some(h),
                    None => continue,
                }
            }
            None => None,
        };
        slices.push(ContextSlice {
            repo: q.repo.to_string(),
            file: occ.file,
            start_line: occ.start_line,
            end_line: occ.end_line,
            score,
            content_hash: occ.content_hash,
            symbols,
            radius_hops,
            source: None,
        });
        if slices.len() >= q.k {
            break;
        }
    }
    Ok(slices)
}

/// `symbol_facts` rows whose definition line falls inside the occurrence's span.
/// Matches files tolerant of a workspace- vs crate-relative path prefix (the
/// manifest and the syn scan can root paths differently) via [`same_file`].
#[cfg(feature = "vector")]
fn symbols_in_span(view: &KnowledgeView, occ: &Occurrence) -> Vec<SliceSymbol> {
    view.symbols
        .iter()
        .filter(|s| {
            same_file(&s.file, &occ.file)
                && (s.line as usize) >= occ.start_line
                && (s.line as usize) <= occ.end_line
        })
        .map(SliceSymbol::from_row)
        .collect()
}

/// [`symbols_in_span`] for MANY occurrences at once, **saturating all cores**.
///
/// The naive path calls [`symbols_in_span`] once per hit, each a full linear scan
/// of `symbol_facts` on ONE core — so a `k`-hit context slice over an `N`-symbol
/// repo does `k·N` comparisons single-threaded (the dominant cost of the whole
/// Graph-RAG retrieval at scale). Here the **single** scan over the symbol rows
/// is fanned across the znippy-zoomies gatling pool
/// ([`gatling_map_balanced`](znippy_zoomies::gatling_forkjoin::gatling_map_balanced)):
/// each worker takes a contiguous batch of symbols and, for every hit whose span
/// contains a symbol, emits `(hit_index, SliceSymbol)`.
///
/// **Byte-identical to the per-hit serial filter:** `gatling_map_balanced` writes
/// its per-item results back in the ORIGINAL symbol order, so flattening them in
/// order and bucketing by `hit_index` yields, for every hit, exactly the symbols
/// `view.symbols.iter().filter(..).map(..).collect()` would — same set, same
/// order. Returns one `Vec<SliceSymbol>` per hit, index-aligned with `hits`.
#[cfg(feature = "vector")]
fn symbols_in_spans(view: &KnowledgeView, hits: &[(f32, Occurrence)]) -> Vec<Vec<SliceSymbol>> {
    let mut out: Vec<Vec<SliceSymbol>> = vec![Vec::new(); hits.len()];
    if hits.is_empty() || view.symbols.is_empty() {
        return out;
    }
    // Small joins: the fork-join fixed cost isn't worth it — stay serial (this is
    // also the single-hit / few-symbol latency floor the microbench exercises).
    if view.symbols.len() * hits.len() < PAR_JOIN_THRESHOLD {
        for (h, (_, occ)) in hits.iter().enumerate() {
            out[h] = symbols_in_span(view, occ);
        }
        return out;
    }
    // One balanced pass over ALL symbols (heaviest-first is moot — every symbol
    // costs the same `hits.len()` span checks — so weight is uniform, batched to
    // amortise the per-unit atomic claim). Each symbol yields the hits it lands in.
    let per_symbol: Vec<Vec<(u32, SliceSymbol)>> =
        znippy_zoomies::gatling_forkjoin::gatling_map_balanced(
            view.symbols.as_slice(),
            0,
            256,
            |_s| 1u64,
            |_i, s| {
                let mut landed: Vec<(u32, SliceSymbol)> = Vec::new();
                let line = s.line as usize;
                for (h, (_, occ)) in hits.iter().enumerate() {
                    if line >= occ.start_line
                        && line <= occ.end_line
                        && same_file(&s.file, &occ.file)
                    {
                        landed.push((h as u32, SliceSymbol::from_row(s)));
                    }
                }
                landed
            },
        );
    // Flatten in symbol order → each hit bucket stays in symbol order.
    for landed in per_symbol {
        for (h, sym) in landed {
            out[h as usize].push(sym);
        }
    }
    out
}

/// `symbols × hits` comparison count below which [`symbols_in_spans`] stays serial
/// (the gatling fork-join fixed cost dominates smaller joins).
#[cfg(feature = "vector")]
const PAR_JOIN_THRESHOLD: usize = 1 << 15; // 32_768

/// BFS over `call_edges` from the change seeds, returning `symbol name → min hop`.
/// Seeds are hop 0. Forward edges (what a symbol calls) are followed when
/// `include_callees`; reverse edges (its callers — the true blast radius) are
/// followed when `include_callers`. Names are compared by last path segment,
/// matching the [`KnowledgeView`] call-graph queries.
///
/// The Graph-RAG context-slice change radius; a pure primitive over any
/// [`KnowledgeView`], so it is public (and compiled without `vector`).
pub fn expand_call_radius(view: &KnowledgeView, r: &ChangeRadius) -> HashMap<String, usize> {
    // Seeds are the radius-0 frontier, compared (like every call-graph query) by last
    // path segment. `gatling_bfs` de-dups them into `layers[0]` first-seen.
    let seeds = r.seeds.iter().map(|s| last_seg(s).to_string());

    // The out-edges of a node: forward (its callees) when `include_callees`, reverse
    // (its callers — the true blast radius) when `include_callers`, each collapsed to
    // its last segment. This is the exact edge set the old hand-rolled frontier walked;
    // the gatling pool now fans this expansion across cores at every level barrier.
    let neighbors = |name: &String| -> Vec<String> {
        let mut out = Vec::new();
        if r.include_callees {
            for e in view.callees_of(name) {
                out.push(last_seg(&e.callee_ident).to_string());
            }
        }
        if r.include_callers {
            for e in view.callers_of(name) {
                out.push(last_seg(&e.caller_path).to_string());
            }
        }
        out
    };

    // `hops == usize::MAX` is the "full closure" sentinel (walk until the frontier
    // drains) ⇒ no cap; otherwise cap the walk at exactly `hops` radii, matching the
    // old `for hop in 1..=r.hops` bound edge-for-edge.
    let max_radius = (r.hops != usize::MAX).then_some(r.hops);

    // `n_workers = 0` ⇒ one per core (the gatling convention). Each returned layer is
    // the set first discovered at that radius, so the layer index IS the min hop.
    gatling_bfs(seeds, neighbors, max_radius, 0)
        .into_iter()
        .enumerate()
        .flat_map(|(hop, layer)| layer.into_iter().map(move |name| (name, hop)))
        .collect()
}

/// Read the exact span text back for each slice from `repo_root` — a targeted
/// seek per unique file (NOT a search), populating [`ContextSlice::source`]. This
/// is the "clean LLM-injectable source block" step: the warehouse pins the exact
/// spans; this hydrates them with zero scanning. Lines are 1-based and inclusive.
#[cfg(feature = "vector")]
pub fn hydrate_source(slices: &mut [ContextSlice], repo_root: &Path) -> Result<()> {
    let mut cache: HashMap<String, Vec<String>> = HashMap::new();
    for slice in slices.iter_mut() {
        let lines = match cache.get(&slice.file) {
            Some(l) => l,
            None => {
                let text = std::fs::read_to_string(repo_root.join(&slice.file)).unwrap_or_default();
                cache
                    .entry(slice.file.clone())
                    .or_insert_with(|| text.lines().map(|s| s.to_string()).collect())
            }
        };
        if lines.is_empty() {
            continue;
        }
        let start = slice.start_line.saturating_sub(1).min(lines.len());
        let end = slice.end_line.min(lines.len());
        if start < end {
            slice.source = Some(lines[start..end].join("\n"));
        }
    }
    Ok(())
}

/// Last `::`-separated segment of a (possibly path-qualified) name.
fn last_seg(name: &str) -> &str {
    name.rsplit("::").next().unwrap_or(name)
}

/// Tolerant file-path equality: exact, or one path is a trailing suffix of the
/// other (handles workspace-relative vs crate-relative rooting between the
/// embedding manifest and the syn scan).
#[cfg(feature = "vector")]
fn same_file(a: &str, b: &str) -> bool {
    a == b || a.ends_with(b) || b.ends_with(a)
}

/// RED-WHEN-BROKEN guard for [`expand_call_radius`] — the frontier-parallel
/// call-radius walk. UNGATED (`#[cfg(test)]`, not `feature = "vector"`) because
/// `expand_call_radius` is ALWAYS compiled, so this must also exercise it in the
/// `--no-default-features` build. Drives the gatling BFS directly over a hand-built
/// call graph and asserts the exact `name → min hop` map, including a cycle that
/// must terminate visiting each node once and a `max_radius` truncation.
#[cfg(test)]
mod radius_tests {
    use super::{ChangeRadius, expand_call_radius};
    use crate::knowledge::query::KnowledgeView;
    use crate::knowledge::symbols::CallEdgeRow;
    use std::collections::HashMap;

    /// One call edge `caller -> callee` (both bare last-segment names here).
    fn edge(caller: &str, callee: &str) -> CallEdgeRow {
        CallEdgeRow {
            crate_name: "demo".into(),
            caller_path: caller.into(),
            callee_ident: callee.into(),
            call_kind: "call".into(),
            file: "src/lib.rs".into(),
            line: 1,
        }
    }

    /// A cyclic call graph:  alpha → beta → gamma → {alpha (back-edge), delta}.
    /// The gamma→alpha edge closes a cycle the walk must not loop on.
    fn cyclic_view() -> KnowledgeView {
        let calls = vec![
            edge("alpha", "beta"),
            edge("beta", "gamma"),
            edge("gamma", "alpha"),
            edge("gamma", "delta"),
        ];
        KnowledgeView::new(Vec::new(), calls)
    }

    /// Forward (callee) walk, FULL closure (`hops == usize::MAX`). The cycle must
    /// terminate and every node get its shortest hop; the back-edge to the seed is
    /// dropped, not re-walked.
    #[test]
    fn forward_full_closure_terminates_on_cycle() {
        let view = cyclic_view();
        let dist = expand_call_radius(
            &view,
            &ChangeRadius {
                seeds: vec!["alpha".into()],
                hops: usize::MAX,
                include_callers: false,
                include_callees: true,
            },
        );
        let want: HashMap<String, usize> = [("alpha", 0), ("beta", 1), ("gamma", 2), ("delta", 3)]
            .into_iter()
            .map(|(k, v)| (k.to_string(), v))
            .collect();
        assert_eq!(dist, want, "full-closure hops over a cyclic graph");
    }

    /// `max_radius` truncation: capping at 2 hops keeps alpha/beta/gamma and DROPS
    /// delta (radius 3). Maps the `--radius`/`hops` bound onto `Some(hops)`.
    #[test]
    fn max_radius_truncates() {
        let view = cyclic_view();
        let dist = expand_call_radius(
            &view,
            &ChangeRadius {
                seeds: vec!["alpha".into()],
                hops: 2,
                include_callers: false,
                include_callees: true,
            },
        );
        let want: HashMap<String, usize> = [("alpha", 0), ("beta", 1), ("gamma", 2)]
            .into_iter()
            .map(|(k, v)| (k.to_string(), v))
            .collect();
        assert_eq!(dist, want, "hops=2 truncates the walk before delta");
        assert!(!dist.contains_key("delta"), "delta is past the radius cap");
    }

    /// Reverse (caller) walk — the blast-radius direction the transitive
    /// what-breaks query uses. Seeding at `delta` and following callers must reach
    /// gamma (hop 1) then beta (hop 2) then alpha (hop 3), the chain in reverse.
    #[test]
    fn reverse_walk_follows_callers() {
        let view = cyclic_view();
        let dist = expand_call_radius(
            &view,
            &ChangeRadius {
                seeds: vec!["delta".into()],
                hops: usize::MAX,
                include_callers: true,
                include_callees: false,
            },
        );
        assert_eq!(dist.get("delta"), Some(&0), "seed at hop 0");
        assert_eq!(dist.get("gamma"), Some(&1), "gamma calls delta (hop 1)");
        assert_eq!(dist.get("beta"), Some(&2), "beta calls gamma (hop 2)");
        assert_eq!(dist.get("alpha"), Some(&3), "alpha calls beta (hop 3)");
    }
}

#[cfg(all(test, feature = "vector"))]
mod tests {
    use super::*;
    use chrono::Utc;
    use nornir_warehouse_trait::{CallEdgeRow, SymbolScan};
    use uuid::Uuid;

    use crate::vector::chunk::{Chunk, ChunkOptions, hash_text};
    use crate::vector::store::{IndexParams, ModelProfile, chunker_hash, index_snapshot};

    fn profile() -> ModelProfile {
        ModelProfile {
            model_name: "test-embed".into(),
            weights_sha: "w0".into(),
            tokenizer_sha: "t0".into(),
            pooling: "mean".into(),
            normalize: true,
            dim: 8,
            dtype: "f32".into(),
        }
    }

    fn unit(axis: usize) -> Vec<f32> {
        let mut v = vec![0.0f32; 8];
        v[axis] = 1.0;
        v
    }

    /// Deterministic test "embedding": map known text → a basis axis.
    fn axis_of(text: &str) -> usize {
        match text {
            "alpha" => 0,
            "bravo" => 1,
            "charlie" => 2,
            _ => 7,
        }
    }

    fn mk(file: &str, line: usize, text: &str) -> Chunk {
        Chunk {
            file: file.into(),
            start_line: line,
            end_line: line + 2,
            content_hash: hash_text(text),
            text: text.into(),
        }
    }

    fn sym(name: &str, kind: &str, vis: &str, file: &str, line: u32) -> SymbolRow {
        SymbolRow {
            crate_name: "cr".into(),
            module_path: "cr::m".into(),
            item_kind: kind.into(),
            item_name: name.into(),
            visibility: vis.into(),
            file: file.into(),
            line,
            doc_lines: 0,
            signature: Some(format!("fn {name}()")),
        }
    }

    fn call(caller: &str, callee: &str, file: &str, line: u32) -> CallEdgeRow {
        CallEdgeRow {
            crate_name: "cr".into(),
            caller_path: caller.into(),
            callee_ident: callee.into(),
            call_kind: "call".into(),
            file: file.into(),
            line,
        }
    }

    fn params<'a>(
        repo: &'a str,
        sha: &'a str,
        m: &'a ModelProfile,
        ch: &'a str,
    ) -> IndexParams<'a> {
        IndexParams {
            workspace: "ws",
            repo,
            git_sha: sha,
            branch: "main",
            model: m,
            chunker_hash: ch,
            complete: true,
        }
    }

    /// Build a fixture warehouse: 3 embedded chunks (a/b/c.rs, each near a basis
    /// axis) + a matching `symbol_facts`/`call_edges` scan with a call chain
    /// `alpha → bravo` (a.rs calls b.rs) and an isolated `charlie` (c.rs).
    fn fixture() -> (tempfile::TempDir, IcebergWarehouse, ModelProfile, KnowledgeView) {
        let dir = tempfile::tempdir().unwrap();
        let wh = IcebergWarehouse::open(dir.path()).unwrap();
        let m = profile();
        let ch = chunker_hash(&ChunkOptions::default());
        let chunks = vec![
            mk("a.rs", 10, "alpha"),
            mk("b.rs", 20, "bravo"),
            mk("c.rs", 30, "charlie"),
        ];
        index_snapshot(&wh, &params("repo", "sha1", &m, &ch), &chunks, |missing| {
            Ok(missing.iter().map(|c| unit(axis_of(&c.text))).collect())
        })
        .unwrap();

        let scan = SymbolScan {
            snapshot_id: Uuid::new_v4(),
            ts: Utc::now(),
            repo: "repo".into(),
            symbols: vec![
                sym("alpha", "fn", "pub", "a.rs", 11),
                sym("bravo", "fn", "pub(crate)", "b.rs", 21),
                sym("charlie", "fn", "pub", "c.rs", 31),
            ],
            calls: vec![call("alpha", "bravo", "a.rs", 12)],
            features: vec![],
            tests: vec![],
        };
        let view = KnowledgeView::new(scan.symbols, scan.calls);
        (dir, wh, m, view)
    }

    /// RED-WHEN-BROKEN #1 — the join: a semantic query for `alpha` returns a.rs's
    /// exact `embedding_manifest` span AND the `symbol_facts` metadata joined into
    /// it (kind/name/visibility). Break `symbols_in_span` (span bounds or the file
    /// match) and the asserted symbol vanishes.
    #[test]
    fn query_joins_manifest_span_to_symbol_facts() {
        let (_d, wh, m, view) = fixture();
        let q = ContextQuery {
            repo: "repo",
            git_sha: Some("sha1"),
            model_profile: &m.id(),
            k: 1,
            radius: None,
        };
        let slices = context_slices(&wh, &view, &q, &unit(0)).unwrap();
        assert_eq!(slices.len(), 1, "one top hit");
        let s = &slices[0];
        assert_eq!(s.file, "a.rs", "vector match resolved a.rs");
        // The RIGHT span from embedding_manifest (mk set start=10, end=12).
        assert_eq!((s.start_line, s.end_line), (10, 12), "manifest span");
        // symbol_facts JOINED: alpha is defined at line 11, inside [10,12].
        assert_eq!(s.symbols.len(), 1, "exactly the contained symbol");
        assert_eq!(s.symbols[0].item_name, "alpha");
        assert_eq!(s.symbols[0].item_kind, "fn");
        assert_eq!(s.symbols[0].visibility, "pub");
    }

    /// RED-WHEN-BROKEN #2 — change-radius trims to the affected set: seeding the
    /// radius at `alpha` with 1 hop keeps a.rs (seed, hop 0) and b.rs (`alpha`
    /// calls `bravo`, hop 1) but DROPS the unrelated c.rs (`charlie`). Break the
    /// call-graph BFS or the trim and c.rs leaks back in.
    #[test]
    fn change_radius_trims_to_affected_set() {
        let (_d, wh, m, view) = fixture();
        let mp = m.id();
        let radius = ChangeRadius {
            seeds: vec!["alpha".into()],
            hops: 1,
            include_callers: false,
            include_callees: true,
        };
        let q = ContextQuery {
            repo: "repo",
            git_sha: Some("sha1"),
            model_profile: &mp,
            k: 10,
            radius: Some(radius),
        };
        // A query vector equally near all three basis axes → all three would
        // otherwise be returned; the radius must trim c.rs out.
        let mut qv = unit(0);
        qv[1] = 1.0;
        qv[2] = 1.0;
        let slices = context_slices(&wh, &view, &q, &qv).unwrap();
        let files: Vec<&str> = slices.iter().map(|s| s.file.as_str()).collect();
        assert!(files.contains(&"a.rs"), "seed a.rs kept (hop 0)");
        assert!(files.contains(&"b.rs"), "callee b.rs kept (hop 1)");
        assert!(
            !files.contains(&"c.rs"),
            "unrelated c.rs trimmed by the radius"
        );
        // Hop annotations are correct.
        let a = slices.iter().find(|s| s.file == "a.rs").unwrap();
        let b = slices.iter().find(|s| s.file == "b.rs").unwrap();
        assert_eq!(a.radius_hops, Some(0), "alpha is the seed");
        assert_eq!(b.radius_hops, Some(1), "bravo is one hop out");
    }

    /// The opt-in source hydration reads the exact known span back from disk (a
    /// seek, not a search) — the finished prompt-injectable block.
    #[test]
    fn hydrate_reads_exact_span() {
        let (_d, wh, m, view) = fixture();
        // Write a real a.rs whose lines 10..12 carry known text.
        let root = tempfile::tempdir().unwrap();
        let mut body = String::new();
        for i in 1..=15 {
            body.push_str(&format!("line {i}\n"));
        }
        std::fs::write(root.path().join("a.rs"), body).unwrap();

        let q = ContextQuery {
            repo: "repo",
            git_sha: Some("sha1"),
            model_profile: &m.id(),
            k: 1,
            radius: None,
        };
        let mut slices = context_slices(&wh, &view, &q, &unit(0)).unwrap();
        hydrate_source(&mut slices, root.path()).unwrap();
        let src = slices[0].source.as_deref().unwrap();
        assert!(
            src.contains("line 10") && src.contains("line 12"),
            "exact span text: {src:?}"
        );
        assert!(!src.contains("line 13"), "span is bounded");
    }

    /// RED-WHEN-BROKEN (core-saturation cycle 2) — the gatling-parallel symbol
    /// join [`symbols_in_spans`] must be **byte-identical** to the serial per-hit
    /// [`symbols_in_span`], across a fixture LARGE enough to cross
    /// `PAR_JOIN_THRESHOLD` (so the fork-join path — not the serial fallback — is
    /// the one under test). Break the fan-out (wrong bucket, dropped/duplicated
    /// symbol, reordered output) and the per-hit vectors diverge and this reds.
    #[test]
    fn parallel_symbol_join_matches_serial() {
        // Enough symbols × hits to exceed PAR_JOIN_THRESHOLD (32_768): 4000 syms
        // × 24 hits = 96_000 comparisons ⇒ the parallel path runs.
        let n_sym = 4000usize;
        let symbols: Vec<SymbolRow> = (0..n_sym)
            .map(|i| {
                sym(
                    &format!("f{i}"),
                    "fn",
                    "pub",
                    &format!("src/f{}.rs", i % 32),
                    (i % 400) as u32 + 1,
                )
            })
            .collect();
        let view = KnowledgeView::new(symbols, vec![]);

        // 24 hits spanning varied files + line windows (some overlapping, some
        // empty, some multi-file via the suffix-tolerant `same_file`).
        let hits: Vec<(f32, Occurrence)> = (0..24u64)
            .map(|h| {
                let file = format!("src/f{}.rs", (h % 40) as usize); // some files have no symbols
                let start = (h as usize * 13) % 400 + 1;
                (
                    1.0 / (h as f32 + 1.0),
                    Occurrence {
                        content_hash: format!("h{h}"),
                        file,
                        start_line: start,
                        end_line: start + 20,
                    },
                )
            })
            .collect();

        assert!(
            view.symbols.len() * hits.len() >= super::PAR_JOIN_THRESHOLD,
            "fixture must exercise the parallel path"
        );
        let parallel = symbols_in_spans(&view, &hits);
        // Ground truth: the serial per-hit filter, one hit at a time.
        let serial: Vec<Vec<SliceSymbol>> = hits
            .iter()
            .map(|(_, occ)| symbols_in_span(&view, occ))
            .collect();
        assert_eq!(parallel.len(), serial.len(), "one bucket per hit");
        for (h, (p, s)) in parallel.iter().zip(&serial).enumerate() {
            assert_eq!(
                p, s,
                "hit {h}: parallel join must equal serial (same symbols, same order)"
            );
        }
        // And it must actually have found symbols (guard against a vacuous pass).
        assert!(
            serial.iter().any(|v| !v.is_empty()),
            "fixture should match some symbols"
        );
    }
}