Skip to main content

borg/
judge_length.rs

1//! The judge's effective length (0.7.6): the largest prompt the consequence model still judges about as
2//! well as a short one, measured per model on its own task from the judge's cost lines, and the
3//! session-end judge's prompt budget from it (hook.rs `end_budget`).
4//!
5//! **Definition** (NoLiMa, ICML 2025, arXiv 2502.05167: a model's effective length is the longest
6//! context at which it keeps at least 85% of its short-context score). The score here is the judge's
7//! *answered share*: of the lessons a session-end judge call was sent (`pending=`), those it gave a
8//! verdict other than unclear (changed, ignored, not arisen, consequence remains); a failed call
9//! (`judge-failed`) sent its lessons and answered none. Calls are grouped by their prompt's characters
10//! into ×`STEP` size steps whose upper edges are `END_START_BUDGET` × `STEP`^j (j may be negative:
11//! 32,000, 48,000, 72,000, 108,000, …). The reference is the smallest steps pooled until they hold
12//! `REFERENCE_LESSONS` sent lessons in `MIN_CALLS` calls; a step above it **holds** when the lower bound
13//! of its answered share's 95% interval is at least `HOLD` × the reference's share, **fails** when the
14//! upper bound is below that line, and is **open** while its interval still straddles the line or it has
15//! fewer than `MIN_CALLS` calls (it counts for neither). The interval is Wilson's over the step's
16//! effective number of lessons: the lessons of one call share one prompt, one cut of the work and one
17//! answer, so they are not independent trials; the spread of the calls' shares around the step's share
18//! sets how many independent lessons they are worth (a design effect, Kish), never more than were sent.
19//!
20//! **Budget.** It starts at `END_START_BUDGET`'s step. While the step above the budget holds, the budget
21//! moves up to it; while its own step fails, it moves down (never below the reference). A share of
22//! session-end passes, drawn per pass and logged as `psi=`, uses the step above: `EXPLORE_SHARE` while
23//! it is open, so it gathers the evidence that decides it, and `FAILED_EXPLORE_SHARE` once it failed, so
24//! a judge that reads longer prompts better later (or a step failed by early bad luck) can still move the
25//! budget back up. No ceiling but the model's window: the 85% line is the rot detector.
26//!
27//! **Lines read** (`<state>/*.cost`, hook.rs `judge_line`): the session-end judge's lines (` at=end`,
28//! 0.7.5 on), whose `chars=` is its whole prompt (0.7.5 counted bytes, 0.7.6 characters), and its failed
29//! calls' `judge-failed` lines (0.7.6 on). A check's judge line is left out: a check judges an unfinished
30//! session beside its own grading, a different task whose share says nothing of the session-end judge's
31//! (its `prompt=`, 0.7.6 on, is kept for a check's own budget, docs/roadmap.md). The answered lessons
32//! are the `verdicts=` counts other than `unclear`; a line before 0.7.5 has none and gives `resolved=`
33//! (changed, ignored, not arisen): a lower bound, since it leaves out a remaining consequence. A line
34//! names its judge (`model=`, 0.7.6 on); one before names none and counts as the judge this machine runs
35//! by default (`hook::judge_model("")`), the one those versions ran unless `BORG_PREDICT_MODEL` pinned
36//! another.
37//!
38//! **Cache.** The counts per model and step are kept in `<state>/judge-length.json` (`CACHE`, format
39//! `VERSION`), with the size and newest change of the `.cost` files they were read from: read as long as
40//! those are unchanged, rebuilt otherwise. The decision itself is recomputed from the counts on every
41//! read (`Length::of`), so a change of the rule never needs a new cache.
42
43use std::collections::BTreeMap;
44use std::path::Path;
45
46use serde::{Deserialize, Serialize};
47
48use crate::hook::END_START_BUDGET;
49
50/// The ratio between consecutive size steps. Method: NoLiMa's context lengths grow by about ×2; ×1.5 is
51/// finer, so the budget moves by at most half again per decision (0.7.6 pre-registration, 2026-11-01).
52pub const STEP: f64 = 1.5;
53
54/// A step holds while its answered share's lower bound stays at least this share of the reference's.
55/// Method: NoLiMa's effective-length threshold (85% of the short-context score), ICML 2025.
56pub const HOLD: f64 = 0.85;
57
58/// The fewest sent lessons the reference holds (the smallest steps pooled until they do), so one call's
59/// few lessons never set the line every other step is judged by. Guard: about one session's open
60/// deliveries at the 90th percentile (32–78 on copies of three stores, 2026-10-31) is more than one
61/// call; 30 makes the reference's share known to about ±18 points at a 50% share (Wilson, 95%).
62pub const REFERENCE_LESSONS: u64 = 30;
63
64/// The fewest calls a size step (and the reference) needs before it is decided. Guard: the lessons of
65/// one call are not independent (one prompt, one answer), so a step's interval rests on the spread
66/// between its calls (the design effect, module doc), which two or three calls do not show; one bad
67/// batch never decides a step.
68pub const MIN_CALLS: u64 = 5;
69
70/// The chance a session-end pass uses the step above the budget while that step is open, drawn per pass
71/// and logged (`psi=`). Baseline: one pass in five; most passes run at the measured budget while the
72/// step above gathers evidence. Once the step holds, the budget moves up and the next step is explored;
73/// once it fails, `FAILED_EXPLORE_SHARE`.
74pub const EXPLORE_SHARE: f64 = 0.2;
75
76/// The chance a session-end pass uses the step above the budget once that step failed, drawn and
77/// logged like `EXPLORE_SHARE`. Baseline: a quarter of it (one pass in twenty), so a failed step still
78/// gathers evidence (only the session-end judge's own calls reach it) and can hold again, at little
79/// cost to the passes that read at the measured budget.
80pub const FAILED_EXPLORE_SHARE: f64 = EXPLORE_SHARE / 4.0;
81
82/// Method: the normal quantile of a 95% interval (Wilson's score interval).
83const Z95: f64 = 1.96;
84
85/// The cache file in the state directory.
86pub const CACHE: &str = "judge-length.json";
87
88/// The cache's format: `{"version", "stamp", "models": {label: [[step, tally], …]}}` (`Tally`), the
89/// label "" for lines that name no model. Protocol: a cache of another version is rebuilt (1: sent and
90/// answered only, with check lines; 2: the session-end judge's lines, with calls and their spread).
91pub const VERSION: u32 = 2;
92
93/// The upper edge of size step `j`, in prompt characters: `END_START_BUDGET` × `STEP`^j, rounded.
94#[must_use]
95pub fn edge(j: i32) -> usize {
96    (END_START_BUDGET as f64 * STEP.powi(j)).round() as usize
97}
98
99/// The size step a prompt of `chars` characters falls in: the smallest j whose edge it does not exceed.
100#[must_use]
101pub fn step_of(chars: usize) -> i32 {
102    let mut j = 0;
103    while chars > edge(j) {
104        j += 1;
105    }
106    while chars <= edge(j - 1) && edge(j - 1) > 0 {
107        j -= 1;
108    }
109    j
110}
111
112/// One session-end judge call as its cost line gives it (`parse`).
113#[derive(Debug, Clone, PartialEq)]
114pub struct Call {
115    /// The judge's label (`model=`), empty when the line names none.
116    pub model: String,
117    /// The prompt's characters.
118    pub chars: usize,
119    /// Lessons sent, and those that came back with a verdict other than unclear.
120    pub sent: u64,
121    pub answered: u64,
122}
123
124/// A session-end judge cost line's call (see the module doc for each format); None for another line (a
125/// check's judge line among them), or one that sent no lesson.
126#[must_use]
127pub fn parse(line: &str) -> Option<Call> {
128    let (rest, failed) = match line.strip_prefix("judge ") {
129        Some(rest) => (rest, false),
130        None => (line.strip_prefix("judge-failed ")?, true),
131    };
132    let field = |k: &str| rest.split_whitespace().find_map(|w| w.strip_prefix(k));
133    let num = |k: &str| field(k).and_then(|v| v.parse::<u64>().ok());
134    let sent = num("pending=").filter(|n| *n > 0)?;
135    if field("at=") != Some(crate::outcome::AT_END) {
136        return None;
137    }
138    let chars = num("chars=")? as usize;
139    let answered = match field("verdicts=") {
140        _ if failed => 0,
141        Some(v) => v
142            .split(',')
143            .filter_map(|p| p.split_once(':'))
144            .filter(|(word, _)| *word != crate::predict::Verdict::Unclear.word())
145            .filter_map(|(_, n)| n.parse::<u64>().ok())
146            .sum(),
147        None => num("resolved=").unwrap_or(0),
148    };
149    Some(Call { model: field("model=").unwrap_or_default().to_string(), chars, sent, answered: answered.min(sent) })
150}
151
152/// A size step's calls: lessons sent and answered, the calls, and the sums the spread of the calls'
153/// shares is computed from (`interval`): Σ sent², Σ answered², Σ sent × answered over the calls.
154#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155pub struct Tally {
156    pub sent: u64,
157    pub answered: u64,
158    pub calls: u64,
159    pub sent_sq: u64,
160    pub answered_sq: u64,
161    pub cross: u64,
162}
163
164impl Tally {
165    /// One call's lessons.
166    pub fn add(&mut self, sent: u64, answered: u64) {
167        self.sent += sent;
168        self.answered += answered;
169        self.calls += 1;
170        self.sent_sq += sent * sent;
171        self.answered_sq += answered * answered;
172        self.cross += sent * answered;
173    }
174
175    /// Two tallies as one (calls pooled).
176    pub fn merge(&mut self, o: &Tally) {
177        self.sent += o.sent;
178        self.answered += o.answered;
179        self.calls += o.calls;
180        self.sent_sq += o.sent_sq;
181        self.answered_sq += o.answered_sq;
182        self.cross += o.cross;
183    }
184
185    /// The answered share (0 without lessons).
186    #[must_use]
187    pub fn share(&self) -> f64 {
188        if self.sent == 0 { 0.0 } else { self.answered as f64 / self.sent as f64 }
189    }
190
191    /// How many independent lessons the calls are worth: the sent lessons divided by the design effect,
192    /// which the spread of the calls' shares around the step's share gives (the ratio estimator's
193    /// variance between calls, Kish), never more than were sent. Fewer than two calls show no spread:
194    /// the lessons sent.
195    #[must_use]
196    pub fn effective(&self) -> f64 {
197        let n = self.sent as f64;
198        let p = self.share();
199        if self.calls < 2 || p <= 0.0 || p >= 1.0 {
200            return n;
201        }
202        // Σ (answered_i − p × sent_i)² over the calls.
203        let spread = (self.answered_sq as f64 - 2.0 * p * self.cross as f64 + p * p * self.sent_sq as f64).max(0.0);
204        let c = self.calls as f64;
205        let var = c / (c - 1.0) * spread / (n * n);
206        if var <= 0.0 { n } else { (p * (1.0 - p) / var).min(n) }
207    }
208
209    /// The answered share's 95% Wilson interval over the effective lessons (`effective`).
210    #[must_use]
211    pub fn interval(&self) -> (f64, f64) {
212        wilson_of(self.share(), self.effective())
213    }
214}
215
216/// Size step → its calls.
217pub type Steps = BTreeMap<i32, Tally>;
218
219/// The cache's content: the counts per judge label ("" for lines naming none) and the stamp of the
220/// `.cost` files they were read from.
221#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
222struct Cache {
223    version: u32,
224    stamp: String,
225    models: BTreeMap<String, Vec<(i32, Tally)>>,
226}
227
228/// The `.cost` files of `state`: their count, total size and newest change. Changes when a line is
229/// added, a file appears or goes.
230fn stamp(state: &Path) -> String {
231    let (mut files, mut bytes, mut newest) = (0u64, 0u64, 0u128);
232    for e in std::fs::read_dir(state).into_iter().flatten().flatten() {
233        if e.path().extension().is_some_and(|x| x == "cost") {
234            let Ok(m) = e.metadata() else { continue };
235            files += 1;
236            bytes += m.len();
237            let t = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
238            newest = newest.max(t);
239        }
240    }
241    format!("{files}:{bytes}:{newest}")
242}
243
244/// The counts of every judge line in `state`'s `.cost` files, per judge label.
245fn count(state: &Path) -> BTreeMap<String, Steps> {
246    let mut out: BTreeMap<String, Steps> = BTreeMap::new();
247    for e in std::fs::read_dir(state).into_iter().flatten().flatten() {
248        if e.path().extension().is_none_or(|x| x != "cost") {
249            continue;
250        }
251        for c in std::fs::read_to_string(e.path()).unwrap_or_default().lines().filter_map(parse) {
252            out.entry(c.model).or_default().entry(step_of(c.chars)).or_default().add(c.sent, c.answered);
253        }
254    }
255    out
256}
257
258/// The counts per judge label of `state` (`count`), from the cache while the `.cost` files are unchanged,
259/// else counted again and cached (written whole, then renamed in place; a failed write only costs the
260/// next reader a recount).
261#[must_use]
262pub fn counts(state: &Path) -> BTreeMap<String, Steps> {
263    let path = state.join(CACHE);
264    let now = stamp(state);
265    if let Some(c) = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str::<Cache>(&t).ok())
266        && c.version == VERSION
267        && c.stamp == now
268    {
269        return c.models.into_iter().map(|(m, v)| (m, v.into_iter().collect())).collect();
270    }
271    let counted = count(state);
272    let cache = Cache { version: VERSION, stamp: now, models: counted.iter().map(|(m, s)| (m.clone(), s.iter().map(|(j, t)| (*j, *t)).collect())).collect() };
273    if let Ok(text) = serde_json::to_string(&cache) {
274        // A temp file of this writer's own: borg web's threads may recount at once.
275        static WRITER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
276        let tmp = state.join(format!("{CACHE}.{}.{}", std::process::id(), WRITER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
277        if std::fs::write(&tmp, text).is_ok() && std::fs::rename(&tmp, &path).is_err() {
278            let _ = std::fs::remove_file(&tmp);
279        }
280    }
281    counted
282}
283
284/// The 95% Wilson score interval of `k` successes in `n` trials; (0, 1) without any.
285#[must_use]
286pub fn wilson(k: u64, n: u64) -> (f64, f64) {
287    wilson_of(if n == 0 { 0.0 } else { k as f64 / n as f64 }, n as f64)
288}
289
290/// The 95% Wilson score interval of share `p` over `n` (possibly effective, `Tally::effective`) trials;
291/// (0, 1) without any.
292#[must_use]
293pub fn wilson_of(p: f64, n: f64) -> (f64, f64) {
294    if n <= 0.0 {
295        return (0.0, 1.0);
296    }
297    let z2 = Z95 * Z95;
298    let centre = (p + z2 / (2.0 * n)) / (1.0 + z2 / n);
299    let half = Z95 * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt() / (1.0 + z2 / n);
300    ((centre - half).max(0.0), (centre + half).min(1.0))
301}
302
303/// What the evidence says of one size step (`Step::decision`).
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
305#[serde(rename_all = "snake_case")]
306pub enum Decision {
307    /// The reference: the step where the smallest steps pooled reach `REFERENCE_LESSONS` sent in
308    /// `MIN_CALLS` calls; the line is `HOLD` × their share.
309    Reference,
310    /// Below the reference, pooled into it (no line to compare a smaller step with).
311    Below,
312    /// Its lower bound is at or above the line.
313    Holds,
314    /// Its upper bound is below the line.
315    Fails,
316    /// Its interval straddles the line, it has fewer than `MIN_CALLS` calls, or no reference is known
317    /// yet: counts for neither.
318    Open,
319}
320
321/// One size step of a judge's evidence.
322#[derive(Debug, Clone, PartialEq, Serialize)]
323pub struct Step {
324    pub step: i32,
325    /// Its upper edge: prompts up to this many characters (above the step below's edge).
326    pub upto: usize,
327    pub sent: u64,
328    pub answered: u64,
329    /// The calls they were sent in.
330    pub calls: u64,
331    /// The answered share's 95% Wilson interval over the effective lessons (`Tally::interval`).
332    pub lo: f64,
333    pub hi: f64,
334    pub decision: Decision,
335}
336
337/// One judge's effective length and the session-end budget it sets (module doc).
338#[derive(Debug, Clone, PartialEq, Serialize)]
339pub struct Length {
340    /// The judge's label (runner.rs `Choice::label`).
341    pub model: String,
342    /// The budget: a session-end batch's prompt characters at most.
343    pub chars: usize,
344    /// Whether it is still the start (`END_START_BUDGET`): no decided step moved it.
345    pub start: bool,
346    /// The line a step's lower bound must reach (`HOLD` × the reference's share); None without a reference.
347    pub line: Option<f64>,
348    /// The steps with evidence, smallest first, and the explored step when it has none yet.
349    pub steps: Vec<Step>,
350    /// The step above the budget a share of passes explores (its edge).
351    pub explore: Option<usize>,
352    /// The chance a session-end pass explores it: `EXPLORE_SHARE` while it is open,
353    /// `FAILED_EXPLORE_SHARE` once it failed (0 when none is explored).
354    pub psi: f64,
355}
356
357impl Length {
358    /// The budget from `steps`, a judge's counts (module doc).
359    #[must_use]
360    pub fn of(model: &str, steps: &Steps) -> Length {
361        // The reference: the smallest steps pooled until they hold enough lessons in enough calls.
362        let mut pooled = Tally::default();
363        let reference = steps.iter().find_map(|(j, t)| {
364            pooled.merge(t);
365            (pooled.sent >= REFERENCE_LESSONS && pooled.calls >= MIN_CALLS).then_some((*j, pooled.share()))
366        });
367        let line = reference.map(|(_, share)| HOLD * share);
368        let decide = |j: i32| -> Decision {
369            let Some((r, _)) = reference else { return Decision::Open };
370            match j.cmp(&r) {
371                std::cmp::Ordering::Less => return Decision::Below,
372                std::cmp::Ordering::Equal => return Decision::Reference,
373                std::cmp::Ordering::Greater => {}
374            }
375            let t = steps.get(&j).copied().unwrap_or_default();
376            if t.calls < MIN_CALLS {
377                return Decision::Open;
378            }
379            let (lo, hi) = t.interval();
380            let l = line.unwrap_or(0.0);
381            if lo >= l {
382                Decision::Holds
383            } else if hi < l {
384                Decision::Fails
385            } else {
386                Decision::Open
387            }
388        };
389        // Down while its own step fails (never below the reference), else up while the next one holds; a
390        // step at or below the reference holds by definition (the reference is the short-context score).
391        let holds = |j: i32| matches!(decide(j), Decision::Holds | Decision::Reference | Decision::Below);
392        let mut j = 0;
393        while decide(j) == Decision::Fails {
394            j -= 1;
395        }
396        if j == 0 {
397            while holds(j + 1) {
398                j += 1;
399            }
400        }
401        let (explore, psi) = match decide(j + 1) {
402            Decision::Open => (Some(edge(j + 1)), EXPLORE_SHARE),
403            Decision::Fails => (Some(edge(j + 1)), FAILED_EXPLORE_SHARE),
404            _ => (None, 0.0),
405        };
406        let mut shown: Vec<Step> = steps
407            .iter()
408            .map(|(&k, t)| {
409                let (lo, hi) = t.interval();
410                Step { step: k, upto: edge(k), sent: t.sent, answered: t.answered, calls: t.calls, lo, hi, decision: decide(k) }
411            })
412            .collect();
413        if explore.is_some() && !steps.contains_key(&(j + 1)) {
414            shown.push(Step { step: j + 1, upto: edge(j + 1), sent: 0, answered: 0, calls: 0, lo: 0.0, hi: 1.0, decision: Decision::Open });
415        }
416        Length { model: model.to_string(), chars: edge(j), start: j == 0, line, steps: shown, explore, psi }
417    }
418}
419
420/// The counts of judge `model` among `all` (`counts`): its own lines, and those naming none when it is
421/// `unnamed`'s (the default judge, module doc).
422fn of_model(all: &BTreeMap<String, Steps>, model: &str, unnamed: &str) -> Steps {
423    let mut out = all.get(model).cloned().unwrap_or_default();
424    if !unnamed.is_empty() && model == unnamed {
425        for (j, t) in all.get("").into_iter().flatten() {
426            out.entry(*j).or_default().merge(t);
427        }
428    }
429    out
430}
431
432/// Judge `model`'s effective length from `state`'s cost lines (the session-end judge's budget).
433#[must_use]
434pub fn length(state: &Path, model: &str) -> Length {
435    let all = counts(state);
436    // Lines naming no judge count for the default one; finding it asks the PATH, so only when there are.
437    let unnamed = if all.contains_key("") { crate::hook::judge_model("") } else { String::new() };
438    Length::of(model, &of_model(&all, model, &unnamed))
439}
440
441/// Every judge's effective length in `state` (`borg status`, borg web, `borg top --json`): each judge
442/// whose lines are there, and `current`, the one this environment runs (`hook::judge_model`), even
443/// without evidence; by label.
444#[must_use]
445pub fn lengths(state: &Path, current: &str) -> Vec<Length> {
446    let all = counts(state);
447    let unnamed = crate::hook::judge_model("");
448    let mut models: std::collections::BTreeSet<String> = all.keys().filter(|m| !m.is_empty()).cloned().collect();
449    if all.contains_key("") && !unnamed.is_empty() {
450        models.insert(unnamed.clone());
451    }
452    if !current.is_empty() {
453        models.insert(current.to_string());
454    }
455    models.into_iter().map(|m| Length::of(&m, &of_model(&all, &m, &unnamed))).collect()
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    /// Steps from (upper edge, sent, answered), each in ten calls (fewer when fewer lessons) of shares as
463    /// even as the counts allow.
464    fn steps(rows: &[(usize, u64, u64)]) -> Steps {
465        rows.iter()
466            .map(|&(upto, n, a)| {
467                let c = n.min(10);
468                let mut t = Tally::default();
469                for i in 0..c {
470                    t.add(n / c + u64::from(i < n % c), a / c + u64::from(i < a % c));
471                }
472                (step_of(upto), t)
473            })
474            .collect()
475    }
476
477    /// A step from its calls' (sent, answered).
478    fn calls(rows: &[(u64, u64)]) -> Tally {
479        let mut t = Tally::default();
480        for &(n, a) in rows {
481            t.add(n, a);
482        }
483        t
484    }
485
486    #[test]
487    fn steps_are_half_again_apart_around_the_start_budget() {
488        assert_eq!((edge(-2), edge(-1), edge(0), edge(1), edge(2)), (32_000, 48_000, 72_000, 108_000, 162_000));
489        assert_eq!((step_of(72_000), step_of(72_001), step_of(48_001), step_of(48_000), step_of(1)), (0, 1, 0, -1, step_of(1)));
490        assert!(step_of(1) < -10 && edge(step_of(1)) >= 1);
491        for c in [1, 999, 31_999, 32_000, 100_000, 2_000_000] {
492            let j = step_of(c);
493            assert!(c <= edge(j) && c > edge(j - 1), "{c}: {j}");
494        }
495    }
496
497    /// The interval matches the closed form at known points.
498    #[test]
499    fn the_wilson_interval_matches_known_values() {
500        let (lo, hi) = wilson(50, 100);
501        assert!((lo - 0.4038).abs() < 1e-3 && (hi - 0.5962).abs() < 1e-3, "{lo} {hi}");
502        let (lo, hi) = wilson(0, 10);
503        assert!(lo == 0.0 && (hi - 0.2775).abs() < 1e-3, "{hi}");
504        assert_eq!(wilson(0, 0), (0.0, 1.0));
505    }
506
507    /// Every format of the session-end judge's cost line: 0.7.6's with `model=` (its `chars=` the prompt);
508    /// 0.7.5's without `model=`; before 0.7.5, `resolved=` as the answered count; a failed call sent its
509    /// lessons and answered none. A check's judge line (another task) is left out.
510    #[test]
511    fn every_cost_line_format_gives_what_it_holds() {
512        let check = "judge call=3 pending=4 resolved=2 chars=900 returned=a,b,c unmatched= verdicts=changed:1,ignored:1,not_arisen:0,consequence_remains:1,unclear:1 reason_chars=40 model=claude:sonnet prompt=30000";
513        assert_eq!(parse(check), None, "a check's line: another task");
514        let end = "judge call=5 pending=10 resolved=6 chars=70000 returned= unmatched= verdicts=changed:6,ignored:0,not_arisen:0,consequence_remains:0,unclear:4 reason_chars=9 model=codex:gpt-5 at=end batch=1/3 budget=72000 cut=after";
515        assert_eq!(parse(end), Some(Call { model: "codex:gpt-5".into(), chars: 70_000, sent: 10, answered: 6 }));
516        let end_075 = "judge call=2 pending=7 resolved=1 chars=40000 returned= unmatched= verdicts=changed:1,ignored:0,not_arisen:0,consequence_remains:2,unclear:4 reason_chars=9 at=end";
517        assert_eq!(parse(end_075), Some(Call { model: String::new(), chars: 40_000, sent: 7, answered: 3 }));
518        let failed = "judge-failed pending=12 chars=71000 model=claude:sonnet at=end batch=2/3 budget=72000 cut=after";
519        assert_eq!(parse(failed), Some(Call { model: "claude:sonnet".into(), chars: 71_000, sent: 12, answered: 0 }));
520        assert_eq!(parse("judge call=2 pending=3 resolved=2 chars=4100"), None, "a check's line before 0.7.6");
521        assert_eq!(parse("judge call=2 pending=3 resolved=2 chars=4100 at=end").map(|c| c.answered), Some(2), "before 0.7.5: resolved=");
522        assert_eq!(parse("judge call=2 pending=0 resolved=0 chars=4100 at=end"), None);
523        assert_eq!(parse("block questions=2 chars=600"), None);
524    }
525
526    /// One call's lessons are not independent: a single call never decides a step, however it went,
527    /// and calls that disagree widely are worth few lessons (the design effect), so they stay open where
528    /// the lessons counted as independent would decide.
529    #[test]
530    fn one_call_never_decides_a_step_and_disagreeing_calls_count_for_less() {
531        let mut base = steps(&[(4_000, 59, 15)]);
532        // One end call at 100k, both its lessons answered: open, the budget stays.
533        base.insert(step_of(100_000), calls(&[(2, 2)]));
534        let up = Length::of("m", &base);
535        assert_eq!((up.chars, up.start, up.explore), (72_000, true, Some(108_000)), "{up:?}");
536        // One call at 70k, none of its 30 answered: open, the budget stays.
537        let mut base = steps(&[(4_000, 59, 15)]);
538        base.insert(step_of(70_000), calls(&[(30, 0)]));
539        let down = Length::of("m", &base);
540        assert_eq!((down.chars, down.start, down.psi), (72_000, true, EXPLORE_SHARE), "{down:?}");
541        assert_eq!(down.steps.iter().find(|s| s.upto == 72_000).map(|s| s.decision), Some(Decision::Open));
542        // Six calls at 108k sharing 30% overall, but all or nothing each: 180 independent lessons would
543        // fail it (upper bound 0.37 below the line 0.425), the calls' spread keeps it open.
544        let reference = (32_000, 400, 200);
545        let clustered = calls(&[(30, 30), (30, 0), (30, 0), (30, 24), (30, 0), (30, 0)]);
546        assert!(wilson(clustered.answered, clustered.sent).1 < HOLD * 0.5, "independent: would fail");
547        assert!(clustered.effective() < 20.0, "{}", clustered.effective());
548        let mut st = steps(&[reference, (72_000, 300, 150)]);
549        st.insert(step_of(100_000), clustered);
550        let l = Length::of("m", &st);
551        assert_eq!(l.steps.iter().find(|s| s.upto == 108_000).map(|s| s.decision), Some(Decision::Open), "{l:?}");
552        // The same share in calls that agree: decided.
553        let mut st = steps(&[reference, (72_000, 300, 150)]);
554        st.insert(step_of(100_000), calls(&[(30, 9); 6]));
555        assert_eq!(Length::of("m", &st).steps.iter().find(|s| s.upto == 108_000).map(|s| s.decision), Some(Decision::Fails));
556    }
557
558    /// The reference pools the smallest steps until they hold enough lessons in enough calls.
559    #[test]
560    fn the_reference_pools_the_smallest_steps() {
561        let l = Length::of("m", &steps(&[(10_000, 20, 10), (21_333, 20, 10), (72_000, 300, 150)]));
562        assert_eq!(l.steps.iter().filter(|s| s.sent > 0).map(|s| s.decision).collect::<Vec<_>>(), [Decision::Below, Decision::Reference, Decision::Holds]);
563        assert!((l.line.unwrap() - HOLD * 0.5).abs() < 1e-9);
564        // Many lessons in too few calls: no reference yet.
565        let mut few = Steps::new();
566        few.insert(step_of(10_000), calls(&[(40, 20), (40, 20)]));
567        assert_eq!(Length::of("m", &few).line, None);
568    }
569
570    /// No evidence, or too little to decide: the budget stays at the start and the step above is explored.
571    #[test]
572    fn thin_evidence_keeps_the_start_and_explores_the_step_above() {
573        let none = Length::of("m", &Steps::new());
574        assert_eq!((none.chars, none.start, none.explore, none.psi, none.line), (72_000, true, Some(108_000), EXPLORE_SHARE, None));
575        assert_eq!(none.steps.len(), 1, "the explored step shows: {:?}", none.steps);
576        // A reference of 40 at 50%, and 108k with 5 lessons: the interval straddles the line.
577        let thin = Length::of("m", &steps(&[(32_000, 40, 20), (108_000, 5, 2)]));
578        assert_eq!((thin.chars, thin.start, thin.explore), (72_000, true, Some(108_000)));
579        assert_eq!(thin.steps.iter().map(|s| s.decision).collect::<Vec<_>>(), [Decision::Reference, Decision::Open]);
580        // The steps pooled do not reach the reference's lessons: no line yet.
581        let small = Length::of("m", &steps(&[(32_000, 12, 12), (108_000, 10, 1)]));
582        assert_eq!((small.chars, small.line, small.explore), (72_000, None, Some(108_000)));
583    }
584
585    /// Flat logs: every step above keeps the reference's share, so the budget climbs to the largest
586    /// decided step and explores the one above it.
587    #[test]
588    fn flat_logs_step_the_budget_up() {
589        let flat = Length::of("m", &steps(&[(32_000, 400, 200), (72_000, 300, 150), (108_000, 300, 151), (162_000, 300, 149), (243_000, 3, 1)]));
590        assert_eq!((flat.chars, flat.start, flat.explore, flat.psi), (162_000, false, Some(243_000), EXPLORE_SHARE));
591        let d: Vec<Decision> = flat.steps.iter().map(|s| s.decision).collect();
592        assert_eq!(d, [Decision::Reference, Decision::Holds, Decision::Holds, Decision::Holds, Decision::Open]);
593        // The step above decided as failing: the budget stays, the failed step explored at the floor.
594        let capped = Length::of("m", &steps(&[(32_000, 400, 200), (108_000, 300, 150), (162_000, 400, 100)]));
595        assert_eq!((capped.chars, capped.explore, capped.psi), (108_000, Some(162_000), FAILED_EXPLORE_SHARE));
596    }
597
598    /// Degrading logs: the share falls with size, below the line by 72k: the budget steps down to the
599    /// largest step still holding, never below the reference.
600    #[test]
601    fn degrading_logs_step_the_budget_down() {
602        let worse = Length::of("m", &steps(&[(21_333, 400, 300), (32_000, 300, 250), (48_000, 300, 230), (72_000, 300, 120)]));
603        assert_eq!((worse.chars, worse.start), (48_000, false), "{worse:?}");
604        assert_eq!((worse.explore, worse.psi), (Some(72_000), FAILED_EXPLORE_SHARE), "72k failed: explored at the floor, so it can hold again");
605        let d: Vec<Decision> = worse.steps.iter().map(|s| s.decision).collect();
606        assert_eq!(d, [Decision::Reference, Decision::Holds, Decision::Holds, Decision::Fails]);
607        // Further down while each step fails; an open one stops the descent (the one above failed: the floor).
608        let open = Length::of("m", &steps(&[(21_333, 400, 300), (32_000, 300, 250), (48_000, 300, 180), (72_000, 300, 120)]));
609        assert_eq!((open.chars, open.explore, open.psi), (48_000, Some(72_000), FAILED_EXPLORE_SHARE), "{open:?}");
610        assert_eq!(open.steps[2].decision, Decision::Open);
611        let deep = Length::of("m", &steps(&[(21_333, 400, 300), (32_000, 300, 250), (48_000, 300, 120), (72_000, 300, 120)]));
612        assert_eq!(deep.chars, 32_000);
613        // Failing down to the reference stops there.
614        let bottom = Length::of("m", &steps(&[(48_000, 300, 270), (72_000, 300, 30)]));
615        assert_eq!(bottom.chars, 48_000);
616    }
617
618    /// The counts are cached beside the cost files and rebuilt when they change; lines naming no model
619    /// count for the default judge only; another version's cache is rebuilt.
620    #[test]
621    fn the_counts_are_cached_until_the_cost_lines_change() {
622        let d = std::env::temp_dir().join(format!("borg-judge-length-{}", std::process::id()));
623        let _ = std::fs::remove_dir_all(&d);
624        std::fs::create_dir_all(&d).unwrap();
625        let end = |n: u64, a: u64, model: &str| {
626            format!(
627                "judge call=1 pending={n} resolved={a} chars=60000 returned= unmatched= verdicts=changed:{a},ignored:0,not_arisen:0,consequence_remains:0,unclear:{} reason_chars=1{model} at=end\n",
628                n - a
629            )
630        };
631        std::fs::write(d.join("a.cost"), end(10, 4, " model=x:y") + &end(6, 6, "") + "block questions=1 chars=3\n").unwrap();
632        let c = counts(&d);
633        assert_eq!((c["x:y"][&0].sent, c["x:y"][&0].answered, c["x:y"][&0].calls), (10, 4, 1));
634        assert_eq!((c[""][&0].sent, c[""][&0].answered), (6, 6));
635        let cached: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(d.join(CACHE)).unwrap()).unwrap();
636        assert_eq!(cached["version"], VERSION);
637        // Unnamed lines join the default judge, not another.
638        let all = counts(&d);
639        let pair = |t: &Tally| (t.sent, t.answered, t.calls);
640        assert_eq!(pair(&of_model(&all, "x:y", "x:y")[&0]), (16, 10, 2));
641        assert_eq!(pair(&of_model(&all, "x:y", "z:w")[&0]), (10, 4, 1));
642        // A new line changes the stamp: recounted.
643        std::fs::write(d.join("b.cost"), end(5, 5, " model=x:y")).unwrap();
644        assert_eq!(pair(&counts(&d)["x:y"][&0]), (15, 9, 2));
645        // Another version: rebuilt, not read.
646        let mut stale: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(d.join(CACHE)).unwrap()).unwrap();
647        stale["version"] = serde_json::json!(VERSION + 1);
648        stale["models"]["x:y"][0][1]["sent"] = serde_json::json!(1);
649        std::fs::write(d.join(CACHE), stale.to_string()).unwrap();
650        assert_eq!(pair(&counts(&d)["x:y"][&0]), (15, 9, 2));
651        // Two writers at once (borg web's threads): each its own temp file, the cache whole after.
652        std::fs::write(d.join("c.cost"), end(5, 1, " model=x:y")).unwrap();
653        std::thread::scope(|s| {
654            for _ in 0..8 {
655                s.spawn(|| assert_eq!(pair(&counts(&d)["x:y"][&0]), (20, 10, 3)));
656            }
657        });
658        let cache: Cache = serde_json::from_str(&std::fs::read_to_string(d.join(CACHE)).unwrap()).unwrap();
659        assert_eq!(cache.version, VERSION);
660        assert!(std::fs::read_dir(&d).unwrap().all(|e| !e.unwrap().file_name().to_string_lossy().starts_with(&format!("{CACHE}."))), "no temp file left");
661        // The listing names the current judge even without evidence.
662        assert!(lengths(&d, "q:r").iter().any(|l| l.model == "q:r" && l.start));
663        let _ = std::fs::remove_dir_all(&d);
664    }
665}