diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index cdf80989e..7e4563f5f 100644 --- a/.claude/skills/ci-gates/SKILL.md +++ b/.claude/skills/ci-gates/SKILL.md @@ -11440,3 +11440,72 @@ lines changed, 20 of them deletions, for a two-line wiring change. **The check that catches it is the shape of the diff, not the list of files.** A wiring change that adds a subcommand has no deletions; when the stat shows some, the question is what else the formatter decided while it was in there. + +## 448. Subtract sets, not strings -- and the gap was an undocumented threshold + +§446 shipped **121** where an independent probe said **123**, and said the gap +was not resolved. Four attempts to locate it had failed, and every one failed +the same way: the comparison matched **truncated titles**. That is a defect in +the comparison, not in either reader, and it survives four tries because a +title-prefix match is *almost* right -- it finds most rows and quietly misses +the ones whose text was cut differently. + +The fix is one flag. `tri skill claims --numbers` prints one +`:` per counted section and nothing else, so the comparison is + +```sh +comm -13 <(sort -n rust.txt) <(sort -n probe.txt) +``` + +Two lines of output, first try: sections **54** and **303**. + +**When two readers of one population disagree, compare the IDENTITIES they +counted, not renderings of them.** A count has an index; use it. Every failed +attempt here was a string comparison standing in for a set operation. + +**And the cause was worth the hunt.** Both sections carry a *single* digit -- +"the gate that exited 0", "Typecheck FAILED, exit 0" -- and `carries()` requires +a run of **two or more**. That threshold was in the code with no comment, doing +work the documented rule (a word boundary on both sides) does not claim. + +Measured on 485 open issues: the threshold excludes **20 titles**, and they are +not one kind of thing. + +* About a dozen state a **count**: *"`implies` appears 9 times in live source + and 0 times in the compiler"*, *"MAX_SORRY counts 5 admitted proofs; 4 are in + files nothing compiles"*, *"4 of 7 passes have no precondition"*. +* The rest state a **value**: an exit code (`seal exits 0`), a literal (`the + lexer turns 0o777 into 0`), arithmetic (`-3/2 is -1, -3>>1 is -2`). + +So the threshold is a crude proxy for *not a value*: wrong in one direction, and +dropping it takes the population **288 → 308** while admitting about eight +titles that count nothing. It stays -- and it is now **printed**, with +`--single` to list what it removes. + +**A silent threshold makes a population read as complete.** The repair for a +rule you cannot cleanly justify is not always to delete it; where the +alternative is measurably worse, state it, size it, and let the reader see the +set it removed. + +## 449. Widening a population is safe exactly when you measured it first + +§446 named three tracked `SKILL.md` files outside `.claude/skills` and did not +read them, and the option written for the next pass said widening was blocked on +a question: *are they copies, forks, or the originals?* -- because counting +copies would double every figure. + +Measured, in two commands: `.agents/skills/phi-loop/SKILL.md` and +`.agents/skills/tri-pipeline/SKILL.md` are **byte-identical** to their +`.claude/skills` counterparts, and all three unread files carry **zero** +numbered sections. So the worry was about an empty set: widening the walk to +every tracked `SKILL.md` leaves the figure count **unchanged at 123**. + +The widening shipped, and so did the guard the worry deserved: byte-identical +files are detected, **named in the output**, and counted once. They contribute +nothing today; the day one of them gains a section, the count would otherwise +double it in silence. + +**The pattern: a blocked option is often blocked on a measurement rather than a +decision.** Two `shasum` calls and a `grep -c` turned "this needs an owner's +call about which directory is authoritative" into "this changes nothing, and +here is the guard for the day it would". diff --git a/cli/tri/src/issues.rs b/cli/tri/src/issues.rs index 39f7084b4..669f17598 100644 --- a/cli/tri/src/issues.rs +++ b/cli/tri/src/issues.rs @@ -35,6 +35,9 @@ pub enum IssuesCmd { }, /// Open issues that state a COUNT in the title, and a reproducible sample. Numbers { + /// Print the titles excluded by the two-digit rule alone. + #[arg(long)] + single: bool, /// Print a systematic sample of this size. 0 prints only the population. #[arg(long, default_value_t = 0)] sample: usize, @@ -164,6 +167,48 @@ pub fn named_workflows(text: &str, keys: &BTreeMap) -> Vec>1 is -2`). +/// +/// So the threshold is a crude proxy for *not a value*, wrong in one direction, +/// and removing it takes the population from **288 to 308** while adding about +/// eight titles that state no count. It is kept -- and it is now **printed**. +/// A silent threshold makes 288 read as the whole population; a stated one +/// makes it read as 288 plus a named 20 that a reader can judge. +pub fn single_digit_only(title: &str) -> bool { + let t = strip_addresses(title); + let c: Vec = t.chars().collect(); + let (mut i, mut one, mut two) = (0usize, false, false); + while i < c.len() { + if !c[i].is_ascii_digit() { + i += 1; + continue; + } + let start = i; + while i < c.len() && c[i].is_ascii_digit() { + i += 1; + } + let left = start == 0 || !c[start - 1].is_alphanumeric(); + let right = i >= c.len() || !c[i].is_alphanumeric(); + if left && right { + if i - start >= 2 { + two = true; + } else { + one = true; + } + } + } + one && !two && !NUMERALS.iter().any(|w| has_word(&t, w)) +} + /// What kind of number a title carries. /// /// The distinction is the whole point. `Wave Loop 369` and `#2841` and `Prop. 65` @@ -341,6 +386,9 @@ pub fn carries(title: &str) -> Carries { if i - start >= 2 && left && right { found = true; } + // A single digit with the boundary satisfied is NOT counted here. + // The threshold is deliberate and measured; `single_digit_only` + // carries the reason and the twenty titles it excludes. } found }; @@ -365,7 +413,7 @@ pub fn carries(title: &str) -> Carries { /// sample is SYSTEMATIC -- every k-th issue by ascending number -- rather than /// chosen. Nothing here is random: run it next month and the overlap is exact /// wherever the backlog has not moved. -fn numbers(sample: usize, limit: usize) -> Result<()> { +fn numbers(sample: usize, limit: usize, single: bool) -> Result<()> { let lim = limit.to_string(); let raw = gh(&[ "issue", @@ -413,6 +461,12 @@ fn numbers(sample: usize, limit: usize) -> Result<()> { c(Carries::QuantifierOnly) ); println!(" no figure {}", c(Carries::None)); + let singles: Vec<&(u64, String, Carries)> = + rows.iter().filter(|r| single_digit_only(&r.1)).collect(); + println!( + " single-digit only, excluded {} (--single prints them)", + singles.len() + ); println!( "\n An ADDRESS is not a count. `#2841`, `Wave Loop 369`, `Prop. 65`, `w699`\n \ @@ -428,6 +482,26 @@ fn numbers(sample: usize, limit: usize) -> Result<()> { c(Carries::Words) ); + println!( + "\n The digit rule requires TWO or more digits, and that threshold was\n \ + never written down. Measured: it excludes {} titles, and they are not\n \ + one kind of thing. Roughly a dozen state a count -- \"`implies` appears\n \ + 9 times in live source and 0 times in the compiler\" -- and the rest\n \ + state a VALUE: an exit code, a literal, arithmetic. Dropping the\n \ + threshold takes the population to {} and admits about eight titles that\n \ + count nothing. It is kept, and now it is PRINTED: a silent threshold\n \ + makes this population read as complete.", + singles.len(), + pop.len() + singles.len() + ); + + if single { + println!("\n EXCLUDED BY THE TWO-DIGIT RULE ALONE:\n"); + for (n, t, _) in singles.iter().copied() { + println!(" #{n} {}", &t[..t.len().min(88)]); + } + } + if sample > 0 { let k = pop.len().checked_div(sample).unwrap_or(1).max(1); let picked: Vec<&&(u64, String, Carries)> = pop.iter().step_by(k).take(sample).collect(); @@ -491,7 +565,11 @@ struct Row { pub fn run(cmd: &IssuesCmd) -> Result<()> { let limit = match cmd { - IssuesCmd::Numbers { sample, limit } => return numbers(*sample, *limit), + IssuesCmd::Numbers { + sample, + limit, + single, + } => return numbers(*sample, *limit, *single), IssuesCmd::Dated { limit, list } => return dated(*limit, *list), IssuesCmd::Stale { limit } => limit, }; @@ -1103,3 +1181,65 @@ mod dated_tests { assert_eq!(anchor_of("plain prose", 1), Anchor::Answered); } } + +#[cfg(test)] +mod single_digit_tests { + use super::*; + + /// The twenty titles the two-digit threshold removes, in miniature. + #[test] + fn a_lone_digit_is_excluded_and_said_so() { + // Real ones from this backlog: counts the population does not carry. + assert!(single_digit_only( + "t27c seal exits 0 on a spec every backend rejects" + )); + assert!(single_digit_only("4 of 7 passes have no precondition")); + assert!(single_digit_only("parser-fix blocker drops 6 -> 5")); + } + + #[test] + fn a_two_digit_run_anywhere_takes_it_out_of_the_excluded_set() { + // It is IN the population, so it is not what this reports. + assert!(!single_digit_only("5 of 36 gates pass an empty tree")); + assert!(!single_digit_only("283 titles state a count")); + } + + #[test] + fn a_numeral_word_takes_it_out_too() { + // Already counted as `Words`; reporting it as excluded would double it. + assert!(!single_digit_only("Nine live sites and 0 in the compiler")); + } + + #[test] + fn an_address_is_not_a_lone_digit() { + // `#2841` is stripped first; what remains states nothing. + assert!(!single_digit_only( + "Grep before you file -- #2964 duplicated #2822" + )); + assert!(!single_digit_only("Wave Loop 369 is an address")); + // A SINGLE-digit address is the case that actually exercises the + // stripping here: without it, `#7` reads as a lone figure. The + // four-digit examples above pass either way, which is why they are + // not a control on their own. + assert!(!single_digit_only("The gate refuses an empty tree -- #7")); + assert!(!single_digit_only("Prop. 5 is an address, not a count")); + } + + /// The two sets must not overlap, or the printed totals double-count. + #[test] + fn the_population_and_the_excluded_set_are_disjoint() { + for t in [ + "t27c seal exits 0 on a spec every backend rejects", + "4 of 7 passes have no precondition", + "5 of 36 gates pass an empty tree", + "Nine live sites and 0 in the compiler", + "#2964 duplicated #2822", + ] { + let in_pop = matches!(carries(t), Carries::Digits | Carries::Words | Carries::Both); + assert!( + !(in_pop && single_digit_only(t)), + "{t:?} is in both the population and the excluded set" + ); + } + } +} diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index fb5c1935f..74d06ae9e 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -316,6 +316,9 @@ enum SkillAction { /// Print every section in the free population, one line each. #[arg(long)] list: bool, + /// Print `:` for every counted section and nothing else. + #[arg(long)] + numbers: bool, }, Begin { #[arg(long)] @@ -880,8 +883,11 @@ fn main() -> Result<()> { SkillAction::Check { gaps } => { skillnum::run(&skillnum::SkillCmd::Check { gaps: *gaps })? } - SkillAction::Claims { list } => { - skillnum::run(&skillnum::SkillCmd::Claims { list: *list })? + SkillAction::Claims { list, numbers } => { + skillnum::run(&skillnum::SkillCmd::Claims { + list: *list, + numbers: *numbers, + })? } SkillAction::Begin { issue, desc } => cmd_skill_begin(&root, *issue, desc)?, SkillAction::End => cmd_skill_end(&root)?, diff --git a/cli/tri/src/skillnum.rs b/cli/tri/src/skillnum.rs index 8683aaa25..e2ad2a6ab 100644 --- a/cli/tri/src/skillnum.rs +++ b/cli/tri/src/skillnum.rs @@ -38,6 +38,13 @@ pub enum SkillCmd { /// Print every section in the free population, one line each. #[arg(long)] list: bool, + /// Print `:` for every section counted, and nothing + /// else. A second reader can then subtract SETS rather than strings: + /// four attempts to locate a two-section disagreement failed because + /// each compared truncated titles, which is a defect in the comparison + /// and not in either reader. + #[arg(long)] + numbers: bool, }, } @@ -135,7 +142,7 @@ fn skill_files(root: &std::path::Path) -> Vec { pub fn run(cmd: &SkillCmd) -> Result<()> { let show_gaps = match cmd { - SkillCmd::Claims { list } => return claims(*list), + SkillCmd::Claims { list, numbers } => return claims(*list, *numbers), SkillCmd::Check { gaps } => gaps, }; if *show_gaps { @@ -348,24 +355,63 @@ pub fn names_a_command(body: &str) -> bool { false } -fn claims(list: bool) -> Result<()> { +/// A path as the repository writes it. +fn rel(root: &std::path::Path, p: &std::path::Path) -> String { + p.strip_prefix(root) + .unwrap_or(p) + .to_string_lossy() + .to_string() +} + +fn claims(list: bool, numbers: bool) -> Result<()> { let root = repo_root()?; - let files = skill_files(&root); - // Name what is outside the population rather than leaving it silent: this - // reads `.claude/skills/*/SKILL.md`, and the repository tracks SKILL.md - // files under other roots that no command here has ever opened. - let mut unread: Vec = Vec::new(); + // Every tracked SKILL.md, not just `.claude/skills/*`. + // + // The previous version read that one directory and NAMED the three files + // outside it, which was honest and incomplete. Measured before widening: + // `.agents/skills/phi-loop/SKILL.md` and `.agents/skills/tri-pipeline/SKILL.md` + // are **byte-identical** to their `.claude/skills` counterparts -- copies, + // not forks -- and all three unread files carry **zero** numbered sections. + // So widening adds nothing to the figure count, which is exactly why it is + // safe to do and why the previous iteration's worry (that counting copies + // would double every figure) turned out to be about an empty set. + // + // Copies are still detected and named: they contribute nothing today, and + // the day one of them gains a section the count would double it silently. + let mut files = skill_files(&root); + let mut copies: Vec<(String, String)> = Vec::new(); if let Ok(out) = std::process::Command::new("git") .args(["ls-files", "*SKILL.md"]) .current_dir(&root) .output() { for line in String::from_utf8_lossy(&out.stdout).lines() { - if !line.starts_with(".claude/skills/") { - unread.push(line.to_string()); + let p = root.join(line); + if p.is_file() && !files.contains(&p) { + files.push(p); + } + } + } + files.sort(); + // Byte-identical duplicates: keep the first, name the rest. + let mut seen: BTreeMap = BTreeMap::new(); + let mut keep: Vec = Vec::new(); + for f in files { + let Ok(text) = std::fs::read_to_string(&f) else { + continue; + }; + let digest = format!("{}:{}", text.len(), text.lines().count()); + match seen.get(&digest) { + Some(first) if std::fs::read_to_string(first).ok().as_deref() == Some(&text) => { + copies.push((rel(&root, &f), rel(&root, first))); + } + _ => { + seen.insert(digest, f.clone()); + keep.push(f); } } } + let files = keep; if files.is_empty() { anyhow::bail!( "no SKILL.md under {}/.claude/skills -- nothing was read, and a zero \ @@ -408,6 +454,9 @@ fn claims(list: bool) -> Result<()> { } carrying += 1; fcar += 1; + if numbers { + println!("{name}:{n}"); + } if names_a_command(&body) { with_cmd += 1; } @@ -423,10 +472,13 @@ fn claims(list: bool) -> Result<()> { per_file.push((name, fsecs, fcar)); } + if numbers { + return Ok(()); + } println!("FIGURES IN THE KNOWLEDGE BASE, AND WHAT COULD RE-TAKE THEM\n"); println!(" SKILL.md files read {}", files.len()); - for f in &unread { - println!(" NOT read (outside .claude/skills): {f}"); + for (dup, first) in &copies { + println!(" byte-identical copy, counted once: {dup} == {first}"); } println!(" numbered sections {total}"); println!(" stating a figure {carrying}"); diff --git a/docs/now/2026-09-03-the-two-section-gap-closed-and-an-undocumented-threshold-fou.md b/docs/now/2026-09-03-the-two-section-gap-closed-and-an-undocumented-threshold-fou.md new file mode 100644 index 000000000..5b261acd8 --- /dev/null +++ b/docs/now/2026-09-03-the-two-section-gap-closed-and-an-undocumented-threshold-fou.md @@ -0,0 +1,7 @@ +# NOW -- The two-section gap closed, and an undocumented threshold found (2026-09-03) + +## The two-section gap closed, and an undocumented threshold found (Refs #2994) + +- tri skill claims --numbers prints section numbers so a second reader subtracts SETS, not strings: the 121-vs-123 gap is sections 54 and 303, and four earlier attempts failed only because they compared truncated titles. +- Both carry a single digit, and the shipped digit rule requires TWO. That threshold was never written down: measured, it excludes 20 open titles, about a dozen of which state a real count. Kept and now PRINTED, with --single to list them. +- tri skill claims now walks every tracked SKILL.md. Measured first: the .agents copies are byte-identical and carry zero numbered sections, so widening adds nothing -- and byte-identical copies are named and counted once.