diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index 8c76953c7..ad6876029 100644 --- a/.claude/skills/ci-gates/SKILL.md +++ b/.claude/skills/ci-gates/SKILL.md @@ -12029,3 +12029,77 @@ renaming a required check. A reader who matches file name to subject opens the w file, finds a shape checker, and concludes the freshness gate does not run. That has already happened, in this loop's own notes. Both files now say so in their first ten lines, which costs nothing and is the whole fix available. + +## 461. A count over a live backlog is a clock reading nobody wrote down + +`tri issues numbers` printed `486` open issues. That is not a fact about this +repository; it is a fact about the moment it was asked. Read as of a date one month +back, the same query answers **140** -- a 3.5x move in 33 days, and nothing in the old +output says which month it belonged to. + +`--as-of YYYY-MM-DD` fixes the population instead of the phrasing. It drops `--state +open` -- an issue open THEN may be closed NOW, and that filter removes exactly the rows +that make the two readings differ -- reads `--state all` with `createdAt`/`closedAt`, +and keeps what was open at the end of that UTC day: + +``` +open_at(created, closed, t) = created <= t && (closed is empty || closed > t) +``` + +**The two boundaries point opposite ways** and that is the whole rule: created AT the +instant counts as existing, closed AT the instant counts as closed, so an issue opened +and closed in the same second is not open. A row with no creation time is not counted +rather than defaulted to open -- guessing would put it in the population in silence, +which is the failure the command exists to expose. + +**The END of the day, not the start.** GitHub's own search reads a bare date in +`created:<=2026-08-01` as covering that whole day. Two tools answering the same question +must mean the same thing by the same date, or the second reader gets a different number +and blames the first. + +**Three independent routes agree on 140:** GitHub search as two queries +(`created:<=T state:open` 43, plus `created:<=T closed:>T` 97); a full walk of all 1482 +issues computing open-at-T from timestamps; and this command. The first two were run by +separate readers before the command existed. + +A malformed date is **refused, not defaulted**. `--as-of 2026-8-1` errors out, because a +date the tool cannot read silently becoming "today" would print a number over the wrong +population under a heading that says the reading is anchored -- worse than no anchor at +all. The shape check is `skillnum::is_iso_date`, the rule already mutation-proved for +§459's recovered anchors, not a second copy of the same ten conjuncts. + +Without the flag the command now says so in its own first line: *this reading is NOT +anchored*. The default still answers, because refusing to count today's backlog would +be a different tool -- but it no longer lets the number pass as a fact. + +## 462. `--limit 500` against 486 open issues: fourteen from printing a page as a census + +Every `gh`-backed count in this CLI asked for at most `--limit` rows and then printed +what came back as a total. `gh` returns at most that many and says nothing about what it +left behind, so **a full page is a lower bound and only a short page is a total**. + +Measured `2026-09-03T16:35Z`: **486** open issues against a default `--limit` of +**500**. Fourteen issues away from every figure this command prints becoming a page, in +silence, with no line of output different. + +The check is one comparison and the boundary is its whole content: + +```rust +pub fn read_is_complete(returned: usize, limit: usize) -> bool { returned < limit } +``` + +At exactly `limit` rows there may or may not be more, and the honest answer is that this +cannot tell -- so it reports incomplete. Mutating `<` to `<=` kills a test; the fixture +is `(486, 500)` and `(500, 500)`, the live boundary rather than an invented one. + +**The class was four call sites, not one.** Grepping `"--limit"` across `cli/tri/src/` +found `numbers`, `dated`, `stale` and `gates prs` -- and `gates prs` carries a +**hardcoded 50** with no flag at all (10 open PRs at the time of measuring, so it does +not bite, and when it does it will bite in silence). Fixing only the command that +prompted the reading would have left three. Each was then run at its own boundary: +`--limit 486` prints the LOWER BOUND line, `--limit 487` prints COMPLETE. + +This is §457 one level down. There the population was a query and the figure went +stale; here the population is a query **and the tool does not know whether it saw all of +it**. An anchor on an incomplete read is worse than none: it says *this number can be +taken again* about a number that was never the whole thing. diff --git a/cli/tri/src/gates.rs b/cli/tri/src/gates.rs index c5572014c..f3265b393 100644 --- a/cli/tri/src/gates.rs +++ b/cli/tri/src/gates.rs @@ -1535,6 +1535,10 @@ fn prs(repo: Option<&str>) -> Result<()> { "--state", "open", "--limit", + // A hard 50 with no flag: 10 open at 2026-09-03T16:35Z, so it does not + // bite -- and when it does it will bite in silence unless the read says + // whether it reached the end. The check below is why the constant can + // stay a constant. "50", "--json", "number,title,mergeable", @@ -1561,6 +1565,13 @@ fn prs(repo: Option<&str>) -> Result<()> { return Ok(()); } + if !crate::issues::read_is_complete(items.len(), 50) { + println!( + "*** {} open PRs came back and the query asked for 50: this is a LOWER \ + BOUND, not the open set. ***", + items.len() + ); + } println!( "{:<7} {:<13} {:>7} {}", "pr", "mergeable", "checks", "title" diff --git a/cli/tri/src/issues.rs b/cli/tri/src/issues.rs index 669f17598..7473fbac8 100644 --- a/cli/tri/src/issues.rs +++ b/cli/tri/src/issues.rs @@ -41,9 +41,17 @@ pub enum IssuesCmd { /// 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. + /// How many issues to read. The read is a LOWER BOUND when this many + /// come back -- the output says so rather than presenting a page as a + /// total. #[arg(long, default_value_t = 500)] limit: usize, + /// Count the backlog as it stood at the END of this UTC day + /// (`YYYY-MM-DD`), instead of now. Without it the population is a + /// query whose answer changes on every open and close, and the number + /// cannot be re-taken by a second reader. + #[arg(long)] + as_of: Option, }, /// Open issues whose figure is ANCHORED, so re-measuring it proves nothing. Dated { @@ -407,24 +415,89 @@ pub fn carries(title: &str) -> Carries { } } +/// Was this issue open at `instant`? +/// +/// Both timestamps come from GitHub as ISO-8601 with a `Z` suffix, and such strings +/// compare lexicographically in chronological order -- so this needs no date library +/// and cannot drift from one. An issue with no `closedAt` is open now and was open +/// then, provided it existed. +/// +/// The empty-`created` case returns false rather than defaulting to open: a row whose +/// creation time did not arrive is a row this cannot classify, and guessing would put +/// it in the population silently. +pub fn open_at(created: &str, closed: &str, instant: &str) -> bool { + if created.is_empty() || created > instant { + return false; + } + closed.is_empty() || closed > instant +} + +/// `YYYY-MM-DD` to the last instant of that UTC day. +/// +/// The END of the day, not the start, because that is what GitHub's own search means +/// by `created:<=2026-08-01` -- a bare date there covers the whole day. Two tools +/// answering the same question must mean the same thing by the same date, or the +/// second reader gets a different number and blames the first. +/// +/// The shape check is `skillnum::is_iso_date`, the rule already mutation-proved for +/// the skill anchors, rather than a second copy of the same ten conjuncts here. +pub fn instant_of(date: &str) -> Result { + let c: Vec = date.chars().collect(); + if c.len() != 10 || !crate::skillnum::is_iso_date(&c) { + anyhow::bail!( + "--as-of wants YYYY-MM-DD and got `{date}`. A date this cannot read is \ + refused rather than silently treated as today, which would print a \ + number over the wrong population under an anchor that looks right." + ); + } + Ok(format!("{date}T23:59:59Z")) +} + +/// Did the read reach the end, or did it fill the page? +/// +/// `gh` returns at most `--limit` rows and says nothing about what it left behind, so +/// a FULL page is a lower bound and anything short of one is complete. The boundary is +/// the whole content of this function: at exactly `limit` rows there may or may not be +/// more, and the honest answer is that this cannot tell -- so it reports incomplete. +pub fn read_is_complete(returned: usize, limit: usize) -> bool { + returned < limit +} + /// 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, single: bool) -> Result<()> { +fn numbers(sample: usize, limit: usize, single: bool, as_of: Option<&str>) -> Result<()> { let lim = limit.to_string(); - let raw = gh(&[ - "issue", - "list", - "--state", - "open", - "--limit", - &lim, - "--json", - "number,title", - ])?; + let instant = as_of.map(instant_of).transpose()?; + // With --as-of the state filter has to come off: an issue open THEN may be + // closed NOW, and `--state open` would drop exactly the ones that make the two + // readings differ. The filtering is done here from the timestamps instead. + let raw = if instant.is_some() { + gh(&[ + "issue", + "list", + "--state", + "all", + "--limit", + &lim, + "--json", + "number,title,createdAt,closedAt", + ])? + } else { + 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() { @@ -433,8 +506,22 @@ fn numbers(sample: usize, limit: usize, single: bool) -> Result<()> { did not run print the same zero." ); } + // Whether the READ is complete is a different question from what it contains, + // and it has to be asked before any total is printed. `gh` returns at most + // --limit rows and says nothing about what it left behind, so a full page is a + // LOWER BOUND. Measured 2026-09-03: 486 open against a default limit of 500 -- + // fourteen issues from printing a page as a census, in silence. + let complete = read_is_complete(arr.len(), limit); let mut rows: Vec<(u64, String, Carries)> = arr .iter() + .filter(|i| match instant.as_deref() { + None => true, + Some(t) => open_at( + i["createdAt"].as_str().unwrap_or(""), + i["closedAt"].as_str().unwrap_or(""), + t, + ), + }) .map(|i| { let n = i["number"].as_u64().unwrap_or(0); let t = i["title"].as_str().unwrap_or("").to_string(); @@ -450,7 +537,25 @@ fn numbers(sample: usize, limit: usize, single: bool) -> Result<()> { .filter(|r| matches!(r.2, Carries::Digits | Carries::Words | Carries::Both)) .collect(); - println!("OPEN ISSUES THAT STATE A COUNT IN THE TITLE\n"); + match instant.as_deref() { + Some(t) => println!( + "OPEN ISSUES THAT STATE A COUNT IN THE TITLE, AS OF {t}\n\n \ + This reading is ANCHORED: the population is the set of issues created\n \ + at or before {t} and not closed by then, which does not move. Run it\n \ + again next month and every number below is the same.\n" + ), + None => println!( + "OPEN ISSUES THAT STATE A COUNT IN THE TITLE\n\n \ + This reading is NOT anchored: `open issues` is a query, not a set, and\n \ + its answer changes on every open and close. Pass --as-of YYYY-MM-DD to\n \ + take a number a second reader can take again.\n" + ), + } + if complete { + println!(" issues read from gh {} (fewer than the --limit of {limit}, so the read is COMPLETE)", arr.len()); + } else { + println!(" issues read from gh {} *** EQUALS the --limit of {limit}: this is a LOWER BOUND, not a total. Raise --limit and read again. ***", arr.len()); + } println!(" open issues read {}", rows.len()); println!(" count in digits only {}", c(Carries::Digits)); println!(" count in words only {}", c(Carries::Words)); @@ -569,7 +674,8 @@ pub fn run(cmd: &IssuesCmd) -> Result<()> { sample, limit, single, - } => return numbers(*sample, *limit, *single), + as_of, + } => return numbers(*sample, *limit, *single, as_of.as_deref()), IssuesCmd::Dated { limit, list } => return dated(*limit, *list), IssuesCmd::Stale { limit } => limit, }; @@ -641,6 +747,9 @@ pub fn run(cmd: &IssuesCmd) -> Result<()> { } println!("OPEN ISSUES THAT CALL A WORKFLOW RED, AND WHAT IT DOES ON MASTER TODAY\n"); + if !read_is_complete(issues.len(), *limit) { + println!(" issues read from gh {} *** EQUALS the --limit of {limit}: a LOWER BOUND, not a total. Raise --limit and read again. ***", issues.len()); + } println!(" open issues read {}", issues.len()); println!(" workflow files {}", files.len()); println!(" titles claiming red {}", rows.len()); @@ -1066,6 +1175,9 @@ fn dated(limit: usize, list: bool) -> Result<()> { let anchored = pop.len() - c(Anchor::Free); println!("OPEN ISSUES WHOSE FIGURE RE-MEASUREMENT CANNOT JUDGE\n"); + if !read_is_complete(issues.len(), limit) { + println!(" issues read from gh {} *** EQUALS the --limit of {limit}: a LOWER BOUND, not a total. Raise --limit and read again. ***", issues.len()); + } println!(" open issues read {}", issues.len()); println!(" no figure in the title {no_figure}"); println!(" POPULATION (carries a figure) {}", pop.len()); @@ -1243,3 +1355,89 @@ mod single_digit_tests { } } } + +#[cfg(test)] +mod as_of_tests { + use super::{instant_of, open_at, read_is_complete}; + + const T: &str = "2026-08-01T23:59:59Z"; + + /// The probe: the two shapes that ARE open at the instant. + #[test] + fn an_issue_is_open_then_if_it_existed_and_had_not_closed() { + assert!(open_at("2026-07-01T10:00:00Z", "", T), "still open today"); + assert!( + open_at("2026-07-01T10:00:00Z", "2026-09-01T10:00:00Z", T), + "closed later, so it was open then" + ); + } + + /// The counter-examples, and the second is the whole reason `--state open` cannot + /// be left on the query: an issue closed before the instant is open NOW-negative + /// and then-negative, but one closed AFTER it is open then and closed now. + #[test] + fn it_is_not_open_then_if_it_did_not_exist_or_had_closed() { + assert!(!open_at("2026-09-01T10:00:00Z", "", T), "created after"); + assert!( + !open_at("2026-07-01T10:00:00Z", "2026-07-15T10:00:00Z", T), + "closed before" + ); + } + + /// Both boundaries are inclusive-of-existing and exclusive-of-surviving, and they + /// are opposite: created AT the instant counts as existing, closed AT the instant + /// counts as closed. An issue opened and closed in the same second is not open. + #[test] + fn the_two_boundaries_point_opposite_ways() { + assert!(open_at(T, "", T), "created exactly at the instant existed"); + assert!(!open_at(T, T, T), "closed exactly at the instant is closed"); + } + + /// A row this cannot classify does not get a default. Guessing "open" would put it + /// in the population in silence, which is the failure this whole command is about. + #[test] + fn a_row_with_no_creation_time_is_not_counted() { + assert!(!open_at("", "", T)); + assert!(!open_at("", "2026-09-01T10:00:00Z", T)); + } + + #[test] + fn a_date_becomes_the_last_instant_of_that_utc_day() { + assert_eq!(instant_of("2026-08-01").unwrap(), "2026-08-01T23:59:59Z"); + assert_eq!(instant_of("2099-12-31").unwrap(), "2099-12-31T23:59:59Z"); + } + + /// Refused, not defaulted. A date this cannot read must not become "today" under + /// a heading that says the reading is anchored. + #[test] + fn a_date_it_cannot_read_is_refused() { + for bad in [ + "2026-8-1", + "01-08-2026", + "2026-08-01T00:00:00Z", + "", + "yesterday", + ] { + assert!(instant_of(bad).is_err(), "{bad} must be refused"); + } + } + + /// The boundary IS the rule. Measured 2026-09-03: 486 open against a default limit + /// of 500, so this is fourteen issues from mattering. + #[test] + fn a_full_page_is_a_lower_bound_and_a_short_one_is_a_total() { + assert!(read_is_complete(486, 500), "short page: complete"); + assert!( + !read_is_complete(500, 500), + "full page: cannot tell, so not complete" + ); + assert!( + !read_is_complete(501, 500), + "over the limit is not complete either" + ); + assert!( + read_is_complete(0, 1), + "an empty read of a non-zero limit is complete" + ); + } +} diff --git a/cli/tri/src/skillnum.rs b/cli/tri/src/skillnum.rs index 8400079a4..93f977597 100644 --- a/cli/tri/src/skillnum.rs +++ b/cli/tri/src/skillnum.rs @@ -508,7 +508,7 @@ pub fn names_its_anchor(body: &str) -> bool { /// A left-hand word-boundary check used to sit here too (`v2026-08-20` should not /// count). It was removed rather than kept unproved: no natural counter-example /// exists in this corpus and removing it changed no section. -fn is_iso_date(w: &[char]) -> bool { +pub fn is_iso_date(w: &[char]) -> bool { w.len() == 10 && w[0] == '2' && w[1] == '0' diff --git a/docs/now/2026-09-03-a-count-over-a-live-backlog-is-a-clock-reading.md b/docs/now/2026-09-03-a-count-over-a-live-backlog-is-a-clock-reading.md new file mode 100644 index 000000000..b46360ae6 --- /dev/null +++ b/docs/now/2026-09-03-a-count-over-a-live-backlog-is-a-clock-reading.md @@ -0,0 +1,18 @@ +# NOW -- A count over a live backlog is a clock reading nobody wrote down (2026-09-03) + +## `tri issues numbers --as-of DATE` (Refs #2994) + +- `486` open issues is not a fact about this repository; it is a fact about the moment it was asked. Read as of a date one month back the same query answers **140** -- a 3.5x move in 33 days, with nothing in the old output saying which month it belonged to +- the flag fixes the population rather than the phrasing: it drops `--state open` (an issue open THEN may be closed NOW, and that filter removes exactly the rows that make the two readings differ), reads `--state all` with `createdAt`/`closedAt`, and keeps `created <= t && (closed is empty || closed > t)` +- **the two boundaries point opposite ways**: created AT the instant counts as existing, closed AT the instant counts as closed, so an issue opened and closed in the same second is not open. A row with no creation time is not counted rather than defaulted to open +- the END of the UTC day, not the start, because GitHub's own search reads a bare date in `created:<=2026-08-01` as covering that whole day. Two tools answering the same question must mean the same thing by the same date +- **three independent routes agree on 140**: GitHub search as two queries (43 + 97), a full walk of all 1482 issues computing open-at-T from timestamps, and this command. The first two were run by separate readers before the command existed +- a malformed date is **refused, not defaulted** -- `--as-of 2026-8-1` errors, because a date silently becoming "today" under a heading that says the reading is anchored is worse than no anchor. The shape check is `skillnum::is_iso_date`, the rule already mutation-proved for section 459, not a second copy + +## `--limit 500` against 486 open: fourteen from printing a page as a census (Refs #2994) + +- `gh` returns at most `--limit` rows and says nothing about what it left behind, so **a full page is a lower bound and only a short page is a total**. Nothing in this CLI checked that +- measured `2026-09-03T16:35Z`: **486 open against a default limit of 500**. Fourteen issues from every printed figure becoming a page, in silence, with no line of output different +- `read_is_complete(returned, limit) = returned < limit`, and the boundary is its whole content: at exactly `limit` there may or may not be more, so it reports incomplete. Mutating `<` to `<=` kills a test whose fixture is the live boundary, `(486, 500)` and `(500, 500)` +- **the class was four call sites, not one.** Grepping `"--limit"` across `cli/tri/src/` found `numbers`, `dated`, `stale` and `gates prs` -- the last with a **hardcoded 50** and no flag (10 open PRs, so it does not bite yet). Each was run at its own boundary: `--limit 486` prints LOWER BOUND, `--limit 487` prints COMPLETE +- this is section 457 one level down: there the population was a query and the figure went stale; here the tool does not know whether it saw all of the population. **An anchor on an incomplete read is worse than none**