Skip to main content

lean_ctx/core/
qubo_select.rs

1//! QUBO context-selection spike (#10) — research only, never the default.
2//!
3//! Context selection under a token budget is a quadratic optimization: maximize
4//! total salience while penalizing redundancy between co-selected items and
5//! staying within budget. That is naturally a QUBO (quadratic unconstrained
6//! binary optimization):
7//!
8//! ```text
9//!   minimize  E(x) = -Σ φ_i x_i  +  α Σ_{i<j} sim_ij x_i x_j  +  β·overflow(x)
10//!   over      x ∈ {0,1}^n
11//! ```
12//!
13//! where `overflow` is the budget violation. QUBO is the form solved by quantum
14//! annealers and their classical analogues (simulated annealing / simulated
15//! bifurcation). This module provides a *deterministic* simulated-annealing
16//! solver (seeded PRNG — no `getrandom`) plus a benchmark harness comparing it to
17//! the production greedy knapsack on quality (φ captured) and tokens.
18//!
19//! IMPORTANT: this is a benchmark spike gated behind `LEAN_CTX_EXPERIMENTAL_QUBO`.
20//! It never changes selection defaults; the greedy compiler remains in charge.
21//! Promotion is conditional on a measurable win from the harness below.
22
23use crate::core::entropy::jaccard_similarity;
24
25/// Redundancy penalty weight in the QUBO objective.
26const ALPHA: f64 = 0.5;
27/// Budget-overflow penalty weight (per token over budget). Large so any feasible
28/// solution dominates an infeasible one.
29const BETA: f64 = 1.0;
30/// Annealing iterations. Fixed for determinism and bounded cost.
31const SA_ITERS: usize = 4000;
32
33/// `true` when the experimental QUBO spike is enabled. Off by default — the
34/// greedy selector stays the default selection path regardless.
35pub fn is_enabled() -> bool {
36    matches!(
37        std::env::var("LEAN_CTX_EXPERIMENTAL_QUBO")
38            .ok()
39            .as_deref()
40            .map(str::trim),
41        Some("1" | "true" | "yes" | "on")
42    )
43}
44
45/// A candidate item for QUBO selection.
46#[derive(Debug, Clone)]
47pub struct QuboItem {
48    pub id: String,
49    pub phi: f64,
50    pub tokens: usize,
51    /// Content fingerprint for the pairwise redundancy term.
52    pub sketch: String,
53}
54
55/// Deterministic, reproducible PRNG (SplitMix64) — keeps the spike free of
56/// `getrandom` so results are byte-stable across runs/machines.
57struct SplitMix64(u64);
58impl SplitMix64 {
59    fn new(seed: u64) -> Self {
60        Self(seed)
61    }
62    fn next_u64(&mut self) -> u64 {
63        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
64        let mut z = self.0;
65        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
66        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
67        z ^ (z >> 31)
68    }
69    fn next_f64(&mut self) -> f64 {
70        (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
71    }
72    fn next_below(&mut self, bound: usize) -> usize {
73        if bound == 0 {
74            0
75        } else {
76            (self.next_u64() % bound as u64) as usize
77        }
78    }
79}
80
81/// Precomputed pairwise redundancy (upper triangle), so SA energy deltas are
82/// cheap. `sim[i][j]` for `i < j`.
83fn redundancy_matrix(items: &[QuboItem]) -> Vec<Vec<f64>> {
84    let n = items.len();
85    let mut sim = vec![vec![0.0; n]; n];
86    for i in 0..n {
87        for j in (i + 1)..n {
88            let s = jaccard_similarity(&items[i].sketch, &items[j].sketch);
89            sim[i][j] = s;
90            sim[j][i] = s;
91        }
92    }
93    sim
94}
95
96fn energy(items: &[QuboItem], sim: &[Vec<f64>], x: &[bool], budget: usize) -> f64 {
97    let mut e = 0.0;
98    let mut tokens = 0usize;
99    for i in 0..items.len() {
100        if !x[i] {
101            continue;
102        }
103        e -= items[i].phi;
104        tokens += items[i].tokens;
105        for j in (i + 1)..items.len() {
106            if x[j] {
107                e += ALPHA * sim[i][j];
108            }
109        }
110    }
111    let overflow = tokens.saturating_sub(budget) as f64;
112    e + BETA * overflow
113}
114
115/// Solve the selection QUBO with deterministic simulated annealing. Returns the
116/// indices of selected items. Seeded by a stable hash of the problem so the
117/// result is reproducible. Registers activity for `introspect cognition`.
118pub fn select(items: &[QuboItem], budget: usize) -> Vec<usize> {
119    crate::core::introspect::tick("qubo_select");
120    let n = items.len();
121    if n == 0 {
122        return Vec::new();
123    }
124    let sim = redundancy_matrix(items);
125
126    // Start from the greedy feasible solution — a good basin for SA to refine.
127    let mut x = greedy_mask(items, budget);
128    let mut best = x.clone();
129    let mut best_e = energy(items, &sim, &x, budget);
130    let mut cur_e = best_e;
131
132    let mut rng = SplitMix64::new(problem_seed(items, budget));
133    for k in 0..SA_ITERS {
134        // Geometric temperature schedule from 1.0 → ~0.01.
135        let t = (1.0 - (k as f64 / SA_ITERS as f64)).mul_add(0.99, 0.01);
136        let i = rng.next_below(n);
137        x[i] = !x[i];
138        let new_e = energy(items, &sim, &x, budget);
139        let delta = new_e - cur_e;
140        if delta <= 0.0 || rng.next_f64() < (-delta / t).exp() {
141            cur_e = new_e;
142            if new_e < best_e {
143                best_e = new_e;
144                best.clone_from(&x);
145            }
146        } else {
147            x[i] = !x[i]; // reject: revert
148        }
149    }
150
151    best.iter()
152        .enumerate()
153        .filter_map(|(i, &on)| on.then_some(i))
154        .collect()
155}
156
157/// Greedy feasible selection (efficiency = φ/token, descending) used both as the
158/// SA seed and as the benchmark baseline (mirrors the production compiler).
159fn greedy_mask(items: &[QuboItem], budget: usize) -> Vec<bool> {
160    let mut order: Vec<usize> = (0..items.len()).collect();
161    order.sort_by(|&a, &b| {
162        let ea = items[a].phi / items[a].tokens.max(1) as f64;
163        let eb = items[b].phi / items[b].tokens.max(1) as f64;
164        eb.partial_cmp(&ea)
165            .unwrap_or(std::cmp::Ordering::Equal)
166            .then_with(|| items[a].id.cmp(&items[b].id))
167    });
168    let mut mask = vec![false; items.len()];
169    let mut used = 0usize;
170    for i in order {
171        if used + items[i].tokens <= budget {
172            mask[i] = true;
173            used += items[i].tokens;
174        }
175    }
176    mask
177}
178
179/// Stable per-problem seed so SA is reproducible.
180fn problem_seed(items: &[QuboItem], budget: usize) -> u64 {
181    let mut h = budget as u64;
182    for it in items {
183        h ^= it.id.bytes().fold(1469598103934665603u64, |acc, b| {
184            (acc ^ u64::from(b)).wrapping_mul(1099511628211)
185        });
186        h = h.wrapping_mul(0x100000001b3).wrapping_add(it.tokens as u64);
187    }
188    h
189}
190
191/// Result of a QUBO-vs-greedy benchmark run.
192#[derive(Debug, Clone)]
193pub struct BenchReport {
194    pub items: usize,
195    pub budget: usize,
196    pub greedy_phi: f64,
197    pub greedy_tokens: usize,
198    pub qubo_phi: f64,
199    pub qubo_tokens: usize,
200}
201
202impl BenchReport {
203    /// Total φ captured, relative gain of QUBO over greedy (can be negative).
204    pub fn phi_gain_pct(&self) -> f64 {
205        if self.greedy_phi <= 0.0 {
206            return 0.0;
207        }
208        (self.qubo_phi - self.greedy_phi) / self.greedy_phi * 100.0
209    }
210
211    pub fn format(&self) -> String {
212        format!(
213            "QUBO spike (experimental, greedy stays default)\n\
214             items={}  budget={}\n\
215             greedy: phi={:.3} tokens={}\n\
216             qubo:   phi={:.3} tokens={}\n\
217             phi gain: {:+.1}%",
218            self.items,
219            self.budget,
220            self.greedy_phi,
221            self.greedy_tokens,
222            self.qubo_phi,
223            self.qubo_tokens,
224            self.phi_gain_pct(),
225        )
226    }
227}
228
229fn captured(items: &[QuboItem], idx: &[usize]) -> (f64, usize) {
230    idx.iter().fold((0.0, 0usize), |(p, t), &i| {
231        (p + items[i].phi, t + items[i].tokens)
232    })
233}
234
235/// Run the QUBO-vs-greedy benchmark on a problem. Pure and deterministic.
236pub fn benchmark(items: &[QuboItem], budget: usize) -> BenchReport {
237    let greedy: Vec<usize> = greedy_mask(items, budget)
238        .iter()
239        .enumerate()
240        .filter_map(|(i, &on)| on.then_some(i))
241        .collect();
242    let qubo = select(items, budget);
243    let (greedy_phi, greedy_tokens) = captured(items, &greedy);
244    let (qubo_phi, qubo_tokens) = captured(items, &qubo);
245    BenchReport {
246        items: items.len(),
247        budget,
248        greedy_phi,
249        greedy_tokens,
250        qubo_phi,
251        qubo_tokens,
252    }
253}
254
255/// A deterministic synthetic problem for the CLI harness: clusters of redundant
256/// items plus unique high-φ items, so QUBO's redundancy awareness can show.
257pub fn synthetic_problem() -> (Vec<QuboItem>, usize) {
258    let mut items = Vec::new();
259    // Three near-duplicate clusters (same sketch) of medium φ.
260    for cluster in 0..3 {
261        for k in 0..3 {
262            items.push(QuboItem {
263                id: format!("dup{cluster}_{k}"),
264                phi: 0.6,
265                tokens: 300,
266                sketch: format!("cluster {cluster} shared redundant content body"),
267            });
268        }
269    }
270    // Unique high-φ items with genuinely distinct content (no shared words, so
271    // the redundancy term reflects only the intended duplicate clusters).
272    let unique_sketches = [
273        "kepler orbital mechanics ellipse perihelion",
274        "ribosome translation codon peptide synthesis",
275        "byzantine consensus quorum fault tolerance",
276        "monsoon humidity evaporation precipitation cycle",
277    ];
278    for (u, sketch) in unique_sketches.iter().enumerate() {
279        items.push(QuboItem {
280            id: format!("uniq{u}"),
281            phi: 0.8,
282            tokens: 300,
283            sketch: (*sketch).to_string(),
284        });
285    }
286    (items, 1500)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn items() -> Vec<QuboItem> {
294        synthetic_problem().0
295    }
296
297    #[test]
298    fn disabled_by_default() {
299        // Spike must be opt-in; greedy stays default. Serialize env access through
300        // the shared test lock so this never races other env-reading tests.
301        let _lock = crate::core::data_dir::test_env_lock();
302        crate::test_env::remove_var("LEAN_CTX_EXPERIMENTAL_QUBO");
303        assert!(!is_enabled());
304    }
305
306    #[test]
307    fn selection_respects_budget() {
308        let it = items();
309        let budget = 1500;
310        let sel = select(&it, budget);
311        let (_, tokens) = captured(&it, &sel);
312        assert!(tokens <= budget, "QUBO must not exceed budget: {tokens}");
313    }
314
315    #[test]
316    fn selection_is_deterministic() {
317        // Determinism contract (#498): seeded SA → identical selection each run.
318        let it = items();
319        let a = select(&it, 1500);
320        let b = select(&it, 1500);
321        assert_eq!(a, b, "seeded SA must be reproducible");
322    }
323
324    #[test]
325    fn benchmark_runs_and_reports() {
326        let (it, budget) = synthetic_problem();
327        let report = benchmark(&it, budget);
328        assert_eq!(report.items, it.len());
329        assert!(report.greedy_phi > 0.0);
330        assert!(report.qubo_phi > 0.0);
331        // Both stay within budget.
332        assert!(report.greedy_tokens <= budget);
333        assert!(report.qubo_tokens <= budget);
334        // Report formats without panicking.
335        assert!(report.format().contains("QUBO spike"));
336    }
337
338    #[test]
339    fn empty_problem_selects_nothing() {
340        assert!(select(&[], 1000).is_empty());
341    }
342}