diff --git a/Cargo.toml b/Cargo.toml index 273ceb2..06788c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,5 +40,8 @@ harness = false name = "bench_pipeline" harness = false +[[example]] +name = "cost_cascade" + [lib] crate-type = ["rlib"] diff --git a/README.md b/README.md index 3c957c9..b5e198e 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,50 @@ Every inferred name gets a confidence score: --- +## 💸 Cost Cascade (confidence-gated model routing) + +The confidence score above isn't just a label — it's a **routing signal**. The +`cascade` module wires a cost cascade onto it: run a **cheap** model first, and +**escalate to an expensive (frontier) model only when confidence < threshold**. +Most names are recovered cheaply; you only pay the frontier price for the hard, +low-confidence ones. Pure cost-Pareto — no accuracy is lost, because the cheap +answer is kept whenever it's already confident enough. + +```rust +use ruvector_decompiler::cascade::{CascadeInferrer, CorpusTier, NameInferrer}; + +// Cheapest-first tiers. The $0 corpus tier handles the easy cases; +// `MyFrontierTier` (your model — local transformer or remote API) only runs +// when the corpus answer is below the threshold. +let tiers: Vec> = vec![ + Box::new(CorpusTier::builtin()), // cost 0.0 ($0) + Box::new(MyFrontierTier::new()), // cost 100 (pay only on escalation) +]; +let mut cascade = CascadeInferrer::new(tiers, CascadeInferrer::DEFAULT_THRESHOLD); // 0.9 +let names = cascade.infer_modules(&modules); + +let stats = cascade.stats(); +println!("cheap wins: {:.0}% cost saved: {:.0}% vs frontier-only", + stats.cheap_win_rate() * 100.0, + stats.cost_saved() / stats.frontier_only_cost * 100.0); +``` + +**Default = unchanged.** The standard `decompile()` pipeline does *not* use the +cascade — it's strictly opt-in (a single-tier cascade is identical to the +existing inferrer). Bring your own frontier tier by implementing the +`NameInferrer` trait (one method); no provider is hardcoded. + +**Self-tuning.** Every inference records a `CascadeOutcome` (which tier answered, +its confidence, whether escalation changed the answer). `cascade.self_tune(step)` +reads that log and adjusts the threshold for the next run — lowering it when the +frontier keeps *confirming* the cheap tier (stop paying for confirmations), +raising it when the frontier keeps *overturning* it. This closes the loop with +ruDevolution's "gets smarter every run" self-learning. + +Runnable, model-free demo (deterministic, $0): `cargo run --example cost_cascade`. + +--- +
📖 Tutorial: Decompile an npm Package diff --git a/examples/cost_cascade.rs b/examples/cost_cascade.rs new file mode 100644 index 0000000..14895bb --- /dev/null +++ b/examples/cost_cascade.rs @@ -0,0 +1,118 @@ +//! Confidence-gated cost cascade for AI name recovery (no real model calls). +//! +//! Demonstrates the metaharness cost-cascade thesis mapped onto rudevolution's +//! existing per-inference confidence score: run a cheap tier first, escalate to +//! a frontier tier only when confidence < threshold. +//! +//! This example uses the built-in $0 corpus tier as the cheap tier and a +//! *deterministic stand-in* for the frontier tier — it makes NO network calls +//! and loads NO model. Swap `MockFrontierTier` for a real backend +//! (`neural::NeuralInferrer`, an HTTP client to `@metaharness/router`, etc.) by +//! implementing the same `NameInferrer` trait. +//! +//! Usage: cargo run --example cost_cascade + +use ruvector_decompiler::cascade::{CascadeInferrer, CorpusTier, NameInferrer}; +use ruvector_decompiler::inferrer::InferenceContext; +use ruvector_decompiler::types::{DeclKind, Declaration, InferredName, Module}; + +/// Deterministic stand-in for a frontier model. In production this would call a +/// real model (local transformer or remote API). Here it just returns a +/// plausible high-confidence name so the example is reproducible and $0. +struct MockFrontierTier; + +impl NameInferrer for MockFrontierTier { + fn label(&self) -> &str { + "frontier(mock)" + } + fn cost(&self) -> f64 { + 100.0 // relative cost units vs corpus = 0.0 + } + fn infer(&self, decl: &Declaration, ctx: &InferenceContext) -> Option { + // A real frontier model would reason over `ctx`. The mock derives a + // deterministic name from the available signal so output is stable. + let hint = ctx + .property_accesses + .first() + .or_else(|| ctx.string_literals.first()) + .cloned() + .unwrap_or_else(|| decl.kind.to_string()); + Some(InferredName { + original: decl.name.clone(), + inferred: format!("frontier_{hint}"), + confidence: 0.93, + evidence: vec!["mock frontier model (deterministic, $0)".to_string()], + }) + } +} + +fn decl(name: &str, strings: &[&str], props: &[&str]) -> Declaration { + Declaration { + name: name.to_string(), + kind: DeclKind::Var, + byte_range: (0, 4), + string_literals: strings.iter().map(|s| s.to_string()).collect(), + property_accesses: props.iter().map(|s| s.to_string()).collect(), + references: vec![], + } +} + +fn main() { + // A mix: some declarations the cheap corpus tier recovers confidently + // (high-confidence known patterns), some it can only guess at (low conf). + let module = Module { + name: "bundle".to_string(), + index: 0, + declarations: vec![ + decl("a", &["tools/call"], &[]), // corpus: high conf -> cheap win + decl("b", &["authenticate"], &[]), // corpus: high conf -> cheap win + decl("c", &[], &[]), // corpus: weak/none -> escalate + decl("d", &[], &["weirdProp"]), // corpus: weak -> escalate + ], + source: String::new(), + byte_range: (0, 0), + }; + + // Cheapest-first tiers. Threshold 0.9 matches the crate's "High" confidence. + let tiers: Vec> = vec![ + Box::new(CorpusTier::builtin()), + Box::new(MockFrontierTier), + ]; + let mut cascade = CascadeInferrer::new(tiers, CascadeInferrer::DEFAULT_THRESHOLD); + + let names = cascade.infer_modules(&[module]); + + println!("Recovered {} names (threshold {:.2}):\n", names.len(), cascade.threshold()); + for o in cascade.outcomes() { + println!( + " {:<4} -> tier={:<14} conf={:.2} escalated={} cost={}", + o.original, o.winning_tier, o.confidence, o.escalated, o.cost + ); + } + + let stats = cascade.stats(); + println!("\nCascade stats:"); + println!(" total : {}", stats.total); + println!( + " cheap wins : {} ({:.0}%)", + stats.cheap_wins, + stats.cheap_win_rate() * 100.0 + ); + println!(" escalations : {}", stats.escalations); + println!(" total cost : {}", stats.total_cost); + println!(" frontier-only : {}", stats.frontier_only_cost); + println!( + " cost saved : {} ({:.0}% vs frontier-only)", + stats.cost_saved(), + if stats.frontier_only_cost > 0.0 { + stats.cost_saved() / stats.frontier_only_cost * 100.0 + } else { + 0.0 + } + ); + + // Self-tuning: feed the recorded outcomes back into the threshold for the + // next run. This closes the loop with rudevolution's self-learning design. + let next = cascade.suggest_threshold(0.05); + println!("\nSelf-tuned threshold for next run: {next:.3}"); +} diff --git a/src/cascade.rs b/src/cascade.rs new file mode 100644 index 0000000..9087779 --- /dev/null +++ b/src/cascade.rs @@ -0,0 +1,646 @@ +//! Confidence-gated cost cascade for AI name recovery. +//! +//! rudevolution already computes a [`crate::types::InferredName::confidence`] +//! score for every recovered name. This module wires a *cost cascade* onto that +//! existing gate: run a **cheap** model first, and **escalate to a more +//! expensive (frontier) model only when the cheap result's confidence falls +//! below a threshold**. Most names are recovered cheaply; you only pay the +//! frontier price for the hard, low-confidence ones. This is a pure +//! cost-Pareto win — no accuracy is sacrificed, because the cheap tier's answer +//! is kept whenever it is already confident enough. +//! +//! ## Mapping to the metaharness thesis +//! +//! The metaharness "cascade" thesis says: route work to the cheapest model that +//! can do it, and escalate only on signal. rudevolution's per-inference +//! confidence score *is* that signal, so the cascade is a thin, native wiring +//! rather than bolted-on machinery. +//! +//! ## Default = unchanged behavior +//! +//! A [`CascadeInferrer`] built with a single tier behaves exactly like calling +//! that tier directly. The crate's existing pipeline does not use the cascade +//! unless a caller explicitly opts in via [`crate::infer_names_cascade`], so all +//! pre-existing tests and the default `decompile()` path are untouched. +//! +//! ## Self-tuning (closes the self-learning loop) +//! +//! Every inference records a [`CascadeOutcome`] (which tier answered, its +//! confidence, whether it escalated, and whether escalation changed the answer). +//! [`CascadeInferrer::suggest_threshold`] reads that log and proposes an +//! adjusted threshold for the next run: if frontier escalations keep *agreeing* +//! with the cheap tier, the threshold drops (stop paying for confirmations); if +//! they keep *overturning* it, the threshold rises (escalate more eagerly). + +use crate::inferrer::{infer_declaration_name, InferenceContext}; +use crate::training::TrainingCorpus; +use crate::types::{Declaration, InferredName, Module}; + +/// A single model tier in the cascade. +/// +/// Implementors range from the built-in pattern/corpus inferrer (cheap, $0) to +/// a neural transformer (medium) to an external frontier model behind a network +/// call (expensive). The cascade only knows two things about a tier: a human +/// label/relative cost (for reporting + self-tuning) and how to attempt an +/// inference for one declaration. +pub trait NameInferrer { + /// Short label for this tier, e.g. `"corpus"`, `"transformer"`, `"frontier"`. + fn label(&self) -> &str; + + /// Relative cost weight of invoking this tier (arbitrary units; only the + /// ordering/ratio matters). Used purely for cost accounting in + /// [`CascadeStats`]. A typical scheme: corpus = 0.0, local model = 1.0, + /// frontier API = 100.0. + fn cost(&self) -> f64 { + 0.0 + } + + /// Attempt to infer a name for `decl`. Returns `None` if this tier has no + /// opinion (the cascade then keeps the previous best, if any, and may still + /// escalate). + fn infer(&self, decl: &Declaration, ctx: &InferenceContext) -> Option; +} + +/// The built-in cheap tier: pattern tables + training corpus + structural +/// heuristics. This is exactly the inference the crate has always done, wrapped +/// as a cascade tier. Cost is $0 (no model call). +pub struct CorpusTier { + corpus: TrainingCorpus, +} + +impl CorpusTier { + /// Build a corpus tier from the built-in Claude Code patterns. + pub fn builtin() -> Self { + Self { + corpus: TrainingCorpus::builtin(), + } + } + + /// Build a corpus tier from a caller-supplied corpus. + pub fn with_corpus(corpus: TrainingCorpus) -> Self { + Self { corpus } + } +} + +impl NameInferrer for CorpusTier { + fn label(&self) -> &str { + "corpus" + } + + fn cost(&self) -> f64 { + 0.0 + } + + fn infer(&self, decl: &Declaration, _ctx: &InferenceContext) -> Option { + infer_declaration_name(decl, &self.corpus) + } +} + +/// Outcome of a single declaration's pass through the cascade. +/// +/// Recorded so the existing self-learning loop can both (a) attribute results to +/// model tiers and (b) self-tune the escalation threshold over runs. +#[derive(Debug, Clone)] +pub struct CascadeOutcome { + /// The minified identifier that was being recovered. + pub original: String, + /// Label of the tier whose answer was ultimately kept. + pub winning_tier: String, + /// Confidence of the kept answer. + pub confidence: f64, + /// Number of tiers actually invoked (1 = cheap tier was enough). + pub tiers_tried: usize, + /// Whether the cascade escalated past the first (cheapest) tier. + pub escalated: bool, + /// When escalation happened: did the more expensive tier produce a + /// *different* name than the cheap tier? `None` if no escalation occurred. + /// `Some(false)` means the frontier merely confirmed the cheap answer — + /// a signal the threshold could be lowered. + pub escalation_changed_answer: Option, + /// Total relative cost spent on this declaration (sum of invoked tier costs). + pub cost: f64, +} + +/// Aggregate statistics over a batch of cascade inferences. +#[derive(Debug, Clone, Default)] +pub struct CascadeStats { + /// Total declarations processed. + pub total: usize, + /// How many were answered by the cheapest tier alone. + pub cheap_wins: usize, + /// How many escalated to a more expensive tier. + pub escalations: usize, + /// Of the escalations, how many had the frontier *agree* with the cheap tier. + pub escalations_confirmed: usize, + /// Of the escalations, how many had the frontier *overturn* the cheap tier. + pub escalations_overturned: usize, + /// Total relative cost spent. + pub total_cost: f64, + /// Hypothetical cost if every declaration had gone straight to the most + /// expensive tier (the "frontier-only" baseline). Lets callers report + /// savings. + pub frontier_only_cost: f64, +} + +impl CascadeStats { + /// Fraction of declarations resolved without escalation (0.0..=1.0). + pub fn cheap_win_rate(&self) -> f64 { + if self.total == 0 { + 0.0 + } else { + self.cheap_wins as f64 / self.total as f64 + } + } + + /// Cost saved versus the frontier-only baseline (>= 0.0). + pub fn cost_saved(&self) -> f64 { + (self.frontier_only_cost - self.total_cost).max(0.0) + } +} + +/// A confidence-gated cost cascade over an ordered list of model tiers. +/// +/// Tiers must be ordered cheapest-first. For each declaration the cascade +/// invokes tiers in order, stopping as soon as a tier returns an answer with +/// `confidence >= threshold`. If no tier clears the bar, the highest-confidence +/// answer seen across all tiers is kept. +pub struct CascadeInferrer { + tiers: Vec>, + threshold: f64, + outcomes: Vec, +} + +impl CascadeInferrer { + /// Default escalation threshold. Mirrors the crate's existing "high + /// confidence" boundary (see [`crate::types::Confidence::High`]): a cheap + /// answer at or above this is trusted without paying for a frontier call. + pub const DEFAULT_THRESHOLD: f64 = 0.9; + + /// Build a cascade from cheapest-first tiers and an escalation threshold. + /// + /// # Panics + /// Panics if `tiers` is empty — a cascade needs at least one tier. + pub fn new(tiers: Vec>, threshold: f64) -> Self { + assert!(!tiers.is_empty(), "cascade requires at least one tier"); + Self { + tiers, + threshold: threshold.clamp(0.0, 1.0), + outcomes: Vec::new(), + } + } + + /// Build a single-tier cascade over the built-in corpus inferrer. + /// + /// This is the **default-unchanged** constructor: with one tier and any + /// threshold, the cascade can never escalate, so its output is identical to + /// the crate's existing [`crate::inferrer::infer_names`]. + pub fn single_corpus() -> Self { + Self::new( + vec![Box::new(CorpusTier::builtin())], + Self::DEFAULT_THRESHOLD, + ) + } + + /// Current escalation threshold. + pub fn threshold(&self) -> f64 { + self.threshold + } + + /// Override the escalation threshold (clamped to `0.0..=1.0`). + pub fn set_threshold(&mut self, threshold: f64) { + self.threshold = threshold.clamp(0.0, 1.0); + } + + /// Number of configured tiers. + pub fn tier_count(&self) -> usize { + self.tiers.len() + } + + /// Recorded per-inference outcomes since construction (or last [`Self::reset`]). + pub fn outcomes(&self) -> &[CascadeOutcome] { + &self.outcomes + } + + /// Clear the recorded outcome log. + pub fn reset(&mut self) { + self.outcomes.clear(); + } + + /// Infer a name for one declaration, recording the outcome. + /// + /// Returns `None` only if *no* tier produced any answer at all. + pub fn infer_declaration(&mut self, decl: &Declaration) -> Option { + let ctx = InferenceContext::from_declaration(decl); + + let mut best: Option = None; + let mut best_tier_idx = 0usize; + let mut tiers_tried = 0usize; + let mut spent = 0.0f64; + // Track the first tier's answer so we can tell whether escalation + // changed it (the self-tuning signal). + let mut first_answer: Option = None; + + for (idx, tier) in self.tiers.iter().enumerate() { + tiers_tried += 1; + spent += tier.cost(); + + if let Some(candidate) = tier.infer(decl, &ctx) { + if idx == 0 { + first_answer = Some(candidate.inferred.clone()); + } + let candidate_conf = candidate.confidence; + let candidate_name = candidate.inferred.clone(); + + // Keep the higher-confidence answer. + if best.as_ref().map_or(true, |b| candidate_conf > b.confidence) { + best = Some(candidate); + best_tier_idx = idx; + } + + // Confident enough? Stop — don't pay for the next tier. + if candidate_conf >= self.threshold { + let _ = candidate_name; + break; + } + } + // else: this tier abstained; fall through and try the next one. + } + + let escalated = tiers_tried > 1; + let escalation_changed_answer = if escalated { + match (&first_answer, &best) { + (Some(first), Some(b)) => Some(first != &b.inferred), + // Cheap tier abstained but a later tier answered: count as changed. + (None, Some(_)) => Some(true), + _ => None, + } + } else { + None + }; + + if let Some(ref b) = best { + self.outcomes.push(CascadeOutcome { + original: decl.name.clone(), + winning_tier: self.tiers[best_tier_idx].label().to_string(), + confidence: b.confidence, + tiers_tried, + escalated, + escalation_changed_answer, + cost: spent, + }); + } + + best + } + + /// Infer names for every declaration across all modules. + pub fn infer_modules(&mut self, modules: &[Module]) -> Vec { + let mut out = Vec::new(); + for module in modules { + for decl in &module.declarations { + if let Some(name) = self.infer_declaration(decl) { + out.push(name); + } + } + } + out + } + + /// Aggregate statistics over all recorded outcomes. + pub fn stats(&self) -> CascadeStats { + let frontier_cost = self.tiers.iter().map(|t| t.cost()).sum::(); + let mut s = CascadeStats { + frontier_only_cost: frontier_cost * self.outcomes.len() as f64, + ..Default::default() + }; + for o in &self.outcomes { + s.total += 1; + s.total_cost += o.cost; + if o.escalated { + s.escalations += 1; + match o.escalation_changed_answer { + Some(true) => s.escalations_overturned += 1, + Some(false) => s.escalations_confirmed += 1, + None => {} + } + } else { + s.cheap_wins += 1; + } + } + s + } + + /// Propose an adjusted escalation threshold for the next run, learning from + /// recorded outcomes. This is the self-tuning step that closes the loop with + /// rudevolution's "gets smarter every run" design. + /// + /// Heuristic: + /// - If escalations mostly **confirmed** the cheap answer, the threshold is + /// too high (we paid for needless frontier calls) → lower it. + /// - If escalations mostly **overturned** the cheap answer, the cheap tier + /// was being trusted too readily near the bar → raise it. + /// - With no escalations, leave the threshold unchanged. + /// + /// `step` bounds the per-call adjustment (e.g. `0.05`). The result is + /// clamped to a sane `0.5..=0.99` band. + pub fn suggest_threshold(&self, step: f64) -> f64 { + let s = self.stats(); + if s.escalations == 0 { + return self.threshold; + } + let confirmed = s.escalations_confirmed as f64; + let overturned = s.escalations_overturned as f64; + let total = (confirmed + overturned).max(1.0); + // Net signal in [-1, 1]: positive => mostly confirmed => lower threshold. + let confirm_ratio = (confirmed - overturned) / total; + let delta = -confirm_ratio * step; + (self.threshold + delta).clamp(0.5, 0.99) + } + + /// Apply [`Self::suggest_threshold`] in place and return the new value. + pub fn self_tune(&mut self, step: f64) -> f64 { + let next = self.suggest_threshold(step); + self.threshold = next; + next + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{DeclKind, Declaration, Module}; + + /// Deterministic fake tier for testing escalation — no real model call. + /// Returns a fixed name at a fixed confidence for any declaration. + struct FakeTier { + label: &'static str, + cost: f64, + name: &'static str, + confidence: f64, + /// If true, abstain (return None) instead of answering. + abstain: bool, + } + + impl NameInferrer for FakeTier { + fn label(&self) -> &str { + self.label + } + fn cost(&self) -> f64 { + self.cost + } + fn infer(&self, decl: &Declaration, _ctx: &InferenceContext) -> Option { + if self.abstain { + return None; + } + Some(InferredName { + original: decl.name.clone(), + inferred: self.name.to_string(), + confidence: self.confidence, + evidence: vec![format!("fake tier {}", self.label)], + }) + } + } + + fn decl(name: &str) -> Declaration { + Declaration { + name: name.to_string(), + kind: DeclKind::Var, + byte_range: (0, 4), + string_literals: vec![], + property_accesses: vec![], + references: vec![], + } + } + + fn module(decls: Vec) -> Module { + Module { + name: "m".to_string(), + index: 0, + declarations: decls, + source: String::new(), + byte_range: (0, 0), + } + } + + fn fake(label: &'static str, cost: f64, name: &'static str, conf: f64) -> Box { + Box::new(FakeTier { + label, + cost, + name, + confidence: conf, + abstain: false, + }) + } + + #[test] + fn cheap_win_does_not_escalate() { + // Cheap tier returns a high-confidence answer (>= threshold). + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "cheap_name", 0.95), + fake("frontier", 100.0, "frontier_name", 0.99), + ], + 0.9, + ); + let got = c.infer_declaration(&decl("a")).unwrap(); + assert_eq!(got.inferred, "cheap_name"); + let o = &c.outcomes()[0]; + assert_eq!(o.tiers_tried, 1, "must not invoke the frontier tier"); + assert!(!o.escalated); + assert_eq!(o.winning_tier, "cheap"); + assert_eq!(o.cost, 0.0, "no frontier cost paid"); + } + + #[test] + fn low_confidence_escalates_to_frontier() { + // Cheap tier is below threshold → escalate; frontier is more confident. + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "cheap_name", 0.4), + fake("frontier", 100.0, "frontier_name", 0.95), + ], + 0.9, + ); + let got = c.infer_declaration(&decl("a")).unwrap(); + assert_eq!(got.inferred, "frontier_name"); + let o = &c.outcomes()[0]; + assert_eq!(o.tiers_tried, 2); + assert!(o.escalated); + assert_eq!(o.escalation_changed_answer, Some(true)); + assert_eq!(o.winning_tier, "frontier"); + assert_eq!(o.cost, 100.0); + } + + #[test] + fn escalation_keeps_best_when_frontier_weaker() { + // Cheap below threshold, frontier even weaker → keep cheap (highest conf). + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "cheap_name", 0.6), + fake("frontier", 100.0, "frontier_name", 0.3), + ], + 0.9, + ); + let got = c.infer_declaration(&decl("a")).unwrap(); + assert_eq!(got.inferred, "cheap_name"); + let o = &c.outcomes()[0]; + assert!(o.escalated, "still escalated because cheap was below bar"); + assert_eq!(o.winning_tier, "cheap"); + } + + #[test] + fn frontier_confirming_cheap_is_recorded() { + // Both produce the SAME name; cheap is below bar so it escalates and the + // frontier confirms → escalation_changed_answer = Some(false). + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "same_name", 0.5), + fake("frontier", 100.0, "same_name", 0.95), + ], + 0.9, + ); + c.infer_declaration(&decl("a")).unwrap(); + let o = &c.outcomes()[0]; + assert_eq!(o.escalation_changed_answer, Some(false)); + } + + #[test] + fn single_tier_never_escalates_default_unchanged() { + // A single-tier cascade behaves exactly like the wrapped tier — the + // default-unchanged guarantee. Even a below-threshold answer is returned + // as-is with no escalation. + let mut c = CascadeInferrer::new(vec![fake("only", 0.0, "only_name", 0.2)], 0.9); + let got = c.infer_declaration(&decl("a")).unwrap(); + assert_eq!(got.inferred, "only_name"); + let o = &c.outcomes()[0]; + assert_eq!(o.tiers_tried, 1); + assert!(!o.escalated); + } + + #[test] + fn single_corpus_matches_existing_inferrer() { + // The opt-in single-corpus cascade must reproduce the crate's existing + // corpus inference for a known high-confidence pattern. + let mut d = decl("x"); + d.string_literals = vec!["tools/call".to_string()]; + let mut c = CascadeInferrer::single_corpus(); + let got = c.infer_declaration(&d).expect("should infer"); + assert!(got.confidence > 0.9); + // Matches inferrer.rs's KNOWN_PATTERNS mapping. + assert_eq!(got.inferred, "mcp_tool_call"); + assert!(!c.outcomes()[0].escalated); + } + + #[test] + fn abstaining_cheap_tier_escalates() { + let mut c = CascadeInferrer::new( + vec![ + Box::new(FakeTier { + label: "cheap", + cost: 0.0, + name: "unused", + confidence: 0.0, + abstain: true, + }), + fake("frontier", 100.0, "frontier_name", 0.95), + ], + 0.9, + ); + let got = c.infer_declaration(&decl("a")).unwrap(); + assert_eq!(got.inferred, "frontier_name"); + let o = &c.outcomes()[0]; + assert!(o.escalated); + assert_eq!(o.escalation_changed_answer, Some(true)); + } + + #[test] + fn stats_track_cheap_wins_and_savings() { + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "cheap_name", 0.95), + fake("frontier", 100.0, "frontier_name", 0.99), + ], + 0.9, + ); + let m = module(vec![decl("a"), decl("b"), decl("c")]); + let names = c.infer_modules(&[m]); + assert_eq!(names.len(), 3); + let s = c.stats(); + assert_eq!(s.total, 3); + assert_eq!(s.cheap_wins, 3); + assert_eq!(s.escalations, 0); + assert_eq!(s.total_cost, 0.0); + assert_eq!(s.frontier_only_cost, 100.0 * 3.0); + assert_eq!(s.cost_saved(), 300.0); + assert!((s.cheap_win_rate() - 1.0).abs() < 1e-9); + } + + #[test] + fn self_tune_lowers_threshold_when_frontier_confirms() { + // All escalations confirm the cheap answer → threshold should drop. + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "same", 0.5), + fake("frontier", 100.0, "same", 0.95), + ], + 0.9, + ); + for n in ["a", "b", "c", "d"] { + c.infer_declaration(&decl(n)); + } + let before = c.threshold(); + let after = c.self_tune(0.05); + assert!(after < before, "confirmations should lower threshold"); + assert!(after >= 0.5); + } + + #[test] + fn self_tune_raises_threshold_when_frontier_overturns() { + // All escalations overturn the cheap answer → threshold should rise. + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "cheap_wrong", 0.5), + fake("frontier", 100.0, "frontier_right", 0.95), + ], + 0.9, + ); + for n in ["a", "b", "c", "d"] { + c.infer_declaration(&decl(n)); + } + let before = c.threshold(); + let after = c.self_tune(0.05); + assert!(after > before, "overturns should raise threshold"); + assert!(after <= 0.99); + } + + #[test] + fn self_tune_no_op_without_escalations() { + let mut c = CascadeInferrer::new(vec![fake("cheap", 0.0, "n", 0.95)], 0.9); + c.infer_declaration(&decl("a")); + assert_eq!(c.suggest_threshold(0.05), 0.9); + } + + #[test] + fn three_tier_stops_at_middle_when_confident() { + // cheap below bar, middle clears bar → frontier never runs. + let mut c = CascadeInferrer::new( + vec![ + fake("cheap", 0.0, "c", 0.4), + fake("middle", 1.0, "m", 0.92), + fake("frontier", 100.0, "f", 0.99), + ], + 0.9, + ); + let got = c.infer_declaration(&decl("a")).unwrap(); + assert_eq!(got.inferred, "m"); + let o = &c.outcomes()[0]; + assert_eq!(o.tiers_tried, 2); + assert_eq!(o.cost, 1.0, "frontier (cost 100) must not be paid"); + } + + #[test] + #[should_panic(expected = "at least one tier")] + fn empty_cascade_panics() { + let _ = CascadeInferrer::new(vec![], 0.9); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7d2c0b1..7ad8c8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ //! ``` pub mod beautifier; +pub mod cascade; pub mod error; pub mod graph; pub mod inferrer; @@ -41,12 +42,37 @@ pub mod tree; pub mod types; pub mod witness; +pub use cascade::{ + CascadeInferrer, CascadeOutcome, CascadeStats, CorpusTier, NameInferrer, +}; pub use error::{DecompilerError, Result}; pub use types::{ Declaration, DecompileConfig, DecompileResult, InferredName, Module, ModuleTree, WitnessChainData, }; +/// Infer names using a confidence-gated cost cascade (opt-in). +/// +/// This is the cost-cascade entry point: supply model tiers ordered +/// cheapest-first plus an escalation `threshold`, and the cascade runs the cheap +/// tier first, escalating to more expensive tiers **only** when a result's +/// confidence falls below the threshold. The returned [`CascadeInferrer`] holds +/// the per-inference outcome log (see [`CascadeInferrer::outcomes`] and +/// [`CascadeInferrer::stats`]) so the self-learning loop can attribute results +/// and self-tune the threshold via [`CascadeInferrer::self_tune`]. +/// +/// The crate's default [`decompile`] pipeline does **not** use this path, so +/// enabling the cascade is a deliberate, behavior-preserving opt-in. +pub fn infer_names_cascade( + modules: &[Module], + tiers: Vec>, + threshold: f64, +) -> (Vec, CascadeInferrer) { + let mut cascade = CascadeInferrer::new(tiers, threshold); + let names = cascade.infer_modules(modules); + (names, cascade) +} + /// Decompile a minified JavaScript bundle. /// /// Runs the full five-phase pipeline: parse, graph, partition, infer, witness.