1use std::collections::{BTreeMap, BTreeSet};
34use std::path::Path;
35
36use schemars::JsonSchema;
37use serde::{Deserialize, Serialize};
38
39use super::model::{EconomicsFreshness, EconomicsMetric, EconomicsMetricStatus, EconomicsSummary};
40
41const VOCABULARY: &[&str] = &[
43 "token", "context", "cost", "costs", "spend", "spending", "cheaper", "saving", "savings",
44 "save", "saves", "bill", "billing", "usage", "prompt",
45];
46
47const SAVING: &[&str] = &[
52 "reduce",
53 "reduces",
54 "reduced",
55 "reducing",
56 "reduction",
57 "save",
58 "saves",
59 "saved",
60 "saving",
61 "savings",
62 "cheaper",
63 "fewer",
64 "less",
65 "lower",
66 "lowers",
67 "lowered",
68 "cuts",
69 "cut",
70 "cutting",
71 "halve",
72 "halves",
73 "halved",
74 "halving",
75];
76
77const FRACTIONS: &[&str] = &[
82 "half", "halve", "halves", "halved", "halving", "twice", "double", "doubled", "doubles",
83 "triple", "tripled", "third", "thirds", "quarter", "quarters",
84];
85
86const ORDINALS: &[&str] = &["third", "quarter"];
89
90const NEAR: usize = 6;
93
94const DASHES: &[char] = &['-', '\u{2010}', '\u{2011}', '\u{2013}', '\u{2014}'];
97
98const PROSE: &[&str] = &[
101 "README.md",
102 "AGENTS.md",
103 "CLAUDE.md",
104 "docs/*.md",
105 "docs/**/*.md",
106 "site/content-src/**",
107 "site/data/*.toml",
108 "site/templates/**",
109 ".ai/repo/**/*.md",
110 "share/providers/*.tmpl",
111 ".ai/repo/providers/*.tmpl",
112];
113
114const GENERATED_DIRS: &[&str] = &["site/data/generated/", "site/content/"];
117
118const MANIFEST: &str = "docs/generated/artifacts.json";
120
121const STAMPS: &[&str] = &[
123 "<!-- generated by",
124 "# GENERATED FILE",
125 "<!-- GENERATED FILE",
126 "// GENERATED FILE",
127];
128
129const CLAIMS: &str = "docs/CLAIMS.yaml";
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163pub struct EconomicsFinding {
164 pub kind: String,
167 pub path: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub line: Option<usize>,
172 pub detail: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
195pub struct EconomicsCheckReport {
196 pub ok: bool,
198 pub scanned: usize,
200 pub claims_checked: usize,
202 pub findings: Vec<EconomicsFinding>,
204}
205
206#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(deny_unknown_fields)]
219pub struct EconomicsCheckInput {}
220
221const COMPARATIVES: &[&str] = &[
223 "fewer",
224 "less",
225 "cheaper",
226 "smaller",
227 "lower",
228 "more",
229 "faster",
230 "reduction",
231 "saving",
232 "savings",
233];
234
235fn offset(outer: &str, inner: &str) -> usize {
237 inner.as_ptr() as usize - outer.as_ptr() as usize
238}
239
240const APOSTROPHES: &[char] = &['\'', '\u{2019}'];
242
243fn words(sentence: &str) -> Vec<(usize, String)> {
250 let mut out = Vec::new();
251 for token in sentence.split_whitespace() {
252 for part in token.split(|c: char| DASHES.contains(&c)) {
253 let t = part.trim_matches(|c: char| !c.is_alphanumeric());
254 let t = t
255 .strip_suffix(['s', 'S'])
256 .and_then(|x| x.strip_suffix(APOSTROPHES))
257 .unwrap_or(t);
258 if t.is_empty()
259 || !t
260 .chars()
261 .all(|c| c.is_alphabetic() || APOSTROPHES.contains(&c))
262 {
263 continue;
264 }
265 out.push((offset(sentence, t), t.to_lowercase()));
266 }
267 }
268 out
269}
270
271fn vocabulary(word: &str) -> bool {
272 VOCABULARY
273 .iter()
274 .any(|v| word == *v || (word.starts_with(v) && word.len() <= v.len() + 1))
275}
276
277fn economic(sentence: &str) -> bool {
278 words(sentence).iter().any(|(_, w)| vocabulary(w))
279}
280
281const SAVING_ALONE: &[&str] = &["cheaper", "savings"];
284
285fn saving_claim(sentence: &str) -> bool {
290 let w = words(sentence);
291 let saving: Vec<usize> = (0..w.len())
292 .filter(|&k| SAVING.contains(&w[k].1.as_str()))
293 .collect();
294 let vocab: Vec<usize> = (0..w.len()).filter(|&k| vocabulary(&w[k].1)).collect();
295 w.iter().any(|(_, x)| SAVING_ALONE.contains(&x.as_str()))
296 || saving.iter().any(|s| vocab.iter().any(|v| v != s))
297}
298
299const ABBREVIATIONS: &[&str] = &[
302 "e.g.", "i.e.", "etc.", "vs.", "cf.", "approx.", "ca.", "incl.", "resp.", "viz.",
303];
304
305fn abbreviation(text: &str, i: usize) -> bool {
309 let word = text[..=i]
310 .rsplit(|c: char| c.is_whitespace() || matches!(c, '(' | '[' | '"' | '*' | '_' | '`'))
311 .next()
312 .unwrap_or("")
313 .to_lowercase();
314 let capital_next = text[i + 1..]
315 .trim_start()
316 .chars()
317 .next()
318 .is_some_and(char::is_uppercase);
319 ABBREVIATIONS.contains(&word.as_str()) && !(word == "etc." && capital_next)
320}
321
322fn sentence_spans(text: &str) -> Vec<(usize, &str)> {
327 let mut out = Vec::new();
328 let mut start = 0;
329 let b = text.as_bytes();
330 let mut i = 0;
331 while i < b.len() {
332 if matches!(b[i], b'.' | b'!' | b'?') && !(b[i] == b'.' && abbreviation(text, i)) {
333 let mut j = i + 1;
334 while j < b.len() && matches!(b[j], b'"' | b'\'' | b')' | b']' | b'*' | b'_') {
335 j += 1;
336 }
337 if b.get(j) == Some(&b' ') {
338 out.push((start, &text[start..j]));
339 start = j;
340 i = j;
341 continue;
342 }
343 }
344 i += 1;
345 }
346 out.push((start, &text[start..]));
347 out
348}
349
350fn glued(chars: &[(usize, char)], i: usize) -> bool {
353 let Some(&(_, prev)) = i.checked_sub(1).and_then(|k| chars.get(k)) else {
354 return false;
355 };
356 if prev.is_alphanumeric() || prev == '.' {
357 return true;
358 }
359 if prev == '_' {
360 let mut k = i - 1;
361 while k > 0 && matches!(chars[k - 1].1, '_' | '*') {
362 k -= 1;
363 }
364 return k > 0 && chars[k - 1].1.is_alphanumeric();
365 }
366 false
367}
368
369fn numeric_quantity(sentence: &str) -> Option<(usize, String)> {
373 let chars: Vec<(usize, char)> = sentence.char_indices().collect();
374 let at = |k: usize| chars.get(k).map(|&(_, c)| c);
375 let mut i = 0;
376 while i < chars.len() {
377 if !chars[i].1.is_ascii_digit() || glued(&chars, i) {
378 i += 1;
379 continue;
380 }
381 let start = i;
382 while at(i).is_some_and(|c| c.is_ascii_digit())
383 || (matches!(at(i), Some('.' | ',')) && at(i + 1).is_some_and(|c| c.is_ascii_digit()))
384 {
385 i += 1;
386 }
387 let number: String = chars[start..i].iter().map(|&(_, c)| c).collect();
388 let position = chars[start].0;
389 let attached = at(i);
390 let mut j = i;
391 while matches!(at(j), Some('*' | '_')) {
392 j += 1;
393 }
394 while at(j).is_some_and(char::is_whitespace) {
395 j += 1;
396 }
397 let rest: String = chars[j..]
398 .iter()
399 .map(|&(_, c)| c)
400 .collect::<String>()
401 .to_lowercase();
402 let next_word = |from: usize| -> String {
403 rest.chars()
404 .skip(from)
405 .collect::<String>()
406 .split_whitespace()
407 .next()
408 .unwrap_or("")
409 .trim_matches(|c: char| !c.is_alphanumeric())
410 .to_string()
411 };
412 let ends_word = |len: usize| rest.chars().nth(len).is_none_or(|c| !c.is_alphanumeric());
413 if (rest.starts_with('%') && !rest[1..].starts_with('}')) || rest.starts_with('\u{ff05}') {
415 return Some((position, format!("{number}%")));
416 }
417 if rest.starts_with("percentage point") {
418 return Some((position, format!("{number} percentage points")));
419 }
420 if rest.starts_with("percent") {
421 return Some((position, format!("{number} percent")));
422 }
423 if rest.starts_with("per cent") && ends_word(8) {
424 return Some((position, format!("{number} per cent")));
425 }
426 if rest.starts_with("pp") && ends_word(2) {
427 return Some((position, format!("{number} pp")));
428 }
429 if rest.starts_with('x') || rest.starts_with('×') {
430 let glued_sign = matches!(attached, Some('x') | Some('×')) && ends_word(1);
431 let compared = COMPARATIVES.contains(&next_word(1).as_str());
432 if glued_sign || compared {
433 return Some((position, format!("{number}x")));
434 }
435 }
436 if rest.starts_with("times ") && COMPARATIVES.contains(&next_word(5).as_str()) {
437 return Some((position, format!("{number} times")));
438 }
439 }
440 None
441}
442
443fn fraction_quantity(sentence: &str) -> Option<(usize, String)> {
447 let w = words(sentence);
448 if !w.iter().any(|(_, x)| SAVING.contains(&x.as_str())) {
449 return None;
450 }
451 let before = |k: usize| k.checked_sub(1).map(|p| w[p].1.as_str());
452 let noun = |k: usize| {
453 (w[k].1 == "halves" && matches!(before(k), Some("both" | "two")))
454 || (ORDINALS.contains(&w[k].1.as_str()) && before(k) == Some("the"))
455 };
456 let near = |k: usize| {
457 w[k.saturating_sub(NEAR)..(k + NEAR + 1).min(w.len())]
458 .iter()
459 .any(|(_, x)| vocabulary(x))
460 };
461 (0..w.len())
462 .find(|&k| FRACTIONS.contains(&w[k].1.as_str()) && !noun(k) && near(k))
463 .map(|k| w[k].clone())
464}
465
466fn quantity(sentence: &str) -> Option<(usize, String)> {
468 if !economic(sentence) {
469 return None;
470 }
471 match (numeric_quantity(sentence), fraction_quantity(sentence)) {
472 (Some(a), Some(b)) => Some(if b.0 < a.0 { b } else { a }),
473 (a, b) => a.or(b),
474 }
475}
476
477pub fn unsupported_quantity(line: &str) -> Option<String> {
498 sentence_spans(line)
499 .into_iter()
500 .find_map(|(_, s)| quantity(s))
501 .map(|(_, q)| q)
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct ProseQuantity {
516 pub line: usize,
518 pub quantity: String,
520 pub code: bool,
522}
523
524#[derive(Clone, Copy, PartialEq, Eq)]
526enum Syntax {
527 Markdown,
529 Toml,
531 Yaml,
533}
534
535fn syntax(path: &str) -> Syntax {
536 if path.ends_with(".toml") {
537 Syntax::Toml
538 } else if path.ends_with(".yaml") || path.ends_with(".yml") {
539 Syntax::Yaml
540 } else {
541 Syntax::Markdown
542 }
543}
544
545struct Paragraph<'a> {
548 lines: Vec<(usize, &'a str)>,
549 code: bool,
550}
551
552fn fence(t: &str) -> Option<(char, usize)> {
554 let c = t.chars().next().filter(|c| matches!(c, '`' | '~'))?;
555 let n = t.chars().take_while(|&x| x == c).count();
556 (n >= 3).then_some((c, n))
557}
558
559fn list_item(t: &str) -> bool {
560 if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
561 return rest.starts_with(' ');
562 }
563 let digits = t.chars().take_while(char::is_ascii_digit).count();
564 digits > 0 && (t[digits..].starts_with(". ") || t[digits..].starts_with(") "))
565}
566
567fn heading(t: &str) -> bool {
568 let rest = t.trim_start_matches('#');
569 rest.len() < t.len() && (rest.is_empty() || rest.starts_with(' '))
570}
571
572fn thematic_break(t: &str) -> bool {
573 t.chars().filter(|c| !c.is_whitespace()).count() >= 3
574 && t.chars()
575 .all(|c| matches!(c, '-' | '*' | '_' | '=') || c.is_whitespace())
576}
577
578fn key_line(t: &str, separator: char) -> bool {
579 t.split_once(separator).is_some_and(|(k, v)| {
580 let k = k.trim();
581 !k.is_empty()
582 && k.chars()
583 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '"'))
584 && (separator == '=' || v.is_empty() || v.starts_with(' '))
585 })
586}
587
588fn indent(line: &str) -> usize {
589 line.len() - line.trim_start().len()
590}
591
592fn text_paragraphs(text: &str, syntax: Syntax) -> Vec<Paragraph<'_>> {
597 let mut out = Vec::new();
598 let mut current: Vec<(usize, &str)> = Vec::new();
599 let mut current_comment = false;
600 let mut code: Vec<(usize, &str)> = Vec::new();
601 let mut open: Option<(char, usize)> = None;
602 let mut front =
603 syntax == Syntax::Markdown && text.lines().next().map(str::trim_end) == Some("---");
604 fn flush<'a>(out: &mut Vec<Paragraph<'a>>, lines: &mut Vec<(usize, &'a str)>, code: bool) {
605 if !lines.is_empty() {
606 out.push(Paragraph {
607 lines: std::mem::take(lines),
608 code,
609 });
610 }
611 }
612 for (i, raw) in text.lines().enumerate() {
613 let n = i + 1;
614 let t = raw.trim();
615 if let Some((c, len)) = open {
616 if fence(t)
617 .is_some_and(|(c2, len2)| c2 == c && len2 >= len && t.chars().all(|x| x == c))
618 {
619 flush(&mut out, &mut code, true);
620 open = None;
621 } else if !t.is_empty() {
622 code.push((n, t));
623 }
624 continue;
625 }
626 if front {
627 if n > 1 && t == "---" {
628 flush(&mut out, &mut current, false);
629 front = false;
630 } else if n > 1 && !t.is_empty() {
631 if key_line(t, ':') || list_item(t) {
632 flush(&mut out, &mut current, false);
633 }
634 current.push((n, t));
635 } else if t.is_empty() {
636 flush(&mut out, &mut current, false);
637 }
638 continue;
639 }
640 if t.is_empty() {
641 flush(&mut out, &mut current, false);
642 continue;
643 }
644 if syntax == Syntax::Markdown {
645 if let Some(f) = fence(t) {
646 flush(&mut out, &mut current, false);
647 open = Some(f);
648 continue;
649 }
650 if thematic_break(t) {
651 flush(&mut out, &mut current, false);
652 continue;
653 }
654 if t.starts_with('|') || heading(t) {
655 flush(&mut out, &mut current, false);
656 out.push(Paragraph {
657 lines: vec![(n, t)],
658 code: false,
659 });
660 continue;
661 }
662 if list_item(t) {
663 flush(&mut out, &mut current, false);
664 }
665 current.push((n, t));
666 continue;
667 }
668 if let Some(comment) = t.strip_prefix('#') {
670 let comment = comment.trim();
671 if !current_comment || list_item(comment) {
672 flush(&mut out, &mut current, false);
673 }
674 current_comment = true;
675 if !comment.is_empty() {
676 current.push((n, comment));
677 }
678 continue;
679 }
680 if t.starts_with('[') && t.ends_with(']') && !t.contains('=') && !t.contains('"') {
681 flush(&mut out, &mut current, false);
682 current_comment = false;
683 continue;
684 }
685 if current_comment || key_line(t, '=') {
686 flush(&mut out, &mut current, false);
687 }
688 current_comment = false;
689 current.push((n, t));
690 }
691 flush(&mut out, &mut current, false);
692 flush(&mut out, &mut code, true);
693 out
694}
695
696fn yaml_paragraphs(text: &str) -> Vec<Paragraph<'_>> {
699 let lines: Vec<&str> = text.lines().collect();
700 let mut out = Vec::new();
701 let mut i = 0;
702 while i < lines.len() {
703 let raw = lines[i];
704 let mut t = raw.trim_start();
705 let mut key_indent = indent(raw);
706 if let Some(rest) = t.strip_prefix("- ") {
707 key_indent += 2 + indent(rest);
708 t = rest.trim_start();
709 }
710 let value = t.strip_prefix("claim:").or_else(|| t.strip_prefix("note:"));
711 let Some(value) = value else {
712 i += 1;
713 continue;
714 };
715 let value = value.trim();
716 let mut paragraph = Vec::new();
717 if !value.is_empty() && !value.chars().all(|c| matches!(c, '>' | '|' | '-' | '+')) {
718 paragraph.push((i + 1, value));
719 }
720 i += 1;
721 while i < lines.len() {
722 let next = lines[i];
723 let nt = next.trim();
724 if nt.is_empty() || nt.starts_with('#') || indent(next) <= key_indent {
725 break;
726 }
727 paragraph.push((i + 1, nt));
728 i += 1;
729 }
730 if !paragraph.is_empty() {
731 out.push(Paragraph {
732 lines: paragraph,
733 code: false,
734 });
735 }
736 }
737 out
738}
739
740fn paragraph_quantities(p: &Paragraph<'_>) -> Vec<ProseQuantity> {
743 let mut joined = String::new();
744 let mut starts: Vec<(usize, usize)> = Vec::new();
745 for &(n, text) in &p.lines {
746 if !joined.is_empty() {
747 joined.push(' ');
748 }
749 starts.push((joined.len(), n));
750 joined.push_str(text);
751 }
752 sentence_spans(&joined)
753 .into_iter()
754 .filter_map(|(at, s)| quantity(s).map(|(within, q)| (at + within, q)))
755 .map(|(position, quantity)| ProseQuantity {
756 line: starts
757 .iter()
758 .rev()
759 .find(|(start, _)| *start <= position)
760 .map_or(p.lines[0].0, |&(_, n)| n),
761 quantity,
762 code: p.code,
763 })
764 .collect()
765}
766
767pub fn prose_quantities(path: &str, text: &str) -> Vec<ProseQuantity> {
788 let paragraphs = match syntax(path) {
789 Syntax::Yaml => yaml_paragraphs(text),
790 s => text_paragraphs(text, s),
791 };
792 paragraphs.iter().flat_map(paragraph_quantities).collect()
793}
794
795fn generated_artifacts(root: &Path) -> BTreeSet<String> {
798 std::fs::read_to_string(root.join(MANIFEST))
799 .ok()
800 .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
801 .and_then(|v| {
802 v.get("artifacts").and_then(|a| a.as_array()).map(|a| {
803 a.iter()
804 .filter_map(|x| x.get("path").and_then(|p| p.as_str()).map(str::to_string))
805 .collect()
806 })
807 })
808 .unwrap_or_default()
809}
810
811fn is_generated(path: &str, text: &str, manifest: &BTreeSet<String>) -> bool {
816 let first = text
817 .lines()
818 .next()
819 .unwrap_or("")
820 .trim_start_matches('\u{feff}')
821 .trim_start();
822 manifest.contains(path)
823 || GENERATED_DIRS.iter().any(|g| path.starts_with(g))
824 || STAMPS.iter().any(|s| first.starts_with(s))
825}
826
827fn scan_failed(path: &str, detail: String) -> EconomicsFinding {
828 EconomicsFinding {
829 kind: "scan_failed".into(),
830 path: path.into(),
831 line: None,
832 detail,
833 }
834}
835
836fn read_text(path: &Path) -> Result<Option<String>, String> {
839 match std::fs::read(path) {
840 Ok(bytes) if bytes.contains(&0) => Ok(None),
841 Ok(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
842 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
843 Err(e) => Err(e.to_string()),
844 }
845}
846
847pub fn scan_prose(root: &Path) -> (usize, Vec<EconomicsFinding>) {
886 let mut scanned = 0;
887 let mut findings = Vec::new();
888 match crate::git::ls_files_any(root, PROSE) {
889 Err(e) => findings.push(scan_failed(
890 ".",
891 format!(
892 "git could not list the tracked prose ({e}): nothing was scanned, so nothing can be said to be free of typed numbers about tokens"
893 ),
894 )),
895 Ok(files) => {
896 let manifest = generated_artifacts(root);
897 for path in files {
898 let text = match read_text(&root.join(&path)) {
899 Ok(Some(t)) => t,
900 Ok(None) => continue,
901 Err(e) => {
902 findings.push(scan_failed(&path, format!("{path} could not be read ({e}), so it was not scanned")));
903 continue;
904 }
905 };
906 if is_generated(&path, &text, &manifest) {
907 continue;
908 }
909 scanned += 1;
910 for q in prose_quantities(&path, &text) {
911 findings.push(EconomicsFinding {
912 kind: "unsupported_quantity".into(),
913 path: path.clone(),
914 line: Some(q.line),
915 detail: format!(
916 "{} next to the economics vocabulary{}: numbers about tokens, context or cost are generated from recorded evidence (majordomus economics), never typed",
917 q.quantity,
918 if q.code { " in a code block" } else { "" }
919 ),
920 });
921 }
922 }
923 }
924 }
925 match read_text(&root.join(CLAIMS)) {
927 Ok(None) => {}
928 Err(e) => findings.push(scan_failed(
929 CLAIMS,
930 format!("{CLAIMS} could not be read ({e}), so its claim sentences were not scanned"),
931 )),
932 Ok(Some(text)) => {
933 scanned += 1;
934 for q in prose_quantities(CLAIMS, &text) {
935 findings.push(EconomicsFinding {
936 kind: "unsupported_quantity".into(),
937 path: CLAIMS.into(),
938 line: Some(q.line),
939 detail: format!(
940 "{} in a claim sentence next to the economics vocabulary: a claim about tokens is bound to a metric in the economics methodology instead",
941 q.quantity
942 ),
943 });
944 }
945 }
946 }
947 (scanned, findings)
948}
949
950struct Claim {
952 id: String,
953 status: String,
954 sentence: String,
955}
956
957struct Matrix {
960 claims: Vec<Claim>,
961 lines: BTreeMap<String, Vec<usize>>,
962}
963
964fn read_claims(root: &Path) -> Result<Option<Matrix>, String> {
967 let text = match std::fs::read_to_string(root.join(CLAIMS)) {
968 Ok(t) => t,
969 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
970 Err(e) => return Err(e.to_string()),
971 };
972 let map = crate::metadata::yaml::parse_mapping(&text)?;
973 let field = |c: &serde_json::Value, k: &str| {
974 c.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string()
975 };
976 let claims = match map.get("claims") {
977 None => Vec::new(),
978 Some(v) => v
979 .as_array()
980 .ok_or("`claims` is not a list")?
981 .iter()
982 .map(|c| Claim {
983 id: field(c, "id"),
984 status: field(c, "status"),
985 sentence: field(c, "claim"),
986 })
987 .collect(),
988 };
989 let mut lines: BTreeMap<String, Vec<usize>> = BTreeMap::new();
992 let mut inside = false;
993 let mut item: Option<usize> = None;
994 for (i, raw) in text.lines().enumerate() {
995 if !raw.starts_with([' ', '-', '#']) && !raw.trim().is_empty() {
996 inside = raw.split(" #").next().unwrap_or("").trim_end() == "claims:";
997 item = None;
998 continue;
999 }
1000 let mut t = raw.trim_start();
1001 let mut key = indent(raw);
1002 if let Some(rest) = t.strip_prefix("- ") {
1003 key += 2 + indent(rest);
1004 t = rest.trim_start();
1005 item.get_or_insert(key);
1006 }
1007 if let (true, Some(id)) = (inside && item == Some(key), t.strip_prefix("id:")) {
1008 let id = id
1009 .split(" #")
1010 .next()
1011 .unwrap_or("")
1012 .trim()
1013 .trim_matches(['\'', '"']);
1014 lines.entry(id.to_string()).or_default().push(i + 1);
1015 }
1016 }
1017 Ok(Some(Matrix { claims, lines }))
1018}
1019
1020fn claim_finding(kind: &str, line: Option<usize>, detail: String) -> EconomicsFinding {
1021 EconomicsFinding {
1022 kind: kind.into(),
1023 path: CLAIMS.into(),
1024 line,
1025 detail,
1026 }
1027}
1028
1029fn status_name(metric: Option<&EconomicsMetric>) -> String {
1031 metric
1032 .and_then(|m| serde_json::to_value(m.status).ok())
1033 .and_then(|v| v.as_str().map(str::to_string))
1034 .unwrap_or_else(|| "absent".into())
1035}
1036
1037fn not_current(metric: &EconomicsMetric, summary: &EconomicsSummary) -> Vec<String> {
1041 let names: Vec<&str> = metric
1042 .suite
1043 .split(',')
1044 .map(str::trim)
1045 .filter(|s| !s.is_empty())
1046 .collect();
1047 if names.is_empty() {
1048 return vec!["no suite".into()];
1049 }
1050 names
1051 .into_iter()
1052 .filter_map(|n| match summary.suites.iter().find(|s| s.id == n) {
1053 Some(s) if s.freshness == EconomicsFreshness::Current => None,
1054 Some(s) => Some(format!(
1055 "{n} {}",
1056 serde_json::to_value(s.freshness)
1057 .ok()
1058 .and_then(|v| v.as_str().map(str::to_string))
1059 .unwrap_or_default()
1060 )),
1061 None => Some(format!("{n} undeclared")),
1062 })
1063 .collect()
1064}
1065
1066pub fn check_claims(root: &Path, summary: &EconomicsSummary) -> (usize, Vec<EconomicsFinding>) {
1094 let mut findings = Vec::new();
1095 let bindings = match super::load(root) {
1096 Ok(Some(decl)) => Some(decl.methodology.claims),
1097 Ok(None) => Some(Vec::new()),
1098 Err(errors) => {
1099 let errors = if errors.is_empty() {
1100 vec!["the declarations could not be read".to_string()]
1101 } else {
1102 errors
1103 };
1104 for e in errors {
1105 let path = e
1106 .split_once(": ")
1107 .map(|(p, _)| p)
1108 .filter(|p| p.starts_with(super::DIR))
1109 .unwrap_or(super::DIR)
1110 .to_string();
1111 findings.push(EconomicsFinding {
1112 kind: "declarations_unreadable".into(),
1113 path,
1114 line: None,
1115 detail: format!("{e}: the claim bindings cannot be read, so no bound claim can be shown to stand"),
1116 });
1117 }
1118 None
1119 }
1120 };
1121 let Matrix { claims, lines } = match read_claims(root) {
1122 Ok(Some(m)) => m,
1123 Ok(None) => Matrix {
1124 claims: Vec::new(),
1125 lines: BTreeMap::new(),
1126 },
1127 Err(e) => {
1128 findings.push(claim_finding(
1129 "claims_unreadable",
1130 None,
1131 format!("{CLAIMS} could not be read ({e}), so no claim in it can be checked"),
1132 ));
1133 return (bindings.map_or(0, |b| b.len()), findings);
1134 }
1135 };
1136 let line_of = |id: &str, nth: usize| lines.get(id).and_then(|l| l.get(nth)).copied();
1137
1138 let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
1139 for c in &claims {
1140 let count = seen.entry(c.id.as_str()).or_default();
1141 *count += 1;
1142 if *count > 1 {
1143 findings.push(claim_finding(
1144 "claim_duplicate",
1145 line_of(&c.id, *count - 1),
1146 format!(
1147 "claim id {} appears more than once in {CLAIMS}: a binding, a page and a gate would each read whichever copy they found first; give each claim its own id",
1148 c.id
1149 ),
1150 ));
1151 }
1152 }
1153
1154 let Some(bindings) = bindings else {
1155 return (0, findings);
1156 };
1157 let guaranteed = |id: &str| {
1158 claims
1159 .iter()
1160 .any(|c| c.id == id && c.status == "guaranteed")
1161 };
1162 for b in &bindings {
1163 if !claims.iter().any(|c| c.id == b.claim) {
1164 findings.push(claim_finding(
1165 "claim_unsupported",
1166 None,
1167 format!(
1168 "the methodology binds claim {} to {}, and {CLAIMS} has no such claim",
1169 b.claim, b.metric
1170 ),
1171 ));
1172 continue;
1173 }
1174 if !guaranteed(&b.claim) {
1175 continue;
1176 }
1177 let metric = summary.metrics.iter().find(|m| m.id == b.metric);
1178 let stale = metric.map(|m| not_current(m, summary)).unwrap_or_default();
1179 let stands = match (b.requires.as_str(), metric.map(|m| m.status)) {
1180 ("verified", Some(EconomicsMetricStatus::Verified)) => {
1181 summary.verdict.publishable && stale.is_empty()
1182 }
1183 (
1184 "measured",
1185 Some(EconomicsMetricStatus::Measured | EconomicsMetricStatus::Verified),
1186 ) => stale.is_empty(),
1187 _ => false,
1188 };
1189 if !stands {
1190 let mut why = Vec::new();
1191 if !stale.is_empty() {
1192 why.push(format!("evidence not current: {}", stale.join(", ")));
1193 }
1194 if b.requires == "verified" && !summary.verdict.publishable {
1195 why.push("verdict not publishable".to_string());
1196 }
1197 let why = if why.is_empty() {
1198 String::new()
1199 } else {
1200 format!(" ({})", why.join("; "))
1201 };
1202 findings.push(claim_finding(
1203 "claim_unsupported",
1204 line_of(&b.claim, 0),
1205 format!(
1206 "claim {} is guaranteed, but its metric {} is {} and must be {} and current{why}: set the claim to planned, or record the evidence",
1207 b.claim,
1208 b.metric,
1209 status_name(metric),
1210 b.requires
1211 ),
1212 ));
1213 }
1214 }
1215
1216 let mut reported = BTreeSet::new();
1217 for c in &claims {
1218 let bound = bindings.iter().any(|b| b.claim == c.id);
1219 let says_saving = sentence_spans(&c.sentence)
1220 .into_iter()
1221 .any(|(_, s)| saving_claim(s));
1222 if c.status == "guaranteed" && says_saving && !bound && reported.insert(c.id.as_str()) {
1223 findings.push(claim_finding(
1224 "claim_unbound",
1225 line_of(&c.id, 0),
1226 format!(
1227 "claim {} is guaranteed and says tokens, context or cost go down, and no binding in {}/methodology.yaml ties it to a metric: bind it, or set it to planned",
1228 c.id,
1229 super::DIR
1230 ),
1231 ));
1232 }
1233 }
1234 (bindings.len(), findings)
1235}
1236
1237pub fn check(root: &Path) -> EconomicsCheckReport {
1258 let summary = super::summarize(root, &Default::default());
1259 let (scanned, mut findings) = scan_prose(root);
1260 let (claims_checked, more) = check_claims(root, &summary);
1261 findings.extend(more);
1262 EconomicsCheckReport {
1263 ok: findings.is_empty(),
1264 scanned,
1265 claims_checked,
1266 findings,
1267 }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273
1274 #[test]
1275 fn the_shapes_a_savings_claim_takes_are_all_caught() {
1276 for line in [
1277 "Majordomus saves 50% of tokens",
1278 "cuts token spend by 44.2 %",
1279 "uses 3x fewer tokens",
1280 "3 × cheaper per task",
1281 "reduces context by 60 percent",
1282 "tokens: 2 times fewer",
1283 "Majordomus reduces the amount of context loaded into each coding session by 50%.",
1284 "| context reduction | 61.4% |",
1285 "total-token use falls by 30%",
1286 "a token-saving of 30%",
1287 "cuts context by 60 per cent",
1288 "token usage fell 12 percentage points",
1289 "token usage fell 12pp",
1290 "token usage fell 12 pp.",
1291 "saves 50% of tokens",
1292 "saves **50%** of tokens",
1293 "saves __50%__ of tokens",
1294 "saves 50\u{a0}% of tokens",
1295 "halves the token bill",
1296 "context is cut to a third",
1297 "uses half the tokens, and fewer calls",
1298 "twice as many tokens are saved",
1299 "two thirds of the tokens are saved",
1300 "Majordomus cuts tokens, e.g. by 40% on bug fixes.",
1301 "the prompt's size falls 30%",
1302 "cost\u{2014}40% lower",
1303 ] {
1304 assert!(unsupported_quantity(line).is_some(), "missed: {line}");
1305 }
1306 }
1307
1308 #[test]
1309 fn a_savings_claim_needs_a_word_of_each_list_unless_one_word_says_both() {
1310 assert!(saving_claim("Majordomus saves tokens"));
1311 assert!(saving_claim(
1312 "The compiler reduces the context a session loads"
1313 ));
1314 assert!(
1315 saving_claim("Sessions are cheaper"),
1316 "cheaper is about cost already"
1317 );
1318 assert!(saving_claim("The savings are real"));
1319 assert!(
1320 !saving_claim("Majordomus saves every decision it records"),
1321 "saves alone says nothing about tokens"
1322 );
1323 assert!(
1324 !saving_claim("Context is counted in tokens"),
1325 "nothing goes down"
1326 );
1327 }
1328
1329 #[test]
1330 fn ordinary_numbers_are_not_claims() {
1331 for line in [
1332 "The briefing is capped at 60 lines of context.",
1333 "coverage must stay above 80% for the crate",
1334 "a 5x5 grid of tokens",
1335 "context/v1 documents",
1336 "I0305 plans token telemetry",
1337 "The hex digest 3x7f of the token is stable.",
1338 "there is no `0.x` hatch at all, and breaking changes cost a major",
1339 "phase 648 ms 1 x validate:context",
1340 "The operator asked for coverage near 100 percent. It costs something.",
1341 "The other half of the context is rules.",
1342 "The token is read twice.",
1343 "a_50% token identifier",
1344 "12 ppm of tokens",
1345 "{%- if s.segments | length > 0 %} token {% endif %}",
1346 "It runs twice, without Majordomus and with it, and the provider's usage is recorded; savings are refused.",
1347 "The test reads both halves of the prompt and fails when fewer are declared.",
1348 "The third session reduces nothing about how tokens are counted.",
1349 "Tokens are counted, etc. Coverage is 80%.",
1350 ] {
1351 assert!(unsupported_quantity(line).is_none(), "false positive: {line}");
1352 }
1353 }
1354
1355 #[test]
1356 fn a_sentence_wrapped_over_lines_is_one_sentence_reported_where_its_quantity_starts() {
1357 let found = prose_quantities(
1358 "a.md",
1359 "Majordomus cuts the token\nbill by 40% on each task.\n",
1360 );
1361 assert_eq!(
1362 found,
1363 vec![ProseQuantity {
1364 line: 2,
1365 quantity: "40%".into(),
1366 code: false
1367 }]
1368 );
1369 let found = prose_quantities(
1370 "a.md",
1371 "It uses 40% fewer\nof the tokens a session would.\n",
1372 );
1373 assert_eq!(found[0].line, 1, "the quantity starts on the first line");
1374 let found = prose_quantities("a.md", "Coverage is 80%.\nTokens are counted.\n");
1375 assert!(found.is_empty(), "two sentences stay two: {found:?}");
1376 let found = prose_quantities("a.md", "Tokens are counted\n\n40% of runs are slow.\n");
1377 assert!(
1378 found.is_empty(),
1379 "a blank line ends the paragraph: {found:?}"
1380 );
1381 }
1382
1383 #[test]
1384 fn blocks_do_not_run_into_the_prose_around_them() {
1385 let text = "Tokens are counted\n```\n40% done\n```\n# Tokens\n40% of runs\n- tokens\n- 40% of runs\n";
1386 assert!(
1387 prose_quantities("a.md", text).is_empty(),
1388 "{:?}",
1389 prose_quantities("a.md", text)
1390 );
1391 let text = "Intro\n```sh\n# tokens\n# 40% fewer\n```\n";
1392 assert_eq!(
1393 prose_quantities("a.md", text),
1394 vec![ProseQuantity {
1395 line: 4,
1396 quantity: "40%".into(),
1397 code: true
1398 }],
1399 "a fenced block is a paragraph of its own"
1400 );
1401 let toml = "# Tokens are what\n# a session spends: 40% fewer.\n[hero]\ntitle = \"tokens\"\nlead = \"40% faster\"\n";
1402 let found = prose_quantities("site/data/x.toml", toml);
1403 assert_eq!(found.len(), 1, "comments join, keys do not: {found:?}");
1404 assert_eq!(found[0].line, 2);
1405 let front = "---\ntitle: tokens\nsummary: 40% faster\n---\nBody.\n";
1406 assert!(
1407 prose_quantities("a.md", front).is_empty(),
1408 "each front-matter key is its own"
1409 );
1410 }
1411
1412 #[test]
1413 fn a_generated_file_is_known_by_provenance_not_by_its_prose() {
1414 let manifest: BTreeSet<String> = ["AGENTS.md".to_string()].into();
1415 assert!(is_generated("AGENTS.md", "hand-written?", &manifest));
1416 assert!(is_generated("site/content/x.md", "", &manifest));
1417 assert!(is_generated(
1418 "docs/generated/x.md",
1419 "<!-- GENERATED FILE — DO NOT EDIT\n",
1420 &manifest
1421 ));
1422 assert!(is_generated("x.rs", "// GENERATED FILE\n", &manifest));
1423 assert!(
1424 !is_generated("docs/generated/x.md", "# Report\n", &manifest),
1425 "the directory alone says nothing"
1426 );
1427 assert!(
1428 !is_generated(
1429 "docs/NOTES.md",
1430 "# Notes\n\nThis was generated by hand.\n",
1431 &manifest
1432 ),
1433 "a prose line is not a stamp"
1434 );
1435 assert!(
1436 !is_generated(
1437 "docs/NOTES.md",
1438 "# Notes\n<!-- generated by x -->\n",
1439 &manifest
1440 ),
1441 "only the first line"
1442 );
1443 }
1444
1445 const METHODOLOGY: &str = "\
1448schema: economics-methodology/v1
1449version: 1
1450title: fixture
1451question: how many tokens
1452unit: tokens per task
1453primary_metric: effective_token_reduction
1454classes: []
1455variants: []
1456success: []
1457pairing:
1458 key: [suite, task, repetition]
1459 comparable: []
1460 valid: both runs succeeded
1461statistics:
1462 per_pair: reduction
1463 location: median
1464 interval: bootstrap
1465 confidence_bp: 9500
1466 resamples: 100
1467 seed: 1
1468 min_pairs_for_interval: 5
1469publication:
1470 min_valid_pairs: 30
1471 min_categories: 4
1472 min_pairs_per_category: 5
1473 min_repetitions: 3
1474 min_valid_pair_rate_bp: 8000
1475 max_interval_width_bp: 2000
1476 require_current: true
1477outliers:
1478 rule: no run is excluded
1479claims:
1480 - claim: guaranteed-early
1481 metric: effective_token_reduction
1482 requires: verified
1483 - claim: still-planned
1484 metric: effective_token_reduction
1485 requires: verified
1486 - claim: never-written
1487 metric: context_reduction_ratio
1488 requires: measured
1489";
1490
1491 fn git(root: &Path, args: &[&str]) {
1492 let status = std::process::Command::new("git")
1493 .env_remove("GIT_DIR")
1494 .env_remove("GIT_WORK_TREE")
1495 .env_remove("GIT_INDEX_FILE")
1496 .arg("-C")
1497 .arg(root)
1498 .args(args)
1499 .status()
1500 .unwrap();
1501 assert!(status.success(), "git {args:?}");
1502 }
1503
1504 fn repository(claims: &str) -> tempfile::TempDir {
1505 let dir = tempfile::tempdir().unwrap();
1506 let economics = dir.path().join(crate::economics::DIR);
1507 std::fs::create_dir_all(&economics).unwrap();
1508 std::fs::write(economics.join("methodology.yaml"), METHODOLOGY).unwrap();
1509 std::fs::create_dir_all(dir.path().join("docs")).unwrap();
1510 std::fs::write(dir.path().join("docs/CLAIMS.yaml"), claims).unwrap();
1511 git(dir.path(), &["init", "-q"]);
1512 dir
1513 }
1514
1515 #[test]
1516 fn a_guaranteed_claim_without_standing_evidence_is_refused() {
1517 let dir = repository(
1518 "claims:\n - id: guaranteed-early\n status: guaranteed\n \
1519 - id: still-planned\n status: planned\n",
1520 );
1521 let summary = crate::economics::summarize(dir.path(), &Default::default());
1522 assert!(
1523 summary.present,
1524 "the fixture must load: {:?}",
1525 summary.diagnostics
1526 );
1527 let (checked, findings) = check_claims(dir.path(), &summary);
1528 assert_eq!(
1529 checked, 3,
1530 "every binding is counted, whatever its claim's status"
1531 );
1532 let details: Vec<&str> = findings.iter().map(|f| f.detail.as_str()).collect();
1533 assert_eq!(findings.len(), 2, "{details:?}");
1534 assert!(findings.iter().all(|f| f.kind == "claim_unsupported"));
1535 assert!(details
1536 .iter()
1537 .any(|d| d.starts_with("claim guaranteed-early is guaranteed")));
1538 assert_eq!(
1539 findings
1540 .iter()
1541 .find(|f| f.detail.contains("guaranteed-early"))
1542 .unwrap()
1543 .line,
1544 Some(2),
1545 "the claim's own line"
1546 );
1547 assert!(details
1548 .iter()
1549 .any(|d| d.contains("claim never-written") && d.contains("has no such claim")));
1550 assert!(
1551 !details.iter().any(|d| d.contains("still-planned")),
1552 "a planned claim promises nothing and is left alone"
1553 );
1554 }
1555
1556 #[test]
1557 fn a_verified_metric_on_stale_evidence_does_not_hold_a_guarantee() {
1558 let dir = repository(
1559 "claims:\n - id: guaranteed-early\n status: guaranteed\n \
1560 - id: still-planned\n status: planned\n - id: never-written\n status: planned\n",
1561 );
1562 let suite = [
1563 "schema: economics-suite/v1",
1564 "id: pilot",
1565 "version: 1",
1566 "kind: live",
1567 "title: Pilot",
1568 "control: baseline",
1569 "treatment: majordomus",
1570 "freshness_inputs: [methodology.yaml]",
1571 "",
1572 ]
1573 .join("\n");
1574 let suites = dir.path().join(crate::economics::DIR).join("suites");
1575 std::fs::create_dir_all(&suites).unwrap();
1576 std::fs::write(suites.join("pilot.yaml"), suite).unwrap();
1577 let mut summary = crate::economics::summarize(dir.path(), &Default::default());
1578 assert!(summary.present, "{:?}", summary.diagnostics);
1579 summary.verdict.publishable = true;
1581 for m in &mut summary.metrics {
1582 if m.id == crate::economics::EFFECTIVE_TOKEN_REDUCTION {
1583 m.status = EconomicsMetricStatus::Verified;
1584 }
1585 }
1586 let pilot = summary.suites.iter_mut().find(|s| s.id == "pilot").unwrap();
1587 pilot.freshness = EconomicsFreshness::Stale;
1588 let (_, findings) = check_claims(dir.path(), &summary);
1589 let refused = findings
1590 .iter()
1591 .find(|f| f.detail.contains("guaranteed-early"));
1592 assert!(
1593 refused.is_some_and(|f| f.detail.contains("(evidence not current: pilot stale)")),
1594 "{findings:?}"
1595 );
1596 summary
1597 .suites
1598 .iter_mut()
1599 .find(|s| s.id == "pilot")
1600 .unwrap()
1601 .freshness = EconomicsFreshness::Current;
1602 let (_, findings) = check_claims(dir.path(), &summary);
1603 assert!(
1604 findings.is_empty(),
1605 "verified, publishable and current stands: {findings:?}"
1606 );
1607 }
1608
1609 #[test]
1610 fn an_unbound_or_duplicated_savings_claim_is_refused() {
1611 let dir = repository(
1612 "claims:\n - id: guaranteed-early\n status: planned\n - id: still-planned\n status: planned\n \
1613 - id: never-written\n status: planned\n \
1614 - id: cheaper-sessions\n claim: Sessions spend fewer tokens with the compiler\n status: guaranteed\n \
1615 - id: counted\n claim: Context is counted in tokens\n status: guaranteed\n \
1616 - id: planned-saving\n claim: Majordomus will reduce token spend\n status: planned\n \
1617 - id: counted\n claim: again\n status: planned\n",
1618 );
1619 let summary = crate::economics::summarize(dir.path(), &Default::default());
1620 let (_, findings) = check_claims(dir.path(), &summary);
1621 let kinds: Vec<(&str, Option<usize>)> =
1622 findings.iter().map(|f| (f.kind.as_str(), f.line)).collect();
1623 assert_eq!(
1624 kinds,
1625 [("claim_duplicate", Some(17)), ("claim_unbound", Some(8))],
1626 "{findings:?}"
1627 );
1628 assert!(findings[1]
1629 .detail
1630 .starts_with("claim cheaper-sessions is guaranteed"));
1631 }
1632
1633 #[test]
1634 fn a_finding_names_the_line_of_the_claim_not_of_an_id_nested_in_it() {
1635 let dir = repository(
1636 "claims:\n - id: guaranteed-early\n status: planned\n evidence:\n - id: cheap\n \
1637 - id: still-planned\n status: planned\n - id: never-written\n status: planned\n \
1638 - id: cheap\n claim: Sessions spend fewer tokens\n status: guaranteed\n",
1639 );
1640 let summary = crate::economics::summarize(dir.path(), &Default::default());
1641 let (_, findings) = check_claims(dir.path(), &summary);
1642 let kinds: Vec<(&str, Option<usize>)> =
1643 findings.iter().map(|f| (f.kind.as_str(), f.line)).collect();
1644 assert_eq!(kinds, [("claim_unbound", Some(10))], "{findings:?}");
1645 }
1646
1647 #[test]
1648 fn declarations_that_cannot_be_read_fail_closed() {
1649 let dir = repository("claims: []\n");
1650 std::fs::write(
1651 dir.path()
1652 .join(crate::economics::DIR)
1653 .join("methodology.yaml"),
1654 "schema: x\n",
1655 )
1656 .unwrap();
1657 let summary = crate::economics::summarize(dir.path(), &Default::default());
1658 let (checked, findings) = check_claims(dir.path(), &summary);
1659 assert_eq!(checked, 0);
1660 assert_eq!(findings.len(), 1, "{findings:?}");
1661 assert_eq!(findings[0].kind, "declarations_unreadable");
1662 assert_eq!(
1663 findings[0].path,
1664 format!("{}/methodology.yaml", crate::economics::DIR)
1665 );
1666
1667 std::fs::write(dir.path().join("docs/CLAIMS.yaml"), "claims:\n\tbad\n").unwrap();
1668 let (_, findings) = check_claims(dir.path(), &summary);
1669 assert!(
1670 findings.iter().any(|f| f.kind == "claims_unreadable"),
1671 "{findings:?}"
1672 );
1673 }
1674
1675 #[test]
1676 fn check_joins_every_check_and_is_ok_only_without_findings() {
1677 let dir = repository(
1678 "claims:\n - id: guaranteed-early\n status: planned\n \
1679 - id: still-planned\n status: planned\n",
1680 );
1681 let report = check(dir.path());
1682 assert_eq!((report.scanned, report.claims_checked), (1, 3));
1683 assert_eq!(
1684 report.findings.len(),
1685 1,
1686 "only the unwritten claim: {:?}",
1687 report.findings
1688 );
1689 assert!(!report.ok);
1690 }
1691
1692 #[test]
1693 fn a_repository_git_cannot_list_is_not_a_clean_one() {
1694 let dir = tempfile::tempdir().unwrap();
1695 let (scanned, findings) = scan_prose(dir.path());
1696 assert_eq!(scanned, 0);
1697 assert_eq!(findings.len(), 1);
1698 assert_eq!(
1699 (findings[0].kind.as_str(), findings[0].path.as_str()),
1700 ("scan_failed", ".")
1701 );
1702 }
1703}