|
| 1 | +// Copyright (c) 2026 Ernest Hamblen <rioplay@rioplay.dev> |
| 2 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 3 | + |
| 4 | +// Budget-sweep eval (measurement harness, #[ignore]d, real repos). |
| 5 | +// |
| 6 | +// Question: what token budget does structural assembly (`asm`/`ask`) actually |
| 7 | +// need to capture a symbol's full connected neighborhood — and how does that |
| 8 | +// "ideal budget" vary across corpora of different size and richness? |
| 9 | +// |
| 10 | +// Method: for each corpus, pick the richest hub seeds (largest neighborhoods). |
| 11 | +// The set of anchors the assembler includes at a very large "reach" budget is |
| 12 | +// the gold (complete) neighborhood. Recall at each smaller budget then traces |
| 13 | +// the accuracy-per-token curve; the knee — the smallest budget reaching |
| 14 | +// KNEE_PCT% of the gold — is the practical ideal budget for that corpus. |
| 15 | +// |
| 16 | +// Gold is STRUCTURAL (what assembly includes given an unlimited budget), not |
| 17 | +// answer-correctness: a scalable proxy that needs no hand labels, so it runs on |
| 18 | +// every gen'd corpus. Pair with assembly_ab (query-focused, hand-authored gold) |
| 19 | +// for the correctness axis. |
| 20 | +// |
| 21 | +// Corpora: every gen'd repo under ~/Projects/eval-repos (override the root with |
| 22 | +// ADEN_EVAL_REPOS). Each must have had `aden gen <repo>` run. |
| 23 | +// |
| 24 | +// Run: cargo test -p aden-cli --test budget_sweep -- --include-ignored --nocapture |
| 25 | + |
| 26 | +use aden_asm::traverse::{AssemblyOptions, assemble_with_anchors}; |
| 27 | +use std::collections::HashSet; |
| 28 | +use std::path::PathBuf; |
| 29 | + |
| 30 | +const DEPTH: usize = 2; |
| 31 | +const REACH_BUDGET: usize = 16_384; // "unlimited" — defines the gold neighborhood |
| 32 | +const BUDGETS: &[usize] = &[64, 128, 256, 512, 1024, 2048, 4096, 8192]; |
| 33 | +const CANDIDATE_CAP: usize = 80; // nodes sampled per corpus when picking hubs |
| 34 | +const SEEDS: usize = 10; // richest hubs benchmarked per corpus |
| 35 | +const MIN_GOLD: usize = 3; // a hub needs a non-trivial neighborhood to be informative |
| 36 | +const KNEE_PCT: usize = 90; // knee = smallest budget reaching this % of the gold |
| 37 | +const DEFAULT_BUDGET: usize = 4096; // AssemblyOptions default, for the readout |
| 38 | +const MAX_CORPUS_NODES: usize = 50_000; // above this, CANDIDATE_CAP probes can't sample hubs representatively |
| 39 | + |
| 40 | +fn eval_root() -> PathBuf { |
| 41 | + std::env::var("ADEN_EVAL_REPOS") |
| 42 | + .map(PathBuf::from) |
| 43 | + .unwrap_or_else(|_| { |
| 44 | + PathBuf::from(std::env::var("HOME").unwrap_or_default()).join("Projects/eval-repos") |
| 45 | + }) |
| 46 | +} |
| 47 | + |
| 48 | +fn corpora() -> Vec<PathBuf> { |
| 49 | + let Ok(rd) = std::fs::read_dir(eval_root()) else { |
| 50 | + return Vec::new(); |
| 51 | + }; |
| 52 | + // Every subdirectory is a candidate; whether it was actually `aden gen`'d is |
| 53 | + // gated below by the loaded graph. `gen` is store-first — it drops no marker |
| 54 | + // in the repo, so a path check would miss store-only corpora. |
| 55 | + let mut out: Vec<PathBuf> = rd |
| 56 | + .filter_map(|e| e.ok().map(|e| e.path())) |
| 57 | + .filter(|p| p.is_dir()) |
| 58 | + .collect(); |
| 59 | + out.sort(); |
| 60 | + out |
| 61 | +} |
| 62 | + |
| 63 | +fn opts(seed: &str, budget: usize) -> AssemblyOptions { |
| 64 | + AssemblyOptions { |
| 65 | + start_anchor: seed.to_string(), |
| 66 | + max_depth: DEPTH, |
| 67 | + token_budget: budget, |
| 68 | + ..Default::default() |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +#[test] |
| 73 | +#[ignore = "measurement harness, not a CI gate; reads external repos"] |
| 74 | +fn budget_sweep_report() { |
| 75 | + let corpora = corpora(); |
| 76 | + if corpora.is_empty() { |
| 77 | + eprintln!( |
| 78 | + "SKIP: no gen'd eval repos under {} (set ADEN_EVAL_REPOS)", |
| 79 | + eval_root().display() |
| 80 | + ); |
| 81 | + return; |
| 82 | + } |
| 83 | + |
| 84 | + println!("\n=== Budget sweep: ideal token budget by corpus ==="); |
| 85 | + println!( |
| 86 | + "depth={DEPTH} reach={REACH_BUDGET} seeds/corpus={SEEDS} knee={KNEE_PCT}% of gold\nbudgets: {BUDGETS:?}\n" |
| 87 | + ); |
| 88 | + println!( |
| 89 | + "{:<14} {:>6} {:>7} {:>6} recall@budget", |
| 90 | + "corpus", "nodes", "|gold|", "knee" |
| 91 | + ); |
| 92 | + |
| 93 | + for repo in corpora { |
| 94 | + let name = repo |
| 95 | + .file_name() |
| 96 | + .and_then(|s| s.to_str()) |
| 97 | + .unwrap_or("?") |
| 98 | + .to_string(); |
| 99 | + let graph = match aden_graph::cache::build_from_directory_cached(&repo) { |
| 100 | + Ok(g) => g, |
| 101 | + Err(e) => { |
| 102 | + println!("{name:<14} SKIP (load: {e})"); |
| 103 | + continue; |
| 104 | + } |
| 105 | + }; |
| 106 | + |
| 107 | + // An un-gen'd path loads as a doc-only / empty graph; assembly traverses |
| 108 | + // edges, so skip anything without the gen'd (edge-rich) store graph. |
| 109 | + if graph.node_count() == 0 || graph.edge_count() == 0 { |
| 110 | + println!("{name:<14} SKIP (not gen'd — empty/edge-less graph)"); |
| 111 | + continue; |
| 112 | + } |
| 113 | + if graph.node_count() > MAX_CORPUS_NODES { |
| 114 | + println!( |
| 115 | + "{name:<14} {:>6} SKIP (too large for representative sampling)", |
| 116 | + graph.node_count() |
| 117 | + ); |
| 118 | + continue; |
| 119 | + } |
| 120 | + |
| 121 | + // Anchors the assembler includes for `seed` at `budget`. |
| 122 | + let inc = |seed: &str, budget: usize| -> HashSet<String> { |
| 123 | + assemble_with_anchors(&graph, &opts(seed, budget)) |
| 124 | + .map(|(_, anchors)| anchors.into_iter().collect()) |
| 125 | + .unwrap_or_default() |
| 126 | + }; |
| 127 | + |
| 128 | + // Pick the richest hubs from an even stride across ALL anchors (first-N |
| 129 | + // badly under-samples large graphs — their hubs aren't at the front). |
| 130 | + // Score each candidate by its reach-budget neighborhood size; keep the |
| 131 | + // SEEDS largest. Trivial leaves make the budget question meaningless. |
| 132 | + let all: Vec<String> = graph |
| 133 | + .all_nodes() |
| 134 | + .into_iter() |
| 135 | + .map(|(a, _)| a.to_string()) |
| 136 | + .collect(); |
| 137 | + let step = (all.len() / CANDIDATE_CAP).max(1); |
| 138 | + let mut scored: Vec<(String, HashSet<String>)> = all |
| 139 | + .iter() |
| 140 | + .step_by(step) |
| 141 | + .take(CANDIDATE_CAP) |
| 142 | + .map(|anchor| { |
| 143 | + let gold = inc(anchor, REACH_BUDGET); |
| 144 | + (anchor.clone(), gold) |
| 145 | + }) |
| 146 | + .filter(|(_, gold)| gold.len() >= MIN_GOLD) |
| 147 | + .collect(); |
| 148 | + scored.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0))); |
| 149 | + scored.truncate(SEEDS); |
| 150 | + |
| 151 | + if scored.is_empty() { |
| 152 | + println!("{name:<14} SKIP (no hub with >= {MIN_GOLD} neighbors in sample)"); |
| 153 | + continue; |
| 154 | + } |
| 155 | + |
| 156 | + // Mean recall across the corpus's hubs at each budget (gold = reach set). |
| 157 | + let mut recall = vec![0f64; BUDGETS.len()]; |
| 158 | + for (seed, gold) in &scored { |
| 159 | + for (bi, &b) in BUDGETS.iter().enumerate() { |
| 160 | + let hit = inc(seed, b).intersection(gold).count(); |
| 161 | + recall[bi] += hit as f64 / gold.len() as f64; |
| 162 | + } |
| 163 | + } |
| 164 | + for r in &mut recall { |
| 165 | + *r /= scored.len() as f64; |
| 166 | + } |
| 167 | + |
| 168 | + let target = KNEE_PCT as f64 / 100.0; |
| 169 | + let knee = BUDGETS |
| 170 | + .iter() |
| 171 | + .zip(&recall) |
| 172 | + .find(|&(_, &r)| r >= target) |
| 173 | + .map(|(&b, _)| b.to_string()) |
| 174 | + .unwrap_or_else(|| format!(">{}", BUDGETS.last().unwrap_or(&0))); |
| 175 | + |
| 176 | + let nodes = graph.node_count(); |
| 177 | + let avg_gold = |
| 178 | + scored.iter().map(|(_, g)| g.len()).sum::<usize>() as f64 / scored.len() as f64; |
| 179 | + let curve = recall |
| 180 | + .iter() |
| 181 | + .map(|r| format!("{r:.2}")) |
| 182 | + .collect::<Vec<_>>() |
| 183 | + .join(" "); |
| 184 | + let at_default = BUDGETS |
| 185 | + .iter() |
| 186 | + .position(|&b| b == DEFAULT_BUDGET) |
| 187 | + .map(|i| format!("{:.2}", recall[i])) |
| 188 | + .unwrap_or_else(|| "-".into()); |
| 189 | + println!( |
| 190 | + "{name:<14} {nodes:>6} {avg_gold:>7.1} {knee:>6} [{curve}] default@{DEFAULT_BUDGET}={at_default}" |
| 191 | + ); |
| 192 | + } |
| 193 | + |
| 194 | + println!( |
| 195 | + "\nknee = smallest budget capturing {KNEE_PCT}% of a hub's full (reach-budget) neighborhood;" |
| 196 | + ); |
| 197 | + println!( |
| 198 | + "below it, asm/ask truncates real context. Compare knee to the {DEFAULT_BUDGET} default" |
| 199 | + ); |
| 200 | + println!("per corpus to see where the default is over- or under-provisioned."); |
| 201 | + println!("Caveat: structural gold (reach-budget assembly), not answer-correctness — pair with"); |
| 202 | + println!("assembly_ab for the query/answer axis."); |
| 203 | +} |
0 commit comments