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}