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, ¶ms("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"
);
}
}