diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index 37b64ea5d..efdc85a31 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. @@ -10587,3 +10592,43 @@ the one-command check. The general rule, which is why this is worth a section: **the state you are committing to is not printed by the commit.** Branch, freeze hash, and clean tree are three preconditions that all fail silently and all cost one command each. + +## 420. 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. + +## 421. 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 0cf380e4c..35b799706 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 000000000..3d7adcaa6 --- /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.