1use anyhow::Result;
18use ed25519_dalek::{Signer, SigningKey};
19use serde::{Deserialize, Serialize};
20
21use crate::core::agent_identity::{hex_decode, hex_encode, verify_signature};
22use crate::core::tokens::count_tokens;
23
24use super::model::{ModelFingerprint, ModelRequest, ModelRunner};
25use super::report::{AbReport, PairRecord, ReportConfig, Verdict};
26use super::scorers::score_task;
27use super::suite::EvalSuite;
28use super::{artifact, sha256_hex};
29
30const KIND: &str = "lean-ctx.footprint-report";
32const SCHEMA_VERSION: u32 = 1;
33
34const FOOTPRINT_SYSTEM: &str = "You are an AI coding agent. Use the lean-ctx context provided above \
37(rules, available tools and session memory) when deciding what to do next. Answer concisely and correctly.";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum InjectedElement {
43 Rules,
45 ToolSchemas,
47 Wakeup,
49}
50
51impl InjectedElement {
52 pub const ALL: [InjectedElement; 3] = [Self::Rules, Self::ToolSchemas, Self::Wakeup];
54
55 pub fn label(self) -> &'static str {
57 match self {
58 Self::Rules => "rules",
59 Self::ToolSchemas => "tool_schemas",
60 Self::Wakeup => "wakeup",
61 }
62 }
63
64 fn header(self) -> &'static str {
66 match self {
67 Self::Rules => "## lean-ctx rules",
68 Self::ToolSchemas => "## available tools",
69 Self::Wakeup => "## session memory",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct Footprint {
77 pub rules: String,
79 pub tool_schemas: String,
81 pub wakeup: String,
83}
84
85impl Footprint {
86 #[must_use]
93 pub fn live(project_root: &str) -> Self {
94 let tools = crate::server::tool_visibility::advertised_tool_defs_default();
95 Self {
96 rules: crate::rules_inject::canonical_rules_block(),
97 tool_schemas: serialize_tools(&tools),
98 wakeup: crate::tools::ctx_overview::build_wakeup_briefing(project_root, None),
99 }
100 }
101
102 fn element_text(&self, e: InjectedElement) -> &str {
104 match e {
105 InjectedElement::Rules => &self.rules,
106 InjectedElement::ToolSchemas => &self.tool_schemas,
107 InjectedElement::Wakeup => &self.wakeup,
108 }
109 }
110
111 #[must_use]
113 pub fn element_tokens(&self, e: InjectedElement) -> usize {
114 count_tokens(self.element_text(e))
115 }
116}
117
118fn serialize_tools(tools: &[rmcp::model::Tool]) -> String {
121 let mut out = String::new();
122 for t in tools {
123 let desc = t.description.as_deref().unwrap_or("");
124 let schema = serde_json::to_string(&t.input_schema).unwrap_or_default();
125 out.push_str(&format!("- {}: {desc}\n {schema}\n", t.name));
126 }
127 out
128}
129
130#[derive(Debug, Clone)]
132struct AssembledPrefix {
133 text: String,
134 tokens: usize,
135 digest: String,
136}
137
138fn assemble_prefix(fp: &Footprint, dropped: Option<InjectedElement>) -> AssembledPrefix {
140 let mut sections = Vec::new();
141 for e in InjectedElement::ALL {
142 if Some(e) == dropped {
143 continue;
144 }
145 let text = fp.element_text(e);
146 if !text.trim().is_empty() {
147 sections.push(format!("{}\n{text}", e.header()));
148 }
149 }
150 let text = sections.join("\n\n");
151 let tokens = count_tokens(&text);
152 let digest = sha256_hex(text.as_bytes());
153 AssembledPrefix {
154 text,
155 tokens,
156 digest,
157 }
158}
159
160fn build_footprint_request(prefix: &str, prompt: &str) -> ModelRequest {
162 let system = if prefix.is_empty() {
163 FOOTPRINT_SYSTEM.to_string()
164 } else {
165 format!("{prefix}\n\n{FOOTPRINT_SYSTEM}")
166 };
167 ModelRequest {
168 system,
169 user: prompt.to_string(),
170 }
171}
172
173#[derive(Debug, Clone, Copy)]
175pub struct FootprintConfig {
176 pub report: ReportConfig,
178 pub token_floor: usize,
180}
181
182impl Default for FootprintConfig {
183 fn default() -> Self {
184 Self {
185 report: ReportConfig::default(),
186 token_floor: 50,
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ElementVerdict {
194 pub element: InjectedElement,
195 pub token_cost: usize,
197 pub pass_rate_with: f64,
199 pub pass_rate_without: f64,
201 pub pass_rate_delta: f64,
203 pub verdict: Verdict,
205 pub prune_recommended: bool,
207 pub report: AbReport,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct FootprintReport {
214 pub schema_version: u32,
215 pub kind: String,
216 pub suite: String,
217 pub full_prefix_tokens: usize,
218 pub full_prefix_digest: String,
219 pub rules_tokens: usize,
220 pub tool_schema_tokens: usize,
221 pub wakeup_tokens: usize,
222 pub model: ModelFingerprint,
223 pub elements: Vec<ElementVerdict>,
224 pub determinism_digest: String,
226 pub signer_public_key: Option<String>,
228 pub signature: Option<String>,
230}
231
232impl FootprintReport {
233 #[must_use]
236 pub fn gate_passes(&self) -> bool {
237 self.elements
238 .iter()
239 .all(|e| e.verdict != Verdict::Regressed)
240 }
241
242 fn recompute_digest(&self) -> String {
244 let parts: Vec<String> = self
245 .elements
246 .iter()
247 .map(|e| artifact::determinism_digest(&e.report))
248 .collect();
249 sha256_hex(parts.join("|").as_bytes())
250 }
251
252 pub fn sign(&mut self, agent_id: &str) -> Result<(), String> {
254 let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
255 self.sign_with_key(&key);
256 Ok(())
257 }
258
259 pub fn sign_with_key(&mut self, key: &SigningKey) {
261 let sig = key.sign(self.determinism_digest.as_bytes());
262 self.signer_public_key = Some(hex_encode(&key.verifying_key().to_bytes()));
263 self.signature = Some(hex_encode(&sig.to_bytes()));
264 }
265
266 #[must_use]
268 pub fn verify(&self) -> bool {
269 if self.recompute_digest() != self.determinism_digest {
270 return false;
271 }
272 let (Some(sig), Some(pk)) = (&self.signature, &self.signer_public_key) else {
273 return false;
274 };
275 let (Ok(sig_bytes), Ok(pk_bytes)) = (hex_decode(sig), hex_decode(pk)) else {
276 return false;
277 };
278 verify_signature(&pk_bytes, self.determinism_digest.as_bytes(), &sig_bytes)
279 }
280
281 #[must_use]
283 pub fn to_json(&self) -> String {
284 serde_json::to_string_pretty(self).unwrap_or_default()
285 }
286
287 #[must_use]
289 pub fn render(&self) -> String {
290 let mut out = String::new();
291 out.push_str(&format!("Footprint ablation — suite {}\n", self.suite));
292 out.push_str(&format!(
293 "Full injected prefix: {} tok (rules {} + tools {} + wakeup {})\n",
294 self.full_prefix_tokens, self.rules_tokens, self.tool_schema_tokens, self.wakeup_tokens
295 ));
296 out.push_str(&format!(
297 "Model: {} ({})\n\n",
298 self.model.params.model, self.model.provider
299 ));
300 out.push_str(&format!(
301 "{:<13} {:>6} {:>8} {:>8} {:>7} {:<14} {}\n",
302 "Element", "Cost", "Pass+", "Pass-", "dPass", "Verdict", "Action"
303 ));
304 for e in &self.elements {
305 let action = if e.prune_recommended {
306 if e.verdict == Verdict::Regressed {
307 format!("PRUNE (harmful, -{} tok)", e.token_cost)
308 } else {
309 format!("PRUNE (-{} tok)", e.token_cost)
310 }
311 } else {
312 "keep".to_string()
313 };
314 out.push_str(&format!(
315 "{:<13} {:>6} {:>7.0}% {:>7.0}% {:>+6.0}% {:<14} {}\n",
316 e.element.label(),
317 e.token_cost,
318 e.pass_rate_with * 100.0,
319 e.pass_rate_without * 100.0,
320 e.pass_rate_delta * 100.0,
321 e.verdict.label(),
322 action,
323 ));
324 }
325 out.push_str(&format!(
326 "\nVerdict: {}\n",
327 if self.gate_passes() {
328 "OK (no harmful element)"
329 } else {
330 "HARMFUL ELEMENT PRESENT"
331 }
332 ));
333 out.push_str(&format!(
334 "Determinism digest: {}\n",
335 self.determinism_digest
336 ));
337 out
338 }
339}
340
341pub fn run_footprint_ab(
347 suite: &EvalSuite,
348 suite_name: &str,
349 footprint: &Footprint,
350 runner: &dyn ModelRunner,
351 cfg: &FootprintConfig,
352) -> Result<FootprintReport> {
353 let full = assemble_prefix(footprint, None);
354
355 let mut full_runs: Vec<(f64, bool, String)> = Vec::with_capacity(suite.tasks.len());
357 for task in &suite.tasks {
358 let workspace = task.workspace_path(&suite.dir);
359 let resp = runner.run(&build_footprint_request(&full.text, &task.prompt))?;
360 let score = score_task(task, &resp.text, &workspace)?;
361 full_runs.push((score.value, score.passed, resp.digest()));
362 }
363
364 let mut elements = Vec::with_capacity(InjectedElement::ALL.len());
365 for element in InjectedElement::ALL {
366 let minus = assemble_prefix(footprint, Some(element));
367 let token_cost = full.tokens.saturating_sub(minus.tokens);
368 let element_present = minus.digest != full.digest;
371
372 let mut records = Vec::with_capacity(suite.tasks.len());
373 for (i, task) in suite.tasks.iter().enumerate() {
374 let (without_value, without_passed, without_digest) = if element_present {
375 let workspace = task.workspace_path(&suite.dir);
376 let resp = runner.run(&build_footprint_request(&minus.text, &task.prompt))?;
377 let score = score_task(task, &resp.text, &workspace)?;
378 (score.value, score.passed, resp.digest())
379 } else {
380 (full_runs[i].0, full_runs[i].1, full_runs[i].2.clone())
381 };
382
383 records.push(PairRecord {
384 task_id: task.id.clone(),
385 domain: task.domain.label().to_string(),
386 baseline_value: without_value,
387 lean_ctx_value: full_runs[i].0,
388 baseline_passed: without_passed,
389 lean_ctx_passed: full_runs[i].1,
390 baseline_tokens: minus.tokens,
391 lean_ctx_tokens: full.tokens,
392 baseline_context_digest: minus.digest.clone(),
393 lean_ctx_context_digest: full.digest.clone(),
394 baseline_answer_digest: without_digest,
395 lean_ctx_answer_digest: full_runs[i].2.clone(),
396 });
397 }
398
399 let report = AbReport::build(
400 format!("{suite_name}::{}", element.label()),
401 full.tokens,
402 runner.fingerprint().clone(),
403 records,
404 cfg.report,
405 );
406 let pass_rate_with = report.stats.lean_ctx_pass_rate;
407 let pass_rate_without = report.stats.baseline_pass_rate;
408 let prune_recommended =
409 !matches!(report.verdict, Verdict::Improved) && token_cost >= cfg.token_floor;
410
411 elements.push(ElementVerdict {
412 element,
413 token_cost,
414 pass_rate_with,
415 pass_rate_without,
416 pass_rate_delta: pass_rate_with - pass_rate_without,
417 verdict: report.verdict,
418 prune_recommended,
419 report,
420 });
421 }
422
423 let determinism_digest = {
424 let parts: Vec<String> = elements
425 .iter()
426 .map(|e| artifact::determinism_digest(&e.report))
427 .collect();
428 sha256_hex(parts.join("|").as_bytes())
429 };
430
431 Ok(FootprintReport {
432 schema_version: SCHEMA_VERSION,
433 kind: KIND.to_string(),
434 suite: suite_name.to_string(),
435 full_prefix_tokens: full.tokens,
436 full_prefix_digest: full.digest,
437 rules_tokens: footprint.element_tokens(InjectedElement::Rules),
438 tool_schema_tokens: footprint.element_tokens(InjectedElement::ToolSchemas),
439 wakeup_tokens: footprint.element_tokens(InjectedElement::Wakeup),
440 model: runner.fingerprint().clone(),
441 elements,
442 determinism_digest,
443 signer_public_key: None,
444 signature: None,
445 })
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::core::eval_ab::model::{
452 ModelFingerprint, ModelParams, ModelResponse, PROVIDER_RECORDED, RecordedRunner, Recording,
453 };
454 use std::path::PathBuf;
455
456 fn fixed_footprint() -> Footprint {
457 Footprint {
458 rules: "RULE: prefer ctx_search over grep for code search. RULE: read a file before \
459 editing it. RULE: never fabricate values. RULE: keep outputs deterministic."
460 .repeat(2),
461 tool_schemas: "- ctx_search: semantic and lexical code search across the repository. \
462 - ctx_read: read source files with compression. - ctx_shell: run shell commands \
463 with compressed output. - ctx_symbol: find an exact symbol definition."
464 .repeat(2),
465 wakeup: "FACTS: the project indexes code with BM25 and a property graph. \
466 LAST_TASK: wire the footprint ablation harness. DECISIONS: reuse eval_ab."
467 .to_string(),
468 }
469 }
470
471 fn fixture_fingerprint() -> ModelFingerprint {
472 ModelFingerprint {
473 provider: PROVIDER_RECORDED.into(),
474 endpoint: "test".into(),
475 params: ModelParams {
476 model: "fixture".into(),
477 ..ModelParams::default()
478 },
479 }
480 }
481
482 fn test_key() -> SigningKey {
483 let mut seed = [0u8; 32];
484 getrandom::fill(&mut seed).unwrap();
485 SigningKey::from_bytes(&seed)
486 }
487
488 #[test]
489 fn dropping_an_element_reduces_tokens_and_is_deterministic() {
490 let fp = fixed_footprint();
491 for e in InjectedElement::ALL {
492 assert!(fp.element_tokens(e) > 0, "{} must carry tokens", e.label());
493 }
494 let full = assemble_prefix(&fp, None);
495 let again = assemble_prefix(&fp, None);
496 assert_eq!(full.digest, again.digest, "assembly must be deterministic");
497 for e in InjectedElement::ALL {
498 let minus = assemble_prefix(&fp, Some(e));
499 assert!(
500 minus.tokens < full.tokens,
501 "dropping {} must reduce prefix tokens",
502 e.label()
503 );
504 assert_ne!(minus.digest, full.digest);
505 }
506 }
507
508 fn pipeline_setup() -> (EvalSuite, Footprint, RecordedRunner) {
512 let raw = "{\"id\":\"t1\",\"domain\":\"qa\",\"prompt\":\"Which tool finds a symbol?\",\"workspace\":\"ws\",\"answers\":[\"ctx_symbol\"]}\n\
513 {\"id\":\"t2\",\"domain\":\"qa\",\"prompt\":\"Which tool searches code?\",\"workspace\":\"ws\",\"answers\":[\"ctx_search\"]}";
514 let suite = EvalSuite::parse(raw, PathBuf::from(".")).unwrap();
515 let fp = fixed_footprint();
516 let full = assemble_prefix(&fp, None);
517
518 let mut rec = Recording::new(fixture_fingerprint());
519 for task in &suite.tasks {
520 let gold = task.answers[0].clone();
521 let full_req = build_footprint_request(&full.text, &task.prompt);
522 rec.entries
523 .insert(full_req.key(), ModelResponse::new(gold.clone()));
524 for e in InjectedElement::ALL {
525 let minus = assemble_prefix(&fp, Some(e));
526 if minus.digest == full.digest {
527 continue;
528 }
529 let req = build_footprint_request(&minus.text, &task.prompt);
530 let answer = if e == InjectedElement::ToolSchemas {
531 "a vague wrong guess".to_string()
532 } else {
533 gold.clone()
534 };
535 rec.entries.insert(req.key(), ModelResponse::new(answer));
536 }
537 }
538 (suite, fp, RecordedRunner::new(rec))
539 }
540
541 #[test]
542 fn footprint_run_flags_unhelpful_elements_for_pruning() {
543 let (suite, fp, runner) = pipeline_setup();
544 let report = run_footprint_ab(&suite, "fixture", &fp, &runner, &FootprintConfig::default())
545 .expect("recording must cover every replay key");
546
547 assert_eq!(report.elements.len(), 3);
548
549 let tools = report
550 .elements
551 .iter()
552 .find(|e| e.element == InjectedElement::ToolSchemas)
553 .unwrap();
554 assert_eq!(
555 tools.verdict,
556 Verdict::Improved,
557 "tool schemas decide answers"
558 );
559 assert!(!tools.prune_recommended, "an improving element is kept");
560
561 let rules = report
562 .elements
563 .iter()
564 .find(|e| e.element == InjectedElement::Rules)
565 .unwrap();
566 assert!(
567 rules.prune_recommended,
568 "rules cost tokens but never changed an answer → prune"
569 );
570 assert!(report.gate_passes(), "no element is actively harmful here");
571 }
572
573 #[test]
574 fn footprint_report_is_deterministic_and_signable() {
575 let (suite, fp, runner) = pipeline_setup();
576 let cfg = FootprintConfig::default();
577 let report = run_footprint_ab(&suite, "fixture", &fp, &runner, &cfg).unwrap();
578 let report2 = run_footprint_ab(&suite, "fixture", &fp, &runner, &cfg).unwrap();
579 assert_eq!(report.determinism_digest, report2.determinism_digest);
580
581 let mut signed = report;
582 signed.sign_with_key(&test_key());
583 assert!(signed.verify(), "fresh signature must verify");
584
585 signed.elements[0].report.records[0].lean_ctx_value = 0.123;
586 assert!(
587 !signed.verify(),
588 "tampered evidence must break verification"
589 );
590 }
591
592 #[test]
593 fn live_footprint_carries_rules_and_tool_schemas() {
594 let _iso = crate::core::data_dir::isolated_data_dir();
595 let fp = Footprint::live(".");
596 assert!(!fp.rules.is_empty(), "default config injects a rules block");
597 assert!(!fp.tool_schemas.is_empty(), "tools are always advertised");
598 }
599
600 #[test]
604 fn committed_footprint_suite_loads_and_is_sensitive() {
605 let path =
606 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("eval/footprint-suite.ndjson");
607 let suite = EvalSuite::load(&path).expect("committed footprint suite must load + validate");
608 assert!(
609 suite.tasks.len() >= 4,
610 "need enough tasks for a meaningful ablation, got {}",
611 suite.tasks.len()
612 );
613 assert!(
614 suite.tasks.iter().any(|t| t.id.starts_with("route-")),
615 "need a tool-routing task (sensitive to the tool-schema element)"
616 );
617 assert!(
618 suite.tasks.iter().any(|t| t.id.starts_with("control-")),
619 "need a footprint-insensitive control task"
620 );
621 for t in &suite.tasks {
622 assert_eq!(t.domain, super::super::suite::Domain::Qa);
623 assert!(!t.answers.is_empty(), "task {} needs a gold answer", t.id);
624 }
625 }
626}