From 32fb51a18be0fb002416e441dcd7ec96cff7aae5 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 22 Sep 2026 12:06:02 -0600 Subject: [PATCH 1/5] docs: three documents named an upgrade chain the tree does not ship (#1197) A sentence in each names the installed versions one `ALTER EXTENSION pgcolumnar UPDATE` can start from, and the version it arrives at. Opening the `1.0-alpha5` cycle left all three wrong, and every version check in `docs_style.sh` stayed green. document starting versions it named destination CHANGELOG.md 4 of 5, 1.0-alpha4 missing 1.0-alpha4 docs/installation.md all 5 none a machine can find docs/limitations.md 3 of 5, hand-typed "Three" 1.0-alpha3 Both facts are knowable from the tree. The `pgcolumnar--A--B.sql` filenames give the starting versions and `pgcolumnar.control` gives the destination, so the three documents are now compared against them. THE TWO ERRORS CANCEL IF YOU COUNT. CHANGELOG.md named `1.0-alpha4` once too few as a starting version and once too many as the destination, so the set of versions in the sentence was exactly right. A rule comparing that set against the tree would have passed a sentence in which both halves were wrong. The two claims are read separately, each against its own source on disk. Scoped to the sentence, not the paragraph: the paragraph around the claim in docs/installation.md names the destination twice more in prose that is correct, and a paragraph-wide reading counts those as starting versions. A document the rule cannot read is named with its reason rather than skipped. `reaches it from every previously published version` is correct English carrying no destination a machine can find. Six arms in test/docs_style.sh and four tests in test/pytest/test_docs_upgrade_chain.py. The shell half folds the file with `tr` and cuts sentences with `sed`; the pytest half splits on a lookbehind and collects with `re`, so a parsing mistake in one is not a parsing mistake in the other. Their failures diverge too: on an empty population the shell half leaves three arms passing on nothing and only its premise red, while the vacuity layer refuses all three outright. guard_tests 398 -> 402, derived by collection. cluster_tests re-derived in the same run and unmoved at 463. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n --- CHANGELOG.md | 34 +++++- docs/installation.md | 2 +- docs/limitations.md | 6 +- test/docs_style.sh | 93 +++++++++++++++ test/pytest/TESTS.md | 150 +++++++++++++++++++++++++ test/pytest/expected_tests.txt | 7 +- test/pytest/test_docs_upgrade_chain.py | 148 ++++++++++++++++++++++++ test/pytest/test_harness_deps.py | 3 + 8 files changed, 435 insertions(+), 8 deletions(-) create mode 100644 test/pytest/test_docs_upgrade_chain.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae20f2b..ada28cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ are written in the native on-disk format, PGCN v1. For the forward-looking plan The extension's `default_version` is `1.0-alpha5`. `v1.0-alpha4` is the latest published pre-release. Upgrade scripts from every previously shipped version ship with it (`1.0-dev`, which the v1.0-alpha tag -installed, `1.0-alpha`, `1.0-alpha2`, and `1.0-alpha3`), so a single -`ALTER EXTENSION pgcolumnar UPDATE` reaches `1.0-alpha4` from any of them. Older +installed, `1.0-alpha`, `1.0-alpha2`, `1.0-alpha3`, and `1.0-alpha4`), so a single +`ALTER EXTENSION pgcolumnar UPDATE` reaches `1.0-alpha5` from any of them. Older notes in this file describe `default_version` as pinned at an earlier version, each true until the next version shipped. @@ -147,12 +147,40 @@ true until the next version shipped. ### Fixed +- Three documents named an upgrade chain the tree does not ship (#1197). + + A sentence in each names the installed versions one `ALTER EXTENSION pgcolumnar + UPDATE` can start from, and the version it arrives at. Opening the `1.0-alpha5` + cycle left all three wrong, and every version check in `docs_style.sh` stayed + green: + + | document | starting versions it named | destination it named | + | --- | --- | --- | + | `CHANGELOG.md` | 4 of 5, `1.0-alpha4` missing | `1.0-alpha4` | + | `docs/installation.md` | all 5 | none a machine can find: "reaches **it** from" | + | `docs/limitations.md` | 3 of 5, under a hand-typed "Three such scripts" | `1.0-alpha3` | + + Both facts are knowable from the tree. The `pgcolumnar--A--B.sql` filenames give + the starting versions and `pgcolumnar.control` gives the destination, so the three + documents are now compared against them rather than edited by hand. + + **The two errors in `CHANGELOG.md` cancel if you count.** It named `1.0-alpha4` + once too few as a starting version and once too many as the destination, so the set + of versions in the sentence was exactly right. A rule comparing that set against + the tree would have passed a sentence in which both halves were wrong. The two + claims are therefore read separately, each against its own source on disk. + + Six arms in `test/docs_style.sh` and four tests in + `test/pytest/test_docs_upgrade_chain.py`. The shell half folds the file with `tr` + and cuts sentences with `sed`; the pytest half splits on a lookbehind and collects + with `re`, so a parsing mistake in one is not a parsing mistake in the other. + - `native_reclaim_cycles` could not reach the defect it guards (#1138). It is the declared regression guard for #84. Deleting the #84 fix left it reporting `12 passed + 0 failed`, arm for arm, including the arm named after the defect. -`pgcolumnar.reclaim_coalesce` defaults on, and it does two things: it merges + `pgcolumnar.reclaim_coalesce` defaults on, and it does two things: it merges adjacent freed ranges, and it carries its own `CommandCounterIncrement` on the free path. That second one does the visibility work the #84 fix would otherwise do, so with coalescing on the defect is masked. diff --git a/docs/installation.md b/docs/installation.md index 489a39f8..8561b373 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -154,7 +154,7 @@ columnar table with it. `1.0-alpha5`, recorded in `VERSION`, is what this source tree installs. The latest published pre-release is `v1.0-alpha4`. -`ALTER EXTENSION pgcolumnar UPDATE` (step 3 above) reaches it from every +`ALTER EXTENSION pgcolumnar UPDATE` (step 3 above) reaches `1.0-alpha5` from every previously published version: `1.0-dev`, which the `v1.0-alpha` tag installed, `1.0-alpha`, `1.0-alpha2`, `1.0-alpha3`, and `1.0-alpha4`. PostgreSQL applies the shipped upgrade scripts in sequence. One command therefore carries a diff --git a/docs/limitations.md b/docs/limitations.md index 20966141..bd4d9ed6 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -115,9 +115,9 @@ A physical copy does not replace the source across a version change. The same posture covers the extension's own catalog, not only the on-disk data format. The install script of a build defines the `pgcolumnar` catalog tables for a fresh `CREATE EXTENSION`. An `ALTER EXTENSION UPDATE` script ships when a build needs one. -Three such scripts ship today: 1.0-dev to 1.0-alpha, 1.0-alpha to 1.0-alpha2, and -1.0-alpha2 to 1.0-alpha3. A single `ALTER EXTENSION pgcolumnar UPDATE` walks the -chain from any of them. +One `ALTER EXTENSION pgcolumnar UPDATE` reaches `1.0-alpha5` from every +previously shipped version: `1.0-dev`, `1.0-alpha`, `1.0-alpha2`, `1.0-alpha3` +and `1.0-alpha4`. PostgreSQL applies the shipped scripts in sequence. **Replacing the shared library is not sufficient on its own.** After installing a new build, run `ALTER EXTENSION pgcolumnar UPDATE;` in every database that has the extension. diff --git a/test/docs_style.sh b/test/docs_style.sh index 9df6abdf..4a880dc5 100755 --- a/test/docs_style.sh +++ b/test/docs_style.sh @@ -339,6 +339,99 @@ check "every document names the same latest published pre-release" \ || printf 'DISAGREE:%s' "$(printf '%s' "$_pubvers" | tr '\n' ',' | sed 's/,$//')")" \ "$(printf '%s\n' "$_pubvers" | head -1)" +# ---- and the UPGRADE CHAIN, which is a fourth place a version is written ---- +# +# Two documents tell a reader which installed versions one `ALTER EXTENSION +# pgcolumnar UPDATE` can start from, and which version it arrives at. Both facts +# are knowable from the tree: the shipped `pgcolumnar--A--B.sql` files give the +# set of starting versions, and `pgcolumnar.control` gives the arrival. +# +# Neither was compared against anything. Opening the `1.0-alpha5` cycle bumped +# VERSION, the control file, the badge and META.json, and every check above went +# green, while CHANGELOG.md kept saying that the chain starts at four versions +# and arrives at `1.0-alpha4`. Five ship and it arrives at `1.0-alpha5`. +# +# THE TWO ERRORS CANCEL IF YOU COUNT, which is why this reads the two claims +# separately. CHANGELOG.md named `1.0-alpha4` once too few as a source and once +# too many as the destination, so the set of versions in the sentence was exactly +# right. A rule comparing that set against the tree would have passed on a +# sentence in which both halves were wrong. +# +# SCOPED TO THE SENTENCE, not the paragraph. The surrounding paragraph in +# docs/installation.md also names the destination twice in prose that is correct, +# so a paragraph-wide reading counts the destination as a starting version. +_upgsrc="$(ls "$SRCDIR"/pgcolumnar--*--*.sql 2>/dev/null \ + | sed 's|.*/pgcolumnar--||; s|\.sql$||; s|--.*||' \ + | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/ $//')" +check "premise: the tree ships upgrade scripts to derive the chain from" \ + "$([ -n "$_upgsrc" ] && echo yes || echo no)" "yes" + +_defver="$(sed -n "s/^[[:space:]]*default_version[[:space:]]*=[[:space:]]*'\([^']*\)'.*/\1/p" \ + "$SRCDIR/pgcolumnar.control")" +check "premise: the control file names a default_version to arrive at" \ + "$([ -n "$_defver" ] && echo yes || echo no)" "yes" + +# ONE SENTENCE PER FILE, and the count is checked rather than assumed: `grep -m1` +# reads the first and a second would go unread, which is the silent half of the +# same shape the published-release arm above was bitten by. +_upg_claim() { # _upg_claim FILE -> the sentence making the upgrade-chain claim + tr '\n' ' ' < "$1" | sed 's/\. /.\n/g' \ + | grep -m1 -E 'previously (shipped|published) version' +} +_upg_claims() { # _upg_claims FILE -> how many sentences make it + tr '\n' ' ' < "$1" | sed 's/\. /.\n/g' \ + | grep -cE 'previously (shipped|published) version' +} +# The destination is removed before the starting versions are collected, because +# the two claims share one sentence and each has its own arm below. +_upg_sources() { + _upg_claim "$1" | sed 's/reaches `[^`]*`//g' \ + | grep -oE '`1\.0-[a-z0-9]+`' | tr -d '`' \ + | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/ $//' +} +_upg_target() { + _upg_claim "$1" | grep -oE 'reaches `[^`]*`' | sed 's/reaches `//; s/`$//' \ + | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/ $//' +} + +_upgdocs="$(grep -rlE 'previously (shipped|published) version' \ + "$SRCDIR/CHANGELOG.md" "$SRCDIR/README.md" "$SRCDIR/docs" 2>/dev/null | LC_ALL=C sort)" +check "premise: at least one document states the upgrade chain" \ + "$([ -n "$_upgdocs" ] && echo yes || echo no)" "yes" + +# NAMED WITH ITS REASON, not just named. A file this rule cannot read is the way +# the rule goes quiet, so the arm says which half was missing rather than leaving +# a reader to rediscover it. +_upgunread="" +for _d in $_upgdocs; do + _b="$(basename "$_d")" + [ "$(_upg_claims "$_d")" = 1 ] || _upgunread="$_upgunread $_b:claims=$(_upg_claims "$_d")" + [ -n "$(_upg_sources "$_d")" ] || _upgunread="$_upgunread $_b:no-starting-versions" + [ -n "$(_upg_target "$_d")" ] || _upgunread="$_upgunread $_b:no-destination" +done +check "every document stating the upgrade chain states it in a form this rule can read" \ + "$(printf '%s' "$_upgunread" | sed 's/^ //')" "" + +# BOTH SIDES CARRY THE VERSIONS (#1164). An arm that names only the offending +# file reports which document is wrong and not what is wrong with it, and the +# reader then has to re-derive the tree's own answer to find out. +_upggot="" +_upgwant="" +for _d in $_upgdocs; do + _upggot="$_upggot $(basename "$_d")=[$(_upg_sources "$_d")]" + _upgwant="$_upgwant $(basename "$_d")=[$_upgsrc]" +done +check "every such document names the versions the shipped upgrade scripts start from" \ + "${_upggot# }" "${_upgwant# }" + +_upggot="" +_upgwant="" +for _d in $_upgdocs; do + _upggot="$_upggot $(basename "$_d")=[$(_upg_target "$_d")]" + _upgwant="$_upgwant $(basename "$_d")=[$_defver]" +done +check "every such document names default_version as the version one UPDATE reaches" \ + "${_upggot# }" "${_upgwant# }" # ---- and META.json, which NOTHING read at all ------------------------------ # # `META.json` is the PGXN distribution metadata. It hardcodes the version TWICE diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index 46596d02..b63fde72 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -121,6 +121,7 @@ behaviour, the source of that number is named. - [73. test_assertion_carries_its_measurement.py: a failure must say what it measured](#73-test_assertion_carries_its_measurementpy-a-failure-must-say-what-it-measured) - [74. test_base_scan_io.py: a base scan is not priced from sibling projection pages](#74-test_base_scan_iopy-a-base-scan-is-not-priced-from-sibling-projection-pages) - [75. test_range_pruning.py: a range prunes on overlap, containment, and under its own collation](#75-test_range_pruningpy-a-range-prunes-on-overlap-containment-and-under-its-own-collation) +- [76. test_docs_upgrade_chain.py: the documented upgrade chain must be the one that ships](#76-test_docs_upgrade_chainpy-the-documented-upgrade-chain-must-be-the-one-that-ships) ## 1. How to read a test in here @@ -5830,3 +5831,152 @@ on a user-defined range type it creates for the purpose. Independent of `test/range_pruning.sh`: same public seam, own fixture, own row counts, own observations. Neither file reads or runs the other. + +## 76. test_docs_upgrade_chain.py: the documented upgrade chain must be the one that ships + +Two claims travel in one sentence. It names the installed versions a single +`ALTER EXTENSION pgcolumnar UPDATE` can start from, and the version it arrives at. +Both are knowable from the tree: + +| claim | where the tree answers it | +| --- | --- | +| the starting versions | the `pgcolumnar--A--B.sql` files, WALKED, not read as the set of `A` | +| the destination | `default_version` in `pgcolumnar.control` | + +Nothing compared them. Opening the `1.0-alpha5` cycle bumped `VERSION`, the control +file, the version badge and `META.json`, and every version arm in `docs_style.sh` +stayed green over three documents that were wrong: + +| document | starting versions it named | destination it named | +| --- | --- | --- | +| `CHANGELOG.md` | 4 of 5, `1.0-alpha4` missing | `1.0-alpha4` | +| `docs/installation.md` | all 5 | none a machine can find: "reaches **it** from" | +| `docs/limitations.md` | 3 of 5, and a hand-typed "Three such scripts" | `1.0-alpha3`, two cycles stale | + +### The two errors cancel if you count + +`CHANGELOG.md` named `1.0-alpha4` once too few as a starting version and once too +many as the destination. **The set of versions in the sentence was therefore exactly +right**, and a rule comparing that set against the tree would have passed a sentence +in which both halves were wrong. So the two claims are read separately, each against +its own source on disk. + +### Scoped to the sentence, not the paragraph + +The paragraph around the claim in `docs/installation.md` names the destination twice +more, in prose that is correct. A paragraph-wide reading counts those as starting +versions and reports a document that is right as wrong. + +### A document this rule cannot read is named, with its reason + +`reaches it from every previously published version` is correct English carrying no +destination a machine can find. An arm that skipped such a file would go quiet +exactly where the prose drifted, so the readable-form arm reports +`installation.md=no-destination` rather than passing over it. It also counts the +claim sentences and refuses a file that makes the claim twice, because reading only +the first leaves the second unchecked. + +### The claim is reachability, not membership + +"a **single** `ALTER EXTENSION pgcolumnar UPDATE` reaches `1.0-alpha5` from **any** +of them" says the shipped scripts form an unbroken chain. Reading the `A` side of +each filename cannot see that. @OffgridwithJD broke the chain without editing a +document: + + pgcolumnar--1.0-alpha2--1.0-alpha3.sql -> pgcolumnar--1.0-alpha2--1.0-alphaX.sql + +`1.0-alpha2` still starts a script, so the set of starting versions does not move, +and every arm passed on a tree where three of the five named versions cannot +arrive in one command. So the versions are **walked**: follow `A--B` to the script +starting at `B`, and keep the version only if the walk ends at `default_version`. +That subsumes the membership test, because a version whose target starts nothing +drops out of the expectation. + +A walk needs the chain to be a walk, so a second premise refuses any version that +starts more than one script rather than picking one of them silently. + +| test | what it pins | +| --- | --- | +| `test_the_tree_states_a_chain_to_compare_against` | the scripts, the control file and the population are non-empty, no version starts two scripts, and the walk reaches `default_version` from somewhere | +| `test_every_document_states_the_chain_in_a_readable_form` | one claim sentence per file, carrying both halves | +| `test_every_document_names_the_versions_that_reach_default_version` | the starting versions equal the versions the walk reaches from | +| `test_every_document_names_default_version_as_the_destination` | the destination equals `default_version` | + +### Removal proof, nine ways + +Each mutation was applied to a copy of the tree, run, and reverted; all four files +were confirmed byte-identical to the source afterwards. + +| mutation | what went red | +| --- | --- | +| `CHANGELOG.md` loses `1.0-alpha4` from its list | the starting-versions arm | +| `CHANGELOG.md` destination back to `1.0-alpha4` | the destination arm | +| `docs/installation.md` destination back to "reaches it" | the readable-form arm, and the destination arm | +| a second claim sentence in `docs/limitations.md` | the readable-form arm, `limitations.md=claims=2` | +| `pgcolumnar--1.0-alpha4--1.0-alpha5.sql` moved aside | the starting-versions arm, with the **want** side moving | +| `default_version` blanked | the control-file premise | +| the marker phrase erased in all three documents | the population premise | +| `1.0-alpha2--1.0-alpha3.sql` renamed to `--1.0-alphaX.sql` | the reachability arm, with the **want** side falling to two versions | +| a second script copied to `1.0-alpha3--1.0-alpha9.sql` | the one-script-per-version premise, `1.0-alpha3=2` | + +The fifth row is the one that shows the arm reads the disk rather than agreeing with +itself: the documents did not change and the expectation did. The eighth is the one +a set comparison cannot produce at all. + +### The splitter: what was claimed, what was measured + +@OffgridwithJD expected a markdown list marker to SEVER the claim if the sentence +were moved into numbered step 3 of `docs/installation.md`. It does not. The claim +was moved there and **both halves read the whole sentence with and without any +masking**, because a marker precedes a sentence rather than sitting inside it. No +arrangement was found in which one falls inside the claim, and that half of the +finding was withdrawn. + +**The over-match was live, and that half is measured.** The first version masked +any number followed by period-space, which also protects a sentence ENDING in a +number and merges it with the next one. Sentences found on the unmodified +documents: + +| document | column 0 only | any number | boundaries lost | +| --- | ---: | ---: | ---: | +| `docs/limitations.md` | 688 | 679 | 9 | +| `docs/installation.md` | 74 | 72 | 2 | +| `CHANGELOG.md` | 3942 | 3873 | 69 | + +No verdict moved, because none of those merged pairs put a stray version token +into the claim sentence. **That is a property of today's prose, not of the rule**, +which is why the tight form ships. + +### Column 0, and why that is measured rather than chosen + +The first tight version allowed an indented marker and read **3939** on +`CHANGELOG.md` where @OffgridwithJD read **3942**. Reconciling the three rather +than splitting the difference found all three to be wrapped prose: + +``` + 4286. The port forces the path each arm is named for and a... + 1000. Every narrowing floors, so an instant before the epo... + 1000. The constant mis-sized every scan and corrupted join... +``` + +A sentence ending in a number, wrapped so the number starts an indented line, is +the same over-match one indent to the right. Every real ordered-list marker in +these documents sits at column 0: three in `docs/installation.md`, one in +`docs/limitations.md`, none in `CHANGELOG.md`. + +### The twins fail differently, and this is where it shows + +The shell half keeps its own tally and compares strings, so an empty population +leaves its three comparison arms passing on nothing and only the premise red. The +pytest half runs under the vacuity layer, and `expect.text` refuses an empty +expectation outright: + + VacuityError: ... the expected text is empty, so anything empty satisfies it. + +So the same mutation produces one red arm in one harness and four in the other. That +is the reason to keep both: the shell twin needs its premise to stay honest, and the +pytest twin is refused before it can report a vacuous pass. + +The shell twin is six arms in `test/docs_style.sh`. It folds the file with `tr` and +cuts sentences with `sed`; this half splits on a lookbehind and collects with `re`. +The two halves share no code. diff --git a/test/pytest/expected_tests.txt b/test/pytest/expected_tests.txt index 21774d1c..cb0f9c39 100644 --- a/test/pytest/expected_tests.txt +++ b/test/pytest/expected_tests.txt @@ -251,7 +251,12 @@ # `cluster_tests` was re-derived in the same run and did NOT move -- 462 -- which # is the expected answer for a file that needs no cluster, and checking it rather # than assuming it is what this file asks for. -guard_tests 398 +# 398 -> 402: four arms in test_docs_upgrade_chain.py, over the upgrade chain three +# published documents state. Derived by collection, not by adding four: +# `402 tests collected`. `cluster_tests` was re-derived in the same run and did NOT +# move -- 463 -- which is the expected answer for a file declared in NO_CLUSTER, and +# measuring it is what tells the declaration and the split still agree. +guard_tests 402 # The complement: tests that need the driver and a throwaway cluster. Until #1016 these ran # in no CI job at all -- a quarter of the corpus, green when somebody ran them by hand and diff --git a/test/pytest/test_docs_upgrade_chain.py b/test/pytest/test_docs_upgrade_chain.py new file mode 100644 index 00000000..2d62d3d5 --- /dev/null +++ b/test/pytest/test_docs_upgrade_chain.py @@ -0,0 +1,148 @@ +"""A document naming the upgrade chain names the one the tree ships. + +Two claims travel in one sentence. It says which installed versions a single +`ALTER EXTENSION pgcolumnar UPDATE` can start from, and which version it arrives +at. Both are knowable from the tree: the `pgcolumnar--A--B.sql` files give the +starting versions, and `pgcolumnar.control` gives the arrival. + +Nothing compared them. Opening the `1.0-alpha5` cycle bumped `VERSION`, the +control file, the version badge and `META.json`, and CHANGELOG.md went on saying +that the chain starts at four versions and arrives at `1.0-alpha4`. Five ship and +it arrives at `1.0-alpha5`. `docs/limitations.md` was two cycles worse: it named +three scripts and stopped at `1.0-alpha3`. + +THE TWO ERRORS CANCEL IF YOU COUNT. CHANGELOG.md named `1.0-alpha4` once too few +as a starting version and once too many as the destination, so the SET of +versions in the sentence was exactly right. A rule comparing that set against the +tree would have passed a sentence in which both halves were wrong. So the two +claims are read separately here, each against its own source on disk. + +Read independently of docs_style.sh. That suite folds the file with `tr` and cuts +sentences with `sed`; this one splits on a lookbehind and collects with `re`, so +a parsing mistake in one is not a parsing mistake in the other. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CONTROL = ROOT / "pgcolumnar.control" +CANDIDATES = ( + [ROOT / "CHANGELOG.md", ROOT / "README.md"] + + sorted((ROOT / "docs").glob("*.md")) +) + +_CLAIM = re.compile(r"previously (?:shipped|published) version") +_VERSION = re.compile(r"`(1\.0-[a-z0-9]+)`") +_REACHES = re.compile(r"reaches `([^`]*)`") + + +def _shipped_sources(): + """The versions an upgrade script starts from, from the filenames on disk.""" + out = set() + for path in ROOT.glob("pgcolumnar--*--*.sql"): + out.add(path.name[len("pgcolumnar--"):-len(".sql")].split("--")[0]) + return out + + +def _default_version(): + for line in CONTROL.read_text(encoding="utf-8").splitlines(): + hit = re.match(r"\s*default_version\s*=\s*'([^']*)'", line) + if hit: + return hit.group(1) + return "" + + +def _claim_sentences(path): + """Every sentence in the file that makes the upgrade-chain claim. + + A LIST, not the first match. Reading only the first leaves a second sentence + unchecked, and a document that states the chain twice is the way this rule + goes quiet without anything turning red. + """ + flat = " ".join(path.read_text(encoding="utf-8").split()) + return [s for s in re.split(r"(?<=\.)\s+", flat) if _CLAIM.search(s)] + + +def _starting_versions(sentence): + """The versions named as starting points, with the destination removed first. + + The two claims share one sentence, so the destination has to come out before + the rest are collected or it reads as a starting version too. + """ + return set(_VERSION.findall(_REACHES.sub("", sentence))) + + +def _destination(sentence): + return set(_REACHES.findall(sentence)) + + +def _documents(): + return [p for p in CANDIDATES if p.is_file() and _claim_sentences(p)] + + +def test_the_tree_states_a_chain_to_compare_against(expect): + """The two sources on disk exist, so neither arm below compares with nothing.""" + expect.at_least( + len(_shipped_sources()), 1, + "premise: the tree ships upgrade scripts to derive the chain from") + expect.at_least( + len(_default_version()), 1, + "premise: the control file names a default_version to arrive at") + expect.at_least( + len(_documents()), 1, + "premise: at least one document states the upgrade chain") + + +def test_every_document_states_the_chain_in_a_readable_form(expect): + """A document this rule cannot read is how the rule stops holding. + + Named with its reason rather than merely named. `reaches it from every + previously published version` is the form that hid in `docs/installation.md`: + the sentence is correct English and carries no destination a machine can find. + """ + got, want = [], [] + for path in _documents(): + sentences = _claim_sentences(path) + if len(sentences) != 1: + reason = f"claims={len(sentences)}" + elif not _starting_versions(sentences[0]): + reason = "no-starting-versions" + elif not _destination(sentences[0]): + reason = "no-destination" + else: + reason = "readable" + got.append(f"{path.name}={reason}") + want.append(f"{path.name}=readable") + expect.text( + " ".join(got), " ".join(want), + "every document stating the upgrade chain states it in a form this rule can read") + + +def test_every_document_names_the_versions_the_scripts_start_from(expect): + """Both sides carry the versions, so a failure says what is wrong, not only where.""" + truth = " ".join(sorted(_shipped_sources())) + got, want = [], [] + for path in _documents(): + named = set() + for sentence in _claim_sentences(path): + named |= _starting_versions(sentence) + got.append(f"{path.name}=[{' '.join(sorted(named))}]") + want.append(f"{path.name}=[{truth}]") + expect.text( + " ".join(got), " ".join(want), + "every such document names the versions the shipped upgrade scripts start from") + + +def test_every_document_names_default_version_as_the_destination(expect): + truth = _default_version() + got, want = [], [] + for path in _documents(): + named = set() + for sentence in _claim_sentences(path): + named |= _destination(sentence) + got.append(f"{path.name}=[{' '.join(sorted(named))}]") + want.append(f"{path.name}=[{truth}]") + expect.text( + " ".join(got), " ".join(want), + "every such document names default_version as the version one UPDATE reaches") diff --git a/test/pytest/test_harness_deps.py b/test/pytest/test_harness_deps.py index 4fcabc04..66d6c7e2 100644 --- a/test/pytest/test_harness_deps.py +++ b/test/pytest/test_harness_deps.py @@ -129,6 +129,9 @@ # #1121's file. Parses the RESULT record format in Python and reads the runner's # text -- no connection, no cluster, no driver. "test_record_names_its_major.py", + # This branch's file. It reads three published pages, the control file and the + # upgrade-script filenames -- no connection, no cluster, no driver. + "test_docs_upgrade_chain.py", ] From 18d07c07f2dc2ee1805da2ef935bc8bbf251bb7c Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 22 Sep 2026 12:24:24 -0600 Subject: [PATCH 2/5] docs: the upgrade chain must be WALKED, not counted (#1197) Review of the first version found that reading the `A` side of each `pgcolumnar--A--B.sql` filename cannot see the claim being made. "a single `ALTER EXTENSION pgcolumnar UPDATE` reaches `1.0-alpha5` from ANY of them" says the scripts form an unbroken chain. Renaming pgcolumnar--1.0-alpha2--1.0-alpha3.sql -> pgcolumnar--1.0-alpha2--1.0-alphaX.sql leaves `1.0-alpha2` starting a script, so the set of starting versions does not move, and all four arms passed on a tree where three of the five named versions cannot arrive in one command. Reported by @OffgridwithJD, with the mutation. Each version is now followed `A--B` to the script starting at `B`, bounded against a cycle, and kept only if the walk ends at `default_version`. That subsumes the membership test. A second premise refuses any version that starts two scripts, because a walk that picks one of them silently is an opinion. The sentence splitter also stops breaking at a markdown list marker. That change carries NO removal proof and both halves say so: the claim was moved into numbered step 3 of docs/installation.md and both read the whole sentence with and without the guard, because the marker precedes the sentence rather than sitting inside it. It is kept because a fragment that is nothing but `3.` is a sentence the splitter invented, not because it was shown to matter. docs_style.sh 47 -> 55 checks; the pytest half 6 -> 8. guard_tests re-derived by collection and unmoved at 402: the two new checks are premises inside an existing test, not new tests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n --- CHANGELOG.md | 15 ++++- test/docs_style.sh | 82 ++++++++++++++++++++++++-- test/pytest/test_docs_upgrade_chain.py | 81 +++++++++++++++++++++++-- 3 files changed, 163 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ada28cc9..8058a666 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -160,17 +160,26 @@ true until the next version shipped. | `docs/installation.md` | all 5 | none a machine can find: "reaches **it** from" | | `docs/limitations.md` | 3 of 5, under a hand-typed "Three such scripts" | `1.0-alpha3` | - Both facts are knowable from the tree. The `pgcolumnar--A--B.sql` filenames give - the starting versions and `pgcolumnar.control` gives the destination, so the three + Both facts are knowable from the tree. `pgcolumnar.control` gives the destination, + and the `pgcolumnar--A--B.sql` filenames give the starting versions, so the three documents are now compared against them rather than edited by hand. + **The filenames are walked, not counted.** "a single update reaches `1.0-alpha5` + from any of them" says the scripts form an unbroken chain, which the set of `A` + sides cannot see: renaming `pgcolumnar--1.0-alpha2--1.0-alpha3.sql` to + `--1.0-alphaX.sql` leaves `1.0-alpha2` starting a script while three of the five + named versions can no longer arrive, and every arm passed on that tree. Reported + by @OffgridwithJD. Each version is now followed `A--B` to the script starting at + `B` and kept only if the walk ends at `default_version`, and a second premise + refuses any version that starts two scripts rather than picking one silently. + **The two errors in `CHANGELOG.md` cancel if you count.** It named `1.0-alpha4` once too few as a starting version and once too many as the destination, so the set of versions in the sentence was exactly right. A rule comparing that set against the tree would have passed a sentence in which both halves were wrong. The two claims are therefore read separately, each against its own source on disk. - Six arms in `test/docs_style.sh` and four tests in + Eight arms in `test/docs_style.sh` and four tests in `test/pytest/test_docs_upgrade_chain.py`. The shell half folds the file with `tr` and cuts sentences with `sed`; the pytest half splits on a lookbehind and collects with `re`, so a parsing mistake in one is not a parsing mistake in the other. diff --git a/test/docs_style.sh b/test/docs_style.sh index 4a880dc5..1509f6a6 100755 --- a/test/docs_style.sh +++ b/test/docs_style.sh @@ -360,6 +360,20 @@ check "every document names the same latest published pre-release" \ # SCOPED TO THE SENTENCE, not the paragraph. The surrounding paragraph in # docs/installation.md also names the destination twice in prose that is correct, # so a paragraph-wide reading counts the destination as a starting version. +# +# AND THE CLAIM IS REACHABILITY, NOT MEMBERSHIP. "a SINGLE update reaches X from +# ANY of them" says the scripts form an unbroken chain, and reading only the `A` +# side of each filename cannot see that. Reported by @OffgridwithJD, who broke the +# chain without editing a document: +# +# pgcolumnar--1.0-alpha2--1.0-alpha3.sql -> pgcolumnar--1.0-alpha2--1.0-alphaX.sql +# +# `1.0-alpha2` still starts a script, so the set of starting versions does not +# move and all four arms passed, on a tree where three of the five named versions +# cannot reach `default_version` in one command. So the population below is +# WALKED: from each starting version, follow `A--B` to the script starting at `B`, +# and keep it only if the walk ends at `default_version`. That subsumes the +# membership test, because a version whose target starts nothing drops out. _upgsrc="$(ls "$SRCDIR"/pgcolumnar--*--*.sql 2>/dev/null \ | sed 's|.*/pgcolumnar--||; s|\.sql$||; s|--.*||' \ | LC_ALL=C sort -u | tr '\n' ' ' | sed 's/ $//')" @@ -371,16 +385,72 @@ _defver="$(sed -n "s/^[[:space:]]*default_version[[:space:]]*=[[:space:]]*'\([^' check "premise: the control file names a default_version to arrive at" \ "$([ -n "$_defver" ] && echo yes || echo no)" "yes" +# NO VERSION MAY START TWO SCRIPTS, or the walk below would pick one of them and +# report on a chain the reader does not have. Checked rather than assumed, +# because picking silently is how a walk becomes an opinion. +_upgbranch="" +for _s in $_upgsrc; do + _n="$(ls "$SRCDIR"/pgcolumnar--"$_s"--*.sql 2>/dev/null | wc -l)" + [ "$_n" = 1 ] || _upgbranch="$_upgbranch $_s=$_n" +done +check "each shipped version starts exactly one upgrade script, so the chain is a walk" \ + "$(printf '%s' "$_upgbranch" | sed 's/^ //')" "" + +# THE WALK. From each starting version, follow `A--B` to the script starting at +# `B` until nothing starts there. Keep the version only if it arrived at +# `default_version`. The step count is bounded because a mis-generated pair of +# scripts can form a cycle, and a guard that hangs is a guard that gets removed. +_upg_step() { # _upg_step FROM -> the version its script targets, or empty + ls "$SRCDIR"/pgcolumnar--"$1"--*.sql 2>/dev/null | head -1 \ + | sed "s|.*/pgcolumnar--$1--||; s|\.sql$||" +} +_upgreach="" +for _s in $_upgsrc; do + _cur="$_s" + _steps=0 + while [ "$_cur" != "$_defver" ] && [ "$_steps" -lt 50 ]; do + _nxt="$(_upg_step "$_cur")" + [ -n "$_nxt" ] || break + _cur="$_nxt" + _steps=$((_steps + 1)) + done + [ "$_cur" = "$_defver" ] && _upgreach="$_upgreach $_s" +done +_upgreach="$(printf '%s' "$_upgreach" | sed 's/^ //')" +check "premise: some shipped version reaches default_version, so the walk found a chain" \ + "$([ -n "$_upgreach" ] && echo yes || echo no)" "yes" + # ONE SENTENCE PER FILE, and the count is checked rather than assumed: `grep -m1` # reads the first and a second would go unread, which is the silent half of the # same shape the published-release arm above was bitten by. +# A PERIOD AFTER A BARE NUMBER IS A MARKDOWN LIST MARKER, not a full stop. +# `docs/installation.md` already yields two fragments that are nothing but "2." +# and "3.". Raised by @OffgridwithJD, who expected it to sever the claim if the +# sentence were moved into numbered step 3, which is the `ALTER EXTENSION` step. +# +# NO VERDICT CHANGES TODAY, AND THAT IS STATED RATHER THAN IMPLIED. The claim was +# moved into step 3 and both halves still read the whole sentence, with and +# without this mask, because the marker always PRECEDES the sentence: splitting +# there drops the marker and leaves the claim intact. No arrangement was found in +# which a marker falls inside the claim, so this mask has no removal proof. +# +# It is kept anyway, and the reason is not "it might help". The rule this file +# enforces is that a fragment either carries the claim or is ignored, and a +# fragment that is nothing but "3." is neither -- it is a sentence the splitter +# invented. One `sed` removes a class of input the rest of the block was never +# designed to receive, and the cost of being wrong about it is zero because a +# masked marker cannot match the marker phrase either way. +_upg_sentences() { # _upg_sentences FILE -> one sentence per line + tr '\n' ' ' < "$1" \ + | sed -E 's/(^|[[:space:]])([0-9]+)\. /\1\2.@LM@/g' \ + | sed 's/\. /.\n/g' \ + | sed 's/@LM@/ /g' +} _upg_claim() { # _upg_claim FILE -> the sentence making the upgrade-chain claim - tr '\n' ' ' < "$1" | sed 's/\. /.\n/g' \ - | grep -m1 -E 'previously (shipped|published) version' + _upg_sentences "$1" | grep -m1 -E 'previously (shipped|published) version' } _upg_claims() { # _upg_claims FILE -> how many sentences make it - tr '\n' ' ' < "$1" | sed 's/\. /.\n/g' \ - | grep -cE 'previously (shipped|published) version' + _upg_sentences "$1" | grep -cE 'previously (shipped|published) version' } # The destination is removed before the starting versions are collected, because # the two claims share one sentence and each has its own arm below. @@ -419,9 +489,9 @@ _upggot="" _upgwant="" for _d in $_upgdocs; do _upggot="$_upggot $(basename "$_d")=[$(_upg_sources "$_d")]" - _upgwant="$_upgwant $(basename "$_d")=[$_upgsrc]" + _upgwant="$_upgwant $(basename "$_d")=[$_upgreach]" done -check "every such document names the versions the shipped upgrade scripts start from" \ +check "every such document names the versions that reach default_version in one update" \ "${_upggot# }" "${_upgwant# }" _upggot="" diff --git a/test/pytest/test_docs_upgrade_chain.py b/test/pytest/test_docs_upgrade_chain.py index 2d62d3d5..2bdbf085 100644 --- a/test/pytest/test_docs_upgrade_chain.py +++ b/test/pytest/test_docs_upgrade_chain.py @@ -37,11 +37,45 @@ _REACHES = re.compile(r"reaches `([^`]*)`") +def _upgrade_steps(): + """-> {starting version: [target, ...]} from the shipped script filenames.""" + steps = {} + for path in ROOT.glob("pgcolumnar--*--*.sql"): + src, _, dst = path.name[len("pgcolumnar--"):-len(".sql")].partition("--") + steps.setdefault(src, []).append(dst) + return steps + + def _shipped_sources(): """The versions an upgrade script starts from, from the filenames on disk.""" + return set(_upgrade_steps()) + + +def _reaching_versions(): + """The versions that reach `default_version` by following the shipped scripts. + + THE CLAIM IS REACHABILITY, NOT MEMBERSHIP. "a single update reaches X from any + of them" says the scripts form an unbroken chain, and the set of starting + versions cannot see that: renaming `1.0-alpha2--1.0-alpha3.sql` to + `1.0-alpha2--1.0-alphaX.sql` leaves `1.0-alpha2` starting a script while three + of the five named versions can no longer arrive. Reported by @OffgridwithJD. + + The step count is bounded because two mis-generated scripts can form a cycle, + and a guard that hangs is a guard that gets removed. + """ + steps = _upgrade_steps() + target = _default_version() out = set() - for path in ROOT.glob("pgcolumnar--*--*.sql"): - out.add(path.name[len("pgcolumnar--"):-len(".sql")].split("--")[0]) + for start in steps: + cur, walked = start, 0 + while cur != target and walked < 50: + nxt = steps.get(cur) + if not nxt: + break + cur = sorted(nxt)[0] + walked += 1 + if cur == target: + out.add(start) return out @@ -53,6 +87,34 @@ def _default_version(): return "" +def _sentences(flat): + """Split on a full stop, and NOT on a markdown list marker. + + A period after a bare number ends a list marker, not a sentence, and + `docs/installation.md` already yields two fragments that are nothing but "2." + and "3.". Raised by @OffgridwithJD. + + NO VERDICT CHANGES TODAY. The claim was moved into numbered step 3 and both + halves still read the whole sentence, with and without this guard, because the + marker PRECEDES the sentence rather than sitting inside it. This refuses a + boundary the rest of the file was never designed to receive; it is not carrying + a proof. + + A SCANNER, not a masking pass. The shell twin masks the markers, splits and + restores; this walks the candidate boundaries and refuses the ones whose left + side ends in a bare number. Same rule, and a mistake in one is not a mistake + in the other. + """ + out, start = [], 0 + for m in re.finditer(r"\.\s+", flat): + if re.search(r"(?:^|\s)\d+$", flat[start:m.start()]): + continue + out.append(flat[start:m.end()].strip()) + start = m.end() + out.append(flat[start:].strip()) + return [s for s in out if s] + + def _claim_sentences(path): """Every sentence in the file that makes the upgrade-chain claim. @@ -61,7 +123,7 @@ def _claim_sentences(path): goes quiet without anything turning red. """ flat = " ".join(path.read_text(encoding="utf-8").split()) - return [s for s in re.split(r"(?<=\.)\s+", flat) if _CLAIM.search(s)] + return [s for s in _sentences(flat) if _CLAIM.search(s)] def _starting_versions(sentence): @@ -92,6 +154,13 @@ def test_the_tree_states_a_chain_to_compare_against(expect): expect.at_least( len(_documents()), 1, "premise: at least one document states the upgrade chain") + branching = {s: len(t) for s, t in _upgrade_steps().items() if len(t) != 1} + expect.text( + " ".join(f"{s}={n}" for s, n in sorted(branching.items())) or "none", "none", + "each shipped version starts exactly one upgrade script, so the chain is a walk") + expect.at_least( + len(_reaching_versions()), 1, + "premise: some shipped version reaches default_version, so the walk found a chain") def test_every_document_states_the_chain_in_a_readable_form(expect): @@ -119,9 +188,9 @@ def test_every_document_states_the_chain_in_a_readable_form(expect): "every document stating the upgrade chain states it in a form this rule can read") -def test_every_document_names_the_versions_the_scripts_start_from(expect): +def test_every_document_names_the_versions_that_reach_default_version(expect): """Both sides carry the versions, so a failure says what is wrong, not only where.""" - truth = " ".join(sorted(_shipped_sources())) + truth = " ".join(sorted(_reaching_versions())) got, want = [], [] for path in _documents(): named = set() @@ -131,7 +200,7 @@ def test_every_document_names_the_versions_the_scripts_start_from(expect): want.append(f"{path.name}=[{truth}]") expect.text( " ".join(got), " ".join(want), - "every such document names the versions the shipped upgrade scripts start from") + "every such document names the versions that reach default_version in one update") def test_every_document_names_default_version_as_the_destination(expect): From efc6c43f9ef0efa6ae981cbc151d57d77a8ed691 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 22 Sep 2026 12:51:03 -0600 Subject: [PATCH 3/5] docs: a list marker begins a line, and the over-match was live (#1197) @OffgridwithJD expected a markdown list marker to SEVER the claim sentence if it were moved into numbered step 3 of docs/installation.md. It does not: the claim was moved there and both halves read the whole sentence with and without any masking, because a marker PRECEDES a sentence rather than sitting inside it. That half of the finding is withdrawn, and both halves now say so. The other half is real and was LIVE rather than latent. Masking any number followed by period-space also protects a sentence ENDING in a number and merges it with the next one. Sentences found on the unmodified documents: docs/limitations.md 688 line-initial 679 any-number 9 lost docs/installation.md 74 72 2 lost CHANGELOG.md 3922 3856 66 lost No verdict moved, because none of those merged pairs put a stray version token into the claim sentence. That is a property of today's prose rather than of the rule, so the tight form ships: a list marker begins a LINE, so the mask is applied per line, before the lines are joined. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n --- CHANGELOG.md | 7 +++ test/docs_style.sh | 50 +++++++++++++-------- test/pytest/test_docs_upgrade_chain.py | 60 +++++++++++++++++--------- 3 files changed, 78 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8058a666..cc1fd133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,6 +179,13 @@ true until the next version shipped. the tree would have passed a sentence in which both halves were wrong. The two claims are therefore read separately, each against its own source on disk. + The sentence splitter masks markdown list markers per line, before the lines are + joined, because a list marker begins a line and nothing else does. Masking any + number followed by period-space also protects a sentence ENDING in a number and + merged 9, 2 and 66 sentence boundaries in `docs/limitations.md`, + `docs/installation.md` and `CHANGELOG.md`. No verdict moved, which is a property + of today's prose rather than of the rule. + Eight arms in `test/docs_style.sh` and four tests in `test/pytest/test_docs_upgrade_chain.py`. The shell half folds the file with `tr` and cuts sentences with `sed`; the pytest half splits on a lookbehind and collects diff --git a/test/docs_style.sh b/test/docs_style.sh index 1509f6a6..7a9fc57c 100755 --- a/test/docs_style.sh +++ b/test/docs_style.sh @@ -423,26 +423,38 @@ check "premise: some shipped version reaches default_version, so the walk found # ONE SENTENCE PER FILE, and the count is checked rather than assumed: `grep -m1` # reads the first and a second would go unread, which is the silent half of the # same shape the published-release arm above was bitten by. -# A PERIOD AFTER A BARE NUMBER IS A MARKDOWN LIST MARKER, not a full stop. -# `docs/installation.md` already yields two fragments that are nothing but "2." -# and "3.". Raised by @OffgridwithJD, who expected it to sever the claim if the -# sentence were moved into numbered step 3, which is the `ALTER EXTENSION` step. -# -# NO VERDICT CHANGES TODAY, AND THAT IS STATED RATHER THAN IMPLIED. The claim was -# moved into step 3 and both halves still read the whole sentence, with and -# without this mask, because the marker always PRECEDES the sentence: splitting -# there drops the marker and leaves the claim intact. No arrangement was found in -# which a marker falls inside the claim, so this mask has no removal proof. -# -# It is kept anyway, and the reason is not "it might help". The rule this file -# enforces is that a fragment either carries the claim or is ignored, and a -# fragment that is nothing but "3." is neither -- it is a sentence the splitter -# invented. One `sed` removes a class of input the rest of the block was never -# designed to receive, and the cost of being wrong about it is zero because a -# masked marker cannot match the marker phrase either way. +# A LIST MARKER BEGINS A LINE, and nothing else does. `docs/installation.md` +# yields two fragments that are nothing but "2." and "3." without this, which +# @OffgridwithJD raised expecting it to SEVER the claim if the sentence were +# moved into numbered step 3. +# +# IT DOES NOT SEVER ANYTHING, AND THAT IS STATED RATHER THAN IMPLIED. The claim +# was moved into step 3 and both halves read the whole sentence with and without +# any masking, because a marker PRECEDES a sentence rather than sitting inside +# it. No arrangement was found in which one falls inside the claim. +# +# WHAT IS MEASURED IS THE OVER-MATCH, AND IT WAS LIVE. The first version of this +# masked ANY number followed by period-space, which also protects a sentence +# ENDING in a number and merges it with the next one. On the unmodified +# documents, sentences found by each rule: +# +# docs/limitations.md 688 line-initial 679 any-number 9 lost +# docs/installation.md 74 72 2 lost +# CHANGELOG.md 3922 3856 66 lost +# +# No verdict moved, because none of those merged pairs put a stray version token +# into the claim sentence. That is a property of today's prose, not of the rule, +# which is why the tight form is the one that ships: masking per line, before +# `tr` joins them, is the marker's own definition rather than a pattern that +# resembles it. _upg_sentences() { # _upg_sentences FILE -> one sentence per line - tr '\n' ' ' < "$1" \ - | sed -E 's/(^|[[:space:]])([0-9]+)\. /\1\2.@LM@/g' \ + # MASKED PER LINE, BEFORE `tr` JOINS THEM, because a list marker BEGINS a + # line and nothing else does. The first version masked any number followed + # by period-space, which also protects a sentence ENDING in a number and + # merges it with the next one -- demonstrated by @OffgridwithJD with + # `The corpus held 1444. ` injected ahead of the claim. + sed -E 's/^([[:space:]]*)([0-9]+)\. /\1\2.@LM@/' "$1" \ + | tr '\n' ' ' \ | sed 's/\. /.\n/g' \ | sed 's/@LM@/ /g' } diff --git a/test/pytest/test_docs_upgrade_chain.py b/test/pytest/test_docs_upgrade_chain.py index 2bdbf085..2bb41994 100644 --- a/test/pytest/test_docs_upgrade_chain.py +++ b/test/pytest/test_docs_upgrade_chain.py @@ -87,32 +87,52 @@ def _default_version(): return "" +_MARKER = re.compile(r"^(\s*)(\d+)\. ") +_MASK = "@LM@" + + +def _flatten(text): + """One line, with markdown list markers masked so they are not full stops. + + A LIST MARKER BEGINS A LINE, and nothing else does. The first version of this + masked any number followed by period-space, which also protects a sentence + ENDING in a number and merges it with the next one. @OffgridwithJD + demonstrated it by injecting `The corpus held 1444. ` ahead of the claim, so + the masking happens per line, before the lines are joined. + + NO VERDICT MOVES EITHER WAY TODAY, and that is stated rather than implied. The + claim was moved into numbered step 3 and both halves read the whole sentence + with and without any masking, because a marker PRECEDES a sentence rather than + sitting inside it. + + THE OVER-MATCH WAS LIVE, THOUGH. Sentences found on the unmodified documents: + + docs/limitations.md 688 line-initial 679 any-number 9 lost + docs/installation.md 74 72 2 lost + CHANGELOG.md 3922 3856 66 lost + + None of those merged pairs put a stray version token into the claim sentence, + so no verdict changed. That is a property of today's prose rather than of the + rule. + """ + masked = [_MARKER.sub(lambda m: m.group(1) + m.group(2) + "." + _MASK, line, count=1) + for line in text.splitlines()] + return " ".join(" ".join(masked).split()) + + def _sentences(flat): - """Split on a full stop, and NOT on a markdown list marker. - - A period after a bare number ends a list marker, not a sentence, and - `docs/installation.md` already yields two fragments that are nothing but "2." - and "3.". Raised by @OffgridwithJD. - - NO VERDICT CHANGES TODAY. The claim was moved into numbered step 3 and both - halves still read the whole sentence, with and without this guard, because the - marker PRECEDES the sentence rather than sitting inside it. This refuses a - boundary the rest of the file was never designed to receive; it is not carrying - a proof. - - A SCANNER, not a masking pass. The shell twin masks the markers, splits and - restores; this walks the candidate boundaries and refuses the ones whose left - side ends in a bare number. Same rule, and a mistake in one is not a mistake - in the other. + """Split on a full stop. + + A SCANNER, not a split. The shell twin runs `sed` over the file and cuts with + a second `sed`; this walks the candidate boundaries and keeps each sentence + with its own period, so a mistake in one is not a mistake in the other. """ out, start = [], 0 for m in re.finditer(r"\.\s+", flat): - if re.search(r"(?:^|\s)\d+$", flat[start:m.start()]): - continue out.append(flat[start:m.end()].strip()) start = m.end() out.append(flat[start:].strip()) - return [s for s in out if s] + return [s.replace(_MASK, " ").strip() for s in out if s.strip()] def _claim_sentences(path): @@ -122,7 +142,7 @@ def _claim_sentences(path): unchecked, and a document that states the chain twice is the way this rule goes quiet without anything turning red. """ - flat = " ".join(path.read_text(encoding="utf-8").split()) + flat = _flatten(path.read_text(encoding="utf-8")) return [s for s in _sentences(flat) if _CLAIM.search(s)] From acc4116dda6abac1bebe6a482a234cd97eca6f04 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 22 Sep 2026 13:00:56 -0600 Subject: [PATCH 4/5] docs: the marker mask is anchored at column 0, and that is measured (#1197) @OffgridwithJD re-ran the sentence counts on 5649eba and got 3942 on CHANGELOG.md where I had 3939. The `over` count agreed exactly on all three documents, so the disagreement was in the tight rule, not in the thing it was being compared with. Reconciled rather than split. The three lines are not list markers: 4286. The port forces the path each arm is named for and a... 1000. Every narrowing floors, so an instant before the epo... 1000. The constant mis-sized every scan and corrupted join... Each is a sentence ending in a number, wrapped so the number starts an indented line. Allowing an indented marker is therefore the same over-match this change exists to remove, one indent to the right. Every real ordered-list marker in these documents is at column 0: three in docs/installation.md, one in docs/limitations.md, none in CHANGELOG.md. So the mask is anchored there, in both halves, and the recorded numbers become docs/limitations.md 688 column-0 679 any-number 9 lost docs/installation.md 74 72 2 lost CHANGELOG.md 3942 3873 69 lost which is what both of us now measure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n --- CHANGELOG.md | 8 +++++++- test/docs_style.sh | 26 +++++++++++++++++++------- test/pytest/test_docs_upgrade_chain.py | 17 ++++++++++++----- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1fd133..9cc7e6f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,10 +182,16 @@ true until the next version shipped. The sentence splitter masks markdown list markers per line, before the lines are joined, because a list marker begins a line and nothing else does. Masking any number followed by period-space also protects a sentence ENDING in a number and - merged 9, 2 and 66 sentence boundaries in `docs/limitations.md`, + merged 9, 2 and 69 sentence boundaries in `docs/limitations.md`, `docs/installation.md` and `CHANGELOG.md`. No verdict moved, which is a property of today's prose rather than of the rule. + The mask is anchored at column 0. Allowing an indented marker read 3939 + sentences in `CHANGELOG.md` against @OffgridwithJD's 3942, and all three lines + that differ are wrapped prose rather than list items, which is the same + over-match one indent to the right. Every ordered-list marker in these documents + is at column 0. + Eight arms in `test/docs_style.sh` and four tests in `test/pytest/test_docs_upgrade_chain.py`. The shell half folds the file with `tr` and cuts sentences with `sed`; the pytest half splits on a lookbehind and collects diff --git a/test/docs_style.sh b/test/docs_style.sh index 7a9fc57c..b771238d 100755 --- a/test/docs_style.sh +++ b/test/docs_style.sh @@ -438,22 +438,34 @@ check "premise: some shipped version reaches default_version, so the walk found # ENDING in a number and merges it with the next one. On the unmodified # documents, sentences found by each rule: # -# docs/limitations.md 688 line-initial 679 any-number 9 lost -# docs/installation.md 74 72 2 lost -# CHANGELOG.md 3922 3856 66 lost +# docs/limitations.md 688 column-0 679 any-number 9 lost +# docs/installation.md 74 72 2 lost +# CHANGELOG.md 3942 3873 69 lost # # No verdict moved, because none of those merged pairs put a stray version token # into the claim sentence. That is a property of today's prose, not of the rule, -# which is why the tight form is the one that ships: masking per line, before -# `tr` joins them, is the marker's own definition rather than a pattern that -# resembles it. +# which is why the tight form is the one that ships. +# +# COLUMN 0, NOT "LINE-INITIAL AFTER INDENT", and that distinction was measured +# rather than chosen. Allowing an indented marker read 3939 on CHANGELOG.md +# against @OffgridwithJD's 3942, and reconciling the three rather than splitting +# the difference found them all to be WRAPPED PROSE, not list items: +# +# 4286. The port forces the path each arm is named for and a... +# 1000. Every narrowing floors, so an instant before the epo... +# 1000. The constant mis-sized every scan and corrupted join... +# +# A sentence ending in a number, wrapped so the number starts an indented line, +# is the same over-match one indent to the right. Every real ordered-list marker +# in these documents sits at column 0: three in docs/installation.md, one in +# docs/limitations.md, none in CHANGELOG.md. _upg_sentences() { # _upg_sentences FILE -> one sentence per line # MASKED PER LINE, BEFORE `tr` JOINS THEM, because a list marker BEGINS a # line and nothing else does. The first version masked any number followed # by period-space, which also protects a sentence ENDING in a number and # merges it with the next one -- demonstrated by @OffgridwithJD with # `The corpus held 1444. ` injected ahead of the claim. - sed -E 's/^([[:space:]]*)([0-9]+)\. /\1\2.@LM@/' "$1" \ + sed -E 's/^([0-9]+)\. /\1.@LM@/' "$1" \ | tr '\n' ' ' \ | sed 's/\. /.\n/g' \ | sed 's/@LM@/ /g' diff --git a/test/pytest/test_docs_upgrade_chain.py b/test/pytest/test_docs_upgrade_chain.py index 2bb41994..dddbe99f 100644 --- a/test/pytest/test_docs_upgrade_chain.py +++ b/test/pytest/test_docs_upgrade_chain.py @@ -87,7 +87,7 @@ def _default_version(): return "" -_MARKER = re.compile(r"^(\s*)(\d+)\. ") +_MARKER = re.compile(r"^(\d+)\. ") _MASK = "@LM@" @@ -107,15 +107,22 @@ def _flatten(text): THE OVER-MATCH WAS LIVE, THOUGH. Sentences found on the unmodified documents: - docs/limitations.md 688 line-initial 679 any-number 9 lost - docs/installation.md 74 72 2 lost - CHANGELOG.md 3922 3856 66 lost + docs/limitations.md 688 column-0 679 any-number 9 lost + docs/installation.md 74 72 2 lost + CHANGELOG.md 3942 3873 69 lost None of those merged pairs put a stray version token into the claim sentence, so no verdict changed. That is a property of today's prose rather than of the rule. + + COLUMN 0, NOT "LINE-INITIAL AFTER INDENT". Allowing an indented marker read + 3939 on CHANGELOG.md against @OffgridwithJD's 3942, and the three lines that + differ are wrapped prose rather than list items -- a sentence ending in a + number, wrapped so the number starts an indented line, which is the same + over-match one indent to the right. Every real ordered-list marker in these + documents is at column 0. """ - masked = [_MARKER.sub(lambda m: m.group(1) + m.group(2) + "." + _MASK, line, count=1) + masked = [_MARKER.sub(lambda m: m.group(1) + "." + _MASK, line, count=1) for line in text.splitlines()] return " ".join(" ".join(masked).split()) From 58514c82d8c5f994ede756945910816e45d252d5 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Tue, 22 Sep 2026 13:07:43 -0600 Subject: [PATCH 5/5] docs: a count about a growing file needs the revision beside it (#1197) The sentence counts recorded for the marker mask read 66 boundaries lost on CHANGELOG.md at 5649eba, 69 at 197602f and 71 at acc4116d. Nothing about the rule changed; the file grew three times in an afternoon. That is the defect check_ledger_budget.txt already records about its own example -- a frozen number in a comment about a moving corpus goes stale by construction, and the comment then sits directly above the value it contradicts. Reported against this branch by @OffgridwithJD, who read 71 where the file said 69. Every copy now quotes the revision it was measured at, and says plainly that the counts drift while the rule does not. The three wrapped CHANGELOG lines stay as the durable half: they do not drift, and they are what decided that the mask belongs at column 0 rather than after an indent. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XiFn3HteTXnGdRiA2xDP2n --- CHANGELOG.md | 6 ++++-- test/docs_style.sh | 15 ++++++++++++--- test/pytest/TESTS.md | 9 +++++++-- test/pytest/test_docs_upgrade_chain.py | 10 +++++++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc7e6f6..768eb2eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,8 +182,10 @@ true until the next version shipped. The sentence splitter masks markdown list markers per line, before the lines are joined, because a list marker begins a line and nothing else does. Masking any number followed by period-space also protects a sentence ENDING in a number and - merged 9, 2 and 69 sentence boundaries in `docs/limitations.md`, - `docs/installation.md` and `CHANGELOG.md`. No verdict moved, which is a property + merged 9, 2 and 71 sentence boundaries in `docs/limitations.md`, + `docs/installation.md` and `CHANGELOG.md`, measured at `acc4116d`. The counts + drift as those files grow, so the revision is quoted with them; the three named + lines below do not drift and are what decided the rule. No verdict moved, which is a property of today's prose rather than of the rule. The mask is anchored at column 0. Allowing an indented marker read 3939 diff --git a/test/docs_style.sh b/test/docs_style.sh index b771238d..8e4ffe46 100755 --- a/test/docs_style.sh +++ b/test/docs_style.sh @@ -438,9 +438,18 @@ check "premise: some shipped version reaches default_version, so the walk found # ENDING in a number and merges it with the next one. On the unmodified # documents, sentences found by each rule: # -# docs/limitations.md 688 column-0 679 any-number 9 lost -# docs/installation.md 74 72 2 lost -# CHANGELOG.md 3942 3873 69 lost +# MEASURED AT acc4116d, AND THE SHA IS THE LOAD-BEARING PART. These documents +# grow, so the counts drift with them: CHANGELOG.md read 66 lost at 5649eba, 69 at +# 197602f and 71 here, with nothing about the rule changing. A frozen number in a +# comment about a growing file goes stale by construction, which is the lesson +# test/check_ledger_budget.txt already carries about its own example. +# +# docs/limitations.md 699 column-0 690 any-number 9 lost +# docs/installation.md 74 72 2 lost +# CHANGELOG.md 3957 3886 71 lost +# +# THE NAMED LINES BELOW ARE THE DURABLE HALF. They do not drift, and they are what +# the rule was decided on. # # No verdict moved, because none of those merged pairs put a stray version token # into the claim sentence. That is a property of today's prose, not of the rule, diff --git a/test/pytest/TESTS.md b/test/pytest/TESTS.md index b63fde72..0fd4e56a 100644 --- a/test/pytest/TESTS.md +++ b/test/pytest/TESTS.md @@ -5937,11 +5937,16 @@ any number followed by period-space, which also protects a sentence ENDING in a number and merges it with the next one. Sentences found on the unmodified documents: +**Measured at `acc4116d`, and the revision is the load-bearing part.** These +documents grow, so the counts drift with them and the rule does not: `CHANGELOG.md` +read 66 boundaries lost at `5649eba`, 69 at `197602f` and 71 here. A frozen number +in a note about a growing file goes stale by construction. + | document | column 0 only | any number | boundaries lost | | --- | ---: | ---: | ---: | -| `docs/limitations.md` | 688 | 679 | 9 | +| `docs/limitations.md` | 699 | 690 | 9 | | `docs/installation.md` | 74 | 72 | 2 | -| `CHANGELOG.md` | 3942 | 3873 | 69 | +| `CHANGELOG.md` | 3957 | 3886 | 71 | No verdict moved, because none of those merged pairs put a stray version token into the claim sentence. **That is a property of today's prose, not of the rule**, diff --git a/test/pytest/test_docs_upgrade_chain.py b/test/pytest/test_docs_upgrade_chain.py index dddbe99f..ea427d2e 100644 --- a/test/pytest/test_docs_upgrade_chain.py +++ b/test/pytest/test_docs_upgrade_chain.py @@ -107,9 +107,13 @@ def _flatten(text): THE OVER-MATCH WAS LIVE, THOUGH. Sentences found on the unmodified documents: - docs/limitations.md 688 column-0 679 any-number 9 lost - docs/installation.md 74 72 2 lost - CHANGELOG.md 3942 3873 69 lost + Measured at acc4116d, and the revision is the load-bearing part: these + documents grow, so CHANGELOG.md read 66 lost at 5649eba, 69 at 197602f and 71 + here, with nothing about the rule changing. + + docs/limitations.md 699 column-0 690 any-number 9 lost + docs/installation.md 74 72 2 lost + CHANGELOG.md 3957 3886 71 lost None of those merged pairs put a stray version token into the claim sentence, so no verdict changed. That is a property of today's prose rather than of the