Skip to content

Commit 81d5711

Browse files
authored
Merge PR #35: cross-corpus budget-sweep eval harness
Measurement basis for the selection work: the 4096 default captures ~25% of a rich symbol's neighborhood (90% knee >8192), so selection beats a bigger budget. #[ignore]d harness; no production-path change.
2 parents deb6b0f + 9724625 commit 81d5711

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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

Comments
 (0)