Skip to main content

reliary_pack/
lib.rs

1//! Holographic codebase pack generator.
2//!
3//! Produces a cache-stable, model-readable representation of a codebase
4//! that pre-loads enough information into an LLM's context to answer most
5//! codebase questions without tool calls.
6//!
7//! ## Format
8//!
9//! Each symbol gets one entry:
10//! ```text
11//! ## <symbol_name>
12//! L2: <signature>  [<file>:<line>]
13//! L3: <surprise facts only>
14//! ```
15//!
16//! L2 (signature) is always present — it anchors the model's prior.
17//! L3 (surprise) is present only when the function deviates from what
18//! a trained model would predict.
19//!
20//! ## Cross-references
21//!
22//! Uses the SQLite index (occurrence + block + scope_binding tables)
23//! for precise, disambiguated cross-references — not regex word matching.
24//!
25//! ## Surprise detection
26//!
27//! Uses skeleton-frequency analysis: lines whose aggressive_skeleton
28//! is rare across the codebase are "surprising"; lines whose skeleton
29//! is common are "expected" (the model's prior handles them).
30
31use std::collections::HashSet;
32use rustc_hash::FxHashMap;
33use std::path::Path;
34
35use rusqlite::Connection;
36
37use reliary_sift::classify::aggressive_skeleton;
38
39// ---------------------------------------------------------------------------
40// Public API
41// ---------------------------------------------------------------------------
42
43/// Pack format — controls which layers are included.
44#[derive(Debug, Clone, Copy)]
45pub enum PackFormat {
46    /// Signatures + surprise only (optimal, 85% reasoning accuracy).
47    L2L3,
48    /// Full: L0 (purpose) + L1 (paragraph) + L2 (signature) + L3 (surprise) + cross-refs.
49    Full,
50    /// Body snippets: signature + 5-8 representative body lines (simpler than L3).
51    /// The control experiment showed random body lines ≥ curated L3 surprise.
52    BodySnippets,
53}
54
55/// Gate decision returned by `should_inject_pack`.
56///
57/// Two-tier: high-complexity codebases get the full pack; middle-complexity
58/// ones get a minimal top-level map; low-complexity (or famous) ones get
59/// nothing. Calibration is based on measured pack-helpfulness data on
60/// reliary8 (HIGH), quale (MIDDLE), tokio (LOW).
61#[derive(Debug, Clone, Copy)]
62pub enum GateDecision {
63    /// Inject the full focused pack (~5K chars, hotspot-selected).
64    Full,
65    /// Inject a minimal top-level map only (~1K chars, signatures + L3 for top-15 symbols).
66    Minimal,
67    /// Skip the pack entirely — codebase is either too simple or already known by the model.
68    Skip,
69}
70
71/// Compute a composite complexity score for the codebase from the SQLite index.
72///
73/// `symbol_score = 0.4 * inbound_refs + 0.3 * outbound_calls + 0.3 * body_len_normalized`
74///
75/// The codebase score is the mean of symbol scores, weighted by inbound ref
76/// count (high-traffic symbols dominate). Higher = more complex = pack more
77/// useful.
78pub fn compute_complexity_score(path: &str) -> Result<f32, String> {
79    let db_path = format!(
80        "{}/.reliary/index.sqlite",
81        path.trim_end_matches('/')
82    );
83    let db = Connection::open(&db_path)
84        .map_err(|e| format!("cannot open index at {}: {}", db_path, e))?;
85    reliary_search::schema::open_existing_db_safe(&db)
86        .map_err(|e| format!("index not initialized: {}", e))?;
87    // V54: delegate to the single-pass implementation.
88    compute_complexity_score_for(&db, path)
89}
90
91/// Decide whether to inject the pack based on the codebase's complexity score.
92///
93/// Two-tier gate:
94/// - score > HIGH_THRESHOLD: full pack
95/// - score > LOW_THRESHOLD: minimal map
96/// - else: skip
97///
98/// Thresholds calibrated against measured data:
99/// - reliary8 (complex, specific identifiers, tight coupling): score ≈ 6.4 → Full
100/// - quali (medium, simpler bodies): score ≈ 5.4 → Minimal
101/// - tokio (famous, similar score to quali): score ≈ 5.4 → Minimal
102///   (we can't distinguish famous from private via index alone —
103///   the model's training data exposure is external knowledge)
104pub fn gate_decision(score: f32) -> GateDecision {
105    const HIGH_THRESHOLD: f32 = 6.0;
106    const LOW_THRESHOLD: f32 = 4.0;
107
108    if score > HIGH_THRESHOLD {
109        GateDecision::Full
110    } else if score > LOW_THRESHOLD {
111        GateDecision::Minimal
112    } else {
113        GateDecision::Skip
114    }
115}
116
117/// Convenience: run the full gate pipeline for a codebase path.
118pub fn should_inject_pack(path: &str) -> Result<GateDecision, String> {
119    let score = compute_complexity_score(path)?;
120    Ok(gate_decision(score))
121}
122
123/// C1: Gate decision using an already-open connection (no double open).
124pub fn gate_decision_for(conn: &Connection, path: &str) -> Result<GateDecision, String> {
125    let score = compute_complexity_score_for(conn, path)?;
126    Ok(gate_decision(score))
127}
128
129/// C12: Complexity score using an already-open connection.
130/// Combines 5 query_row calls into fewer queries via prepare_cached.
131pub fn compute_complexity_score_for(conn: &Connection, _path: &str) -> Result<f32, String> {
132    // V54: single pass over occurrence — one query computes symbol_count +
133    // non_def_count (source-only) instead of two separate full scans.
134    let row: (i64, i64) = conn.query_row(
135        "SELECT
136            COUNT(DISTINCT CASE WHEN o.is_def = 1 AND (
137                f.file_path LIKE '%.rs' OR f.file_path LIKE '%.py' OR f.file_path LIKE '%.ts'
138                OR f.file_path LIKE '%.go' OR f.file_path LIKE '%.c' OR f.file_path LIKE '%.js'
139                OR f.file_path LIKE '%.java' OR f.file_path LIKE '%.rb' OR f.file_path LIKE '%.swift'
140                OR f.file_path LIKE '%.kt' OR f.file_path LIKE '%.zig' OR f.file_path LIKE '%.nim'
141            ) THEN p.phrase END),
142            COUNT(CASE WHEN o.is_def = 0 AND (
143                f.file_path NOT LIKE '%/bench/%' AND f.file_path NOT LIKE '%/tests/%'
144                AND f.file_path NOT LIKE '%/test/%' AND f.file_path NOT LIKE '%/target/%'
145                AND f.file_path NOT LIKE '%/vendor/%' AND f.file_path NOT LIKE '%/node_modules/%'
146                AND f.file_path NOT LIKE '%/__pycache__/%' AND f.file_path NOT LIKE '%/.specstory/%'
147            ) THEN 1 END)
148         FROM occurrence o
149         JOIN phrases p ON o.phrase_id = p.id
150         JOIN file_map f ON o.file_id = f.id",
151        [], |r| Ok((r.get(0)?, r.get(1)?)),
152    ).unwrap_or((0, 0));
153
154    let (symbol_count, non_def_count) = row;
155    if symbol_count == 0 {
156        return Ok(0.0);
157    }
158    // Remaining scores: avg block span + specificity ratio (2 cheap queries).
159    let avg_block_span: f32 = conn.query_row(
160        "SELECT AVG(CAST(end_line - start_line AS REAL))
161         FROM block WHERE end_line > start_line",
162        [], |r| r.get::<_, Option<f64>>(0),
163    ).ok().flatten().unwrap_or(0.0) as f32;
164
165    let specific_count: i64 = conn.query_row(
166        "SELECT COUNT(DISTINCT p.phrase)
167         FROM phrases p
168         WHERE length(p.phrase) > 8
169          AND (p.phrase LIKE '%\\_%' ESCAPE '\\' OR substr(p.phrase, 2, 1) GLOB '[a-z]'
170              AND substr(p.phrase, 1, 1) GLOB '[A-Z]')",
171        [], |r| r.get(0),
172    ).unwrap_or(0);
173    let total_phrases: i64 = conn.query_row("SELECT COUNT(*) FROM phrases", [], |r| r.get(0)).unwrap_or(1);
174    let specificity_ratio = if total_phrases > 0 { specific_count as f32 / total_phrases as f32 } else { 0.0 };
175
176    let log_symbols = (symbol_count.max(1) as f32).ln();
177    let log_xrefs = (non_def_count.max(1) as f32).ln();
178    let span_score = avg_block_span / 10.0;
179
180    Ok(0.25 * log_symbols + 0.20 * span_score + 0.25 * log_xrefs + 0.30 * specificity_ratio)
181}
182
183/// Generate a holographic pack for a codebase.
184///
185/// Reads the SQLite index at `<path>/.reliary/index.sqlite` and produces
186/// a markdown pack string.
187pub fn generate_pack(path: &str, format: PackFormat) -> Result<String, String> {
188    let db_path = format!(
189        "{}/.reliary/index.sqlite",
190        path.trim_end_matches('/')
191    );
192    let db = Connection::open(&db_path)
193        .map_err(|e| format!("cannot open index at {}: {}", db_path, e))?;
194
195    // Verify the index is valid
196    reliary_search::schema::open_existing_db_safe(&db)
197        .map_err(|e| format!("index not initialized: {}", e))?;
198
199    // 1. Extract all definition symbols from the index
200    let symbols = extract_symbols_from_index(&db)?;
201
202    // 2. Read sources + build skeleton frequency in one pass (avoids re-reading files)
203    let (symbol_sources, skeleton_freq) = read_symbols_and_frequency(&symbols);
204
205    // 3. Extract cross-references from the index
206    let cross_refs = build_cross_refs_from_index(&db, &symbols);
207
208    // 4. Render the pack
209    let mut entries = Vec::new();
210    entries.push(format!(
211        "# Holographic Pack — {} ({} symbols)\n",
212        Path::new(path)
213            .file_name()
214            .map(|n| n.to_string_lossy().to_string())
215            .unwrap_or_else(|| path.to_string()),
216        symbols.len()
217    ));
218
219    for sym in &symbols {
220        let refs = cross_refs.get(&sym.name).cloned().unwrap_or_default();
221        let source = symbol_sources.get(&sym.name).cloned().unwrap_or_default();
222        let entry = render_entry(sym, &source, &refs, &skeleton_freq, format);
223        entries.push(entry);
224    }
225
226    Ok(entries.join("\n"))
227}
228
229/// Generate a hotspot-selected pack: top-K symbols by composite score.
230///
231/// Use this when queries are unknown — picks the most "interesting" symbols
232/// (most-called, most-connected, longest bodies) rather than the full pack.
233///
234/// Uses a LIGHT cross-ref computation (just inbound ref count per symbol)
235/// instead of the full cross-ref graph — this is 50x faster on large codebases.
236pub fn generate_pack_hotspot(
237    path: &str,
238    format: PackFormat,
239    top_k: usize,
240) -> Result<String, String> {
241    let db = open_index(path)?;
242    generate_pack_hotspot_with(&db, path, format, top_k)
243}
244
245/// C1: Hotspot pack using an already-open connection.
246pub fn generate_pack_hotspot_with(
247    db: &Connection,
248    path: &str,
249    format: PackFormat,
250    top_k: usize,
251) -> Result<String, String> {
252    let symbols = extract_symbols_from_index(db)?;
253
254    // For hotspot mode: only read sources for the top-K symbols (after ranking)
255    // First pass: rank using just inbound ref count (no file I/O needed)
256    let inbound_counts = compute_inbound_ref_counts(db, &symbols);
257    let mut scored: Vec<(f32, &Symbol)> = symbols
258        .iter()
259        .map(|s| {
260            let inbound = *inbound_counts.get(&s.name).unwrap_or(&0) as f32;
261            (0.6 * inbound, s) // body_len unknown yet; rank by inbound only
262        })
263        .collect();
264    // C33: select_nth_unstable_by for top-K (O(N) vs O(N log N)).
265    let k = top_k.min(scored.len());
266    if k < scored.len() {
267        scored.select_nth_unstable_by(k, |a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
268    }
269    scored.truncate(k);
270
271    let selected: Vec<Symbol> = scored
272        .into_iter()
273        .map(|(_, s)| s.clone())
274        .collect();
275
276    let (symbol_sources, skeleton_freq) = read_symbols_for_subset(&selected, &symbols);
277
278    // Build cross-refs only for the selected symbols
279    let cross_refs = build_cross_refs_for_subset(db, &selected, &symbols);
280
281    let mut entries = Vec::with_capacity(selected.len() + 1);
282    entries.push(format!(
283        "# Holographic Pack — {} ({} of {} symbols, hotspot-ranked)\n",
284        Path::new(path)
285            .file_name()
286            .map(|n| n.to_string_lossy().to_string())
287            .unwrap_or_else(|| path.to_string()),
288        selected.len(),
289        symbols.len()
290    ));
291
292    for sym in &selected {
293        // C4: Borrow instead of clone.
294        let refs = cross_refs.get(&sym.name).map(|v| v.as_slice()).unwrap_or(&[]);
295        let source = symbol_sources.get(&sym.name).map(|s| s.as_str()).unwrap_or("");
296        let entry = render_entry(sym, source, refs, &skeleton_freq, format);
297        entries.push(entry);
298    }
299
300    let result = entries.join("\n");
301    Ok(result)
302}
303
304/// Compute inbound reference counts for all symbols in one query.
305/// Only counts refs for symbols that pass the is_definition_like filter
306/// (otherwise common words like "std", "build", "fmt" dominate the ranking).
307fn compute_inbound_ref_counts(
308    db: &Connection,
309    symbols: &[Symbol],
310) -> FxHashMap<String, usize> {
311    let mut counts: FxHashMap<String, usize> = FxHashMap::default();
312    let symbol_names: HashSet<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
313
314    // Count: for each phrase that appears as a non-def occurrence, how many times?
315    // This is a proxy for "how many things reference this symbol".
316    let mut stmt = match db.prepare(
317        "SELECT p.phrase, COUNT(*) as cnt
318         FROM occurrence o
319         JOIN phrases p ON o.phrase_id = p.id
320         JOIN file_map f ON o.file_id = f.id
321         WHERE o.is_def = 0
322         AND f.file_path NOT LIKE '%/bench/%'
323         AND f.file_path NOT LIKE '%/tests/%'
324         AND f.file_path NOT LIKE '%/test/%'
325         AND f.file_path NOT LIKE '%/target/%'
326         AND f.file_path NOT LIKE '%/vendor/%'
327         AND f.file_path NOT LIKE '%/node_modules/%'
328         AND f.file_path NOT LIKE '%/__pycache__/%'
329         AND f.file_path NOT LIKE '%/.specstory/%'
330         GROUP BY p.phrase",
331    ) {
332        Ok(s) => s,
333        Err(_) => return counts,
334    };
335
336    let rows = stmt.query_map([], |row| {
337        Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
338    });
339
340    if let Ok(rows) = rows {
341        for row in rows.flatten() {
342            let (phrase, cnt) = row;
343            // Only count if this phrase is a real symbol (passed is_definition_like)
344            if symbol_names.contains(phrase.as_str()) {
345                counts.insert(phrase, cnt as usize);
346            }
347        }
348    }
349
350    counts
351}
352
353/// Build cross-refs only for a subset of symbols (the selected hotspots).
354/// This is much faster than building the full cross-ref graph.
355fn build_cross_refs_for_subset(
356    db: &Connection,
357    selected: &[Symbol],
358    all_symbols: &[Symbol],
359) -> FxHashMap<String, Vec<String>> {
360    let all_names: HashSet<&str> = all_symbols.iter().map(|s| s.name.as_str()).collect();
361    let mut cross_refs: FxHashMap<String, Vec<String>> = FxHashMap::default();
362
363    // BATCH: load all (file_id, phrase, is_def) in one query, then join in Rust.
364    // This avoids 27 × 2 SQL queries (each scanning the full occurrence table).
365    // V54: filter to known symbol names at the SQL level — cuts the load from
366    // the full occurrence table (OOM risk on large repos) to symbol-relevant
367    // rows only. The symbol set is small (thousands), occurrence rows for
368    // non-symbol phrases (common words, std lib) dominate the table.
369    let mut all_pairs_stmt = match db.prepare(
370        "SELECT o.file_id, p.phrase, o.is_def
371         FROM occurrence o
372         JOIN phrases p ON o.phrase_id = p.id
373         WHERE p.phrase IN (SELECT phrase FROM phrases WHERE length(phrase) BETWEEN 3 AND 30)
374         ORDER BY o.file_id LIMIT 200000",
375    ) {
376        Ok(s) => s,
377        Err(_) => return cross_refs,
378    };
379
380    let mut file_to_phrases: FxHashMap<i64, Vec<(String, bool)>> = FxHashMap::default();
381    for row in all_pairs_stmt
382        .query_map([], |row| {
383            Ok((
384                row.get::<_, i64>(0)?,
385                row.get::<_, String>(1)?,
386                row.get::<_, i64>(2)? != 0,
387            ))
388        })
389        .ok()
390        .into_iter()
391        .flatten()
392        .filter_map(|r| r.ok())
393    {
394        let (file_id, phrase, is_def) = row;
395        file_to_phrases.entry(file_id).or_default().push((phrase, is_def));
396    }
397
398    // For each selected symbol: find files where it's used (non-def), then
399    // find other defined symbols in those files.
400    for sym in selected {
401        // Find files where this symbol is used (non-def)
402        let usage_files: Vec<i64> = file_to_phrases
403            .iter()
404            .filter(|(_, pairs)| pairs.iter().any(|(p, is_def)| *p == sym.name && !*is_def))
405            .map(|(fid, _)| *fid)
406            .take(50)
407            .collect();
408
409        let mut refs: HashSet<String> = HashSet::new();
410        for file_id in usage_files {
411            if let Some(pairs) = file_to_phrases.get(&file_id) {
412                for (phrase, is_def) in pairs {
413                    if *is_def
414                        && *phrase != sym.name
415                        && all_names.contains(phrase.as_str())
416                        && !is_common_word(phrase)
417                    {
418                        refs.insert(phrase.clone());
419                    }
420                    if refs.len() >= 6 {
421                        break;
422                    }
423                }
424            }
425            if refs.len() >= 6 {
426                break;
427            }
428        }
429
430        cross_refs.insert(sym.name.clone(), refs.into_iter().take(6).collect());
431    }
432
433    cross_refs
434}
435
436/// Auto-mode: gate + hotspot selection combined.
437///
438/// 1. Compute complexity score
439/// 2. Gate: Full → top-50 hotspot pack; Minimal → top-15; Skip → empty
440/// 3. Return the pack (or empty string)
441pub fn generate_pack_auto(path: &str) -> Result<String, String> {
442    // C1: Open DB once, pass through to gate + generate.
443    let conn = open_index(path)?;
444    let decision = gate_decision_for(&conn, path)?;
445    
446    match decision {
447        GateDecision::Skip => Ok(String::new()),
448        GateDecision::Minimal => generate_pack_hotspot_with(&conn, path, PackFormat::L2L3, 15),
449        GateDecision::Full => generate_pack_hotspot_with(&conn, path, PackFormat::L2L3, 50),
450    }
451}
452
453/// C9: Extract repeated open_index logic.
454fn open_index(codebase_path: &str) -> Result<rusqlite::Connection, String> {
455    let db_path = format!(
456        "{}/.reliary/index.sqlite",
457        codebase_path.trim_end_matches('/')
458    );
459    let conn = Connection::open(&db_path)
460        .map_err(|e| format!("pack: open {}: {}", db_path, e))?;
461    reliary_search::schema::open_existing_db_safe(&conn)
462        .map_err(|e| format!("index not initialized: {}", e))?;
463    Ok(conn)
464}
465
466/// A pack entry parsed into searchable fields.
467#[derive(Debug, Clone)]
468pub struct PackEntry {
469    pub name: String,
470    pub header: String,   // "## name/crate"
471    pub body: String,     // L0+L2+L3+crossrefs text
472}
473
474/// Parse a full pack string into individual entries.
475pub fn parse_pack_entries(pack: &str) -> Vec<PackEntry> {
476    let mut entries = Vec::new();
477    let mut current_name: Option<String> = None;
478    let mut current_header: String = String::new();
479    let mut current_body: String = String::new();
480
481    for line in pack.lines() {
482        if line.starts_with("## ") {
483            // Save previous entry
484            if let Some(name) = current_name.take() {
485                entries.push(PackEntry {
486                    name,
487                    header: std::mem::take(&mut current_header),
488                    body: std::mem::take(&mut current_body),
489                });
490            }
491            current_name = Some(line.strip_prefix("///").unwrap_or(line).split('/').next().unwrap_or("").trim().to_string());
492            current_header = line.to_string();
493        } else if current_name.is_some() {
494            if !current_body.is_empty() {
495                current_body.push('\n');
496            }
497            current_body.push_str(line);
498        }
499    }
500    // Save last entry
501    if let Some(name) = current_name {
502        entries.push(PackEntry {
503            name,
504            header: current_header,
505            body: current_body,
506        });
507    }
508    entries
509}
510
511/// Tokenize text into lowercase word tokens for BM25 scoring.
512/// S1: Delegates to reliary_search::tokenize to use ASCII-only rules and
513/// match the main index's BM25 grammar (prevents CJK/Unicode fusion).
514fn tokenize(text: &str) -> Vec<String> {
515    reliary_search::tokenize(text)
516}
517
518/// Slice a full pack for a specific query using BM25 scoring.
519///
520/// Pre-build the full pack once, then call this per query to retrieve the
521/// top-K entries most relevant to the user's question. This handles the
522/// Per-query decision: should we slice the pack for this query, or skip it?
523///
524/// Based on N→F analysis from the 63-probe benchmark:
525/// - bug/crossref/detail queries: pack provides essential context (Δ = +1.0 to +1.8)
526/// - arch/impact/review queries: model already knows from training (Δ = +0.0)
527/// - discriminate/edge/what queries: marginal gain (Δ = +0.2 to +0.8)
528///
529/// This is a pure structural classifier — no API calls, no token cost.
530/// Runs in <1ms, zero side effects. Safe to call on every query.
531#[derive(Debug, Clone, Copy, PartialEq, Eq)]
532pub enum SliceDecision {
533    /// Send the sliced pack — query benefits from context
534    Slice,
535    /// Skip the pack — model likely knows the answer
536    Skip,
537}
538
539/// Classify a query text to decide whether the sliced pack is worth sending.
540///
541/// Grammar-free: uses keyword presence and structural signals, not language parsing.
542pub fn should_slice_for_query(query: &str) -> SliceDecision {
543    let text = query.to_ascii_lowercase();
544
545    // SLICE: bug detection and cross-references (biggest deltas)
546    if text.contains("bug") || text.contains("issue") || text.contains("wrong")
547        || text.contains("incorrect") || text.contains("missing") {
548        return SliceDecision::Slice;
549    }
550    if text.contains("call") || text.contains("caller") || text.contains("who uses")
551        || text.contains("references") || text.contains("called by") {
552        return SliceDecision::Slice;
553    }
554
555    // SLICE: detail questions need specific values
556    if text.contains("value") || text.contains("threshold") || text.contains("parameter")
557        || text.contains("default") || text.contains("exactly") || text.contains("what number") {
558        return SliceDecision::Slice;
559    }
560
561    // SLICE: question mentions specific symbol names that might not be in training.
562    // These are NAMES that the model is unlikely to know from generic Rust knowledge
563    // (project-specific internals), NOT common function names like "skeleton" or "classify".
564    let func_indicators = [
565        "find_clusters", "find_clusters_global", "skelhash", "skeleton_hash",
566        "aggressive_skeleton_hash", "maxwell", "maxwellgate", "should_drop",
567        "detect_strategy", "detect_tabular", "detect_json", "callgraph",
568        "is_definition_line", "reliary_sift", "reliary-output",
569    ];
570    for indicator in &func_indicators {
571        if text.contains(indicator) {
572            return SliceDecision::Slice;
573        }
574    }
575
576    // SKIP: architecture, impact, review, and what/edge/discriminate questions
577    // (model already knows from training, or marginal gain)
578    SliceDecision::Skip
579}
580
581/// Slice a pack for a specific query, but only if the classifier says it's worth it.
582/// Returns (sliced_content, decision) so callers can log/meter the decision.
583pub fn adaptive_slice(pack: &str, query: &str, top_k: usize) -> (String, SliceDecision) {
584    let decision = should_slice_for_query(query);
585    match decision {
586        SliceDecision::Slice => (slice_pack_for_query(pack, query, top_k), decision),
587        SliceDecision::Skip => (String::new(), decision),
588    }
589}
590
591/// "which symbols to include" problem that hotspot selection can't solve.
592///
593/// Returns a sliced pack string with just the top-K entries.
594pub fn slice_pack_for_query(pack: &str, query: &str, top_k: usize) -> String {
595    let entries = parse_pack_entries(pack);
596    if entries.is_empty() {
597        return String::new();
598    }
599
600    let query_tokens = tokenize(query);
601    if query_tokens.is_empty() {
602        // No query — return first top_k entries (fallback)
603        return entries
604            .into_iter()
605            .take(top_k)
606            .map(|e| format!("{}\n{}", e.header, e.body))
607            .collect::<Vec<_>>()
608            .join("\n\n");
609    }
610
611    // Build a simple BM25 index over the pack entries
612    let n_docs = entries.len() as f64;
613    let mut doc_tokens: Vec<Vec<String>> = Vec::with_capacity(entries.len());
614    for entry in &entries {
615        // Search over name + body (L0/L2/L3 text)
616        let combined = format!("{} {}", entry.name, entry.body);
617        doc_tokens.push(tokenize(&combined));
618    }
619
620    // Compute document frequencies for each query token
621    let mut df: FxHashMap<String, usize> = FxHashMap::default();
622    for tokens in &doc_tokens {
623        let unique: HashSet<&str> = tokens.iter().map(|s| s.as_str()).collect();
624        for t in &query_tokens {
625            if unique.contains(t.as_str()) {
626                *df.entry(t.clone()).or_insert(0) += 1;
627            }
628        }
629    }
630
631    // Compute avg document length
632    let total_len: usize = doc_tokens.iter().map(|d| d.len()).sum();
633    let avgdl = if n_docs > 0.0 {
634        total_len as f64 / n_docs
635    } else {
636        1.0
637    };
638
639    // Score each document
640    let mut scored: Vec<(f64, &PackEntry)> = Vec::with_capacity(entries.len());
641    for (i, entry) in entries.iter().enumerate() {
642        let doc_len = doc_tokens[i].len() as f64;
643        let mut score = 0.0;
644        // Count term frequencies in this doc
645        let mut tf: FxHashMap<&str, usize> = FxHashMap::default();
646        for t in &doc_tokens[i] {
647            *tf.entry(t.as_str()).or_insert(0) += 1;
648        }
649        for qt in &query_tokens {
650            let d_freq = *df.get(qt).unwrap_or(&0) as f64;
651            if d_freq == 0.0 {
652                continue;
653            }
654            let idf = ((n_docs - d_freq + 0.5) / (d_freq + 0.5) + 1.0).ln();
655            let term_freq = *tf.get(qt.as_str()).unwrap_or(&0) as f64;
656            if term_freq > 0.0 {
657                let bm25 = idf * (term_freq * (1.2 + 1.0))
658                    / (term_freq + 1.2 * (1.0 - 0.75 + 0.75 * (doc_len / avgdl)));
659                score += bm25;
660            }
661        }
662        // Boost: if the entry name directly matches a query token, boost score
663        let name_lower = entry.name.to_ascii_lowercase();
664        for qt in &query_tokens {
665            if name_lower == *qt {
666                score += 5.0; // strong boost for exact name match
667            } else if name_lower.contains(qt.as_str()) {
668                score += 2.0; // weaker boost for substring match
669            }
670        }
671        scored.push((score, entry));
672    }
673
674    // Sort by score descending
675    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
676
677    // Take top-K with score > 0
678    let selected: Vec<&PackEntry> = scored
679        .into_iter()
680        .filter(|(s, _)| *s > 0.0)
681        .take(top_k)
682        .map(|(_, e)| e)
683        .collect();
684
685    if selected.is_empty() {
686        return String::new();
687    }
688
689    // Cross-ref expansion: for each selected entry, find cross-ref names
690    // in its body (Cross-refs: or L4: lines) and include those entries too.
691    // This ensures that when we slice for "detect_strategy", we also
692    // include its callers/callees like "detect_json", "detect_tabular".
693    let selected_names: HashSet<String> = selected.iter().map(|e| e.name.clone()).collect();
694    // Pre-build index for O(1) entry lookup by name
695    let mut entries_by_name: FxHashMap<&str, &PackEntry> = FxHashMap::default();
696    for e in &entries {
697        entries_by_name.entry(e.name.as_str()).or_insert(e);
698    }
699    let mut expanded: Vec<&PackEntry> = selected.clone();
700    let mut expanded_names: HashSet<String> = selected_names.clone();
701    let mut expansion_count = 0;
702    let max_expansion = 15usize.saturating_sub(top_k.min(15));
703    for entry in &selected {
704        for line in entry.body.lines() {
705            let refs_text = if let Some(rest) = line.strip_prefix("Cross-refs:") {
706                Some(rest.trim())
707            } else if line.starts_with("L4:") && line.contains("caller") {
708                // L4: Rename → update N caller(s): name1, name2, name3
709                if let Some(pos) = line.find(": ") {
710                    let after = &line[pos + 2..];
711                    // Skip if it's "update N caller(s)" prefix — extract names after the last ":"
712                    if let Some(last_colon) = after.rfind(": ") {
713                        Some(&after[last_colon + 2..])
714                    } else {
715                        Some(after)
716                    }
717                } else {
718                    None
719                }
720            } else {
721                None
722            };
723            if let Some(refs) = refs_text {
724                for ref_name in refs.split(',').take(3) {
725                    let ref_name = ref_name.trim();
726                    if ref_name.is_empty() { continue; }
727                    let ref_name_slash = format!("{}/", ref_name);
728                    // O(1) lookup: exact match or prefix match
729                    if let Some(e) = entries_by_name.get(ref_name).or_else(|| {
730                        entries_by_name.values().find(|e| e.name.starts_with(&ref_name_slash))
731                    }) {
732                        if !selected_names.contains(&e.name) && !expanded_names.contains(&e.name) {
733                            expanded_names.insert(e.name.clone());
734                            expanded.push(e);
735                            expansion_count += 1;
736                            if expansion_count >= max_expansion { break; }
737                        }
738                    }
739                    if expansion_count >= max_expansion { break; }
740                }
741                break; // Only one Cross-refs/L4 line per entry
742            }
743        }
744        if expansion_count >= max_expansion { break; }
745    }
746
747    let total_entries = expanded.len();
748
749    let mut result = format!(
750        "# Sliced Pack — {} entries for query: \"{}\"\n\n",
751        total_entries,
752        query.chars().take(80).collect::<String>()
753    );
754    for entry in &expanded {
755        result.push_str(&format!("{}\n{}\n\n", entry.header, entry.body));
756    }
757    result
758}
759
760/// Generate a hierarchical pack: top-level map + per-module packs.
761pub fn generate_hierarchical_pack(
762    path: &str,
763    format: PackFormat,
764    max_symbols_per_module: usize,
765) -> Result<HierarchicalPack, String> {
766    let db_path = format!(
767        "{}/.reliary/index.sqlite",
768        path.trim_end_matches('/')
769    );
770    let db = Connection::open(&db_path)
771        .map_err(|e| format!("cannot open index: {}", e))?;
772    reliary_search::schema::open_existing_db_safe(&db)
773        .map_err(|e| format!("index not initialized: {}", e))?;
774
775    let symbols = extract_symbols_from_index(&db)?;
776    let (symbol_sources, skeleton_freq) = read_symbols_and_frequency(&symbols);
777    let cross_refs = build_cross_refs_from_index(&db, &symbols);
778
779    // Group by module (top-level directory or packages/*)
780    let modules = detect_modules(&symbols);
781
782    // Build top-level map
783    let mut top_parts = vec![format!(
784        "# Holographic Pack — {} (top-level map)\n",
785        Path::new(path)
786            .file_name()
787            .map(|n| n.to_string_lossy().to_string())
788            .unwrap_or_else(|| path.to_string()),
789    )];
790
791    let mut module_packs: Vec<(String, String)> = Vec::new();
792
793    for (module_name, module_syms) in &modules {
794        let sym_count = module_syms.len();
795        let key_syms: Vec<String> = module_syms
796            .iter()
797            .filter(|s| !s.doc_comment.is_empty())
798            .take(5)
799            .map(|s| s.name.clone())
800            .collect();
801
802        top_parts.push(format!("## {}", module_name));
803        top_parts.push(format!("L0: {} symbols across this module", sym_count));
804        if !key_syms.is_empty() {
805            top_parts.push(format!("Key symbols: {}", key_syms.join(", ")));
806        }
807        top_parts.push(String::new());
808
809        // Build per-module pack
810        let limited: Vec<&Symbol> = module_syms.iter().take(max_symbols_per_module).collect();
811        if limited.is_empty() {
812            continue;
813        }
814        let mut pack_parts = vec![format!("# Holographic Pack — {}\n", module_name)];
815        for sym in limited {
816            let refs = cross_refs.get(&sym.name).cloned().unwrap_or_default();
817            let source = symbol_sources.get(&sym.name).cloned().unwrap_or_default();
818            let entry = render_entry(sym, &source, &refs, &skeleton_freq, format);
819            pack_parts.push(entry);
820        }
821        module_packs.push((module_name.clone(), pack_parts.join("\n")));
822    }
823
824    Ok(HierarchicalPack {
825        top_level: top_parts.join("\n"),
826        module_packs,
827    })
828}
829
830pub struct HierarchicalPack {
831    pub top_level: String,
832    pub module_packs: Vec<(String, String)>,
833}
834
835// ---------------------------------------------------------------------------
836// Symbol extraction from the SQLite index
837// ---------------------------------------------------------------------------
838
839#[derive(Debug, Clone)]
840pub struct Symbol {
841    pub name: String,
842    pub signature: String,
843    pub file: String,
844    pub line: i32,
845    pub doc_comment: String,
846    pub block_id: i64,
847    pub end_line: i32,
848    pub indent: i32,
849}
850
851/// Extract all definition symbols from the index.
852///
853/// Uses the `occurrence` table (is_def = 1) joined with `block` for body
854/// boundaries and `file_map` for file paths.
855fn extract_symbols_from_index(db: &Connection) -> Result<Vec<Symbol>, String> {
856    let mut stmt = db
857        .prepare(
858            "SELECT o.phrase_id, o.file_id, o.line, o.block_id,
859                    p.phrase, f.file_path,
860                    b.end_line, b.indent
861             FROM occurrence o
862             JOIN phrases p ON o.phrase_id = p.id
863             JOIN file_map f ON o.file_id = f.id
864             JOIN block b ON o.block_id = b.block_id
865             WHERE o.is_def = 1
866             ORDER BY f.file_path, o.line",
867        )
868        .map_err(|e| format!("query symbols: {}", e))?;
869
870    let rows = stmt
871        .query_map([], |row| {
872            let phrase: String = row.get(4)?;
873            let file_path: String = row.get(5)?;
874            let line: i32 = row.get(2)?;
875            let block_id: i64 = row.get(3)?;
876            let end_line: i32 = row.get(6)?;
877            let indent: i32 = row.get(7)?;
878            Ok((phrase, file_path, line, block_id, end_line, indent))
879        })
880        .map_err(|e| format!("query symbols: {}", e))?;
881
882    let mut symbols: Vec<Symbol> = Vec::new();
883    // Dedupe by (name, file_path) so the same name in different files
884    // (e.g. `skeleton` in reliary-output and reliary-sift) is kept,
885    // but the same name in the same file is deduped.
886    let mut seen: HashSet<(String, String)> = HashSet::new();
887
888    // S2: Use shared file_meta cache instead of per-call file_cache.
889    // file_meta::get returns Arc<FileMeta> with lines pre-split and cached.
890    let mut lines_cache: FxHashMap<String, Vec<String>> = FxHashMap::default();
891
892    for row in rows {
893        let (_phrase, file_path, line, block_id, end_line, indent) =
894            row.map_err(|e| format!("row: {}", e))?;
895
896        // Skip non-source files first (markdown, config, etc.)
897        if !is_source_file(&file_path) {
898            continue;
899        }
900
901        // Ensure lines are cached — file_meta::get is the shared LRU cache.
902        if !lines_cache.contains_key(&file_path) {
903            let lines_vec: Vec<String> = if let Some(meta) = reliary_search::file_meta::get(&file_path) {
904                meta.lines.clone()
905            } else {
906                std::fs::read_to_string(&file_path)
907                    .map(|c| c.lines().map(String::from).collect())
908                    .unwrap_or_default()
909            };
910            lines_cache.insert(file_path.clone(), lines_vec);
911        }
912
913        let lines = &lines_cache[&file_path];
914
915        // Read the source line for the signature
916        let signature = read_signature_line(lines, line);
917
918        // GRAMMAR-FREE DEFINITION FILTER:
919        if !is_definition_like(&signature) {
920            continue;
921        }
922
923        // Extract the REAL function name from the signature line
924        let real_name = extract_name_from_signature(&signature);
925        if real_name.len() < 3 {
926            continue;
927        }
928
929        // Skip common English words and noise
930        if is_common_word(&real_name) {
931            continue;
932        }
933
934        // Skip Python dunders (often auto-generated, not real symbols)
935        if real_name.starts_with("__") && real_name.ends_with("__") {
936            continue;
937        }
938
939        // Dedupe by (name, file) — keep same name in different files
940        let key = (real_name.clone(), file_path.clone());
941        if seen.contains(&key) {
942            continue;
943        }
944        seen.insert(key);
945
946        // Look backward for doc comments
947        let doc_comment = read_doc_comment(lines, line);
948
949        symbols.push(Symbol {
950            name: real_name,
951            signature,
952            file: file_path,
953            line,
954            doc_comment,
955            block_id,
956            end_line,
957            indent,
958        });
959    }
960
961    Ok(symbols)
962}
963
964/// Extract the function/type name from a signature line (grammar-free).
965///
966/// Strips common keywords and type annotations, then takes the first
967/// identifier-like token that isn't a keyword or type.
968fn extract_name_from_signature(sig: &str) -> String {
969    let cleaned = sig.trim();
970
971    // Strip leading keywords (language-agnostic)
972    let mut rest = cleaned;
973    let keywords = [
974        "pub(crate) ", "pub(super) ", "pub ", "export ", "async ", "extern ",
975        "static ", "inline ", "virtual ", "override ", "final ", "abstract ",
976        "unsafe ", "mut ",
977    ];
978    loop {
979        let mut changed = false;
980        for kw in &keywords {
981            if rest.starts_with(kw) {
982                rest = &rest[kw.len()..];
983                changed = true;
984                break;
985            }
986        }
987        if !changed {
988            break;
989        }
990    }
991
992    // Strip declaration keywords
993    let decl_kws = [
994        "fn ", "function ", "def ", "func ", "struct ", "class ", "enum ",
995        "trait ", "interface ", "type ", "impl ", "const ", "let ", "var ",
996        "val ",
997    ];
998    for kw in &decl_kws {
999        if rest.starts_with(kw) {
1000            rest = &rest[kw.len()..];
1001            break;
1002        }
1003    }
1004
1005    // For impl blocks, skip the type name: "MaxwellGate {" → not a function
1006    // But "impl MaxwellGate {" → we want "MaxwellGate"
1007    if rest.starts_with("impl ") {
1008        rest = &rest[5..];
1009    }
1010
1011    // Find the first identifier-like token (up to (, <, :, {, space, =)
1012    let end = rest
1013        .find(['(', '<', ':', '{', ' ', '=', ';'])
1014        .unwrap_or(rest.len());
1015    let name = rest[..end].trim();
1016
1017    // Clean up: remove generics, lifetimes
1018    let name = name.trim_start_matches('\'');
1019
1020    if name.is_empty() || name.len() < 2 {
1021        return String::new();
1022    }
1023
1024    // Validate: must be identifier-like (alphanumeric + underscore, starts with alpha or _)
1025    if !name.chars().next().map(|c| c.is_alphabetic() || c == '_').unwrap_or(false) {
1026        return String::new();
1027    }
1028    if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
1029        return String::new();
1030    }
1031
1032    name.to_string()
1033}
1034
1035/// Check if a file is a source code file (not markdown, config, etc.)
1036fn is_source_file(path: &str) -> bool {
1037    // Exclude common test/bench/vendor directories — these aren't the
1038    // codebase the user wants to explore.
1039    if path.contains("/bench/")
1040        || path.contains("/tests/")
1041        || path.contains("/test/")
1042        || path.contains("/target/")
1043        || path.contains("/vendor/")
1044        || path.contains("/node_modules/")
1045        || path.contains("/__pycache__/")
1046        || path.contains("/.git/")
1047    {
1048        return false;
1049    }
1050    let ext = Path::new(path)
1051        .extension()
1052        .and_then(|e| e.to_str())
1053        .unwrap_or("");
1054    matches!(
1055        ext,
1056        "rs" | "py" | "ts" | "tsx" | "js" | "jsx"
1057            | "go" | "java" | "c" | "h" | "cpp" | "cc" | "cxx"
1058            | "rb" | "swift" | "kt" | "scala" | "clj" | "ex" | "exs"
1059            | "elm" | "fs" | "ml" | "hs" | "lua" | "php" | "pl"
1060    )
1061}
1062
1063/// Grammar-free definition detection — same heuristic as the experiment.
1064/// Checks if a line looks like a function/struct/enum/class definition
1065/// without knowing the language.
1066fn is_definition_like(line: &str) -> bool {
1067    let stripped = line.trim();
1068    if stripped.is_empty() {
1069        return false;
1070    }
1071
1072    // Skip lines that are purely comments
1073    if stripped.starts_with("//")
1074        || stripped.starts_with('#')
1075        || stripped.starts_with("/*")
1076        || stripped.starts_with("///")
1077        || stripped.starts_with("--")
1078        || stripped.starts_with('\'')
1079    {
1080        return false;
1081    }
1082
1083    // Skip control flow (if/for/while/switch/match/return) — not definitions
1084    if stripped.starts_with("if ")
1085        || stripped.starts_with("for ")
1086        || stripped.starts_with("while ")
1087        || stripped.starts_with("return ")
1088        || stripped.starts_with("match ")
1089        || stripped.starts_with("switch ")
1090        || stripped.starts_with("let ")
1091        || stripped.starts_with("let mut")
1092        || stripped.starts_with("const ")
1093        || stripped.starts_with("var ")
1094        || stripped.starts_with("static ")   // Rust statics, not function definitions
1095        || stripped.starts_with("type ")     // TypeScript type aliases
1096        || stripped.starts_with("enum ")     // already handles enum, but be explicit
1097        || stripped.starts_with("f.write")
1098        || stripped.starts_with("println")
1099        || stripped.starts_with("print")
1100        || stripped.starts_with("eprintln")
1101    {
1102        return false;
1103    }
1104
1105    let check = stripped.trim_end_matches([';', ',']);
1106
1107    // Must contain a function-like or type-like declaration pattern.
1108    // We require BOTH an identifier AND a block-opener or type-marker.
1109
1110    // Function definition: has identifier( and ends with { or (
1111    // e.g., "pub fn skeleton(line: &str) -> String {"
1112    // e.g., "export function processSession(id: string): Result {"
1113    // Single-line body: ends with } but has balanced { ... } inside the line.
1114    //   e.g., "pub fn foo() -> i32 { 42 }"
1115    // BUT NOT: "std::iter::from_fn(move || {" (that's a call, not a definition)
1116    // BUT NOT: "search = run_altbackend_cli(" (that's an assignment, not a def)
1117    // BUT NOT: Python "def foo():" (handled separately below)
1118    let ends_with_brace = check.ends_with('{')
1119        || (check.ends_with('}')
1120            && check.matches('{').count() == 1
1121            && check.matches('}').count() == 1);
1122    if check.contains('(') && ends_with_brace {
1123        let before_paren = check.split('(').next().unwrap_or("").trim();
1124        // Must look like a function name: identifier optionally preceded by
1125        // keywords (pub, fn, def, function, etc.) but NOT a path (::) or a call.
1126        // Strip keywords to get the bare name
1127        let mut name_part = before_paren;
1128        for kw in &["pub(crate) ", "pub(super) ", "pub ", "async ", "unsafe ",
1129                    "extern ", "fn ", "function ", "func ", "export ",
1130                    "static ", "inline ", "const ", "final ", "override "] {
1131            if name_part.starts_with(kw) {
1132                name_part = &name_part[kw.len()..];
1133            }
1134        }
1135        // The name must be a simple identifier (no ::, no ., no ->, no =, no ||)
1136        // and must be > 3 chars to avoid noise like "new", "run", "get"
1137        if !name_part.contains("::")
1138            && !name_part.contains('.')
1139            && !name_part.contains("->")
1140            && !name_part.contains("||")
1141            && !name_part.contains('=')
1142            && !name_part.contains(' ')
1143            && name_part.len() > 3
1144        {
1145            return true;
1146        }
1147    }
1148
1149    // Python-style: def name(args):
1150    if check.starts_with("def ") && check.ends_with(':') {
1151        return true;
1152    }
1153
1154    // Class/struct/enum/interface/trait: ends with { and starts with keyword
1155    if check.ends_with('{') {
1156        let lower = check.to_ascii_lowercase();
1157        if lower.contains(" struct ")
1158            || lower.contains(" class ")
1159            || lower.contains(" enum ")
1160            || lower.contains(" trait ")
1161            || lower.contains(" interface ")
1162            || lower.contains(" impl ")
1163            || lower.starts_with("struct ")
1164            || lower.starts_with("class ")
1165            || lower.starts_with("enum ")
1166            || lower.starts_with("trait ")
1167            || lower.starts_with("interface ")
1168            || lower.starts_with("impl ")
1169            || lower.starts_with("pub struct ")
1170            || lower.starts_with("pub enum ")
1171            || lower.starts_with("pub trait ")
1172        {
1173            return true;
1174        }
1175    }
1176
1177    // TypeScript interface/type: ends with { or starts with interface/type
1178    if check.ends_with('{') && (check.starts_with("export interface ") || check.starts_with("interface ") || check.starts_with("export type ") || check.starts_with("type ")) {
1179        return true;
1180    }
1181
1182    false
1183}
1184
1185/// Filter out common English words that the ingestion might mark as definitions.
1186fn is_common_word(word: &str) -> bool {
1187    // Skip very short words
1188    if word.len() < 3 {
1189        return true;
1190    }
1191    // Common English words that appear in comments/docs
1192    const COMMON: &[&str] = &[
1193        "the", "and", "for", "not", "are", "but", "you", "all", "can", "her",
1194        "was", "one", "our", "out", "has", "his", "how", "its", "may", "new",
1195        "now", "old", "see", "way", "who", "did", "get", "let", "say", "she",
1196        "too", "use", "any", "ask", "bad", "big", "day", "end", "few", "got",
1197        "had", "him", "his", "how", "its", "man", "men", "put", "run", "set",
1198        "try", "two", "use", "via", "yet", "also", "code", "data", "file",
1199        "from", "have", "into", "just", "like", "make", "more", "must",
1200        "only", "over", "some", "such", "than", "them", "then", "they",
1201        "this", "that", "when", "what", "where", "which", "while", "will",
1202        "with", "work", "would", "your", "about", "after", "again", "before",
1203        "being", "below", "could", "every", "first", "found", "great",
1204        "group", "having", "here", "high", "into", "itself", "just",
1205        "large", "last", "left", "like", "long", "made", "many", "most",
1206        "much", "never", "next", "once", "other", "part", "place", "right",
1207        "same", "should", "show", "since", "small", "some", "still",
1208        "such", "take", "their", "them", "then", "there", "these",
1209        "they", "thing", "think", "this", "those", "through", "time",
1210        "under", "until", "very", "well", "were", "what", "when",
1211        "where", "which", "while", "will", "with", "without", "would",
1212        "write", "your", "index", "free", "grammar", "instead", "tool",
1213        "tree", "language", "read", "work", "reliary",
1214    ];
1215    // P2-7: use HashSet for O(1) lookup instead of O(200) linear scan.
1216    static COMMON_SET: std::sync::OnceLock<std::collections::HashSet<&'static str>> = std::sync::OnceLock::new();
1217    let set = COMMON_SET.get_or_init(|| std::collections::HashSet::from_iter(COMMON.iter().copied()));
1218    let lower = word.to_ascii_lowercase();
1219    set.contains(lower.as_str())
1220}
1221
1222fn read_signature_line(lines: &[String], line: i32) -> String {
1223    lines
1224        .get(line.max(0) as usize)
1225        .map(|s| s.trim().to_string())
1226        .unwrap_or_default()
1227}
1228
1229fn read_doc_comment(lines: &[String], def_line: i32) -> String {
1230    let def_idx = (def_line.max(0) as usize).min(lines.len().saturating_sub(1));
1231
1232    // Look backward for doc comments (/// or /** */ or // or #)
1233    let mut doc_parts: Vec<String> = Vec::new();
1234    let mut j = def_idx.saturating_sub(1);
1235
1236    // Check for block comment ending right above
1237    if j < lines.len() && lines[j].trim().ends_with("*/") {
1238        let mut block: Vec<String> = Vec::new();
1239        while j > 0 {
1240            let l = lines[j].trim();
1241            block.insert(0, l.to_string());
1242            if l.starts_with("/**") || l.starts_with("/*") {
1243                break;
1244            }
1245            j -= 1;
1246        }
1247        for bl in &block {
1248            let clean = bl
1249                .trim()
1250                .trim_start_matches("/**")
1251                .trim_start_matches("/*")
1252                .trim_end_matches("*/")
1253                .trim()
1254                .trim_start_matches('*')
1255                .trim();
1256            if !clean.is_empty() && !clean.starts_with('@') && clean != "/**" {
1257                doc_parts.insert(0, clean.to_string());
1258            }
1259        }
1260    }
1261
1262    if doc_parts.is_empty() {
1263        // Line comments above
1264        j = def_idx.saturating_sub(1);
1265        while j > 0 {
1266            let l = lines[j].trim();
1267            if let Some(rest) = l.strip_prefix("///") {
1268                doc_parts.insert(0, rest.trim().to_string());
1269            } else if let Some(rest) = l.strip_prefix("//") {
1270                doc_parts.insert(0, rest.trim().to_string());
1271            } else if l.starts_with('#') && !l.starts_with("#[") {
1272                doc_parts.insert(0, l[1..].trim().to_string());
1273            } else if l.is_empty() {
1274                // skip blanks between comment and definition
1275            } else {
1276                break;
1277            }
1278            j -= 1;
1279        }
1280    }
1281
1282    doc_parts.join(" ")
1283}
1284
1285// ---------------------------------------------------------------------------
1286// Skeleton-frequency for surprise detection
1287// ---------------------------------------------------------------------------
1288
1289/// Returns true if `line` is a non-surprising code pattern that should NOT
1290/// appear in L3 surprise output.
1291///
1292/// These are the lines that pollute the auto-generated L3 with noise — they
1293/// appear as "unique line: ..." entries but carry no real surprise value.
1294/// The hand-crafted pack omitted them entirely; this filter makes the auto-
1295/// generated pack match that judgment.
1296fn is_noise_line(line: &str) -> bool {
1297    let trimmed = line.trim();
1298    if trimmed.is_empty() {
1299        return true;
1300    }
1301    // Length: lines shorter than 15 chars are too short to carry surprise
1302    // (closing braces, semicolons, blank-ish content).
1303    if trimmed.len() < 15 {
1304        return true;
1305    }
1306    // Pure structural closers
1307    if trimmed == "}" || trimmed == "});" || trimmed == "};" || trimmed == "];" || trimmed == ")" {
1308        return true;
1309    }
1310    // Imports / use statements (the skeleton normalizes these all to {w} anyway,
1311    // and the model's prior handles them).
1312    if trimmed.starts_with("use ") || trimmed.starts_with("import ") {
1313        return true;
1314    }
1315    // Comments — already excluded by tokenization, but double-check
1316    if trimmed.starts_with("//") || trimmed.starts_with("#") || trimmed.starts_with("/*") {
1317        return true;
1318    }
1319    // Python docstrings (multi-line string literals used as documentation)
1320    if trimmed.starts_with("\"\"\"") || trimmed.starts_with("'''") {
1321        return true;
1322    }
1323    // Python class-method internals — not surprising, just noise
1324    if trimmed.starts_with("self.") || trimmed.starts_with("cls.") || trimmed.starts_with("super()") {
1325        return true;
1326    }
1327    // Python imports — model's prior handles these
1328    if trimmed.starts_with("from ") && trimmed.contains(" import ") {
1329        return true;
1330    }
1331    // Python assertions — test helpers, not surprising
1332    if trimmed.starts_with("assert") {
1333        return true;
1334    }
1335    // Lines that are just a closing docstring/quote
1336    if trimmed == "\"\"\"" || trimmed == "'''" || trimmed == "\"\"\";" || trimmed == "'''\";" {
1337        return true;
1338    }
1339    // Simple variable bindings: `let x = y;` or `let mut x = ...;`
1340    // These are the most common "unique line" noise. Allow ones with parens
1341    // (`let x = foo(...)` may be a function call worth noting).
1342    if (trimmed.starts_with("let ") || trimmed.starts_with("let mut "))
1343        && !trimmed.contains("(")
1344        && !trimmed.contains("{")
1345        && trimmed.ends_with(";")
1346    {
1347        return true;
1348    }
1349    // Simple return statements with no value or a bare identifier
1350    // (`return;` or `return x;` where x is a single word — not surprising)
1351    // S1 fix: strip inline comments first so trailing // doesn't bypass filters
1352    let trimmed_for_return = if let Some(comment_start) = trimmed.find("//") {
1353        trimmed[..comment_start].trim()
1354    } else {
1355        trimmed
1356    };
1357    if let Some(rest) = trimmed_for_return.strip_prefix("return ") {
1358        // `return;` or `return Some(x);` or `return None;` IS interesting if it's
1359        // a sentinel. Only filter the trivial `return <single_word>;` case.
1360        if let Some(inner) = rest.strip_suffix(";") {
1361            let inner = inner.trim();
1362            if !inner.contains("(") && !inner.contains(".") && !inner.contains("[") {
1363                // Single bare identifier return — likely noise
1364                return true;
1365            }
1366        }
1367        // Python returns: `return X.lower() in Y.lower()` — too common to be surprising
1368        if rest.contains(".lower()") || rest.contains(".upper()") {
1369            return true;
1370        }
1371    }
1372    // Python `if isinstance(obj, X):` — too common to be surprising
1373    if trimmed.starts_with("if isinstance(") {
1374        return true;
1375    }
1376    // Python `for X in Y:` — basic iteration, not surprising
1377    if trimmed.starts_with("for ") && trimmed.ends_with(":") && !trimmed.contains("enumerate") {
1378        return true;
1379    }
1380    // Python `self.X = Y` — attribute assignment, not surprising
1381    if trimmed.starts_with("self.") && trimmed.contains(" = ") && !trimmed.contains("(") {
1382        return true;
1383    }
1384    // Simple assertions / panics with no context
1385    if trimmed.starts_with("assert!") || trimmed.starts_with("panic!") || trimmed.starts_with("todo!") {
1386        return true;
1387    }
1388    // Attribute lines
1389    if trimmed.starts_with("#[") || trimmed.starts_with("#![") {
1390        return true;
1391    }
1392    // Mod / pub mod declarations
1393    if trimmed.starts_with("mod ") || trimmed.starts_with("pub mod ") {
1394        return true;
1395    }
1396    // S1 fix: derive attributes already filtered above with #include, but
1397    // additional patterns below were still producing "unique line:" noise
1398    if trimmed.starts_with("derive(") || trimmed.starts_with("cfg_attr(") || trimmed.starts_with("allow(") {
1399        return true;
1400    }
1401    // S1 fix: Python test methods (self.assertX, self.setUp, self.X.method)
1402    if let Some(rest) = trimmed.strip_prefix("self.") {
1403        if rest.starts_with("assert") || rest.contains(".assert") || rest.starts_with("setUp")
1404            || rest.starts_with("tearDown") || rest.starts_with("set_up")
1405            || rest.starts_with("tear_down") || rest.starts_with("skip(")
1406            || rest.starts_with("fixture") || rest.starts_with("patch(")
1407        {
1408            return true;
1409        }
1410    }
1411    // S1 fix: Rust method calls ending in ; — operations, not surprising
1412    //   e.g., `block_phrases.append(line);`
1413    if trimmed.contains(".append(") || trimmed.contains(".push(")
1414        || trimmed.contains(".insert(") || trimmed.contains(".remove(")
1415        || trimmed.contains(".clear(") || trimmed.contains(".drop(")
1416        || trimmed.contains(".len()") || trimmed.contains(".is_empty()")
1417        || trimmed.contains(".expect(") || trimmed.contains(".collect()")
1418        || trimmed.contains(".clone(") || trimmed.contains(".iter()")
1419    {
1420        return true;
1421    }
1422    // S1 fix: Rust single-token statements
1423    if trimmed == "break;" || trimmed == "continue;"
1424        || trimmed == "break" || trimmed == "continue" {
1425        return true;
1426    }
1427    // S1 fix: Python from X import statements (already covered by use)
1428    if trimmed.starts_with("from ") {
1429        return true;
1430    }
1431    // S1 fix: trivial returns (`return None;` `return true;` `return false;`)
1432    if let Some(stripped_return) = trimmed.strip_prefix("return ") {
1433        let rest = stripped_return.trim().trim_end_matches(';').trim();
1434        if matches!(rest, "true" | "false" | "None" | "0" | "1" | "Self" | "-1") {
1435            return true;
1436        }
1437    }
1438    // S1 fix: log macros and trivial logging
1439    if trimmed.starts_with("log::") || trimmed.starts_with("tracing::")
1440        || trimmed.starts_with("debug!") || trimmed.starts_with("info!")
1441        || trimmed.starts_with("warn!") || trimmed.starts_with("error!")
1442        || trimmed.starts_with("print!") || trimmed.starts_with("eprint!")
1443        || trimmed.starts_with("println!") || trimmed.starts_with("eprintln!")
1444        || trimmed.starts_with("dbg!") || trimmed.starts_with("todo!")
1445    {
1446        return true;
1447    }
1448    // S1 fix: ignore/discard assignments like `let _ = expr;`
1449    if trimmed.starts_with("let _ =") || trimmed.starts_with("let _mut _ =")
1450        || trimmed.starts_with("let (_, _) =") || trimmed.starts_with("let (_, _, _) =")
1451    {
1452        return true;
1453    }
1454    // S1 fix: option-result idioms (.ok() / .ok_or() patterns)
1455    // Strip trailing inline comment first (// GUARDED: ...)
1456    let code_only = if let Some(comment_start) = trimmed.find("//") {
1457        trimmed[..comment_start].trim()
1458    } else {
1459        trimmed
1460    };
1461    if code_only.ends_with(".ok()?") || code_only.ends_with(".ok();")
1462        || code_only.contains(".expect(\"") || code_only.contains(".unwrap_or(")
1463        || code_only.contains(".unwrap_or_default(") || code_only.contains(".unwrap_or_else(")
1464        || (code_only.starts_with("return ") && code_only.contains(".ok()"))
1465        || (code_only.starts_with("return ") && code_only.contains(".err()"))
1466        || (code_only.starts_with("return ") && code_only.contains(".unwrap()"))
1467    {
1468        return true;
1469    }
1470    if let Some(eq_pos) = trimmed.find('=') {
1471        if eq_pos > 0 && !trimmed[eq_pos+1..].trim().starts_with("\"")
1472            && trimmed.contains(".to_string(")
1473            && !trimmed.contains("unsafe ") && !trimmed.contains("transmute")
1474        {
1475            return true;
1476        }
1477    }
1478    false
1479}
1480
1481/// Extract surprise facts from a symbol's body using skeleton-frequency.
1482///
1483/// A line is "surprising" if its aggressive_skeleton appears < RARE_THRESHOLD
1484/// times across the codebase. We also extract magic numbers, compound
1485/// conditions, and early returns from surprising lines.
1486fn extract_surprise_from_body(
1487    body: &str,
1488    skeleton_freq: &FxHashMap<String, usize>,
1489) -> Vec<String> {
1490    let mut surprises: Vec<String> = Vec::new();
1491    let mut seen: HashSet<String> = HashSet::new();
1492
1493    let lines: Vec<&str> = body.lines().collect();
1494
1495    // === Pass 1: Pattern-based detection ===
1496    // Recognize common "surprising code patterns" and generate labeled descriptions.
1497
1498    // Pattern: `<N>` auto-pass guard at the top of a function
1499    //   e.g., `if text.len() < 50 { return Some((1.0, text.len())); }`
1500    //   → "auto-pass for input below N chars (no gates applied)"
1501    if let Some(m) = regex_capture(r"if\s+\w+\.len\(\)\s*<\s*(\d+)\s*\{", body) {
1502        add_surprise(&mut surprises, &mut seen,
1503            format!("auto-pass for input below {} chars (no gates applied)", m));
1504    }
1505
1506    // Pattern: UUID-like position checks (8, 13, 18, 23)
1507    //   e.g., `let expect_dash = j == 8 || j == 13 || j == 18 || j == 23;`
1508    if body.contains("expect_dash") || body.contains("j == 8") && body.contains("j == 13") && body.contains("j == 23") {
1509        add_surprise(&mut surprises, &mut seen,
1510            "UUID dash positions: 8, 13, 18, 23 (standard 8-4-4-4-12)".to_string());
1511    }
1512
1513    // Pattern: hex-hash length range
1514    //   e.g., `he - i >= 7 && he - i <= 40`
1515    if body.contains("he - i") || body.contains("hex_len") {
1516        if let Some(m) = regex_capture(r">=\s*(\d+)\s*&&\s*\w+\s*-\s*\w+\s*<=\s*(\d+)", body) {
1517            add_surprise(&mut surprises, &mut seen,
1518                format!("hex-hash length range: {} to {}", m.split(',').next().unwrap_or(""), m.split(',').nth(1).unwrap_or("")));
1519        }
1520    }
1521
1522    // Pattern: djb3-33 hash constants
1523    //   e.g., `let mut h: u64 = 5381; for b ... { h = h.wrapping_mul(33) }`
1524    if body.contains("5381") && body.contains("wrapping_mul") && body.contains("33") {
1525        add_surprise(&mut surprises, &mut seen,
1526            "djb3-33 hash: starts at 5381, multiplies by 33 (Bernstein variant)".to_string());
1527    }
1528
1529    // Pattern: byte-DFA processing
1530    //   e.g., iterating `bytes[i]` and `bytes[he]` directly
1531    if body.contains("bytes[") && body.contains("while") && !body.contains("chars()") {
1532        add_surprise(&mut surprises, &mut seen,
1533            "byte-indexed processing (not char-indexed)".to_string());
1534    }
1535
1536    // Pattern: sentinel return value
1537    //   e.g., `if s.is_empty() { return 0; }` or `return String::new()`
1538    if let Some(m) = regex_capture(r"if\s+\w+\.is_empty\(\)\s*\{\s*return\s+([^;]+)", body) {
1539        add_surprise(&mut surprises, &mut seen,
1540            format!("sentinel return: {} for empty input", m));
1541    }
1542
1543// Pattern: import collapse label
1544    //   e.g., `format!("[{} imports]", import_count)`
1545    if body.contains("import") && body.contains("count") && body.contains('[') {
1546        add_surprise(&mut surprises, &mut seen,
1547            "imports collapsed into a single label '[N imports]'".to_string());
1548    }
1549
1550    // Pattern: lazy/deferred flush
1551    //   e.g., only writing to result on the next non-matching line
1552    if body.contains("flush") || (body.contains("if ") && body.contains("emit") && body.contains("reset")) {
1553        add_surprise(&mut surprises, &mut seen,
1554            "lazy flush: label emitted on next non-matching line, not immediately".to_string());
1555    }
1556
1557    // Pattern: priority ordering
1558    //   e.g., Error check before Comment check
1559    if body.contains("Error") && body.contains("Comment") {
1560        let error_pos = body.find("Error").unwrap_or(0);
1561        let comment_pos = body.find("Comment").unwrap_or(0);
1562        if error_pos < comment_pos && error_pos > 0 && comment_pos > 0 {
1563            add_surprise(&mut surprises, &mut seen,
1564                "Error check runs BEFORE Comment check (priority order)".to_string());
1565        } else if comment_pos < error_pos && comment_pos > 0 && error_pos > 0 {
1566            add_surprise(&mut surprises, &mut seen,
1567                "Comment check runs BEFORE Error check (potential bug: errors in comments are missed)".to_string());
1568        }
1569    }
1570
1571    // Pattern: gate combination (AND vs OR)
1572    //   e.g., `if ent < threshold && ratio > max { return None; }`
1573    if regex_capture(r"if\s+\w+\s*[<>]=\s*\w+_threshold\s*[&|][&|]\s*\w+\s*[<>]=\s*\w+", body).is_some() {
1574        if body.contains("&&") {
1575            add_surprise(&mut surprises, &mut seen,
1576                "gates are AND-combined: ALL must fail for None return".to_string());
1577        } else if body.contains("||") {
1578            add_surprise(&mut surprises, &mut seen,
1579                "gates are OR-combined: ANY failing gate returns None".to_string());
1580        }
1581    }
1582
1583    // Pattern: minimum word length for aggressive collapsing
1584    //   e.g., `we > i + 1` (length > 1 keeps single letters verbatim)
1585    if body.contains("aggressive") || body.contains("collapse") {
1586        if let Some(m) = regex_capture(r"\w+\s*>\s*\w+\s*\+\s*(\d+)", body) {
1587            add_surprise(&mut surprises, &mut seen,
1588                format!("minimum word length threshold: {} (below this, words kept verbatim)", m));
1589        }
1590    }
1591
1592    // Pattern: function called on the SKELETON, not the original
1593    //   e.g., `let s = skeleton(text); ... hash(s)`
1594    if let Some(m) = regex_capture(r"let\s+(\w+)\s*=\s*skeleton\(\w+\)", body) {
1595        add_surprise(&mut surprises, &mut seen,
1596            format!("operates on the SKELETON of input, not the original text (via {})", m));
1597    }
1598
1599    // Pattern: regex-based normalization (not byte-DFA)
1600    //   e.g., `PATTERNS.uuid.replace_all(&cleaned, "{uuid}")`
1601    if body.contains("replace_all") && body.contains("&") {
1602        let placeholders: Vec<&str> = body
1603            .split(".replace_all")
1604            .skip(1)
1605            .filter_map(|s| {
1606                if let Some(idx) = s.find('"') {
1607                    let after = &s[idx..];
1608                    if let Some(end) = after[1..].find('"') {
1609                        return Some(&after[1..=end]);
1610                    }
1611                }
1612                None
1613            })
1614            .collect();
1615        if !placeholders.is_empty() {
1616            add_surprise(&mut surprises, &mut seen,
1617                format!("regex-based replacement: {}", placeholders.iter().take(3).cloned().collect::<Vec<_>>().join(", ")));
1618        }
1619    }
1620
1621// Pattern: three-comma version detection
1622    //   e.g., `X.Y.Z` with `num_end` calls
1623    if body.contains("num_end") && body.contains('.') {
1624        add_surprise(&mut surprises, &mut seen,
1625            "version detection: requires exactly three dot-separated numeric components (X.Y.Z)".to_string());
1626    }
1627
1628    // === Pass 2: Line-level signal extraction ===
1629    let mut seen_nums: HashSet<String> = HashSet::new();
1630    let mut seen_conds: HashSet<String> = HashSet::new();
1631
1632    for (line_idx, line) in lines.iter().enumerate() {
1633        let trimmed = line.trim();
1634        if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("#")
1635            || trimmed.starts_with("///") || trimmed.starts_with("/*") || trimmed.starts_with("*") {
1636            continue;
1637        }
1638        let code = trimmed;
1639
1640        // Magic numbers in comparisons
1641        for num in find_magic_numbers(code) {
1642            let key = format!("{}_{}", num, line_idx);
1643            if !seen_nums.contains(&key) {
1644                seen_nums.insert(key);
1645                let labeled = label_number(&num, code);
1646                if !labeled.starts_with("constant: 0") && !labeled.starts_with("constant: 1") {
1647                    add_surprise(&mut surprises, &mut seen, labeled);
1648                }
1649            }
1650        }
1651
1652        // Compound conditions (not already captured by patterns)
1653        if code.contains("if ") && (code.contains("&&") || code.contains("||")) {
1654            if let Some(cond) = extract_condition(code) {
1655                let cond_key: String = cond.chars().take(40).collect();
1656                if cond.len() > 10 && cond.len() < 200 && !seen_conds.contains(&cond_key) {
1657                    seen_conds.insert(cond_key);
1658                    add_surprise(&mut surprises, &mut seen,
1659                        format!("compound condition: {}", cond));
1660                }
1661            }
1662        }
1663
1664        // Early returns
1665        if line_idx < 15 && code.contains("if ") && code.contains("return") {
1666            if let Some(ret) = extract_return_value(code) {
1667                if !ret.is_empty() && ret != "true" && ret != "false" {
1668                    add_surprise(&mut surprises, &mut seen,
1669                        format!("early return → {}", ret));
1670                }
1671            }
1672        }
1673
1674        // Deliberate patterns
1675        if code.contains(".wrapping_") {
1676            let ops: Vec<&str> = code
1677                .split(".wrapping_")
1678                .skip(1)
1679                .filter_map(|s| s.split('(').next())
1680                .collect();
1681            if !ops.is_empty() {
1682                add_surprise(&mut surprises, &mut seen,
1683                    format!("wrapping: {}", ops.join(", ")));
1684            }
1685        }
1686        if code.contains("unwrap_or(") {
1687            if let Some(def) = extract_unwrap_default(code) {
1688                if !def.is_empty() {
1689                    add_surprise(&mut surprises, &mut seen,
1690                        format!("fallback: unwrap_or({})", def));
1691                }
1692            }
1693        }
1694
1695        // Capacity hints
1696        if code.contains("with_capacity(") {
1697            if let Some(n) = extract_capacity(code) {
1698                add_surprise(&mut surprises, &mut seen,
1699                    format!("capacity: {}", n));
1700            }
1701        }
1702
1703        // Comment signals
1704        if let Some(comment_start) = code.find("//") {
1705            let comment = code[comment_start + 2..].trim().to_ascii_lowercase();
1706            let comment_text = code[comment_start + 2..].trim();
1707            for sig in ["key", "difference", "note", "important", "surprise",
1708                        "critical", "deliberate", "only fires", "not regex", "must"]
1709            {
1710                if comment.contains(sig) {
1711                    add_surprise(&mut surprises, &mut seen,
1712                        format!("source note: {}", comment_text));
1713                    break;
1714                }
1715            }
1716        }
1717    }
1718
1719    // === Pass 3: Skeleton-frequency rare patterns (tertiary) ===
1720    for (line_idx, line) in lines.iter().enumerate() {
1721        if line_idx == 0 { continue; } // skip signature line
1722        let trimmed = line.trim();
1723        if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("#") {
1724            continue;
1725        }
1726        if is_noise_line(trimmed) {
1727            continue;
1728        }
1729        let skel = aggressive_skeleton(trimmed);
1730        if skel.is_empty() || skel == "{w}" || skel == "}" || skel.len() < 10 {
1731            continue;
1732        }
1733        let freq = skeleton_freq.get(&skel).copied().unwrap_or(0);
1734        if freq == 1 {
1735            // S1 fix: only emit unique-line for lines with structural signal.
1736            // A truly unique line with no parens / braces / comparisons /
1737            // assignments is just noise (any random `let x = other_line;`
1738            // can be unique because of variable name).
1739            let has_signal = trimmed.contains('(')
1740                || trimmed.contains("==") || trimmed.contains("!=")
1741                || trimmed.contains(" < ") || trimmed.contains(" > ")
1742                || trimmed.contains(">=") || trimmed.contains("<=")
1743                || trimmed.contains("&&") || trimmed.contains("||")
1744                || trimmed.contains("wrapping_") || trimmed.contains("unwrap_or")
1745                || trimmed.contains("match ") || trimmed.contains("=>");
1746            if has_signal {
1747                let snippet: String = trimmed.chars().take(80).collect();
1748                add_surprise(&mut surprises, &mut seen,
1749                    format!("unique line: {}", snippet));
1750            }
1751        }
1752    }
1753
1754    // S1 fix: dedupe across passes (early return + unique line often duplicate)
1755    // After both passes are done, dedupe entries that share key information.
1756    let pre_dedup_len = surprises.len();
1757    let mut final_surprises: Vec<String> = Vec::new();
1758    let mut seen_fragments: HashSet<String> = HashSet::new();
1759    for s in surprises {
1760        // Extract a canonical "fragment" — same early return + same unique line → same key
1761        let fragment: String = s
1762            .chars()
1763            .filter(|c| !c.is_whitespace())
1764            .take(40)
1765            .collect();
1766        if seen_fragments.contains(&fragment) {
1767            continue;
1768        }
1769        seen_fragments.insert(fragment);
1770        final_surprises.push(s);
1771    }
1772    let surprises = final_surprises;
1773    let post_dedup_len = surprises.len();
1774    if pre_dedup_len > post_dedup_len + 1 {
1775        // dedupe was meaningful; that's good
1776    }
1777    // Count pattern-based (non "rare pattern" / "unique line") vs. frequency-based
1778    let pattern_count = surprises.iter()
1779        .filter(|s| !s.starts_with("rare pattern") && !s.starts_with("unique line"))
1780        .count();
1781    let freq_count = surprises.len() - pattern_count;
1782
1783    // S1 fix: only include freq-based if we have 3+ pattern matches.
1784    // Without this guard, functions with zero patterns (just let-binding lines)
1785    // get 5 useless "unique line:" entries.
1786    let limit = if pattern_count >= 3 {
1787        pattern_count.min(8) // only pattern-based
1788    } else if pattern_count >= 1 {
1789        (pattern_count + freq_count.min(2)).min(8)
1790    } else {
1791        // No patterns matched — skip freq-based entirely (would be all noise)
1792        0
1793    };
1794
1795    surprises.into_iter().take(limit).collect()
1796}
1797
1798fn add_surprise(surprises: &mut Vec<String>, seen: &mut HashSet<String>, s: String) {
1799    let key: String = s.chars().take(60).collect();
1800    if !seen.contains(&key) {
1801        seen.insert(key);
1802        surprises.push(s);
1803    }
1804}
1805
1806/// Minimal regex capture helper — finds the first match of `pattern` in `text`
1807/// and returns the first capture group as a String.
1808fn regex_capture(pattern: &str, text: &str) -> Option<String> {
1809    // Avoid adding a regex dependency — simple manual patterns
1810    if pattern == r"if\s+\w+\.len\(\)\s*<\s*(\d+)\s*\{" {
1811        return manual_capture_len_guard(text);
1812    }
1813    if pattern == r">=\s*(\d+)\s*&&\s*\w+\s*-\s*\w+\s*<=\s*(\d+)" {
1814        return manual_capture_range(text);
1815    }
1816    if pattern == r"if\s+\w+\.is_empty\(\)\s*\{\s*return\s+([^;]+)" {
1817        return manual_capture_sentinel(text);
1818    }
1819    if pattern == r"\w+\s*>\s*\w+\s*\+\s*(\d+)" {
1820        return manual_capture_gt_plus(text);
1821    }
1822    if pattern == r"if\s+\w+\s*[<>]=\s*\w+_threshold\s*[&|][&|]\s*\w+\s*[<>]=\s*\w+" {
1823        return if text.contains("_threshold") { Some("found".to_string()) } else { None };
1824    }
1825    if pattern == r"let\s+(\w+)\s*=\s*skeleton\(\w+\)" {
1826        return manual_capture_skeleton_let(text);
1827    }
1828    None
1829}
1830
1831fn manual_capture_len_guard(text: &str) -> Option<String> {
1832    let bytes = text.as_bytes();
1833    let mut i = 0;
1834    while i < bytes.len() {
1835        if i + 2 < bytes.len() && bytes[i] == b'i' && bytes[i+1] == b'f' {
1836            // skip "if"
1837            i += 2;
1838            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; }
1839            // skip identifier
1840            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; }
1841            // skip .len()
1842            while i + 4 < bytes.len() && &bytes[i..i+5] == b".len(" { i += 5; while i < bytes.len() && bytes[i] != b')' { i += 1; } if i < bytes.len() { i += 1; } }
1843            // skip spaces and <
1844            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; }
1845            if i < bytes.len() && bytes[i] == b'<' { i += 1; }
1846            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; }
1847            // read number
1848            let start = i;
1849            while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
1850            if i > start {
1851                return Some(String::from_utf8_lossy(&bytes[start..i]).to_string());
1852            }
1853        }
1854        i += 1;
1855    }
1856    None
1857}
1858
1859fn manual_capture_range(text: &str) -> Option<String> {
1860    let bytes = text.as_bytes();
1861    let mut i = 0;
1862    while i < bytes.len() {
1863        if i + 1 < bytes.len() && bytes[i] == b'>' && bytes[i+1] == b'=' {
1864            i += 2;
1865            while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; }
1866            let start = i;
1867            while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
1868            if i > start {
1869                let first = String::from_utf8_lossy(&bytes[start..i]).to_string();
1870                // find the second number after <=
1871                while i < bytes.len() && bytes[i] != b'<' { i += 1; }
1872                if i + 1 < bytes.len() && bytes[i] == b'<' && bytes[i+1] == b'=' { i += 2; }
1873                while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { i += 1; }
1874                let start2 = i;
1875                while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; }
1876                if i > start2 {
1877                    let second = String::from_utf8_lossy(&bytes[start2..i]).to_string();
1878                    return Some(format!("{},{}", first, second));
1879                }
1880            }
1881        }
1882        i += 1;
1883    }
1884    None
1885}
1886
1887fn manual_capture_sentinel(text: &str) -> Option<String> {
1888    if let Some(idx) = text.find("is_empty") {
1889        let after = &text[idx + 9..];
1890        if let Some(ret_idx) = after.find("return") {
1891            let after_ret = &after[ret_idx + 6..];
1892            let end = after_ret
1893                .find([';', '}', '\n'])
1894                .unwrap_or(after_ret.len());
1895            return Some(after_ret[..end].trim().to_string());
1896        }
1897    }
1898    None
1899}
1900
1901fn manual_capture_gt_plus(text: &str) -> Option<String> {
1902    if let Some(idx) = text.find('>') {
1903        let after = &text[idx + 1..];
1904        // skip spaces
1905        let trimmed = after.trim_start();
1906        // skip identifier
1907        let after_ident = trimmed
1908            .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_');
1909        let trimmed2 = after_ident.trim_start();
1910        if let Some(rest) = trimmed2.strip_prefix('+') {
1911            let trimmed3 = rest.trim_start();
1912            let end = trimmed3
1913                .find(|c: char| !c.is_ascii_digit())
1914                .unwrap_or(trimmed3.len());
1915            if end > 0 {
1916                return Some(trimmed3[..end].to_string());
1917            }
1918        }
1919    }
1920    None
1921}
1922
1923fn manual_capture_skeleton_let(text: &str) -> Option<String> {
1924    if let Some(idx) = text.find("let ") {
1925        let after = &text[idx + 4..];
1926        let after_ident = after
1927            .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_');
1928        let trimmed = after_ident.trim_start();
1929        if let Some(after_eq) = trimmed.strip_prefix('=') {
1930            let after_eq = after_eq.trim_start();
1931            if let Some(sk_idx) = after_eq.find("skeleton(") {
1932                let before = &after_eq[..sk_idx].trim();
1933                if !before.is_empty() {
1934                    return Some(before.to_string());
1935                }
1936            }
1937        }
1938    }
1939    None
1940}
1941
1942fn find_magic_numbers(code: &str) -> Vec<String> {
1943    let mut nums = Vec::new();
1944    // Simple regex-free approach: find numbers after comparison operators
1945    let bytes = code.as_bytes();
1946    let mut i = 0;
1947    while i < bytes.len() {
1948        if (bytes[i] == b'>' || bytes[i] == b'<' || bytes[i] == b'=' || bytes[i] == b'!')
1949            && i + 1 < bytes.len()
1950        {
1951            // Skip the operator
1952            i += 1;
1953            if i < bytes.len() && (bytes[i] == b'=' || bytes[i] == b' ') {
1954                i += 1;
1955            }
1956            // Skip spaces
1957            while i < bytes.len() && bytes[i] == b' ' {
1958                i += 1;
1959            }
1960            // Read number
1961            let start = i;
1962            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
1963                i += 1;
1964            }
1965            if i > start {
1966                let num = String::from_utf8_lossy(&bytes[start..i]).to_string();
1967                if num != "0" && num != "1" {
1968                    nums.push(num);
1969                }
1970            }
1971        } else {
1972            i += 1;
1973        }
1974    }
1975    nums
1976}
1977
1978fn label_number(num: &str, code: &str) -> String {
1979    let lower = code.to_ascii_lowercase();
1980    if lower.contains("uuid") || (num == "8" || num == "13" || num == "18" || num == "23") {
1981        format!("UUID position: {}", num)
1982    } else if lower.contains("hash") || lower.contains("hex") {
1983        format!("hex-hash bound: {}", num)
1984    } else if lower.contains("entropy") || lower.contains("threshold") {
1985        format!("threshold: {}", num)
1986    } else if lower.contains("len") || lower.contains("length") || lower.contains("count") {
1987        format!("length: {}", num)
1988    } else if lower.contains("capacity") {
1989        format!("capacity: {}", num)
1990    } else {
1991        format!("constant: {}", num)
1992    }
1993}
1994
1995fn extract_condition(code: &str) -> Option<String> {
1996    let if_pos = code.find("if ")?;
1997    let after = &code[if_pos + 3..];
1998    // Find the end of condition (opening brace or end of line)
1999    let end = after
2000        .find(['{', '\n'])
2001        .unwrap_or(after.len());
2002    Some(after[..end].trim().to_string())
2003}
2004
2005fn extract_return_value(code: &str) -> Option<String> {
2006    let ret_pos = code.find("return")?;
2007    let after = &code[ret_pos + 6..];
2008    let end = after.find([';', '}']).unwrap_or(after.len());
2009    Some(after[..end].trim().to_string())
2010}
2011
2012fn extract_unwrap_default(code: &str) -> Option<String> {
2013    let pos = code.find("unwrap_or(")?;
2014    let after = &code[pos + 10..];
2015    let end = after.find(')')?;
2016    Some(after[..end].trim().to_string())
2017}
2018
2019fn extract_capacity(code: &str) -> Option<String> {
2020    let pos = code.find("with_capacity(")?;
2021    let after = &code[pos + 14..];
2022    let end = after.find(')')?;
2023    Some(after[..end].trim().to_string())
2024}
2025
2026// ---------------------------------------------------------------------------
2027// Cross-references from the SQLite index
2028// ---------------------------------------------------------------------------
2029
2030/// Build cross-reference map: symbol_name → list of symbols that reference it.
2031///
2032/// Uses file-level co-occurrence: find files where the symbol appears as a
2033/// non-def occurrence, then list other symbols defined in those files.
2034/// This is broader than block-matching and catches most real references.
2035fn build_cross_refs_from_index(
2036    db: &Connection,
2037    symbols: &[Symbol],
2038) -> FxHashMap<String, Vec<String>> {
2039    // C3: Return empty map on error instead of panicking.
2040    build_cross_refs_inner(db, symbols).unwrap_or_default()
2041}
2042
2043fn build_cross_refs_inner(
2044    db: &Connection,
2045    symbols: &[Symbol],
2046) -> Result<FxHashMap<String, Vec<String>>, rusqlite::Error> {
2047    let def_names: HashSet<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
2048    let mut cross_refs: FxHashMap<String, Vec<String>> = FxHashMap::default();
2049    let mut ref_set: FxHashMap<String, HashSet<String>> = FxHashMap::default();
2050
2051    // === Phase 1: Batch-load ALL (file_id, phrase) pairs in one query ===
2052    // This avoids the N+1 query pattern that made the previous version O(N²).
2053    // V54: cap rows to bound memory on large repos (full occurrence table
2054    // can be 500K+ rows; symbol-relevant rows are a small subset).
2055    let mut all_pairs_stmt = db.prepare(
2056        "SELECT o.file_id, p.phrase, o.is_def
2057         FROM occurrence o
2058         JOIN phrases p ON o.phrase_id = p.id
2059         WHERE length(p.phrase) BETWEEN 3 AND 30
2060         ORDER BY o.file_id
2061         LIMIT 200000",
2062    )?;
2063
2064    // For each file_id, the set of (phrase, is_def) pairs that appear in it
2065    let mut file_to_phrases: FxHashMap<i64, Vec<(String, bool)>> = FxHashMap::default();
2066    for row in all_pairs_stmt
2067        .query_map([], |row| {
2068            Ok((
2069                row.get::<_, i64>(0)?,
2070                row.get::<_, String>(1)?,
2071                row.get::<_, i64>(2)? != 0,
2072            ))
2073        })?
2074        .flatten()
2075    {
2076        let (file_id, phrase, is_def) = row;
2077        file_to_phrases
2078            .entry(file_id)
2079            .or_default()
2080            .push((phrase, is_def));
2081    }
2082
2083    // === Phase 2: For each symbol, find files where it's used (non-def) ===
2084    let mut phrase_to_non_def_files: FxHashMap<String, Vec<i64>> = FxHashMap::default();
2085    for (file_id, pairs) in &file_to_phrases {
2086        for (phrase, is_def) in pairs {
2087            if !*is_def {
2088                phrase_to_non_def_files
2089                    .entry(phrase.clone())
2090                    .or_default()
2091                    .push(*file_id);
2092            }
2093        }
2094    }
2095
2096    // Now for each symbol: look up files directly via inverted index
2097    for sym in symbols {
2098        let mut refs: HashSet<String> = HashSet::new();
2099
2100        let files_using_sym = match phrase_to_non_def_files.get(&sym.name) {
2101            Some(f) => f,
2102            None => continue,
2103        };
2104
2105        for &file_id in files_using_sym {
2106            if let Some(pairs) = file_to_phrases.get(&file_id) {
2107                for (phrase, is_def) in pairs {
2108                    if *is_def
2109                        && phrase != &sym.name
2110                        && def_names.contains(phrase.as_str())
2111                        && !is_common_word(phrase)
2112                        && phrase.len() >= 3
2113                        && phrase.len() < 30
2114                        && !phrase.starts_with('_')
2115                        && !phrase.ends_with("Error")
2116                        && !phrase.ends_with("Result")
2117                        && !phrase.ends_with("Option")
2118                        && phrase != "Self"
2119                    {
2120                        refs.insert(phrase.clone());
2121                    }
2122                }
2123            }
2124
2125            // Don't bother scanning more files once we have enough refs
2126            if refs.len() >= 5 {
2127                break;
2128            }
2129        }
2130
2131        // Take top 3 refs, sorted
2132        if !refs.is_empty() {
2133            let mut sorted: Vec<String> = refs.into_iter().collect();
2134            sorted.sort();
2135            sorted.truncate(3);
2136            ref_set.insert(sym.name.clone(), sorted.into_iter().collect());
2137        }
2138    }
2139
2140    // Convert sets to sorted vectors, capped at 3 to keep pack compact
2141    for (name, refs) in ref_set {
2142        let mut sorted: Vec<String> = refs.into_iter().collect();
2143        sorted.sort();
2144        sorted.truncate(3);
2145        cross_refs.insert(name, sorted);
2146    }
2147
2148    Ok(cross_refs)
2149}
2150
2151// ---------------------------------------------------------------------------
2152// Source reading
2153// ---------------------------------------------------------------------------
2154
2155/// Read sources for a SUBSET of symbols (the selected hotspots) + build
2156/// skeleton frequency from just those symbols' bodies. This is much faster
2157/// than reading all files — only ~50 file reads instead of ~700.
2158fn read_symbols_for_subset(
2159    selected: &[Symbol],
2160    _all_symbols: &[Symbol],
2161) -> (FxHashMap<String, String>, FxHashMap<String, usize>) {
2162    let mut sources: FxHashMap<String, String> = FxHashMap::default();
2163    let mut freq: FxHashMap<String, usize> = FxHashMap::default();
2164
2165    // S2: use the shared file_meta cache instead of a per-call file_cache.
2166    // file_meta::get returns Arc<FileMeta> with lines pre-split and cached
2167    // across the MCP lifetime. Eliminates redundant disk reads.
2168    for sym in selected {
2169        let lines: Vec<String> = if let Some(meta) = reliary_search::file_meta::get(&sym.file) {
2170            meta.lines.to_vec()
2171        } else {
2172            // Fallback: direct disk read for files not yet cached.
2173            std::fs::read_to_string(&sym.file)
2174                .map(|c| c.lines().map(String::from).collect())
2175                .unwrap_or_default()
2176        };
2177
2178        let start = (sym.line as usize).min(lines.len().saturating_sub(1));
2179        let end = find_body_end(lines.as_slice(), start, sym.indent).min(lines.len());
2180        if start < end {
2181            // Iterate the slice directly — no join→re-split
2182            for line in &lines[start..end] {
2183                let trimmed = line.trim();
2184                if trimmed.is_empty() {
2185                    continue;
2186                }
2187                let skel = aggressive_skeleton(trimmed);
2188                if !skel.is_empty() {
2189                    *freq.entry(skel).or_insert(0) += 1;
2190                }
2191            }
2192            sources.insert(sym.name.clone(), lines[start..end].join("\n"));
2193        }
2194    }
2195
2196    (sources, freq)
2197}
2198
2199/// Read all symbol sources and build skeleton frequency in a single pass.
2200/// Avoids re-reading the same file multiple times across symbols.
2201fn read_symbols_and_frequency(
2202    symbols: &[Symbol],
2203) -> (FxHashMap<String, String>, FxHashMap<String, usize>) {
2204    let mut sources: FxHashMap<String, String> = FxHashMap::default();
2205    let mut freq: FxHashMap<String, usize> = FxHashMap::default();
2206
2207    // S2: shared file_meta cache replaces per-call file_cache.
2208
2209    for sym in symbols {
2210        let lines: Vec<String> = if let Some(meta) = reliary_search::file_meta::get(&sym.file) {
2211            meta.lines.to_vec()
2212        } else {
2213            std::fs::read_to_string(&sym.file)
2214                .map(|c| c.lines().map(String::from).collect())
2215                .unwrap_or_default()
2216        };
2217
2218        // Find the actual body extent. The block table's end_line is often wrong
2219        // (it undercounts for large functions), so we use brace-matching to find
2220        // the true end. We take the max of brace-matched end and end_line so
2221        // we never lose body content when the index is wrong.
2222        let start = (sym.line.max(0) as usize).min(lines.len().saturating_sub(1));
2223        let brace_end = find_body_end(lines.as_slice(), start, sym.indent);
2224        let index_end = (sym.end_line as usize).max(start + 1).min(lines.len());
2225        let end = brace_end.max(index_end).min(lines.len());
2226
2227        if start < end {
2228            // Build skeleton frequency from body lines — iterate slice directly, no join→re-split
2229            for line in &lines[start..end] {
2230                let trimmed = line.trim();
2231                if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("#") {
2232                    continue;
2233                }
2234                let skel = aggressive_skeleton(trimmed);
2235                if skel.is_empty() || skel == "{w}" || skel == "}" || skel.len() < 5 {
2236                    continue;
2237                }
2238                *freq.entry(skel).or_insert(0) += 1;
2239            }
2240
2241            sources.insert(sym.name.clone(), lines[start..end].join("\n"));
2242        }
2243    }
2244
2245    (sources, freq)
2246}
2247
2248/// Find the end of a function body by brace-matching from the start line.
2249/// Returns the line index AFTER the closing brace.
2250/// If brace-matching fails (e.g., Python uses indentation), falls back to
2251/// looking for the next def/class line at the same indent.
2252fn find_body_end(lines: &[String], start: usize, _base_indent: i32) -> usize {
2253    if start >= lines.len() {
2254        return start;
2255    }
2256    let first_line = &lines[start];
2257
2258    // Python-style: find the next def/class at the same or lower indent
2259    let stripped = first_line.trim();
2260    if stripped.starts_with("def ")
2261        || stripped.starts_with("class ")
2262        || stripped.starts_with("async def ")
2263    {
2264        let first_indent = first_line.len() - first_line.trim_start().len();
2265        for (i, line) in lines.iter().enumerate().skip(start + 1) {
2266            let trimmed = line.trim();
2267            if trimmed.is_empty() || trimmed.starts_with("#") {
2268                continue;
2269            }
2270            let indent = line.len() - line.trim_start().len();
2271            // A new top-level def/class at the same indent marks the end
2272            if indent <= first_indent
2273                && (trimmed.starts_with("def ")
2274                    || trimmed.starts_with("class ")
2275                    || trimmed.starts_with("async def "))
2276            {
2277                return i;
2278            }
2279        }
2280        return lines.len();
2281    }
2282
2283    // C-style: brace-matching
2284    let mut depth: i32 = 0;
2285    let mut seen_open = false;
2286    for (i, line) in lines.iter().enumerate().skip(start) {
2287        let trimmed = line.trim();
2288
2289        // Skip comments and strings (crude — doesn't handle multi-line strings)
2290        if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with("*") {
2291            continue;
2292        }
2293
2294        for ch in line.chars() {
2295            match ch {
2296                '{' => { depth += 1; seen_open = true; }
2297                '}' => { depth -= 1; }
2298                _ => {}
2299            }
2300        }
2301
2302        if seen_open && depth == 0 {
2303            return i + 1;
2304        }
2305    }
2306    lines.len()
2307}
2308
2309// ---------------------------------------------------------------------------
2310// Module detection
2311// ---------------------------------------------------------------------------
2312
2313/// Extract the crate name from a file path.
2314/// Looks for `crates/<name>/` or `packages/<name>/` patterns.
2315fn derive_crate_name(file_path: &str) -> String {
2316    let parts: Vec<&str> = file_path.split('/').collect();
2317    for (i, part) in parts.iter().enumerate() {
2318        if (*part == "crates" || *part == "packages") && i + 1 < parts.len() {
2319            return parts[i + 1].to_string();
2320        }
2321    }
2322    // Fallback: parent directory name
2323    if parts.len() >= 2 {
2324        return parts[parts.len() - 2].to_string();
2325    }
2326    String::new()
2327}
2328
2329fn detect_modules(symbols: &[Symbol]) -> Vec<(String, Vec<Symbol>)> {
2330    let mut module_map: FxHashMap<String, Vec<Symbol>> = FxHashMap::default();
2331
2332    for sym in symbols {
2333        let module = derive_module_name(&sym.file);
2334        module_map.entry(module).or_default().push(sym.clone());
2335    }
2336
2337    let mut modules: Vec<(String, Vec<Symbol>)> = module_map.into_iter().collect();
2338    modules.sort_by(|a, b| a.0.cmp(&b.0));
2339    modules
2340}
2341
2342fn derive_module_name(file_path: &str) -> String {
2343    let path = Path::new(file_path);
2344    let components: Vec<_> = path.components().collect();
2345
2346    // packages/<name>/... → packages/<name>
2347    for (i, comp) in components.iter().enumerate() {
2348        let part = comp.as_os_str().to_string_lossy().to_string();
2349        if part == "packages" && i + 1 < components.len() {
2350            return format!("packages/{}", components[i + 1].as_os_str().to_string_lossy());
2351        }
2352        if part == "src" && i + 1 < components.len() {
2353            return format!("src/{}", components[i + 1].as_os_str().to_string_lossy());
2354        }
2355        if part == "crates" && i + 1 < components.len() {
2356            return format!("crates/{}", components[i + 1].as_os_str().to_string_lossy());
2357        }
2358    }
2359
2360    // Fallback: first component
2361    components
2362        .first()
2363        .map(|c| c.as_os_str().to_string_lossy().to_string())
2364        .unwrap_or_else(|| "root".to_string())
2365}
2366
2367// ---------------------------------------------------------------------------
2368// Entry rendering
2369// ---------------------------------------------------------------------------
2370
2371/// Build an L4 co-change line for editing guidance.
2372fn build_cochange_line(_name: &str, signature: &str, _body: &str, cross_refs: &[String]) -> String {
2373    let lower = signature.to_ascii_lowercase();
2374    let mut parts: Vec<String> = Vec::new();
2375    let is_enum = lower.contains(" enum ") || lower.starts_with("enum ") || lower.starts_with("pub enum ");
2376    let is_struct = lower.contains(" struct ") || lower.starts_with("struct ") || lower.starts_with("pub struct ");
2377
2378    if is_enum {
2379        if !cross_refs.is_empty() {
2380            let sites: Vec<&str> = cross_refs.iter().take(6).map(|s| s.as_str()).collect();
2381            parts.push(format!("Add variant → update match sites: {}", sites.join(", ")));
2382        }
2383    } else if is_struct {
2384        if !cross_refs.is_empty() {
2385            let sites: Vec<&str> = cross_refs.iter().take(6).map(|s| s.as_str()).collect();
2386            parts.push(format!("Add field → update constructors: {}", sites.join(", ")));
2387        }
2388    } else {
2389        if !cross_refs.is_empty() {
2390            let sites: Vec<&str> = cross_refs.iter().take(6).map(|s| s.as_str()).collect();
2391            parts.push(format!("Rename → update {} caller(s): {}", cross_refs.len(), sites.join(", ")));
2392        }
2393    }
2394    parts.join("; ")
2395}
2396
2397fn expand_type_signature(signature: &str, body: &str) -> String {
2398    let sig_trimmed = signature.trim();
2399    let lower = sig_trimmed.to_ascii_lowercase();
2400    let is_enum = lower.contains(" enum ") || lower.starts_with("enum ") || lower.starts_with("pub enum ");
2401    let is_struct = lower.contains(" struct ") || lower.starts_with("struct ") || lower.starts_with("pub struct ");
2402    if !is_enum && !is_struct {
2403        return signature.to_string();
2404    }
2405    let mut names: Vec<String> = Vec::new();
2406    for line in body.lines().skip(1) {
2407        let trimmed = line.trim();
2408        if trimmed == "}" || trimmed.is_empty() { continue; }
2409        if is_enum {
2410            let name = trimmed.split([',', '{', '(', ':']).next().unwrap_or("").trim().trim_end_matches(',');
2411            if !name.is_empty() && name.len() > 1 && name.chars().next().is_some_and(|c| c.is_uppercase() || c == '_') {
2412                names.push(name.to_string());
2413            }
2414        } else if is_struct {
2415            if let Some(colon_pos) = trimmed.find(':') {
2416                let name = trimmed[..colon_pos].trim().trim_start_matches("pub ");
2417                if !name.is_empty() && name.len() > 1 { names.push(name.to_string()); }
2418            }
2419        }
2420    }
2421    if names.is_empty() { return signature.to_string(); }
2422    let kind = if is_enum { "variants" } else { "fields" };
2423    format!("{} // {}({})", sig_trimmed, kind, names.join(", "))
2424}
2425
2426fn render_entry(
2427    sym: &Symbol,
2428    body: &str,
2429    cross_refs: &[String],
2430    skeleton_freq: &FxHashMap<String, usize>,
2431    format: PackFormat,
2432) -> String {
2433    // Include crate/module name to disambiguate same-name symbols across crates
2434    let crate_name = derive_crate_name(&sym.file);
2435    let header = if crate_name.is_empty() {
2436        format!("## {}", sym.name)
2437    } else {
2438        format!("## {}/{}", sym.name, crate_name)
2439    };
2440    let mut parts = vec![header];
2441
2442    // L0: one-line purpose (only in Full format, if doc comment exists)
2443    if matches!(format, PackFormat::Full) && !sym.doc_comment.is_empty() {
2444        let l0 = sym
2445            .doc_comment
2446            .split('.')
2447            .next()
2448            .unwrap_or(&sym.doc_comment)
2449            .trim();
2450        let l0 = if l0.chars().count() > 120 {
2451            // Truncate at char boundary, not byte boundary
2452            l0.chars().take(120).collect::<String>()
2453        } else {
2454            l0.to_string()
2455        };
2456        parts.push(format!("L0: {}", l0));
2457    }
2458
2459    // L2: always present — expand enum/struct signatures to include variants/fields
2460    let l2_line = expand_type_signature(&sym.signature, body);
2461    parts.push(format!("L2: {}  [{}:{}]", l2_line, sym.file, sym.line));
2462
2463    // L3: surprise
2464    if !body.is_empty() {
2465        let surprises = extract_surprise_from_body(body, skeleton_freq);
2466        if !surprises.is_empty() {
2467            parts.push(format!("L3: {}", surprises.join("; ")));
2468        }
2469    }
2470
2471    // Cross-refs (only in Full format, with ≥3 refs)
2472    if matches!(format, PackFormat::Full) && cross_refs.len() >= 2 {
2473        parts.push(format!("Cross-refs: {}", cross_refs.iter().take(5).cloned().collect::<Vec<_>>().join(", ")));
2474    }
2475
2476    // L4: co-change (editing guidance) — always present when we have cross-refs or type info
2477    let l4 = build_cochange_line(&sym.name, &sym.signature, body, cross_refs);
2478    if !l4.is_empty() {
2479        parts.push(format!("L4: {}", l4));
2480    }
2481
2482    parts.push(String::new());
2483    parts.join("\n")
2484}