Skip to main content

majordomus_cli/economics/
context.rs

1//! The deterministic suite: what the context compiler considers and what it selects, counted.
2//!
3//! No model is called. For every seed (an issue of the repository's plan) the compiler
4//! (`majordomus devcontext`) is asked for the context of that work under its default budget.
5//! Every distinct file it reached is read from disk and counted in bytes and in tokens of a
6//! named tokenizer; a file two entries share is counted once, because a reader would read it
7//! once.
8//!
9//! The denominator is deliberately the narrow one. A *candidate* is a file the compiler judged
10//! relevant: selected, or left out only because the budget ran out. Files it reached and
11//! judged irrelevant (below the relevance floor, too deep, stale, superseded) are counted as
12//! *considered* and reported by reason, but never divided by: a ratio against everything a
13//! graph walk touched would describe the walk, not the selection.
14//!
15//! What this measures is *selection*: of what the compiler found relevant, how much it put in
16//! front of the worker. It is not what a session consumed, and it is never reported as total
17//! token savings — the metric that carries it says so in its own `not` field.
18//!
19//! ```
20//! use majordomus_cli::economics::context::{count_tokens, TOKENIZER_ENCODING};
21//! assert_eq!(TOKENIZER_ENCODING, "o200k_base");
22//! assert_eq!(count_tokens(""), 0);
23//! assert!(count_tokens("Majordomus counts what it selects.") > 3);
24//! ```
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::Path;
28
29use crate::capability::handler::Context;
30use crate::devcontext::{self, CompileInput};
31
32use super::model::{EconomicsContextRun, EconomicsContextSeed, EconomicsSuite, EconomicsTokenizer};
33use super::{inputs_digest, Declarations, CONTEXT_RUN_SCHEMA};
34
35/// The encoding every count of this suite is in.
36pub const TOKENIZER_ENCODING: &str = "o200k_base";
37/// The implementation and its version, as `Cargo.lock` resolved the pin in `Cargo.toml`:
38/// the build script reads it from the lockfile, so the number is stated in one place and a
39/// record can never name a version other than the one compiled in.
40///
41/// ```
42/// use majordomus_cli::economics::context::TOKENIZER_IMPLEMENTATION;
43/// let (name, version) = TOKENIZER_IMPLEMENTATION.split_once(' ').unwrap();
44/// assert_eq!(name, "tiktoken-rs");
45/// assert!(version.split('.').count() == 3, "a resolved version, never `unknown`: {version}");
46/// ```
47pub const TOKENIZER_IMPLEMENTATION: &str =
48    concat!("tiktoken-rs ", env!("MAJORDOMUS_TIKTOKEN_VERSION"));
49
50/// Tokens of `text` in [`TOKENIZER_ENCODING`], special-token text counted as ordinary text.
51///
52/// A file that happens to contain `<|endoftext|>` is counted as the characters it holds, not
53/// as the single control token a model would see: what is counted is what a reader is
54/// handed, and a count must never depend on whether the text looks like a protocol marker.
55/// The count is of the text alone, with no framing a provider adds around a message.
56///
57/// ```
58/// use majordomus_cli::economics::context::count_tokens;
59/// assert_eq!(count_tokens("hello world"), 2);
60/// assert!(count_tokens("<|endoftext|>") > 1, "special-token text is ordinary text");
61/// let prose = "Majordomus counts what it selects.";
62/// assert!(count_tokens(prose) < prose.len() as u64, "a token spans several bytes");
63/// ```
64pub fn count_tokens(text: &str) -> u64 {
65    tiktoken_rs::o200k_base_singleton()
66        .encode_ordinary(text)
67        .len() as u64
68}
69
70/// The tokenizer's identity, as recorded with every count.
71///
72/// Two counts are comparable only when both the encoding and the implementation agree, so
73/// a run carries both: a change to either makes earlier records a different measurement,
74/// not a smaller or larger one.
75///
76/// ```
77/// use majordomus_cli::economics::context::{tokenizer, TOKENIZER_ENCODING};
78/// let t = tokenizer();
79/// assert_eq!(t.encoding, TOKENIZER_ENCODING);
80/// assert_eq!(
81///     serde_json::to_value(&t).unwrap(),
82///     serde_json::json!({ "encoding": "o200k_base", "implementation": "tiktoken-rs 0.12.0" }),
83/// );
84/// ```
85pub fn tokenizer() -> EconomicsTokenizer {
86    EconomicsTokenizer {
87        encoding: TOKENIZER_ENCODING.into(),
88        implementation: TOKENIZER_IMPLEMENTATION.into(),
89    }
90}
91
92/// Bytes and tokens of every file, read once each.
93#[derive(Default)]
94struct Counter {
95    cache: BTreeMap<String, (u64, u64)>,
96}
97
98impl Counter {
99    fn count(&mut self, root: &Path, path: &str) -> Option<(u64, u64)> {
100        if let Some(c) = self.cache.get(path) {
101            return Some(*c);
102        }
103        let bytes = std::fs::read(root.join(path)).ok()?;
104        let text = String::from_utf8_lossy(&bytes);
105        let c = (bytes.len() as u64, count_tokens(&text));
106        self.cache.insert(path.to_string(), c);
107        Some(c)
108    }
109}
110
111/// The seeds of a context suite: the ids of the plan's issues, from the file names under
112/// the suite's `seeds` directory, sorted.
113///
114/// Only `.yaml` files name a seed, by their stem, so a README beside them is not measured.
115/// A suite that declares no `seeds` directory (a live suite), or one whose directory is
116/// missing, has no seeds rather than an error: the suite then measures nothing.
117///
118/// ```
119/// use majordomus_cli::economics::context::seeds;
120/// use majordomus_cli::economics::model::EconomicsSuite;
121/// let dir = tempfile::tempdir().unwrap();
122/// std::fs::create_dir(dir.path().join("seeds")).unwrap();
123/// for f in ["I0302.yaml", "I0301.yaml", "README.md"] {
124///     std::fs::write(dir.path().join("seeds").join(f), "").unwrap();
125/// }
126/// let mut suite: EconomicsSuite = serde_json::from_value(serde_json::json!({
127///     "schema": "economics-suite/v1", "id": "context", "version": 1, "kind": "context",
128///     "title": "Context selection", "seeds": "seeds", "freshness_inputs": []
129/// }))
130/// .unwrap();
131/// assert_eq!(seeds(dir.path(), &suite), ["I0301", "I0302"]);
132/// suite.seeds = None;
133/// assert!(seeds(dir.path(), &suite).is_empty());
134/// ```
135pub fn seeds(root: &Path, suite: &EconomicsSuite) -> Vec<String> {
136    let Some(dir) = &suite.seeds else {
137        return Vec::new();
138    };
139    let mut out: Vec<String> = std::fs::read_dir(root.join(dir))
140        .into_iter()
141        .flatten()
142        .flatten()
143        .filter_map(|e| {
144            let p = e.path();
145            (p.extension().and_then(|x| x.to_str()) == Some("yaml"))
146                .then(|| p.file_stem().and_then(|s| s.to_str()).map(str::to_string))
147                .flatten()
148        })
149        .collect();
150    crate::order::canonical(&mut out);
151    out
152}
153
154/// Measure one seed: compile its context, and count what was considered and selected.
155///
156/// Every file is read from disk once, however many entries name it. An entry the index
157/// cannot resolve, or a file the disk no longer holds, is counted as `unresolved` rather
158/// than silently dropped. A seed the compiler refuses (an issue the repository does not
159/// have) is an error that starts with the seed, so that a suite can report it beside the
160/// record. [`measure`] shares one count of every file across all its seeds; this is the
161/// same measurement for a seed on its own.
162///
163/// ```
164/// use majordomus_cli::economics::context::measure_seed;
165/// use majordomus_cli::synthetic::SyntheticRepository;
166/// let repo = SyntheticRepository::small().unwrap();
167/// let ctx = repo.context().unwrap();
168/// let err = measure_seed(&ctx, "I9999").unwrap_err();
169/// assert!(err.starts_with("I9999: "), "{err}");
170/// ```
171pub fn measure_seed(ctx: &Context, seed: &str) -> Result<EconomicsContextSeed, String> {
172    measure_seed_with(ctx, seed, &mut Counter::default())
173}
174
175/// [`measure_seed`] over a count the caller keeps. A file's bytes and tokens depend on the
176/// file alone, so one count serves every seed of a measurement. The plan's issues reach
177/// largely the same files: tokenizing each of them again for every seed spent nine tenths of
178/// a measurement of this repository's 238 issues in the tokenizer — 176 s in an unoptimised
179/// build, where counting each file once takes 12 s — and the cases that measure it were
180/// killed at their 600 s bound in CI. The record is the same either way.
181fn measure_seed_with(
182    ctx: &Context,
183    seed: &str,
184    counter: &mut Counter,
185) -> Result<EconomicsContextSeed, String> {
186    let root = Path::new(&ctx.index.repository.root);
187    let compiled = devcontext::compile(
188        ctx,
189        CompileInput {
190            issue: Some(seed.to_string()),
191            ..Default::default()
192        },
193    )
194    .map_err(|e| format!("{seed}: {e}"))?;
195    let objects = &ctx.index.objects;
196    let path_of = |uri: &str| -> Option<String> {
197        objects
198            .binary_search_by(|o| o.uri.as_str().cmp(uri))
199            .ok()
200            .map(|i| objects[i].provenance.path.clone())
201    };
202    let selected_paths: BTreeSet<String> = compiled
203        .selected
204        .iter()
205        .map(|e| e.provenance.path.clone())
206        .collect();
207    let mut excluded_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
208    let mut unresolved = 0u64;
209    for x in &compiled.excluded {
210        match path_of(&x.uri) {
211            Some(p) if !selected_paths.contains(&p) => {
212                excluded_by
213                    .entry(x.reason.as_str().to_string())
214                    .or_default()
215                    .insert(p);
216            }
217            Some(_) => {}
218            None => unresolved += 1,
219        }
220    }
221    let mut candidates: BTreeSet<String> = selected_paths.clone();
222    if let Some(ps) = excluded_by.get("budget") {
223        candidates.extend(ps.iter().cloned());
224    }
225    let mut considered: BTreeSet<String> = selected_paths.clone();
226    for ps in excluded_by.values() {
227        considered.extend(ps.iter().cloned());
228    }
229    let mut sum = |paths: &BTreeSet<String>, counter: &mut Counter| -> (u64, u64, u64) {
230        let (mut n, mut b, mut t) = (0, 0, 0);
231        for p in paths {
232            match counter.count(root, p) {
233                Some((bytes, tokens)) => {
234                    n += 1;
235                    b += bytes;
236                    t += tokens;
237                }
238                None => unresolved += 1,
239            }
240        }
241        (n, b, t)
242    };
243    let (selected, selected_bytes, selected_tokens) = sum(&selected_paths, counter);
244    let (candidates_n, candidate_bytes, candidate_tokens) = sum(&candidates, counter);
245    let (considered_n, _, considered_tokens) = sum(&considered, counter);
246    // A file excluded for two reasons is counted under the first, in the order the compiler
247    // names them, so that the per-reason figures add up to what was left out.
248    let mut seen: BTreeSet<String> = selected_paths;
249    let mut excluded_tokens = BTreeMap::new();
250    for (reason, ps) in &excluded_by {
251        let fresh: BTreeSet<String> = ps
252            .iter()
253            .filter(|p| seen.insert((*p).clone()))
254            .cloned()
255            .collect();
256        let (_, _, t) = sum(&fresh, counter);
257        excluded_tokens.insert(reason.clone(), t);
258    }
259    Ok(EconomicsContextSeed {
260        seed: seed.to_string(),
261        candidates: candidates_n,
262        candidate_bytes,
263        candidate_tokens,
264        considered: considered_n,
265        considered_tokens,
266        selected,
267        selected_bytes,
268        selected_tokens,
269        estimated_selected_tokens: compiled.budget.used_tokens,
270        excluded_tokens,
271        unresolved,
272        over_budget: compiled.budget.over_budget,
273    })
274}
275
276/// Measure a context suite over every seed, as one record. Seeds the compiler refuses are
277/// kept in the record's `refused` list, each with the compiler's reason, and returned beside
278/// it as well so that the caller can report them: a ratio over the seeds that compiled must
279/// never be read as one over every seed the suite names.
280///
281/// The record names the commit it was measured at, the tokenizer, the methodology version
282/// and the digest of the suite's freshness inputs, so a later read can tell whether it still
283/// describes the tree. Only a repository without a `HEAD` commit, or a git that cannot list
284/// the inputs, fails the whole measurement.
285///
286/// ```
287/// # use std::process::Command;
288/// # use majordomus_cli::economics::Declarations;
289/// # let methodology = serde_json::from_value(serde_json::json!({
290/// #     "schema": "economics-methodology/v1", "version": 3, "title": "t", "question": "q",
291/// #     "unit": "tokens", "primary_metric": "m", "classes": [], "variants": [], "success": [],
292/// #     "pairing": { "key": [], "comparable": [], "valid": "v" },
293/// #     "statistics": { "per_pair": "p", "location": "median", "interval": "bootstrap",
294/// #         "confidence_bp": 9500, "resamples": 1000, "seed": 1, "min_pairs_for_interval": 5 },
295/// #     "publication": { "min_valid_pairs": 1, "min_categories": 1, "min_pairs_per_category": 1,
296/// #         "min_repetitions": 1, "min_valid_pair_rate_bp": 1, "max_interval_width_bp": 1,
297/// #         "require_current": true },
298/// #     "outliers": { "rule": "none" }
299/// # })).unwrap();
300/// # let decl = Declarations { methodology, suites: Default::default(), tasks: Default::default() };
301/// # let suite: majordomus_cli::economics::model::EconomicsSuite =
302/// #     serde_json::from_value(serde_json::json!({
303/// #         "schema": "economics-suite/v1", "id": "context", "version": 2, "kind": "context",
304/// #         "title": "Context selection", "seeds": "seeds", "freshness_inputs": ["docs"]
305/// #     })).unwrap();
306/// use majordomus_cli::economics::context::{measure, tokenizer};
307/// use majordomus_cli::synthetic::SyntheticRepository;
308/// let repo = SyntheticRepository::small().unwrap();
309/// std::fs::create_dir(repo.root().join("seeds")).unwrap();
310/// std::fs::write(repo.root().join("seeds/I9001.yaml"), "").unwrap();
311/// let git = |a: &[&str]| Command::new("git").arg("-C").arg(repo.root()).args(a).output();
312/// git(&["init", "-q"]).unwrap();
313/// git(&["add", "-A"]).unwrap();
314/// git(&["-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-qm", "i"]).unwrap();
315///
316/// let ctx = repo.context().unwrap();
317/// let (run, errors) = measure(&ctx, &decl, &suite, "2026-09-24T00:00:00Z").unwrap();
318/// assert!(run.seeds.is_empty(), "a refused seed is not measured");
319/// assert!(errors.len() == 1 && errors[0].starts_with("I9001: "), "it is reported beside it");
320/// assert_eq!(run.refused, errors, "and named in the record with its reason, never dropped");
321/// assert_eq!((run.suite.as_str(), run.suite_version, run.methodology), ("context", 2, 3));
322/// assert_eq!(run.tokenizer, tokenizer());
323/// assert!(!run.repository.dirty, "measured at the commit it names");
324/// ```
325pub fn measure(
326    ctx: &Context,
327    decl: &Declarations,
328    suite: &EconomicsSuite,
329    measured_at: &str,
330) -> Result<(EconomicsContextRun, Vec<String>), String> {
331    let root = Path::new(&ctx.index.repository.root);
332    let repository = super::revision(root)?;
333    let mut out = Vec::new();
334    let mut errors = Vec::new();
335    // one count of every file for the whole measurement, however many seeds reach it
336    let mut counter = Counter::default();
337    for seed in seeds(root, suite) {
338        match measure_seed_with(ctx, &seed, &mut counter) {
339            Ok(s) => out.push(s),
340            Err(e) => errors.push(e),
341        }
342    }
343    Ok((
344        EconomicsContextRun {
345            schema: CONTEXT_RUN_SCHEMA.into(),
346            suite: suite.id.clone(),
347            suite_version: suite.version,
348            methodology: decl.methodology.version,
349            repository,
350            majordomus_version: crate::VERSION.into(),
351            tokenizer: tokenizer(),
352            inputs_digest: inputs_digest(root, &suite.freshness_inputs)?,
353            budget_tokens: devcontext::model::DEFAULT_BUDGET_TOKENS,
354            measured_at: measured_at.into(),
355            seeds: out,
356            refused: errors.clone(),
357        },
358        errors,
359    ))
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn the_tokenizer_is_pinned_to_known_counts() {
368        // Known o200k_base encodings: if the vocabulary or the implementation changes,
369        // these move, and every recorded count would stop being comparable.
370        assert_eq!(count_tokens("hello world"), 2);
371        assert_eq!(count_tokens("Hello, world!"), 4);
372        assert_eq!(count_tokens("<|endoftext|>"), count_tokens("<|endoftext|>"));
373        assert!(
374            count_tokens("<|endoftext|>") > 1,
375            "special-token text is counted as text"
376        );
377    }
378
379    #[test]
380    fn counting_is_deterministic_and_additive_over_whitespace_separated_words() {
381        let text = "fn main() { println!(\"{}\", 42); }\n".repeat(50);
382        assert_eq!(count_tokens(&text), count_tokens(&text));
383        assert!(
384            count_tokens(&text) < text.len() as u64,
385            "tokens are fewer than bytes for code"
386        );
387    }
388}