Skip to main content

lean_ctx/core/eval_ab/
footprint.rs

1//! Footprint ablation eval (#959) — proving lean-ctx's OWN injected context earns
2//! its tokens.
3//!
4//! lean-ctx injects three things into every session: the rules block, the MCP tool
5//! schemas and the wakeup briefing. The Nisi/WorkOS "Case" talk shows that added
6//! context routinely makes agents *worse* — so each element must be *proven*
7//! net-positive, not assumed.
8//!
9//! The key reuse insight: **each element's ablation is itself an A/B** — arm A is
10//! the full injected prefix, arm B is the full prefix MINUS that one element. So
11//! the whole eval_ab machinery applies unchanged: the pinned [`ModelRunner`] (+
12//! strict replay recording), the deterministic [`score_task`] scorers, and the
13//! bootstrap-CI [`Verdict`]/[`AbReport`]. A footprint run is therefore as
14//! reproducible and auditable as the context A/B (#235), and the prune
15//! recommendation falls straight out of the per-element verdict + token cost.
16
17use 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
30/// Report schema discriminator + version.
31const KIND: &str = "lean-ctx.footprint-report";
32const SCHEMA_VERSION: u32 = 1;
33
34/// Shared framing for every arm — only the injected PREFIX differs between A and B,
35/// exactly mirroring how lean-ctx rides the host instruction file + MCP surface.
36const 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/// One element of lean-ctx's injected per-turn footprint.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum InjectedElement {
43    /// The tool-mapping rules block written into the host instruction file.
44    Rules,
45    /// The advertised MCP tool descriptions + input schemas.
46    ToolSchemas,
47    /// The wakeup briefing (facts / last task / decisions) injected at session start.
48    Wakeup,
49}
50
51impl InjectedElement {
52    /// Every element, in the stable order used for assembly + reporting.
53    pub const ALL: [InjectedElement; 3] = [Self::Rules, Self::ToolSchemas, Self::Wakeup];
54
55    /// Stable label used in reports + the determinism digest.
56    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    /// Section header prefixed to the element's text inside the assembled prefix.
65    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/// The three injected texts, rendered once and reused across every arm.
75#[derive(Debug, Clone, Default)]
76pub struct Footprint {
77    /// Rules block text (`rules_inject::canonical_rules_block`).
78    pub rules: String,
79    /// Serialized advertised tool descriptions + schemas.
80    pub tool_schemas: String,
81    /// Wakeup briefing text.
82    pub wakeup: String,
83}
84
85impl Footprint {
86    /// The live footprint this install actually injects (CLI path).
87    ///
88    /// Rules + tool schemas are deterministic for a given config; the wakeup
89    /// briefing reflects the on-disk session/knowledge store, so a live footprint
90    /// run pins its evidence through the recording (a drifted wakeup misses a
91    /// replay key and hard-errors, exactly like the context A/B).
92    #[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    /// The raw text of one element.
103    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    /// Tokens contributed by one element's text in isolation.
112    #[must_use]
113    pub fn element_tokens(&self, e: InjectedElement) -> usize {
114        count_tokens(self.element_text(e))
115    }
116}
117
118/// Serializes advertised tools to a stable string — exactly the two fields a client
119/// re-sends every turn (description + input schema), matching `context_overhead`.
120fn 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/// The assembled injected prefix for one arm (full, or full minus one element).
131#[derive(Debug, Clone)]
132struct AssembledPrefix {
133    text: String,
134    tokens: usize,
135    digest: String,
136}
137
138/// Assembles the injected prefix, optionally dropping one element.
139fn 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
160/// Builds the chat request for one arm: the injected prefix rides the system turn.
161fn 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/// Configuration for a footprint run.
174#[derive(Debug, Clone, Copy)]
175pub struct FootprintConfig {
176    /// Statistics + non-inferiority gate config (shared with the context A/B).
177    pub report: ReportConfig,
178    /// Minimum marginal tokens before an unhelpful element is flagged for pruning.
179    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/// The per-element conclusion: cost, quality delta, verdict and prune recommendation.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct ElementVerdict {
194    pub element: InjectedElement,
195    /// Marginal tokens this element adds to the full prefix.
196    pub token_cost: usize,
197    /// Pass rate with the element present (the full arm).
198    pub pass_rate_with: f64,
199    /// Pass rate with the element removed.
200    pub pass_rate_without: f64,
201    /// `with − without` — the element's contribution to quality.
202    pub pass_rate_delta: f64,
203    /// Bootstrap-CI verdict of *adding* the element (`Improved` keeps it).
204    pub verdict: Verdict,
205    /// Element costs tokens (≥ floor) without earning a quality improvement.
206    pub prune_recommended: bool,
207    /// The full paired A/B report (full vs. minus-element) for auditing.
208    pub report: AbReport,
209}
210
211/// The full footprint ablation report.
212#[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    /// Machine-independent digest over every element's evidence.
225    pub determinism_digest: String,
226    /// Ed25519 public key (hex). `None` until signed.
227    pub signer_public_key: Option<String>,
228    /// Ed25519 signature over `determinism_digest` (hex). `None` until signed.
229    pub signature: Option<String>,
230}
231
232impl FootprintReport {
233    /// The CI gate passes unless an injected element is actively *harmful*
234    /// (removing it improves quality beyond the margin).
235    #[must_use]
236    pub fn gate_passes(&self) -> bool {
237        self.elements
238            .iter()
239            .all(|e| e.verdict != Verdict::Regressed)
240    }
241
242    /// Recomputes the evidence digest from the per-element reports.
243    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    /// Signs with the persistent machine identity (`agent_identity` keystore).
253    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    /// Signs `determinism_digest` (which commits to all evidence) with an explicit key.
260    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    /// Verifies the signature *and* that the embedded digest still matches the evidence.
267    #[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    /// Pretty JSON for machine consumption.
282    #[must_use]
283    pub fn to_json(&self) -> String {
284        serde_json::to_string_pretty(self).unwrap_or_default()
285    }
286
287    /// Compact, deterministic side-by-side summary for the terminal.
288    #[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
341/// Runs the footprint ablation: the full arm once, then each element's minus arm,
342/// pairing them into a per-element [`AbReport`] and collapsing to a prune verdict.
343///
344/// The model is the only non-deterministic input; with a [`super::model::RecordedRunner`]
345/// the whole run is byte-identical everywhere.
346pub 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    // The full arm is identical for every element, so run it exactly once.
356    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        // An absent element leaves the prefix byte-identical → reuse the full arm
369        // instead of issuing a redundant (and identically-keyed) model call.
370        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    /// Builds a 2-task QA suite + a recording where the tool schemas are the only
509    /// element that changes an answer, so tool_schemas must be IMPROVED (kept) and
510    /// rules/wakeup must be prune candidates (cost tokens, no quality gain).
511    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    /// Guards the committed footprint suite (`rust/eval/footprint-suite.ndjson`)
601    /// in-process so suite drift fails in `cargo test` / `dev-install`, mirroring
602    /// the accuracy-suite guard. Model-free: structure + footprint-sensitivity only.
603    #[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}