From 8c91a9f446fea4330a7e5af6ed811d9501d4e41c Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Mon, 31 Aug 2026 03:15:39 +0700 Subject: [PATCH] feat(tri): an address is not a count, and a count is not always a digit Refs #2983 New `tri issues numbers`. Of 478 open issues, **283** state a COUNT in the title -- 128 in digits only, 98 in words only, 57 both. open issues read 478 count in digits only 128 count in words only 98 both 57 POPULATION 283 quantifier only, excluded 20 no figure 175 THIS CORRECTS A NUMBER I PUBLISHED ONE PASS AGO. "268 open issues carry a number in the title" came from a matcher that read any two-digit run, and it was wrong in BOTH directions at once: - 145 of the 329 loose hits were ADDRESSES -- `#2841`, `Wave Loop 369`, `Prop. 65`, `w699`, `CI-01`. They identify a thing and measure nothing: 44% of what the digit matcher called the population, every one with nothing to re-read. - 98 issues state their figure only in WORDS. "Twelve quantified clauses call a function with the wrong number of arguments" is re-measurable and invisible to a digit matcher. The two errors partly cancel -- 268 against 283 -- which is the worst case, because a number that is nearly right survives review. The skill section is corrected IN PLACE rather than left standing beside the new one. TWO READERS, AND THE LOOSE ONE WAS A STRICT SUPERSET. The Rust read 295 where an independent Python reader read 283 on the same backlog. Subtracting was the whole diagnosis: 12 in Rust, 0 in Python, so the rule was strictly looser rather than differently wrong -- the signature of a missing boundary. It was: the digit scan had no word boundary and fired inside identifiers, and this repository is made of them (`t27`, `GF16`, `dlc10`, `SRL16E`, `0o777`, `2'b11`, `bitset.t27`). With the boundary both readers agree at 283 against 283, zero in either direction. `every`, `all`, `none` and `half` quantify without giving a figure, so they are excluded and counted separately rather than dropped in silence. `--sample N` takes a SYSTEMATIC sample -- every k-th by ascending number, not random and not chosen -- so re-running it gives an exact overlap wherever the backlog has not moved. A rate is only worth taking if the same sample can be taken again. Nine tests, both properties mutation-checked: removing the word boundary fails two, removing address-stripping fails three. Skill 419-420. Co-Authored-By: Claude Opus 5 --- .claude/skills/ci-gates/SKILL.md | 50 ++- cli/tri/src/issues.rs | 380 +++++++++++++++++- ...ount-my-own-population-was-wrong-in-bot.md | 9 + 3 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 docs/now/2026-08-31-an-address-is-not-a-count-my-own-population-was-wrong-in-bot.md diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index 19eff7428a..7d2d222268 100644 --- a/.claude/skills/ci-gates/SKILL.md +++ b/.claude/skills/ci-gates/SKILL.md @@ -10493,8 +10493,13 @@ and "cannot see this shape at all" print identically. ## 416. An open issue is a claim with a date, and one subset re-measures itself -477 open issues here, **268** with a number in the title -- each a stated measurement of -the tree, taken once, re-read by nothing. Most cannot be checked automatically: pulling +478 open issues here, and **283** state a COUNT in the title -- each a stated measurement +of the tree, taken once, re-read by nothing. + +*(Corrected in place one pass later. The first reading said **268**, from a matcher that +read any two-digit run, and it was wrong in BOTH directions: 145 of those were ADDRESSES +-- `#2841`, `Wave Loop 369`, `Prop. 65` -- which measure nothing, and 98 issues state +their figure only in WORDS, which a digit matcher cannot see. See the section on that.)* Most cannot be checked automatically: pulling the measurement out of free prose is the precision problem that killed a detector one pass earlier. @@ -10546,3 +10551,44 @@ the body contains backticks, `$`, or `!`, which for prose about code is always. Recovery is `git checkout ` and a rewrite, and it cost nothing because the file was not yet committed. Had it been, the sections would have shipped with the holes and read as sloppy prose rather than as a shell bug. + + +## 419. An address is not a count, and a count is not always a digit + +I published "268 open issues carry a number in the title" one pass ago, from a matcher +that read any two-digit run. Re-measured with an independent reader, the population is +**283**, and the first number was wrong in **both directions at once**: + +- **145 of the 329 loose hits were ADDRESSES.** `#2841`, `Wave Loop 369`, `Prop. 65`, + `w699`, `CI-01` identify a thing and measure nothing. That is **44%** of what a digit + matcher calls the population, and every one of them has nothing to re-read. +- **98 issues state their figure only in WORDS.** "Twelve quantified clauses call a + function with the wrong number of arguments" is re-measurable and invisible to a digit + matcher. + +Both errors are the same mistake: reading the SPELLING of a number instead of asking +whether the title makes a claim you could go and check. The two corrections happen to +partly cancel -- 268 against 283 -- which is the worst case, because a number that is +nearly right survives review. + +Excluded on purpose and counted separately: 20 titles carrying only `every`, `all`, +`none` or `half`. They quantify without giving a figure, so there is no number to +re-read; dropping them silently would have made the population look cleaner than it is. + +## 420. Two readers of one population, and the loose one is a strict superset + +The Rust command read **295** where an independent Python reader read **283**, on the same +backlog. Subtracting was the whole diagnosis: 12 in Rust, 0 in Python, so the Rust rule +was strictly looser rather than differently wrong -- which is the signature of a missing +boundary, not a missing case. + +It was. The digit rule scanned for two consecutive digits with no word boundary, so it +fired INSIDE identifiers, and this repository is made of them: `t27`, `GF16`, `dlc10`, +`SRL16E`, `0o777`, `2'b11`, `bitset.t27`. With the boundary the two readers agree at +**283 against 283, zero in either direction**. + +The lesson is the cheap availability of the control. **Two implementations of one question +is a control you get for free: run both and subtract.** It is written on this page already +from `prose report` against `unparsed report`, and the direction of the difference names +the defect class before you read a line of the code -- superset means too loose, disjoint +means two different questions. diff --git a/cli/tri/src/issues.rs b/cli/tri/src/issues.rs index 0cf380e4c4..35b7997069 100644 --- a/cli/tri/src/issues.rs +++ b/cli/tri/src/issues.rs @@ -33,6 +33,15 @@ pub enum IssuesCmd { #[arg(long, default_value_t = 500)] limit: usize, }, + /// Open issues that state a COUNT in the title, and a reproducible sample. + Numbers { + /// Print a systematic sample of this size. 0 prints only the population. + #[arg(long, default_value_t = 0)] + sample: usize, + /// How many open issues to read. + #[arg(long, default_value_t = 500)] + limit: usize, + }, } /// The phrases a title uses to call something red. @@ -146,6 +155,287 @@ pub fn named_workflows(text: &str, keys: &BTreeMap) -> Vec String { + let mut out = String::with_capacity(title.len()); + let b: Vec = title.chars().collect(); + let mut i = 0usize; + let low: String = title.to_lowercase(); + let lb: Vec = low.chars().collect(); + // the prefixes that make a number an address, longest first + const PRE: [&str; 7] = [ + "wave loop ", + "wave ", + "prop. ", + "prop ", + "adr-", + "rfc-", + "ci-", + ]; + while i < b.len() { + // #1234 + if b[i] == '#' && b.get(i + 1).is_some_and(|c| c.is_ascii_digit()) { + i += 1; + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + out.push(' '); + continue; + } + // w699 / W12 -- a bare w followed by digits, on a word boundary + if (b[i] == 'w' || b[i] == 'W') + && b.get(i + 1).is_some_and(|c| c.is_ascii_digit()) + && (i == 0 || !b[i - 1].is_alphanumeric()) + { + let mut j = i + 1; + while j < b.len() && b[j].is_ascii_digit() { + j += 1; + } + if j - i >= 3 && (j >= b.len() || !b[j].is_alphanumeric()) { + out.push(' '); + i = j; + continue; + } + } + // wave loop 369 / prop. 65 / ci-01 + let mut matched = false; + for p in PRE { + let pc: Vec = p.chars().collect(); + if i + pc.len() <= lb.len() + && lb[i..i + pc.len()] == pc[..] + && (i == 0 || !b[i - 1].is_alphanumeric()) + { + let mut j = i + pc.len(); + let start = j; + while j < b.len() && b[j].is_ascii_digit() { + j += 1; + } + if j > start { + out.push(' '); + i = j; + matched = true; + break; + } + } + } + if matched { + continue; + } + out.push(b[i]); + i += 1; + } + out +} + +/// Numeral words. `every`, `all`, `none` and `half` are deliberately NOT here: +/// they quantify without giving a figure, so a reader has nothing to re-measure. +const NUMERALS: [&str; 26] = [ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", + "twenty", + "thirty", + "forty", + "fifty", + "hundred", + "thousand", +]; +const QUANTIFIERS: [&str; 4] = ["every", "all", "none", "half"]; + +fn has_word(hay: &str, w: &str) -> bool { + let h = hay.to_lowercase(); + let mut from = 0usize; + while let Some(rel) = h[from..].find(w) { + let i = from + rel; + let j = i + w.len(); + let l = h[..i] + .chars() + .next_back() + .is_none_or(|c| !c.is_alphanumeric()); + let r = h[j..].chars().next().is_none_or(|c| !c.is_alphanumeric()); + if l && r { + return true; + } + from = i + 1; + } + false +} + +/// What a title carries, once addresses are gone. +pub fn carries(title: &str) -> Carries { + let t = strip_addresses(title); + // A digit run counts only when nothing alphanumeric touches it. Without + // that boundary the rule fires inside identifiers -- `t27`, `GF16`, + // `dlc10`, `SRL16E`, `0o777` -- and reports twelve issues here whose + // titles state no count at all. Found by running an independent reader + // over the same backlog and subtracting: 295 against 283, and the Rust was + // a strict superset, which is what an over-loose matcher looks like. + let digits = { + let c: Vec = t.chars().collect(); + let mut i = 0usize; + let mut found = 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 i - start >= 2 && left && right { + found = true; + } + } + found + }; + let words = NUMERALS.iter().any(|w| has_word(&t, w)); + match (digits, words) { + (true, true) => Carries::Both, + (true, false) => Carries::Digits, + (false, true) => Carries::Words, + (false, false) => { + if QUANTIFIERS.iter().any(|w| has_word(&t, w)) { + Carries::QuantifierOnly + } else { + Carries::None + } + } + } +} + +/// The population of re-measurable open issues, and a reproducible sample of it. +/// +/// A rate is only worth taking if the same sample can be taken again, so the +/// 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<()> { + let lim = limit.to_string(); + let raw = gh(&[ + "issue", + "list", + "--state", + "open", + "--limit", + &lim, + "--json", + "number,title", + ])?; + let v: serde_json::Value = serde_json::from_str(&raw).context("gh returned no JSON")?; + let arr = v.as_array().cloned().unwrap_or_default(); + if arr.is_empty() { + anyhow::bail!( + "gh returned no open issues -- a repository with none and a query that \ + did not run print the same zero." + ); + } + let mut rows: Vec<(u64, String, Carries)> = arr + .iter() + .map(|i| { + let n = i["number"].as_u64().unwrap_or(0); + let t = i["title"].as_str().unwrap_or("").to_string(); + let c = carries(&t); + (n, t, c) + }) + .collect(); + rows.sort_by_key(|r| r.0); + + let c = |k: Carries| rows.iter().filter(|r| r.2 == k).count(); + let pop: Vec<&(u64, String, Carries)> = rows + .iter() + .filter(|r| matches!(r.2, Carries::Digits | Carries::Words | Carries::Both)) + .collect(); + + println!("OPEN ISSUES THAT STATE A COUNT IN THE TITLE\n"); + println!(" open issues read {}", rows.len()); + println!(" count in digits only {}", c(Carries::Digits)); + println!(" count in words only {}", c(Carries::Words)); + println!(" both {}", c(Carries::Both)); + println!(" POPULATION {}", pop.len()); + println!( + " quantifier only, excluded {}", + c(Carries::QuantifierOnly) + ); + println!(" no figure {}", c(Carries::None)); + + println!( + "\n An ADDRESS is not a count. `#2841`, `Wave Loop 369`, `Prop. 65`, `w699`\n \ + and `CI-01` identify a thing and measure nothing. A matcher reading any\n \ + two-digit run reports 329 on this backlog, of which 145 -- 44% -- are\n \ + addresses, and every one of them has nothing to re-read.\n\n \ + A count written in WORDS is still a count. \"Twelve quantified clauses\n \ + call a function with the wrong number of arguments\" is re-measurable and\n \ + invisible to a digit matcher: {} issues here state their figure only in\n \ + words. Reading digits alone gets the population wrong in BOTH directions.\n\n \ + `every`, `all`, `none` and `half` quantify without giving a figure, so\n \ + they are excluded and counted separately rather than dropped in silence.", + c(Carries::Words) + ); + + 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(); + println!( + "\n SYSTEMATIC SAMPLE -- every {k}th of {} by ascending number, {} taken.\n \ + Not random and not chosen: re-run it and the overlap is exact wherever\n \ + the backlog has not moved, which is what makes a rate comparable.\n", + pop.len(), + picked.len() + ); + for (n, t, _) in picked.iter().copied() { + println!(" #{n} {}", &t[..t.len().min(92)]); + } + } + Ok(()) +} + fn gh(args: &[&str]) -> Result { let out = Command::new("gh") .args(args) @@ -191,7 +481,10 @@ struct Row { } pub fn run(cmd: &IssuesCmd) -> Result<()> { - let IssuesCmd::Stale { limit } = cmd; + let limit = match cmd { + IssuesCmd::Numbers { sample, limit } => return numbers(*sample, *limit), + IssuesCmd::Stale { limit } => limit, + }; let root = repo_root()?; let keys = workflow_keys(&root.join(".github/workflows"))?; let files: std::collections::BTreeSet<&String> = keys.values().collect(); @@ -439,6 +732,91 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// An address identifies a thing and measures nothing. This repository + /// writes five spellings of them, and a matcher that reads any two-digit run + /// reports 329 titles here of which 145 -- 44% -- are only these. + #[test] + fn an_address_is_not_a_count() { + for t in [ + "Seal Coverage has been red on master since #2841", + "Wave Loop 369 -- IGLA CODER+RACE + retry board flash", + "Wave 679: a _CoqProject nobody runs builds nothing", + "formal: the memory axiom, over a symbolic address (Prop. 78)", + "cli/tri: six names unresolvable on w699", + "Catalog Count Invariant CI-01 fires on every run", + ] { + assert_ne!( + carries(t), + Carries::Digits, + "the only digits here are an address: {t}" + ); + } + } + + /// The bug an independent reader found by subtraction. Without a word + /// boundary the digit rule fires INSIDE identifiers, and this repository is + /// full of them: 295 against 283, the loose reader a strict superset. + #[test] + fn a_digit_inside_an_identifier_is_not_a_count() { + for t in [ + "[IGLA-Coder] P8 Integration into t27 and publication", + "feat(fpga): tri CLI integration for openXC7 GF16 flow", + "fix(igla): add Digilent FTDI cable support to cli/dlc10", + "openXC7 emits a wrong bitstream for SRL16E (same class as DSP48E1)", + "The lexer turns 0o777 into 0", + "Wave Loop 564 -- layer-boundary requantizer; 2'b11 proved unreachable", + "parser: Expected LParen in specs/tri/collections/bitset.t27", + ] { + assert_eq!( + carries(t), + Carries::None, + "digits are inside an identifier, and there is no numeral word: {t}" + ); + } + } + + /// A count in words is still a count, and a digit matcher cannot see it. + /// 98 titles on this backlog state their figure only this way. + #[test] + fn a_numeral_word_is_a_count_and_a_quantifier_is_not() { + for t in [ + "Twelve quantified clauses call a function with the wrong number of arguments", + "Five open issues say a workflow is red", + "Eight invariants spell determinism as f(x) == f(x)", + "specs/ml/optimizer/adamw.t27 contains two complete copies of the AdamW module", + ] { + assert_eq!( + carries(t), + Carries::Words, + "a numeral word is a figure: {t}" + ); + } + for t in [ + "Wave Loop 601 -- every assumption audited for what it removes", + "formal: a verdict for every property, and none of them is dead (Prop. 64)", + ] { + assert_eq!( + carries(t), + Carries::QuantifierOnly, + "quantifies without giving a figure: {t}" + ); + } + } + + /// A title can carry both, and the address in it must not change that. + #[test] + fn digits_and_words_together_are_both() { + assert_eq!( + carries("Three pull requests have run almost no CI, and 40 checks were skipped"), + Carries::Both + ); + assert_eq!( + carries("Wave 690: the corpus reaches 12 on every parse metric"), + Carries::Digits, + "the wave number is stripped; 12 is the count" + ); + } + fn tempdir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!( "tri-issues-{tag}-{}-{}", diff --git a/docs/now/2026-08-31-an-address-is-not-a-count-my-own-population-was-wrong-in-bot.md b/docs/now/2026-08-31-an-address-is-not-a-count-my-own-population-was-wrong-in-bot.md new file mode 100644 index 0000000000..3d7adcaa65 --- /dev/null +++ b/docs/now/2026-08-31-an-address-is-not-a-count-my-own-population-was-wrong-in-bot.md @@ -0,0 +1,9 @@ +# NOW -- An address is not a count: my own population was wrong in both directions (2026-08-31) + +## An address is not a count: my own population was wrong in both directions (Refs #2983) + +- New tri issues numbers. Of 478 open issues, 283 state a COUNT in the title: 128 in digits only, 98 in words only, 57 both. 20 more carry only a quantifier (every, all, none, half) and are excluded and counted separately rather than dropped in silence. +- This corrects a number I published one pass ago. 268 came from a matcher reading any two-digit run, and it was wrong in BOTH directions: 145 of those hits were ADDRESSES (#2841, Wave Loop 369, Prop. 65, w699, CI-01) which measure nothing -- 44% of that population -- while 98 issues state their figure only in words, invisible to a digit matcher. The skill section is corrected in place, not beside itself. +- The Rust read 295 where an independent Python reader read 283, on the same backlog. Subtracting was the diagnosis: 12 in Rust, 0 in Python, so the rule was strictly looser. The digit scan had no word boundary and fired inside identifiers -- t27, GF16, dlc10, SRL16E, 0o777, 2'b11. With the boundary both read 283, zero in either direction. +- The sample is systematic (every k-th by ascending number), not random and not chosen, so re-running it next month gives an exact overlap wherever the backlog has not moved. A rate is only worth taking if the same sample can be taken again. +- Both properties are mutation-checked: removing the word boundary fails two tests, removing address-stripping fails three.