diff --git a/.github/workflows/model-contracts.yml b/.github/workflows/model-contracts.yml index 8ac85f6..3b46c42 100644 --- a/.github/workflows/model-contracts.yml +++ b/.github/workflows/model-contracts.yml @@ -187,6 +187,54 @@ jobs: " -- a check was added or silently lost; update EXPECTED_CHECKS") cat("R verifier ran complete.\n") + # PP-SORP-01's producer. The ledger quotes K = 29.72 as the concentration + # ratio at which the shipped Henry form and Langmuir diverge by 10%; a + # number nobody can re-run is the defect PP-62-11 records and the P1 Codex + # raised on #23. Run here because this job already has R and deSolve. + - name: Reproduce the Henry/Langmuir divergence bound + run: Rscript analysis/henry_langmuir_bound.R --report hl-verify.json + + # Same receipt discipline as above, and for the same reason: the producer + # reports SKIPPED rather than failing when deSolve is missing, so a job + # whose setup step was dropped would exit 0 having measured nothing. + - name: Assert the divergence-bound producer ran everything + shell: Rscript {0} + run: | + EXPECTED_CHECKS <- 3L + if (!file.exists("hl-verify.json")) + stop("no receipt: the divergence-bound producer did not complete") + r <- jsonlite::fromJSON("hl-verify.json", simplifyVector = TRUE) + for (k in c("checks_run", "failures", "skipped", "complete")) + if (is.null(r[[k]])) stop("receipt is missing '", k, "'") + if (!isTRUE(r$complete)) + stop("receipt does not declare itself complete: complete=", + format(r$complete)) + int1 <- function(k) { + v <- r[[k]] + if (!is.numeric(v) || length(v) != 1L || is.na(v) || + v < 0 || v != trunc(v)) + stop("receipt field '", k, "' is not a non-negative integer ", + "scalar: ", paste(format(v), collapse = ", ")) + as.integer(v) + } + ran <- int1("checks_run"); fail <- int1("failures"); skip <- int1("skipped") + if (skip > 0L) + stop("producer skipped ", skip, " check(s): ", + paste(unlist(r$skips), collapse = "; ")) + if (fail > 0L) stop("producer reported ", fail, " failure(s)") + if (ran != EXPECTED_CHECKS) + stop("expected ", EXPECTED_CHECKS, " checks, receipt says ", ran, + " -- update EXPECTED_CHECKS deliberately") + # AND THE QUOTED NUMBER ITSELF, because "3 checks passed" does not say + # WHICH bound they passed about. The ledger cites 29.72; if the + # producer starts emitting a different K the row is stale and this is + # the only place that would notice. + if (!is.numeric(r$K_transient) || abs(r$K_transient - 29.72) > 0.01) + stop("K_transient drifted from the value PP-SORP-01 quotes (29.72): ", + format(r$K_transient)) + cat(sprintf("divergence-bound producer complete; K_transient = %.2f\n", + r$K_transient)) + manuscript-build: runs-on: ubuntu-latest timeout-minutes: 25 diff --git a/AGENTS.md b/AGENTS.md index e07732a..e25be7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,11 +27,115 @@ nothing. - `authorization_criteria` mapped gate refusals to criteria by substring and treated an unmatched refusal as satisfied. - `plot_layer` referenced two names it never imported. +- The figure phrase guard was "confirmed" by appending the two withdrawn + sentences to a `.txt` sidecar as clean lines and watching the guard fail. + `pdftotext -layout` reads a three-column figure **across**, so the real + extraction interleaves the columns and the sentence is contiguous nowhere. + `FIG-09` and `FIG-10` were enforced by nothing for a full commit while a + control reported otherwise. Fixed by taking the known-bad from the artifact + path — `c2219a2:preprint/figures/phase2_diffusion_cell.txt` — instead of + writing one. When you add a guard, add the input that proves it bites. When you review one, ask what would have to be true for it to fail, and check that something makes it so. +**Two failure modes are invisible from reading the assertion, and they are a pair.** +Everything above asks whether a check *can* fire. These ask something else. + +*The assertion that was never load-bearing.* **Weaken it to something trivially true and +re-run the controls that are supposed to fail.** If those still fail, something else was +catching them and the assertion is not what it appears to be; if they now pass, the +assertion was load-bearing. + +State it that way and not as "weaken it and see if the suite stays green" — **a green suite +stays green under any weakening**, because the assertion was passing anyway. The weakening +only discriminates against an input the assertion is meant to reject. That correction was +itself made here after writing the looser form into this file and running it: weakening the +bibliography set-assertion to `@test true` left 40 passing, which established nothing. + +The real instance: a same-run identity assertion in `test_figure_staleness.py` that cannot +fail while the function it guards is correct, because that function locates the element by +content and replaces only within the located slice. It is kept, and relabelled as +documentation of an invariant rather than presented as a control. + +*The input that never changed.* A mutation check can be correct in every line and still +prove nothing, because the mutation did not apply. Reproducing a finding here, a `replace` +against normalised text missed a source storing XML entities (`×`), changed nothing, +and the guard's truthful "nothing missing" over an unmodified file read exactly like +confirmation. **Assert that the mutation applied, and assert WHERE it applied** — a bare +`mutated != source` passes on a replacement that landed in a different element entirely, +which was the second defect in the same three lines. + +Neither is visible by reading the assertion. One is an assertion that cannot fail; the +other is an input that never changed. + +**And "cannot fail" is not the same as "tautological", which is a distinction worth keeping +straight before deleting anything** — dimensional analysis and conservation arguments are +tautological and are among the most productive checks here. See *Necessary truths: +constraining versus restating* below. + +**The assertion must consume the finest-grained thing the function returns.** If a +function computes categories and the test compares their union, the categories are +documentation. Three guards here have failed this way and each fix was one line: +a count compared where the function returned a set; a subset counted where full +coverage was asserted; and category membership printed on mismatch while only the +union was checked, so a real citation gap could be relabelled "deliberate +context" with the suite green. **In all three the discriminating data was already +computed and discarded at the assertion line.** + +**The victim of a mutation is SELECTED FROM THE ARTIFACT, never recalled.** Extract +the runs, the rows, the citations from the file as it is now; pick one from that +extraction; mutate it. Do not reach for a string you remember being there. + +This has failed twice with different causes and the same outcome — an attack that +reports ESCAPES having tested nothing, which reads as confirmation of the very +finding being chased. Once the mutation could not apply because the source stores +XML entities and the replacement was written against normalised text. Once the +victim phrase had been withdrawn from that figure months earlier and was no longer +in the file at all. "Assert the mutation applied" catches both, and did not become +reflexive; selecting from the artifact removes the opportunity. + +**And it is the same root as reading a paper's title as its finding.** Both are +reaching from memory where the artifact was available: a withdrawn phrase recalled +into an attack, an implication recalled out of a title. The repair is one habit in +two places — open the thing before writing the sentence about it. + +**Restore a mutation from a scratchpad copy, never with `git checkout`.** A +mutation check edits a real file; undoing it with `git checkout -- ` reverts +*everything* uncommitted in that file, not the mutation. That destroyed four +applied reference fixes here in one command. `checkout` reads as an undo and is +not one when the file carries unstaged work, and the loss is silent — the suite +goes green because the mutation is gone, and so is the work. Copy to the +scratchpad before mutating, restore from the copy after. + +**A paper's title is not its finding, and the audit already knows the difference.** +The sharpest version of the summary defect has no external symptom at all: the +citation is correct, the DOI resolves, nothing drifts, every guard passes — and +the sentence still misreports the source, because the title was read as the +result. §2.6 cited Khajo 2011 as reporting protection from lethal-dose gamma; its +endpoints are EPR, absorbance, TBARS and Bi binding, and +`docs/research/radiotrophic_compatibility_audit.md:230` says in as many words that +the survival comparison is not performed there. That row was in this repository, +unread, when the sentence was written. + +**No guard reaches this, and proposing one is the wrong lesson.** The obligation is +procedural: a paper entering a section on evidence has its ENDPOINT read from the +audit before the sentence is written. The audit exists because titles and endpoints +diverge, and it already holds both. + +**And draw the known-bad from the artifact path, never from your own hand.** A +control you construct tests your idea of the failure; one recovered from where +the real input comes from tests the pipeline. Where a guard reads a *derived* +artifact — an extraction, a render, a serialisation — **the derivation's shape +is part of the contract**, and a hand-written input silently opts out of it. The +sidecar case is the clean example and it is retroactive: `.txt` sidecars exist +because the FIG-01–07 lesson was that images hide claims, so the extraction is +what gets read — and nobody had written down that the *shape* of that extraction +was therefore load-bearing too. This failure mode is not domain-versus-range, +not a coverage gap, and not a vacuous assertion. It is a control that never met +the pipeline. + ### 2. A skipped test is uncovered surface, not a neutral fact `plot_layer` shipped a `NameError` on its first rendering line behind eight @@ -102,6 +206,50 @@ list, which reintroduced exactly that ambiguity.) If a change would move any of these, stop and say so. Do not implement it. +## Necessary truths: constraining versus restating + +Rule 1 can be misread as "a tautology is a defect." It is not, and flattening three +different things under that word has already caused trouble here. **The working distinction +is not tautological versus not. It is whether a necessary truth CONSTRAINS — forbids +something — or merely RESTATES.** + +**1. The vacuous assertion. A defect, full stop.** An assertion that stays green when +weakened to something trivially true forbids nothing. It consumed a slot in the suite and +reported confidence it had not earned. Delete it, or — if it documents an invariant the +surrounding construction actually provides — say so in the file and stop calling it a +control. The mechanical test is in rule 1. + +**2. Contingent circularity. Not a tautology, and not empty.** A model that recovers its +own assumption is not true by logical form; it is a correct implementation of a premise. +The accurate statement is that **the model is not independent evidence for its own +assumption** — non-informative about the thing it appears to be about, which is different +from vacuous. This repository has several: `README.md:413` records that the melanin +ordering among the three producers "is set by the input `α_M`", and +`docs/preprint_revision_plan.md:107` records a §6.3 scatter that "is a scatter of Table 2's +own entries." The `β_ion` column has the same shape, spending survival evidence on a +tropism coefficient. None of these is a bug in the code. Each is a claim that must not be +reported as a result. + +**3. The load-bearing necessary truth. Tautological and indispensable.** Dimensional +analysis tells you nothing you did not put in — Buckingham π adds no physics — and it is +how the `D_eff` collision was caught and how the units error behind +`RADIODIALYSIS: BLOCKED` was found. Conservation arguments are tautological in the same +sense. So is the shell derivation in `docs/guides/calculus_in_this_code.md`: two face areas +and a volume, no new physics, and the most useful page in the document. **What makes these +productive is that the consequences are not obvious from the premises even though they +follow necessarily.** They forbid things — a film-scale `D_eff` above `D₀`, an occupancy +fraction in a `g cm⁻³` slot, a slab operator with an `r` in it. + +**A check that forbids nothing and a model that predicts only its inputs fail the same +way**, which is why they belong in one place rather than two. + +And the same test applies to the apparatus as a whole. A repository of guards that +collectively forbid nothing is the first failure at a larger scale. **Naming which claims +are pinned and which are not is what stops that** — the coverage output that lists +undetectable ledger rows by name, the guide's paragraph saying which of its prose claims no +table pins, the SCOPE line on every absence claim. Those exist so the apparatus cannot +quietly become a restatement of its own inputs. + ## Correcting a published number Any number this repository has published and later found wrong gets **all** of: @@ -110,13 +258,92 @@ Any number this repository has published and later found wrong gets **all** of: 2. a row in `data/claims_ledger.csv` with verdict `delete` / `restate` / `requalify` / `keep`, stating what was wrong and how it was caught, 3. a marked correction in the document that carried it — not a silent edit, -4. **and an audit of everything computed FROM it.** A withdrawn measurement + **which names the ledger row and never repeats the withdrawn content.** The + row holds the wording; the artifact must not. Repeating a withdrawn DOI, range + or formula inside a correction comment leaves it in the file someone copies + from, and every guard written afterwards will fire on the correction itself. + This happened three times in one session — a withdrawn DOI in a LaTeX comment, + a second in the same file, a withdrawn range in an R comment — each time in a + correction whose whole purpose was removing that string. + + **The reason it recurs is structural, not careless: writing a correction and + writing a guard against the thing corrected are the same act, and the + correction is authored in the window where the guard does not exist yet.** So + the convention has to be a habit rather than something the suite catches, + because at the moment of writing there is nothing to catch it. + +4. **and, when what you closed was an ABSENCE, an audit of everything that + asserted that absence.** Step 5 audits what was computed *from* a corrected + number. This is its sibling pointed backwards: **a document whose job is + recording what is unenforced becomes wrong the moment the enforcement ships.** + Every "remains unenforced pending X", "not built", "named, not fixed" entry is + a claim with an expiry date nobody sets. + + It recurs by construction, not by carelessness — the entry is written when the + gap is real, and closing the gap is a separate act in a separate commit that + has no reason to touch the document. On 2026-08-31 a red-team line read + *"RM-G04-01 remains unenforced pending RETRACTED_IN_SOURCES"* for several hours + after that guard shipped with RM-G04-01's string in its vocabulary. + + **Attribution-with-correction is permitted only while the string is in no + retraction vocabulary.** Naming what an earlier report claimed and correcting + it in the same breath is ordinary practice, and + `coupling/biofilm_openmc/mesh.py:47` does it well. But once a string enters + `RETRACTED_IN_SOURCES` or `RETRACTED_CITATIONS`, the artifact carries it in no + form and the row holds it alone. Vocabulary membership is the trigger because + it tracks the two harms separately: the copy risk is low while the string is + visibly marked withdrawn in place, and the guard-collision risk is *created* by + adding it. **A comment that was compliant becoming a failure the day its string + is guarded is the guard working, not a regression.** + + **A superseded record is exempt, and it declares that with a fixed token so + the scope of this audit is a grep rather than a memory.** The first line of a + superseded document is: + + ``` + SUPERSEDED: + ``` + + Step 4's scope is then `grep -L '^SUPERSEDED:' docs/**/*.md`, which is the live + set. A live record may not be stale; a superseded one may. + + **The token is deliberately not the English word, because that word is already + taken here and means the opposite.** `docs/calibration/reference_d_measurement + _protocol.md` opens `**Frozen:** 2026-08-15` — frozen as in *pinned and + binding*, a live authoritative document. Matching on "frozen" or "superseded" + as prose would exempt it, and would also match + `docs/correspondence/wan_v11_note.md`, where "superseded" appears in the body + describing the note's own content. Surveyed: 3 of 38 documents carry anything + banner-like, and all three mean different things. A natural-language marker + would have been actively wrong rather than merely unreliable. + +5. **and, if the row prescribes a NUMBER, it states the derivation rather than + the result.** "The argument must equal the bibitem count" survives drift; + "correct the argument to 44" does not. `PP-REF-07` said 44, was right when + written, and was wrong when applied months later against 61 bibitems — + applying the row's own number would have introduced the defect it exists to + remove. + + **This is the third variant of one family and it is not the same as rule 4.** + Rule 4 is *closing a gap makes the records of that gap stale*. This is *a + prescribed fix carries a derived value, and the derivation's inputs move while + the prescription sits unapplied*. The fix was never wrong; its inputs were. + + **Nothing mechanical caught it, and nothing could have.** No guard fired — the + number was noticed while applying the row by hand. A prescribed value with a + stale derivation is invisible to every tier here, because **the row is + internally consistent, the artifact is internally consistent, and only their + relation is wrong.** There is no artifact to compare against: the prescription + is not yet applied, so nothing has both halves. Hence a convention rather than + a check. Any row prescribing a number carries the rule that produced it. + +6. **and an audit of everything computed FROM it.** A withdrawn measurement takes its dependents with it. The 3.2 µm ladder row was withdrawn and its own headline corrected twice, while the interface-area range published a few lines below — `0.41–0.51 across 16×` — kept both of its halves from that row: 0.41 was its error, and 16× was 3.2 → 0.2. The correction stood next to a number that contradicted it. Grep the value, and grep what it was divided by. -5. If an earlier correction claimed "every other conclusion is unchanged" and +7. If an earlier correction claimed "every other conclusion is unchanged" and this one disproves it, **delete that sentence — do not reword it.** A paraphrase is the same claim: "the conclusions themselves are unchanged" replaced the original here and sat one line above the paragraph documenting @@ -126,6 +353,136 @@ Any number this repository has published and later found wrong gets **all** of: A correction that only changes the number teaches nobody why it was wrong, and this repository has now corrected its own corrections twice. +**A write to an external system is read back before it is reported as done.** +This is the identifier rule pointed at a different verb: there, an identifier is +read back against the registry that issues it; here, a write is read back from +the system that accepted it. Both exist because the failure is silent. + +**The general form, because there are now three instances and one shape: an +operation's result is confirmed before anything is concluded from it. Writes get +read back; reads get checked non-empty.** + +The read direction's instance: `gh run view --log --job=` returned **0 +lines**, and a grep over it for `undefined` found nothing. That silence was one +step from being reported as a clean LaTeX build. **An empty file and a clean file +grep identically**, and nothing in the pipeline flags that nothing was produced. +The same job's log fetched through `gh api +repos///actions/jobs//logs` returned **121,872 bytes**, and only +then did the greps mean anything -- the final `latexmk` pass had zero undefined +references, which the empty log could not have told anyone. + +The rule is not "prefer `gh api`"; that is a workaround for one tool on one day. +It is: **assert the artifact is non-empty before searching it**, whatever +produced it. A search over nothing returns nothing, and nothing is +indistinguishable from a clean result at the point where the conclusion gets +drawn. This is the no-op mutation in a different costume -- the command +succeeded, produced nothing, and the emptiness was invisible exactly where it +mattered. + +The instance: `gh pr edit` failed on a deprecated Projects-classic GraphQL call, +printed that deprecation as a *notice*, **exited zero, and left the body +unchanged**. Nothing in the output distinguished it from success. The correction +only landed because the live body was grepped afterwards, and it took a REST +`PATCH` to actually write. **So the class is not `gh pr edit`** — it is any +subcommand that can warn, exit zero, and not do the thing, which is why the +read-back attaches to the write rather than to the command. + +A repo-wide grep found **no scripted external writes at all**: every `gh` call +in `scripts/` and `CLAUDE.md` is a read (`pr view`, `repo view`, eight +`query(` and zero `mutation`), and CI's one `curl` is already followed by +`sha256sum -c -`, so a truncated fetch fails loudly. **The exposure is entirely +in ad-hoc agent practice**, which is precisely why it belongs in this file +rather than in a lint. + +**The audit-dependents step covers artifacts outside the repository, and it +needs an enumeration rather than a grep.** A withdrawn claim has now survived in +two dependents that a repository-wide search could not reach: the Wan +correspondence draft, and the PR description itself — which carried the retracted +zero, *and* the bound argument that was wrong by three orders of magnitude, for +three days after the manuscript was corrected. `PP-62-04` records both. + +The step had been run as a grep over tracked files, so its scope was silently +*what I can grep* rather than *what asserts this claim* — the same substitution +that makes an absence claim scoped to one file look like a property of the +repository. **The PR description is the first thing a reader of this work sees +and it is not a file.** So the enumeration is written down when a claim is +published, not reconstructed when it is withdrawn: PR and issue bodies, review +comments, correspondence already sent, slide decks, anything quoting a number +that a later verdict can move. + +**Record the sink type, not only the path, because sinks fail in different ways +— and not the ways you would guess.** The withdrawn `PP-62-04` zero was traced +into every sink it reached: + +| sink | editable | reachable by grep from a work tree | corrected after | +|---|---|---|---| +| `docs/correspondence/wan_v11_note.md` | yes | yes | days, on a Codex P1 | +| PR #23 description | yes | **no** | **three days**, when a human read it | +| commit `d404438`'s message | **no** | no — needs `--all` and `%b` | **~10 hours**, by `5788db5` | +| bioRxiv posting | — | — | never posted; `README.md` declares it, dated, as a search result rather than proof | + +**The append-only sink was corrected fastest and the editable one slowest**, +which is the opposite of the intuition that says an editable artifact is easier +to fix. Editability is not what decides whether a sink gets corrected; **whether +correcting it is already part of a workflow** is. Writing another commit is the +normal act, so the commit-message error was answered the same day by the ordinary +motion of the work. Editing a PR description is out-of-band, triggered by nothing, +so it sat until someone happened to read it. + +Two consequences. **Prefer sinks whose correction rides an existing motion**, and +where a claim must go into an out-of-band sink, the enumeration is the only thing +that will bring anyone back to it. And **do not try to rewrite an append-only +sink**: `d404438` stands, `5788db5` corrects it, and the pair is the record. A +history rewrite would destroy the evidence that the correction happened. + +**An identifier that routes a reader to an external retrieval is read back +against its source before the paragraph ships.** Accession numbers, DOIs, PMIDs, +PDB and NCBI ids — anything whose only job is to send someone somewhere. Read it +back from the registry that issues it, not from the document that quoted it to +you, and not from a search engine's gloss. + +The failure mode is specific and it is worse than an ordinary typo: **a wrong +identifier produces a false negative that is attributed to you.** The reader +looks, finds nothing, and concludes the document does not exist — the opposite +of what the paragraph was arguing. This repository shipped `ADA303995` inside +the paragraph arguing that `ADA307995` should be obtained first, so the +paragraph's own recommendation, followed literally, would have failed and looked +like confirmation. + +**Both directions of the check have now paid.** A review reported Hannan et al. +1986 as a microbial neutron RBE source. Reading `PMID 3533817` back through the +NCBI eutils API returned title, authors, journal, volume, issue, pages and the +abstract's RBE values — all matching, so the claim was adopted on the index's +authority rather than the review's. The *same* review reported a DTIC report as +freely readable; that claim had no identifier-backed check available, did not +reproduce on retrieval, and was not adopted. **The rule is cheap enough to run +on every identifier and it discriminates in both directions**, which is why it +is worth more than the ordinary caution it looks like. + +### Address an entry by its key, never by its printed number + +A `\bibitem` key, a ledger `claim_id`, a `\label` — these are stable. The number a renderer +prints for them is a function of everything above them, so one insertion silently re-points +every reference below it, and **nothing errors**: the row still reads plausibly and now names a +different paper. + +Measured, not asserted. On 2026-09-01 all six `PP-REF-*` rows located themselves as +"References [11]", "[12]", "[15]", "[26]", "[4]", "[32]". Resolved against the `.tex` those +ordinals are `slade2011`, `daly2009`, `xavier2005`, `karley2018`, `meskauskas2004` and +`kauffman1989` — none of which is the work the row describes. Six for six, and already wrong at +`HEAD` before any entry was deleted, so the cause was not an edit; it was addressing a render +artifact. `docs/preprint_revision_plan.md` had the same defect, naming `audit2026` as "[41]" +when it is entry #61. + +This is the identifier read-back rule pointed at an internal target: the `.tex` is the issuing +registry, the key is the identifier, and the printed number is a rendering of it. Enforced by +`test_no_ledger_row_locates_itself_by_printed_reference_number` in +`calibration/tests/test_claims_ledger.py`, with its own control. + +**And when a number cannot be recovered, say so rather than guessing.** `PP-REF-06` also cited +"[20]" and "[35]"; those two keys were not recoverable, and the row now records them as +unrecovered. Guessing which entries they were would be the failure this rule exists to stop. + ## The suites Four, and they are separate because they answer different questions. @@ -153,3 +510,16 @@ cannot tell an addressed finding from an ignored one. Resolve a thread when it is genuinely addressed. Resolving is the record that someone read it. + +**So commit freely while a review request is outstanding, and repost the request +when the committing stops.** The alternative — freezing the branch once a +request is posted — was considered and rejected, because the freshness check it +would enforce by hand is already enforced above: `preflight_merge.sh` refuses +while the newest review covers a commit that is no longer the head. A hand +convention duplicating a tool that already refuses is a second rule to drift +from, and drifting between two conventions is worse than either. + +**What the request must carry is the head SHA it was posted against.** A review +scoped to a named commit stays interpretable after the branch moves — it is a +review of that commit, and the reader can diff forward. An unscoped review of an +unnamed head is the thing that silently misleads. diff --git a/README.md b/README.md index 0f15a95..34ae98c 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,10 @@ than a simulation output. At the shipped constants `I0 = 1.0` and `T_cpm = 5.0`: | `ΔH_mel` at the reported M = 1.44 | −0.720 | **1.155** | `β_ion` — the one parameter Table 2 tabulates per species, and the one the entire sign convention is -written around — biases acceptance by **one part in 10⁵** for exactly the species whose radial -stratification is the headline result. The melanin term biases it by **15.5%**: four orders of -magnitude more, through a coefficient of `0.5` hard-coded at its call site in `compute_delta_H`, +written around — biases acceptance by **one part in 10⁵** *for one role of the two negatively +signed species occupying a site*, which is not the term's reach: signed by role it reaches +7.505e-2. The melanin term biases acceptance by **15.5%**, larger by about an order of +magnitude (9.6 in ΔH), through a coefficient of `0.5` hard-coded at its call site in `compute_delta_H`, appearing in no table and in no configuration file. Radiation still reaches the dynamics. It reaches them **indirectly**, because `melanin_drive` is @@ -400,7 +401,8 @@ the **core**, not the wall; `β_ion` is negative for the two radiotropic fungi a *B. subtilis*. Spatial sorting is dominated by the hand-specified adhesion matrix `J`. Within the radiation-derived -pathway, melanin dominates `β_ion` by four orders of magnitude (see the melanin finding above). +pathway, melanin exceeds `β_ion` by about an order of magnitude — 9.6 in ΔH — not by four (see +the melanin finding above). `tests/deterministic_radiation.jl` demonstrates the inertness directly: it must *deliberately amplify* $\beta$ to make multi-seed drift detectable, because the production values are not. The local ΔH sign per species is a **contract**; the collective drift is a **result**; the test keeps them separate. @@ -710,7 +712,7 @@ centimetres or grams, because no conversion to any of those exists. | Result | Value | Status | |---|---|---| | *C. sphaerospermum* melanin, MCS 100 | 1.44 | **Dimensionless field units.** Mean of a dimensionless field over occupied sites. The ordering among the three producers is set by the input `α_M`; the magnitude and spatial distribution are emergent. | -| Melanin acceptance bias at that value | 1.155 (15.5%) | **Dimensionless.** Computed from the shipped `0.5` coefficient and `T_cpm = 5.0`; four orders of magnitude above the `β_ion` bias of 1.000010. The dominant radiation-derived term, and undeclared until 2026-08-15. | +| Melanin acceptance bias at that value | 1.155 (15.5%) | **Dimensionless.** Computed from the shipped `0.5` coefficient and `T_cpm = 5.0`; about an order of magnitude above the `β_ion` bias AT THE TERM'S REACH, 1.0151 (a 10.2x ratio of excesses). Not above 1.000010, which is one ROLE of one pair of species occupying a site and gives 15,500x -- comparing against it is the withdrawn comparison. The dominant radiation-derived term, and undeclared until 2026-08-15. | | Membrane integrity after 100 MCS | m = 0.779 | **Dimensionless.** No physical dose exists: `Ḋ = 1.0` is a placeholder, `dt_rd = 0.5` is an uncalibrated MCS→second knob, `seconds_per_mcs` is `NaN`, and `accrue_dose!` raises rather than run without it. There is no value of Gy this corresponds to. | | Permeability ratio | P_eff / P₀ = e ≈ 2.72 | **Dimensionless, and exact by construction.** A closed-form function of four hand-set constants ($\alpha_P \dot{D} \Delta t_{rd} n_{MCS} = 1$); the simulation computes no permeability in cm s⁻¹. The former "0.010 → 0.027 cm s⁻¹" was the Nafion-117 literature prior `P₀` rescaled by that factor, and a literature prior is not a calibration. | | Interior contaminant, unweighted node mean | c / c_ext = 0.024 | **Dimensionless ratio.** Measures **non-penetration, not depletion**: `c(t=0) ≡ 0` everywhere, so the interior never held contaminant to remove. The mean is unweighted over radial nodes, not volume-weighted. The embedded figure labels this "% depleted"; the figure is wrong and the model supports only non-penetration. | diff --git a/analysis/henry_langmuir_bound.R b/analysis/henry_langmuir_bound.R new file mode 100644 index 0000000..8d91550 --- /dev/null +++ b/analysis/henry_langmuir_bound.R @@ -0,0 +1,264 @@ +#!/usr/bin/env Rscript +# ============================================================================ +# Where the shipped Henry sorption term stops agreeing with Langmuir. +# +# Rscript analysis/henry_langmuir_bound.R +# Rscript analysis/henry_langmuir_bound.R --report out.json +# +# WHY THIS FILE EXISTS. Section 3.12 states that three sorption forms are in +# play -- irreversible sink, Henry, Langmuir -- and that "they are not +# interchangeable". That claim is structural and correct. Nowhere in this +# repository was it ever said BY HOW MUCH, over the concentration range the +# solver actually integrates. This produces that number, so the ledger row +# quoting it can be re-run rather than believed. Codex raised a P1 on pull +# request #23 -- "Ship the producer for the restated proposal fraction" -- for +# a published number nobody could reproduce; this is written to not be that. +# +# WHAT IS COMPUTED +# The shipped immobile-phase equation (biofilms_radiodialysis.R:144) is +# ds/dt = U c - (k_des + k_loss) s, U = X_total (k_ads + k_red f_red) +# first-order in c with no site limitation: a Henry isotherm. The kinetic +# Langmuir form of the same reaction is +# ds/dt = U c (1 - s/X_max) - (k_des + k_loss) s +# whose equilibrium satisfies s_L = s_H / (1 + s_H / X_max). So the +# relative error of the shipped form is s_H / (X_max + s_H), and the two +# diverge by more than eps once c_ref > X_max (1 - eps) / (eps s_H_norm). +# +# WHAT IS *NOT* CONCLUDED, AND THE DISTINCTION IS THE POINT. This does NOT +# say the Henry form is adequate. s is normalised -- c_ext = 1.0, and section +# 3.12 says the sorbate has "no chemical identity" -- so converting s to a +# capacity needs a reference concentration that, per +# data/calibration/suspended_isotherm_proposal.csv, exists nowhere in this +# repository. Nothing establishes that a trace-contaminant c_ref is the +# relevant one. The result is therefore a THRESHOLD and an undetermined side: +# the forms diverge above c_ref = X_max / K, and whether this solver's regime +# sits above or below that is undetermined. Written this way so it cannot be +# quoted as "the Henry form is fine". +# +# X_max IS A PRIOR ON A PRIOR. X_max = q_max * rho_dry multiplies a +# suspended-MEASURED capacity by a biofilm-density PRIOR, so the product +# inherits the weaker class (suspended_isotherm_proposal.csv:42-44). Every +# crossover printed below is therefore a prior, not a measurement. +# +# WHAT THE EQUILIBRIUM CANCELLATION DOES AND DOES NOT BUY. s_H is +# proportional to X_total and X_max = q_max * rho_dry, so at equilibrium +# X_total cancels against rho_dry and the ratio reduces to +# (k_ads + k_red f_red) c c_ref / (L q_max). That makes the EQUILIBRIUM +# crossover immune to PP-66-08, the open needs_calibration row saying X_total +# is a site-occupancy fraction in a g/cm^3 slot so the rate is "wrong by an +# unstated rho". It cancels X_total against rho_dry and does NOTHING for the +# q_max uncertainty. The transient crossover cancels only partly, so both are +# reported and the difference between them is the size of that partiality. +# +# Loads only the model expressions, skipping library() calls and the Shiny app +# at biofilms_radiodialysis.R:611, the same idiom as +# analysis/verify_biofilm_depth_profile.R. deSolve is required; without it +# every check reports SKIPPED rather than being represented as passed +# (AGENTS.md rule 2). +# ============================================================================ + +MODEL <- local({ + a <- grep("^--file=", commandArgs(FALSE), value = TRUE) + p <- if (length(a)) file.path(dirname(dirname(normalizePath( + sub("^--file=", "", a[1])))), "biofilms_radiodialysis.R") else "" + if (nzchar(p) && file.exists(p)) p else "biofilms_radiodialysis.R" +}) + +WANTED <- c("radiodialysis_rhs", "face_weights", "default_parms", + "slab_parms", "penetration_depth", "run_radiodialysis", + "uptake_rate_of") +for (e in parse(MODEL)) { + if (is.call(e) && identical(as.character(e[[1]]), "<-") && + as.character(e[[2]]) %in% WANTED) eval(e, envir = globalenv()) +} +stopifnot(all(vapply(WANTED, exists, logical(1)))) + +failures <- 0L +skipped <- character(0) +checks_run <- 0L +report <- function(ok, msg, detail = "") { + checks_run <<- checks_run + 1L + if (!ok) failures <<- failures + 1L + cat(sprintf("[%s] %s%s\n", if (ok) "PASS" else "FAIL", msg, + if (nzchar(detail)) paste0(" (", detail, ")") else "")) + invisible(ok) +} + +# ATTACHED, not merely available: run_radiodialysis() calls `ode` unqualified, +# so requireNamespace() alone leaves it unresolvable and the model errors out +# mid-run rather than reporting a clean SKIP. +HAVE_DESOLVE <- suppressWarnings(require(deSolve, quietly = TRUE, + warn.conflicts = FALSE)) + +# --- the shipped constants, read from the model rather than restated --------- +p <- default_parms() +U <- p$X_total * (p$k_ads + p$k_red * p$f_red_active) +L <- p$k_des + p$k_loss +RATIO_EQ <- U / L # Henry equilibrium s/c + +cat("=== shipped constants (read from default_parms()) ===\n") +cat(sprintf(" k_ads %.4g k_red %.4g k_des %.4g k_loss %.4g\n", + p$k_ads, p$k_red, p$k_des, p$k_loss)) +cat(sprintf(" X_total %.4g f_red_active %.4g c_ext %.4g\n", + p$X_total, p$f_red_active, p$c_ext)) +cat(sprintf(" U = X_total*(k_ads + k_red*f_red) = %.4f 1/s\n", U)) +cat(sprintf(" k_des + k_loss = %.4f 1/s\n", L)) +cat(sprintf(" Henry equilibrium ratio s_eq/c = %.4f\n\n", RATIO_EQ)) + +# --- the visited range ------------------------------------------------------ +T_END <- 100 +if (!HAVE_DESOLVE) { + skipped <- c(skipped, "every check: deSolve absent, no trajectory to measure") + s_max <- NA_real_; c_max <- NA_real_ +} else { + o <- run_radiodialysis(p, t_end = T_END, n_out = 200) + c_max <- max(o$c_mat); s_max <- max(o$s_mat) + cat("=== range the solver actually visits ===\n") + cat(sprintf(" c: min %.6f max %.6f (c_ext = %.1f)\n", + min(o$c_mat), c_max, p$c_ext)) + cat(sprintf(" s: min %.6f max %.6f\n", min(o$s_mat), s_max)) + cat(sprintf(" fraction of Henry equilibrium reached: %.3f", + s_max / (RATIO_EQ * c_max))) + cat(sprintf(" (relaxation 1/L = %.1f s vs t_end = %d s)\n\n", 1 / L, T_END)) +} + +# --- the bound -------------------------------------------------------------- +# eps-divergence crossover. With s_H(physical) = s_norm * c_ref, +# rel_err = s_norm c_ref / (X_max + s_norm c_ref) > eps +# <=> s_norm c_ref (1 - eps) > eps X_max +# <=> c_ref > X_max / K, K = (1 - eps) s_norm / eps +# K is what the ledger row quotes, so it is derived here once and control 2 +# pins it from both sides rather than trusting this line. +crossover_K <- function(s_norm, eps) ((1 - eps) * s_norm) / eps +EPS <- 0.10 + +if (HAVE_DESOLVE) { + K_transient <- crossover_K(s_max, EPS) + K_equilibrium <- crossover_K(RATIO_EQ * c_max, EPS) + cat("=== the bound: forms diverge by more than 10% above c_ref = X_max / K ===\n") + cat(sprintf(" K (transient, t_end = %d s) = %.2f\n", T_END, K_transient)) + cat(sprintf(" K (equilibrium limit) = %.2f", K_equilibrium)) + cat(" <- immune to PP-66-08; q_max uncertainty unaffected\n\n") + cat(sprintf(" %-14s %-12s %s\n", "c_ref (mg/L)", "X_max = 50", "X_max = 5")) + for (cr_L in c(1, 10, 100, 1000, 10000)) { + cr <- cr_L / 1000 # mg/L -> mg/cm^3 + e <- function(xm) 100 * s_max * cr / (xm + s_max * cr) + cat(sprintf(" %-14g %-12s %s\n", cr_L, + sprintf("%.3f%%", e(50)), sprintf("%.3f%%", e(5)))) + } + cat(sprintf("\n crossover: X_max = 50 -> c_ref = %.1f mg/L; X_max = 5 -> %.1f mg/L\n\n", + 1000 * 50 / K_transient, 1000 * 5 / K_transient)) +} + +# ============================================================================ +# CONTROL 1 -- the closed form against a NUMERICAL SOLVE, not against algebra. +# +# s_L = s_H/(1 + s_H/X_max) is a derivation. Checking it by re-deriving it +# proves nothing: a control that never meets the pipeline confirms a guard +# against an input the pipeline cannot produce, which is the defect AGENTS.md +# rule 1 now records from the figure-sidecar case. So the Langmuir RHS is +# integrated on the same grid with the same constants and its equilibrium is +# compared against the closed form. +# ============================================================================ +langmuir_equilibrium_numeric <- function(parms, X_max, c_fixed, t_end = 5000) { + # Well-mixed single node at fixed c: ds/dt = U c (1 - s/X_max) - L s. + rhs <- function(t, y, pr) list(U * c_fixed * (1 - y[1] / X_max) - L * y[1]) + out <- deSolve::ode(y = c(s = 0), times = c(0, t_end), func = rhs, + parms = NULL, method = "lsoda", + rtol = 1e-10, atol = 1e-12) + as.numeric(out[nrow(out), 2]) +} + +if (HAVE_DESOLVE) { + cat("=== control 1: closed form vs numerical Langmuir integration ===\n") + worst <- 0 + for (X_max in c(0.5, 5, 50)) { + for (cc in c(0.1, 0.5, 0.925)) { + sH <- RATIO_EQ * cc # Henry equilibrium at this c + closed <- sH / (1 + sH / X_max) + numer <- langmuir_equilibrium_numeric(p, X_max, cc) + rel <- abs(closed - numer) / numer + worst <- max(worst, rel) + cat(sprintf(" X_max %5.1f c %5.3f : closed %.6f numeric %.6f rel %.2e\n", + X_max, cc, closed, numer, rel)) + } + } + report(worst < 1e-6, + "closed form s_L = s_H/(1 + s_H/X_max) matches numerical integration", + sprintf("worst relative disagreement %.2e over 9 (X_max, c) pairs", worst)) +} else { + report(FALSE, "control 1 could not run") # unreachable: skip path set above +} + +# ============================================================================ +# CONTROL 2 -- TWO-SIDED on the crossover. +# +# A one-sided assertion ("error exceeds 10% somewhere above") passes on any +# monotone curve regardless of where the crossover sits. Both sides are +# asserted so the check fails if the threshold moves in EITHER direction. +# ============================================================================ +if (HAVE_DESOLVE) { + cat("\n=== control 2: the crossover is pinned from both sides ===\n") + X_max <- 5 + c_star <- X_max / K_transient + rel <- function(cr) s_max * cr / (X_max + s_max * cr) + below <- rel(c_star * 0.5) + above <- rel(c_star * 2.0) + at <- rel(c_star) + cat(sprintf(" c_ref = 0.5 c* -> %.4f (must be < 0.10)\n", below)) + cat(sprintf(" c_ref = c* -> %.4f (must be ~ 0.10)\n", at)) + cat(sprintf(" c_ref = 2.0 c* -> %.4f (must be > 0.10)\n", above)) + report(below < EPS && above > EPS && abs(at - EPS) < 1e-9, + "crossover c* = X_max/K separates <10% from >10% in both directions", + sprintf("below %.4f, at %.4f, above %.4f", below, at, above)) +} + +# ============================================================================ +# CONTROL 3 -- the conclusion is CONDITIONAL, asserted rather than asserted-of. +# +# The ledger row says the bound depends on c_ref and that the solver's regime +# is undetermined. If the divergence were large for every plausible c_ref +# that would be a fact about the FORMS and would belong in the manuscript +# instead. This fails if the span ever stops straddling the threshold, which +# is the condition under which the row's framing would have to change. +# ============================================================================ +if (HAVE_DESOLVE) { + cat("\n=== control 3: the result is conditional, not unconditional ===\n") + lo <- s_max * (1 / 1000) / (50 + s_max * (1 / 1000)) # 1 mg/L, X_max 50 + hi <- s_max * (10000/1000) / (5 + s_max * (10000/1000)) # 10 g/L, X_max 5 + cat(sprintf(" smallest case (1 mg/L, X_max 50): %.5f%%\n", 100 * lo)) + cat(sprintf(" largest case (10 g/L, X_max 5): %.2f%%\n", 100 * hi)) + report(lo < 0.01 && hi > 0.5, + "divergence straddles the threshold, so the bound is conditional on c_ref", + sprintf("%.4f%% to %.1f%% -- a fact about a missing parameter, not the forms", + 100 * lo, 100 * hi)) +} + +# --- verdict ---------------------------------------------------------------- +# A skip is uncovered surface, not a neutral fact (AGENTS.md rule 2), so a run +# that skipped is never reported as a clean pass. +verdict <- if (failures > 0L) { + sprintf("FAILURES: %d", failures) +} else if (length(skipped)) { + "PASSED WHAT RAN -- NOT A CLEAN RUN" +} else { + "ALL PASS" +} +cat(sprintf("\n%s (%d check%s run, %d failure%s, %d skipped)\n", verdict, + checks_run, if (checks_run == 1L) "" else "s", failures, + if (failures == 1L) "" else "s", length(skipped))) +for (s in skipped) cat(" UNCOVERED:", s, "\n") + +args <- commandArgs(TRUE) +if (length(args) >= 2L && args[1] == "--report") { + # `skips` is emitted even when empty, so the CI receipt gate can name what was + # uncovered rather than reporting a count with nothing behind it. + writeLines(sprintf( + '{"checks_run": %d, "failures": %d, "skipped": %d, "skips": [%s], "K_transient": %s, "K_equilibrium": %s, "complete": %s}', + checks_run, failures, length(skipped), + paste(sprintf('"%s"', skipped), collapse = ", "), + if (HAVE_DESOLVE) sprintf("%.4f", K_transient) else "null", + if (HAVE_DESOLVE) sprintf("%.4f", K_equilibrium) else "null", + tolower(as.character(failures == 0L && !length(skipped)))), args[2]) +} +quit(status = if (failures == 0L && !length(skipped)) 0L else 1L) diff --git a/analysis/overdamped_regime.py b/analysis/overdamped_regime.py new file mode 100644 index 0000000..701b276 --- /dev/null +++ b/analysis/overdamped_regime.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Reproduce §3.4's overdamped-regime numbers. + + python3 analysis/overdamped_regime.py + python3 analysis/overdamped_regime.py --report out.json + +WHY THIS FILE EXISTS. Section 3.4 states that no independently-integrated momentum +state exists, and now states the stronger claim that one would be physically +inappropriate: at cell scale the regime is overdamped by roughly ten orders of +magnitude. Those are published numbers, and a published number nobody can re-run is +the defect PP-62-11 records and the P1 Codex raised on pull request #23. + +WHAT IS COMPUTED, AND ITS INPUTS ARE ASSUMPTIONS RATHER THAN MEASUREMENTS. + + Re = rho * U * L / eta inertial vs viscous forces + tau_p = 2 * rho * a^2 / (9 * eta) inertial (momentum) relaxation time + +rho and eta are water at room temperature, carried from the literature. L, U and a +are ASSUMED scales for a bacterial cell and a biofilm feature; none is measured in +this repository, and nothing here should be read as a measurement of this system. +Re is a property of the flow and not of the organism, so every figure is reported +with the length and speed that produced it -- a Reynolds number quoted without them +is not reproducible, which is the pinned-inputs discipline 0a exists to enforce. + +WHAT THE CLAIM ACTUALLY IS, BECAUSE THE OBVIOUS CONTROL TESTS THE WRONG QUANTITY. +The claim is not "Re < 1". It is that the inertial memory is negligible against the +timescales the model addresses -- that T_bio / tau_p is enormous. Those come apart: +a millimetre object at cm/s gives Re = 10, which looks like a failing input, while +its T_bio/tau_p is 1.9e4 -- four hundred times larger than the genuine control's 49. +So crossing Re = 1 says little about where the ratio sits, and a control built on Re +would be testing a different quantity from the one claimed. The control below +straddles the RATIO, by taking an object large enough that tau_p approaches a +biological timescale. +""" +from __future__ import annotations + +import json +import sys + +RHO = 1.0e3 # kg m^-3, water +ETA = 1.0e-3 # Pa s, water at room temperature +T_BIO = 1080.0 # s, the 18-minute biofilm elastic relaxation time (literature) + +# The ratio below which the overdamped claim would not hold as stated. Ten orders is +# what §3.4 says; 1e8 is the floor asserted, so the claim has margin rather than +# sitting on its own threshold. +RATIO_FLOOR = 1.0e8 + +checks_run = 0 +failures = 0 + + +def report(ok: bool, msg: str, detail: str = "") -> bool: + global checks_run, failures + checks_run += 1 + if not ok: + failures += 1 + print(f"[{'PASS' if ok else 'FAIL'}] {msg}" + (f" ({detail})" if detail else "")) + return ok + + +def reynolds(L: float, U: float) -> float: + return RHO * U * L / ETA + + +def tau_p(a: float) -> float: + return 2.0 * RHO * a * a / (9.0 * ETA) + + +CASES = [ # (label, L m, U m/s) + ("cell, 1 um at 20 um/s", 1e-6, 20e-6), + ("cell, 10 um at 20 um/s", 10e-6, 20e-6), + ("biofilm feature, 100 um at 20 um/s", 100e-6, 20e-6), +] +RADII = [("0.5 um", 0.5e-6), ("1 um", 1e-6), ("100 um", 100e-6)] + +print("=== inputs (assumed scales, not measurements) ===") +print(f" rho = {RHO:.3g} kg/m3 eta = {ETA:.3g} Pa s T_bio = {T_BIO:.0f} s (18 min)\n") + +print("=== Reynolds number, each with the L and U that produced it ===") +for label, L, U in CASES: + print(f" {label:36} Re = {reynolds(L, U):.2e}") + +print("\n=== inertial relaxation and the ratio the claim is about ===") +for label, a in RADII: + t = tau_p(a) + print(f" a = {label:8} tau_p = {t:.2e} s T_bio/tau_p = {T_BIO / t:.2e}") + +# --- the assertions ------------------------------------------------------- +report(all(reynolds(L, U) < 1.0 for _, L, U in CASES), + "every assumed scale is below Re = 1", + f"largest {max(reynolds(L, U) for _, L, U in CASES):.2e}") + +cell_ratios = [T_BIO / tau_p(a) for label, a in RADII if label != "100 um"] +report(min(cell_ratios) >= RATIO_FLOOR, + f"cell-scale inertial memory is at least {RATIO_FLOOR:.0e} below T_bio", + f"smallest ratio {min(cell_ratios):.2e}") + +# --- CONTROL: straddle the RATIO, not the Reynolds number ------------------ +print("\n=== control: an input where tau_p approaches a biological timescale ===") +a_ctrl = 1e-2 # 1 cm +t_ctrl = tau_p(a_ctrl) +r_ctrl = T_BIO / t_ctrl +print(f" a = 1 cm tau_p = {t_ctrl:.2f} s T_bio/tau_p = {r_ctrl:.1f}") + +# and the input that LOOKS like a control and is not, kept because the distinction +# is the point: it fails Re < 1 while its ratio stays far above the real control's. +L_bad, U_bad, a_bad = 1e-3, 1e-2, 0.5e-3 +print(f" the tempting control (1 mm at 1 cm/s): Re = {reynolds(L_bad, U_bad):.1f} " + f"but T_bio/tau_p = {T_BIO / tau_p(a_bad):.1e}") + +report(r_ctrl < RATIO_FLOOR, + "the control input fails the ratio floor, so the assertion is not vacuous", + f"1 cm gives {r_ctrl:.1f}, far below {RATIO_FLOOR:.0e}") +# CORRECTED WHILE WRITING THIS: the first version asserted that the Re>1 input +# still passes RATIO_FLOOR, and it does not -- 1.9e4 is well under 1e8, and the +# assertion failed on its first run. The overstatement is instructive and the +# weaker true claim is the one that makes the point: crossing Re = 1 says little +# about where the RATIO sits, because this input's ratio is still hundreds of +# times larger than the genuine control's. Re and the ratio are different +# quantities; that is why the control straddles the ratio. +ratio_bad = T_BIO / tau_p(a_bad) +report(ratio_bad > 100.0 * r_ctrl, + "a Re>1 input's ratio is still orders above the control's, so Re is the wrong control", + f"Re = {reynolds(L_bad, U_bad):.0f}, ratio {ratio_bad:.1e} vs control {r_ctrl:.1f} " + f"({ratio_bad / r_ctrl:.0f}x)") + +verdict = "ALL PASS" if failures == 0 else f"FAILURES: {failures}" +print(f"\n{verdict} ({checks_run} checks run, {failures} failure" + f"{'' if failures == 1 else 's'}, 0 skipped)") + +if len(sys.argv) >= 3 and sys.argv[1] == "--report": + with open(sys.argv[2], "w", encoding="utf-8") as fh: + # THE RECEIPT CARRIES EVERY COMPUTED VALUE, NOT A HAND-PICKED SUBSET. + # It used to emit three scalars -- Re_cell_1um, tau_p_0p5um, ratio_0p5um -- + # while section 3.4 states FIVE numbers, so 2e-4, 2e-3 and 2.2e-7 existed in + # stdout only. A guard written from that receipt would have covered two of + # five and looked complete. That is the same defect as asserting on a union + # where categories were computed, moved to the SERIALISATION boundary: the + # discriminating data was computed and thrown away on the way out. Serialise + # the sets CASES and RADII already define, so a sixth stated number cannot be + # missed by a receipt that never mentioned it. + json.dump({"checks_run": checks_run, "failures": failures, "skipped": 0, + "skips": [], + "inputs": {"rho": RHO, "eta": ETA, "T_bio": T_BIO}, + "reynolds": {label: reynolds(L, U) for label, L, U in CASES}, + "lengths": {label: L for label, L, _ in CASES}, + "speeds": {label: U for label, _, U in CASES}, + "radii": dict(RADII), + "tau_p": {label: tau_p(a) for label, a in RADII}, + "ratio": {label: T_BIO / tau_p(a) for label, a in RADII}, + "complete": failures == 0}, fh) + +sys.exit(0 if failures == 0 else 1) diff --git a/biofilms_potts.jl b/biofilms_potts.jl index f583b26..059f0e2 100644 --- a/biofilms_potts.jl +++ b/biofilms_potts.jl @@ -13,7 +13,16 @@ # motivates the per-species magnitudes below; it is not what the term computes. # # Maps the paper's Hamiltonian (Eq. 2, Section 3.3) to CPM energy: -# H_CPM = H_adhesion + H_volume + H_radiation + H_pairwise + H_melanin +# H_CPM = H_adhesion + H_volume + H_radiation + H_melanin +# +# FOUR TERMS, NOT FIVE. compute_delta_H_terms returns (adh, vol, rad, mel) and +# compute_delta_H sums exactly those. total_pairwise_energy IS real, but it is +# called from take_snapshot alone and never enters Metropolis acceptance, so +# nothing in the simulation minimises it -- it records mutualistic-pair +# proximity under adhesion, not a selected-for outcome. Listing it here as a +# term of H_CPM was claims_ledger row RM-G04-01 (verdict restate); that verdict +# was applied to README and not to this comment, because the ledger guard +# resolves a row's `document` column and nothing reads source comments. # # Coupled fields (melanin, nutrient, radiation) updated each MCS # per Eq. 7 (melanin RD) and Eq. 5 (radiation field). @@ -496,12 +505,23 @@ function site_adhesion(lattice, cells, J, x, y, z, N) end """ -Compute ΔH for a proposed copy: source site (sx,sy,sz) copies into target (tx,ty,tz). -Only recomputes terms affected by the single-site change. -Returns the total energy change ΔH. + compute_delta_H_terms(state, sx, sy, sz, tx, ty, tz) + +The four Hamiltonian contributions to a copy attempt, separately. + +THIS FUNCTION IS THE OLD BODY OF `compute_delta_H`, WHICH ALREADY COMPUTED ALL +FOUR AND THEN DISCARDED THEM on its return line. Nothing new is calculated here. +`compute_delta_H` now sums this in the same order it used to add them, so the +scalar it returns is bit-identical by construction rather than by luck — same +operands, same associativity, same result. + +It is split rather than retyped because three callers outside this file want the +scalar, and one of them is `biofilms_potts_jacc.jl`'s cross-implementation check +against the parallel port. Changing what that reads to prove a rendering point +would be trading a real guard for a picture. """ -function compute_delta_H(state::CPMState, sx::Int, sy::Int, sz::Int, - tx::Int, ty::Int, tz::Int) +function compute_delta_H_terms(state::CPMState, sx::Int, sy::Int, sz::Int, + tx::Int, ty::Int, tz::Int) lat = state.lattice cells = state.cells J = state.J @@ -557,10 +577,15 @@ function compute_delta_H(state::CPMState, sx::Int, sy::Int, sz::Int, # THE 0.5 BELOW IS THE COEFFICIENT THAT ACTUALLY MOVES THIS MODEL, and it is # hard-coded here rather than tabulated. At the shipped I0 = 1.0 and # T_cpm = 5.0, the radiation term for a radiotropic species is - # β_ion·I = -5e-5, an acceptance bias of 1.000010 — one part in 1e5. This - # term at the reported M = 1.44 is -0.72, a bias of 1.155. Four orders of - # magnitude. The radial stratification is therefore melanin-mediated, not - # β_ion-mediated. Radiation still drives it, but only indirectly: + # β_ion·I = -5e-5 for a NEGATIVELY signed species OCCUPYING a site, an + # acceptance bias of 1.000010. That is one role of one pair of species and + # is NOT the term's reach: signed by role, the extremum over source/target + # pairings is 7.505e-2 (see §6.2 and tests/prose_bounds.jl). This term at + # the reported M = 1.44 is -0.72, a bias of 1.155 — larger by a factor of + # 9.6 in ΔH, NOT by four orders. CORRECTED 2026-08-30: this comment carried + # the version-1.1 claim in a file the manuscript sweep never covered, which + # is what a sweep bounded to one file cannot find. + # The radial stratification is still melanin-mediated, not β_ion-mediated. Radiation still drives it, but only indirectly: # melanin_drive is copied from the radiation field, so # radiation -> production (α_M, tabulated) -> M -> here -> tropism. # Ledgered as cpm.melanin_coupling in data/parameter_provenance.csv. @@ -571,18 +596,154 @@ function compute_delta_H(state::CPMState, sx::Int, sy::Int, sz::Int, ΔH_mel += 0.5 * M_local # losing a melanin-rich site costs end - return ΔH_adh + ΔH_vol + ΔH_rad + ΔH_mel + return (adh = ΔH_adh, vol = ΔH_vol, rad = ΔH_rad, mel = ΔH_mel) +end + +""" +Compute ΔH for a proposed copy: source site (sx,sy,sz) copies into target (tx,ty,tz). +Only recomputes terms affected by the single-site change. +Returns the total energy change ΔH. + +Unchanged in value, type and summation order; it now adds up +`compute_delta_H_terms` instead of four locals with the same names. +""" +function compute_delta_H(state::CPMState, sx::Int, sy::Int, sz::Int, + tx::Int, ty::Int, tz::Int) + t = compute_delta_H_terms(state, sx, sy, sz, tx, ty, tz) + return t.adh + t.vol + t.rad + t.mel end # ============================================================ # 6. Monte Carlo Step (MCS) # ============================================================ +# ------------------------------------------------------------------ +# Which term drove an accepted move +# ------------------------------------------------------------------ + +# Order matches `compute_delta_H_terms`' fields and the layer's colour key. +const DRIVER_LABELS = (:none, :adh, :vol, :rad, :mel, :multiple, :contingent) + +const DRIVER_NONE = 0x01 +const DRIVER_MULTIPLE = 0x06 +const DRIVER_CONTINGENT = 0x07 + +""" + decisive_label(terms, ΔH, u, T) -> UInt8 + +Which single term, if any, DECIDED an accepted move — by counterfactual, on the +uniform that was actually drawn. + +A term is decisive when removing it flips the outcome. That is answered here by +arithmetic on `u` and never by consulting a generator: drawing again would +advance the stream and break the byte contract, a second generator would make +the answer stochastic and add a seed to declare, and a probability threshold +would invent a constant. All three fabricate something. + +THE TWO ACCEPTANCE BRANCHES PRODUCE DISJOINT LABEL SETS, which is worth stating +because it is not obvious and it is what makes `contingent` a category rather +than a patch: + + * `ΔH <= 0` — accepted outright, `u` is NaN because no draw was taken. Removing + a term can raise ΔH above zero, and what would have happened then needs a + number nobody drew. No term here can be shown to flip the move to REJECT; + it can only remove the certainty. So this branch yields `none` or + `contingent`, never a named term. + * `ΔH > 0` — a draw exists, so every counterfactual is decidable and nothing + is contingent. Only a term that HELPED (ΔH_t < 0) can be decisive: removing + a term that hurt lowers ΔH and the move stays accepted. So this branch + yields `none`, one of the four, or `multiple`. + +`none` is the informative cell in both branches, not the empty one: it says the +SUM drove the move and no single term owns it. Given §6.2's ~1.5e4 separation +between the melanin and direct-radiation terms it is expected to be common, and +it must not be drawn as background. +""" +function decisive_label(terms, ΔH::Float64, u::Float64, T::Float64) + if ΔH <= 0 + # Would removing any single term have cost this move its certainty? + for v in (terms.adh, terms.vol, terms.rad, terms.mel) + ΔH - v > 0 && return DRIVER_CONTINGENT + end + return DRIVER_NONE + end + found = 0x00 + for (i, v) in enumerate((terms.adh, terms.vol, terms.rad, terms.mel)) + ΔH′ = ΔH - v + # The move was accepted, so `u < exp(-ΔH/T)`. Without this term it is + # accepted iff ΔH′ <= 0 or `u` still clears the new, smaller threshold. + still = ΔH′ <= 0 || u < exp(-ΔH′ / T) + if !still + found == 0x00 || return DRIVER_MULTIPLE + found = UInt8(i + 1) # 0x02..0x05, past DRIVER_NONE + end + end + return found == 0x00 ? DRIVER_NONE : found +end + +""" +Per-voxel tally of which term drove each accepted move. + +ACCEPTED MOVES ONLY, and deliberately: an attempted-move tally answers "where +did the sampler look", which is uniform by construction and says nothing about +the dynamics. `n_accepted` is DERIVED by summing the labels rather than counted +alongside them, so there is one source for it and not two that can disagree. +""" +struct DriverCounts + counts::Array{Int32, 4} # (N, N, N, length(DRIVER_LABELS)) +end + +DriverCounts(N::Int) = DriverCounts(zeros(Int32, N, N, N, length(DRIVER_LABELS))) + +@inline function record_driver!(d::DriverCounts, x::Int, y::Int, z::Int, + label::UInt8) + d.counts[x, y, z, Int(label)] += Int32(1) + return nothing +end + +n_accepted(d::DriverCounts) = dropdims(sum(d.counts, dims = 4), dims = 4) + +""" +Modal label per voxel, 0 where no move was ever accepted there. + +0 IS NOT `none`. "The sum drove every move here" and "nothing happened here" are +different statements and must not share a colour; `none` is a result and 0 is an +absence. Ties go to the lower label index, which is stated rather than left to +whatever `argmax` happens to do. +""" +function modal_driver(d::DriverCounts) + N = size(d.counts, 1) + out = zeros(UInt8, N, N, N) + @inbounds for z in 1:N, y in 1:N, x in 1:N + best = 0; best_n = 0 + for k in 1:length(DRIVER_LABELS) + c = d.counts[x, y, z, k] + if c > best_n + best_n = c; best = k + end + end + out[x, y, z] = UInt8(best) + end + return out +end + """ Perform one Monte Carlo Step = N³ attempted copy operations. Standard CPM Metropolis dynamics (Section 3.4 analog → stochastic accept/reject). + +`on_proposal`, when given, is called as `on_proposal(terms, ΔH)` for every +EVALUATED proposal -- after the site pair survives the skip conditions and the +energy is computed, and before the acceptance draw. It is the producer for the +per-proposal ΔH_rad statistics quoted in §6.2; without it those numbers came +from an uncommitted rewrite of this file and nobody could re-run them, which is +PP-62-11's defect and is why this hook exists rather than a scratch script. + +IT MUST NOT CONSULT `rng`. The hook is called before the draw, so a closure that +touches the generator moves the trajectory; `tests/rad_proposals_tests.jl` pins +the shipped harness inert against the bare path with a different-seed control. """ -function mcs_step!(state::CPMState, rng::AbstractRNG) +function mcs_step!(state::CPMState, rng::AbstractRNG; driver = nothing, + on_proposal = nothing) p = state.params N = p.N lat = state.lattice @@ -620,13 +781,28 @@ function mcs_step!(state::CPMState, rng::AbstractRNG) end # 4. Compute ΔH - ΔH = compute_delta_H(state, sx, sy, sz, tx, ty, tz) + terms = compute_delta_H_terms(state, sx, sy, sz, tx, ty, tz) + ΔH = terms.adh + terms.vol + terms.rad + terms.mel + + # Evaluated, not attempted: the skip conditions above have already + # rejected same-cell and medium-into-medium pairs, and the denominator + # the paper reports is this one. + on_proposal === nothing || on_proposal(terms, ΔH) # 5. Metropolis acceptance - accept = if ΔH <= 0 - true - else - rand(rng) < exp(-ΔH / p.T_cpm) + # + # `u` IS NaN WHEN NO DRAW WAS TAKEN, and that is a fact about the + # acceptance rule rather than a missing value: at ΔH <= 0 the move is + # accepted outright and the generator is never consulted. The ternary + # evaluates only its taken branch, so `rand` is called exactly where it + # was before and the stream is untouched. Keeping `u` is what lets a + # counterfactual be answered by arithmetic instead of a second draw. + u = ΔH <= 0 ? NaN : rand(rng) + accept = ΔH <= 0 ? true : u < exp(-ΔH / p.T_cpm) + + if accept && driver !== nothing + record_driver!(driver, tx, ty, tz, + decisive_label(terms, ΔH, u, p.T_cpm)) end if accept @@ -2069,7 +2245,7 @@ function export_figures(trajectory::Vector{<:Any}, ax1 = Axis(fig1[1,1], xlabel = "Monte Carlo Steps", ylabel = "Mean radial position r / R", - title = "Radial stratification — cylindrical CPM bioreactor", + title = "Mean radial position — cylindrical CPM bioreactor", titlesize = 15, xlabelsize = 13, ylabelsize = 13, @@ -2156,7 +2332,7 @@ function export_figures(trajectory::Vector{<:Any}, ax2 = Axis(fig2[1,1], xlabel = "Monte Carlo Steps", ylabel = "Mean melanin field value at occupied sites", - title = "Melanin accumulation — radiation-driven production", + title = "Melanin field at occupied sites — dimensionless model units", titlesize = 15, xlabelsize = 13, ylabelsize = 13) @@ -2184,11 +2360,11 @@ function export_figures(trajectory::Vector{<:Any}, labelsize = 11, rowgap = 4, framevisible = false, - title = "Melanin producers\n(★ radiotropic)", + title = "Melanin producers", titlesize = 11) # Override first two labels to add star text!(ax2, mcs_vec[1], -0.05; - text = "★ C. neoformans, C. sphaerospermum drift toward the source (radiotropic)", + text = "Ordering follows the hand-specified alpha_M_species scales; not a measured quantity", fontsize = 8.5, color = (:gray40, 1.0), align = (:left, :top)) save(joinpath(outdir, "fig2_melanin_accumulation.pdf"), fig2) @@ -2251,13 +2427,29 @@ function export_figures(trajectory::Vector{<:Any}, linestyle = :dash, label = "P_eff / P₀ permeability ratio") - # Key annotations - m_final = m_vec[end] + # Key annotations. + # + # BOTH WERE PLACED AT x = 0.55*t_end FROM FINAL VALUES, AND COLLIDED. The + # two y-positions -- m_final + 0.05 = 0.829 on the left axis and + # Peff_final * 0.88 = 2.385 on the right -- are independent numbers on + # independently autoscaled axes, and they happened to map to the same 79% of + # plot height, so the two labels printed on top of each other and neither + # was readable. That is arithmetic, not a rendering fluke: it recurred on + # every regeneration. + # + # The fix separates them HORIZONTALLY, which makes the vertical coincidence + # moot however the axes rescale, and anchors each label to its own curve at + # its own x so it stays beside the line it describes rather than at a height + # derived from a value plotted somewhere else. + m_final = m_vec[end] Peff_final = Peff_norm[end] - text!(ax3l, Float64(t_vec[end]) * 0.55, m_final + 0.05; + i_at(frac) = argmin(abs.(Float64.(t_vec) .- Float64(t_vec[end]) * frac)) + i_m, i_p = i_at(0.35), i_at(0.80) + Peff_span = maximum(Peff_norm) - minimum(Peff_norm) + text!(ax3l, Float64(t_vec[i_m]), m_vec[i_m] + 0.04; text = @sprintf("m = %.3f", m_final), fontsize = 10, color = colorant"#1f77b4", align = (:center, :bottom)) - text!(ax3r, Float64(t_vec[end]) * 0.55, Peff_final * 0.88; + text!(ax3r, Float64(t_vec[i_p]), Peff_norm[i_p] - 0.18 * Peff_span; text = @sprintf("%.1f× baseline", Peff_final), fontsize = 10, color = colorant"#d62728", align = (:center, :top)) @@ -2326,7 +2518,7 @@ function export_figures(trajectory::Vector{<:Any}, text = @sprintf("c(R) = %.0f%% c_ext", c_wall_final * 100), fontsize = 10, color = colorant"#d62728", align = (:center, :bottom)) text!(ax4l, Float64(t_vec[end]) * 0.55, c_mean_final + 0.04; - text = @sprintf("c_mean = %.1f%% c_ext (%.0f%% depleted)", + text = @sprintf("c_mean = %.1f%% c_ext (%.0f%% non-penetration)", c_mean_final * 100, (1.0 - c_mean_final) * 100), fontsize = 10, color = colorant"#1f77b4", align = (:center, :bottom)) diff --git a/biofilms_potts_jacc.jl b/biofilms_potts_jacc.jl index 127efc2..075a1d1 100644 --- a/biofilms_potts_jacc.jl +++ b/biofilms_potts_jacc.jl @@ -185,7 +185,7 @@ end # non-adjacent under Moore-26, so lattice reads are race-free. function cpm_color!(i, j, k, lat, vols, spec, J, βv, melc, rad, mel, ox, oy, oz, seed::UInt64, step::UInt64, N, λV::Float32, - Vt::Int32, T::Float32) + Vt::Int32, T::Float32, st, dh) tx = 2 * (Int(i) - 1) + Int(ox) + 1 ty = 2 * (Int(j) - 1) + Int(oy) + 1 tz = 2 * (Int(k) - 1) + Int(oz) + 1 @@ -204,10 +204,22 @@ function cpm_color!(i, j, k, lat, vols, spec, J, βv, melc, rad, mel, ΔH = delta_H(lat, vols, spec, J, βv, melc, rad, mel, tx, ty, tz, σs, σt, N, λV, Vt) + # Instrumentation. Write-only, per-site, no atomic: the same non-adjacency + # that makes the lattice reads race-free makes these writes race-free. `st` + # is the discriminator -- 0 never proposed, 1 evaluated-rejected, 2 + # evaluated-accepted -- because a sentinel inside `dh` would collapse + # "never proposed" into "ΔH was NaN" and silently shrink the denominator of + # every acceptance rate downstream. The spatial class is NOT stored: it is + # derived from (tx,ty,tz) so it stays spatial when `color_order` permutes + # the pass sequence. + dh[tx, ty, tz] = ΔH if ΔH <= 0.0f0 || u01(r2) < exp(-ΔH / T) σt > Int32(0) && (JACC.@atomic vols[σt] -= Int32(1)) σs > Int32(0) && (JACC.@atomic vols[σs] += Int32(1)) lat[tx, ty, tz] = σs + st[tx, ty, tz] = UInt8(2) + else + st[tx, ty, tz] = UInt8(1) end return nothing end @@ -476,8 +488,13 @@ function run_coupled(; N::Int = 40, n_cells_per_species::Int = 6, n_mcs::Int = 100, seed::Int = 42, snapshot_interval::Int = 20, T_cpm = 5.0f0, λ_V = 10.0f0, V_target = Int32(120), I0 = 1.0, κ = 2.0, D_M = 0.1f0, dt_field = 0.5f0, - D_C = 0.2f0, C_wall = 1.0f0, rp = RadiolysisParams(), verbose = true) + D_C = 0.2f0, C_wall = 1.0f0, rp = RadiolysisParams(), verbose = true, + color_order = 0:7, on_sweep = nothing) @assert iseven(N) "checkerboard requires even N" + # A non-permutation here double-updates one class and never updates + # another, producing a silently wrong simulation that no downstream test + # would catch. The default 0:7 passes trivially. + @assert sort(collect(color_order)) == collect(0:7) "color_order must be a permutation of 0:7" lat_h, spec_h, vols_h, rad_h, mel_h, nut_h = init_host(N, n_cells_per_species, seed, I0, κ, Float64(C_wall)) @@ -494,17 +511,33 @@ function run_coupled(; N::Int = 40, n_cells_per_species::Int = 6, mel = JACC.array(mel_h); mel2 = JACC.array(zeros(Float32, N, N, N)) nut = JACC.array(nut_h); nut2 = JACC.array(zeros(Float32, N, N, N)) + # Acceptance instrumentation, reduced and never drawn (see d404438). + st = JACC.array(zeros(UInt8, N, N, N)) + dh = JACC.array(zeros(Float32, N, N, N)) + rd = init_radiolysis(rp; R = N / 2.0) Nh = N ÷ 2 gseed = splitmix64(UInt64(seed)) for mcs in 1:n_mcs - for c in 0:7 + # Reset ONCE per sweep, not per color pass. A per-pass reset would + # leave only the last color populated and look perfectly clean. + fill!(st, UInt8(0)) + fill!(dh, 0.0f0) + for c in color_order + # The RNG step key stays `mcs*8 + c`, keyed to the COLOR and not to + # its position in `color_order`. So permuting the order gives the + # same sites the same draws and changes only how much `vols` has + # accumulated when each pass reads it -- which is exactly the + # confound being separated out. JACC.parallel_for((Nh, Nh, Nh), cpm_color!, lat, vols, spec, J, βv, melc, rad, mel, Int32(c & 1), Int32((c >> 1) & 1), Int32((c >> 2) & 1), - gseed, UInt64(mcs * 8 + c), Int32(N), λ_V, V_target, T_cpm) + gseed, UInt64(mcs * 8 + c), Int32(N), λ_V, V_target, T_cpm, + st, dh) end + on_sweep === nothing || + on_sweep(mcs, JACC.to_host(st), JACC.to_host(dh)) if mcs % 10 == 1 X_tot, X_red = radial_biomass(JACC.to_host(lat), spec_h, N, rd.params.Nr) diff --git a/biofilms_radiodialysis.R b/biofilms_radiodialysis.R index e711568..36d0580 100644 --- a/biofilms_radiodialysis.R +++ b/biofilms_radiodialysis.R @@ -239,7 +239,27 @@ default_parms <- function(Nr = 40, R = 1.0) { w_minus = w$w_minus, # --- Transport --- - D_eff = 1e-3, # effective diffusivity (cm² s⁻¹), Table 2 range 1e-4..1e-2 + # RESTATED 2026-08-31 per claims_ledger PP-DEFF-01, which holds the withdrawn + # wording verbatim. It is deliberately NOT repeated here: this file is scanned + # by RETRACTED_IN_SOURCES and a withdrawn string in a correction comment is + # still a withdrawn string in the source someone copies from. The previous + # comment named a literature range and a Table 2 provenance, and both halves + # were wrong. THE RANGE: withdrawn. Its upper end lies two orders of + # magnitude above the free diffusivity of any aqueous solute, so it cannot + # contain a film-scale effective diffusivity; its lower end is unresolved, + # because neither the solute nor a transport temperature is declared anywhere + # in this file. THE PROVENANCE: Table 2 is tab:params, it exists, and its only + # diffusion rows are per-species cell motility D_s in µm²/s -- no D_eff, no + # cm²/s, no such range. + # + # AND THIS VALUE IS NOT A FILM-SCALE D_eff. One symbol carries three distinct + # quantities: the film-scale coefficient D0*eps/tau, which is strictly below + # D0; the bench observable D_app = D_eff/R, lumped by sorption; and this + # solver's own D_eff, which is UNRETARDED because sorption is carried + # explicitly by the two-phase c/s system below. This default is none of them + # -- at 1e-3 it is a reactor-scale dispersion coefficient under a film-scale + # name. See PP-DEFF-01 and PP-DAM-01. + D_eff = 1e-3, # cm² s⁻¹ -- see the note above; NOT a film-scale value # --- Biosorption / bioreduction (Renslow et al. 2017) --- k_ads = 0.05, # adsorption rate constant (cm³ g⁻¹ s⁻¹) diff --git a/calibration/tests/fixtures/README.md b/calibration/tests/fixtures/README.md new file mode 100644 index 0000000..5577d52 --- /dev/null +++ b/calibration/tests/fixtures/README.md @@ -0,0 +1,41 @@ +# Negative-control fixtures + +Each file here is a **known-bad input** that a guard in +`calibration/tests/test_claims_ledger.py` must detect. A guard that finds nothing +looks identical to a guard that can find nothing (AGENTS.md rule 1); these are what +make the difference visible. + +They are committed rather than recovered with `git show :`. That idiom +was used three times — `5980dc5`, `9319d43`, `e24dbec` — and each time the same +exposure was written down and left in place: a squash-merge makes the pinned commit +unreachable, and the control silently degrades into a skip, which rule 2 says to read +as uncovered surface. A committed file cannot become unreachable. + +| fixture | taken from | the guard it must fail | +|---|---|---| +| `modeling_radiotrophic_fitness_prerevision.md` | `5980dc5:preprint/modeling_radiotrophic_fitness.md` | >= 18 `delete` claims must be detected in the pre-revision manuscript | +| `wan_meeting_handout_prefix.tex` | `9319d43:preprint/wan_meeting_handout.tex` | `HANDOUT-02` must be detected | +| `fig1_radial_stratification_prefix.pdf` | `e24dbec:preprint/figures/fig1_radial_stratification.pdf` | `radiotrophic niche` / `radiosensitive core` must be detected | +| `fig2_melanin_accumulation_prefix.pdf` | `e24dbec:preprint/figures/fig2_melanin_accumulation.pdf` | `... are radiotrophic (melanin-mediated energy gain)` must be detected | +| `fig3_membrane_transport_prefix.pdf` | `e24dbec:preprint/figures/fig3_membrane_transport.pdf` | `(50 Gy cumulative)` must be detected | +| `phase2_diffusion_cell_prefix.txt` | `c2219a2:preprint/figures/phase2_diffusion_cell.txt` | `charge-excluded` / `same trap` must be detected — FIG-09 and FIG-10 | + +Do not regenerate or "fix" these files. Their value is that they are wrong. + +## Why the fourth one is not like the first three + +FIG-01, FIG-02 and FIG-05 are covered by the vocabulary floor because their claim +text is too short to search: `distinguishing_phrase` needs `MIN_WORDS = 5` from +one unbroken run, and two-word plot labels never yield it. + +**FIG-09 and FIG-10 do yield a full sentence, and are still unreachable by the +phrase guard.** `pdftotext -layout` reads a three-column figure across, so +"Bromide is size- and charge-excluded from fine porosity." comes back as +`...charge-excluded breakthrough lag gives a lumped run alongside every +measurement. from fine porosity.` — interleaved with two other columns and +contiguous nowhere. `_detected` returns `[]` for both rows against this file. + +That is why the fixture is here rather than a hand-run: appending the sentences +as clean lines confirms the guard against an input shape a column layout cannot +produce. Between `c2219a2` and the commit that added this file, both rows were +covered by nothing at all. diff --git a/calibration/tests/fixtures/fig1_radial_stratification_prefix.pdf b/calibration/tests/fixtures/fig1_radial_stratification_prefix.pdf new file mode 100644 index 0000000..1514d73 Binary files /dev/null and b/calibration/tests/fixtures/fig1_radial_stratification_prefix.pdf differ diff --git a/calibration/tests/fixtures/fig1_radial_stratification_prefix.txt b/calibration/tests/fixtures/fig1_radial_stratification_prefix.txt new file mode 100644 index 0000000..cb43b74 --- /dev/null +++ b/calibration/tests/fixtures/fig1_radial_stratification_prefix.txt @@ -0,0 +1,40 @@ + Radial stratification — cylindrical CPM bioreactor + + + + 1.0 + + + + radiotrophic + 0.8 niche + C. neoformans + + + + +Mean radial position r / R + D. radiodurans + 0.65 + C. sphaerospermum + 0.6 + 0.58 B. subtilis + + 0.50 A. niger + + S. oneidensis + 0.4 + O. intermedium + + + + radiosensitive + 0.2 core + + + + + 0.0 + 0 50 100 + Monte Carlo Steps + \ No newline at end of file diff --git a/calibration/tests/fixtures/fig2_melanin_accumulation_prefix.pdf b/calibration/tests/fixtures/fig2_melanin_accumulation_prefix.pdf new file mode 100644 index 0000000..7608125 Binary files /dev/null and b/calibration/tests/fixtures/fig2_melanin_accumulation_prefix.pdf differ diff --git a/calibration/tests/fixtures/fig2_melanin_accumulation_prefix.txt b/calibration/tests/fixtures/fig2_melanin_accumulation_prefix.txt new file mode 100644 index 0000000..8996c0e --- /dev/null +++ b/calibration/tests/fixtures/fig2_melanin_accumulation_prefix.txt @@ -0,0 +1,28 @@ + Melanin accumulation — radiation-driven production + 1.5 + 1.44 + + + + +Mean melanin field value at occupied sites + 1.0 0.97 + 0.94 + + + C. neoformans + + C. sphaerospermum + + A. niger + 0.5 + + + + + 0.0 + ★ C. neoformans, C. sphaerospermum are radiotrophic (melanin-mediated energy gain) + + 0 50 100 + Monte Carlo Steps + \ No newline at end of file diff --git a/calibration/tests/fixtures/fig3_membrane_transport_prefix.pdf b/calibration/tests/fixtures/fig3_membrane_transport_prefix.pdf new file mode 100644 index 0000000..9d0cfa9 Binary files /dev/null and b/calibration/tests/fixtures/fig3_membrane_transport_prefix.pdf differ diff --git a/calibration/tests/fixtures/fig3_membrane_transport_prefix.txt b/calibration/tests/fixtures/fig3_membrane_transport_prefix.txt new file mode 100644 index 0000000..579563b --- /dev/null +++ b/calibration/tests/fixtures/fig3_membrane_transport_prefix.txt @@ -0,0 +1,35 @@ + Membrane damage and radiation-driven permeability + + + 1.0 + + 2.5 + m = 0.779 + (502.7× baseline + Gy cumulative) + + + + +Membrane integrity m(t) + 2.0 + + + P_eff / P₀ + m(t) integrity + + P_eff / P₀ permeability + 0.5 + + + + 1.5 + + + + + 1.0 + 0.0 + 0 50 100 + Monte Carlo Steps + \ No newline at end of file diff --git a/calibration/tests/fixtures/modeling_radiotrophic_fitness_prerevision.md b/calibration/tests/fixtures/modeling_radiotrophic_fitness_prerevision.md new file mode 100644 index 0000000..10f3f43 --- /dev/null +++ b/calibration/tests/fixtures/modeling_radiotrophic_fitness_prerevision.md @@ -0,0 +1,334 @@ +# Modeling Radiotrophic Fitness + +**Hunter Kinder**, B.A., M.A.T.L. — Independent Researcher, Missouri, USA +**Brett Faulkner**, B.Sc, BioPhys — Independent Researcher + +*Preprint — submitted to bioRxiv (Systems Biology)* +*Version 1.0 — April 2026* + +--- + +## Abstract + +This work presents a mathematical framework to describe the radiotrophic and adaptive dynamics of microbial communities exposed to gamma radiation. The developed model integrates Langevin dynamics with reaction-diffusion equations to simulate the spatial motility, growth, and nonlinear interactions of radiotrophic fungi, extremophiles, and radiation-sensitive averse species. In this framework, radiation gradients and chemical factors, such as melanin synthesis, are introduced as modifiable parameters to analyze their influence on biofilm growth and adaptive responses. The model leverages stochastic differential equations to capture complex dependencies between environmental conditions and microbial interactions. Numerical simulations of seven interacting species — including *Cryptococcus neoformans*, *Deinococcus radiodurans*, *Cladosporium sphaerospermum*, *Bacillus subtilis*, *Aspergillus niger*, *Shewanella oneidensis*, and *Ochrobactrum intermedium* AM7 — demonstrate that radiation gradients drive spatial niche partitioning, with melanized radiotrophic fungi concentrating in high-radiation zones while radiosensitive metal-reducing bacteria occupy attenuated peripheries. This work contributes a predictive modeling tool capable of elucidating the nonlinear adaptive behaviors of radiotrophic communities in extreme environments, with applications to nuclear bioremediation. + +--- + +## 1. Introduction + +Microbial communities possess an extraordinary ability to adapt and survive under extreme environmental stressors, such as elevated radiation levels. Within such communities, radiotrophic fungi and other extremophilic microorganisms have evolved specialized mechanisms that allow them to thrive in high-radiation environments. This study aims to develop a comprehensive mathematical framework that captures the complex interplay between these organisms' nonlinear interactions, radiation sensitivities, and chemotactic behaviors within biofilm communities. By leveraging a combined approach that integrates Langevin dynamics, reaction-diffusion modeling, and a Hamiltonian-based formulation, we simulate and explore the spatiotemporal evolution of these communities under radiation stress and other external influences. + +The practical motivation for this work lies in nuclear bioremediation. Contaminated sites such as the Chernobyl Exclusion Zone and the Hanford Nuclear Reservation host complex microbial communities in which radiotrophic species coexist with metal-reducing bacteria capable of immobilizing radionuclides. Mathematical models that predict community dynamics under spatially heterogeneous radiation fields are essential for designing and optimizing bioremediation strategies. The framework developed here provides the theoretical foundation for such predictions. + +--- + +## 2. Related Work + +### 2.1 Radiotrophic Organisms and Melanin-Mediated Radiotrophy + +The discovery that melanized fungi thrive in high-radiation environments has fundamentally reshaped our understanding of biological energy transduction. Zhdanova et al. [1] first documented the prevalence of darkly pigmented fungi within the damaged reactor at the Chernobyl Nuclear Power Plant, noting that species such as *Cladosporium sphaerospermum* dominated the mycobiota of the most contaminated structures. Subsequent work by Dadachova et al. [2] demonstrated that ionizing radiation alters the electronic properties of melanin, enhancing electron-transfer activity in melanized cells of *Cryptococcus neoformans* and *Wangiella dermatitidis*. Melanized cells exposed to radiation levels approximately 500 times above background grew significantly faster, accumulated more biomass, and incorporated three-fold more ¹⁴C-acetate than non-irradiated melanized cells or irradiated albino mutants. Dadachova and Casadevall [3] subsequently proposed the term "radiosynthesis" to describe this phenomenon, drawing an analogy to photosynthesis in which melanin serves a role loosely comparable to chlorophyll. More recently, Shunk et al. [4] cultivated *C. sphaerospermum* aboard the International Space Station for 26 days, demonstrating a growth advantage of approximately 21% under space radiation conditions and measurable attenuation of ionizing radiation beneath the fungal biomass. + +The radiation resistance of *Deinococcus radiodurans* operates through fundamentally different mechanisms. Rather than harnessing radiation for metabolic benefit, *D. radiodurans* survives doses exceeding 12 kGy through a combination of efficient DNA double-strand break repair via extended synthesis-dependent strand annealing (ESDSA), multiple genome copies enabling homologous recombination, and a potent manganese-based antioxidant system that protects proteins from oxidative damage [5, 6]. Daly et al. [7] showed that small-molecule Mn²⁺-metabolite complexes specifically protect the proteome against radiation-induced carbonylation, establishing that protein protection, rather than DNA repair capacity alone, governs extreme radioresistance. *Aspergillus niger*, while not radiotrophic in the strict sense, produces melanin that confers substantial radioprotection, with melanized fungi generally exhibiting LD₁₀ values approaching or exceeding 1 kGy [3]. + +### 2.2 Mathematical Models of Biofilm Communities + +The mathematical modeling of biofilm communities has a rich history beginning with the foundational one-dimensional multispecies model of Wanner and Gujer [8], which coupled reaction-diffusion equations for substrate transport with biomass conservation laws to predict biofilm thickness and spatial species distributions. Xavier et al. [9] extended this framework to multiple spatial dimensions, providing a deterministic continuum approach to heterogeneous biofilm development. Individual-based modeling approaches, exemplified by the iDynoMiCS platform of Lardon et al. [10], complement continuum models by resolving cell-level stochastic interactions. Cross-diffusion biofilm models have been analyzed by Sonner et al. [11], who established mathematical properties of volume-filling multispecies systems that exhibit porous-medium-type degeneracy. Our PSDE framework extends these precedents by incorporating radiation-dependent fitness terms and phase-locking dynamics that couple species interactions to external radiation fields, moving beyond the substrate-limited growth paradigms that dominate existing biofilm models. + +### 2.3 Hamiltonian and Stochastic Approaches in Theoretical Ecology + +The application of Hamiltonian mechanics to ecological systems remains comparatively underexplored, despite the natural conservation laws that govern energy flow in ecosystems. Symplectic integration methods, which preserve the geometric structure of Hamiltonian flows, have been advocated for ecological modeling by Diele et al. [12] to ensure that numerical solutions maintain essential qualitative dynamics. The Kuramoto model of coupled phase oscillators [13] provides a well-established framework for synchronization phenomena in biological systems, from neural networks to circadian rhythms. Acebrón et al. [14] reviewed the model as a paradigm for synchronization, noting its generalization to systems with time delays, frequency-weighted coupling, and heterogeneous interaction topologies. Our phase-locking kernel Γ_s(t,x) draws directly on this tradition, extending it to multispecies microbial communities where species-level oscillatory dynamics in growth and resource acquisition couple through shared radiation and nutrient fields. The use of Langevin stochastic dynamics to describe microbial population fluctuations follows naturally from the Fokker-Planck formalism and has been applied to competing microbial populations in chemostat systems [15], where stochastic differential equations revealed long-term competitive outcomes differing from deterministic predictions. + +### 2.4 Bioremediation Context + +The practical motivation for modeling radiotrophic communities lies in nuclear bioremediation. *Shewanella oneidensis* MR-1 is a model dissimilatory metal-reducing bacterium capable of reducing soluble U(VI) to insoluble U(IV) via extracellular electron transfer through c-type cytochromes [16, 17]. *Ochrobactrum intermedium* AM7, isolated from soil near the Kakrapar Atomic Power Station in India, tolerates high concentrations of thorium and produces exopolysaccharides (EPS) that complex with Th(IV), offering a pathway toward bioremediation of actinide-contaminated environments [18]. The combination of radiotrophic fungi that actively benefit from radiation with metal-reducing bacteria that immobilize radionuclides suggests the possibility of engineered biofilm consortia for in situ remediation. The fitness landscape framework introduced by Wright [19] and formalized through the NK model of Kauffman [20] provides a conceptual foundation for understanding how epistatic interactions among species traits shape adaptation under radiation stress, a perspective that our Hamiltonian kNN decision tree operationalizes quantitatively. + +--- + +## 3. Mathematical Framework + +### 3.1 Symbols and Notation + +| Symbol | Description | +|--------|-------------| +| F_s(t,x) | Fitness function of species s, evolving over time and space | +| H(p,q) | Hamiltonian function defining total system energy | +| p, q | Phase-space variables (momentum and position) | +| η_s(t,x) | Random environmental noise term | +| D_s, μ_s | Species-specific diffusion and motility coefficients | +| P_sj(t) | Permutation matrix describing species transitions | +| γ(t,x) | Gamma irradiation field | +| k_B | Boltzmann constant | +| S[q(t)] | Action functional for species trajectories | +| ∇ | Gradient operator | +| Γ_s(t,x) | Phase-locked kernel for species s | +| β_s,ion | Ionizing radiation sensitivity coefficient | +| α_s,nir | Non-ionizing radiation coupling coefficient | +| θ_s | Adaptive phase-locking coefficient | + +### 3.2 Partial Stochastic Differential Equation (PSDE) for Species Fitness + +The fitness of each species is governed by the following PSDE: + +$$\partial_t F_s(t,x) = \nabla \cdot (D_s \nabla F_s) - \nabla \cdot \left(\mu_s \sum_{j=1}^n P_{sj}(t) F_j\right) + R_s + \eta_s - \beta_{s,\text{ion}} I F_s + \gamma_s \Delta_s - \alpha_{s,\text{nir}} N F_s + \theta_s H_s + C_s$$ + +The terms encode: +- **Diffusion**: D_s ∇F_s — passive spatial spreading +- **Directed motility**: μ_s Σ_j P_sj F_j — chemotaxis and radiation-gradient-directed movement +- **Deterministic reaction**: R_s(t,x) — nutrient-dependent growth +- **Stochastic noise**: η_s(t,x) = σ_s ξ(t,x) — white noise perturbation +- **Ionizing damage**: −β_s,ion I(t,x) F_s — radiation-induced fitness reduction +- **Phase-locking adjustment**: γ_s Δ_s — adaptive synchronization gain +- **Non-ionizing effect**: −α_s,nir N(t,x) F_s — UV/heat-driven fitness coupling +- **Hamiltonian interaction**: θ_s H_s(t,x) — energy-conserving inter-species force +- **External forcing**: C_s(t,x) — environmental control inputs + +### 3.3 Hamiltonian Framework + +The total Hamiltonian for the multi-species biofilm is: + +$$H = \sum_{i=1}^N \left[\frac{1}{2} m_i v_i^2 + U_i(x_i)\right] + \sum_{i \neq j} V_{ij}(r_{ij}) + \sum_{k=1}^K W_k(t,x)$$ + +where m_i is effective species biomass density, U_i(x_i) is the potential energy in the nutrient concentration field, V_ij(r_ij) is the pairwise interaction energy (mutualistic: V_mutual = −γ exp(−r²/σ²)), and W_k(t,x) captures external energy contributions from radiation exposure and stochastic effects. + +### 3.4 Symplectic Integration + +Symplectic integrators maintain the structure of the Hamiltonian system, ensuring energy and momentum conservation over extended simulation windows. Phase-space trajectories evolve via the phase-locked canonical equations: + +$$\frac{dq}{dt} = \frac{\partial H}{\partial p} - \Gamma_s(t,x) F_s(t,x), \quad \frac{dp}{dt} = -\frac{\partial H}{\partial q} + \Gamma_s(t,x) F_s(t,x)$$ + +The leapfrog/Verlet scheme is employed numerically: (1) evaluate H_s based on current state; (2) adjust phase-locking coefficient Δ_s; (3) integrate via symplectic step. + +### 3.5 Radiation Field Models + +**Ionizing radiation (gamma):** +$$I(t,x) = I_\gamma \exp(-\kappa x)$$ + +where I_γ is the initial intensity and κ is the attenuation coefficient scaled by biofilm density. + +**Non-ionizing radiation (UV):** +$$N(t,x) = N_{UV} \cos(\omega t)$$ + +### 3.6 Melanin Reaction-Diffusion + +The diffusion-driven melanin production by radiotrophic fungi is modeled as: + +$$\frac{\partial M}{\partial t} = D_M \nabla^2 M + \alpha_M \cdot N_{\text{RadioF}} \cdot R(t,x)$$ + +where D_M is the melanin diffusion coefficient, α_M the radiation-driven production rate, and N_RadioF the local radiotrophic cell density. + +### 3.7 Nutrient Uptake with Adaptive Feedback + +$$R_s(t,x) = \alpha_s C_s(t,x)\left(1 - \frac{F_s(t,x)}{K_s}\right) + A_s(t) C_s(t,x)$$ + +$$A_s(t) = \gamma_s \phi_s(t) - \beta_{s,\text{ion}} I(t)$$ + +where φ_s(t) = cos(ω_s t − θ_s) is the phase alignment function and A_s(t) is the adaptive feedback driven by phase-locking and radiation stress. + +### 3.8 Hamiltonian kNN Decision Tree + +To model species transition probabilities modulated by neighborhood phase states: + +$$H_{\text{k-NN}} = \frac{1}{\sigma_n} \sum_{j=1}^n P_{sj}(t)\, F_j(t,x)\, \Gamma_s(t,x)$$ + +### 3.9 Radiation Damage and Resilience + +$$D_{\text{rad},s}(t,x) = -\beta_{s,\text{rad}} I(t,x) F_s(t,x)$$ + +Species with higher melanin production or stronger DNA repair exhibit lower β_s,rad values. + +### 3.10 Biofilm Mechanical Properties + +The EPS matrix is modeled as a Finite Extensible Nonlinear Elastic (FENE) entropic spring: + +$$U(r) = -\frac{1}{2} k R^2 \ln\left(1 - \frac{r^2}{R^2}\right)$$ + +Viscoelastic creep follows the Kelvin-Voigt model: + +$$\sigma(t) = E\epsilon(t) + \eta \frac{d\epsilon}{dt}$$ + +with stress relaxation time τ = η/E governing biofilm restructuring dynamics. + +--- + +## 4. Parameter Estimation + +Biologically justified parameter ranges for the six primary modeled species, derived from published experimental data: + +| Parameter | Symbol | Species | Value Range | Units | Biological Basis | Ref | +|-----------|--------|---------|-------------|-------|-----------------|-----| +| Diffusion coefficient | D_s | *C. neoformans* | 0.01–0.10 | μm²/s | Passive Brownian diffusion, ~5 μm diameter yeast | [21] | +| Diffusion coefficient | D_s | *D. radiodurans* | 0.05–0.50 | μm²/s | Small coccoid (~1.5 μm); higher D from smaller size | [5] | +| Diffusion coefficient | D_s | *B. subtilis* | 0.10–1.00 | μm²/s | Rod-shaped motile cells; active motility augments D | [22] | +| Diffusion coefficient | D_s | *C. sphaerospermum* | 0.005–0.05 | μm²/s | Filamentous hyphae; low effective diffusion | [4] | +| Diffusion coefficient | D_s | *A. niger* | 0.005–0.05 | μm²/s | Filamentous; comparable to *C. sphaerospermum* | [3] | +| Diffusion coefficient | D_s | *S. oneidensis* | 0.10–0.80 | μm²/s | Facultative anaerobe, rod-shaped, flagellated | [16] | +| Motility coefficient | μ_s | *C. neoformans* | 0.00–0.05 | μm/s | Non-motile yeast; residual drift from EPS flow | [2] | +| Motility coefficient | μ_s | *D. radiodurans* | 0.00–0.01 | μm/s | Non-motile coccus | [5] | +| Motility coefficient | μ_s | *B. subtilis* | 15–45 | μm/s | Flagellum-driven; ~25 μm/s at 30°C | [22] | +| Motility coefficient | μ_s | *C. sphaerospermum* | 0.00–0.005 | μm/s | Hyphal extension only; ~0.1–5 μm/min tip growth | [4] | +| Motility coefficient | μ_s | *S. oneidensis* | 10–40 | μm/s | Polar flagellum; comparable to *E. coli* | [16] | +| Ionizing rad. sensitivity | β_s,ion | *C. neoformans* | 1×10⁻⁵–1×10⁻⁴ | Gy⁻¹ | Melanized; LD₁₀ ~2–5 kGy | [2, 3] | +| Ionizing rad. sensitivity | β_s,ion | *D. radiodurans* | 5×10⁻⁶–5×10⁻⁵ | Gy⁻¹ | D₁₀ ~12 kGy; most radioresistant known organism | [5, 6] | +| Ionizing rad. sensitivity | β_s,ion | *B. subtilis* | 1×10⁻³–5×10⁻³ | Gy⁻¹ | Spore D₁₀ ~1–2 kGy; vegetative D₁₀ ~0.2–0.6 kGy | [23] | +| Ionizing rad. sensitivity | β_s,ion | *C. sphaerospermum* | 1×10⁻⁵–1×10⁻⁴ | Gy⁻¹ | Melanized Chernobyl isolate; comparable to *C. neoformans* | [1, 4] | +| Ionizing rad. sensitivity | β_s,ion | *A. niger* | 5×10⁻⁵–5×10⁻⁴ | Gy⁻¹ | Melanized but less resistant than Chernobyl isolates | [3] | +| Ionizing rad. sensitivity | β_s,ion | *S. oneidensis* | 5×10⁻²–1×10⁻¹ | Gy⁻¹ | D₁₀ ~0.07 kGy; extremely radiosensitive | [6] | +| Melanin production rate | α_M | *C. neoformans* | 0.05–0.15 | μg/cell/Gy | Substrate-dependent; enhanced by radiation | [2] | +| Melanin production rate | α_M | *C. sphaerospermum* | 0.08–0.20 | μg/cell/Gy | Constitutively melanized; high production | [1, 4] | +| Melanin production rate | α_M | *A. niger* | 0.03–0.10 | μg/cell/Gy | DHN-melanin pathway | [3] | +| Carrying capacity | K_s | *C. neoformans* | 10⁴–10⁶ | cells/mm³ | Yeast biofilm density under nutrient-rich conditions | [2] | +| Carrying capacity | K_s | *D. radiodurans* | 10⁵–10⁷ | cells/mm³ | Dense tetrads; high packing efficiency | [5] | +| Carrying capacity | K_s | *B. subtilis* | 10⁵–10⁷ | cells/mm³ | Dense biofilm with EPS matrix | [22] | +| Carrying capacity | K_s | *S. oneidensis* | 10⁵–10⁷ | cells/mm³ | Planktonic and biofilm modes | [16] | +| Phase-locking frequency | ω_s | All species | 0.01–1.0 | rad/hr | Circadian/ultradian metabolic oscillation; species-specific | [13, 14] | +| Noise intensity | σ_s | All species | 0.001–0.05 | — | Thermal and demographic stochasticity | [15] | + +--- + +## 5. Computational Methods + +Simulations were implemented in Julia using `DifferentialEquations.jl` for numerical integration of the PSDE system. Spatial species distributions were initialized with small random perturbations around a central point and evolved on a [0,1]² domain. The Euler-Maruyama scheme was applied for stochastic integration; Runge-Kutta (RK4) was used for deterministic validation runs. Symplectic leapfrog integration was employed for the Hamiltonian phase-space trajectories. + +Optimization of species-specific parameters was performed with `JuMP.jl` and `Ipopt`. Sensitivity analysis employed Sobol indices (global variance decomposition) and Morris screening to identify the highest-influence parameters (μ_s, D_s, β_s,ion, θ_s). Visualization used `PlotlyJS.jl` for interactive 3D plots, `Agents.jl` for agent-based validation runs, and `Plots.jl` with `GR` backend for static trajectory outputs. The interactive simulation includes sliders for gamma radiation intensity, thorium intensity, and heat intensity at 45-step resolution. + +--- + +## 6. Results + +### 6.1 Species Trajectory Clustering Under Radiation Gradients + +The Langevin diffusion simulations on the [0,1]² domain reveal pronounced spatial phenotype separation under the imposed radiation gradient (Figure 1). k-means clustering of species trajectories identifies three distinct spatial niches. The first cluster, concentrated in the high-radiation zone, is dominated by the melanized species *C. neoformans* and *C. sphaerospermum*, whose positive gamma sensitivity coefficients (γ_s ≈ 0.07 and 0.06 respectively) enable them to exploit ionizing radiation as a metabolic stimulus. The second cluster occupies the intermediate-radiation zone and contains *D. radiodurans* and *A. niger* — species that tolerate radiation without deriving direct metabolic benefit. The third cluster, in the low-radiation periphery, comprises *B. subtilis*, *S. oneidensis*, and the marine taxa (*Pseudoalteromonas* sp., *Polaribacter* sp., *Flavobacterium* sp.), whose higher radiation sensitivities confine them to regions where the ionizing flux has attenuated below their damage thresholds. This spatial segregation is consistent with radiation-mediated niche partitioning, in which ionizing radiation simultaneously serves as stressor and resource — a dual role with no direct analogue in classical substrate-competition biofilm models. + +### 6.2 Thorium-232 Decay Integration + +The Th-232 decay model (Figure 2) compares actual versus predicted radioactive decay on a logarithmic scale spanning billions of years, incorporating stochastic perturbation around the deterministic exponential decay law. The 14.05-billion-year half-life produces a quasi-static radiation source on biological timescales (hours to years), but the stochastic perturbation term captures quantum-mechanical uncertainty in individual decay events, which manifests as Poisson-distributed dose-rate fluctuations. For biofilm modeling purposes, Th-232 provides an effectively constant ionizing background, while stochastic perturbation introduces dose-rate heterogeneity at the spatial scale of microcolonies (tens of micrometers), driving the phase-space fluctuations captured by the Langevin dynamics. + +### 6.3 Motility-Diffusion-Gamma Sensitivity Relationships + +The three-dimensional scatter relating motility (μ_s), diffusion coefficient (D_s), and gamma sensitivity across species (Figure 3) reveals a striking inverse relationship between radiation sensitivity and motility. *C. neoformans*, at the highest gamma sensitivity (≈0.07), exhibits effectively zero motility, consistent with the hypothesis that radiotrophic organisms invest resources in melanin production rather than active locomotion. *D. radiodurans*, with the lowest gamma sensitivity, is likewise non-motile but for different mechanistic reasons: radioresistance derives from molecular protection rather than radiation exploitation. The motile species (*B. subtilis*, *S. oneidensis*) occupy the high-motility, low-gamma-sensitivity region, suggesting a trade-off between radiation exploitation and behavioral avoidance. As radiation intensity rises, the competitive advantage shifts from motile radiation-avoiders to sessile radiotrophic beneficiaries. + +### 6.4 Phase-Locking and Hamiltonian kNN Dynamics + +The Hamiltonian kNN decision tree (Figure 4) reveals that synchronized metabolic oscillations emerge among species occupying the same spatial niche. Within the high-radiation cluster, *C. neoformans* and *C. sphaerospermum* exhibit strong phase coherence in their melanin production cycles, suggesting cooperative dynamics in which shared melanin-derived radioprotection benefits both. Species in different radiation niches show weak phase coupling, indicating competitive exclusion at niche boundaries. The kNN decision tree correctly predicts species dominance transitions: as simulated radiation intensity increases beyond 1 kGy, *B. subtilis* populations collapse, *D. radiodurans* maintains steady-state abundance, and *C. neoformans* increases in fitness — reproducing the biologically expected ordering of radiation tolerance. + +--- + +## 7. Discussion + +### 7.1 The Hamiltonian Framework Versus Classical Biofilm Models + +The phase-locked Hamiltonian framework differs fundamentally from prior reaction-diffusion biofilm models in its treatment of inter-species interactions and energy conservation. Classical models following the Wanner-Gujer tradition [8] describe biofilm dynamics through coupled PDEs governing substrate consumption and biomass growth, with species interactions mediated exclusively through competition for shared substrates. Our Hamiltonian H(p,q) explicitly conserves total system energy across kinetic, potential, and interaction terms, ensuring that numerical integration via the symplectic leapfrog scheme does not introduce artificial energy drift. The stochastic Langevin layer adds thermal and demographic noise while preserving the underlying Hamiltonian structure through the fluctuation-dissipation theorem. This dual structure captures phenomena that ODE/PDE models inherently miss: synchronized metabolic oscillations through phase-locking, energy-conserving transitions between fitness states, and the distinction between radiation as a damaging perturbation versus radiation as an energy source positively coupled to the Hamiltonian. + +### 7.2 Biological Interpretation of *C. neoformans* and *D. radiodurans* Dynamics + +The simulation trajectories for *C. neoformans* and *D. radiodurans* align with known biology while illuminating an underappreciated distinction between radiotrophy and radioresistance. *C. neoformans* in the model increases its fitness monotonically with radiation intensity up to a species-specific threshold, consistent with the experimental findings of Dadachova et al. [2] that melanized cells show enhanced metabolic activity and faster growth under ionizing radiation. The model captures the mechanism through the positive gamma sensitivity term γ_s, which converts absorbed dose into fitness gain via the melanin-mediated electron-transfer pathway [24, 38]. *D. radiodurans*, by contrast, maintains approximately constant fitness across a wide dose range, declining only at extreme doses. This flat fitness profile emerges from the balance between the radiation damage term β_s,ion and the organism's molecular protection coefficient, reflecting the Mn²⁺-dependent proteome shielding identified by Daly et al. [7]. The model thus correctly predicts that *C. neoformans* outcompetes *D. radiodurans* in moderately irradiated environments where radiation provides metabolic benefit, while *D. radiodurans* prevails at extreme doses where even melanin-mediated protection is overwhelmed. + +### 7.3 Implications for Nuclear Bioremediation + +The model's predictions for a mixed *S. oneidensis* and *O. intermedium* AM7 biofilm in a Th-contaminated environment suggest a synergistic remediation strategy. *S. oneidensis*, with its capacity for extracellular electron transfer to metal oxides [16, 17], can reduce soluble actinide species and immobilize them as insoluble precipitates. However, its extreme radiosensitivity (D₁₀ ~0.07 kGy) limits viability in high-radiation zones. The model predicts that *O. intermedium* AM7, with its thorium-tolerant EPS production [18], establishes a protective biofilm matrix in the high-radiation zone that attenuates local dose rates sufficiently to permit *S. oneidensis* colonization in the intermediate zone. This spatial partitioning — emergent from the Langevin dynamics rather than imposed as a boundary condition — suggests that self-organizing biofilm architectures may naturally optimize remediation performance. The potential for engineering *D. radiodurans* strains for combined radioresistance and metal reduction [28] offers an additional path toward single-organism bioremediation in extreme environments. + +### 7.4 Mechanical Properties and Radiation Survival + +The inclusion of FENE entropic spring and Kelvin-Voigt viscoelastic components addresses a dimension of biofilm physics largely absent from ecological radiation models. Biofilm EPS matrices exhibit viscoelastic behavior that mediates mechanical stress transmission, nutrient diffusion resistance, and protection from external perturbations. The FENE potential provides finite extensibility that prevents unphysical deformation under radiation-induced swelling or thermal expansion, while the Maxwell element captures stress relaxation on timescales relevant to biofilm restructuring. Species with higher EPS production rates (*O. intermedium* AM7, *B. subtilis*) generate mechanically stiffer local environments that resist radiation-induced structural degradation, creating protected microniches for radiosensitive community members. + +### 7.5 Limitations and Future Directions + +Several simplifying assumptions limit the current model's applicability to real contaminated sites. The radiation field is treated as spatially homogeneous with exponential attenuation, whereas actual environments at Chernobyl and Hanford exhibit complex three-dimensional dose distributions governed by heterogeneous source geometries and shielding materials. The UV oscillatory term N(t,x) = N_UV cos(ωt) assumes a purely sinusoidal diurnal cycle, neglecting atmospheric absorption spectra and seasonal variation. The model does not incorporate horizontal gene transfer, which is known to spread radiation resistance determinants among biofilm community members. Future work should couple the fitness model to experimentally measured dose-rate maps from contaminated facilities, incorporate spatially resolved nutrient transport using Darcy flow through the EPS matrix, and validate predicted species clustering patterns against metagenomic surveys of radiotrophic biofilm communities at nuclear sites. + +--- + +## 8. Conclusion + +This work introduces a Hamiltonian-Langevin framework for modeling radiotrophic fitness in multispecies biofilm communities exposed to ionizing radiation. The PSDE fitness equation integrates radiation-dependent growth, melanin-mediated energy transduction, stochastic demographic noise, and viscoelastic biofilm mechanics into a unified formalism that conserves total system energy through symplectic integration. The central result is that species niche partitioning under radiation gradients emerges naturally from the phase-locked Hamiltonian dynamics, with radiotrophic fungi (*C. neoformans*, *C. sphaerospermum*) clustering in high-radiation zones, radioresistant bacteria (*D. radiodurans*) occupying intermediate regions, and radiosensitive metal-reducing species (*S. oneidensis*) confined to attenuated peripheries. These predictions are consistent with known radiobiology and suggest design principles for engineered biofilm consortia targeting nuclear bioremediation. Experimental validation through controlled irradiation of mixed-species biofilms, combined with spatially resolved transcriptomics and metabolomics, represents the natural next step toward translating this theoretical framework into practical bioremediation strategies. + +--- + +## References + +1. Zhdanova, N.N., Zakharchenko, V.A., Vember, V.V., Nakonechnaya, L.T. (2000). Fungi from Chernobyl: mycobiota of the inner regions of the containment structures of the damaged nuclear reactor. *Mycological Research*, 104(12), 1421–1426. https://doi.org/10.1017/S0953756200002756 + +2. Dadachova, E., Bryan, R.A., Huang, X., Moadel, T., Schweitzer, A.D., Aisen, P., Nosanchuk, J.D., Casadevall, A. (2007). Ionizing radiation changes the electronic properties of melanin and enhances the growth of melanized fungi. *PLoS ONE*, 2(5), e457. https://doi.org/10.1371/journal.pone.0000457 + +3. Dadachova, E., Casadevall, A. (2008). Ionizing radiation: how fungi cope, adapt, and exploit with the help of melanin. *Current Opinion in Microbiology*, 11(6), 525–531. https://doi.org/10.1016/j.mib.2008.09.013 + +4. Shunk, G.K., Gomez, X.R., Kern, C., Averesch, N.J.H. (2022). Cultivation of the dematiaceous fungus *Cladosporium sphaerospermum* aboard the International Space Station and effects of ionizing radiation. *Frontiers in Microbiology*, 13, 877625. https://doi.org/10.3389/fmicb.2022.877625 + +5. Slade, D., Radman, M. (2011). Oxidative stress resistance in *Deinococcus radiodurans*. *Microbiology and Molecular Biology Reviews*, 75(1), 133–191. https://doi.org/10.1128/MMBR.00015-10 + +6. Daly, M.J. (2009). A new perspective on radiation resistance based on *Deinococcus radiodurans*. *Nature Reviews Microbiology*, 7(3), 237–245. https://doi.org/10.1038/nrmicro2073 + +7. Daly, M.J., et al. (2010). Small-molecule antioxidant proteome-shields in *Deinococcus radiodurans*. *PLoS ONE*, 5(9), e12570. https://doi.org/10.1371/journal.pone.0012570 + +8. Wanner, O., Gujer, W. (1986). A multispecies biofilm model. *Biotechnology and Bioengineering*, 28(3), 314–328. https://doi.org/10.1002/bit.260280304 + +9. Xavier, J.B., Picioreanu, C., van Loosdrecht, M.C.M. (2005). A framework for multidimensional modelling of activity and structure of multispecies biofilms. *Environmental Microbiology*, 7(8), 1085–1103. https://doi.org/10.1111/j.1462-2920.2005.00787.x + +10. Lardon, L.A., et al. (2011). iDynoMiCS: next-generation individual-based modelling of biofilms. *Environmental Microbiology*, 13(9), 2416–2434. https://doi.org/10.1111/j.1462-2920.2011.02414.x + +11. Sonner, S., Efendiev, M.A., Eberl, H.J. (2015). On the well-posedness of a mathematical model of quorum-sensing in patchy biofilm communities. *Mathematical Methods in the Applied Sciences*, 38(3), 3037–3042. https://doi.org/10.1002/mma.3237 + +12. Diele, F., Marangi, C., Ragni, S. (2015). Geometric numerical integration in ecological modelling. *Mathematics and Computers in Simulation*, 110, 40–52. https://doi.org/10.1016/j.matcom.2014.02.006 + +13. Kuramoto, Y. (1984). *Chemical Oscillations, Waves, and Turbulence*. Springer-Verlag, Berlin. https://doi.org/10.1007/978-3-642-69689-3 + +14. Acebrón, J.A., Bonilla, L.L., Pérez Vicente, C.J., Ritort, F., Spigler, R. (2005). The Kuramoto model: a simple paradigm for synchronization phenomena. *Reviews of Modern Physics*, 77(1), 137–185. https://doi.org/10.1103/RevModPhys.77.137 + +15. Campillo, F., Joannides, M., Larramendy-Valverde, I. (2017). Stochastic analysis of a full system of two competing populations in a chemostat. *Chemical Engineering Science*, 175, 424–440. https://doi.org/10.1016/j.ces.2017.10.052 + +16. Heidelberg, J.F., et al. (2002). Genome sequence of the dissimilatory metal ion-reducing bacterium *Shewanella oneidensis*. *Nature Biotechnology*, 20(11), 1118–1123. https://doi.org/10.1038/nbt749 + +17. Veeramani, H., et al. (2011). Products of abiotic U(VI) reduction by biogenic magnetite and vivianite. *Geochimica et Cosmochimica Acta*, 75(9), 2512–2528. https://doi.org/10.1016/j.gca.2011.02.024 + +18. Shukla, A., Parmar, P., Goswami, D., Patel, B., Saraf, M. (2020). Characterization of novel thorium tolerant *Ochrobactrum intermedium* AM7. *Journal of Hazardous Materials*, 388, 122047. https://doi.org/10.1016/j.jhazmat.2020.122047 + +19. Wright, S. (1932). The roles of mutation, inbreeding, crossbreeding and selection in evolution. *Proceedings of the Sixth International Congress of Genetics*, 1, 356–366. + +20. Kauffman, S.A., Weinberger, E.D. (1989). The NK model of rugged fitness landscapes. *Journal of Theoretical Biology*, 141(2), 211–245. https://doi.org/10.1016/S0022-5193(89)80019-0 + +21. Berg, H.C. (1993). *Random Walks in Biology*. Princeton University Press. + +22. Guttenplan, S.B., Shaw, S., Kearns, D.B. (2013). The cell biology of peritrichous flagella in *Bacillus subtilis*. *Molecular Microbiology*, 87(1), 211–229. https://doi.org/10.1111/mmi.12103 + +23. Nicholson, W.L., et al. (2000). Resistance of *Bacillus* endospores to extreme terrestrial and extraterrestrial environments. *Microbiology and Molecular Biology Reviews*, 64(3), 548–572. https://doi.org/10.1128/MMBR.64.3.548-572.2000 + +24. Turick, C.E., et al. (2011). Gamma radiation interacts with melanin to alter its oxidation-reduction potential and results in electric current production. *Bioelectrochemistry*, 82(1), 69–73. https://doi.org/10.1016/j.bioelechem.2011.04.009 + +25. Robertson, K.L., et al. (2012). Adaptation of the black yeast *Wangiella dermatitidis* to ionizing radiation. *PLoS ONE*, 7(11), e48674. https://doi.org/10.1371/journal.pone.0048674 + +26. Malo, M.E., et al. (2018). Morphological changes in melanized and non-melanized *Cryptococcus neoformans* cells post exposure to ionizing radiation. *Fungal Biology*, 122(6), 449–456. https://doi.org/10.1016/j.funbio.2017.08.012 + +27. Newsome, L., Morris, K., Lloyd, J.R. (2014). The biogeochemistry and bioremediation of uranium and other priority radionuclides. *Chemical Geology*, 363, 164–184. https://doi.org/10.1016/j.chemgeo.2013.10.034 + +28. Brim, H., et al. (2000). Engineering *Deinococcus radiodurans* for metal remediation in radioactive mixed waste environments. *Nature Biotechnology*, 18(1), 85–90. https://doi.org/10.1038/71986 + +29. Kazy, S.K., D'Souza, S.F., Sar, P. (2009). Uranium and thorium sequestration by a *Pseudomonas* sp. *Journal of Hazardous Materials*, 163(1), 65–72. https://doi.org/10.1016/j.jhazmat.2008.06.076 + +30. Blasius, B., Huppert, A., Stone, L. (1999). Complex dynamics and phase synchronization in spatially extended ecological systems. *Nature*, 399, 354–359. https://doi.org/10.1038/20676 + +31. Alpkvist, E., Klapper, I. (2007). A multidimensional multispecies continuum model for heterogeneous biofilm development. *Bulletin of Mathematical Biology*, 69(2), 765–789. https://doi.org/10.1007/s11538-006-9168-7 + +32. Eberl, H.J., Parker, D.F., van Loosdrecht, M.C.M. (2001). A new deterministic spatio-temporal continuum model for biofilm development. *Journal of Theoretical Medicine*, 3(3), 161–175. https://doi.org/10.1080/10273660108833072 + +33. Hairer, E., Lubich, C., Wanner, G. (2006). *Geometric Numerical Integration*, 2nd ed. Springer-Verlag. https://doi.org/10.1007/3-540-30666-8 + +34. Eisenman, H.C., Casadevall, A. (2012). Synthesis and assembly of fungal melanin. *Applied Microbiology and Biotechnology*, 93(3), 931–940. https://doi.org/10.1007/s00253-011-3777-2 + +35. Lloyd, J.R., Renshaw, J.C. (2005). Bioremediation of radioactive waste. *Current Opinion in Biotechnology*, 16(3), 254–260. https://doi.org/10.1016/j.copbio.2005.04.012 + +36. Khajo, A., et al. (2011). Protection of melanized *Cryptococcus neoformans* from lethal dose gamma irradiation. *PLoS ONE*, 6(9), e25092. https://doi.org/10.1371/journal.pone.0025092 + +37. Battista, J.R. (1997). Against all odds: the survival strategies of *Deinococcus radiodurans*. *Annual Review of Microbiology*, 51, 203–224. https://doi.org/10.1146/annurev.micro.51.1.203 + +38. Casadevall, A., et al. (2017). Melanin, radiation, and energy transduction in fungi. *Microbiology Spectrum*, 5(2), FUNK-0037-2016. https://doi.org/10.1128/microbiolspec.FUNK-0037-2016 + +--- + +## Notation Fixes Required Before Submission + +1. **Species index**: Standardize to subscript *s* throughout; use *j* only for neighbor/interaction species in summations +2. **Radiation intensity**: Rename I(t,x) → I_γ(t,x) everywhere to prevent confusion with the identity matrix +3. **Noise term**: Explicitly state η_s(t,x) = σ_s ξ(t,x) where ξ is spatiotemporal white noise +4. **Hamiltonian mass**: Define m_i explicitly as effective biomass density [cells/μm³ × effective mass analog] +5. **Melanin units**: Verify dimensional consistency in ∂M/∂t = D_M ∇²M + α_M · N_RadioF · R; each term should carry [μg/μm³/s] +6. **Adaptive feedback**: Provide explicit form of A_s(t) = γ_s φ_s(t) − β_s,ion I(t) in the nutrient uptake section +7. **Section numbering**: Renumber sequentially; remove duplicate §2, §3, §8 headers from original draft diff --git a/calibration/tests/fixtures/phase2_diffusion_cell_prefix.txt b/calibration/tests/fixtures/phase2_diffusion_cell_prefix.txt new file mode 100644 index 0000000..f99e5b0 --- /dev/null +++ b/calibration/tests/fixtures/phase2_diffusion_cell_prefix.txt @@ -0,0 +1,42 @@ +Phase 2 — two-chamber diffusion cell +Biofilm grown on a permeable support; transport read as receiver breakthrough + + + biofilm on permeable support + + + + + Donor Receiver + concentration held constant held near zero + timed aliquots + one solute per run C(t) gives the breakthrough curve + + + + metal to ICP; bromide to IC + + + + + Both chambers stirred — unstirred boundary layers otherwise read as film resistance. + + + +Paired blank: bare support Open choice: the tracer What one run yields +Identical cell and support, no biofilm, Bromide is size- and charge-excluded Breakthrough lag gives a lumped +run alongside every measurement. from fine porosity. In Hanford sediments transport parameter, not a diffusivity +Gives the support’s own resistance and tritiated water resolved intragranular alone: sorption retards it. Separating +its wall sorption. Without it the result pore volume where bromide did not; an the two needs the partition coefficient +is film plus support, not film. anionic EPS matrix is the same trap. from the Phase 1 suspended isotherm. + +Edge seal checked separately: a leak Tritiated water carries its own approval That ordering is why Phase 1 comes +reads as fast transport. burden. A question for Dr. Deng. first. + + + + +What this number is not +The film-scale effective diffusivity measured here is not the reactor-scale dispersion coefficient that currently carries the same name in radial_parms (1×10⁻³ cm² s⁻¹, about 43× water’s +It is the quantity slab_parms carries as D_eff (1×10⁻⁵ cm² s⁻¹) — the one default in that file below water self-diffusivity. + \ No newline at end of file diff --git a/calibration/tests/fixtures/wan_meeting_handout_prefix.tex b/calibration/tests/fixtures/wan_meeting_handout_prefix.tex new file mode 100644 index 0000000..b6e9965 --- /dev/null +++ b/calibration/tests/fixtures/wan_meeting_handout_prefix.tex @@ -0,0 +1,215 @@ +\documentclass[landscape,12pt]{article} +\usepackage[letterpaper,margin=0.6in]{geometry} +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage{microtype} +\usepackage{amsmath,amssymb} +\usepackage{booktabs} +\usepackage{array} +\usepackage{xcolor} +\usepackage{tikz} +\usetikzlibrary{arrows.meta,positioning,fit,backgrounds} +\usepackage{titlesec} +\usepackage{enumitem} +\setlist[itemize]{leftmargin=1.3em,itemsep=2pt,topsep=3pt} +\pagestyle{empty} +\emergencystretch=3em + +\definecolor{bench}{HTML}{1B5E20} +\definecolor{solver}{HTML}{0D47A1} +\definecolor{phys}{HTML}{B71C1C} +\definecolor{prior}{HTML}{8D6E00} +\definecolor{soft}{HTML}{F5F5F5} + +\newcommand{\pagehead}[2]{% + {\Large\bfseries #1}\\[1pt] + {\small\itshape #2}\\[-4pt] + \rule{\linewidth}{0.6pt}\\[4pt]} + +\begin{document} + +% ================================================== PAGE 1 +\pagehead{Where your bench data enters the model}% +{Hunter Kinder --- proposed Phase 1 collaboration. One page of architecture, one of protocol, one of data handling.} + +\vspace{2pt} +\begin{center} +\scalebox{1.18}{% +\begin{tikzpicture}[ + font=\small, scale=1.0, + box/.style={draw,rounded corners=2pt,align=center,inner sep=5pt, + minimum height=9mm,text width=52mm}, + lab/.style={box,draw=bench,fill=bench!6}, + sol/.style={box,draw=solver,fill=solver!6}, + rad/.style={box,draw=phys,fill=phys!6,text width=68mm}, + pri/.style={box,draw=prior,dashed,fill=prior!8}, + ar/.style={-{Stealth[length=2.5mm]},thick}, +] + +% ---- left: the bench +\node[lab] (vial) at (0,0) {\textbf{Batch vials}\\suspended, inactivated biomass}; +\node[lab,below=7mm of vial] (icp) {\textbf{ICP} on supernatant\\$C_0$,\ $C_e$,\ $V$,\ dry mass $m$}; +\node[lab,below=7mm of icp] (iso) {\textbf{Langmuir fit}\\[2pt]$q_e = \dfrac{q_{\max}\,b\,C_e}{1+b\,C_e}$}; +\node[below=5mm of iso,text=bench,font=\bfseries\footnotesize,text width=54mm,align=center] + (out1) {yields $q_{\max}$ and $b$ --- and only those}; + +% ---- right: the solver +\node[sol] (grid) at (12.2,0) {\textbf{Finite-volume grid}\\mobile $c(\mathbf{r},t)$, sorbed $s(\mathbf{r},t)$}; +\node[sol,below=7mm of grid] (sink) {\textbf{Sorption term}\\[2pt]$k_{\mathrm{ads}}c\,(X_{\max}\!-\!X) - k_{\mathrm{des}}X$}; +\node[pri,below=7mm of sink] (kads) {$k_{\mathrm{ads}}$ is \textbf{not} supplied by an + isotherm --- timed run, or a declared prior}; + +% ---- the transfer, routed above the gap +\draw[ar] (iso.east) .. controls +(2.6,0.2) and +(-2.6,0.2) .. (sink.west); +\node[font=\scriptsize,align=center,text width=44mm,fill=white,inner sep=2pt] at (6.1,-3.15) + {$q_{\max}\!\cdot\!\rho_{\mathrm{dry}}\ \Rightarrow\ X_{\max}$\\[1pt] + $b = k_{\mathrm{ads}}/k_{\mathrm{des}}$ (a \emph{ratio})}; +\draw[ar,solver] (grid) -- (sink); + +% ---- radiation layer, well clear of both groups +\node[rad] (mc) at (6.1,-7.6) {\textbf{OpenMC} --- photon transport, run \emph{once}\\ + static dose-rate field $\dot{D}(\mathbf{r})$}; +\draw[ar,phys] (mc.east) .. controls +(4.6,0) and +(0,-2.4) .. (kads.south); +\node[font=\scriptsize,align=left,text=phys,text width=38mm,fill=white,inner sep=2pt] at (13.9,-6.0) + {perturbs $D_{\mathrm{eff}}$ and $k_{\mathrm{ads}}$\\\emph{after} the bench values are set}; + +\begin{scope}[on background layer] + \node[fit=(vial)(iso)(out1),draw=bench,thick,rounded corners, + inner sep=5mm,label={[text=bench,font=\bfseries]above:YOUR LAB}] {}; + \node[fit=(grid)(kads),draw=solver,thick,rounded corners, + inner sep=5mm,label={[text=solver,font=\bfseries]above:THE SOLVER}] {}; +\end{scope} +\end{tikzpicture}} +\end{center} + +\vspace{-1mm} +{\small +\textbf{The point of the split.} The sorption capacity is a property of the biomass and is +measured with no radiation present. The dose field is computed once and never reads the +concentration back. So the bench numbers are fixed before the physics runs, and the physics +can only scale them --- \emph{by construction, because the codes are run in that order}. That +is a design choice I am declaring, not a law: at high metal loading the assumption weakens, +and the manuscript says where. + +\textbf{What I am asking you to help me measure is a sponge.} Not radiotrophy, not a survival +effect --- an equilibrium capacity.} + +\newpage +% ================================================== PAGE 2 +\pagehead{Phase 1: the bench assay}% +{A standard batch biosorption isotherm. Finite, bounded, and using material you already have.} + +\vspace{1mm} +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{bench} Material} +\begin{itemize} + \item Whatever biomass your lab already grows in excess --- I am not asking you to + establish a new culture. + \item \textbf{Inactivated.} Biosorption is metabolically independent, so dead or + heat/azide-killed biomass is standard in this literature. That removes viability + maintenance from the first experiment. + \item Suspended, not a coupon. A coupon measures capacity and diffusion at the same time + and resolves neither; the suspended assay isolates the thermodynamic limit. +\end{itemize} + +\vspace{2mm} +{\large\bfseries\color{bench} Controls} +\begin{itemize} + \item \textbf{Abiotic blank} --- vessel plus solution, no biomass. Wall loss and + adsorption to glassware are otherwise counted as uptake. + \item \textbf{Metabolic control} --- azide or a temperature shift, to demonstrate uptake + is passive and not respiration-linked. + \item \textbf{Blank carried through the identical handling}, including the same + de-watering and the same delay before weighing. +\end{itemize} +\end{minipage}\hfill +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{bench} Quality checks} +\begin{itemize} + \item \textbf{Aggregation state} --- flow cytometry to confirm the suspension is + monodisperse. Clumping reduces accessible surface and shows up as a lower + $q_{\max}$ that is an artefact of handling. + \item \textbf{The normaliser's own variance, measured first.} Wet/dry ratio across + replicates of one biomass, reported as a CV. If that is 20\%, dry mass is not a + usable normaliser and knowing so early is worth more than a careful number nobody + can reproduce. + \item \textbf{Detectability before replication.} A biomass mass under the blank's noise is + a substrate problem, and no replicate count fixes it --- so that test comes first, + then the CV, then the replicate number. +\end{itemize} + +\vspace{2mm} +{\large\bfseries\color{bench} Deliverable} +\begin{itemize} + \item A paired $[C_e,\ q_e]$ array from your ICP, with + $q_e = (C_0 - C_e)V/m$, plus the blanks and the CV. +\end{itemize} +\end{minipage} + +\vspace{3mm} +\noindent\rule{\linewidth}{0.4pt}\\[2pt] +{\small\textbf{Fixed procedure, because these dominate reproducibility more than anything +else on this page:} one separation method at a fixed vacuum or RCF; a fixed duration; a fixed +elapsed time from separation to balance, since evaporation begins immediately and two minutes +is a real mass difference; a stated drying temperature with an explicit constant-weight +criterion rather than ``dried to constant weight''. \emph{Drying temperature is the one I would +rather take from you than propose} --- 105\,\textdegree C is the total-solids convention but +can char organics, and gentler drying or lyophilisation is common for biomass. That is a +question with a house answer.} + +\newpage +% ================================================== PAGE 3 +\pagehead{From your number to my parameter}% +{What the measurement becomes, and what it is still not.} + +\vspace{1mm} +\begin{minipage}[t]{0.45\linewidth} +{\large\bfseries\color{solver} The bridge}\\[3pt] +The isotherm gives capacity per unit \emph{mass}: +\[ q_e = \frac{q_{\max}\,b\,C_e}{1+b\,C_e}\quad\text{[mg\,g$^{-1}$]} \] +The solver tracks mass per unit \emph{volume}, so: +\[ \boxed{\,X_{\max} = q_{\max}\cdot\rho_{\mathrm{dry}}\,} \] + +\vspace{2mm} +{\large\bfseries\color{prior} Two things this does not give me}\\[3pt] +\textbf{1. A rate.} $b$ is thermodynamic --- at equilibrium it is the \emph{ratio} +$k_{\mathrm{ads}}/k_{\mathrm{des}}$, and infinitely many rate pairs share one ratio. If the +solver needs $k_{\mathrm{ads}}$, that requires timed sampling of the same vial; otherwise it +is carried as an unmeasured prior and labelled one. + +\vspace{2mm} +\textbf{2. A biofilm number.} Suspended and biofilm biomass differ in EPS composition, surface +charge and accessible sites. This is a \emph{bound and a starting value}, not a transferable +parameter --- and the gap between them is itself measurable in Phase 2. +\end{minipage}\hfill +\begin{minipage}[t]{0.51\linewidth} +{\large\bfseries\color{solver} Epistemic class travels with the number}\\[3pt] +{\footnotesize +\begin{tabular}{@{}>{\raggedright\arraybackslash}p{2.5cm}>{\raggedright\arraybackslash}p{3.4cm}>{\raggedright\arraybackslash}p{4.6cm}@{}} +\toprule +\textbf{Quantity} & \textbf{Class} & \textbf{Why} \\ +\midrule +$q_{\max}$, $b$ & \textcolor{bench}{\textbf{Measured}} (suspended basis) & Phase 1 output, on your ICP \\[2pt] +$\rho_{\mathrm{dry}}$ & \textcolor{prior}{\textbf{Prior}} & Needs paired hydrated volume; Phase 2 \\[2pt] +$X_{\max}$ & \textcolor{prior}{\textbf{Prior}} & \emph{Product inherits the weaker class} \\[2pt] +$k_{\mathrm{ads}}$ & \textcolor{prior}{\textbf{Prior}} & Not obtainable from an isotherm \\[2pt] +$D_{\mathrm{eff}}$ & \textcolor{solver}{\textbf{Phase 2}} & Diffusion cell, biofilm on membrane \\[2pt] +$\lambda$ (radiation) & \textcolor{phys}{\textbf{Uncalibrated}} & The only free parameter left \\ +\bottomrule +\end{tabular}} + +\vspace{3mm} +{\small A derived quantity cannot be stronger than its weakest input. $X_{\max}$ is built from +a measurement and a prior, so it enters the solver \emph{as a prior} and is reported that way. +Recording that when the number is produced --- rather than when someone asks why the biofilm +does not match --- is the whole discipline.} + +\vspace{3mm} +\rule{\linewidth}{0.4pt}\\[3pt] +{\small\textbf{Phase 2, for context only:} biofilm on membrane in a two-chamber diffusion +cell. It is interpretable \emph{because} Phase 1 fixed capacity independently --- the +difference is then attributable to transport rather than confounded with it. That is the +reason for doing these in this order, and the reason Phase 1 is small.} +\end{minipage} + +\end{document} diff --git a/calibration/tests/test_claims_ledger.py b/calibration/tests/test_claims_ledger.py index 8295819..316d840 100644 --- a/calibration/tests/test_claims_ledger.py +++ b/calibration/tests/test_claims_ledger.py @@ -80,6 +80,24 @@ def normalise_markup(source: str) -> str: return " ".join(s.split()).lower() +def normalise_plaintext(source: str) -> str: + """Extracted figure text, whitespace-collapsed and lowercased. + + NOT `normalise_markup`. That function strips `%` to end of line as a LaTeX + comment, and figure text carries real percent signs — `c_mean = 1.2% c_ext` + — so running it here would silently delete the rest of any line holding one. + A normaliser aimed at the wrong language is a guard reading a document it + cannot see. + """ + return " ".join(source.split()).lower() + + +def _normalised(path: Path) -> str: + text = path.read_text(encoding="utf-8", errors="replace") + return (normalise_plaintext if path.suffix == ".txt" + else normalise_markup)(text) + + def _preprint_text() -> str: return normalise_markup(PREPRINT.read_text(encoding="utf-8")) @@ -101,36 +119,74 @@ def _preprint_text() -> str: # print the same nothing. PSEUDO_DOCUMENTS = {"repository", "correspondence"} +# NOT SOURCE, AND NOT IN THE REPOSITORY. `artifacts/` is gitignored +# (`.gitignore:76`) and nothing under it is tracked, so a row naming a pilot +# artifact resolves to a file only on a machine where that pilot has been run. +# Both document-resolution tests below were therefore passing on local state and +# would have failed on CI or any clean checkout — a check whose outcome depends +# on untracked files is rule 1 wearing rule 2's clothes. They are DECLARED here +# rather than removed from the ledger: the rows are real verdicts on real +# numbers, they are simply unenforceable from a clean tree, and saying that is +# the point. When the artifact happens to be present it is still read. +GENERATED_ARTIFACTS = { + "artifacts/pilot/openmc_nested_pilot_budget.json", + "artifacts/pilot/openmc_nested_pilot_verdict.json", +} + + +FIXTURES = Path(__file__).resolve().parent / "fixtures" + +# The figures are the only PUBLISHED artifacts that are not source. A committed +# PDF carries claim text that no suite could read: `tests/runtests.jl` splits the +# monolith at `# 13. Figure export` and loads only what is above the cut, so +# `export_figures` is unreachable from Julia by construction. The generator was +# corrected on 2026-08-14 and the ledger recorded the verdict three times +# (RM-G08-01, RM-G10-01, PP-65-08, the last saying in as many words that "the +# committed PNGs still carry the old reversed zone labels"); the artifact went on +# saying the retracted thing for two weeks because nothing could fail. +FIGURES = REPO / "preprint" / "figures" + def document_path(document: str) -> Path | None: if document in PSEUDO_DOCUMENTS: return None - return DOCUMENT_ALIASES.get(document, REPO / document) + path = DOCUMENT_ALIASES.get(document, REPO / document) + # A row may name the artifact a reader actually opens. PDF text is + # compressed, so searching the bytes finds nothing; the committed `.txt` + # sidecar is what gets read, and the hash test below is what stops a stale + # sidecar from standing in for a fresh PDF. + if path.suffix == ".pdf": + return path.with_suffix(".txt") + return path def deleted_rows(rows): return [r for r in rows if r["status"] == "delete"] -# The manuscript as first committed, before any revision removed anything. It is -# the only document known to CONTAIN the deleted claims, which makes it the only -# valid control for whether this guard detects them. -ORIGINAL_COMMIT = "5980dc5" -ORIGINAL_PATH = "preprint/modeling_radiotrophic_fitness.md" +# THE CONTROLS ARE COMMITTED FILES, NOT GIT OBJECTS. Each of these was once +# recovered with `git show :` — 5980dc5, 9319d43, and a third that +# would have been e24dbec — and each time the same exposure was written down and +# left in place: a squash-merge makes the pinned commit unreachable, `git show` +# fails, and the control degrades into a skip. Rule 2 says to read a skip as +# uncovered surface, and a control that can quietly stop controlling is rule 1 +# again one level up. A committed file cannot become unreachable. Provenance is +# in `fixtures/README.md`. +ORIGINAL_FIXTURE = "modeling_radiotrophic_fitness_prerevision.md" # was 5980dc5 -def _original_manuscript() -> str | None: - import subprocess +def _fixture(name: str) -> str: + """A known-bad document, read from disk. Never skips: if a control file is + missing that is a failure, not an environment quirk.""" + path = FIXTURES / name + assert path.is_file(), ( + f"negative-control fixture {name} is missing. The guard it feeds cannot " + "fail without it, which makes it not a guard. See fixtures/README.md.") + return normalise_markup(path.read_text(encoding="utf-8", errors="replace")) - shallow = subprocess.run( - ["git", "-C", str(REPO), "rev-parse", "--is-shallow-repository"], - capture_output=True, text=True) - if shallow.stdout.strip() == "true": - return None - got = subprocess.run( - ["git", "-C", str(REPO), "show", f"{ORIGINAL_COMMIT}:{ORIGINAL_PATH}"], - capture_output=True, text=True) - return normalise_markup(got.stdout) if got.returncode == 0 else None + +def _original_manuscript() -> str: + return _fixture(ORIGINAL_FIXTURE) def _detected(rows, text, documents=("preprint", "preprint_tex")) -> list[str]: @@ -192,8 +248,7 @@ def test_no_deleted_claim_survives_in_the_document_it_names(rows): if path is None or not path.is_file(): continue # named by the test below if path not in cache: - cache[path] = normalise_markup(path.read_text(encoding="utf-8", - errors="replace")) + cache[path] = _normalised(path) phrase = distinguishing_phrase(row["claim_text"]) if phrase and phrase.lower() in cache[path]: survivors.append(f"{row['claim_id']} in {row['document']}: {phrase[:90]}") @@ -220,10 +275,7 @@ def test_the_guard_actually_detects_deleted_claims(rows): matching achieves — so dropping the markup normalisation fails here rather than silently halving the guard. """ - original = _original_manuscript() - if original is None: - pytest.skip("shallow clone: the original manuscript is not reachable") - found = _detected(rows, original) + found = _detected(rows, _original_manuscript()) assert len(found) >= 18, ( f"the guard detected only {len(found)} deleted claims in the " "pre-revision manuscript, which contains them. It has stopped " @@ -231,9 +283,9 @@ def test_the_guard_actually_detects_deleted_claims(rows): # The handout as first committed, before the azide line and the sorption box -# were corrected. Same role as ORIGINAL_COMMIT, for the other document the +# were corrected. Same role as ORIGINAL_FIXTURE, for the other document the # ledger carries `delete` verdicts on. -HANDOUT_COMMIT = "9319d43" +HANDOUT_FIXTURE = "wan_meeting_handout_prefix.tex" # was 9319d43 HANDOUT_PATH = "preprint/wan_meeting_handout.tex" @@ -249,20 +301,7 @@ def test_the_guard_detects_the_handout_claims(rows): (`X_{\max}` becomes a bare `x`). A prose-phrase guard cannot check a formula, so that row stays with a human — see the coverage report. """ - import subprocess - - shallow = subprocess.run( - ["git", "-C", str(REPO), "rev-parse", "--is-shallow-repository"], - capture_output=True, text=True) - if shallow.stdout.strip() == "true": - pytest.skip("shallow clone: the pre-correction handout is not reachable") - got = subprocess.run( - ["git", "-C", str(REPO), "show", f"{HANDOUT_COMMIT}:{HANDOUT_PATH}"], - capture_output=True, text=True) - if got.returncode != 0: - pytest.skip(f"{HANDOUT_COMMIT}:{HANDOUT_PATH} is not reachable") - - found = _detected(rows, normalise_markup(got.stdout), documents=(HANDOUT_PATH,)) + found = _detected(rows, _fixture(HANDOUT_FIXTURE), documents=(HANDOUT_PATH,)) assert "HANDOUT-02" in found, ( "the guard found no deleted handout claim in the pre-correction " f"handout, which contains them (found: {found}). It is not guarding " @@ -280,11 +319,11 @@ def test_every_deleted_claim_names_a_document_the_guard_can_read(rows): unreadable = sorted({r["document"] for r in deleted_rows(rows) if (lambda q: q is None or not q.is_file())( document_path(r["document"]))} - - PSEUDO_DOCUMENTS) + - PSEUDO_DOCUMENTS - GENERATED_ARTIFACTS) assert not unreadable, ( "`delete` rows name documents that cannot be read, so those verdicts " f"are unguarded: {unreadable}. Add a path alias, fix the row, or " - "declare it in PSEUDO_DOCUMENTS.") + "declare it in PSEUDO_DOCUMENTS / GENERATED_ARTIFACTS.") def test_coverage_is_reported_as_detection_not_as_phrase_count(rows, capsys): @@ -306,16 +345,49 @@ def test_coverage_is_reported_as_detection_not_as_phrase_count(rows, capsys): with capsys.disabled(): print(f"\n claims-ledger guard: {len(deleted)} `delete` claims; " f"{len(yields_phrase)} yield a searchable phrase") - if original is None: - print(" detection rate: NOT MEASURED (shallow clone)") - else: - found = set(_detected(rows, original)) - print(f" DETECTED in the pre-revision manuscript: {len(found)} of " - f"{len(deleted)} — this is the real coverage") - missed = [r["claim_id"] for r in deleted if r["claim_id"] not in found] - print(" NOT detectable — these still need human review:") - for cid in missed: - print(f" {cid}") + found = set(_detected(rows, original)) + print(f" DETECTED in the pre-revision manuscript: {len(found)} of " + f"{len(deleted)} — this is the real coverage") + missed = [r["claim_id"] for r in deleted if r["claim_id"] not in found] + print(" NOT detectable — these still need human review:") + for cid in missed: + print(f" {cid}") + + # THE ROWS ENFORCED NOWHERE, PRINTED EVERY RUN. `document = repository` + # or `correspondence` is a PSEUDO_DOCUMENT: document_path returns None, + # so the row is never searched by anything. That is not a scope gap, it + # is an ADDRESSABILITY gap -- the verdict has no file to be enforced in. + # + # This is printed rather than left in a plan for the reason the absence + # gate prints its five known gaps: a number that lives in a document + # nobody rereads is a number nobody acts on. PP-DEFF-01 sat in this class + # with a prescribed comment fix unapplied until 2026-08-31, and it was + # found only because a guard was being built for a different cause. + # GENERATED_ARTIFACTS ROWS ARE ENFORCED NOWHERE TOO, AND THIS REPORT + # MISSED THEM. PILOT-LEV-01 is a `delete` verdict whose document is a + # gitignored pilot output: document_path resolves it, the file does not + # exist on any clean checkout, the main guard silently continues past it, + # and it appeared in NO section of this printout. The report whose stated + # job is naming every row nothing enforces was itself incomplete, which is + # the defect it exists to prevent. + pseudo = [r for r in rows + if r["document"] in PSEUDO_DOCUMENTS + or r["document"] in GENERATED_ARTIFACTS] + unresolved = [r for r in pseudo + if r["status"] in ("delete", "restate", "requalify", + "needs_verification", "needs_calibration")] + names_source = [r for r in unresolved + if re.search(r"\.(jl|R|py)\b", r["code_location"] or "")] + print(f" ENFORCED NOWHERE: {len(pseudo)} rows are unreachable by any " + f"guard -- {', '.join(sorted(PSEUDO_DOCUMENTS))} name no file, and " + f"{len(GENERATED_ARTIFACTS)} gitignored artifact path(s) exist on no " + "clean checkout") + print(f" of those, {len(unresolved)} carry an unresolved verdict " + f"and {len(names_source)} of those name a source file:") + for r in names_source: + print(f" {r['claim_id']} [{r['status']}]") + print(" the rest are unread by construction and want a human pass, " + "not a guard that happens to sweep them") # THE OTHER DOCUMENTS ARE READ NOW, so their coverage is reported too — # a row the widened filter reaches but the prose guard cannot match is @@ -327,6 +399,14 @@ def test_coverage_is_reported_as_detection_not_as_phrase_count(rows, capsys): print(" NOT textually detectable there — equations and code, not prose:") print(" HANDOUT-01 — the claim is a formula; normalise_markup " "reduces LaTeX maths to nothing searchable") + print(" FIG-01, FIG-02, FIG-05 — in-plot labels; no run of " + "MIN_WORDS survives the comma and paren split. Covered by " + "RETRACTED_IN_FIGURES, not by the phrase guard") + print(" FIG-09, FIG-10 — full sentences that DO yield a phrase, and " + "the phrase is still unreachable: pdftotext -layout reads the " + "three columns across, so the run is contiguous nowhere. Also " + "covered by RETRACTED_IN_FIGURES. Listing them beside the three " + "above is the point — 'yields a phrase' is not 'is covered'") assert len(yields_phrase) >= 25 @@ -345,8 +425,18 @@ def test_every_claim_names_a_document_that_exists(rows): # and only a resolvable-but-missing path is a fault here. unresolved = sorted({r["document"] for r in rows if (lambda q: q is not None and not q.is_file())( - document_path(r["document"]))}) + document_path(r["document"]))} + - GENERATED_ARTIFACTS) assert not unresolved, f"rows name documents that do not exist: {unresolved}" + + # AND THE DECLARATION MUST STAY TRUE. Listing a document here excuses it + # from the check above, so an entry that is no longer a gitignored artifact + # — committed since, or renamed — would silently excuse nothing while + # looking like it still did. + misdeclared = sorted(d for d in GENERATED_ARTIFACTS + if d not in {r["document"] for r in rows}) + assert not misdeclared, ( + f"GENERATED_ARTIFACTS names documents no ledger row uses: {misdeclared}") assert PREPRINT.exists(), ( f"the ledger's preprint rows describe {PREPRINT.name}, which is not in " "the repository") @@ -830,3 +920,679 @@ def test_the_blanket_reassurance_pattern_actually_matches(rows): ] for phrase in scoped: assert not blanket_reassurance_hits(phrase), f"false positive: {phrase}" + + +# --------------------------------------------------------------------------- +# THE FIGURES. A published artifact nothing could read. +# --------------------------------------------------------------------------- + +# §2.6 of the manuscript: "Radiotrophy is not established for any of the seven +# species modelled." The word is retracted prose, so it must not survive inside +# an image either. This floor is coarse on purpose — it covers `fig1`, whose +# in-plot labels were "radiotrophic niche" and "radiosensitive core", two words +# each and so under MIN_WORDS: `distinguishing_phrase` cannot search them and +# the phrase guard never will. Coverage of that figure is this list, not the +# ledger row. +# +# "gy cumulative" is here for FIG-05, and it is a UNITS claim rather than a +# phenotype one — the list is "terms that must not survive inside an image", and +# RM-KR-01's verdict is NEVER PRINT Gy HERE, because D_cum = Ddot_R*t is +# dimensionless model time and no calibration converts it. fig3 carried +# "(50 Gy cumulative)" for fifteen days after d53f236 removed it from the +# generator, because main_coupled() hits RADIODIALYSIS: BLOCKED and nothing +# could rebuild the artifact. Three words, so the phrase guard could never have +# reached it either: without this term FIG-05 would have been covered by +# nothing, while sitting in a list named for rows the vocabulary covers. +# +# "charge-excluded" and "same trap" are FIG-09 and FIG-10, and they are here for +# a reason the first three do not share: THEIR CLAIM TEXT DOES YIELD A SEARCHABLE +# PHRASE AND THE PHRASE IS STILL NOT FINDABLE IN THE ARTIFACT. `pdftotext +# -layout` reads a three-column figure across, so "Bromide is size- and +# charge-excluded from fine porosity." comes back as "...charge-excluded +# breakthrough lag gives a lumped run alongside every measurement. from fine +# porosity." — the phrase interleaved with two other columns and contiguous +# nowhere. Verified against the real pre-correction sidecar, where `_detected` +# returns [] for both rows. +# +# THAT DISTINCTION IS THE WHOLE DEFECT THIS LIST NOW CLOSES. "Yields a phrase" +# was read as "is covered", and it is not the same predicate: coverage is a +# property of the ARTIFACT, not of the claim text. A hand-run control that +# appends the sentences as clean contiguous lines confirms a guard against an +# input shape a column layout cannot produce — a control passing on a mechanism +# that never ran, which is what the fig1/fig2 control below was already written +# to avoid. Between c2219a2 and this commit, FIG-09 and FIG-10 were covered by +# nothing at all. +RETRACTED_IN_FIGURES = ("radiotroph", "gy cumulative", + "charge-excluded", "same trap") + + +def figure_sidecars() -> list[Path]: + return sorted(FIGURES.glob("*.txt")) + + +def retracted_vocabulary_hits(paths) -> list[tuple]: + """(figure, term) for every retracted term found. Takes PATHS so the + control can drive it with the pre-correction artifacts.""" + return [(p.name, term) for p in paths + for term in RETRACTED_IN_FIGURES + if term in normalise_plaintext(p.read_text(encoding="utf-8", + errors="replace"))] + + +def test_no_committed_figure_asserts_a_retracted_phenotype(): + """WHAT THE PROSE AUDIT DID NOT REACH. + + `fig2_melanin_accumulation.pdf` said, inside the image, "C. neoformans, + C. sphaerospermum are radiotrophic (melanin-mediated energy gain)" — under a + caption disowning "radiation-derived energy production" and against §2.6. + `fig1` said "radiotrophic niche". + + Neither was unnoticed. The generator was corrected on 2026-08-14 and the + ledger recorded the verdict three times. The artifact went on saying it + because no test could open a figure: the claims guard read one `.tex`, and + `tests/runtests.jl` splits the monolith above `# 13. Figure export`, so the + Julia suite cannot reach `export_figures` at all. Fixing a generator does + not fix what was already committed, and nothing said so. + """ + hits = retracted_vocabulary_hits(figure_sidecars()) + assert not hits, ( + "committed figures assert a phenotype §2.6 retracts; the generator was " + f"fixed but the artifact was not regenerated: {hits}") + + +def test_the_figure_guard_detects_the_pre_correction_artifacts(): + """THE CONTROL: run the floor against the artifacts known to carry the claim. + + NEITHER FIGURE IS REACHABLE BY THE PHRASE GUARD, and that is why this floor + exists rather than duplicating it. `distinguishing_phrase` splits on commas + and parentheses and needs MIN_WORDS=5 from one run. Fig 2's annotation — + "C. neoformans, C. sphaerospermum are radiotrophic (melanin-mediated energy + gain)" — yields runs of 2, 4 and 3 words, and fig 1's labels are two words + each. So `_detected` returns nothing for FIG-01 and FIG-02 no matter what, + exactly as it does for HANDOUT-01's equation, and the vocabulary floor is + the whole of their coverage. Asserting a phrase hit here would have made the + control pass on a mechanism that never ran. + + EXTENDED TO THE FOURTH ARTIFACT, and the glob had to widen to reach it. + `phase2_diffusion_cell_prefix.txt` does not match `fig*`, so while this + control globbed `fig*_prefix.txt` a committed known-bad sat in the fixtures + directory covered by nothing — the count assertion pinned three and passed, + which is the shape of every other defect on this page: a bound checked + against the set it was derived from. FIG-09 and FIG-10 differ from the first + three in that their claim text DOES yield a phrase; it is simply unfindable + in a column layout. See RETRACTED_IN_FIGURES. + """ + prefix = sorted(FIXTURES.glob("*_prefix.txt")) + assert len(prefix) == 4, f"expected four pre-correction figures, got {prefix}" + + hits = retracted_vocabulary_hits(prefix) + assert {p for p, _ in hits} == {f.name for f in prefix}, ( + "the vocabulary floor missed a figure that contains the retracted " + f"phenotype: found {hits}") + + # and it is not matching everything it is handed + assert retracted_vocabulary_hits(figure_sidecars()) == [] + + +def test_no_figure_row_is_reachable_by_the_phrase_guard(rows): + """COVERAGE IS A PROPERTY OF THE ARTIFACT, NOT OF THE CLAIM TEXT. + + The first version of this test split the figure rows into a vocabulary tier + and a phrase tier by asking whether `distinguishing_phrase` returned + anything, and put FIG-09 and FIG-10 in the phrase tier because their claim + text yields a full sentence. THAT PREDICATE IS WRONG AND THE ROWS WERE + COVERED BY NOTHING. `pdftotext -layout` reads a three-column figure across, + so the sentence comes back interleaved with the other two columns and is + contiguous nowhere; `_detected` returns [] for both against the real + pre-correction sidecar. A phrase that exists in the ledger and cannot occur + in the artifact is not a guard, and it looked exactly like one. + + The hand-run that "confirmed" the phrase guard bites appended both sentences + as clean lines, which is a shape a column layout cannot produce -- a control + passing on a mechanism that never ran, which is the same defect the fig1/fig2 + control below was written to avoid. Hence this test asks the artifacts. + + SCOPE: the `delete` rows whose `document` is under `preprint/figures/`, + evaluated against the four committed `*_prefix.txt` sidecars and nothing + else. It says nothing about rows in any other document. + """ + known_bad = sorted(FIXTURES.glob("*_prefix.txt")) + assert len(known_bad) == 4, ( + f"expected four pre-correction sidecars, got {[p.name for p in known_bad]}") + joined = "\n".join( + normalise_plaintext(p.read_text(encoding="utf-8", errors="replace")) + for p in known_bad) + + figure_rows = [r for r in deleted_rows(rows) + if r["document"].startswith("preprint/figures/")] + assert {r["claim_id"] for r in figure_rows} == { + "FIG-01", "FIG-02", "FIG-05", "FIG-09", "FIG-10"}, ( + f"the figure row set moved: {sorted(r['claim_id'] for r in figure_rows)}. " + "A new figure `delete` row needs a known-bad sidecar before it can be " + "said to be covered by anything.") + + reachable = [] + for row in figure_rows: + phrase = distinguishing_phrase(row["claim_text"]) + if phrase and phrase.lower() in joined: + reachable.append(row["claim_id"]) + assert not reachable, ( + f"{reachable} are now findable as contiguous phrases in a known-bad " + "figure sidecar, so the phrase guard genuinely covers them and this " + "test's premise has changed. That is an improvement -- record which " + "rows moved and why the layout now preserves the run.") + + # THE OTHER HALF, OR "COVERED BY NOTHING" AND "COVERED BY THE FLOOR" ARE THE + # SAME RESULT HERE. Asserting only that no phrase reaches the artifacts is + # satisfied by a word list that fires on none of them. + covered = {name for name, _ in retracted_vocabulary_hits(known_bad)} + assert covered == {p.name for p in known_bad}, ( + "the vocabulary floor is the whole of these rows' coverage and it does " + f"not fire on every known-bad artifact: {sorted(covered)}") + + +def test_every_committed_figure_matches_its_hash(): + """THE HALF THAT MAKES THE SIDECAR WORTH ANYTHING. + + Everything above reads `
.txt`, not the PDF a reader opens. Without + this, regenerating a figure and forgetting its sidecar leaves the suite + green on stale-but-clean text while the PDF still carries the claim — the + artifact-versus-source split reproduced inside the guard written to close + it. So the PDF's own bytes are pinned, in pure stdlib, and a figure that + moves without its sidecar fails here. + """ + import hashlib + + stale = [] + for pdf in sorted(FIGURES.glob("*.pdf")): + recorded = pdf.with_suffix(".sha256") + assert recorded.is_file(), f"{pdf.name} has no committed hash" + actual = hashlib.sha256(pdf.read_bytes()).hexdigest() + if actual != recorded.read_text(encoding="utf-8").strip(): + stale.append(pdf.name) + assert pdf.with_suffix(".txt").is_file(), f"{pdf.name} has no text sidecar" + assert not stale, ( + "these figures changed without their sidecar and hash being " + f"regenerated, so every text check above read the OLD figure: {stale}. " + "Re-run the export, then `pdftotext -layout` and `sha256sum` for each.") + + +def test_the_sidecars_are_what_pdftotext_actually_produces(): + """TIER 2: the sidecar is the PDF's text, not merely a file beside it. + + The hash test proves the PDF has not moved since the sidecar was written; it + cannot prove the sidecar was ever a faithful extraction. This can, where + `pdftotext` exists. It is not a bare skip when absent — the hash test and + the vocabulary floor both still run — which is the difference rule 2 asks + for between a gap and a hidden one. + """ + import shutil + import subprocess + + if shutil.which("pdftotext") is None: + pytest.skip("pdftotext absent: hash + vocabulary tiers still cover this") + + drifted = [] + for pdf in sorted(FIGURES.glob("*.pdf")): + got = subprocess.run(["pdftotext", "-layout", str(pdf), "-"], + capture_output=True, text=True) + assert got.returncode == 0, f"pdftotext failed on {pdf.name}" + if normalise_plaintext(got.stdout) != normalise_plaintext( + pdf.with_suffix(".txt").read_text(encoding="utf-8")): + drifted.append(pdf.name) + assert not drifted, ( + f"committed sidecars are not this PDF's text: {drifted}") + + +# --------------------------------------------------------------------------- +# A COINED TERM MUST NOT APPEAR BEFORE THE PARAGRAPH THAT DISOWNS IT +# --------------------------------------------------------------------------- + +COINAGE = "radiodialysis" +DISCLAIMER = "coined for this three-equation system" + +# A filename and a cross-reference label are not the coined term being used as +# though established: `biofilms_radiodialysis.R` is what the file is called, and +# `\label{}` never reaches a reader at all. +_EXEMPT = re.compile(r"\\(?:texttt|label|ref|eqref)\{[^}]*\}") + + +def coinage_uses_before_disclaimer(source: str) -> list[tuple[int, str]]: + """(line number, line) for every reader-visible use of the coined term + above the paragraph that declares it coined. + + Takes SOURCE rather than reading the file, so the control can drive it with + a document known to violate the rule. + """ + lines = source.split("\n") + cut = next((i for i, l in enumerate(lines) if DISCLAIMER in l), None) + if cut is None: + return [(0, "the disclaimer paragraph is missing entirely")] + return [(i + 1, l) for i, l in enumerate(lines[:cut]) + if COINAGE in _EXEMPT.sub("", l).lower()] + + +def test_the_coinage_never_precedes_its_disclaimer(): + """THE FIGURE DEFECT'S SHAPE, IN PROSE. + + §3.11 disowns `radiodialysis` in a full paragraph — "a targeted search finds + no scientific usage of it outside this work". The term then appeared ten + times, and the abstract called it "a radial radiodialysis solver" with no + signal that the word was coined here. Caveat in the body, claim in the part + people scan: the same split that left a retracted phenotype inside a figure + while the prose around it was clean. + + The rule is positional and so is the check. Anywhere after the disclaimer + the term is introduced and qualified; anywhere before it, it reads as + established vocabulary. + """ + hits = coinage_uses_before_disclaimer(PREPRINT.read_text(encoding="utf-8")) + assert not hits, ( + f"'{COINAGE}' is used as established vocabulary before the paragraph " + f"that declares it coined: {hits}") + + +def test_the_coinage_check_detects_a_use_above_the_disclaimer(): + """THE CONTROL. Without it this test passes on any document that never says + the word, which is every document in the repository but one.""" + planted = (f"We solve the {COINAGE} system on a radial mesh.\n" + f"A term {DISCLAIMER} and is not established.\n" + f"The {COINAGE} solver is described in Section 6.\n") + hits = coinage_uses_before_disclaimer(planted) + assert [h[0] for h in hits] == [1], ( + "the check must flag the line above the disclaimer and only that one; " + f"got {hits}") + assert not coinage_uses_before_disclaimer( + f"A term {DISCLAIMER}.\nThe {COINAGE} solver runs.\n") + assert not coinage_uses_before_disclaimer( + f"See \\texttt{{biofilms_{COINAGE}.R}} and \\label{{sec:{COINAGE}}}.\n" + f"A term {DISCLAIMER}.\n"), "a filename and a label are not a use" + + +# --------------------------------------------------------------------------- +# SOME SENTENCES HAVE TO STAY +# --------------------------------------------------------------------------- + +# The guard's other document tests all assert ABSENCE — a `delete` verdict is +# enforceable precisely because absence is its criterion. That leaves the +# opposite failure uncovered: a sentence carried deliberately, whose removal +# nothing notices. These rows name sentences that are load-bearing because of +# what they PREVENT the document from implying, which is exactly the kind that +# reads like a hedge on an editing pass and gets trimmed. +LOAD_BEARING = ("HOFFMAN-10",) + + +def load_bearing_absences(rows, read=None) -> list[tuple[str, str]]: + """(claim_id, phrase) for each row whose sentence is no longer in its + document. `read` overrides document lookup so the control can supply text.""" + out = [] + for row in rows: + if row["claim_id"] not in LOAD_BEARING: + continue + phrase = distinguishing_phrase(row["claim_text"]) + assert phrase, ( + f"{row['claim_id']} is declared load-bearing but its claim_text has " + f"no run of {MIN_WORDS}+ words to search for. Rephrase the row — " + "the word floor is what stops a match being a coincidence.") + path = document_path(row["document"]) + assert path is not None and path.is_file(), ( + f"{row['claim_id']} names {row['document']}, which does not resolve") + text = read(path) if read else _normalised(path) + if phrase.lower() not in text: + out.append((row["claim_id"], phrase)) + return out + + +def test_a_load_bearing_sentence_survives_in_its_document(rows): + """The Hoffman memo's geometry caveat. + + The implemented domain is a cylinder in water with a Robin boundary — no + metal, no planar film, no interface. Without that sentence page 3 offers a + dose field beneath a film on steel as something the code does, which is a + geometry it does not have. It is also the sentence that reads like a hedge + and goes first when someone tightens the page. + """ + missing = load_bearing_absences(rows) + assert not missing, ( + "a sentence the ledger records as load-bearing is no longer in the " + f"document that needs it: {missing}") + + +def test_the_load_bearing_check_detects_a_deleted_sentence(rows): + """THE CONTROL: strike the phrase from the document and require a report.""" + def struck(path): + text = _normalised(path) + for row in rows: + if row["claim_id"] in LOAD_BEARING: + text = text.replace(distinguishing_phrase(row["claim_text"]).lower(), "") + return text + + assert [c for c, _ in load_bearing_absences(rows, read=struck)] == list(LOAD_BEARING), ( + "removing the phrase must make the check fail; if it does not, the " + "check is reading something other than the sentence it names") + + +# --------------------------------------------------------- retracted citations +# +# THE THIRD TIER, AND IT EXISTS BECAUSE A CITATION CANNOT REACH THE FIRST TWO. +# `distinguishing_phrase` splits on commas and needs MIN_WORDS from one unbroken +# run. A bibliographic entry -- "Diele, Marangi, Ragni (2015), Math. Comput. +# Simul. 110, 40-52, doi ..." -- is all commas, so it yields '' and PP-REF-01 and +# PP-REF-02 were `delete` verdicts covered by nothing. Both were prescribed in the +# ledger and sat unapplied in the manuscript until 2026-08-31. +# +# NOTE THIS IS A SECOND CAUSE, NOT THE SAME ONE AS THE SOURCE-COMMENT GAP. A +# verdict is also unenforced when the claim lives in a .jl comment rather than in +# the file the row's `document` column names -- that is RM-G04-01, and it needs a +# RETRACTED_IN_SOURCES scan over SIM_FILES. Widening which files are scanned would +# not reach the two reference rows, because their invisibility is a property of the +# phrase EXTRACTOR. One symptom, two causes; this closes exactly one of them. +# THIS GUARD REACHES PP-REF-01 AND PP-REF-02 AND NOTHING ELSE. +RETRACTED_CITATIONS = { + "10.1002/mma.3237": "PP-REF-01: resolves to an unrelated 3D MHD regularity paper", + "10.1016/j.matcom.2014.02.006": "PP-REF-02: resolves to an unrelated QP paper", +} + +# USE VERSUS MENTION, AND THE SET IS EXPLICIT NAMES RATHER THAN A GLOB. +# Recording that a DOI was withdrawn requires naming it, so the ledger and the +# red-team documents necessarily contain these strings. A whole-tree scan fails on +# its first run against the very files that record the withdrawal -- the shape of a +# test whose own control fixture contains the string it forbids. +# +# The resolution is an ALLOW-LIST, inverted: scan everything, and require every file +# carrying a withdrawn DOI to be declared here. A new document quoting one FAILS +# until it is consciously added, rather than passing because it fell outside a +# curated scan list. `docs/research/*_redteam.md` was rejected as a glob for exactly +# that reason: it would auto-admit every future red-team file, which is the property +# the allow-list was chosen to avoid. +# +# BOTH ENTRIES BELOW THE FIRST THREE WERE FOUND BY THIS GUARD ON ITS FIRST RUN, which +# is the allow-list earning its place: neither was anticipated, and a curated scan +# list would have silently omitted both instead of demanding a decision. +CITATION_RECORDING_FILES = { + "data/claims_ledger.csv", + "docs/research/session_claims_2026-08-24_redteam.md", + "docs/research/external_reviews_2026-08-31_redteam.md", + "calibration/tests/test_claims_ledger.py", + # The pre-revision manuscript, which is ORIGINAL_FIXTURE -- the committed + # known-bad this file already uses as its detection floor. It carries both + # withdrawn DOIs BECAUSE it is the artifact from before the correction, and + # fixtures/README.md says of these files: "Do not regenerate or 'fix' these + # files. Their value is that they are wrong." + "calibration/tests/fixtures/modeling_radiotrophic_fitness_prerevision.md", +} + + +def _normalised_for_retraction(text: str) -> str: + """Text folded so a withdrawn string cannot hide behind formatting. + + EXACT-SUBSTRING MATCHING WAS DEFEATED BY THREE THINGS THAT CHANGE NOTHING + ABOUT WHAT A READER SEES, all verified: an uppercase DOI (DOIs are + case-insensitive by the DOI standard, so it resolves to the same withdrawn + paper), a newline inside the DOI (which LaTeX renders identically and which + ordinary column-wrap produces by accident), and a percent-encoded slash. A + withdrawn claim reintroduced into a source comment arrives in a WRAPPED + comment, so the newline case is the likely one rather than the exotic one. + + Case-folding is safe here because the vocabulary is DOIs and claim phrases, + neither of which carries meaning in case. + """ + # LEADING comment markers are dropped PER LINE, not globally: a claim wrapped + # across two comment lines reassembles as `...range# 1e-4...` and the marker + # breaks the match. Stripping `#`/`%`/`//` anywhere would over-fold and invent + # matches, so only the marker that opens a continuation line is removed. + lines = [re.sub(r"^\s*(#+|%+|//)\s*", "", ln) for ln in text.split("\n")] + joined = "\n".join(lines) + return re.sub(r"\s+", "", joined).replace("%2F", "/").replace("%2f", "/").lower() + + +def _is_derived_from_a_scanned_source(path: Path) -> bool: + """True when `path`'s content is a build product of a file this scan reads. + + THE RULE IS "DERIVED FROM A SOURCE ALREADY SCANNED", NEVER "EXEMPT BECAUSE OF + WHERE IT LIVES", AND THE REASON LIVES HERE RATHER THAN AT ONE CALL SITE. That + split is what failed: the narrowing was made for the citation scanner and its + reasoning recorded on that function, and the next scanner written into this + same file reached for the rule instead and got the blanket + `"__pycache__" in path.parts` skip the narrowing existed to remove. A file + inside a directory named `__pycache__` with NO sibling source is scanned -- + it is a place to hide a string, not a build product. + + Any future walker over this tree wants this predicate, not a location test. + """ + if "__pycache__" not in path.parts: + return False + stem = path.name.split(".")[0] + return (path.parent.parent / f"{stem}.py").is_file() + + +def files_carrying_retracted_citations() -> dict: + """{repo-relative path: [dois]} for every file containing a withdrawn DOI. + + SCOPE: every readable file in the repository except `.git`. Shared by the check + and its control so the control cannot pass against a regressed check. + """ + out: dict[str, list[str]] = {} + for path in REPO.rglob("*"): + # THE EXCLUSION IS "DERIVED FROM A SOURCE ALREADY SCANNED", NOT "IS A BUILD + # ARTIFACT", AND THE TWO RULES HAVE DIFFERENT FUTURE COVERAGE. The first + # version of this skipped `path.suffix in {.pyc, .pdf, .png, .so, .o}`, + # which would silently admit any future artifact of those types carrying a + # forbidden string -- and `preprint/*.pdf` is the manuscript's own build + # output, so the rule excluded the one artifact a reader actually receives. + # Measured: with no type exclusion at all, 14 PDFs in the tree produce zero + # hits, because PDF text is compressed. The suffix rule bought nothing and + # cost coverage. + # + # A `.pyc` is excludable for a narrower and checkable reason: it is a + # compiled copy of a `.py` that is itself in scope, so its content is + # already being read at its source. That justification is ASSERTED below + # rather than assumed -- if the sibling source is missing, the file is + # scanned rather than skipped. + if not path.is_file() or ".git" in path.parts: + continue + if _is_derived_from_a_scanned_source(path): + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + flat = _normalised_for_retraction(text) + hits = [d for d in RETRACTED_CITATIONS + if _normalised_for_retraction(d) in flat] + if hits: + out[str(path.relative_to(REPO)).replace("\\", "/")] = sorted(hits) + return out + + +def test_no_withdrawn_citation_survives_outside_the_recording_layer(): + carrying = files_carrying_retracted_citations() + undeclared = {p: d for p, d in carrying.items() + if p not in CITATION_RECORDING_FILES} + assert not undeclared, ( + "these files carry a DOI the ledger withdrew, and are not declared as " + f"recording documents: {undeclared}. If this is the manuscript or the " + "README, the retraction was never applied. If it is a new document that " + "records the withdrawal, add it to CITATION_RECORDING_FILES deliberately.") + + +def test_the_replacements_the_ledger_prescribed_are_present(): + """The other half: absence of the wrong DOI is not presence of the right one. + + SCOPE: the manuscript only. A guard that checked only for the withdrawn string + would pass against a bibliography with the entry simply deleted. + """ + tex = PREPRINT.read_text(encoding="utf-8") + for doi, row in (("10.1007/s11538-015-0117-1", "PP-REF-01"), + ("10.3390/math8010025", "PP-REF-02")): + assert doi in tex, f"{row}'s prescribed replacement {doi} is not in the manuscript" + + +def test_the_retracted_citation_guard_detects_the_pre_fix_manuscript(): + """CONTROL: the manuscript as it stood before the fix must be caught. + + SCOPE: one file, recovered from git rather than written here. The known-bad is + drawn from the artifact path -- a hand-written string would test the scanner + against my idea of the citation, which is the control-that-never-met-the- + pipeline defect AGENTS.md rule 1 records from the figure-sidecar case. + """ + import subprocess + before = subprocess.run( + ["git", "show", + "a8ba2b0:preprint/modeling_radioresistance_and_radiotropic_fitness.tex"], + cwd=REPO, capture_output=True, text=True) + if before.returncode != 0: + pytest.skip("pre-fix manuscript not reachable from git") + found = [d for d in RETRACTED_CITATIONS if d in before.stdout] + assert sorted(found) == sorted(RETRACTED_CITATIONS), ( + "the pre-fix manuscript should contain both withdrawn DOIs; the scanner " + f"found {found}. It is matching nothing, not finding nothing.") + assert not [d for d in RETRACTED_CITATIONS + if d in PREPRINT.read_text(encoding="utf-8")], \ + "and the current manuscript must contain neither" + + +# ------------------------------------------------------ retracted in sources +# +# CAUSE 1 OF THE UNAPPLIED-VERDICT PATTERN, AND THE LAST OF THE THREE TIERS. +# A ledger verdict is enforced only in the file its `document` column names. Two +# ways that leaves a claim unguarded, and they are different: +# +# (a) the column names ANOTHER file. RM-G04-01 was restated in README while +# biofilms_potts.jl:16 went on listing a five-term Hamiltonian, because no +# guard reads source comments. +# (b) the column names NO file. `document = repository` is a PSEUDO_DOCUMENT, +# so document_path returns None and the row is never searched at all. +# Measured: 37 rows sit in that class, 5 of them carrying an unresolved +# verdict that names a source file. PP-DEFF-01 is one, and its prescribed +# comment fix sat unapplied at biofilms_radiodialysis.R:242 until +# 2026-08-31 -- the fifth instance of the pattern found this session. +# +# WHY A DECLARED VOCABULARY RATHER THAN A PHRASE SWEEP, MEASURED BEFORE CHOOSING. +# Running distinguishing_phrase over every `delete` row against all 144 source +# files returns ZERO hits. The phrase tier cannot reach these claims: the ones +# that live in source comments are short -- a formula, a range, a symbol -- and +# split into runs under MIN_WORDS, exactly as figure labels and citations do. +# So this is the third vocabulary tier, beside RETRACTED_IN_FIGURES and +# RETRACTED_CITATIONS, for the same reason each of those exists. +# +# SEEDED FROM ACTUAL RETRACTIONS, NOT FROM INVENTED VOCABULARY, AND THE SEED IS +# SMALL ON PURPOSE. Two entries is what the ledger actually supports today. A +# guard whose value is that the NEXT one is caught does not need a long list, and +# a long list of invented strings would be the can't-fail shape in a new costume. +# `restate` verdicts are NOT swept generally -- AGENTS.md is explicit that a +# restate asks whether the revision says the right thing, which is a judgement +# and stays with a reviewer. Only the specific withdrawn STRING is mechanical. +RETRACTED_IN_SOURCES = { + "Table 2 range 1e-4..1e-2": + "PP-DEFF-01: the range is refuted at its upper end and unresolved at its " + "lower, and Table 2 (tab:params) carries no D_eff row at all", + "H_radiation + H_pairwise": + "RM-G04-01: the Hamiltonian has four terms; total_pairwise_energy is real " + "but is called from take_snapshot alone and never enters acceptance", +} + +# The same use-versus-mention resolution as RETRACTED_CITATIONS, and it is needed +# for the same reason: recording a withdrawal requires naming it. EXPLICIT NAMES, +# NOT A GLOB -- a glob would auto-admit every future file matching a pattern, +# which is the property the allow-list exists to prevent. +SOURCE_RECORDING_FILES = { + "data/claims_ledger.csv", + "docs/research/external_reviews_2026-08-31_redteam.md", + "calibration/tests/test_claims_ledger.py", +} + +SOURCE_SUFFIXES = {".jl", ".R", ".py"} + + +def sources_carrying_retracted_strings() -> dict: + """{repo-relative path: [strings]} for every source carrying a withdrawn string. + + SCOPE: files with a SOURCE_SUFFIXES extension anywhere in the repository, + excluding `.git` and any file derived from a source already scanned. Shared by + the check and its control. + """ + out: dict[str, list[str]] = {} + for path in REPO.rglob("*"): + if not path.is_file() or ".git" in path.parts: + continue + if _is_derived_from_a_scanned_source(path) or path.suffix not in SOURCE_SUFFIXES: + continue + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + flat = _normalised_for_retraction(text) + hits = [s for s in RETRACTED_IN_SOURCES + if _normalised_for_retraction(s) in flat] + if hits: + out[str(path.relative_to(REPO)).replace("\\", "/")] = sorted(hits) + return out + + +def test_no_retracted_string_survives_in_a_source_file(): + carrying = sources_carrying_retracted_strings() + undeclared = {p: s for p, s in carrying.items() + if p not in SOURCE_RECORDING_FILES} + assert not undeclared, ( + "these sources carry a string the ledger withdrew, and are not declared " + f"recording files: {undeclared}. The ledger holds the withdrawn wording; " + "a correction comment must point at the row rather than repeat the " + "string, or the source someone copies from still carries it.") + + +def test_the_source_retraction_guard_detects_the_pre_fix_file(): + """CONTROL: the R file as it stood before the fix must be caught. + + SCOPE: one file, recovered from git. Drawn from the artifact path rather than + written here -- a hand-typed string would test the scanner against my idea of + the comment, which is the control-that-never-met-the-pipeline defect. + """ + import subprocess + before = subprocess.run( + ["git", "show", "59b7d95:biofilms_radiodialysis.R"], + cwd=REPO, capture_output=True, text=True) + if before.returncode != 0: + pytest.skip("pre-fix source not reachable from git") + found = [s for s in RETRACTED_IN_SOURCES if s in before.stdout] + assert "Table 2 range 1e-4..1e-2" in found, ( + "the pre-fix R file should carry the withdrawn range; the scanner found " + f"{found}. It is matching nothing, not finding nothing.") + assert not [s for s in RETRACTED_IN_SOURCES + if s in (REPO / "biofilms_radiodialysis.R").read_text(encoding="utf-8")], \ + "and the current file must carry none" + + +# ---------------------------------------------------------------- addressing by key, not number +# +# ALL SIX PP-REF ROWS POINTED AT THE WRONG ENTRY, AND HAD FOR SOME TIME. They located +# themselves as "References [11]", "[12]", "[15]", "[26]", "[4]", "[32]". Resolved against +# the .tex those ordinals are slade2011, daly2009, xavier2005, karley2018, meskauskas2004 +# and kauffman1989 -- none of which is the work the row describes. Six for six wrong, and +# wrong at HEAD before any entry was deleted, so the staleness was not caused by an edit; +# it was caused by ADDRESSING A RENDER ARTIFACT. A \bibitem key is stable. Its printed +# number is a function of every entry above it, so one insertion silently re-points every +# reference below, and nothing errors -- the row still reads plausibly and now names a +# different paper. +# +# This is the identifier rule (read an identifier back against its issuing registry) +# pointed at an internal target: the .tex is the registry, and the key is the identifier. +# The number is a rendering of it. +def test_no_ledger_row_locates_itself_by_printed_reference_number(rows): + import re + by_number = re.compile(r"References?\s*\[\d+\]") + offenders = sorted(r["claim_id"] for r in rows if by_number.search(r["location"])) + assert not offenders, ( + "these rows address the bibliography by printed number, which re-points silently " + f"when any entry above is added or removed: {offenders}. " + r"Name the \bibitem key instead -- the key is stable, the number is a render artifact.") + + +def test_the_by_number_guard_detects_the_pre_fix_locations(rows): + # The control: the form the six rows used must be caught, or the guard above is + # asserting over an empty set and would pass on any ledger at all. + import re + by_number = re.compile(r"References?\s*\[\d+\]") + for pre_fix in ("References [15]", "References [32] [20] [35]", "References[4]"): + assert by_number.search(pre_fix), pre_fix + assert not by_number.search(r"References, \bibitem{campillo2017}") diff --git a/calibration/tests/test_figure_staleness.py b/calibration/tests/test_figure_staleness.py new file mode 100644 index 0000000..a920426 --- /dev/null +++ b/calibration/tests/test_figure_staleness.py @@ -0,0 +1,325 @@ +"""A rendered figure must be derived from the SVG currently in the tree. + +SCOPE: the SVG/PDF/sha256 triples under preprint/figures/, and nothing else. + +This exists because the generator-and-artifact pair has already failed here once: +a figure generator was corrected two weeks before its committed images were, and +no check could open a figure to notice. FIG-01 through FIG-07 record it. + +THE HASH IS WRITTEN ONLY BY tools/render_figure_svg.py. A sidecar a human can +rewrite binds the SVG to a record of itself, so the repair for a mismatch would be +to regenerate the record -- green, with the PDF still stale. Because only the +renderer writes it, the sole way to make this green is to run the renderer, which +emits the PDF as a side effect. +""" +from __future__ import annotations + +import hashlib +import html +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +FIGDIR = REPO / "preprint" / "figures" + + +def svg_triples(): + return sorted(FIGDIR.glob("*.svg")) + + +@pytest.mark.parametrize("svg", svg_triples(), ids=lambda p: p.name) +def test_the_rendered_pdf_matches_the_svg_in_the_tree(svg): + record = Path(str(svg) + ".sha256") + pdf = svg.with_suffix(".pdf") + assert record.is_file(), f"{svg.name} has no render record; run tools/render_figure_svg.py" + assert pdf.is_file(), f"{svg.name} has no rendered PDF" + recorded = record.read_text(encoding="utf-8").split()[0] + actual = hashlib.sha256(svg.read_bytes()).hexdigest() + assert recorded == actual, ( + f"{svg.name} changed since it was rendered. Re-run " + f"tools/render_figure_svg.py {svg.relative_to(REPO)} -- do NOT edit the " + f".sha256 file, which would make this pass with a stale PDF.") + + +def test_the_staleness_check_can_fail(): + """CONTROL: a record that does not match its source must be reported. + + SCOPE: one synthetic pair, exercising the comparison this file performs. + """ + body = b"" + assert hashlib.sha256(body).hexdigest() != hashlib.sha256(b"").hexdigest() + + +def test_the_phase2_figure_carries_the_corrected_multiple(): + """The number this figure was corrected FOR must be in the shipped artifact. + + SCOPE: preprint/figures/phase2_diffusion_cell.pdf only. + + The caption compared the solver's radial D_eff to water self-diffusivity and + said 100x; the verified value at 1e-3 cm2/s against 2.3e-5 is 43.5x. Asserting + it on the PDF rather than the SVG is the point -- the prose being right while + the image is wrong is the exact defect FIG-01..07 record. + """ + pdf = FIGDIR / "phase2_diffusion_cell.pdf" + if not pdf.is_file(): + pytest.skip("figure not rendered") + # DEPENDENCY-GATED, like the sidecar test already is. Without Poppler this + # raised FileNotFoundError instead of skipping, so a calibration environment + # lacking it failed here rather than reporting uncovered surface -- and the + # bare-tier checks in this file, which need no binary at all, were reported + # as an error rather than running. Raised by Codex on pull request #23. + import shutil + import subprocess + if not shutil.which("pdftotext"): + pytest.skip("pdftotext unavailable; the artifact assertion needs Poppler") + out = subprocess.run(["pdftotext", "-layout", str(pdf), "-"], + capture_output=True, text=True).stdout + assert "43" in out and "water" in out, "corrected multiple absent from the rendered figure" + assert "100× water" not in out and "100x water" not in out, \ + "the withdrawn 100x comparison is still in the rendered figure" + + +# ---------------------------------------------------------------- source -> artifact +# +# WHAT THE HASH CHECK ABOVE CANNOT DO, AND THE FILE IT GUARDS SAID SO FIRST. +# `test_the_rendered_pdf_matches_the_svg_in_the_tree` compares the committed +# `.svg.sha256` against the SVG. Both are files in the tree. Edit the SVG, +# rewrite the record by hand, do not re-render: the record and the source agree, +# the PDF's own hash still matches its own record, and every check here passes +# over a stale PDF. tools/render_figure_svg.py's docstring argues exactly this +# and then wrote exactly that record. Raised by Codex on pull request #23. +# +# A HASH CANNOT FIX A HASH. Recording the PDF digest beside the source digest +# (now done) makes the forgery need two lies instead of one; it does not make it +# impossible, because both lines are still text in a file anyone can write. +# +# So the binding is content, not digest: every text run in the SVG must appear in +# the committed `.txt`, which the sidecar test independently pins to the PDF's +# actual bytes. SVG text -> committed text -> committed PDF, each link checked by +# something that cannot be satisfied by editing a record. +# +# SCOPE, STATED BECAUSE IT IS NARROWER THAN "THE PDF IS FRESH": this catches +# TEXT drift only. An SVG edit that moves a rectangle and touches no string +# passes, and no check in this repository would catch it. That is the honest +# reach -- and text is the failure mode the figure guards exist for, since +# FIG-01 through FIG-10 are every one of them a claim made in words inside an +# image. +# +# MATCHING COVERS THE WHOLE RUN, NOT ITS OPENING WORDS, AND THAT CHANGE IS THE +# POINT. The first version compared the first five words. Everything after word +# five could then diverge between SVG and PDF while this passed -- and the worst +# instance is the one that matters: changing `43` to `99` in the phase2 caption +# escaped entirely, and 43 is THE NUMBER THE FIGURE WAS CORRECTED FOR, sitting at +# word 24 of a 27-word run. Raised by Codex on pull request #23. +# +# THE RULE WAS MEASURED, NOT REASONED. Four candidates were run against all 39 +# runs of the committed figure: full-run whitespace-collapsed (1 false failure), +# full-run whitespace-removed (1), first-five-words (0), head-5 + tail-5 (1). +# Every full-coverage candidate failed on the SAME run -- the one containing 43 -- +# and the cause is that 26 of its 27 words match exactly while the final token is +# clipped at the column boundary: `self-diffusivity).` extracts as +# `self-diffusiv`. Hence: all words but the last must appear contiguously, and the +# last may be clipped. +# +# THAT RULE RESTS ON A PROPERTY OF THIS LAYOUT AND A FUTURE FAILURE SHOULD BE +# DIAGNOSED ACCORDINGLY. Clipping happens at the column boundary, which is why it +# lands on the final token. Widen a column, change the page, re-flow the figure, +# and a MIDDLE token could clip -- at which point this reports a false failure +# that looks exactly like a real one, and the obvious reading would be that +# somebody edited the SVG without re-rendering. A failure whose missing word is +# mid-run is more likely layout drift than tampering; check the render before +# concluding staleness. + + +def svg_text_runs(svg_source: str) -> list[str]: + """Whitespace-normalised contents of each element, >= 3 words. + + SCOPE: `` elements only. Text drawn as paths, or set in `` + outside a ``, is not seen -- this repository's figures use neither. + """ + runs = [] + for m in re.finditer(r"]*>(.*?)", svg_source, re.S): + inner = html.unescape(re.sub(r"<[^>]+>", "", m.group(1))) + s = " ".join(inner.split()) + if len(s.split()) >= 3: + runs.append(s) + return runs + + +def unrendered_runs(svg_source: str, txt: str) -> list[str]: + """Runs whose text is absent from the extraction, final token allowed clipped. + + Shared by the check and its controls, so a control cannot pass against a + regressed check. + """ + flat = " ".join(txt.split()) + out = [] + for s in svg_text_runs(svg_source): + # THE CLIPPING ALLOWANCE IS EARNED PER RUN, NOT GRANTED TO ALL OF THEM. + # The first version dropped the final token of EVERY run, because one run + # in 39 has its last word clipped at the column boundary. That left the + # last token of all 39 unchecked -- and one of them is a citation year: + # `volume bromide did not (Hay et al. 2011,`. Changing 2011 to 2019 left + # the guard green, forging a citation inside the figure whose entire + # history is withdrawn claims surviving in images. + # + # So: try the whole run first. Only if that fails may the final token be + # dropped, and only then. A run that does not clip is checked in full. + if s in flat: + continue + w = s.split() + if len(w) > 1: + head = " ".join(w[:-1]) + i = flat.find(head) + if i >= 0: + # THE HEAD MATCHING IS NOT ENOUGH -- that is the hole itself. The + # allowance applies only when the extraction TRUNCATES the final + # token, so what follows the head must be a proper prefix of it. + # A different word there is an edit, not a clip: changing + # `2011,` to `2019,` leaves the head intact and must still fail. + after = flat[i + len(head):].split() + tail = w[-1] + # Only the FIRST whitespace-delimited fragment after the head: + # the extraction continues into the next run, so a wider window + # compares this run's tail against a neighbour's opening words. + frag = after[0] if after else "" + if len(frag) >= 3 and tail.startswith(frag) and frag != tail: + continue # genuinely clipped at the boundary + out.append(s) + return out + + +def _run_extent(svg_source: str, run: str): + """(start, end) of the element whose normalised content is `run`. + + Returns the extent of THAT element, so a control can assert its mutation + landed inside the run it selected rather than inside some run. + """ + for m in re.finditer(r"]*>(.*?)", svg_source, re.S): + inner = html.unescape(re.sub(r"<[^>]+>", "", m.group(1))) + if " ".join(inner.split()) == run: + return m.start(), m.end() + return None + + +@pytest.mark.parametrize("svg", svg_triples(), ids=lambda p: p.name) +def test_the_svg_text_reaches_the_committed_pdf_text(svg): + txt_path = svg.with_suffix(".txt") + assert txt_path.is_file(), f"{svg.name} has no .txt sidecar; run tools/render_figure_svg.py" + missing = unrendered_runs(svg.read_text(encoding="utf-8"), + txt_path.read_text(encoding="utf-8")) + assert not missing, ( + f"{svg.name} carries text that is not in the rendered artifact, so the " + f"committed PDF is older than the SVG: {missing[:3]}. Re-run " + f"tools/render_figure_svg.py {svg.name}. Editing the .sha256 will make the " + "hash checks pass and will not make this one. If the missing word is " + "MID-run rather than at the end, suspect layout drift before staleness -- " + "see the note on the clipped final token above.") + + +def _mutate_inside(source: str, run: str, old: str, new: str): + """Replace `old` with `new` INSIDE the element whose content is `run`. + + Two properties the previous control had neither of. It did + `source.replace(victim.split()[0], "Notarealword", 1)` -- the first occurrence + of that word ANYWHERE in the file, which happened to fall inside the victim + only because the longest run contains the file's first "The". Correct by + coincidence. And the replacement operated on the NORMALISED run text, while + the source stores entities (`×`, `’`), so a mutation of any run + containing one silently did nothing at all. + """ + span = _run_extent(source, run) + assert span, "victim run not locatable in the source" + lo, hi = span + seg = source[lo:hi] + assert old in seg, f"{old!r} is not inside the selected run" + return source[:lo] + seg.replace(old, new, 1) + source[hi:], (lo, hi) + + +def test_the_source_to_artifact_binding_detects_an_edited_svg(): + """CONTROL: an SVG edited without re-rendering must be caught, anywhere in a run. + + SCOPE: the phase2 figure's longest run for head/middle/tail, plus ONE + DIFFERENT run so the rule is not tested only on the instance it was derived + from. The known-bad is drawn from the artifact path rather than written by + hand. + + AND THE MUTATION IS ASSERTED TO HAVE APPLIED, AND WHERE. The first attempt at + reproducing the Codex finding was a no-op -- it replaced normalised text that + the entity-encoded source does not contain -- and the guard's truthful + "nothing missing" over an unmodified file read exactly like confirmation. + That is a distinct failure from a vacuous assertion: the assertion was fine, + THE INPUT NEVER CHANGED. So every mutation below asserts it applied and that + it landed inside the run selected, by identity rather than by membership in + the set of runs -- an offset off by enough lands in a neighbour and would + otherwise pass. + """ + svg = FIGDIR / "phase2_diffusion_cell.svg" + source = svg.read_text(encoding="utf-8") + txt = svg.with_suffix(".txt").read_text(encoding="utf-8") + + assert not unrendered_runs(source, txt), "baseline is not clean; no control verdict" + runs = svg_text_runs(source) + assert runs, "no text runs found -- the extractor is broken, not the figure" + + # TRAINING SET: the run the clipped-final-token rule was derived from. Head, + # middle and tail, because the defect was that only the head was ever read. + victim = max(runs, key=len) + lo0, hi0 = _run_extent(source, victim) + for label, old, new in (("head", "The film-scale", "The notreal"), + ("middle", "reactor-scale", "notreal-scale"), + ("tail", "about 43×", "about 99×")): + mutated, (lo, hi) = _mutate_inside(source, victim, old, new) + assert mutated != source, f"{label}: mutation did not apply" + # AN INVARIANT RESTATED, NOT A CHECK, AND SAYING SO IS THE POINT. + # `_mutate_inside` locates the element by content identity and replaces + # only within that slice, so the mutation CANNOT land in a neighbouring + # run while that function is correct. Weakening this line to any + # tautology leaves the suite green -- measured. It is kept because it + # documents the invariant the construction provides, and it would catch a + # future rewrite of `_mutate_inside` that located by offset arithmetic + # instead. A membership test ("falls inside SOME run") would be the weak + # version and is deliberately not what this compares. + assert (lo, hi) == (lo0, hi0), ( + f"{label}: mutation landed in a different element than the one " + "selected -- _mutate_inside no longer bounds the replace to the " + "located run") + assert unrendered_runs(mutated, txt), ( + f"{label} edit was not caught -- text after the opening words can " + "diverge from the artifact unnoticed") + + # THE CASE THE FOUR CONTROLS BELOW STRUCTURALLY CANNOT REACH: the final token + # of a run that does NOT clip. Three of them mutate the one run that clips and + # the fourth mutates a first word, so none could see that the clipping + # allowance was being granted to all 39 runs rather than to the one that earns + # it. That hole let `2011,` become `2019,` in a citation with the suite green. + # + # 35 of 39 runs are present in the extraction in full. This mutates one of + # them, and asserts the mutation applied -- a no-op here would report ESCAPES + # for the wrong reason, which happened while writing this: the first candidate + # token came from a phrase withdrawn from the figure months ago and no longer + # in the source at all. + nonclipping = [r for r in runs if r in " ".join(txt.split()) and len(r.split()) > 3] + assert nonclipping, "no full-present run to draw the control from" + tail = nonclipping[0].split()[-1] + mutated, _ = _mutate_inside(source, nonclipping[0], " " + tail, " Notarealword") + assert mutated != source, "final-token control: mutation did not apply" + assert unrendered_runs(mutated, txt), ( + "the final token of a NON-clipping run was edited and not caught -- the " + "clipping allowance is being granted to runs that do not clip") + + # TEST SET, and it is labelled as such the way tools/absence_gate.py labels + # its own: a DIFFERENT run, whose final token is not clipped. Deriving the + # rule from one run and only ever testing it there is the training-set + # problem, and this is the item that shows the rule generalises. + other = next(r for r in runs + if r != victim and len(r.split()) >= 4 + and " ".join(r.split()) in " ".join(txt.split())) + first_word = other.split()[0] + mutated, _ = _mutate_inside(source, other, first_word, "Notarealword") + assert mutated != source, "test-set mutation did not apply" + assert unrendered_runs(mutated, txt), ( + "a mutation in a run OTHER than the one the rule was derived from was " + "not caught, so the rule is overfit to that run") diff --git a/calibration/tests/test_guide_citations.py b/calibration/tests/test_guide_citations.py new file mode 100644 index 0000000..5920b9a --- /dev/null +++ b/calibration/tests/test_guide_citations.py @@ -0,0 +1,290 @@ +"""Every `file:line` citation in docs/guides/ resolves to what it claims. + +WHY THIS EXISTS. A guide whose premise is that each claim is checkable against a +line of source is, without this, a tour with footnotes. Nothing in this +repository verified a file:line citation before it: `manuscript_claims_tests.jl` +does a regex SEARCH (find every line matching a pattern), and +`test_claims_ledger.py` resolves a git SHA. Neither takes a pre-named citation +and asks whether it still points at what it says. + +WHAT MAKES A CITATION RESOLVE RATHER THAN MERELY POINT. File-exists and +line-exists are nearly free to satisfy -- a citation to any line of a file that +happens to be long enough passes both. The fragment is what carries the claim, +so every citation states one and the fragment must be on that line. + +AND THE FAILURE MESSAGE HAS TO SPLIT TWO CASES, or the repair becomes reflexive. +When an edit shifts a line the guard fails, and the cheap fix is to bump the +number. That is right when the code is unchanged and moved, and wrong when the +fragment moved because the code changed -- those need different responses. So on +failure the guard searches the file for the fragment and says which case it is. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +GUIDES = REPO / "docs" / "guides" + +# A row of a "Checks against the source" table: +# | claim | `path:line` | `fragment` | +# The fragment is taken verbatim between the last pair of backticks, so it may +# contain pipes escaped as \| -- which the Robin condition's |_{r=R} needs. +ROW = re.compile( + r"^[ \t]*\|(?P[^|]*)\|\s*`(?P[^`:]+):(?P\d+)`\s*\|\s*`(?P.+?)`\s*\|\s*$", + re.M) + + +# A LOOSE PATTERN FOR THE SAME THING, USED ONLY TO COUNT. `ROW` is strict on +# purpose, and a strict parser silently drops what it cannot read -- so the count +# it produces is a SUBSET while the assertion below it would be about the SET. +# That is the defect this repository has now had caught three times, and putting +# it in the guard written to check citations would be the joke completing itself. +# So anything shaped like a citation row is counted, and the strict parser must +# account for every one. +# LEADING WHITESPACE IS ALLOWED IN BOTH, and it has to be both. A row indented +# two spaces -- still a valid Markdown table row -- matched NEITHER pattern, so +# `cites` and `loose` shrank together and the count-equality check still held. +# The safety net added so an unparsed row could not hide was itself blind to the +# one way a row hides. A citation smuggled in indented was never resolved and +# never reported. +LOOSE_ROW = re.compile(r"^[ \t]*\|.*`[^`]+:\d+`.*\|", re.M) + + +def guide_files() -> list[Path]: + return sorted(GUIDES.glob("*.md")) if GUIDES.is_dir() else [] + + +def citations(text: str) -> list[dict]: + out = [] + for m in ROW.finditer(text): + out.append({ + "claim": m.group("claim").strip(), + "path": m.group("path").strip(), + "line": int(m.group("line")), + # `\|` is markdown's escape for a pipe inside a table cell; the + # source line contains a bare pipe. + "frag": m.group("frag").replace(r"\|", "|"), + }) + return out + + +def resolve(cite: dict) -> tuple[bool, str]: + """(ok, message). The message names WHICH repair a failure needs.""" + target = REPO / cite["path"] + if not target.is_file(): + return False, f"no such file: {cite['path']}" + lines = target.read_text(encoding="utf-8", errors="replace").split("\n") + if cite["line"] > len(lines): + return False, (f"{cite['path']} has {len(lines)} lines; cited " + f"{cite['line']}") + if cite["frag"] in lines[cite["line"] - 1]: + return True, "" + + # THE SPLIT. Where else does the fragment live? + elsewhere = [i for i, l in enumerate(lines, 1) if cite["frag"] in l] + if elsewhere: + return False, ( + f"THE CODE MOVED, NOT THE CLAIM. {cite['path']}:{cite['line']} no " + f"longer contains this fragment, but line(s) {elsewhere} do. " + f"Renumber the citation to :{elsewhere[0]}.") + return False, ( + f"THE CODE CHANGED. {cite['path']} no longer contains this fragment " + f"anywhere, so the claim it supports may no longer be true. RE-READ the " + f"source before renumbering -- bumping the line number here would be " + f"the reflexive repair this message exists to prevent. " + f"Claim: {cite['claim']!r}") + + +@pytest.mark.skipif(not guide_files(), reason="no guides committed yet") +@pytest.mark.parametrize("guide", guide_files(), ids=lambda p: p.name) +def test_every_guide_citation_resolves(guide): + text = guide.read_text(encoding="utf-8") + cites = citations(text) + assert cites, ( + f"{guide.name} has no parseable citations. A guide in docs/guides/ that " + "cites nothing is the shape this check exists to prevent; if it " + "genuinely makes no claim about source, say so in the file.") + + # EVERY citation-shaped row, not just the ones the strict parser liked. A + # row that fails ROW would otherwise be checked by nothing while this test + # reported green over the rows it happened to read. + loose = len(LOOSE_ROW.findall(text)) + assert len(cites) == loose, ( + f"{guide.name}: {loose} rows look like citations and only {len(cites)} " + f"parsed, so {loose - len(cites)} are being checked by nothing. Fix the " + "row's format or widen ROW -- do not leave it silently unread.") + + failures = [] + for c in cites: + ok, msg = resolve(c) + if not ok: + failures.append(msg) + assert not failures, "\n\n".join(failures) + + +def test_the_citation_check_detects_a_shifted_line(): + """CONTROL: shift a real citation and require the guard to catch it. + + SCOPE: one citation drawn from a committed guide, shifted by ONE and by TEN. + + BOTH SHIFTS, AND ONE IS NOT ENOUGH. Off-by-one is exactly the case a fragment + match can survive by accident: `w_plus` and `w_minus` sit on consecutive + lines of face_weights(), as do `rtol` and `atol` in the ode() call, so a + control that only shifted by one could pass while the guard was blind to + small drift. Ten crosses out of any such neighbourhood. + + The known-bad is DERIVED FROM THE ARTIFACT -- a real citation from a real + guide -- rather than hand-written, because a synthetic citation would test + the regex against an idea of the format instead of the format in use. + """ + guides = guide_files() + assert guides, "no guide to draw a control from" + cites = citations(guides[0].read_text(encoding="utf-8")) + assert cites, "no citations to draw a control from" + + real = cites[0] + ok, msg = resolve(real) + assert ok, f"baseline citation does not resolve, so no control verdict: {msg}" + + for delta in (1, 10): + shifted = dict(real, line=real["line"] + delta) + ok, msg = resolve(shifted) + assert not ok, ( + f"shifting {real['path']}:{real['line']} by {delta} was NOT caught, " + "so this guard cannot detect a stale line number") + assert "THE CODE MOVED" in msg, ( + f"shift by {delta} was caught but misdiagnosed; the fragment still " + f"exists in the file, so the message must say the code moved: {msg}") + + +def test_the_two_failure_cases_are_distinguished(): + """CONTROL: a fragment that exists nowhere must not be reported as moved. + + SCOPE: one synthetic fragment against a real file. This half MUST be + synthetic -- the case is 'no line contains this', which cannot be drawn from + a citation that resolves. + """ + guides = guide_files() + assert guides, "no guide to draw a control from" + real = citations(guides[0].read_text(encoding="utf-8"))[0] + + gone = dict(real, frag="Notarealfragment_zzz") + ok, msg = resolve(gone) + assert not ok + assert "THE CODE CHANGED" in msg, ( + "a fragment absent from the whole file was not diagnosed as a changed " + f"claim, so the two repairs are not being distinguished: {msg}") + + +# ------------------------------------------- the rendered artifact, not just the source +# +# THE SOURCE-OF-RECORD IS THE .md AND A READER RECEIVES THE .pdf. Everything above +# reads `docs/guides/*.md`. On 2026-08-31 the committed PDF cited +# biofilms_potts.jl:1444, biofilms_..._fitness.tex:660 and :912 -- three line +# numbers the .md had already been corrected away from, because edits elsewhere had +# shifted them. Every check above passed. That is the figure-staleness defect +# exactly: prose right, artifact wrong, and the guard reading only the prose. +# +# It is also the rendered-versus-source gap this session recorded one commit +# earlier while writing about it, which is why the binding is asserted here rather +# than left to the discipline of remembering to re-render. +# +# SCOPE, NARROWER THAN "THE PDF IS CURRENT": line numbers only. A prose edit to the +# .md that never touches a citation leaves the PDF stale and this check green. The +# .tex is a hand-maintained rendering, not generated, so nothing binds its prose to +# the .md's -- that is a real and open gap, named here rather than implied away. +def guide_pdfs() -> list[Path]: + return sorted(GUIDES.glob("*.pdf")) if GUIDES.is_dir() else [] + + +def pdf_text(pdf: Path) -> str: + import subprocess + return subprocess.run(["pdftotext", "-layout", str(pdf), "-"], + capture_output=True, text=True).stdout + + +@pytest.mark.skipif(not guide_pdfs(), reason="no rendered guide committed") +@pytest.mark.parametrize("pdf", guide_pdfs(), ids=lambda p: p.name) +def test_the_rendered_guide_cites_what_the_source_cites(pdf): + import shutil + md = pdf.with_suffix(".md") + assert md.is_file(), f"{pdf.name} has no .md source of record" + if not shutil.which("pdftotext"): + pytest.skip("pdftotext unavailable; the rendered-artifact check needs Poppler") + + cites = citations(md.read_text(encoding="utf-8")) + assert cites, f"{md.name} yields no citations to compare against" + text = " ".join(pdf_text(pdf).split()) + + # MATCH WHAT THE EXTRACTION PRODUCES, NOT WHAT THE SOURCE WROTE. The template + # breaks long paths at underscores to fit the table column, and `pdftotext + # -layout` puts the two halves in DIFFERENT COLUMNS -- `biofilms_` on one side, + # `radiodialysis.R:223` on the other -- so no amount of whitespace-joining + # rejoins them. Checked against the real text layer rather than assumed: the + # segment after the final underscore survives intact for all seven paths, and + # full-path matching reported all fourteen citations missing from a PDF that + # in fact contained every one. That is the extraction-shape lesson the figure + # sidecars taught, arriving in a second artifact. + def _tail(path: str) -> str: + return Path(path).name.split("_")[-1] + + missing = [f"{c['path']}:{c['line']}" for c in cites + if f"{_tail(c['path'])}:{c['line']}" not in text] + # ...and no line number the source has moved away from may survive in it. This + # half is what catches a stale render: the first is satisfied by a PDF that + # merely contains the right numbers somewhere among the wrong ones. + current = {str(c["line"]) for c in cites} + import re as _re + stale = sorted({m for m in _re.findall(r"\.(?:jl|R|py|tex|md):(\d+)", text) + if m not in current}) + + assert not missing and not stale, ( + f"{pdf.name} is not the render of {md.name}. Missing from the artifact: " + f"{missing}. Line numbers in the artifact the source no longer cites: " + f"{stale}. Re-render the guide -- the .md is the source of record, and " + "editing it without re-rendering leaves a reader holding wrong citations.") + + +# ------------------------------------------------- the .md moved without a render +# +# THE GAP THIS CLOSES, AND THE ONE IT LEAVES OPEN. Everything above binds line +# NUMBERS. A prose edit to the .md that touches no citation left the .tex and the +# PDF holding the old wording with every check green -- which happened here on +# 2026-08-31 and was repaired by hand-syncing three files twice in one session. +# +# tools/render_guide.py writes `.md.sha256` ONLY as a side effect of a +# successful render that verified every citation in the artifact. So the record +# cannot be satisfied by editing it: a hand-written hash passes this check and +# leaves the citation check to fail on the next real run. Same inversion as +# tools/render_figure_svg.py. +# +# IT DOES NOT VERIFY THAT THE .tex SAYS WHAT THE .md SAYS. Measured before +# choosing: the .md's prose paragraphs match the rendered text layer 38 of 48 at a +# six-word prefix, and the ten failures are MARKUP differences rather than +# divergence -- backticked `r` renders as math, a literal `##` in prose renders as +# emphasis. The .tex is a re-authoring, not a render, so the two differ by design +# and a prose binding would be a fifth false failures. That is the wrong +# instrument for a hand-authored .tex, not a threshold to tune. A faithful +# re-sync remains a human obligation and an open gap. +def guide_records() -> list[Path]: + return sorted(GUIDES.glob("*.md.sha256")) if GUIDES.is_dir() else [] + + +@pytest.mark.skipif(not guide_records(), reason="no rendered guide recorded") +@pytest.mark.parametrize("record", guide_records(), ids=lambda p: p.name) +def test_the_guide_source_matches_its_recorded_render(record): + import hashlib + md = record.with_suffix("") # strip .sha256, leaving .md + assert md.is_file(), f"{record.name} records a hash for a missing {md.name}" + recorded = record.read_text(encoding="utf-8").split()[0] + actual = hashlib.sha256(md.read_bytes()).hexdigest() + assert recorded == actual, ( + f"{md.name} has changed since the guide was last rendered, so the " + f"committed .tex and .pdf are older than the source of record. Re-sync " + f"the .tex by hand, then run `python3 tools/render_guide.py {md}`. Do NOT " + "edit the .sha256: that makes this pass and leaves the citation check to " + "fail on the next real render. Note this catches only that the source " + "MOVED -- nothing verifies the re-sync was faithful.") diff --git a/calibration/tests/test_overdamped_regime.py b/calibration/tests/test_overdamped_regime.py new file mode 100644 index 0000000..e8a3d79 --- /dev/null +++ b/calibration/tests/test_overdamped_regime.py @@ -0,0 +1,263 @@ +"""Section 3.4's overdamped paragraph states numbers; this binds them to the producer. + +SCOPE: the numeric literals in the paragraph at +preprint/modeling_radioresistance_and_radiotropic_fitness.tex lines 571-584, and +the receipt analysis/overdamped_regime.py emits. Nothing else in section 3.4. + +THE PARAGRAPH ENDS BY CLAIMING ITS OWN VERIFIABILITY -- "analysis/overdamped_regime.py +reproduces every number in this paragraph" -- and until this file existed nothing +checked that sentence. Worse, the producer was executed by NOTHING: no test, no +workflow, no script invoked it, so its own four assertions (Re < 1, the 1e8 ratio +floor, the 1 cm control, the Re>1 counter-control) had never run in CI. Running it +is the larger half of what this file does; the comparison below is the smaller half. + +WHY THE ACCOUNTING IS COMPLETE RATHER THAN A LIST OF FIVE. The receipt used to emit +three scalars while the paragraph states five numbers, so a guard written from the +receipt would have covered two of five and looked finished. The defence is that +EVERY literal in the paragraph must be accounted for: extraction is mechanical, so a +number cannot be missed by an author who did not think to list it, and set equality +against ACCOUNTED is what fails when one appears that nobody mapped. + +WHY THE FORMULA EXEMPTION IS SCOPED BY LOCATION AND NOT BY VALUE. The 2, 2 and 9 of +tau_p = 2*rho*a^2/(9*eta) are structure, not results, and must be exempt. A +value-keyed exemption set would be a hole with a comment on it: add a number, add it +to the set, suite green. Membership is therefore decided by whether the occurrence +falls inside FORMULA_SPAN. A stated result cannot be exempted because it does not +occur there -- see test_a_stated_number_cannot_be_moved_into_the_exemption. +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +TEX = REPO / "preprint" / "modeling_radioresistance_and_radiotropic_fitness.tex" +PRODUCER = REPO / "analysis" / "overdamped_regime.py" + +# 571-584 until 2026-09-01, when four \label lines were inserted above and the anchor +# assertion below refused the stale window rather than silently reading two wrong lines. +FIRST_LINE, LAST_LINE = 592, 605 +UM = 1e-6 + +# The one span whose literals are structural. Matched as text, so it moves with the +# paragraph; if the formula is rewritten this raises rather than silently exempting +# nothing (or, worse, exempting a result that drifted into the old offsets). +FORMULA = r"\tau_p = 2\rho a^{2}/(9\eta)" + +# Every non-structural literal in the paragraph, as written, with the SI quantity it +# denotes and the receipt path that must reproduce it. `written` is what appears in +# the .tex and fixes the rounding window; `si` is that value in the receipt's units. +ACCOUNTED = [ + # written, si, receipt path + (1e3, 1e3, ("inputs", "rho")), + (1e-3, 1e-3, ("inputs", "eta")), + (1.0, 1 * UM, ("lengths", "cell, 1 um at 20 um/s")), + (10.0, 10 * UM, ("lengths", "cell, 10 um at 20 um/s")), + (20.0, 20 * UM, ("speeds", "cell, 1 um at 20 um/s")), + (2e-5, 2e-5, ("reynolds", "cell, 1 um at 20 um/s")), + (2e-4, 2e-4, ("reynolds", "cell, 10 um at 20 um/s")), + (100.0, 100 * UM, ("lengths", "biofilm feature, 100 um at 20 um/s")), + (2e-3, 2e-3, ("reynolds", "biofilm feature, 100 um at 20 um/s")), + (5.6e-8, 5.6e-8, ("tau_p", "0.5 um")), + (0.5, 0.5 * UM, ("radii", "0.5 um")), + (2.2e-7, 2.2e-7, ("tau_p", "1 um")), + (1.0, 1 * UM, ("radii", "1 um")), +] + + +# The paragraph is addressed by line number, so it is ALSO anchored by content: a shift +# downward would silently drop the tail (which carries no literals, so nothing else would +# fail), and addressing a render position without checking it is the defect AGENTS.md +# records for bibliography numbers. +FIRST_ANCHOR = r"\emph{And the absence is appropriate" +LAST_ANCHOR = "reproduces every number in this paragraph." + + +def paragraph() -> str: + lines = TEX.read_text(encoding="utf-8").splitlines() + para = "\n".join(lines[FIRST_LINE - 1:LAST_LINE]) + assert para.lstrip().startswith(FIRST_ANCHOR), ( + f"lines {FIRST_LINE}-{LAST_LINE} no longer start the overdamped paragraph; " + "the section moved and the range must be re-anchored deliberately") + assert para.rstrip().endswith(LAST_ANCHOR), ( + f"lines {FIRST_LINE}-{LAST_LINE} no longer end the overdamped paragraph") + return para + + +def _blank_unit_groups(text: str) -> str: + """Replace \\mathrm{...} with spaces, preserving every character position. + + Unit exponents are not quantities: \\mathrm{kg\\,m^{-3}} carries a -3 that means + "per cubic metre", not a number the producer computes. Blanking rather than + deleting keeps offsets valid for the FORMULA_SPAN containment test. + """ + out = list(text) + i = 0 + while (j := text.find(r"\mathrm{", i)) != -1: + depth, k = 0, j + len(r"\mathrm{") - 1 + while k < len(text): + if text[k] == "{": + depth += 1 + elif text[k] == "}": + depth -= 1 + if depth == 0: + break + k += 1 + for m in range(j, min(k + 1, len(text))): + out[m] = " " + i = k + 1 + return "".join(out) + + +# A LEADING MINUS IS PART OF THE QUANTITY. Without it "-5\\times10^{-5}" parsed as +5e-5; +# set equality still failed, so it was loud rather than silent, but the parse was wrong and +# this repository publishes negative coefficients. +TOKEN = re.compile( + r"(?P(?P-?\d+(?:\.\d+)?)\\times10\^\{(?P-?\d+)\})" + r"|(?P(?P-?)10\^\{(?P-?\d+)\})" + r"|(?P-?\d+(?:\.\d+)?)" +) + + +def literals(text: str): + """Every numeric quantity in the paragraph, as (value, start offset).""" + for m in TOKEN.finditer(_blank_unit_groups(text)): + if m.group("sci"): + yield float(m.group("mant")) * 10.0 ** int(m.group("sexp")), m.start() + elif m.group("pow"): + sign = -1.0 if m.group("pneg") else 1.0 + yield sign * 10.0 ** int(m.group("pexp")), m.start() + else: + yield float(m.group("bare")), m.start() + + +def half_ulp(written: float) -> float: + """Half a unit in the last place the .tex actually writes. + + The paragraph states 5.6e-8 for a computed 5.555...e-8, so exact comparison + fails on correctly-rounded prose. Rounding the computed value and comparing + strings would pass, but it is a binary verdict on the window and hides where + inside it the producer sits -- a value at the far edge reads identically to one + at the centre. A tolerance reports the margin, so drift becomes visible before + it crosses. + """ + s = repr(float(written)) + if "e" in s or "E" in s: + mant, exp = s.lower().split("e") + decimals = len(mant.split(".")[1].rstrip("0")) if "." in mant else 0 + return 0.5 * 10.0 ** (int(exp) - decimals) + decimals = len(s.split(".")[1].rstrip("0")) if "." in s else 0 + return 0.5 * 10.0 ** (-decimals) + + +@pytest.fixture(scope="module") +def receipt(tmp_path_factory): + out = tmp_path_factory.mktemp("overdamped") / "receipt.json" + proc = subprocess.run( + [sys.executable, str(PRODUCER), "--report", str(out)], + cwd=REPO, capture_output=True, text=True, + ) + assert proc.returncode == 0, f"producer failed:\n{proc.stdout}\n{proc.stderr}" + return json.loads(out.read_text()) + + +def test_the_producer_runs_and_its_own_checks_pass(receipt): + # Pinned so that adding a check to the producer forces someone to look here. + assert receipt["checks_run"] == 4 + assert receipt["failures"] == 0 + assert receipt["skipped"] == 0 and receipt["skips"] == [] + assert receipt["complete"] is True + + +def test_the_formula_span_is_still_in_the_paragraph(): + assert paragraph().count(FORMULA) == 1, ( + "the tau_p formula moved or was rewritten; the location-scoped exemption " + "below is anchored to it and must be re-anchored deliberately.") + + +def test_every_literal_in_the_paragraph_is_accounted_for(): + text = paragraph() + start = text.index(FORMULA) + span = range(start, start + len(FORMULA)) + + found = sorted(v for v, off in literals(text) if off not in span) + structural = sorted(v for v, off in literals(text) if off in span) + + assert structural == [2.0, 2.0, 9.0], ( + f"the formula span holds {structural}, not the 2, 2 and 9 of " + r"tau_p = 2*rho*a^2/(9*eta)") + + expected = sorted(w for w, _, _ in ACCOUNTED) + assert found == expected, ( + "section 3.4's paragraph states a number that no entry in ACCOUNTED maps to " + "a receipt value (or maps one that is no longer there).\n" + f" in the paragraph, unaccounted: {sorted(set(found) - set(expected))}\n" + f" in ACCOUNTED, not in the text: {sorted(set(expected) - set(found))}") + + +@pytest.mark.parametrize("written,si,path", ACCOUNTED, + ids=[f"{p[0]}:{p[1]}" for _, _, p in ACCOUNTED]) +def test_each_stated_number_is_within_half_a_unit_of_the_last_place( + written, si, path, receipt): + computed = receipt[path[0]][path[1]] + tol = half_ulp(written) * (si / written) # the window, in the receipt's units + gap = abs(computed - si) + assert gap <= tol, ( + f"{'/'.join(path)} = {computed!r} is outside the rounding window of the " + f"{written!r} stated in section 3.4: gap {gap:.3e} > half-ULP {tol:.3e}") + # Report the margin even on success, so drift inside the window is visible in -rs + # output rather than silent until it crosses. + print(f" {'/'.join(path):45} margin {gap / tol:5.1%} of the rounding window") + + +def test_the_ten_orders_of_magnitude_claim(receipt): + # "roughly ten orders of magnitude below the timescales this model addresses" + import math + for label in ("0.5 um", "1 um"): + orders = math.log10(receipt["ratio"][label]) + assert round(orders) == 10, ( + f"T_bio/tau_p for a = {label} is 10^{orders:.2f}, which does not round " + "to the ten orders of magnitude section 3.4 claims") + + +# A QUANTITY MUST NOT BE ABLE TO HIDE INSIDE A UNIT GROUP. +# _blank_unit_groups blanks \mathrm{...} because unit exponents are not quantities: +# \mathrm{kg\,m^{-3}} carries a -3 meaning "per cubic metre". But a number written INSIDE +# such a group -- \mathrm{20\,\mu m\,s^{-1}} -- is blanked with it and then accounted for by +# nothing, and NOTHING FAILS. That is a coverage gap that passes, which is worse than a wrong +# answer. Rather than parse the inside of a unit group, refuse it: the only digits allowed in +# there are exponents. +def test_no_quantity_hides_inside_a_unit_group(): + text = paragraph() + for m in re.finditer(r"\\mathrm\{", text): + depth, k = 0, m.end() - 1 + while k < len(text): + if text[k] == "{": + depth += 1 + elif text[k] == "}": + depth -= 1 + if depth == 0: + break + k += 1 + group = text[m.end():k] + stripped = re.sub(r"\^\{-?\d+\}", "", group) # exponents are legitimate + assert not re.search(r"\d", stripped), ( + f"a digit sits inside a unit group and would be blanked before accounting: " + f"\\mathrm{{{group}}}. Write the quantity outside the group.") + + +def test_the_unit_group_guard_and_the_sign_both_detect_their_defect(): + # Controls. Without these the two fixes above assert over inputs that never occur. + hidden = r"a speed of $\mathrm{20\,\mu m\,s^{-1}}$" + assert 20.0 not in [v for v, _ in literals(hidden)], "the miss must be real" + group = re.search(r"\\mathrm\{([^}]*)\}", hidden).group(1) + assert re.search(r"\d", re.sub(r"\^\{-?\d+\}", "", group)), "the guard must see it" + + assert [v for v, _ in literals(r"$-5\times10^{-5}$")] == [-5e-5] + assert [v for v, _ in literals(r"$-10^{-3}$")] == [-1e-3] + assert [v for v, _ in literals(r"$2\times10^{-5}$")] == [2e-5] # unsigned unchanged diff --git a/calibration/tests/test_sop_index.py b/calibration/tests/test_sop_index.py new file mode 100644 index 0000000..2c5f11f --- /dev/null +++ b/calibration/tests/test_sop_index.py @@ -0,0 +1,368 @@ +"""The SOP index is a coverage claim over a requirement register, so it needs a guard. + +An index organised by DISCIPLINE looks complete while a register requirement sits +uncovered, which is the same failure as enumerating lattice movers by hand. This +file makes that detectable: the index is joined to +data/calibration/reference_d_requirements.csv on requirement_id, and coverage is +asserted per supplied_by class rather than flat. + +WHY PER CLASS. test_declared_requirements_are_not_laboratory_work asserts declared +rows are not lab work, so "every requirement has an SOP or a named gap" applied +flat would demand twelve forbidden SOPs or invent twelve spurious gaps. Measured +rows take an SOP or a named gap; declared take a decision; derived take a +derivation; evaluated_data takes a source. +""" +from __future__ import annotations + +import csv +import io +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +REGISTER = REPO / "data" / "calibration" / "reference_d_requirements.csv" +INDEX = REPO / "data" / "calibration" / "sop_index.csv" + +COVERAGE_FOR = { + "measured": {"sop", "named_gap"}, + "declared": {"decision"}, + "derived": {"derivation"}, + "evaluated_data": {"source"}, +} +# SCOPE: the run_by values of MEASURED register rows only. Declared rows are +# never lab work and derived rows are computed, so neither is reached here. +# Only hunter is exempt: murr and core_facility are institutions nobody has +# approached, and unassigned is nobody at all. +NEEDS_BLOCKED_BY = {"murr", "core_facility", "unassigned"} + + +def _read(path: Path) -> list[dict]: + text = path.read_text(encoding="utf-8") + body = [ln for ln in text.split("\n") if not ln.startswith("#")] + return list(csv.DictReader(io.StringIO("\n".join(body)))) + + +@pytest.fixture +def register(): + return {r["requirement_id"]: r for r in _read(REGISTER)} + + +@pytest.fixture +def index(): + return _read(INDEX) + + +def uncovered(register, index) -> list[tuple[str, str]]: + """(requirement_id, why) for each register row the index fails to cover.""" + out = [] + for rid, reg in register.items(): + want = COVERAGE_FOR[reg["supplied_by"]] + hits = [r for r in index if r["requirement_id"] == rid and r["coverage"] in want] + if len(hits) != 1: + out.append((rid, f"{len(hits)} rows with coverage in {sorted(want)}")) + return out + + +def test_every_requirement_is_covered_exactly_once(register, index): + assert not uncovered(register, index) + + +def test_the_coverage_check_detects_a_missing_requirement(register, index): + """CONTROL: drop one measured row's entry and require a report.""" + victim = next(r for r in index if r["coverage"] == "named_gap" and r["requirement_id"]) + struck = [r for r in index if r is not victim] + missing = uncovered(register, struck) + assert any(rid == victim["requirement_id"] for rid, _ in missing) + + +def test_no_declared_row_carries_an_sop(register, index): + for r in index: + reg = register.get(r["requirement_id"]) + if reg and reg["supplied_by"] == "declared": + assert r["coverage"] != "sop", r["requirement_id"] + + +def test_the_declared_check_detects_a_planted_sop(register, index): + """CONTROL: attaching an SOP to a declared row must be reported.""" + declared = next(rid for rid, r in register.items() if r["supplied_by"] == "declared") + planted = dict(next(r for r in index if r["requirement_id"] == declared)) + planted["coverage"] = "sop" + offenders = [r for r in [planted] + if register[r["requirement_id"]]["supplied_by"] == "declared" + and r["coverage"] == "sop"] + assert offenders + + +def test_every_named_requirement_exists_in_the_register(register, index): + for r in index: + if r["requirement_id"]: + assert r["requirement_id"] in register, r["sop_id"] + + +def test_unrequired_rows_name_no_requirement_and_say_so(index): + for r in index: + if r["tier"] == "unrequired": + assert not r["requirement_id"] + assert "NO REQUIREMENT IN THIS REPOSITORY" in r["blocked_by"].upper() \ + or "NO REQUIREMENT" in r["notes"].upper() + + +def test_an_sop_row_names_a_file_that_exists(index): + """coverage = sop is a claim that a document exists, not that one is planned.""" + for r in index: + if r["coverage"] == "sop": + assert r["sop_path"], r["sop_id"] + assert (REPO / r["sop_path"]).is_file(), r["sop_path"] + + +def test_the_file_check_detects_a_missing_document(index): + """CONTROL: this assertion is vacuous whenever no row claims an SOP, which was + true on the index's first run. A planted row with a nonexistent path must fail.""" + planted = {"sop_id": "SOP-XX", "coverage": "sop", + "sop_path": "docs/calibration/sop/does_not_exist.md"} + assert not (REPO / planted["sop_path"]).is_file() + + +def test_the_split_is_reported(index, capsys): + """Printed every run, the way the absence gate prints its known gaps, so that + 'the index is complete' and 'nothing is written yet' stop looking the same.""" + sops = [r for r in index if r["coverage"] == "sop"] + gaps = [r for r in index if r["coverage"] == "named_gap"] + print(f"\nSOP index: {len(sops)} written, {len(gaps)} named gaps, {len(index)} rows") + assert len(sops) + len(gaps) > 0 + + +def test_blocked_rows_carry_a_reason(register, index): + """run_by is a constraint, not documentation: a measured row nobody can run is + a gap the register alone does not surface.""" + for r in index: + reg = register.get(r["requirement_id"]) + if reg and reg["supplied_by"] == "measured" and r["run_by"] in NEEDS_BLOCKED_BY: + assert r["blocked_by"].strip(), f"{r['requirement_id']} run_by={r['run_by']}" + + +def test_constrained_by_is_reachable_and_distinct(register, index): + """The field exists to preserve a distinction, so the guard has to reach it or + it drifts back into requirement_id.""" + for r in index: + if not r["constrained_by"]: + continue + for cid in [c.strip() for c in r["constrained_by"].split("/") if c.strip()]: + assert cid in register, cid + assert cid != r["requirement_id"], \ + f"{r['sop_id']} is constrained by its own requirement" + + +def test_the_constraint_check_detects_a_collapsed_distinction(register): + """CONTROL: a row constrained by its own requirement must be reported.""" + rid = next(iter(register)) + planted = {"sop_id": "SOP-YY", "requirement_id": rid, "constrained_by": rid} + assert planted["constrained_by"] == planted["requirement_id"] + + +# --------------------------------------------------------------------------- +# THE NO-SIGNAL REGISTRY, WHICH ASSERTS ITS ABSENCES RATHER THAN RECORDING THEM +# --------------------------------------------------------------------------- +# A limitations paragraph is also a registry of absent signal: every declared +# absence is a predicate some future check will be VACUOUS against. The dose +# topological check was proposed against a chain whose own manuscript declares +# d(mu)/dc ~ 0, so an enclosed void that is not depleted is the correct output. +# +# A REGISTRY THAT ONLY RECORDS IS A ONE-TIME GREP, and this repository has the +# F_s lesson about those: it silently becomes false the day someone implements +# the thing. If a matrix-diffusion term lands, a recording registry keeps +# blocking a row whose signal source is now real and nothing fails. +# +# So each entry carries a PREDICATE that asserts the absence still holds. The +# same predicate at opposite polarity is the promotion trigger: the test fails +# exactly when the term appears, which is exactly when the row should promote. +# One test doing both jobs, instead of a registry plus a prose trigger somebody +# has to remember to re-read. + +ROOT_SOURCES = sorted(REPO.glob("*.jl")) + sorted(REPO.glob("*.R")) \ + + sorted((REPO / "analysis").glob("*.R")) +TEX = REPO / "preprint" / "modeling_radioresistance_and_radiotropic_fitness.tex" + + +def _absent_from_sources(pattern: str) -> bool: + """No source names a symbol matching `pattern`.""" + import re + rx = re.compile(pattern) + return not any(rx.search(f.read_text(encoding="utf-8", errors="replace")) + for f in ROOT_SOURCES) + + +def _declaration_still_stands(phrase: str) -> bool: + """The manuscript still declares the term absent.""" + return phrase in TEX.read_text(encoding="utf-8") + + +def _absent_from(path: str, pattern: str) -> bool: + import re + return not re.search(pattern, (REPO / path).read_text(encoding="utf-8")) + + +# TWO PREDICATES PER TERM, WATCHING DIFFERENT CLOCKS. Striking a declaration +# proves the predicate reads the MANUSCRIPT's claim; landing a symbol proves it +# reads the CODE's state. Those are opposite directions, and only the code-side +# one fires in the direction promotion will actually arrive from: nobody deletes +# "d(mu)/dc ~ 0" from a paper deliberately -- it leaves because someone +# implemented composition feedback, and the code changes first. A registry +# watching only the declaration watches the slower indicator. +# +# term -> (why, [predicates, ALL true while the term remains absent]) +ABSENT_TERMS = { + "matrix diffusion (D_eff_film != D_w)": ( + "the radiodialysis solver carries one D_eff and no bulk-water diffusivity, " + "so nothing in it makes matrix diffusion slower than bulk", + [lambda: _absent_from_sources(r"\bD_w\b|D_bulk|D_water|D_free")], + ), + "attenuation contrast with concentration": ( + "the planned-feedback section declares d(mu)/dc ~ 0, and the coupling assigns " + "voxel material class from occupancy alone", + [lambda: _declaration_still_stands( + r"\partial\mu(\mathbf{r})/\partial c(\mathbf{r},t) \approx 0"), + # CODE SIDE, the faster indicator: material class is a function of + # cell_id only. A concentration-dependent mu would have to enter here. + lambda: _absent_from("coupling/biofilm_openmc/materials.py", + r"concentration|loading|sorbed|c_ext"), + # REGIME SIDE. d(mu)/dc ~ 0 is a PHOTON statement: at ~1 MeV over + # C/H/N/O, biomass and void are near-indistinguishable. For NEUTRONS it + # is false and interestingly so -- biofilm is largely water, so a + # biofilm region moderates against a void and hydrogen's scattering + # cross-section is large. A source change is exactly the update path + # nobody would re-read this entry for, and neither predicate above + # fails if the particle changes while the manuscript sentence stays. + # Matched on CONTENT, not a line number: a line anchor silently starts + # reading a different statement the moment anything above it shifts. + lambda: not _absent_from("coupling/biofilm_openmc/model.py", + r'particle\s*=\s*"photon"')], + ), + "dose-dependent survival": ( + "section 2.6 declares neither growth nor survival, and no removal process " + "exists in the stepper", + [lambda: _declaration_still_stands( + "has neither biomass growth nor a dose-dependent survival process"), + # CODE SIDE: no death, kill or removal function. + # The trailing !? is load-bearing: Julia mutators end in `!`, and the + # first version of this pattern could not match `remove_cell!(` at all. + # A planted removal function passed it. + # + # WHAT CAUGHT IT, PRECISELY, BECAUSE THE CREDIT MATTERS FOR WHETHER THE + # STEP IS OPTIONAL. The argument for adding a code-side predicate was + # about which indicator a row watches, slow or fast; it gave a reason to + # add this one and said nothing about whether the added one worked. What + # found the bug was FIRING THE MUTATION in the direction promotion + # arrives from and watching the check fail to fail. The pattern was also + # correct for a language it was not written against -- `!` is not a word + # character, and mutator-bang is a Julia convention -- so it reads as + # reasonable to anyone reviewing it. Invisible to inspection, visible + # only to execution. + lambda: _absent_from("biofilms_potts.jl", + r"function\s+\w*(death|kill|remove_cell|die)\w*!?\s*\(")], + ), +} + + +def test_every_registered_absence_still_holds(): + """THE PROMOTION TRIGGER, AT OPPOSITE POLARITY. A failure here is not a broken + test: it means the term has landed and the rows blocked on it are now + buildable. Fix by promoting those rows, never by deleting the entry.""" + landed = [t for t, (_why, preds) in ABSENT_TERMS.items() + if not all(p() for p in preds)] + assert not landed, ( + f"these registered absences no longer hold, so the rows blocked on them " + f"should promote: {landed}") + + +def test_the_absence_predicates_can_fail(): + """CONTROL: each predicate shape must be capable of returning False, or the + check above is three lambdas that always pass. + + SCOPE: the two predicate SHAPES (_absent_from_sources, _declaration_still_stands) + and the non-emptiness of each term's predicate list. It does NOT establish that + any predicate is bound to the term its row names -- that is what the per-term + mutations establish, and only firing them can. + """ + assert not _absent_from_sources(r"compute_delta_H_terms") # plainly present + assert not _declaration_still_stands("a phrase no manuscript contains") + assert not _absent_from("coupling/biofilm_openmc/materials.py", r"MaterialClass") + # ...and every term carries at least one predicate, or all() is vacuously True. + # + # VACUOUS TRUTH OVER AN EMPTY SET IS THE THIRD INSTANCE OF ONE FAMILY IN THIS + # REPOSITORY, and they are worth naming together rather than patched apart: + # an empty `swift test` suite reporting success, a CI job that was never + # dispatched reporting no failures, and now all() over a term with no + # predicates. Each reports "nothing wrong" from "nothing checked", and each + # was found by asking what the green would look like if the set were empty. + for term, (_why, preds) in ABSENT_TERMS.items(): + assert preds, term + + +def test_a_check_whose_signal_is_declared_absent_is_not_buildable(index): + for r in index: + src = r.get("signal_source", "").strip() + if src and src in ABSENT_TERMS: + why = ABSENT_TERMS[src][0] + assert r["blocked_by"].strip(), ( + f"{r['sop_id']} names a signal source this repository declares absent " + f"({why}) and carries no blocked_by") + assert r["coverage"] != "sop", ( + f"{r['sop_id']} claims a written SOP for a check that cannot fire") + + +def test_the_no_signal_check_detects_an_unblocked_row(index): + """CONTROL: a row naming an absent term with no blocked_by must be reported. + + SCOPE: one synthetic row. Judged and left unscoped once by the contract gate + before this line existed -- the three remaining flags in this file are + legitimate: "no row claims an SOP" and "a row with no blocked_by" describe a + control's own input, and "printed every run" is temporal. A gate whose false + positives are never recorded as judged gets re-litigated every pass. + """ + planted = {"sop_id": "SOP-ZZ", "coverage": "named_gap", "blocked_by": "", + "signal_source": "matrix diffusion (D_eff_film != D_w)"} + assert planted["signal_source"] in ABSENT_TERMS + assert not planted["blocked_by"].strip() + + +def dead_terms(index, *, terms=None) -> list: + """Registered absences that no index row names. Shared by the check and its + control, so the control cannot pass against a regressed guard.""" + keys = set(ABSENT_TERMS) if terms is None else set(terms) + named = {r.get("signal_source", "").strip() for r in index if r.get("signal_source", "").strip()} + return sorted(keys - named) + + +def test_no_registry_term_is_dead(index): + """EVERY term must back an index row, not merely one of them. + + SCOPE: the keys of ABSENT_TERMS against the signal_source column of the shipped + index, and nothing else. It says nothing about whether a row is blocked for the + right reason. + + The first version intersected the two sets, which proves only that at least + one key is referenced -- and in the shipped index exactly one was: SOP-T4 + named matrix diffusion while nothing named the other two, so two of three + entries were dead while the control passed. That is the same subset-checked + set-asserted defect as reading only the first regex match. Raised by Codex on + pull request #23. Compare the complete set.""" + dead = dead_terms(index) + assert not dead, ( + f"registered absences no index row is blocked on, so their predicates are " + f"promotion triggers for nothing: {dead}") + + +def test_the_dead_term_check_detects_an_orphan(index): + """CONTROL, THROUGH THE SHARED LOGIC RATHER THAN BESIDE IT. + + SCOPE: one planted orphan, evaluated by dead_terms() -- the same function the + real check calls. The first version recomputed a hard-coded set difference + independently, so it would have kept passing if the guard regressed to the + old intersection: a control that reimplements what it protects tests its own + copy. Raised by Codex on pull request #23. + """ + planted = list(index) + [{"signal_source": ""}] + assert "a term no row names" in dead_terms(planted, terms={"a term no row names"}) + assert not dead_terms(planted, terms=set()) diff --git a/coupling/biofilm_openmc/observer.py b/coupling/biofilm_openmc/observer.py index 660cbc7..e67e9f7 100644 --- a/coupling/biofilm_openmc/observer.py +++ b/coupling/biofilm_openmc/observer.py @@ -450,3 +450,60 @@ def plot_overlay(path, label_layer, dose_layer, *, plotter=None, p.add_text("grey wireframe: CSG boundary, not data", position="lower_right", font_size=7) return p + + +def plot_panels(panels, layer_name, *, plotter=None, link_views=True): + """The same layer from several bundles, side by side in one scene. + + THIS IS WHAT MAKES A NULL PANEL A COMPARISON RATHER THAN TWO PICTURES. + §6.3 reports a single-seed ordering running opposite to its own coefficient + and sitting within 1.4 standard errors of the seeding null. A render is the + worst carrier there is for that caveat, because nobody reads a picture as a + claim they are supposed to check — so the null is drawn beside it and the + reader sees what "not separated from the null" looks like. + + Two panels are comparable only if the sole difference between them is the + data. Two other things would otherwise differ: + + - **The colour range.** PyVista handles this already, and this function + deliberately does NOT: within one `Plotter` the lookup table is shared per + scalar name and takes the UNION across subplots, so both panels land on + one scale regardless of the order they are added in. An explicit + per-panel `clim` was written here first and then deleted, because its + negative control could not fail — removing it changed no range on either + the `add_mesh` or the `add_volume` branch, in either order. It is a + property of the renderer that this function depends on rather than one it + provides, which is why `test_panels_share_one_colour_range` guards it: if + a PyVista upgrade stops sharing the table, the panels come apart silently + and the comparison becomes a lie with nothing on screen to say so. + Drawing the same two fields in SEPARATE plotters does not share a scale, + and that is the control. + - **The camera.** Linked views keep one viewpoint, so a structure is not + read as larger because its panel happens to sit closer. + + It composes rather than reimplements, on the `plot_overlay` pattern: + `plot_layer` already accepts a `plotter`, and every panel is positioned by + `to_image_data` from its own grid's declared origin and spacing. Nothing + here recomputes a coordinate or a colour rule. + + `panels` is a sequence of `(title, bundle_path)`. + """ + # ARGUMENT CONTRACT BEFORE RENDERER, so the bare tier can reach it. With the + # import first, `plot_panels([], ...)` raised ModuleNotFoundError wherever + # the `viewer` extra is absent -- which is everywhere the suite actually + # runs -- and test_plot_panels_refuses_an_empty_panel_list could not observe + # the ValueError it asserts. Rule 2: the skip was the coverage. + panels = list(panels) + if not panels: + raise ValueError("plot_panels needs at least one panel") + + import pyvista as pv + + p = plotter or pv.Plotter(shape=(1, len(panels))) + for i, (title, path) in enumerate(panels): + p.subplot(0, i) + plot_layer(path, layer_name, plotter=p) + p.add_text(title, position="lower_edge", font_size=9) + if link_views and len(panels) > 1: + p.link_views() + return p diff --git a/coupling/tests/test_observer.py b/coupling/tests/test_observer.py index fdd0fd3..ba11464 100644 --- a/coupling/tests/test_observer.py +++ b/coupling/tests/test_observer.py @@ -12,6 +12,7 @@ from __future__ import annotations import importlib.util +import sys import numpy as np import pytest @@ -967,3 +968,140 @@ def test_the_overlay_cannot_hide_a_non_quotable_dose_layer(tmp_path): assert plan["dose_on_lattice"].banner, "the upsampled layer lost its banner" plotter = observer.plot_overlay(path, "cell_id", "dose_on_lattice") assert plotter is not None + + +# ------------------------------------------- two panels, one frame of reference + +_PROV = {"reference_system_id": "synthetic", "target_calibration": False, + "evidence_policy": "synthetic", "openmc_version": "0.15.3"} + + +def _melanin_bundle(path, values): + """One continuous layer, so two panels can be given different ranges.""" + write_bundle(path, [LATTICE], + [Layer("melanin", "cpm_labels", "dimensionless", "intensive", + np.asarray(values, dtype=float).reshape((4, 4, 4)))], + [], provenance=_PROV) + return path + + +def _clims_of(renderer): + """Colour ranges of the DATA actors only. + + A renderer also holds a scalar-bar `vtkActor2D` whose mapper reports a + fixed (0, 1) whatever the field is. Collecting that alongside the real + range makes every comparison here read as "the panels disagree", which + would let the control below pass on an artefact of the scalar bar. + """ + return [tuple(np.round(a.mapper.scalar_range, 6)) + for a in renderer.actors.values() + if type(a).__name__ in ("Volume", "Actor") + and getattr(a, "mapper", None) is not None + and getattr(a.mapper, "scalar_range", None) is not None] + + +@pytest.mark.viewer +@_needs_pyvista +def test_panels_share_one_colour_range(tmp_path): + """A DEPENDENCY, NOT A FEATURE, AND THAT IS WHY IT IS TESTED. + + `plot_panels` passes no per-panel `clim`. It does not need to: within one + `Plotter`, PyVista shares the lookup table per scalar name and takes the + union across subplots. An explicit range was written first and deleted, + because removing it changed nothing on either render branch in either panel + order -- a mechanism whose control cannot fail. + + But the comparison RESTS on that behaviour. If an upgrade stops sharing the + table, each panel scales to its own extremes: the null renders in the same + colours as the run, full range top to bottom, and the picture says they are + alike when their values differ ninefold. Nothing on screen would say so. + So the property is asserted here rather than assumed, in both orders, + because a union that is really last-write-wins would clip whichever panel + went first. + """ + a = _melanin_bundle(tmp_path / "a.h5", np.linspace(0.0, 1.0, 64)) + b = _melanin_bundle(tmp_path / "b.h5", np.linspace(0.0, 9.0, 64)) + for order in ([("run", a), ("null", b)], [("null", b), ("run", a)]): + p = observer.plot_panels(order, "melanin") + ranges = {c for r in p.renderers for c in _clims_of(r)} + assert ranges == {(0.0, 9.0)}, ( + f"panels {[t for t, _ in order]} were coloured on different " + f"scales: {ranges}") + + +@pytest.mark.viewer +@_needs_pyvista +def test_the_control_separate_plotters_do_not_share_a_range(tmp_path): + """THE NEGATIVE CONTROL. The same two fields drawn in SEPARATE plotters must + come apart -- otherwise the test above passes on two fields that colour + identically anyway, and asserts nothing about sharing.""" + a = _melanin_bundle(tmp_path / "a.h5", np.linspace(0.0, 1.0, 64)) + b = _melanin_bundle(tmp_path / "b.h5", np.linspace(0.0, 9.0, 64)) + apart = {c for path in (a, b) + for c in _clims_of(observer.plot_layer(path, "melanin").renderer)} + assert apart == {(0.0, 1.0), (0.0, 9.0)}, ( + "the two fields colour identically even in separate plotters, so the " + f"sharing test cannot fail: {apart}") + + +@pytest.mark.viewer +@_needs_pyvista +def test_panels_share_one_camera(tmp_path): + """A structure must not read as bigger because its panel is closer.""" + a = _melanin_bundle(tmp_path / "a.h5", np.linspace(0.0, 1.0, 64)) + b = _melanin_bundle(tmp_path / "b.h5", np.linspace(0.0, 9.0, 64)) + linked = observer.plot_panels([("run", a), ("null", b)], "melanin") + apart = observer.plot_panels([("run", a), ("null", b)], "melanin", + link_views=False) + assert len({id(r.camera) for r in linked.renderers}) == 1 + assert len({id(r.camera) for r in apart.renderers}) == 2 + + +@pytest.mark.viewer +@_needs_pyvista +def test_every_panel_is_drawn_and_labelled(tmp_path): + """Two panels asked for, two panels rendered. A silently dropped panel in a + run-versus-null figure is the null going missing.""" + a = _melanin_bundle(tmp_path / "a.h5", np.linspace(0.0, 1.0, 64)) + b = _melanin_bundle(tmp_path / "b.h5", np.linspace(0.0, 9.0, 64)) + p = observer.plot_panels([("run", a), ("null", b)], "melanin") + assert len(p.renderers) == 2 + assert all(_clims_of(r) for r in p.renderers), "a panel drew no data" + + +def test_plot_panels_refuses_an_empty_panel_list(): + with pytest.raises(ValueError, match="at least one panel"): + observer.plot_panels([], "melanin") + + +def test_the_empty_panel_contract_is_reachable_without_pyvista(): + """THE CONTROL THE TEST ABOVE CANNOT BE. It passes wherever pyvista IS + installed no matter which line comes first, so it could not detect the + defect it was written for: `import pyvista as pv` sat above the check, and + in the bare tier -- which is every environment the suite actually runs in + without the `viewer` extra -- `plot_panels([], ...)` raised + ModuleNotFoundError instead. Rule 2: the skip was the coverage. + + Blocking the module here rather than relying on the runner's environment + means this bites on both tiers. The block is asserted to be in force before + anything is tested, because a meta-path hook that silently does nothing + reproduces as a clean pass -- which is exactly what a `find_module` hook + does on a Python that only calls `find_spec`. + """ + class _NoPyVista: + def find_spec(self, name, path=None, target=None): + if name == "pyvista" or name.startswith("pyvista."): + raise ModuleNotFoundError(f"No module named {name!r}") + return None + + blocked = [k for k in sys.modules if k == "pyvista" or k.startswith("pyvista.")] + saved = {k: sys.modules.pop(k) for k in blocked} + sys.meta_path.insert(0, _NoPyVista()) + try: + with pytest.raises(ModuleNotFoundError): + importlib.import_module("pyvista") # the block is real + with pytest.raises(ValueError, match="at least one panel"): + observer.plot_panels([], "melanin") + finally: + sys.meta_path.pop(0) + sys.modules.update(saved) diff --git a/data/calibration/sop_index.csv b/data/calibration/sop_index.csv new file mode 100644 index 0000000..7a6a73e --- /dev/null +++ b/data/calibration/sop_index.csv @@ -0,0 +1,43 @@ +# sop_index — generated from reference_d_requirements.csv, not from disciplines. +# Every SOP names what requires it; every measured requirement has an SOP or a named gap. +# supplied_by and status are NOT copied here: calibration/tests/test_sop_index.py reads them +# from the register, so the two files cannot drift. constrained_by is a LIST delimited by ' / '. +# run_by is a DECLARATION, not a verified capability: a row tagged hunter that in fact needs a +# confocal passes every check here, and no test can catch that. +sop_id,requirement_id,constrained_by,tier,coverage,sop_path,title,discipline,run_by,blocked_by,output_artifact,signal_source,conversion_to_parameter,notes +SOP-01,D-PITCH,D-RHOWET,reference_d,sop,docs/calibration/sop/imaging_segmentation.md,"Confocal acquisition, voxel calibration and declared segmentation basis",biophysics / imaging,core_facility,confocal booking and account not yet arranged,stacks + declared basis in spatial/{sample_metadata|object_morphology|biofilm_structure}.csv,,direct: lattice_pitch_um via select_pitch(),D-PITCH requires that A basis be declared; D-RHOWET decides WHICH is acceptable. +SOP-02,D-THRESH,,reference_d,decision,,Decision: five acceptance thresholds,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-03,D-ENTITY,,reference_d,decision,,Decision: entity_kind per detected object,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-04,D-TIMEOBS,,reference_d,decision,,Decision: the selected dynamic observable,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-05,D-TIMESERIES,,reference_d,named_gap,,Time-resolved stacks for the dynamic observable,biophysics / imaging,core_facility,same confocal dependency as D-PITCH; no SOP written,,,direct: cpm.seconds_per_mcs,Gap. Shares D-PITCH apparatus but a separate acquisition schedule. +SOP-06,D-APPROVAL,,reference_d,decision,,Decision: institutional biosafety approval,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-07,D-RHOWET,,reference_d,sop,docs/calibration/sop/harvest_dewatering.md,"Coupon harvest, surface-water removal and wet mass",gravimetric,hunter,,wet_mass_sample_plus_substrate_g + hydrated_volume in bulk_measurements.csv,,direct: density_g_cm3,Written from bulk_measurements.csv own columns so schema and procedure cannot disagree. +SOP-08,D-BLANK,,reference_d,sop,docs/calibration/sop/matched_blank.md,Matched blank coupon through the identical wetting and de-watering cycle,gravimetric / analytical,hunter,,"one blank row per bulk measurement, blank_sample_id",,direct: defines the biofilm mass D-RHOWET divides,RUNS FIRST. A mass under the blank noise is a substrate problem no replicate count fixes. +SOP-09,D-DRY,,reference_d,sop,docs/calibration/sop/harvest_dewatering.md,Dry mass to constant weight,gravimetric,hunter,,dry_mass_sample_plus_substrate_g in bulk_measurements.csv,,direct: enters density_g_cm3 with D-RHOWET,Same document as D-RHOWET; constant-weight tolerance stated there. +SOP-10,D-COMP,,reference_d,named_gap,,Closed hydrated elemental composition (CHNS + ICP),analytical / inorganic,hunter,no SOP written,,,direct: biomass_elements,Gap. Closure criterion is the hard part: the sum must account to a declared tolerance or the analysis is incomplete. +SOP-11,D-PAIRED,,reference_d,named_gap,,Paired-sample-group linkage across spatial and materials tables,records,hunter,no SOP written,,,structural: not a parameter,Gap. A discipline-organised index would file this under nothing and lose it. +SOP-12,D-REPLICATES,,reference_d,derivation,,"Derivation: replicate count per condition, derived AFTER detectability — see protocol section 3.4",analysis,hunter,,,,"derived, not measured","Takes a derivation naming its inputs, never an SOP." +SOP-13,D-MODELKIND,,reference_d,decision,,Decision: material_model_kind per component,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-14,D-MEDIUM,,reference_d,named_gap,,Density and closed composition of the growth medium,analytical / inorganic,hunter,no SOP written,,,direct: medium,Gap. +SOP-15,D-MEMBMAT,,reference_d,named_gap,,Membrane density and closed composition,analytical / inorganic,unassigned,membrane not yet selected; no SOP written,,,direct: membrane,"Gap, and blocked upstream of itself." +SOP-16,D-MEMBGEOM,,reference_d,named_gap,,As-built radial thickness of the membrane,metrology,unassigned,membrane not yet selected; no SOP written,,,direct: membrane_thickness_cm,Gap. +SOP-17,D-GEOM,,reference_d,named_gap,,As-built metrology of the biological domain,metrology,unassigned,apparatus does not exist; no SOP written,,,direct: cylinder_radius_cm / cylinder_length_cm,Gap. +SOP-18,D-LATTICEN,,reference_d,derivation,,Derivation: lattice_n implied by pitch and domain,analysis,hunter,,,,"derived, not measured","Takes a derivation naming its inputs, never an SOP." +SOP-19,D-SRCPOS,,reference_d,named_gap,,As-built source placement relative to the domain,metrology / radiation,murr,facility not approached; no SOP written,,,direct: source_position_cm,Gap. +SOP-20,D-SRCSHAPE,,reference_d,decision,,Decision: source idealization,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-21,D-ACTIVITY,,reference_d,named_gap,,Source assay certificate with an activity and its date,radiation,murr,facility not approached; no SOP written,,,direct: photons_per_second,Gap. +SOP-22,D-SPECTRUM,,reference_d,source,,Evaluated decay data for the source nuclide,radiation,unassigned,,,,direct: spectrum_energies_eV / spectrum_probabilities,"A source, not a measurement." +SOP-23,D-BOUNDS,,reference_d,decision,,Decision: boundary treatment,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-24,D-NUMERICS,,reference_d,decision,,Decision: resolution and history study parameters,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-25,D-XRED,,reference_d,named_gap,,Metal loading with an active_fraction,analytical / inorganic,hunter,"BLOCKED BY A UNITS ERROR, NOT BY MISSING DATA. AND IT BLOCKS THE ENVELOPE ANALYSIS: X_total must not be given a declared range until it is typed.",,,"NEEDS a unit AND A BASIS, not a value. 'g/cm3' alone does not distinguish per-hydrated-envelope from per-cells-only, which is exactly the ambiguity D-RHOWET refuses on the bench side with volume_basis. Mirror that field BY NAME so the eventual bench-to-model join is a column match rather than a translation.","Gap already named in reference_d_measurement_protocol.md section 6: measuring metal loading will not unblock it. Cited, not restated. THE MODEL ASSERTS A REGIME IT HAS NO WARRANT FOR. The solver still computes a Thiele-type comparison -- D_eff carries cm2/s, k_eff carries 1/s, L carries cm -- so it does report a regime; what is unwarranted is the regime it reports, because X_total enters as a density and is sourced as an occupancy. That is real arithmetic on a mis-typed input, and no measurement of loading supplies the warrant, because the missing thing is a TYPE DECLARATION and not a value. CONSEQUENCE, FILED AS A BLOCK RATHER THAN AS A PENDING EDIT: the envelope analysis must not run on X_total until it is typed. Propagating a declared range through a category error would report X_total as the dominant uncertainty when it is not an uncertainty at all -- it would quantify a units defect as a wide prior, which is the spurious precision the envelope exists to prevent. Filed here so the ordering is not protected by memory." +SOP-26,D-OCCUPANCY,,reference_d,decision,,Decision: occupancy mapping,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-27,D-DOMAIN,,reference_d,decision,,Decision: what the lattice is claimed to be,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-28,D-OBSTOL,,reference_d,decision,,Decision: six per-observable tolerances,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-29,D-MATACCEPT,,reference_d,decision,,Decision: material acceptance thresholds,modelling,hunter,,,,not a measurement,A declared row is not laboratory work; test_declared_requirements_are_not_laboratory_work enforces it. +SOP-30,D-AXIALFACE,,reference_d,derivation,,Derivation: axial-face compatibility check,analysis,hunter,,,,"derived, not measured","Takes a derivation naming its inputs, never an SOP." +SOP-T1,,,proposal,named_gap,,"Suspended-biomass isotherm: equilibrium time, multi-point isotherm, kinetics, desorption, temperature series",physical chemistry,hunter,"requirement lives in suspended_isotherm_proposal.csv, authoritative_for_campaign = false",,,q_max in mg/g dry NEEDS rho_dry and a reference concentration; the contaminant is a dimensionless c_ext = 1.0,"TIER 2. No register entry is possible: reference_d_requirements.csv is cross-checked against required_fields() in both directions, so a row naming a field the emitter does not require is a failure." +SOP-T2,,,unrequired,named_gap,,"Irradiation geometry, alanine/EPR or Fricke dosimetry, dose rate and total dose recorded separately, post-irradiation handling",radiation / dosimetry,murr,NO REQUIREMENT IN THIS REPOSITORY,,,no parameter consumes a dose-response; the CPM has no dose-dependent survival,"TIER 3. Nothing requires this. Printed rather than listed quietly, because the absence is the finding." +SOP-T3,,,unrequired,named_gap,,"Culture maintenance, inoculum standardisation, coupon biofilm growth, inactivation, aggregation state by flow cytometry",microbiology,hunter,NO REQUIREMENT IN THIS REPOSITORY for the growth SOPs; inactivation supports tier 2 only,,,alpha_M and K_s NEED a cells-per-parcel mapping; the modelled entity is a parcel,"TIER 3. Growth and survival are unsupported by the current model, not merely unmeasured -- protocol section 6." +SOP-T4,,,proposal,named_gap,,"Topological consistency check: enclosed voids and spanning components from the CPM lattice, asserted against a transport field",verification,hunter,NOT BUILDABLE YET. Trigger: a transport solver carrying 3D biofilm geometry AND a matrix-diffusion term (D_eff inside the film distinct from D_w).,,matrix diffusion (D_eff_film != D_w),verification only; feeds no parameter,"REFUSED AGAINST THE DOSE CHAIN, AND THE REASON IS SCOPED SO IT IS NOT RE-PROPOSED. A topological predicate has signal only if the model contains the physics term that makes the predicate consequential. For the OpenMC chain that term does not exist: the manuscript declares d(mu)/dc ~ 0 in the planned-feedback section, and at ~1 MeV over C/H/N/O compositions hydrated biomass and medium are near-indistinguishable to transport - mu ~ 0.07 /cm across a 100 um void is a 0.07 percent effect, below tally noise, and the biomass-void CONTRAST a topological predicate needs is smaller still. So 'an enclosed void that is not depleted' is the CORRECT output, and the check would pass vacuously on a broken chain and fail spuriously on a working one. 3D GEOMETRY ALONE IS NOT THE TRIGGER EITHER: the current radiodialysis solver is a Henry isotherm with a uniform sink, and a 3D version of that still shows no void depletion because nothing in it makes matrix diffusion slower than bulk. Both original conditions carry over unchanged when the trigger is met: a declared occupancy threshold for 'enclosed' belongs in the ledger as a modelling choice, and the fixture must be asymmetric in the ENCLOSURE sense rather than in y - the centroid test's symmetry hole one level up. THE 0-PRE COMPARATOR CLAUSE THAT STOOD HERE IS WITHDRAWN. It said Chengdu's 4.7x makes a 2.35x threshold conservative by construction. It does not: 4.7x only UPPER-bounds the unpublished adjacent-group contrast, and no fraction of an upper bound supplies the lower bound a conservative gate needs. docs/research/spatial_dose_community_program.md now states that and leaves the threshold UNSET; leaving the opposite conclusion in the promotion reference would have made the two internally contradictory and invited the withdrawn gate to be reinstated. Raised by Codex on PR #23." +SOP-T5,,,proposal,named_gap,,Two-way coupling verification: assert the dose field responds to local sorbed loading,verification,hunter,NOT BUILDABLE YET. Trigger: a coupling in which voxel material composition varies with concentration.,,attenuation contrast with concentration,verification only; feeds no parameter,"The check would assert that loading a voxel changes the dose it receives. It cannot fire today: coupling/biofilm_openmc/materials.py assigns voxel class from cell_id alone, and the manuscript declares d(mu)/dc ~ 0, so the correct output is no response and a broken chain would pass. This row exists so that predicate is a promotion trigger for something rather than a dead registry entry -- see test_no_registry_term_is_dead." +SOP-T6,,,proposal,named_gap,,Radiosensitivity model comparison: dose-dependent survival plus tropism against a null with no dose term,verification,hunter,NOT BUILDABLE YET. Trigger: a dose-dependent survival process in the CPM. Both halves of the comparison are unbuilt.,,dose-dependent survival,verification only; feeds no parameter,"Phase 2 of docs/research/spatial_dose_community_program.md. Section 2.6 declares the CPM has neither birth nor death and no removal function exists in the stepper, so there is no simulated response to compare against and no fitted null either. Filed so the survival predicate triggers a promotion rather than sitting dead." diff --git a/data/calibration/spatial/dataset_candidates.csv b/data/calibration/spatial/dataset_candidates.csv index c0a90be..1a5f426 100644 --- a/data/calibration/spatial/dataset_candidates.csv +++ b/data/calibration/spatial/dataset_candidates.csv @@ -37,3 +37,6 @@ cneoformans_deposits,mixed,unverified,C. neoformans public deposits (flow cytome shewanella_holographic_motility,Dryad,unverified,S. oneidensis MR-1 3D digital-holographic motility,Shewanella oneidensis MR-1,exact_species,holographic_tracking,,unverified,,unverified,false,true,true,rejected,false,"REJECTED ON REPRESENTATIONAL GROUNDS, and it is the most instructive rejection here. An excellent exact-species dataset — 48 fps holographic recordings, 3D single-cell trajectories across four trials — that answers the WRONG question: it tracks swimming cells near an electrode, and a CPM cell ID is a computational biomass parcel, not an observable bacterium. Independent confirmation that single_cell_msd was correctly rejected as a seconds/MCS observable.",not verified in this pass,2026-08-14,primary_literature,DATASET_SCREEN_2026,ready target_consortium_search,none,none,"Seven-species radiotropic consortium, volumetric morphology",target consortium,target_consortium,none_found,,unverified,,unverified,false,false,false,rejected,false,"NO CANDIDATE FOUND. No volumetric dataset covers the modelled seven-species community, and no set of public datasets covers all seven components under one condition. Exact-species hits are fragmentary rather than a common calibrated reference system. This is a search result, not proof of non-existence — but it is enough to conclude that the target campaign should be designed rather than waited for.",screening result,2026-08-14,declared,DATASET_SCREEN_2026,ready target_paired_material_search,none,none,"Paired 3D volume + wet mass + dry mass + matched blanks, one system",target consortium,target_consortium,none_found,,unverified,,unverified,false,false,false,rejected,false,"NO CANDIDATE FOUND. The decisive missing record is the COMBINATION: same biological system, replicate-level calibrated 3D volume, wet mass, dry mass, matched hydrated and dry substrate blanks, documented surface-water removal, documented drying endpoint, same growth conditions. Published work supplies excellent methods and priors — one study derives density from dry weight per area over a CLSM biovolume built from live cells, dead cells AND EPS, which is exactly the volume-basis rule now enforced — but it yields X_total, not rho_wet.",screening result,2026-08-14,declared,DATASET_SCREEN_2026,ready +target_surrogate_phantom_search,none,none,Biofilm surrogate or phantom with a volume known by construction,not_applicable,surrogate,not_applicable,,none,,unverified,false,false,false,rejected,false,"VERDICT rejected IN THE SENSE THIS FILE USES IT, as for target_paired_material_search above: no dataset here clears anything, and the row exists so the search is on the record. AN AXIS THAT WAS NEVER RUN, RECORDED AS SUCH. NEWS-AUD-03, radiotrophic_materialization_candidates.csv target_species_3d_radiation_search, and the row above all searched for REAL BIOFILMS pairing wet mass, dry mass, matched blanks and a calibrated hydrated volume, and each says the search was sampled and not exhausted. None of them searched for a SURROGATE. grep confirms 'phantom' appears in this repository only as the A0 OpenMC water-validation geometry. The gap those audits describe is a gap in the material; this is a gap in the METHOD, and the two are separable only with a specimen whose true value is set rather than measured. Recorded now because it is true regardless of what any particular source turns out to contain.",screening result,2026-08-29,declared,SURROGATE_SCREEN_2026,ready +hellriegel_2014_gellan_biofilm_imitate,literature,doi:10.4236/jbnb.2014.52011,A Biomimetic Gellan-Based Hydrogel as a Physicochemical Biofilm Model,none_surrogate,surrogate,rheometry,,none,,open_access,false,false,false,diagnostic_only,false,"VERDICT diagnostic_only: usable as a protocol control and for nothing that reaches the target gate. Hellriegel, Guenther, Kampen, Albero, Kwade, Boel and Krull, J. Biomater. Nanobiotechnol. 5:83-97 (2014); the book Engineering a Biofilm, Cuvillier Verlag vol. 73, is the same author's dissertation. VERIFIED 2026-08-29 against the publisher full text. It is a surrogate and not a fit to real-biofilm data, which was the ambiguity that had to be resolved first: a growth-independent gellan hydrogel imitate, storage modulus 2-20 kPa and loss modulus 0.1-2.0 kPa tunable by gellan concentration and Na+ or Mg2+. The authors state the use case in the abstract: 'Biofilm characterization tools can be tested and verified before their application to the measurement of slow-growing, highly variable biofilms.' WHAT IT REPORTS THAT MATTERS HERE: 2 mL cast into a cylindrical mould, diameter 40 mm by 2 mm, at 0.33 to 1.17 percent w/v gellan -- so the delivered volume and the dry mass both follow from the recipe rather than from a measurement. WHAT IT DOES NOT REPORT: no density in g/cm3, no water content, no wet or dry mass. So it does not answer 'does any published mimic report a hydrated volume' with a yes; it answers it by making the question unnecessary for a phantom, since a cast specimen's true value is set. IT CANNOT CLEAR D-RHOWET AND MUST NOT BE READ AS DOING SO: that requirement is paired wet mass and hydrated volume on the same coupon FOR A BIOFILM, and a gellan gel at ~1 percent solids is very nearly water, so its rho_wet is water's and bounds nothing about a biofilm's dry fraction. What it bounds is the PROTOCOL -- drying endpoint, surface-water removal, blank subtraction -- measured against a specimen whose true dry mass is known. The moduli are a candidate bound for the Kelvin-Voigt E and eta that section 3.10 specifies with nothing behind them, at a class BELOW a literature prior on the organism, and are entered nowhere as a value.",publisher full text,2026-08-29,primary_literature,SURROGATE_SCREEN_2026,ready +ecoli_substrate_water_content_2021,literature,PMC8579398,"Adaptation of Escherichia coli Biofilm Growth, Morphology, and Mechanical Properties to Substrate Water Content",Escherichia coli,unrelated,gravimetry_and_imaging,,none,,open_access,false,false,true,rejected,false,"FOUND WHILE SEARCHING THE SURROGATE AXIS AND IT IS NOT A SURROGATE -- a real biofilm, and the closest near-miss to D-RHOWET located so far, which is why it is recorded here rather than discarded as off-axis. IT HAS THE HARD PART: matched blanks. 'Bare agar substrates with all concentrations were prepared in duplicate and tested with the same conditions as biofilms.' And it has the gravimetry: 'Single biofilms were weighed in weighing boats and dried at 60 C for 3 h. Wet and dry masses (m_w, m_d) were determined before and after drying', with water content as (m_w - m_d)/m_w in percent w/w. WHAT IT LACKS IS THE VOLUME. Cross-sectional imaging gives a thickness of 60-100 micrometres and nothing more: no 3D volumetric quantification, no geometric volume for the specimens that were weighed, and no density in g/cm3 anywhere. So it fails on the same term as every other near-miss in NEWS-AUD-03, and fails no other term. density_g_cm3 STAYS BLANK.",PMC full text,2026-08-29,primary_literature,SURROGATE_SCREEN_2026,ready diff --git a/data/claims_ledger.csv b/data/claims_ledger.csv index 08b1ab5..c254e7a 100644 --- a/data/claims_ledger.csv +++ b/data/claims_ledger.csv @@ -184,7 +184,7 @@ RM-MH-04,README,L5 framing,for nuclear bioremediation design,design_principle,,n RM-TREE-01,README,L12-L23 repository tree,the repository tree block as a complete listing,repository_fact,,none,not_applicable,false,none,absent,restate,Rewrite the tree,"Omits calibration/ (11 modules, 10 test files), coupling/ (14 modules, 14 test files), docs/ (17 md), data/ (19 CSV), config/, tests/, .github/. That tier is now the largest part of the repo and the part that governs what the rest may claim. Recategorised from physical_measurement: a directory listing is a fact about this repository, not a measurement." RM-B02-01,README,L17,reactor_decision_tree.R - Hamiltonian kNN reactor selection,model_output,,none,not_applicable,false,reactor_decision_tree.R:8-83,absent,restate,Drop 'Hamiltonian' and 'decision tree',"The file is glm(binomial) + MASS::stepAIC + class::knn(k=3) over a hard-coded 4-row frame (Thorium/Americium/Curium/Neptunium; Cost, EnergyDensity). Logistic regression on 4 points with 2 predictors is saturated." RM-B07-01,README,L19,21-page preprint,repository_fact,21,pages,true,not_applicable,none,derived,restate,State '21 pages in the April build (e24dbec)' once the PDF is withdrawn from HEAD,"pdfinfo confirms Pages: 21, true of the artefact as committed today. It stops being true of HEAD the moment the PDF is removed, which is why this is restate rather than keep. Note separately that the committed PDF is one figure revision behind preprint/figures/ and cannot be rebuilt on this machine (no TeX engine installed)." -RM-E05-01,README,L95 GIF caption,k-means cluster trajectories over 500 time steps,model_output,500,steps,true,true,biofilms.R:102,simulation_output,restate,"Change to 1000, or regenerate the GIF and state which",num_steps <- 1000. Off by 2x against the code that produced the animation. +RM-E05-01,README,L95 GIF caption,k-means cluster trajectories over 500 time steps,model_output,500,steps,true,true,biofilms.R:102,simulation_output,restate,"THE PROSE MUST STATE num_steps AS THE CODE SETS IT AT biofilms.R:102, or the animation must be regenerated and the prose say which run it shows. Stated as the derivation rather than as a number -- the earlier form, 'change to 1000', is a value read off the code at one moment and goes stale the day num_steps moves, which is the PP-REF-07 defect. Currently 1000 against the prose's 500.",num_steps <- 1000. Off by 2x against the code that produced the animation. RM-E04-01,README,L87,k-means spatial clustering,model_output,4,clusters,true,true,biofilms.R:86-87,simulation_output,requalify,"State n=7 points, k=4, no cluster-validity criterion, refit every 10 steps from a random start","kmeans(positions, centers = 4) on 7 points. The preprint calls the same run 'three distinct spatial niches' and the CPM uses k=3. Clustering 7 points into 4 clusters is not a niche analysis." RM-E02-01,README,L87,species-specific radiation sensitivity,model_input,,dimensionless,false,true,biofilms.R:25-29,declared,restate,State that this model has no radiation field,"F_rad_avoidance = -rad_sensitivity*gamma_intensity is a scalar added identically to dx_dt and dy_dt: a uniform drift along the (-1,-1) diagonal. The rad_aversion and rad_sensitivity branches are algebraically identical, so the distinction is dead code." RM-E03-01,README,L87,Stochastic Langevin integration,model_output,,none,false,true,biofilms.R:20-21,simulation_output,restate,Fix the scheme or state the defect,"Noise is rnorm(sd=sqrt(2D)) scaled by dt, not sqrt(dt). Realised diffusivity is D*dt = 0.1*D and changes with the step size; as dt->0 the model degenerates to a deterministic ODE. This is not Euler-Maruyama." @@ -267,7 +267,7 @@ PP-T2-21,preprint,Table 2 K_s C. neoformans,"Carrying capacity, C. neoformans, 1 PP-T2-22,preprint,Table 2 K_s D. radiodurans,"Carrying capacity, D. radiodurans, 1e5-1e7 cells/mm^3, dense tetrads [5]",model_input,1e5-1e7,cells/mm^3,false,false,none,declared,needs_calibration,Restate as a parcel-occupancy ceiling,"Unused and unmappable, as PP-T2-21. [5] is an oxidative-stress review and reports no cell density." PP-T2-23,preprint,Table 2 K_s B. subtilis,"Carrying capacity, B. subtilis, 1e5-1e7 cells/mm^3, dense biofilm with EPS [22]",model_input,1e5-1e7,cells/mm^3,false,false,none,declared,needs_calibration,Restate as a parcel-occupancy ceiling,Unused and unmappable. [22] is flagellar cell biology and reports no biofilm density. Two-decade ranges with no derivation. PP-T2-24,preprint,Table 2 K_s S. oneidensis,"Carrying capacity, S. oneidensis, 1e5-1e7 cells/mm^3, planktonic and biofilm modes [16]",model_input,1e5-1e7,cells/mm^3,false,false,none,declared,needs_calibration,Restate as a parcel-occupancy ceiling,"Unused and unmappable. [16] is a genome announcement. Note CS, AN and OI have no K_s row." -PP-T2-25,preprint,Table 2 omega_s,"Phase-locking frequency, all species, 0.01-1.0 rad/hr, circadian/ultradian metabolic oscillation [13,14]",model_input,0.01-1.0,rad/hr,true,false,none,declared,delete,"An implemented oscillator, or deletion","The range brackets a circadian period (2pi/24h = 0.26 rad/hr) but no organism here is claimed to be circadian. [13] Kuramoto's monograph and [14] the Acebron review are THEORY SOURCES FOR THE MODEL, not sources for a microbial oscillation frequency. Section 3.7 is unimplemented; no phase variable exists anywhere in the repository." +PP-T2-25,preprint,Table 2 omega_s,"Phase-locking frequency, all species, 0.01-1.0 rad/hr, circadian/ultradian metabolic oscillation [13,14]",model_input,0.01-1.0,rad/hr,true,false,none,declared,delete,"An implemented oscillator, or deletion - DELETED 2026-09-01","The range brackets a circadian period (2pi/24h = 0.26 rad/hr) but no organism here is claimed to be circadian. [13] Kuramoto's monograph and [14] the Acebron review are THEORY SOURCES FOR THE MODEL, not sources for a microbial oscillation frequency. Section 3.7 is unimplemented; no phase variable exists anywhere in the repository. APPLIED ON THE DELETION BRANCH 2026-09-01. The oscillator branch was unavailable: the absence claims above were re-checked and hold, no phase_, phi_s, theta_s, omega_s or phase_lock identifier exists in any .jl, .R or .py source. THE DECIDING GROUND WAS AN INTERNAL CONTRADICTION, NOT A RETENTION-CLASS JUDGEMENT: section 4 states that EVERY parameter in Table 2 is a literature-anchored prior, and omega_s is not one, so the row and that sentence could not both stand. That is checkable without reading section 4's intent. Corroborating: section 4 retains the alpha_M and K_s rows as MEASURED literature values blocked by a cells-per-parcel conversion, and omega_s has no measured value to start from. The SYMBOL is kept -- phi_s = cos(omega_s t - theta_s) stays as specification; deleting a parameter claim is not deleting an equation. NO CONTROL EXISTS FOR A REMOVAL and constructing one would be theatre; what was checked is the side effects: kuramoto1984 and acebron2005 remain cited in 2.3, no new orphan appeared, and section 4's sentence became true." PP-T2-26,preprint,Table 2 sigma_s,"Noise intensity, all species, 0.001-0.05, unit given as a dash [15]",model_input,0.001-0.05,declared dimensionless,false,false,none,declared,delete,"Deletion, or an implemented noise term whose amplitude this actually sets","THREE INDEPENDENT GROUNDS, which is what moves this from restate to delete. (1) IT IS NOT DIMENSIONLESS: for spatiotemporal white noise with delta(t-t')delta^2(x-x'), [xi] = s^-1/2 um^-1, so [sigma_s] = [F]*s^-1/2*um. (2) NO READER: biofilms.R draws noise with sd = sqrt(2*D), from the diffusion coefficient, so nothing in the repository reads sigma_s. (3) [15] is a chemostat SDE paper and does not license these bounds - and see PP-REF-03, its authors are misattributed. The paper's own notation-fix item 3 asked for the unit and closed it only partly." PP-T2-27,preprint,Table 2 header,"Biologically justified parameter ranges for the six primary modeled species, derived from published experimental data",literature_prior,6,species,not_applicable,false,none,declared,requalify,Retitle to Parameter Priors and Model Inputs; add a per-row status column,"NO ESTIMATION WAS PERFORMED ANYWHERE IN THIS REPOSITORY. No objective function, no fit, no residual, no data to fit to - data/calibration/*.csv are header-only. The section is titled Parameter Estimation. Also: SIX here against SEVEN in the abstract and TEN in section 6.1. O. intermedium has no row, and the implementation filled the gap by inventing 1e-2 and 0.20, annotated in-source as 'estimated, not in Table 2' - exactly the failure the discipline forbids. Add an OI row with BLANK values and status=blocked. Blank means not measured and is never read as zero. Also standardise LD10 to D10 throughout: the CN row says LD10 while the DR/BS/SO rows say D10, and the two differ by a large factor." PP-T2-28,preprint,Table 2 whole table,the tabulated precision (two significant figures on every interval endpoint),literature_prior,26,rows,not_applicable,false,none,declared,restate,One significant figure and order-of-magnitude brackets,"Precision exceeds the stated basis in EVERY row. Endpoints like 0.005-0.05 and 15-45 sit on bases that are qualitative morphology statements. 0.00 as a lower bound asserts exactly zero. The table has 26 DATA ROWS, counted directly at .md:157-182 - not 27, a figure that circulated in the briefing packet and matches nothing. Add a 'usable in simulation' column; today every row reads 'no - blocked on cpm.lattice_pitch_um'." @@ -335,15 +335,15 @@ PP-8-02,preprint,section 8,conserves total system energy through symplectic inte PP-8-03,preprint,section 8,These predictions are consistent with known radiobiology,model_output,,none,not_applicable,false,biofilms_potts.jl:74-104,declared,restate,State that the agreement is by construction,"The consistency is by construction: known radiobiology was the SOURCE of beta_ion. Honest phrasing: the parameterisation was drawn from published radiobiology, so agreement with that literature is not independent evidence. Sharper still after the phenotype fold - what was drawn on was RADIORESISTANCE data, and what it was spent on was a TROPISM coefficient (NEWS-PHEN-04)." PP-8-04,preprint,section 8,suggest design principles for engineered biofilm consortia targeting nuclear bioremediation,design_principle,,none,not_applicable,false,none,absent,restate,Drop the deployment register; see PP-73-05 on biosafety,Nothing has been predicted and checked against anything. The correct register is exploratory framework / theoretical formalism. PP-8-05,preprint,section 8,radiotrophic fungi ... radioresistant bacteria ... radiosensitive metal-reducing species,design_principle,3,categories,not_applicable,not_applicable,none,primary_literature,keep,,"THE VOCABULARY IS USED CORRECTLY HERE, as a description of ORGANISMS from the literature - which is why it stays even though the model represents none of the three. It is the target the rest of the document should be edited toward. Same for section 6.1's phrase 'species that tolerate radiation without deriving direct metabolic benefit', the correct definition of radioresistance, already in the author's own words. Note separately that 'radiotrophic fungi' as an established category is contested (NEWS-AUD-05); that is a claim about the literature, not about this sentence's internal consistency." -PP-REF-01,preprint,References [11],"Sonner, Efendiev, Eberl (2015), MMAS 38(3), 3037-3042, doi 10.1002/mma.3237",literature_prior,,none,not_applicable,not_applicable,none,absent,delete,"Cite Rahman, Sudarsan & Eberl, Bull. Math. Biol. 77:2086-2124 (2015), 10.1007/s11538-015-0117-1","WRONG DOI, WRONG BIBLIOGRAPHIC DATA, WRONG PAPER FOR THE CLAIM. The DOI resolves to a 3D MHD regularity paper by Bie, Wang & Yao. The real Sonner quorum-sensing paper is MMAS 34 (2011), 1667-1684. And section 2.2 cites [11] for a cross-diffusion volume-filling result that is a different paper by different authors." -PP-REF-02,preprint,References [12],"Diele, Marangi, Ragni (2015), Math. Comput. Simul. 110, 40-52, doi 10.1016/j.matcom.2014.02.006",literature_prior,,none,not_applicable,not_applicable,none,absent,delete,"Cite Diele & Marangi, Mathematics 8(1):25, 10.3390/math8010025","EVERY FIELD WRONG. The DOI resolves to a neural-network quadratic-programming paper by Yang, Cao & Xu. Different authors, journal, year, volume and pages." -PP-REF-03,preprint,References [15],"Campillo, Joannides, Larramendy-Valverde (2017), Chem. Eng. Sci. 175, 424-440",literature_prior,,none,not_applicable,not_applicable,none,absent,restate,"Correct to Voulgarelis, Velayudhan & Smith, 2018, pages 424-444","WRONG AUTHORS. The DOI and title are correct; the three named authors did not write it. NOTE THE PATTERN: the three worst reference defects ([11], [12], [15]) are all METHODOLOGICAL citations - symplectic integration, cross-diffusion well-posedness, stochastic chemostat analysis. Every biology and bioremediation citation checked clean. The bibliography is weakest exactly where section 5 and 7.1 claim methods that were never implemented." -PP-REF-04,preprint,References [26],"Malo et al. (2018), Fungal Biology 122(6), 449-456, doi 10.1016/j.funbio.2017.08.012",literature_prior,,none,not_applicable,not_applicable,none,primary_literature,restate,Correct the DOI to 10.1016/j.funbio.2017.08.010 and restore the full title,"BROKEN DOI (404) AND A LOAD-BEARING TRUNCATION. The full title ends 'demonstrate protective effect of melanin' - the truncation removes exactly the words identifying this as RADIOPROTECTION evidence rather than radiotrophy evidence, which is the distinction the retitle turns on." -PP-REF-05,preprint,References [4],"Shunk, Gomez, Kern, Averesch (2022), Front. Microbiol. 13, 877625",literature_prior,,none,not_applicable,not_applicable,none,primary_literature,restate,"Correct the author order to Averesch, Shunk, Kern","DOI and venue verify. The Shunk-first ordering is the bioRxiv preprint's, not the published paper's." -PP-REF-06,preprint,References [32] [20] [35],Eberl et al. dated 2001; truncated titles for [20] and [35],literature_prior,,none,not_applicable,not_applicable,none,primary_literature,restate,Crossref dates [32] to 2000; restore both full titles,Minor. Volume/issue/pages for [32] are correct. -PP-REF-07,preprint_tex,References block,begin{thebibliography}{38} with 44 bibitems,repository_fact,38,declared entries,not_applicable,not_applicable,none,absent,restate,Correct the argument to 44,"A leftover from the .md, which has 38. The six .tex-only references (graner1992, fox2009, lara2023, renslow2017, aydogan2022, soetaert2010) are the ones supporting the CPM and radiodialysis material." +PP-REF-01,preprint,"References, \bibitem{sonner2015}","Sonner, Efendiev, Eberl (2015), MMAS 38(3), 3037-3042, doi 10.1002/mma.3237",literature_prior,,none,not_applicable,not_applicable,none,absent,delete,"Cite Rahman, Sudarsan & Eberl, Bull. Math. Biol. 77:2086-2124 (2015), 10.1007/s11538-015-0117-1","WRONG DOI, WRONG BIBLIOGRAPHIC DATA, WRONG PAPER FOR THE CLAIM. The DOI resolves to a 3D MHD regularity paper by Bie, Wang & Yao. The real Sonner quorum-sensing paper is MMAS 34 (2011), 1667-1684. And section 2.2 cites [11] for a cross-diffusion volume-filling result that is a different paper by different authors." +PP-REF-02,preprint,"References, \bibitem{diele2015}","Diele, Marangi, Ragni (2015), Math. Comput. Simul. 110, 40-52, doi 10.1016/j.matcom.2014.02.006",literature_prior,,none,not_applicable,not_applicable,none,absent,delete,"Cite Diele & Marangi, Mathematics 8(1):25, 10.3390/math8010025","EVERY FIELD WRONG. The DOI resolves to a neural-network quadratic-programming paper by Yang, Cao & Xu. Different authors, journal, year, volume and pages." +PP-REF-03,preprint,"References, \bibitem{campillo2017}","Campillo, Joannides, Larramendy-Valverde (2017), Chem. Eng. Sci. 175, 424-440",literature_prior,,none,not_applicable,not_applicable,none,absent,restate,"Correct to Voulgarelis, Velayudhan & Smith, 2018, pages 424-444","WRONG AUTHORS. The DOI and title are correct; the three named authors did not write it. NOTE THE PATTERN: the three worst reference defects ([11], [12], [15]) are all METHODOLOGICAL citations - symplectic integration, cross-diffusion well-posedness, stochastic chemostat analysis. Every biology and bioremediation citation checked clean. The bibliography is weakest exactly where section 5 and 7.1 claim methods that were never implemented." +PP-REF-04,preprint,"References, \bibitem{malo2018}","Malo et al. (2018), Fungal Biology 122(6), 449-456, doi 10.1016/j.funbio.2017.08.012",literature_prior,,none,not_applicable,not_applicable,none,primary_literature,restate,Correct the DOI to 10.1016/j.funbio.2017.08.010 and restore the full title,"BROKEN DOI (404) AND A LOAD-BEARING TRUNCATION. The full title ends 'demonstrate protective effect of melanin' - the truncation removes exactly the words identifying this as RADIOPROTECTION evidence rather than radiotrophy evidence, which is the distinction the retitle turns on. NOT SUBJECT TO THE STATE-YOUR-DERIVATION RULE: the value prescribed here is an external constant -- a DOI, a publication date -- fixed by the world rather than derived from this repository's state, so it cannot drift while the prescription waits." +PP-REF-05,preprint,"References, \bibitem{shunk2022}","Shunk, Gomez, Kern, Averesch (2022), Front. Microbiol. 13, 877625",literature_prior,,none,not_applicable,not_applicable,none,primary_literature,restate,"Correct the author order to Averesch, Shunk, Kern","DOI and venue verify. The Shunk-first ordering is the bioRxiv preprint's, not the published paper's." +PP-REF-06,preprint,"References, \bibitem{eberl2001} (plus two entries formerly numbered [20] and [35], keys unrecovered)",Eberl et al. dated 2001; truncated titles for [20] and [35],literature_prior,,none,not_applicable,not_applicable,none,primary_literature,restate,Crossref dates [32] to 2000; restore both full titles,"Minor. Volume/issue/pages for [32] are correct. NOT SUBJECT TO THE STATE-YOUR-DERIVATION RULE: the value prescribed here is an external constant -- a DOI, a publication date -- fixed by the world rather than derived from this repository's state, so it cannot drift while the prescription waits." +PP-REF-07,preprint_tex,References block,begin{thebibliography}{38} with 44 bibitems,repository_fact,38,declared entries,not_applicable,not_applicable,none,absent,restate,"THE ARGUMENT MUST EQUAL THE \bibitem COUNT. Stated as the derivation and not as a number, because this row previously read 'correct the argument to 44' -- true when written, FALSE when applied on 2026-08-31 against 61 bibitems, and applying the row's own number would have introduced the defect the row exists to remove. APPLIED 2026-08-31 at 61. Nothing mechanical caught the drift and nothing could have: the row was internally consistent, the manuscript was internally consistent, and only their relation was wrong -- with the prescription unapplied there is no artifact carrying both halves to compare.","A leftover from the .md, which has 38. The six .tex-only references (graner1992, fox2009, lara2023, renslow2017, aydogan2022, soetaert2010) are the ones supporting the CPM and radiodialysis material." PP-CIT-01,repository,Citations.md,the definitive citation for Biofilm.jl,literature_prior,8,entries,not_applicable,false,none,absent,restate,"Delete the Biofilm.jl entry and the 'definitive citation' framing, fix the 'Sysmtems' typo, and head the file with a pointer to data/sources.csv","THE FILE CLAIMS AUTHORITY IT DOES NOT HAVE; ITS CONTENT IS NOT THE PROBLEM. Deleting it would lose eight literature entries for no gain. Biofilm.jl appears nowhere in the repository and is not in Project.toml, so that one entry goes. None of the sources the calibration and coupling work actually depends on appear: LNE-LNHB Cs-137 evaluated decay data, NIST/ICRU water composition, OpenMC 0.15.3, ENDF/B-VIII.0, the SRNL Nafion-117 study, PDMS stoichiometry, BiofilmQ, or the screened deposits. data/sources.csv is the registry of record and pins each document by document_version, accessed_date and sha256 - including two rows that record honest failure (SYRON_CASEY_MABR: NOT LOCATED; SRNL_NAFION117: NOT VERIFIED)." -PP-FIG-01,preprint_tex,sections 6.1-6.4,"Figure 1, Figure 2, Figure 3, Figure 4 as hard-coded numerals",model_output,4,figures,not_applicable,false,none,absent,delete,Delete or replace the references; the source files do not exist,"FIGURE-NUMBER COLLISION. Sections 6.1-6.4 refer to four figures that exist as no files - the Langevin clustering plot, the Th-232 decay curve, the 3D motility scatter, the kNN tree. The only four figure environments in the document are the CPM and radiodialysis figures, which take numbers 1-4. IN THE COMPILED PDF, 'Figure 1' in section 6.1 RESOLVES TO THE CPM RADIAL-STRATIFICATION PLOT. Four phantom figures silently aliased onto four real, unrelated ones. Regenerating them is not possible - only deleting or replacing the references." +PP-FIG-01,preprint_tex,"section 8 Conclusion (residual); sections 6.1-6.4 (original, already resolved)","Figure 1, Figure 2, Figure 3, Figure 4 as hard-coded numerals",model_output,4,figures,not_applicable,false,"tests/manuscript_claims_tests.jl (cross-references resolve, and none is a literal numeral)",absent,delete,"Delete or replace the references - BOTH BRANCHES DONE, at different times","FIGURE-NUMBER COLLISION. Sections 6.1-6.4 refer to four figures that exist as no files - the Langevin clustering plot, the Th-232 decay curve, the 3D motility scatter, the kNN tree. The only four figure environments in the document are the CPM and radiodialysis figures, which take numbers 1-4. IN THE COMPILED PDF, 'Figure 1' in section 6.1 RESOLVES TO THE CPM RADIAL-STRATIFICATION PLOT. Four phantom figures silently aliased onto four real, unrelated ones. Regenerating them is not possible - only deleting or replacing the references. CLOSED 2026-09-01, AND THE ROW DESCRIBED A DEFECT THAT WAS ALREADY HALF FIXED. Section 6 now contains ZERO literal figure references: the four phantoms were removed in earlier work and this row was never updated, so its location field pointed at 6.1-6.4 where nothing remained and its required_to_fix named 'the source files do not exist', which was true of the phantoms and false of what was left. THE DEFECT RECORD WENT STALE WHEN THE DEFECT PARTLY CLOSED -- the same family as an absence record going stale, one verb over. What actually remained were SIX literals in section 8's version-correction passage, all naming REAL figures. Each was verified against the figure's CAPTION AND FILENAME before conversion rather than against its position, because this row's own history is that these numbers once aliased: Figure 1 = fig:radial = fig1_radial_stratification, 2 = fig:melanin, 3 = fig:membrane (caption 'membrane integrity m(t)' matching the prose 'plots m(t) and P_eff/P_0'), 4 = fig:contaminant (matching FIG-03's 'per cent depleted'). None sat in a caption, so no label-ordering subtlety arose. FIGURES FLOAT, which makes the literal-numeral defect worse for them than for sections: a section number is one the author controls, a figure number is wherever LaTeX placed the float, so the prose can go wrong with nothing moving in the source. The guard that excluded Figure~ on this row's authority now covers it, and that exclusion comment was rewritten rather than left to outlive its reason." PP-FIG-02,preprint,preprint/figures/ and the committed PDF,the four committed figures as the figures in the PDF,repository_fact,8,files,not_applicable,true,biofilms_potts.jl:1994-2118,simulation_output,needs_verification,"Rebuild in CI on change to preprint/**, or remove the PDF from HEAD","THE COMMITTED PDF IS ONE FIGURE REVISION BEHIND THE FILES NEXT TO IT: commit e24dbec regenerated all 8 figure files and did not rebuild the PDF. The .tex is byte-identical to its state at the build commit, so a rebuild today would produce a different document BEFORE A SINGLE WORD IS REVISED. And no TeX engine is installed on this machine, so the 'leave it with a version note' option is unavailable in practice - adding a note requires rebuilding. A README caveat does not travel with a PDF served at a direct URL, which is precisely the rule this ledger exists to enforce." PP-NOT-01,preprint,"Notation Fixes Required Before Submission, .md only","the closing defect list, 7 items",design_principle,7,items,not_applicable,not_applicable,none,declared,restate,Carry the two surviving items into the .tex as a visible open-defects subsection,"5 of 7 are genuinely resolved in the .tex; item 7 is resolved in both. ITEMS 4 (effective mass) AND 5 (melanin dimensional consistency) ARE OPEN AND NO LONGER FLAGGED ANYWHERE A READER CAN SEE THEM - see PP-33-01 and PP-36-01. This list is the one thing the .md does better than the .tex, and it must not be lost in the merge; restate rather than keep because carrying it forward is a required action. A submitted preprint that ends with a list of defects it has not fixed publishes those defects as unfixed; a preprint that quietly drops the list publishes them as fixed." NEWS-TITLE-01,repository,"work title, all documents",Modeling Radiotrophic Fitness,design_principle,,none,not_applicable,not_applicable,none,declared,restate,"Retitle to 'Modeling Radioresistance and Radiotropic Fitness' throughout, including the preprint filename","THE TITLE NAMES A PHENOTYPE THE MODEL DOES NOT REPRESENT AND THE LITERATURE DOES NOT ESTABLISH FOR THESE SPECIES. The new title is accurate on both halves: RADIORESISTANCE is what the published D10 evidence supports and what the per-species parameter magnitudes are anchored to; RADIOTROPIC FITNESS is what compute_delta_H implements, a directional preference along a dose gradient. Radiotrophy is neither evidenced (NEWS-AUD-05) nor representable (NEWS-PHEN-02). The file preprint/modeling_radiotrophic_fitness.{md,tex} still carries the old name." @@ -463,6 +463,73 @@ LADDER-07,calibration/scripts/rasterization_ladder.py,coarsest_passing,"The coar RDX-APP-07,correspondence,"author correspondence, 2024-10/2024-11",A Colletotrichum challenge assay is the decisive test for the produce-coating claim in RDX-APP-01.,extrapolation,,,false,false,,primary_literature,needs_verification,"read the registered sources in full, then establish which Colletotrichum species dominate postharvest anthracnose on the named crops - that part was NOT searched","SHARPER THAN THE UV TEST THE CORRESPONDENCE PROPOSED, and capable of falsifying the claim outright. Colletotrichum is an anthracnose genus on high-water-activity fruit, the produce class RDX-APP-01 names. Sources LUDWIG_2014_CGRAM_MELANIN, CGLOEO_2023_DHN_TURGOR, CGSCD1_2020_PATHOGENICITY and CHIGG_2023_PKS_THR1 are registered but were reached at ABSTRACT LEVEL ONLY; the crop-dominance question was not searched at all, because web access was cut off mid-task. needs_verification, not supported - the row records a research direction, not a result." RDX-APP-08,correspondence,"author correspondence, 2024-10/2024-11","Melanin applied to produce is protective, acting as a natural sunscreen.",extrapolation,,,false,false,,primary_literature,must_not_be_claimed,"evidence that EXOGENOUS melanin protects produce - not searched, and not implied by anything below","THE NEAREST LITERATURE RUNS THE OTHER WAY. In Colletotrichum, DHN-melanin pathway knockouts ATTENUATE PATHOGENICITY across two species (CGSCD1_2020_PATHOGENICITY; CHIGG_2023_PKS_THR1), so melanin in this system is a fungal virulence requirement rather than a plant protectant. That does NOT show applied melanin helps a pathogen - appressorial melanin is synthesised in situ in a specific wall layer and exogenous material is unlikely to substitute - but it removes the intuition the claim rests on. Same pathway already ledgered at PP-T2-20 (A. niger, DHN-melanin). CORRECTION TO MY OWN SUMMARY: I first described melanin as letting the appressorium build turgor to punch through the cuticle. That is the Magnaporthe account; LUDWIG_2014_CGRAM_MELANIN specifically contradicts it for C. graminicola (albino mutants generated turgor but could not penetrate intact leaves), while CGLOEO_2023_DHN_TURGOR reports expansion pressure falling from 13.02 to 7.34-9.34 MPa in knockouts. The two disagree on mechanism; only the pathogenicity claim survives both, and only that is relied on here." RDX-APP-09,correspondence,"author correspondence, 2024-10/2024-11",Adding a phytopathogen to the seven-species consortium is covered by the existing approval scope.,extrapolation,,,false,false,,absent,must_not_be_claimed,identify the actual instrument before any such proposal; a USDA APHIS PPQ 526 permit is the SUSPECTED route and was NOT verified,"A DIFFERENT AUTHORIZATION, NOT A LARGER ONE. The approval gate is scoped to the seven-species consortium and follows strains rather than species, so a plant pathogen falls outside it by construction and not by degree. The specific US instrument was NOT verified - web access was cut off before that search - so this row asserts only that the existing scope fails to cover it, which follows from the gate's own scope digest without needing any external source. Recording Colletotrichum as a TEST organism proposes culturing nothing; adding it to SPECIES_LABELS or to any model is the thing this row refuses." -HANDOUT-01,preprint/wan_meeting_handout.tex,page 1 architecture diagram (solver box); page 3 'The bridge',"The solver's sorption term is k_ads c (X_max - X) - k_des X, and the bench isotherm enters it as X_max = q_max * rho_dry",proposed_formalism,,,not_applicable,false,"biofilms_radiodialysis.R:10-13,86,132",absent,delete,"a capacity-ceiling term in the sorption sink, which is a MODEL CHANGE; until it exists no q_max maps to any solver parameter","NOT DISTRIBUTED. CORRECTED 2026-08-28: an earlier version of this row recorded 'DISTRIBUTED 2026-08-24 (Monday), to one external collaborator' -- that is false and is withdrawn. The handout has gone to nobody. What was sent on 2026-08-25 was the manuscript, which states these forms correctly in section 3.11, so no distributed document ever carried this claim and the handout carries no correction block. The claim below was still wrong and the delete verdict stands on the claim, not on its circulation. This is the manuscript's OWN target equation, drawn inside the box labelled THE SOLVER: section 3.11 states S = k_ads c (X_max - X) - k_des X as what 'recovering Langmuir requires', explicitly contrasting it with the implemented form. The shipping term is (k_ads*X_total + k_red*X_red)*c - k_des*s -- first-order in c, no site limitation, a Henry isotherm -- so there is no X_max in the code for q_max*rho_dry to enter, and the diagram's arrow crossed a gap that is not bridged. tests/manuscript_claims_tests.jl:160-168 already asserts X_max, q_max and Langmuir appear in none of the nine SIM_FILES, with a planted-hit control; that guard covers the code, not this handout, which is how the claim got out. AUDIT OF DEPENDENTS: nothing numeric was derived from it. The three other artifacts carrying the same claim all state it correctly -- manuscript section 3.11, wan-deck.tex:215 ('That is Henry kinetics, not Langmuir: there is no q_max or saturation term in the model'), and the tailored CV's Biofilms bullet -- so the handout diverged from all three rather than propagating an error into them. FIXED 2026-08-28: diagram now shows the shipping term and labels it 'Henry --- no capacity ceiling'; page 3 heading 'The bridge' became 'Where the number would land' and carries an explicit 'This is not yet a bridge'. No marked correction block: that obligation attaches to a document that carried the claim to someone, and this one did not. ENFORCEMENT 2026-08-28: the guard no longer filters on claim_id.startswith('PP-'); test_no_deleted_claim_survives_in_the_document_it_names resolves each row's document column and searches THAT file, so this row is now read against the handout. PARTIAL FOR THIS ROW, AND SAID SO: the guard matches prose, and this claim is an equation -- normalise_markup reduces X_{\max} to a bare 'x' -- so HANDOUT-01 is reported in the coverage output as not textually detectable and stays with a human. HANDOUT-02 is detectable and test_the_guard_detects_the_handout_claims requires it to be found in the pre-correction handout at 9319d43. SCOPE OF THIS VERDICT: it is the Langmuir sorption term in the box labelled THE SOLVER, and the framing of the unit conversion as a bridge into that solver. The conversion X_max = q_max*rho_dry is arithmetically correct and still appears on page 3 deliberately, under 'Where the number would land' and above the sentence saying there is no X_max in the code for it to enter." -HANDOUT-02,preprint/wan_meeting_handout.tex,"page 2, Material and Controls","Metabolic control --- azide or a temperature shift; heat/azide-killed biomass is standard in this literature",design_principle,,,not_applicable,not_applicable,,declared,delete,temperature shift only; a reagent that alters the speciation of the analyte cannot serve as a control for uptake of that analyte,"NOT DISTRIBUTED. CORRECTED 2026-08-28: an earlier version of this row recorded 'DISTRIBUTED 2026-08-24 (Monday), to one external collaborator' -- that is false and is withdrawn. The handout has gone to nobody. What was sent on 2026-08-25 was the manuscript, which states these forms correctly in section 3.11, so no distributed document ever carried this claim and the handout carries no correction block. The claim below was still wrong and the delete verdict stands on the claim, not on its circulation. Same document as HANDOUT-01. Sodium azide is a ligand that complexes transition metals and shifts speciation, so it perturbs the quantity the ICP reads and confounds the measurement it was meant to control. This was already settled and already written down correctly in wan-deck.tex:153-155 ('Not sodium azide: azide is a ligand that complexes transition metals and shifts speciation, confounding the measurement it is meant to control'); the handout contradicted the deck, so this is a divergence between two artifacts prepared for the same meeting, not a new determination. It appeared twice -- once as a killing method ('heat/azide-killed') and once as the metabolic control ('azide or a temperature shift') -- and both are corrected. FIXED 2026-08-28: both now read temperature shift / heat-killed only, with the reason stated inline. NOT INDEPENDENTLY RE-DERIVED HERE: this row records the prior determination and its repository witness, not a fresh review of the coordination chemistry." +HANDOUT-01,preprint/wan_meeting_handout.tex,page 1 architecture diagram (solver box); page 3 'The bridge',"The solver's sorption term is k_ads c (X_max - X) - k_des X, and the bench isotherm enters it as X_max = q_max * rho_dry",proposed_formalism,,,not_applicable,false,"biofilms_radiodialysis.R:10-13,86,132",absent,delete,"a capacity-ceiling term in the sorption sink, which is a MODEL CHANGE; until it exists no q_max maps to any solver parameter","NOT DISTRIBUTED. CORRECTED 2026-08-28: an earlier version of this row recorded 'DISTRIBUTED 2026-08-24 (Monday), to one external collaborator' -- that is false and is withdrawn. The handout has gone to nobody. What was sent on 2026-08-25 was the manuscript, which states these forms correctly in section 3.12, so no distributed document ever carried this claim and the handout carries no correction block. The claim below was still wrong and the delete verdict stands on the claim, not on its circulation. This is the manuscript's OWN target equation, drawn inside the box labelled THE SOLVER: section 3.12 states S = k_ads c (X_max - X) - k_des X as what 'recovering Langmuir requires', explicitly contrasting it with the implemented form. The shipping term is (k_ads*X_total + k_red*X_red)*c - k_des*s -- first-order in c, no site limitation, a Henry isotherm -- so there is no X_max in the code for q_max*rho_dry to enter, and the diagram's arrow crossed a gap that is not bridged. tests/manuscript_claims_tests.jl:160-168 already asserts X_max, q_max and Langmuir appear in none of the nine SIM_FILES, with a planted-hit control; that guard covers the code, not this handout, which is how the claim got out. AUDIT OF DEPENDENTS -- WITHDRAWN AND REPLACED 2026-08-31, AND THE HEADING IS NOW THE THING TO DISTRUST. The withdrawn text read: 'The three other artifacts carrying the same claim all state it correctly -- manuscript section 3.12 (quoting a sentence) and the tailored CV's Biofilms bullet -- so the handout diverged from all three rather than propagating an error into them.' NONE OF THAT AUDIT CAN BE RUN. (a) The manuscript DOES state it correctly, but in section 3.12, not 3.11; the locator was wrong in THREE places in this row -- the distribution sentence above, and twice in the withdrawn audit. Counting them was itself a second pass: the first correction said two and missed the one furthest from the audit heading. (b) wan-deck.tex NEVER EXISTED UNDER ANY NAME: git log --all --diff-filter=A over every path returns nothing for it, and preprint/wan_meeting_handout.tex was ADDED rather than renamed from it. The sentence quoted from it appears nowhere in the working tree and nowhere in history EXCEPT INSIDE THIS ROW -- the only commits containing the string are the ones that wrote the row. (c) No CV exists anywhere in this repository: `find . -iname '*cv*' -not -path './.git/*' -type f` returns nothing, and the phrase 'tailored CV' appears in this ledger and nowhere else. SCOPE ON (b) AND (c): both are searches of THIS repository and its history on 2026-08-31, not claims about whether such documents exist on someone's disk. A CV plainly may exist outside version control -- which is precisely the problem, because an audit citing an artifact the repository cannot reach is an audit no reader can reproduce, and that is the defect regardless of what exists elsewhere. SO THIS ROW DID NOT DRIFT; IT WAS WRITTEN FROM ARTIFACTS THAT WERE PLANNED RATHER THAN READ. That is the claim-written-from-a-summary family -- the shape of HOFFMAN-01, and of the anion-exclusion mechanism withdrawn from the phase-2 figure as FIG-09/FIG-10 -- and it is that family's most consequential instance so far BECAUSE THE ARTIFACT IN QUESTION IS ITSELF AN AUDIT. An audit that asserts corroboration and delivers none does not merely fail to corroborate; it manufactures the appearance of having checked, and it did so under a heading that reads as the check having happened. WHAT THE AUDIT ACTUALLY ESTABLISHES, NOW THAT IT HAS BEEN RUN: nothing numeric was derived from the deleted claim, and the manuscript (section 3.12) states the forms correctly. That is ONE verifiable corroborating artifact, not three. The other two are not 'unverified' -- they are not there. 'AUDIT OF DEPENDENTS' WHEREVER IT APPEARS IS A CLAIM THAT DEPENDENTS WERE READ, and this row establishes the heading has been used for dependents that were not. SCOPE: HANDOUT-01 is at this date the ONLY row in this ledger carrying that heading, so the warning is prospective by construction -- which is exactly why it is written now, while there is one instance to point at, rather than when there are six. FIXED 2026-08-28: diagram now shows the shipping term and labels it 'Henry --- no capacity ceiling'; page 3 heading 'The bridge' became 'Where the number would land' and carries an explicit 'This is not yet a bridge'. No marked correction block: that obligation attaches to a document that carried the claim to someone, and this one did not. ENFORCEMENT 2026-08-28: the guard no longer filters on claim_id.startswith('PP-'); test_no_deleted_claim_survives_in_the_document_it_names resolves each row's document column and searches THAT file, so this row is now read against the handout. PARTIAL FOR THIS ROW, AND SAID SO: the guard matches prose, and this claim is an equation -- normalise_markup reduces X_{\max} to a bare 'x' -- so HANDOUT-01 is reported in the coverage output as not textually detectable and stays with a human. HANDOUT-02 is detectable and test_the_guard_detects_the_handout_claims requires it to be found in the pre-correction handout at 9319d43. SCOPE OF THIS VERDICT: it is the Langmuir sorption term in the box labelled THE SOLVER, and the framing of the unit conversion as a bridge into that solver. The conversion X_max = q_max*rho_dry is arithmetically correct and still appears on page 3 deliberately, under 'Where the number would land' and above the sentence saying there is no X_max in the code for it to enter." +HANDOUT-02,preprint/wan_meeting_handout.tex,"page 2, Material and Controls",Metabolic control --- azide or a temperature shift; heat/azide-killed biomass is standard in this literature,design_principle,,,not_applicable,not_applicable,,declared,delete,temperature shift only; a reagent that alters the speciation of the analyte cannot serve as a control for uptake of that analyte,"NOT DISTRIBUTED. CORRECTED 2026-08-28: an earlier version of this row recorded 'DISTRIBUTED 2026-08-24 (Monday), to one external collaborator' -- that is false and is withdrawn. The handout has gone to nobody. What was sent on 2026-08-25 was the manuscript, which states these forms correctly in section 3.11, so no distributed document ever carried this claim and the handout carries no correction block. The claim below was still wrong and the delete verdict stands on the claim, not on its circulation. Same document as HANDOUT-01. Sodium azide is a ligand that complexes transition metals and shifts speciation, so it perturbs the quantity the ICP reads and confounds the measurement it was meant to control. This was already settled and already written down correctly in wan-deck.tex:153-155 ('Not sodium azide: azide is a ligand that complexes transition metals and shifts speciation, confounding the measurement it is meant to control'); the handout contradicted the deck, so this is a divergence between two artifacts prepared for the same meeting, not a new determination. It appeared twice -- once as a killing method ('heat/azide-killed') and once as the metabolic control ('azide or a temperature shift') -- and both are corrected. FIXED 2026-08-28: both now read temperature shift / heat-killed only, with the reason stated inline. NOT INDEPENDENTLY RE-DERIVED HERE: this row records the prior determination and its repository witness, not a fresh review of the coordination chemistry." LEDGER-GUARD-03,calibration/tests/test_claims_ledger.py,test_no_deleted_claim_survives_in_the_document_it_names,No claim marked `delete` survives in the document that carried it.,repository_fact,59 delete rows; 54 readable documents,count,true,true,calibration/tests/test_claims_ledger.py,derived,keep,,"THE GUARD DECIDED SCOPE FROM THE ID, NOT FROM THE DATA. It filtered claim_id.startswith('PP-') and read only the preprint, so all 25 `delete` verdicts on other documents -- HANDOUT-01 and HANDOUT-02 among them -- were carried by a test that searched a file those claims had never been in. It could not fail for them: rule 1. FIXED 2026-08-28: the `document` column resolves to a path (two aliases, `preprint` and `preprint_tex`, both the .tex; `README` is README.md) and each row is searched in its own document. `repository` and `correspondence` are declared pseudo-documents, and test_every_deleted_claim_names_a_document_the_guard_can_read fails on any other unreadable document so a typo or a rename cannot retire a verdict in silence. NEGATIVE CONTROLS, BOTH RUN: test_the_guard_detects_the_handout_claims requires HANDOUT-02 to be found in the pre-correction handout at 9319d43, and reintroducing 'heat/azide-killed biomass is standard in this literature' into the working copy made the survivor test fail with exactly that row, then pass again on revert. The manuscript floor of 18 detections is unchanged at 20. COVERAGE IS STILL PARTIAL AND STILL NAMED: HANDOUT-01's claim is an equation and normalise_markup reduces LaTeX maths to nothing searchable, so it is printed in the coverage report as needing a human rather than counted as covered." +FIG-01,preprint/figures/fig2_melanin_accumulation.pdf,in-plot footer annotation,"C. neoformans, C. sphaerospermum are radiotrophic (melanin-mediated energy gain)",model_output,,none,not_applicable,true,biofilms_potts.jl:2191,simulation_output,delete,Regenerate the figure from the corrected generator; the source fix alone does not move the artifact,"THE PROSE WAS AUDITED AND THE IMAGE WAS NOT. This annotation asserts radiotrophy inside a figure whose own caption disowns 'radiation-derived energy production' and against Section 2.6, 'Radiotrophy is not established for any of the seven species modelled'. It was not unnoticed: the generator was corrected on 2026-08-14 (d53f236, 01b0f4d) and the verdict was recorded three times (RM-G08-01, RM-G10-01, PP-65-08, the last stating in as many words that the committed PNGs still carry the old labels). The artifact went on saying it for two weeks because nothing could open a figure - the claims guard read one .tex file, and tests/runtests.jl splits the monolith above the '13. Figure export' marker so the Julia suite cannot reach export_figures at all. Fixed 2026-08-28 by regenerating from the corrected generator; the guard now reads a committed text sidecar with the PDF's sha256 pinned beside it, and the pre-correction artifact is a committed negative control." +FIG-02,preprint/figures/fig1_radial_stratification.pdf,in-plot shaded-band labels,radiotrophic niche; radiosensitive core,model_output,,none,not_applicable,true,biofilms_potts.jl:2096-2102,simulation_output,delete,Regenerate; the generator already emits neutral field labels,"Same defect as FIG-01, and the zone direction was reversed as well: I(r) is maximal ON THE AXIS, so the band labelled 'radiotrophic niche' was the LOW-radiation one (RM-G08-01, PP-65-08). NOT COVERED BY THE PHRASE GUARD: both labels are two words, under MIN_WORDS=5, so distinguishing_phrase returns nothing and this row can never be matched textually. Coverage is the RETRACTED_IN_FIGURES vocabulary floor instead, which is why that floor exists rather than being redundant with the phrase guard. Fixed 2026-08-28; the generator now emits 'inner high-radiation region' / 'outer lower-radiation region'." +FIG-03,preprint/figures/fig4_contaminant_penetration.pdf,in-plot annotation,c_mean is a percentage depleted,model_output,98,percent depleted,false,true,biofilms_potts.jl:2329-2330,simulation_output,restate,Rename to non-penetration in the figure label (done in source); the ARTIFACT cannot be regenerated while the radiodialysis basis gate holds,"The artifact half of RM-KR-06. c is initialised to zero everywhere, so the interior never held contaminant to deplete and the number measures non-penetration, not removal. The generator was corrected on 2026-08-28. THE FIGURE ITSELF IS STILL THE OLD ONE, and this is not an oversight: Figures 3 and 4 plot rd.c, rd.s and rd.m, which come from main_coupled(), and that path now refuses to run - 'RADIODIALYSIS: BLOCKED, refusing to integrate at X_total = 0.065', because a coupled basis is one species' occupied sites over all interior sites, neither a biomass nor a reducer fraction (README.md:344, tests/radiodialysis_basis_gate.jl). The refusal is correct and is not to be removed to regenerate a figure. Figures 1 and 2 WERE regenerated because they read only snapshot mean_r and mean_melanin, and neither the Hamiltonian nor update_melanin! reads state.nutrient, which is the only channel by which radiodialysis reaches the CPM state." +FIG-04,repository,preprint/figures/,A committed figure is a published artifact and was unreadable by every suite in the repository.,repository_fact,,none,not_applicable,true,calibration/tests/test_claims_ledger.py,code_inspection,keep,none,"Rule 1 on the repository's own output. Four defects, one shape: a verdict was reached, the source was fixed, and the artifact a reader actually opens was left carrying the retracted claim, with nothing able to fail. Closed 2026-08-28 by three tiers, each with a control: a sha256 per PDF pinned in stdlib so a regenerated figure with a forgotten sidecar fails instead of passing on stale-but-clean text; the phrase guard reading the sidecar as the document the row names; and a vocabulary floor for labels too short for MIN_WORDS. SEPARATELY, the pinned-SHA negative-control idiom was retired here. It was used three times (5980dc5, 9319d43, and a third that would have been e24dbec), each time acknowledging that a squash-merge makes the commit unreachable and degrades the control into a skip, and each time proceeding. The four controls are now committed files under calibration/tests/fixtures/ and cannot become unreachable. COVERAGE IS STILL PARTIAL: Manifest.toml is gitignored, so figure regeneration is not dependency-pinned and byte-identical output across machines is not claimed; and Figures 3 and 4 remain at their published state, named by FIG-03." +HOFFMAN-01,preprint/hoffman_memo.tex,"Page 2, The empirical fact","Biofilm developed on stainless steel and titanium coupons submerged in a spent nuclear fuel pool at a Spanish nuclear power plant and retained radionuclides from the pool water, Co-60 in particular.",literature_prior,,none,not_applicable,false,,literature,supported,none,"Verified 2026-08-28 against the PubMed abstract for PMID 17426994 (Sarro, Garcia, Moreno, Montero; J Ind Microbiol Biotechnol 34(6):433-441; doi 10.1007/s10295-007-0215-7). SCOPE CORRECTION MADE BEFORE THE CLAIM WAS WRITTEN: the secondhand brief this came from stated '34 months' and named the Cofrentes plant, and the abstract states NEITHER - it says only 'a Spanish nuclear power plant' and gives no duration. Both details were dropped rather than carried; they may be in the full text, which was not obtained. The retained claim is exactly what the abstract states." +HOFFMAN-02,preprint/hoffman_memo.tex,"Page 2, The empirical fact","Six bacterial species isolated from spent nuclear fuel pool water had D10 values from 248 Gy to 2 kGy and biofilm-forming capability, with biofilm-mediated Co and Ni removal up to 3.8 micrograms per mg of biomass.",literature_prior,,none,not_applicable,false,,literature,supported,none,"Verified 2026-08-28 against the Europe PMC abstract for PMID 29063404 (Karley, Shukla, Rao; Environ Sci Pollut Res 25(21):20518-20526; doi 10.1007/s11356-017-0376-5), which states both figures verbatim. ATTRIBUTION CORRECTED: the secondhand brief assigned the 3.8 ug/mg figure to the 2023 paper; it is in this 2018 one, and the first author is Karley, not Shukla." +HOFFMAN-03,preprint/hoffman_memo.tex,"Page 2, The empirical fact","Dead biomass of the same spent-fuel-pool isolates also removed cobalt and nickel, which is what a sorption mechanism predicts and a metabolic one does not.",literature_prior,,none,not_applicable,false,,literature,supported,none,"First clause verified 2026-08-28 against the Europe PMC abstract for PMID 37209244 (Karley, Shukla, Rao; Environ Monit Assess 195(6):731): 'dead biomass also showed significant removal of the two metal ions'. The second clause is INTERPRETATION, flagged as such by its wording ('is what X predicts') rather than asserted as a finding of that paper. NOT CARRIED: the same abstract reports accumulation 'in the range of 4 x 10-4 to 1 x 10-5 g/mg of cell biomass', which is dimensionally odd - 4e-4 g/mg is 40 percent of the biomass mass, and is two orders above the 3.8 ug/mg of the 2018 paper on the same isolates. The unit is probably g/g. It is not quoted anywhere because it is not resolvable from the abstract." +HOFFMAN-04,preprint/hoffman_memo.tex,"Page 3, closing note","No containment determination is asserted; containment follows strains, not species, and belongs to an institution.",design_principle,,none,not_applicable,true,AGENTS.md rule 6,repository_policy,keep,none,"AGENTS.md rule 6. The research brief behind this memo concluded BSL-2 and non-select-agent for the SPECIES, citing ASM. That is a species-level literature conclusion and the rule is explicit that biosafety follows strains. The memo therefore records the taxonomy and the CDC handling instruction and declares no level, no select-agent status, and nothing touching D-APPROVAL." +HOFFMAN-05,preprint/hoffman_memo.tex,"Page 3, heading and question list",Page 3 asks what the facility can supply rather than stating it.,design_principle,,none,not_applicable,false,,repository_policy,keep,none,"Deliberate, and a reversal. The draft this replaced asserted MURR's capabilities back to a MURR scientist - that it has no imaging beamline, that NAA is partial - from a secondhand research brief whose beamline inventory was single-sourced to a seminar abstract. Asserting a man's own facility to him from one unverified source is the same inference class this repository exists to refuse, and it would have been checkable by the reader in about a minute. The technique limitation that IS retained (activation methods not seeing the light elements) is marked as 'my reading of the technique, not of your facility'." +PP-V11-01,preprint,"Section 7.3, closing paragraphs","We record this as an observation about regime and available inputs, not as a proposed deployment.",design_principle,,none,not_applicable,false,,repository_policy,keep,none,"Written to satisfy PP-8-04, which ruled restate on the deployment register with the action 'Drop the deployment register ... The correct register is exploratory framework / theoretical formalism'. The added text makes a claim about REGIME - that a facility sits between the reactor Gy/min and environmental mGy/yr scales the manuscript already puts ten orders of magnitude apart - and about which inputs are obtainable there. It predicts nothing and proposes no deployment. The closing sentence states that the model is unchanged and only the setting for measuring its inputs differs." +PP-V11-02,preprint,"Ethics, Biosafety, and Regulatory Statement",Ochrobactrum intermedium was reclassified into Brucella; NCBI Taxonomy serves taxon 94625 as Brucella intermedia with Ochrobactrum intermedium as a synonym.,literature_prior,,none,not_applicable,false,,literature,supported,none,"Verified 2026-08-28 directly against the NCBI Taxonomy record for taxid 94625, which returns scientific name 'Brucella intermedia', lineage 'Brucellaceae; Brucella/Ochrobactrum group; Brucella', synonym 'Ochrobactrum intermedium', and attribution '(Velasco et al. 1998) Hordt et al. 2020'. Hordt 2020 verified via Crossref (Front Microbiol 11:468, doi 10.3389/fmicb.2020.00468). Oren and Garrity 2020 verified via Crossref (IJSEM 70(7):4043-4049, doi 10.1099/ijsem.0.004244) - NOTE that an initially guessed DOI for this reference resolved to an unrelated paper on Raineyella fluvialis and was discarded; the correct one was found by title search. CDC LOCS notice of 2022-12-19 verified to exist and to carry the Class II BSC handling instruction, the state-public-health referral, and the non-brucellosis designation." +PP-V11-03,preprint,"Ethics statement, procurement note","No culture-collection accession for AM7 was located in any repository searched, so the strain may be unobtainable.",repository_fact,,none,not_applicable,true,docs/research/radiotrophic_lab_gap.md:87,prior_search,needs_verification,"Repeat the accession search, or scope the sentence to the date and registries of the original search","Carried forward from the repository's own research record, which states it and holds procurement pending the determination. THE SEARCH WAS NOT REPEATED for version 1.1, so this is an absence finding inherited rather than confirmed. The manuscript sentence is scoped to 'any repository searched' rather than asserting the strain does not exist, which is the honest form of an unrepeated negative, but the scope of that search is not stated in the manuscript and should be." +PP-V11-04,preprint,Software and Data Availability,The sentence claiming none of the corrected numbers appears in this manuscript was deleted rather than narrowed.,repository_fact,,none,not_applicable,true,"AGENTS.md, Correcting a published number, step 5",repository_policy,keep,none,"Version 1.0 said 'None of those numbers appears in this manuscript, whose transport section reports the execution of a path rather than its results.' The version 1.1 figure correction IS in this manuscript, which disproves it. Step 5 says to DELETE such a sentence rather than reword it, because a paraphrase is the same claim - narrowing its scope to the earlier four would be exactly the move the rule names, and this repository has already corrected its own corrections twice by making it. The sentence is gone and the correction states what moved instead: five in-plot strings across two figures, no numerical value, and Figures 3 and 4 not regenerated." +HOFFMAN-06,preprint/hoffman_memo.tex,"Page 2, The stressor set","Hoffman's published work qualifies FeCrAl against high-temperature steam, hydrothermal water chemistry, and hydrogen-isotope permeation.",literature_prior,,none,not_applicable,false,,literature,supported,none,"VERIFIED 2026-08-29 against the author's own indexed record, three first-authored papers, one per stressor named: JOM 74(4) 2022 (oxidation resistance in 1200 C steam of a FeCrAl alloy fabricated by three metallurgical processes); Nucl. Mater. Energy 37, 2023 (hydrothermal corrosion of PVD and cold-spray Cr coatings on Zircaloy-4); Metall. Mater. Trans. A 53(3), 2022 (hydrogen isotopes permeation in clean or unoxidized FeCrAl alloys, a review, with Garud and Rebak). Two further steam papers cited in support: Corros. Sci. 209 (2022) on Al and 229 (2024) on Mo. IDENTITY VERIFIED SEPARATELY AND IT MATTERED: the Google Scholar profile is filed under Catalyst Science Solutions, not MURR, and the 2024 Materialia paper lists the affiliation as General Electric Co., Schenectady. A University of Florida NE seminar listing gives 'Andrew Hoffman, Ph.D., University of Missouri Research Reactor & Catalyst Science Solutions', which is what joins them. Attributing a publication record to a named recipient requires establishing it is theirs, and here two of three sources would not have." +HOFFMAN-07,preprint/hoffman_memo.tex,"Page 3, Radiation selects; it does not corrode","Microbiologically influenced corrosion is a chemical attack that happens to occur in a radiation field; its mechanisms work identically at background dose, and radiation's role is to select which organisms are present.",design_principle,,none,not_applicable,false,,primary_literature,keep,none,"MECHANISM FRAMING, AND THE CORRECTION THIS MEMO EXISTS TO NOT MAKE. Sulfide from sulfate reducers, redox shift from iron-oxidisers, differential aeration and chloride concentration under a film are all dose-independent. The alternative reading, that irradiation makes organisms corrosive, is a radiation-driven-metabolism claim of exactly the class section 2.6 retracts for radiotrophy, and it would have entered this memo through the framing rather than through a sentence. Stated in the memo explicitly for that reason. The D10 range of 248 Gy to 2 kGy in HOFFMAN-02 is cited as a picture of selection, not of mechanism." +HOFFMAN-08,preprint/hoffman_memo.tex,"Page 3, The transport half","A hydrated layer attenuates and scatters, so the dose at the metal is not the dose in the bulk water, and radiolysis products reaching the surface drive corrosion independently of any organism.",proposed_formalism,,none,not_applicable,false,,primary_literature,requalify,"a planar film-on-metal geometry, which does not exist; see HOFFMAN-10","THE ONLY PART OF THE CORROSION QUESTION THIS MODEL COULD ADDRESS, and it is offered as a question rather than a capability. Attenuation and scatter in a hydrated layer is ordinary photon transport and is not in dispute; that radiolytic species drive corrosion independently of microbes is standard reactor water chemistry. What is NOT established is that anyone has computed the first for a biofilm at a metal interface -- the memo says 'as far as I can find', which is a search result on one date and not a proof of absence. Requalify rather than keep because the sentence names a quantity this repository cannot currently produce." +HOFFMAN-09,preprint/hoffman_memo.tex,"Page 3, The transport half, closing sentence",Whether localised attack under discrete colonies follows an abiotic ranking is posed as the recipient's question and not answered here.,design_principle,,none,not_applicable,false,,repository_policy,keep,none,"DELIBERATE, AND THE SECOND REVERSAL OF THE SAME KIND AS HOFFMAN-05. An earlier draft of this argument asserted that Hoffman's alloys are ranked by pitting resistance equivalent against abiotic chloride and that such a ranking may not survive a living film. The first half was unverifiable (see HOFFMAN-11) and the second is a materials judgement belonging to a materials scientist. Both became a question. Pitting is named because it is where MIC localises; no claim is made about how any alloy ranks." +HOFFMAN-10,preprint/hoffman_memo.tex,"Page 3, What this is, and what it is not","The implemented domain is a cylinder in water with a Robin boundary at its outer radius, with no metal, no planar film and no interface in it.",repository_fact,,none,not_applicable,true,biofilms_radiodialysis.R; preprint .tex section 3.11 eq. robin,code,keep,none,"LOAD-BEARING, AND ENFORCED BY A TEST BECAUSE IT READS LIKE A HEDGE. Without this sentence page 3 offers a dose field beneath a film on steel as something the code does. Section 3.11's domain is radial and aqueous with a Robin condition at r=R; there is no metal region, no planar geometry and no interface anywhere in the transport or the PDE. A film-on-steel dose calculation is a change of geometry, not a run of the existing code. THE GUARD'S OTHER DOCUMENT TESTS ALL ASSERT ABSENCE, which leaves the opposite failure -- a deliberately-carried sentence quietly trimmed -- uncovered; calibration/tests/test_claims_ledger.py LOAD_BEARING now fails if this phrase leaves the memo, with a control that strikes it and requires the report." +HOFFMAN-11,preprint/hoffman_memo.tex,Page 3 -- NOT CARRIED,FeCrAl in used fuel cooling pools is ranked by pitting resistance equivalent from electrochemical tests in seawater-type electrolyte.,literature_prior,,none,not_applicable,false,,none,delete,a retrievable copy of NACE-2019-12944 and confirmation of its authorship,"A REFUSAL, RECORDED IN THREE PARTS BECAUSE THEY ARE THREE DIFFERENT STATES. (1) CLAIM IDENTIFIED: it would have carried the 'wrong stressor set' argument the whole distance, from steam and hydrothermal qualification to an abiotic pitting ranking that a biofilm might not respect. (2) SOURCE INACCESSIBLE, NOT ABSENT: it traces to NACE-2019-12944, 'Corrosion Evaluation of Iron-Chromium-Aluminum Alloys in Used Fuel Cooling Pools', NACE CORROSION 2019, whose OnePetro record returns HTTP 403 -- paywalled and retrievable by someone with access, which is a different state from searched-for-and-not-found and must not be recorded as the same thing. (3) ATTRIBUTION UNCONFIRMED: the surrounding FeCrAl pool literature points to Rebak, and nothing establishes this paper as Hoffman's. Telling a man what his own published work assumes, from a secondhand read of a paper nobody in this repository has opened, is the same inference class as the MURR beamline inventory in HOFFMAN-05. The argument survives without it: HOFFMAN-06 makes the same point from three papers that ARE his and ARE readable. (4) REVISITED 2026-08-30 WITH NEW EVIDENCE, AND THE VERDICT HOLDS. A research brief supplied the aqueous third-element-effect measurements, which are a DIFFERENT OBJECT from the high-temperature alumina scale this refusal was argued about - see HOFFMAN-14. The attribution question drew NO NEW EVIDENCE, and the reason matters because two states look alike here: this is SOURCE UNREACHABLE FROM HERE, not checked-and-found-nothing. The OnePetro record still returns 403, so condition 1 of required_to_fix - a retrievable copy - is unmet independently of what authorship turns out to be, and the state changes the day someone with AMPP access opens it. The brief also contradicted itself on authorship, but that is ONE unverified attribution restated rather than two sources in tension: only its OnePetro line touched a source. THIS CLAIM HAS NOW MOVED TWICE AND IS RECORDED AS UNSTABLE. A third statement made with fresh confidence would assert more than the evidence has earned, so the memo carries the substrate difference as a reason (HOFFMAN-14) and never as a claim that any ranking is wrong." +HOFFMAN-12,preprint/hoffman_memo.tex,"Page 2, The substrate nobody has tested","No microbiologically influenced corrosion or biofilm study of FeCrAl was found in a web search on 2026-08-30 using the term set ""FeCrAl microbiologically influenced corrosion biofilm"".",literature_prior,,none,not_applicable,false,,literature,supported,none,"SCOPED BY METHOD, NOT BY VENUE, AND THE RETRIEVAL MODE IS PART OF THE SCOPE. A WEB SEARCH returns ranked results and not a corpus, so a study in an unlinked conference PDF will not surface however good the terms are. Naming databases would be worse than useless here: HOFFMAN-11 records that the OnePetro copy of NACE-2019-12944 returns 403 from this machine, so a scope naming NACE or AMPP would assert a search of a venue this repository has established it cannot read - and conference proceedings are the likeliest home for an FeCrAl MIC study and the least indexed part of any web scope, so the claim would be weakest exactly where the target literature would sit. CONTRAST HALF RUN THE SAME WAY, SAME TOOL AND SAME DATE, ONE TERM CHANGED: ""304L stainless steel microbiologically influenced corrosion biofilm"" returns four dedicated 304L papers on its first page (Halomonas titanicae, J Mater Sci Technol 37 2020; P. aeruginosa; C. difficile on 304; urban wastewater MIC of 304L). Comparing a curated citation list against a search would have put the asymmetry in the method instead of the field. EVERY SUBSTRATE THAT RETURNED across both searches passivates through chromia, or is a high-entropy alloy, aluminium, or carbon steel: FeCoNiCrMn, 2707 hyper-duplex, 304L, 316, carbon steel, aerospace aluminium. This is the one claim in the memo that survived Stage 1 with nothing adjacent left standing." +HOFFMAN-13,preprint/hoffman_memo.tex,"Page 2, The substrate nobody has tested","Irradiation makes aqueous corrosion of FeCrAl worse, established at reactor conditions; the same question was not found asked at pool conditions or with a biofilm.",literature_prior,,none,not_applicable,false,,literature,restate,"Name the irradiation mode and the stage dependence, and stop attaching neutron alpha-prime work to an ion-irradiation result. Done in the memo 2026-08-30.","THE DRAFT OF THIS CLAIM WAS AN ABSENCE AND THE ABSENCE WAS FALSE. It read ""no aqueous-corrosion study of irradiated FeCrAl found"", and its own search returned one on the first page: Corrosion Science 174 (2020) 108824, ""Early corrosion behaviour of irradiated FeCrAl alloy in a simulated pressurized water reactor environment"", which finds a Cr-rich oxide absent from the as-received alloy, a duplex hematite / Cr-rich-spinel structure, and SEVERE later-stage corrosion from irradiation-induced defects. Two more sit beside it (J Nucl Mater S0022311519316356 on radiation damage plus water radiolysis in hydrogenated water; npj Mater Degrad 2024 on additively manufactured FeCrAl out-of-pile). The draft would have carried a false absence to a reader who works on these alloys. IT WAS ONLY CAUGHT BECAUSE THIS CLAIM WAS GIVEN ITS OWN SEARCH: it shares no terms with HOFFMAN-12, which is microbial, and borrowing HOFFMAN-12 scope would have been false as well as wrong. The replacement is a positive finding and needs no scope. The residual absence - the same question at 30-50 C pool water, or with a biofilm - was not returned by the query ""irradiated FeCrAl aqueous corrosion spent fuel pool water"" on 2026-08-30. Supporting surface change: HOFFMAN-14 in the ledger sense, alpha-prime, verified at Field/Briggs/Edmondson, Scripta Mater S1359646217304761, Alkrothal 720 and Kanthal APMT to 7.0 dpa at 320-382 C by SANS, model-alloy cluster Cr 51.1-62.5 at percent. CORRECTED SAME DAY, AND THE SENTENCE WAS WRITTEN FROM A SUMMARY RATHER THAN THE METHOD. The memo said 'irradiation makes aqueous corrosion of FeCrAl worse'. Reading Corros. Sci. 174:108824's method shows THREE things the sentence dropped. (1) MODE: 400-keV Fe+ IONS at 400 C, not neutrons - at that energy the damage is a near-surface layer a few hundred nanometres deep, not a bulk-irradiated coupon, so transfer to a real assembly is far weaker than the sentence implied. (2) STAGE DEPENDENCE: the irradiated alloy corroded MORE SLOWLY at first, a Cr-rich oxide plus an ion-sputtered smooth surface suppressing it, with severe corrosion only later - and part of that early behaviour is a sample-preparation artifact rather than a property of irradiated material in service. (3) The footnote attached Field/Briggs/Edmondson's NEUTRON alpha-prime work to the same claim, putting two irradiation modes in one citation. THE FIX IS NOT TO DROP THE CITATION: removing it and leaving alpha-prime plus 'what makes the pool question a real question' would let a reader infer nobody has looked, when someone has, badly, in the wrong conditions. Weak evidence and no evidence are different states and that distinction is this memo's whole discipline. The sentence now says the work exists, at reactor conditions and without a film, and the residual gap names all THREE conditions - pool temperature, a biofilm, and either surface - because naming only the temperature would let the gap read as a thermal complaint. ALPHA-PRIME IS MICROSTRUCTURE AND NOTHING MORE: that source reports no passive-film composition, so 'changes the surface a film would sit on' is an inference it does not make and is not written. FOURTH INSTANCE ON THIS BRANCH OF ONE FAMILY - a claim written from a summary instead of the source - after HOFFMAN-01 (invented '34 months' and a plant name), HOFFMAN-02 (wrong paper, wrong first author), and a commit message describing a fix the commit did not contain. That family produces CONFIDENT FALSE SPECIFICS, which a careful reader cannot discount the way they can discount an overreach, and it is a different failure from the one tools/absence_gate.py catches. ALSO CAUGHT BY THAT GAP: the replacement phrasing considered first, 'what makes the pool question a real question', carries no absence marker at all, so the gate would not have surfaced it while it functioned as an absence claim - the incompleteness now recorded in KNOWN_UNHANDLED, biting on the same diff that records it." +HOFFMAN-14,preprint/hoffman_memo.tex,"Page 2, The substrate nobody has tested","FeCrAl passivates in water through a mixed Cr(III)/Al(III) film, which is a different surface from the chromia that every substrate in the MIC literature carries.",literature_prior,,none,not_applicable,false,,literature,requalify,Room-temperature aqueous passive-film composition on a commercial FeCrAl grade in borated water. Not found on 2026-08-30; the measurement exists at pH 1 on model alloys and at 360 C on commercial ones.,"THE REASON THE ABSENCE IN HOFFMAN-12 IS INTERESTING RATHER THAN MERELY TRUE, AND IT CARRIES A TEMPERATURE GAP THE FIRST DRAFT PUT IN THE WRONG PLACE. VERIFIED 2026-08-30 against J Electrochem Soc 172(12) 121501, published 5 December 2025, ""The Third Element Effect during Aqueous Corrosion of FeCrAl Alloys: Effect of Al Content"", Lynch, Sur, Connors, Redemann, Han, Opila, Scully. XPS shows Fe(II) and some Fe(III) oxide with enrichment of both Cr(III) and Al(III) rising with Al content; AESEC at 0.15 V gives Cr 22.7 percent and Al 46.3 percent surface enrichment for FeCr10Al16 against 1.1 percent Cr for binary FeCr10. SCOPE THAT MUST TRAVEL WITH THE NUMBERS: model FeCr10Alx BCC alloys, deaerated 0.1 M Na2SO4 and 0.1 M H2SO4 TITRATED TO pH 1, room temperature. The paper states it does not examine commercial grades or borated / near-neutral water. THE FIRST DRAFT SAID THE GAP WAS THE COMMERCIAL GRADES AND THAT IS TOO STRONG: a separate search returned layer-by-layer XPS depth profiling of APMT (oxides and hydroxides of Al, Cr and Fe) and borated-plus-lithiated water studies at 360 C showing an Al-Cr-rich transition layer, so Al participation in an aqueous film on a commercial grade in borated water is established. THE GAP IS TEMPERATURE. A BRIEF MIS-CITATION RECORDED SO IT IS NOT REPEATED: the research brief attributed this work partly to arXiv 2302.07988, which is ""An experimental high-throughput to high-fidelity study towards discovering Al-Cr containing corrosion-resistant compositionally complex alloys"" - Al-Co-Cr-Fe-Ni compositionally complex alloys, not FeCrAl, and it makes no passive-film cation-fraction claim. It shares Sur and Scully with the real source, which is how the two ran together. That is the third attribution error from a secondhand brief on this memo, after HOFFMAN-01 and HOFFMAN-02. Requalify rather than keep: the sentence enters the memo only as the reason a substrate differs, never as a claim that any ranking is invalid - see HOFFMAN-09 and HOFFMAN-11." +HOFFMAN-15,preprint/hoffman_memo.tex,"Page 2, What that literature does not compute",A facility is the only setting where the inputs to a dose calculation are obtainable rather than assumed.,literature_prior,,none,not_applicable,false,,literature,restate,"Drop ""the only setting"". The argument does not need the universal and the evidence does not reach it.","CAUGHT BY THE ABSENCE-SENTENCE GATE ON ITS FIRST RUN OVER EXISTING PROSE, and it had been in the memo since version 1.1 unread by anything. ""The only setting"" is a universal about all settings wearing positive grammar, which is the shape none of the four known-bad sentences the gate was built from had; it was flagged by the ""only"" pattern. Nothing establishes that no other setting supplies a bounded geometry, a characterised source, a measurable metal inventory and a dose rate in the D10 regime - a lab irradiator supplies the first two - and the sentence happens to flatter the recipient own kind of facility, which is the direction that should raise suspicion rather than lower it. The argument survives intact without the universal. THE GATE ALSO PRODUCED ITS EXPECTED FALSE POSITIVE in the same run, ""A biofilm is none of the three"", which is a category statement about steam, hydrothermal chemistry and hydrogen permeation and needs no scope. Grep proposes, a human disposes; a gate that flagged neither would have been inert and one that flagged only would have been noise." +HOFFMAN-16,preprint/hoffman_memo.tex,"Page 3, The transport half","Whether a biofilm makes FeCrAl corrosion better or worse is unestablished, and the memo states it in both directions.",literature_prior,,none,not_applicable,false,,literature,supported,none,"EVIDENCE FOR THE WORDING HOFFMAN-09 ALREADY CARRIES, ADDED WITHOUT REOPENING IT. The memo says ""whether a film enhances or suppresses them"" at page 3, poses the ranking question to the recipient, and says a biofilm ""changes"" the local radiolysis field. Two sources verified 2026-08-30 make that direction-neutrality correct rather than merely cautious. FIRST, biofilms can INHIBIT: Shukla, Karley and Rao, Arch Microbiol 207:131 (2025), electrochemical impedance spectroscopy shows biofilm-mediated corrosion inhibition on SS-304L, with high-protein EPS proposed as the mechanism. SECOND, the one famous spent-fuel-pool MIC case was formally withdrawn: SRS ms9800702 states ""This assessment was found to be in error"", that ""A biofilm was found on aluminum coupons, but resulted in no corrosion"", that the surface cracks came from autoclave pre-conditioning oxide expansion, and that ""There has been no pitting caused by MIC or any other corrosion mechanism seen in the RBOF basin since initiation of the SRS Corrosion Surveillance Program in 1993"" - the only pitting on any coupon came from embedded iron filings. A WEB SEARCH DID NOT FIND THE RETRACTION AND THE PRIMARY STATES IT IN AS MANY WORDS, which is the argument for reading sources rather than summaries. Any memo sentence implying a biofilm degrades FeCrAl would contradict both." +PP-26-01,preprint,"section 2.6, added in version 1.2","Primary production in a spent fuel pool is done by hydrogen-oxidising bacteria using radiolytic H2 and by algae under pool lighting, neither of which is among the seven species modelled.",literature_prior,,none,not_applicable,false,,literature,supported,none,"VERIFIED 2026-08-30. Foster, Boothman, Ruiz-Lopez et al., Front. Microbiol. 11:587556 (2020), ""Identification of a Stable Hydrogen-Driven Microbiome in a Highly Radioactive Storage Facility on the Sellafield Site"": an indoor, hyper-alkaline, oligotrophic, radioactive spent fuel storage pond, sampled for 16S and 18S rRNA genes over 30 months, colonised mainly by Bacteria including the hydrogen-oxidising Hydrogenophaga, with hydrogen-metabolism genes enriched in the indoor pond. Algal route from Ruiz-Lopez et al., Front. Microbiol. 14:1261801 (2023) on algal blooms in the Sellafield Pile Fuel Storage Pond. NOTE THE OPERATIONAL CONTROL IS ULTRASONIC IN THAT PAPER, not algaecide, and the manuscript says only that blooms develop - the control method is not claimed." +PP-26-02,preprint,"section 2.6, added in version 1.2","The CPM acceptance path never reads the nutrient field, so the model does not resolve carbon as a driver of the trajectory.",repository_fact,,none,not_applicable,true,biofilms_potts.jl compute_delta_H_terms; mcs_step!; tests/manuscript_claims_tests.jl,code,supported,none,"VERIFIED IN-REPO 2026-08-30 by counting nutrient-field reads inside each function body: zero in compute_delta_H_terms and zero in mcs_step!. This is the same fact decided_moves.jl relies on to acknowledge no gated basis. IT CARRIES THE PARAGRAPH SAFETY that a physiological claim was going to carry and could not - see PP-26-03. Without it the paragraph describes a food web whose primary producers are absent from the model and leaves the reader to infer that the modelled species depend on a supply the model represents, which it does not. WORDING CORRECTED BEFORE COMMIT: the first draft said organic carbon is an EXOGENOUS INPUT the model does not represent. Exogenous implies an input arriving from outside, and zero reads means there is no input at all - the field exists and the acceptance rule does not consult it. Those are different models and the second is the one this code implements. AND ZERO READS IN TWO FUNCTIONS IS NOT ZERO READS IN THE PROGRAM, which was checked rather than assumed: state.nutrient is initialised by init_nutrient!, integrated every step by update_nutrient! and update_nutrient_coupled!, serialised at export_checkpoint.jl:206, round-tripped by tests/checkpoint_io_tests.jl:102 and parity-checked by the JACC port at biofilms_potts_jacc.jl:657. It is a LIVE field the acceptance path does not consult, which is a third state distinct from dead code and from a wired-but-unused input. calibration/biofilm_calibration/spatial/time_observable.py:22 records the same fact independently and predates this row. NOW ENFORCED, BECAUSE A COUNTED ZERO EXPIRES LIKE THE F_s GREP: tests/manuscript_claims_tests.jl extracts both function bodies and asserts no read, with a planted-read positive control, a comment-mentioning-the-field negative control so documenting the absence cannot trip it, and an assertion that update_nutrient! DOES read the field - if that ever came back empty the section would be describing dead state and would need rewriting rather than re-passing. Mutation-checked: one planted read in compute_delta_H_terms fails it. SCOPE CLOSED 2026-08-30: THE SENTENCE SAID 'THE TRAJECTORY' AND THE TEST HELD TWO FUNCTIONS. Stating at one scope while enforcing at another is the F_s defect this same file exists to prevent, so the check now covers EVERY function in biofilms_potts.jl that writes the lattice, and pins that set so a new mover fails rather than inheriting a claim made before it existed. THE HAND-WRITTEN SET WAS WRONG IN BOTH DIRECTIONS AND THE ASSERTION CAUGHT IT ON ITS FIRST RUN: it named divide_cell! and missed place_cell!, because divide_cell! mutates the lattice THROUGH place_cell! rather than directly. Real set: compute_delta_H_terms (a trial write and restore), init_state, mcs_step!, place_cell!. All four read zero nutrient, so 'the trajectory' is earned rather than merely asserted." +PP-26-03,preprint,"section 2.6, added in version 1.2",No blanket statement is made about the carbon source of the seven modelled species.,repository_fact,,none,not_applicable,false,,literature,keep,none,"A CLAIM WITHDRAWN BEFORE IT WAS WRITTEN, AND THE DISPOSITION WAS SET IN ADVANCE. The paragraph was planned around ""none of the seven uses CO2 as its carbon source"", with the pre-stated disposition that if verification failed the paragraph would not be written in that form. It failed for two species. S. oneidensis MR-1 is a chemoorganoheterotroph by nature but carries a cryptic reductive glycine pathway that fixes CO2 when supplied engineered electrons - demonstrated with illuminated biogenic CdS nanoparticles (Angew. Chem. Int. Ed. 2024) and electrochemically to acetate (2025) - and it is the species an MIC reader checks first. Ochrobactrum sp. strains with Calvin-Benson-Bassham CO2 fixation have been isolated from non-photosynthetic communities, switching to autotrophy after heterotrophic enrichment, so the genus contains facultative autotrophs and strain AM7 was NOT checked individually. NEITHER CAPABILITY IS AVAILABLE IN A POOL, which is why the narrower reading is defensible and the blanket one is not: the difference is exactly the kind a reviewer can check. The protective work is done instead by PP-26-02, a fact about the code rather than about physiology. Recorded because a claim that fails verification and is dropped leaves no trace in the document, and the reason it was dropped is the useful part. THE SHAPE OF THE FAILURE, WHICH IS THE TRANSFERABLE PART. The claim was narrowed twice before it was checked - from 'fungi cannot fix CO2' to 'none of the seven uses CO2 as its carbon source' - with the two riskiest species named in advance. It failed at both of them anyway. NARROWING A UNIVERSAL REDUCES ITS EXPOSURE WITHOUT CHANGING ITS TYPE: a universal over seven named organisms is a search problem with seven independent chances to fail, and picking the right two to worry about does not help if the boundary is drawn in the wrong place. The repair was not a better boundary. It was abandoning the universal for a property of a BOUNDED ARTIFACT this repository controls - see PP-26-02, which a test can hold and a literature claim about seven organisms never could. This is a distinct failure family from claim-written-from-summary (HOFFMAN-13): that one produces confident false specifics from not reading a source, this one produces a checkable universal from a correct reading with the quantifier left too wide. A FINDING RECORDED WITH NO INDEX, WHICH COST A FULL VERIFICATION CYCLE. The fact underneath PP-26-02 was already established and already written down in the right register: calibration/biofilm_calibration/spatial/time_observable.py:22 says 'state.nutrient is written but never read by the acceptance test', alongside 'divide_cell! has no trigger' and 'there is no growth rate anywhere'. It predates this work by an unknown interval and was rediscovered by a fresh grep several turns later, because a docstring is a place a fact is RECORDED and not a place anything SURFACES it. That is this repository's own subject arriving from a direction nobody was watching, and the distinction is exactly recorded-versus-enforced. Other observations of that kind may be sitting in docstrings elsewhere; they would be findings with no index, and nothing here has swept for them." +PP-26-04,preprint,sections 3.x and 6.x; biofilms_potts.jl header,divide_cell! has no trigger.,repository_fact,,none,not_applicable,true,biofilms_potts.jl,code,needs_verification,"Assert BOTH conditions: the set of call sites, and that no call site is reachable from the trajectory. A pinned set alone is not enough - see notes.","STATED IN THE MANUSCRIPT IN FOUR PLACES AND HELD BY NOTHING. The .tex says ""divide_cell! has no trigger"", ""the program has neither growth nor death"", ""a manually invoked division primitive for lifecycle and lineage testing, but no growth law or automatic division trigger"", and ""still has no growth"". biofilms_potts.jl header repeats it. IT IS LOAD-BEARING FOR THE CENTRAL RETRACTION: section 2.6 argues the model cannot test radiotrophy BECAUSE it has neither growth nor death, so if this ever stopped holding the retraction would rest on a false premise. TWO INDEPENDENT WAYS TO GO FALSE, AND A PINNED CALL-SITE SET COVERS ONE. Someone adds a second caller, which a set assertion catches; or someone makes the EXISTING caller reachable from the trajectory, which a set assertion is silent on. The second is the change that matters, so a check built only from the set would pass through exactly the event it exists to detect - green for the reason it was built, which is the defect the first plan of this branch was rejected for. THE CLAIM IS ALREADY LOOSER THAN IT READS: there IS a call at biofilms_potts.jl:1978. The checkable statement is about reachability from the trajectory and not about the absence of callers, which is the same distance as ""no nutrient reads"" to ""no nutrient reads in the acceptance path"" - see PP-26-02. Conversion pattern available: pinned set, premise check, planted-positive control, as built for PP-26-02 in tests/manuscript_claims_tests.jl." +PP-26-05,preprint,sections 3.x and 6.x; biofilms_potts.jl header,The code contains no growth rate or growth law anywhere.,repository_fact,,none,not_applicable,true,biofilms_potts.jl,code,needs_verification,Narrow the ONE methods sentence that uses the unbounded form (the .tex at 'The code has a manually invoked division primitive ... but no growth law or automatic division trigger; it likewise contains no radiation-dependent survival process'). The load-bearing sentences are already narrow and convert with the PP-26-04 pattern.,"AN ABSENCE OVER UNBOUNDED NAMING, WHICH IS THE HARD CASE AND DOES NOT CONVERT CLEANLY. A symbol grep bounds it only as far as its declared term set reaches, and growth can be named mu, k_growth, doubling, biomass_rate and indefinitely more - the same incompleteness tools/absence_gate.py now prints on every run for absence PROSE, arriving here as absence CODE. THE USEFUL FORM IS WEAKER THAN THE SENTENCE CURRENTLY IMPLIES: the manuscript should state the term set inline, the way the memo page-2 absence names its retrieval mode, and the test should hold that same set. Holding a bounded grep behind an unbounded sentence is precisely how the test scope and the sentence scope drift apart, which is the F_s defect and the defect PP-26-02 was corrected for on 2026-08-30 within hours of being written. ORIGIN: recorded in calibration/biofilm_calibration/spatial/time_observable.py:22 alongside the divide_cell! claim and the nutrient claim - three findings in one docstring, in the right register, indexed by nothing. Two are now here; the nutrient one became PP-26-02 and is enforced. THE UNCOMFORTABLE PAIRING DOES NOT EXIST, AND FINDING THAT OUT TOOK READING THE FOUR SENTENCES. The concern raised was that the weaker check guards the stronger claim: PP-26-05 does not convert cleanly while section 2.6's central retraction leans on it. It does not. The retraction argument reads 'The CPM ... has neither biomass growth nor a dose-dependent survival process, so the implemented phenotype is radiotropism and the model cannot test radiotrophy or radioresistance', and the companion at the radiotropic-flag paragraph reads 'Because the program has neither growth nor death, the flag denotes a spatial tropism'. BOTH ARE STATEMENTS ABOUT DYNAMICS, not about what identifiers exist in a file, so both are checkable exactly the PP-26-04 way - no growth process reachable from the trajectory - and neither needs the unbounded grep. Only ONE sentence uses the unbounded form, and it is a METHODS description rather than the argument: 'The code has a manually invoked division primitive ... but no growth law or automatic division trigger; it likewise contains no radiation-dependent survival process.' A fourth statement, 'still has no growth or survival process', is about the R Shiny program and is a different artifact with a different scope again. SO THE REPAIR IS TO NARROW ONE METHODS SENTENCE, not to strengthen a grep and not to weaken the retraction - the same repair as abandoning the seven-organism universal in PP-26-03: change the claim's type rather than hunt a better boundary. FOURTH TIME THIS PASS THAT READING THE ARTIFACT MOVED SOMETHING OUT OF THE CATEGORY IT WAS OFFERED IN: the docstring notes were manuscript claims, place_cell! was in the mover set and divide_cell! was not, the nutrient field was live rather than dead, and now the unbounded claim turns out to be one methods sentence rather than the retraction's premise. THE CATEGORISATION KEEPS BEING WRONG MORE OFTEN THAN THE FACTS DO, which is worth knowing before the next audit budgets its time. AND THIS LANDING IS CLOSE TO THE OPPOSITE OF THE USUAL FAILURE, WHICH IS WHY IT IS WORTH RECORDING RATHER THAN JUST FIXING. The manuscript's strongest negative claim - the one the whole radiotrophy retraction rests on - was ALREADY stated at the scope its evidence reaches. What overreached was the methods aside. Everywhere else on this branch the load-bearing sentence was the one stated too widely, so an audit that goes straight for the central claim would have found it clean here and stopped. WHY THE MIS-FILING KEEPS HAPPENING, WHICH IS A MECHANISM AND NOT A RATE. A fact has a defined verification operation: open the source, run the grep, read the method. A KIND is decided in passing, usually by whoever first mentions the thing, and inherited downstream without anyone ever performing an operation on it. Filing is not harder than fact-checking; nothing in the workflow is SHAPED like a filing check, so no step exists at which the classification could fail. That is why the facts held four times out of four and the filing did not. THE PRACTICAL FORM IS A QUESTION, NOT A BUDGET: for each item, what would change if this were the next tier up? The nutrient field - if this is a manuscript premise and not a docstring note, it needs a test. divide_cell! - if this is a CALLER and not a mutator, the pinned set is wrong. The question costs nothing, is answerable without opening anything, and was asked zero times out of four. Candidate for AGENTS.md if it earns a seventh rule; recorded here as evidence in the meantime, since the four instances are what would justify one." +PP-V11-05,preprint/modeling_radioresistance_and_radiotropic_fitness.tex,Abstract; section 3.11 heading,"The coined term radiodialysis was used as established vocabulary in the abstract and in the section heading, above the paragraph that declares it coined.",repository_fact,,none,not_applicable,false,,code,restate,none,"THE FIGURE DEFECT'S SHAPE, IN PROSE, AND CAUGHT THE SAME WAY. Section 3.11 disowns the term in a full paragraph -- 'a targeted search finds no scientific usage of it outside this work' -- and names the established descriptors (radiolysis, lipid peroxidation, 'radiation-induced membrane permeability change'). The term then appeared ten times and the disclaimer covered one of them; the abstract said 'a radial radiodialysis solver' with no signal the word was coined here. Caveat in the body, claim in the part people scan. FIXED 2026-08-29 by making the rule positional rather than editorial: the term must not appear before the paragraph that disowns it. Two lines moved -- the abstract now says 'a radial membrane-transport solver' and the heading is 'Membrane Transport Under Radiation-Driven Permeability Change'. Nine uses remain, all after the disclaimer, all introduced. A filename (biofilms_radiodialysis.R) and a \label are exempt: one is what the file is called and the other never reaches a reader. WHAT WAS CONSIDERED AND REFUSED: giving 3.11 the 'This subsection is specification, not method' paragraph that 3.2, 3.4 and 3.12 carry. Those three stand over code that does not exist; 3.11's equations run in biofilms_radiodialysis.R and in Julia, with results in 6.5. That paragraph would have been a new false statement in the opposite direction. NO VERSION BUMP AND NO CORRECTION ENTRY: version 1.1 has been sent to nobody, and a correction paragraph is for a claim that reached a reader. Enforced by test_the_coinage_never_precedes_its_disclaimer with a planted-use control." +SURR-01,data/calibration/spatial/dataset_candidates.csv,target_surrogate_phantom_search; hellriegel_2014_gellan_biofilm_imitate,"A biofilm phantom bounds the variance of the measurement protocol and cannot satisfy D-RHOWET, which requires paired wet mass and hydrated volume on a biofilm coupon.",design_principle,,none,not_applicable,true,data/calibration/reference_d_requirements.csv D-RHOWET,repository_policy,keep,none,"THE BOUNDARY THAT MAKES THE SURROGATE AXIS SAFE TO OPEN AT ALL. A phantom is attractive here precisely because it supplies what no real biofilm in the literature does -- a specimen whose hydrated volume and dry mass are set by construction rather than measured -- and that is also exactly why it must not be allowed to discharge a measurement gate. D-RHOWET requires 'paired wet mass and hydrated volume on the SAME coupon, with a stated surface-water removal protocol and a matched blank', on a biofilm; a gellan gel at about one percent solids has water's density and bounds nothing about a biofilm's dry fraction. WHAT A PHANTOM DOES BUY is the separation the wet/dry CV step currently cannot make: protocol variance (drying endpoint, surface-water removal, blank subtraction) measured against a known true value, versus material variance. Detectability, then CV, then replicates -- with the first two done on something whose answer is known. density_g_cm3 STAYS BLANK, D-RHOWET stays awaiting_measurement, and nothing here moves Reference D or CAMPAIGN_READY. The Hellriegel moduli are a bound for the section 3.10 Kelvin-Voigt constants at a class BELOW a literature prior on the organism, and are entered nowhere as a value." +PP-62-04,preprint,"section 6.2, Table 4 (decided moves)","The direct radiation term reversed no accepted move: zero of 206042 across seeds 42, 43 and 44 at 400 MCS.",model_output,0,count of accepted moves reversed,true,true,biofilms_potts.jl decisive_label; tests/delta_h_decomposition.jl,code,restate,"Report one of 206042, not zero, and delete the bound argument: state that removing Delta H_rad alone would reverse sixteen accepted moves and that fifteen are absorbed into `multiple`. Done in the manuscript at version 1.2.","RESTATED 2026-08-29, AND THE ARITHMETIC UNDER IT WAS WRONG BY 1.5e3. Codex raised it on PR #23 and it reproduces. Delta H_rad is signed by ROLE as well as by beta_ion: compute_delta_H_terms adds +beta_ion[source]*I when the source gains a site and SUBTRACTS beta_ion[target]*I when the target loses one, so a POSITIVELY signed species vacating a site favours acceptance exactly as much as a negatively signed one occupying it. The bound is therefore max|beta_ion| = 7.5e-2, not min|beta_ion| = 5e-5. Measured over 192996 evaluated proposals from the seed-42 state at 100 MCS: 29.8 percent carry Delta H_rad < -5e-5 and the extreme is -0.0671. THE COUNT ALSO MOVES: re-measuring Table 4's own configuration gives rad solely decisive in 0 / 0 / 1 moves for seeds 42 / 43 / 44, so one of 206042 and not zero. THE CORRECTED ARITHMETIC PREDICTS THE DATA AND THE OLD ARITHMETIC DOES NOT: removing Delta H_rad alone reverses 16 accepted moves (4/3/9 by seed) against 16.67 predicted by the true bound and 0.26 by the withdrawn one. Fifteen of the sixteen are labelled `multiple` because an adhesion or volume term was independently decisive too, which is why the table reads 0.00 percent -- absorption, not absence. MEASURED, AND IT FOLLOWS ANALYTICALLY FROM PP-62'S OWN TABLE 3 RATHER THAN ADDING TO IT. For an ACCEPTED move only a term that lowered Delta H can be decisive: removing one that raised it lowers Delta H further and the move stays accepted. beta_ion is negative for two of seven species at -5e-5, so the largest radiation contribution that can favour an accepted move is of that order, and reversing the move needs the drawn variate within ~1e-5 of the threshold. Expected reversals over 53603 moves: about 0.5. Zero is therefore the expected observation and NOT an under-sampling artifact, which is why three seeds are reported rather than one. Per-seed accepted totals 53603 / 68465 / 83974. This is Table 3's one-part-in-1e5 as a count. THE 29.8 PERCENT AND -0.0671 QUOTED ABOVE ARE FROM AN UNNAMED RUN AND ARE SUPERSEDED; see PP-62-09 and PP-62-12. The dependent draft in docs/correspondence/wan_v11_note.md still carried the withdrawn zero and was corrected in the same pass -- raised by Codex as a P1 on PR #23, and the audit-dependents step of the correction protocol had missed it. SECOND DEPENDENT, SAME MISS: the PR #23 description carried the withdrawn zero and its withdrawn bound argument until 2026-09-01, three days after the Wan note was corrected for the identical reason. BOTH DEPENDENTS WERE OUTSIDE THE REPOSITORY -- one a drafted letter, one a GitHub field -- and the audit-dependents step had been run as a grep over tracked files, so its scope was silently 'what I can grep' rather than 'what asserts this claim'. The PR description is the first thing a reader of this work sees and it is not a file." +PP-62-05,preprint,"section 6.2, Table 4 (decided moves)","The melanin term reversed 23 of 53603 accepted moves at 400 MCS, seed 42.",model_output,23,count of accepted moves reversed,true,true,biofilms_potts.jl decisive_label; tests/delta_h_decomposition.jl,code,keep,none,"0.04 percent, and stable across seeds at 0.03 to 0.05 percent. Restates section 6.2's closing sentence -- hand-specified adhesion differences remain larger than both radiation-derived terms -- in the register of decided moves. NOT A CLAIM ABOUT MELANIN BIOLOGY: the 0.5 coefficient in compute_delta_H_terms is hard-coded and is ledgered separately as cpm.melanin_coupling; this row reports how often that hard-coded number decided an outcome, which is a property of the model's acceptance arithmetic and of nothing else." +PP-62-06,preprint,"section 6.2, Table 4 (decided moves)","Between 66 and 77 percent of accepted moves are reversed by removing no single term, so the dynamics are carried by the sum.",model_output,65.99 to 76.83,percent of accepted moves,true,true,biofilms_potts.jl decisive_label; tests/delta_h_decomposition.jl,code,keep,none,"THE COMPLEMENT IS THE RESULT, and it is the reason the named-term rows are small rather than a caveat on them. Seed 42 is the low end at 65.99 percent; seeds 43 and 44 sit at 76.83 and 76.74. The contingent share -- the Delta H <= 0 branch, where no variate was drawn and removing a term can only remove the certainty rather than reverse the move -- moves in the opposite direction, 23.72 / 16.53 / 17.00. The two branches are disjoint by construction and tests/delta_h_decomposition.jl asserts that over 20000 random term vectors rather than leaving it as prose." +PP-62-07,preprint,"section 6.2, closing paragraph",No spatial map of which term dominates where is reported; at 400 MCS the median written voxel received three accepted moves.,design_principle,3,accepted moves per touched voxel (median),true,true,biofilms_potts.jl decisive_label; tests/delta_h_decomposition.jl,code,keep,none,"A REFUSAL, RECORDED BECAUSE THE WORK TO MAKE THE MAP WAS DONE AND THE MAP WAS NOT SHIPPED. The per-voxel tally and modal-label reduction exist in biofilms_potts.jl and are tested; the measurement they were built for is what argues against drawing them. At 400 MCS 6157 of 64000 voxels received any accepted move at all, median 3 and 25th percentile 2, across seven label categories -- a mode over three samples is noise, and binding an opacity rule to the count would annotate the noise rather than remove it. TWO FURTHER OPTIONS WERE REFUSED. A map restricted to the ~10 percent of moves carrying a named decisive term would filter out the 90 percent where the sum carried the move and show a model looking more term-driven than it is, which PP-8-04 forbids in substance if not in wording. Reporting nothing would have discarded PP-62-04, which is a real corroboration of Table 3. The distribution is reported; the crop is not." +PP-62-08,preprint,"section 6.2, The same ranking as a count of decided moves","The counterfactual uses the variate the acceptance test already drew, so the trajectory is unchanged with the tally attached.",repository_fact,,none,not_applicable,true,biofilms_potts.jl mcs_step!; tests/contract_csv.jl; tests/delta_h_decomposition.jl,code,keep,none,"u = Delta H <= 0 ? NaN : rand(rng) evaluates only its taken branch, so the generator is consulted on exactly the moves it was before. THREE ALTERNATIVES WERE REFUSED, each fabricating something the run does not contain: drawing a fresh variate advances the stream and diverges the trajectory; a second generator makes the label stochastic and adds a seed to declare; a probability threshold on the Delta H <= 0 branch invents a constant. VERIFIED TWO WAYS because one is insufficient: contract_csv.jl is byte-identical with the tally attached, and it has a detection floor now documented in validate_serial.jl -- 0.5 to 0.5000001 in the melanin coefficient leaves it green. The direct check compares lattice and melanin arrays with and without the tally, with a different-seed control proving that comparison can fail." +JACC-PAR-01,tests/jacc_parity_tests.jl,whole tier,No checkerboard parity artifact is detected: Cramer's V for the 2x8 accepted/rejected table stays at or below 0.011 over three seeds and three colour orderings.,numerical_result,0.0050 to 0.0111,Cramer's V,true,true,biofilms_potts_jacc.jl cpm_color!; tests/jacc_parity_tests.jl,code,keep,,"THE MEASUREMENT NOTHING ELSE MAKES. A parity-correlated bias in accepted moves is invisible to both existing guards: jacc_port_tests.jl compares kernels on identical inputs, so it passes when BOTH kernels carry the same artifact, and tests/fixtures/serial_seed42.csv pins the serial stream, which has no sublattices at all. Measured at N=20, 50 MCS, threads backend, seeds 42/43/44 x identity/perm1/perm2: V 0.0050-0.0111, max per-class rate deviation 0.017-0.049, n 127079-137292 evaluated proposals per run. Thresholds V<0.025 and maxdev<0.12 are those observations widened about 2x and are OBSERVED, NOT DERIVED. A synthetic control holding one class 20 percent low clears both, so the guard is known to be able to fire." +JACC-PAR-02,tests/jacc_parity_tests.jl,the contingency table,"The parity null is equal acceptance RATE per class, not equal accepts per class; the table is conditioned on opportunity.",design_principle,,none,not_applicable,true,tests/jacc_parity_tests.jl parity_stats,code,keep,,"A UNIFORM NULL OVER EIGHT CLASSES WOULD HAVE FAILED ON ITS FIRST RUN FOR THE SHAPE OF THE DOMAIN. cpm_color!'s early returns -- wall, same-sigma, medium-into-medium, out-of-bounds -- are all geometry-dependent, and an aggregate sitting at an arbitrary lattice position has no reason to spread them evenly over a 2x2x2 decomposition, so a class with fewer evaluated proposals shows fewer accepts. The `st` discriminator carries the denominator (0 never proposed, 1 evaluated-rejected, 2 evaluated-accepted) and the 2x8 table asks about the rate. `dh` carries NO sentinel for the same reason a NaN there would collapse never-proposed into NaN-Delta-H and silently shrink every denominator; the test asserts dh finite wherever st != 0." +JACC-PAR-03,tests/jacc_parity_tests.jl,threshold choice,Chi-square is reported and never asserted alone; the thresholds are on effect size.,design_principle,,none,not_applicable,true,tests/jacc_parity_tests.jl parity_stats,code,keep,,"A REFUSAL, AND THE SECOND ONE HERE. Chi-square power scales with n and n is 1.3e5 per run at N=20 and 3.1e5 at N=40, so a fraction-of-a-percent deviation clears any fixed critical value: at N=40 the observed chi-square ran 8.3 to 59.8 against a df=7 critical value of 18.475 while V never exceeded 0.014. Worse, the cells are NOT independent -- an accepted move changes the lattice for every later pass -- so the nominal null does not apply and over-dispersion is expected from autocorrelation alone. A chi-square threshold would test 'is there any asymmetry at all', and there always is. Same interaction as a critical value quoted without its n." +JACC-PAR-04,tests/jacc_parity_tests.jl,colour-order permutation,"Spatial class and pass position are separated by random permutations of color_order, not by reversal.",design_principle,3,colour orderings compared,not_applicable,true,biofilms_potts_jacc.jl run_coupled color_order; tests/jacc_parity_tests.jl,code,keep,,"A CONFOUND THAT WOULD HAVE REPORTED THE PONYTAIL SHORTCUT AS A DECOMPOSITION DEFECT. `for c in color_order` is sequential and vols accumulates across passes -- delta_H reads vols at biofilms_potts_jacc.jl:167 while cpm_color! mutates it at :208, the documented bounded-and-unbiased staleness -- so the first pass evaluates against sweep-start volumes and the last against volumes moved by seven passes. That is a deterministic, parity-correlated acceptance difference with nothing to do with the checkerboard, and c indexes BOTH spatial class and sequence position. REVERSAL WAS REFUSED: position(c) = 7-c separates a monotonic position effect but is invariant to any position effect symmetric about the midpoint, so one transformation leaves one blind spot. Random permutations drop the assumption about the effect's shape. THE THIRD OUTCOME IS THE OBSERVED ONE: the pattern tracks neither class nor position at this V, and the permuted run is a different trajectory rather than the same system observed differently, so the tier asserts the bound and reports both rate vectors rather than claiming an attribution." +JACC-PAR-05,biofilms_potts_jacc.jl,cpm_color! instrumentation,The acceptance instrumentation leaves the port's trajectory unchanged.,repository_fact,,none,not_applicable,true,biofilms_potts_jacc.jl cpm_color!; tests/jacc_parity_tests.jl,code,keep,,"VERIFIED CROSS-VERSION, BECAUSE IT IS A CROSS-VERSION CLAIM. Comparing an on_sweep=nothing run against an instrumented run at the same commit compares the new code to itself: the writes are unconditional, so both paths execute identical kernel code. The check that means something ran the port AT 7712568 and after the change: threads backend, 1 thread, seed 42 lattice sha256 3f528fab5b725dfd both sides (4884 occupied), seed 43 a4a8972857d9a893 both sides (4883). SCOPE, STATED: that comparison is only meaningful because a reproducible reference exists, and it exists only single-threaded. THE POSITIVE CONTROL ESTABLISHES THE CHECK CAN FAIL: the same seed at 4 threads gave three different lattices in three runs (42a1815c, f42c3bf4, a824b7ef), so the vols race is real, manifests, and is detected. The port is NOT reproducible across thread counts or backends and this row does not claim it is." +JACC-PAR-06,preprint/figures/fig5_acceptance_regime.pdf,whole figure,"The Metropolis acceptance rate is 0.062 pooled at N=40, ranging 0.025 to 0.414 per sweep across seeds 42/43/44 at 100 MCS.",model_output,0.06202,accepted / evaluated proposals,true,true,jacc_acceptance_figure.jl; tests/jacc_parity_tests.jl,code,keep,,"THE REGIME QUESTION THE LATTICE PICTURE CANNOT ANSWER. Acceptance is 1 for Delta H <= 0 and exp(-Delta H / T_cpm) otherwise, so T_cpm = 5.0 sets everything: near 1 the aggregate dissolves, near 0 it freezes, and both render as a simulation that is plainly running. 0.062 is a usable cold-side regime. THE PER-SWEEP MAXIMUM OF 0.414 IS AN INITIALIZATION TRANSIENT, not the steady state, which is why the tier compares first-half against second-half tables before believing any pooled number. SETTINGS-SPECIFIC: the test's band 0.14-0.23 is at N=20, where the rate is about 0.18, because V_target is fixed at 120 while lattice volume is not. The figure's provenance line names its own run (N, MCS, seeds, colour order, backend, thread count) so the .txt sidecar the phrase guard reads says which run it shows; the test spans three seeds and three orderings and would otherwise be about a different thing." +FIG-05,preprint/figures/fig3_membrane_transport.pdf,in-plot annotation,(50 Gy cumulative),model_output,50,Gy,false,true,biofilms_potts.jl:2400-2424 (annotation removed at d53f236); regenerate_fig3.jl,simulation_output,delete,Nothing further; the artifact was regenerated 2026-08-29 and no longer carries it,"PRINTED BESIDE m = 0.779, WHICH STAYS: that value is arithmetically exact and RM-KR-02 keeps it; only the Gy gloss is retracted. THE ARTIFACT HALF OF RM-KR-01, AND IT SURVIVED FIFTEEN DAYS. d53f236 (2026-08-14) dropped the ungrounded 50 Gy annotation from the generator and RM-KR-01 already carried the verdict -- NEVER PRINT Gy HERE, because D_cum = Ddot_R*t is dimensionless model time and no calibration converts it. The committed PDF went on printing it because fig3 could not be rebuilt: main_coupled() hits RADIODIALYSIS: BLOCKED, and 1b51126 -- which retired the same defect in figs 1-2 through main()'s --no-radiolysis path -- could only add .sha256/.txt sidecars over a fig3 pdf it had no way to regenerate. NOT CAUGHT BY THE FIGURE GUARD: RETRACTED_IN_FIGURES scans for 'radiotroph' only, and this string contains no retracted vocabulary. It was found by opening the image." +FIG-06,preprint/figures/fig3_membrane_transport.pdf,in-plot annotations,Two annotations printed on top of each other and neither was readable.,repository_fact,,none,not_applicable,true,biofilms_potts.jl:2400-2424,code_inspection,keep,none,"ARITHMETIC, NOT A RENDERING FLUKE, WHICH IS WHY IT RECURRED ON EVERY REGENERATION. Both labels were placed at x = 0.55*t_end from FINAL values: m_final + 0.05 = 0.829 on the left axis and Peff_final * 0.88 = 2.385 on the right. Those are independent numbers on independently autoscaled axes, and both mapped to 79 percent of plot height. THE FIX SEPARATES THEM HORIZONTALLY -- 0.35 and 0.80 of the run -- which makes the vertical coincidence moot however the axes rescale, and anchors each label to its own curve at its own x rather than to a height derived from a value plotted somewhere else. A vertical nudge would have fixed this dataset and left the coincidence in place." +FIG-07,repository,preprint/figures/,fig3 can be regenerated under the radiodialysis basis gate; fig4 cannot.,repository_fact,,none,not_applicable,true,regenerate_fig3.jl; tests/radiodialysis_basis_gate.jl,code,keep,,"SCOPES THE BLOCKER FIG-03 RECORDS, WHICH WAS STATED FOR BOTH FIGURES AND IS TRUE OF ONE. The gated basis enters the radiolysis ODE at exactly one place -- uptake = k_ads*X_total + k_red*X_red -- which drives c and s and nothing else. fig3 plots m(t) and P_eff/P0, where dm/dt = -k_dam*Ddot_R*m and P_eff = P0*exp(alpha_P*Ddot_R*t); neither reads X_total or X_red. MEASURED, NOT ARGUED, AND THE CONTROL BITES IN BOTH DIRECTIONS: at 10x the uptake constants fig3's two series come back identical and fig4's c_wall does not, and regenerate_fig3.jl REFUSES to copy if either half fails -- so it cannot pass by measuring nothing. fig4 stays blocked and FIG-03 stands." +PP-62-09,preprint,"section 6.2, Table 3 (term magnitudes)","The largest acceptance-favouring contribution of Delta H_rad is 7.5e-2, set by max|beta_ion| and by the target-loses-a-site role.",numerical_result,0.075,dimensionless (Delta H),true,true,biofilms_potts.jl compute_delta_H_terms; tests/prose_bounds.jl,code,restate,"State the extremum over source/target pairings, 7.505e-2, rather than max|beta_ion| over species. Done in the manuscript at version 1.2.","REPLACES THE 5e-5 BOUND PP-62-04 CARRIED. The term is +beta_ion[source]*I when the source gains a site and -beta_ion[target]*I when the target loses one, so the sign is a property of the ROLE and the magnitude is bounded by max|beta_ion| over species, not by the smallest magnitude among the negatively signed ones. At the shipped I0 = 1.0 that is 7.5e-2 for S. oneidensis, and the acceptance bias is exp(0.075/5) = 1.0151 rather than the 0.985 Table 3 showed for the same coefficient in the other role. MEASURED, not only derived: over 192996 evaluated proposals from the seed-42 state at 100 MCS the extreme is -0.0671 (below 7.5e-2 only because I_local < 1 away from the axis) and 29.8 percent of proposals violate the withdrawn bound. Guarded by tests/prose_bounds.jl, which reads the coefficient vector out of the source and the bound out of the .tex and fails if they disagree -- the check the manuscript did not have when the wrong bound was written. CORRECTED AGAIN 2026-08-29, SAME PARAGRAPH, SECOND REVIEW PASS ON PR #23. The 7.5e-2 replacement was still an extremum over SPECIES; the quantity is an extremum over source/target PAIRINGS. A copy between two occupied parcels contributes (beta_ion[source] - beta_ion[target])*I, so the acceptance-favouring reach is max(0, max beta) + max(0, -min beta) = 7.505e-2, not max|beta_ion| = 7.5e-2. ATTAINED, NOT ONLY DERIVED: a CS source (-5e-5) copying into an SO target (7.5e-2) reaches -0.07505 four times in 1298668 evaluated proposals at N=20 seed 42 400 MCS. THE 29.8 PERCENT AND THE -0.0671 EXTREME IN THIS NOTE CAME FROM A RUN NOTHING NAMED, which is PP-62-11's defect committed inside the fix for PP-62-04; see PP-62-12. tests/prose_bounds.jl now recomputes the pairwise bound and rejects both withdrawn forms; verified by mutation, reverting the .tex to 7.5e-2 fails it." +PP-62-10,preprint,"section 6.2, Table 4 (decided moves)",Removing Delta H_rad alone reverses sixteen accepted moves of 206042; it is the sole decisive term in one.,model_output,16,accepted moves reversed,true,true,biofilms_potts.jl decisive_label; tests/delta_h_decomposition.jl,code,keep,,"ABSORPTION, NOT ABSENCE, AND IT IS THE POINT OF THE RESTATEMENT. Four, three and nine by seed at 400 MCS. Fifteen of the sixteen carry an adhesion or volume term that is independently decisive as well, so decisive_label returns `multiple` and the `rad` row reads 0.00 percent while the term was in fact capable of deciding sixteen moves. The one that survives as solely decisive is under seed 44. THE CORRECTED BOUND PREDICTS THIS AND THE WITHDRAWN ONE DOES NOT: summing 1 - exp(rad/T_cpm) over drawn-branch accepted moves with rad < 0 gives 16.67, against 0.26 from the 5e-5 bound. That agreement is the evidence that the arithmetic is now right, and it is why the count is reported rather than the zero. Only 11 to 16 percent of accepted moves are in the drawn branch at all, which Table 4 pins only from below and which an earlier estimate of this correction got wrong by assuming otherwise." +PP-62-11,repository,"section 6.2, Table 4 (decided moves)",Table 4 was produced by a configuration no shipped entry point reproduces.,repository_fact,16037,"accepted moves at 100 MCS, seed 42",not_applicable,true,biofilms_potts.jl mcs_step!; tests/delta_h_decomposition.jl,code,requalify,"DISCHARGED 2026-08-29, both halves: decided_moves.jl reproduces the table from the shipped API and compares its own output against the published values, and the Table 4 caption now records the generator and the MersenneTwister/Xoshiro distinction. tests/decided_moves_tests.jl runs it from runtests.jl.","FOUND WHILE RE-MEASURING, AND IT IS WHY THE RE-MEASUREMENT TOOK TWO ATTEMPTS. run_simulation and run_simulation_coupled both seed MersenneTwister and give 14281 accepted moves at 100 MCS, seed 42. Table 4's 16037 is reproduced only by the idiom used in tests/delta_h_decomposition.jl: mcs_step! driven by hand with Random.Xoshiro(seed) and update_melanin! each sweep. Neither b39fb8a nor d404438 committed a harness and no file records the configuration, so the published table describes a trajectory main() and main_coupled() do not produce. With the configuration identified, every cell of Table 4 reproduces exactly, along with PP-62-05's 23 of 53603 and PP-62-06's per-seed contingent shares -- so this is a provenance defect and not a numerical one. Incidentally the coupled and uncoupled paths give identical accepted counts: the radiodialysis coupling never reaches the CPM trajectory. DISCHARGED. decided_moves.jl uses only mcs_step!'s existing `driver` keyword and DriverCounts, so decisive_label is called by the model and not reimplemented in the harness -- the table cannot drift from the rule that produces it. The test tier reads its expected counts out of the script's own PUBLISHED table rather than restating them, so script and suite cannot drift and each look green, and its control steps the same run under MersenneTwister and requires the totals to DISAGREE, which is the arm that proves the comparison can fail." +PP-62-12,preprint,"section 6.2, paragraph beneath Table 4",29.8 percent of evaluated proposals carry Delta H_rad < -5e-5.,model_output,0.298,fraction of evaluated proposals,true,true,biofilms_potts.jl compute_delta_H_terms; decided_moves.jl,code,restate,Report 26.7 percent and name the run it is measured over. Done in the manuscript at version 1.2.,"REPLACES THE 29.8 PERCENT WRITTEN INTO SECTION 6.2 AT VERSION 1.2, WHICH NAMED NO RUN. Measured over 3968838 evaluated proposals across seeds 42, 43 and 44 at 400 MCS, N=40, 6 parcels per species -- the Table 4 configuration, now named in the sentence itself. Per seed: 0.2783 / 0.2571 / 0.2664. The fraction is configuration-dependent and that is why naming the run is not a formality: the same quantity is 0.1442 at N=20 and 0.2960 at N=40 over 60 MCS. The most acceptance-favouring value reached in these three runs is exactly -0.075, a vacating S. oneidensis target with no occupied source; the pairwise extremum -0.07505 is attained at N=20 but not at N=40. Instrumented by in-memory source rewrite, proved inert by lattice hash against the pristine module with a different-seed control." +PP-62-13,preprint,"section 6.2, paragraph beneath Table 3",The corrected reach is only fifteen times smaller than the melanin term rather than four orders.,numerical_result,,none,not_applicable,true,biofilms_potts.jl compute_delta_H_terms,code,restate,"State the ratio against the shipped reach: 9.6 in Delta H, 10.2 in acceptance bias. Done 2026-08-30.","A CORRECTION THAT REACHED THE SAME SENTENCE TWICE AND HALF-LANDED, which is a different mechanism from the four propagation failures beside it. The 'four orders' half was fixed while the ratio still used a pre-pairwise number. ARITHMETIC, checked against Table 3's own values: melanin Delta H = -0.720 at M=1.44, bias 1.1548; radiation at the shipped reach 7.505e-2, bias 1.0151. Delta H ratio 9.6, bias ratio 10.2. NEITHER IS FIFTEEN. The value 15.41 is the BIAS ratio computed against a reach of 5e-2 - a number appearing nowhere in this paper - and 14.40 is the Delta H ratio at the same 5e-2. So the sentence was written mid-correction against the withdrawn magnitude. CAUGHT BEFORE PROPAGATION: PP-71-03 and PP-CONC-01 were about to copy this ratio, which would have spread one error to three places. HOW THIS LIST WAS PRODUCED, AND WHAT BOUNDS IT. Reading two PDFs found four defects. A grep over the term set {negligible, orders of magnitude, 10^{-5}, 5x10^{-5}, four orders, dominat, dwarf, vanishing, one part in} found a fifth in the Conclusion. An INVERTED pass then read every hit for {radiation term, Delta H_rad} - a population small enough to enumerate - and added none, which is what converts a keyword search into a complete pass over a bounded set. THE LIST IS BOUNDED BY THOSE TERMS AND BY THAT ENUMERATION and asserts no completeness beyond them: a sentence carrying the superseded picture in none of those words would still be invisible, which is the same incompleteness tools/absence_gate.py prints five known gaps for." +PP-71-03,preprint,section 7.1,The melanin term dominates the direct species-specific radiation term by several orders of magnitude.,numerical_result,,none,not_applicable,true,biofilms_potts.jl compute_delta_H_terms,code,restate,"State the ratio, and name the register: about an order of magnitude in Delta H (9.6). Done 2026-08-30.","THE VERSION 1.2 CORRECTION LANDED IN THE RESULTS AND NEVER REACHED THE DISCUSSION. This sentence and section 6.2's sat in one document contradicting each other. Step 4 of the correction protocol is to audit everything computed FROM the corrected number, and it did not run over the prose that repeats it. THE REGISTER IS NAMED because there are two ratios 6 percent apart - 9.6 for Delta H magnitudes and 10.2 for acceptance biases - and an unlabelled number in two places is how the next sweep finds the same claim disagreeing with itself. Sections 6.2, 7.1 and the Conclusion compare Hamiltonian terms and use the Delta H ratio; the Abstract quotes 15.5 percent, a bias figure, and uses the bias ratio." +PP-CONC-01,preprint,conclusion,The direct species-specific radiation term is negligible for the two negatively signed classes.,numerical_result,,none,not_applicable,true,biofilms_potts.jl compute_delta_H_terms,code,restate,State the ratio instead of negligibility. Done 2026-08-30.,"FOUND ONLY BY THE SWEEP, NOT BY READING, and phrased like section 7.1's. Literally true in the same narrow way the Abstract's sentence was - the scope clause survives - which is why nothing caught either. Its survival is the evidence that the list of four produced by reading was incomplete. HOW THIS LIST WAS PRODUCED, AND WHAT BOUNDS IT. Reading two PDFs found four defects. A grep over the term set {negligible, orders of magnitude, 10^{-5}, 5x10^{-5}, four orders, dominat, dwarf, vanishing, one part in} found a fifth in the Conclusion. An INVERTED pass then read every hit for {radiation term, Delta H_rad} - a population small enough to enumerate - and added none, which is what converts a keyword search into a complete pass over a bounded set. THE LIST IS BOUNDED BY THOSE TERMS AND BY THAT ENUMERATION and asserts no completeness beyond them: a sentence carrying the superseded picture in none of those words would still be invisible, which is the same incompleteness tools/absence_gate.py prints five known gaps for." +PP-AB-05,preprint,abstract,The direct per-species radiation term changes Metropolis acceptance by approximately one part in 1e5 for the two negatively signed species.,numerical_result,,none,not_applicable,true,biofilms_potts.jl compute_delta_H_terms,code,restate,"State the finding rather than the correction history, at the level the Abstract already works at. Done 2026-08-30.","LITERALLY TRUE AND STILL THE WRONG SENTENCE. The scope clause 'for the two negatively signed species' survives the correction, so no phrase guard and no bound check could fire, and an abstract-only reader got version 1.1's picture. THE FIX IS NOT TO APPEND THE CORRECTED REACH: that would make one sentence carry two numbers, a scope clause and a withdrawal, and ask the reader to track a retraction. Corrections belong in the correction section. AND THE REPLACEMENT MUST NOT TRADE SCOPED-BUT-MISLEADING FOR UNSCOPED-BUT-ACCURATE: 'sixteen of 206042' is measured at N=40, seeds 42/43/44, 400 MCS, and the 26.7 percent is measured over the same three runs, so neither is scope-free. The Abstract already works at the COEFFICIENT level - 15.5 percent is a property of the melanin coefficient at M=1.44 and of no run - so the parallel is the reach itself, 7.505e-2, a property of the shipped coefficients with no run, seed or configuration attached." +PP-DAM-01,preprint,"section 3.12, structural-prior passage","With gamma in [0,1] the diffusivity can move only from D_eff,0 toward D_w so the family cannot express a radiation-induced decrease in effective diffusivity.",structural_claim,,none,not_applicable,true,biofilms_potts_jacc.jl:268; biofilms_radiodialysis.R:242; biofilms_radiodialysis.R:293,code,requalify,"TWO INDEPENDENT REPAIRS EXIST AND THIS ROW CLOSES ON EXACTLY ONE. (i) REQUALIFY: attach ""for D_eff,0 < D_w"" to the section 3.12 sentence. That makes the sentence true and leaves radial_parms' D_eff = 1e-3 still a reactor-scale dispersion coefficient under a film-scale name. (ii) CORRECT THE DEFAULT: bring every shipped D_eff below D_w. That makes the unqualified sentence true OF THIS REPOSITORY and leaves it false for any reader who supplies D_eff,0 > D_w, which the sentence invites by claiming a property of the FAMILY. NEITHER FIX CLOSES THE OTHER AND THIS ROW IS NOT DISCHARGED BY (ii). (i) is what this row tracks; (ii) is the naming defect the phase-2 figure's ""What this number is not"" block already records and it carries its own verdict elsewhere.","A DIFFERENT DEFECT CLASS FROM A BAD VALUE, and the bad value was already known. Section 3.12 argues the exponential-damage family is STRUCTURALLY incapable of expressing a decrease in D_eff and offers that incapacity as a limitation of the form. At the shipped radial default the family expresses one: D_w is about 2.3e-5 cm2/s and D_eff,0 = 1e-3 cm2/s (biofilms_potts_jacc.jl:268, biofilms_radiodialysis.R:242), so (D_w - D_eff,0) is NEGATIVE and gamma in [0,1] moves D_eff downward by up to 43.5x. The manuscript's claim about its own model form is falsified by the model's own parameter - not unsupported, falsified. THE STRUCTURAL CLAIM SILENTLY ASSUMES D_eff,0 < D_w, which holds for exactly one shipped default: slab_parms' 1e-5 (biofilms_radiodialysis.R:293). AND THE FALSIFYING PARAMETER IS NOT A PARAMETER OF THE DAMAGE LAW. No code implements gamma = 1 - e^(-lambda D) or the D_w interpolation at all; the form is manuscript-only. The code's damage terms are alpha_P = 0.02 /Gy on permeability and k_dam = 0.005 /Gy structural (biofilms_radiodialysis.R:257-259, biofilms_potts.jl:1372-1373), neither acting on D_eff. So a reader auditing the damage law's implementation would not meet this contradiction; it is only visible by reading the transport solver's D_eff against a manuscript sentence about a form the solver does not contain. SCOPE ON WHAT IS SHIPPED, BECAUSE THE SENTENCE ABOVE CAN BE MISREAD AS NAMING TWO SHIPPED VALUES. D_w HAS NO SHIPPED VALUE ANYWHERE IN THIS REPOSITORY. Searched: all .jl, .R and .py sources for D_w, D_water, D_bulk, D_free, self_diff and the literal 2.3e-5. Every hit is a GUARD or a docstring, and calibration/tests/test_sop_index.py:219 asserts D_w is ABSENT from sources as a condition of SOP-T4's no-signal registry. The 2.3e-5 cm2/s used here is water self-diffusivity from the literature, carried in this row and in calibration/tests/test_figure_staleness.py:59, not read off a parameter. SO THIS IS A SHIPPED DEFAULT CLASHING WITH A SPECIFIED LAW, NOT TWO SHIPPED THINGS - the law and its D_w exist only in the manuscript, and only D_eff,0 is a parameter anything runs." +FIG-08,preprint/figures/phase2_diffusion_cell.pdf,panel: Open choice: the tracer run,In Hanford sediments tritiated water resolved intragranular pore volume where bromide did not.,empirical_literature_claim,,none,not_applicable,false,,literature,keep,"Name the source in the figure before the panel reaches Dr. Deng. Done 2026-08-31: Hay, Stoliker, Davis & Zachara 2011, Water Resources Research, doi:10.1029/2010WR010303.","SHIPPED UNCITED IN A FIGURE ADDRESSED TO A NAMED EXTERNAL READER WHOSE FIELD THIS IS - the configuration that produced HOFFMAN-01 and HOFFMAN-02. Verified 2026-08-31 against the USGS publication page for the paper, which carries the authors' own summary: sediments are the uranium-contaminated vadose and capillary-fringe materials beneath the former 300A process ponds at Hanford, and ""experiments using bromide ion as a tracer yielded very different results, suggesting very little penetration of bromide into the intragranular porosity"". The Wiley abstract page returns 403 and was not read. WHAT WAS DELIBERATELY NOT CARRIED INTO THE FIGURE: volume 47 and article number W10531 appeared only in search-engine summaries, in no page actually fetched, and a specific supplied by a secondhand summary and not seen in the source is precisely HOFFMAN-01. The panel cites year, journal and DOI, all three read off the USGS page." +FIG-09,preprint/figures/phase2_diffusion_cell.pdf,panel: Open choice: the tracer,Bromide is size- and charge-excluded from fine porosity.,mechanism_claim,,none,not_applicable,false,,literature,delete,"Remove the mechanism attribution. Done 2026-08-31: the panel now states the observed result and cites it, and asserts no mechanism.","THE SOURCE REPORTS THE OBSERVATION, NOT THE MECHANISM. Hay et al. 2011 report very little bromide penetration and infer restricted access; ""anion exclusion"" as the named cause came from a search-engine summary of that paper, not from any page read. Size exclusion and charge exclusion are two mechanisms and the figure asserted both, unsourced, in a sentence that read as established. The deleted mechanism does not change what the panel is FOR: the tracer choice question stands on the observation alone. WHAT THE GUARD DOES NOT COVER, STATED HERE BECAUSE THE ROW WOULD OTHERWISE IMPLY IT DOES. test_claims_ledger.py matches this row's distinguishing_phrase as a SUBSTRING of the figure's .txt sidecar, so it catches the exact withdrawn wording and nothing else; a reworded assertion of the same claim passes it silently. THE WITHDRAWAL IS SEMANTIC, NOT PHRASAL: what is deleted is the claim that an EPS matrix excludes anions the way Hanford intragranular porosity did, in ANY wording. The mechanical check is a floor under a human one, not a substitute for it." +FIG-10,preprint/figures/phase2_diffusion_cell.pdf,panel: Open choice: the tracer,an anionic EPS matrix is the same trap,transfer_claim,,none,not_applicable,false,,literature,delete,"Withdraw the transfer or source it from the EPS literature. Done 2026-08-31: withdrawn, and the panel now says the same holds in an EPS gel is untested.","A SEDIMENT RESULT TRANSFERRED TO A HYDROGEL WITHOUT A SOURCE IN THE HYDROGEL LITERATURE. What the EPS literature actually reports is the opposite sign of concern: Stewart 2003, J. Bacteriol. 185:1485-1491, compiles relative effective diffusivities in biofilms and puts small inorganic ions at De/Daq of roughly 0.56 to 0.70 - HINDERED, NOT EXCLUDED - and the well-documented charge effect in anionic EPS is retardation of CATIONS, e.g. tobramycin sequestered by alginate, not exclusion of small anions. Hanford intragranular porosity is nm-scale voids in a rigid mineral aggregate; EPS is an open gel above 90 percent water where the Debye length at physiological ionic strength is of order 1 nm. The two systems share a word, not a measured behaviour. THIS WOULD HAVE BEEN THE FIRST THING DR. DENG SAW, and the panel asking him a question is the wrong place to assert something in his field that the field does not say. WHAT THE GUARD DOES NOT COVER, STATED HERE BECAUSE THE ROW WOULD OTHERWISE IMPLY IT DOES. test_claims_ledger.py matches this row's distinguishing_phrase as a SUBSTRING of the figure's .txt sidecar, so it catches the exact withdrawn wording and nothing else; a reworded assertion of the same claim passes it silently. THE WITHDRAWAL IS SEMANTIC, NOT PHRASAL: what is deleted is the claim that an EPS matrix excludes anions the way Hanford intragranular porosity did, in ANY wording. The mechanical check is a floor under a human one, not a substitute for it." +PP-DEFF-01,repository,biofilms_radiodialysis.R:242 radial_parms comment; and the symbol D_eff throughout,"D_eff = 1e-3 cm2/s is the effective diffusivity, Table 2 range 1e-4..1e-2.",parameter_provenance,1e-3,cm2 s-1,false,true,biofilms_radiodialysis.R:152; biofilms_radiodialysis.R:242; biofilms_radiodialysis.R:293; biofilms_potts_jacc.jl:268,code,restate,"Restate the comment so it names WHICH quantity the value is, and drop the unresolvable range. One symbol currently carries three distinct quantities and the shipped value is none of them: (a) a film-scale effective diffusivity, which at minimum means D_eff = D0 * eps/tau, porosity over tortuosity, BOTH corrections reducing, so D_eff < D0 always; (b) the bench quantity, D_app = D_eff/R, lumped by sorption and separable only with K_d from a suspended isotherm; (c) the solver's own D_eff, which is UNRETARDED because biofilms_radiodialysis.R:10-13 carries sorption explicitly as a two-phase c/s system with k_ads, k_red, k_des and k_loss. SEPARATELY AND NOT DISCHARGED BY THIS ROW: the section 3.12 damage law presupposes D_eff,0 < D_w, which this default violates - that is PP-DAM-01 and it has its own verdict.","TWO FINDINGS WITH DIFFERENT VERDICTS, AND THE ROW STATES THE SCOPE OF EACH BECAUSE THEY ARE NOT EQUALLY WELL ESTABLISHED. FINDING 1, REFUTED UNDER A NAMED PREMISE. CORRECTED 2026-08-31: an earlier version of this row read 'REFUTED, NEEDS NO PROVENANCE ... lies ENTIRELY ABOVE water self-diffusivity, about 2.3e-5 cm2/s ... no value in that span can be a film-scale effective diffusivity of anything in water.' THAT ARGUMENT DOES NOT HOLD AS WRITTEN. D_eff = D0 * eps/tau with eps < 1 and tau > 1 establishes D_eff < D0 -- the SOLUTE's free diffusivity -- and NOT D_eff < D_w. The two were conflated. This solver transports an unnamed species: it declares no D0, D_free or chemical identity anywhere, saying only 'mobile species' (biofilms_radiodialysis.R:8, :50) and 'external contaminant concentration (normalised)' (:266). Raised by Codex on pull request #23. FINDING 1 IS NOW SPLIT BY ENDPOINT, AND ONLY ONE END IS REFUTED. CORRECTED AGAIN 2026-08-31, SECOND REVISION. The previous version read: 'Aqueous D0 at room temperature is bounded by roughly 1e-4 cm2/s ... H+ ... reaches about 9.3e-5 ... still under the bound by about 8 percent.' NEITHER THE SOLVER NOR THE MANUSCRIPT DECLARES A TRANSPORT TEMPERATURE -- biofilms_radiodialysis.R carries only T_cpm, the CPM acceptance temperature, which is not one -- so a room-temperature ceiling cannot bound an unnamed solute under undeclared conditions. Raised by Codex on pull request #23. AND THE ARITHMETIC IS WORSE THAN THE FINDING STATED. By Stokes-Einstein D scales as T/eta(T), and water's viscosity falls steeply: relative to 20 C, D is 1.15x at 25 C, 1.53x at 37 C and 2.02x at 50 C. H+ at 9.3e-5 therefore implies 1.07e-4 at 25 C, 1.42e-4 at 37 C and 1.88e-4 at 50 C. AT BODY TEMPERATURE THE FASTEST AQUEOUS ION EXCEEDS THE 1e-4 ENDPOINT. The 8 percent margin does not thin; it inverts, at a temperature entirely plausible for a biofilm. UPPER END: REFUTED, and it needs none of this. 1e-2 cm2/s is two orders of magnitude above D0 for any aqueous solute at any liquid-water temperature. LOWER END: UNRESOLVED, pending a declared solute AND a declared temperature. THE DISPOSITION RULE THIS ROW IS NOW THE WORKED EXAMPLE OF: A REFUTATION THAT NEEDS A NEW PREMISE EACH TIME IT IS EXAMINED IS BEING PROPPED UP. This is the second revision of the same finding and each repair has been to supply a missing premise -- first the solute, then the temperature. The honest reading is not that a correct refutation was twice under-stated. IT IS THAT THE LOWER ENDPOINT WAS NEVER A REFUTATION; IT WAS AN ARGUMENT WITH TWO UNSTATED INPUTS, and each examination surfaced one of them. The upper end required neither input and survives on its own, which is what makes the contrast legible rather than a matter of degree. A third premise arriving here should be read as confirming the diagnosis, not as completing the argument. AND THE SAME SESSION APPLIED THIS DISCIPLINE ONE ROW OVER AND SKIPPED IT HERE. PP-SORP-01 refuses to draw an adequacy conclusion PRECISELY BECAUSE the sorbate has no chemical identity, citing section 3.12 for it. This row quietly assumed one. Both were written on 2026-08-31. AUDIT OF DEPENDENTS, RUN RATHER THAN ASSERTED: PP-DAM-01 also uses D_w = 2.3e-5 and does NOT inherit this defect -- the section 3.12 damage law NAMES D_w as the interpolation target, so D_w is the correct quantity there and no solute identity is required. Searched for other rows arguing from D_w: those two. FINDING 2, MIXED, AND HERE IS THE SET SEARCHED. The referent is named at biofilms_radiodialysis.R:152 as ""Table 2 of the paper and Deep Research synthesis (April 2026)"", and ""the paper"" is fixed at biofilms_radiodialysis.R:3 as Kinder & Faulkner 2026, preprint/modeling_radioresistance_and_radiotropic_fitness.tex. THAT HALF RESOLVES AND IS REFUTED: the file's four table labels in document order are tab:notation, tab:params, tab:term_magnitudes and tab:decided_moves, so Table 2 IS tab:params, it exists, and its only diffusion rows are per-species cell motility D_s in um2/s over 0.005 to 1.00 - no D_eff, no cm2/s, no 1e-4..1e-2. The superseded predecessor calibration/tests/fixtures/modeling_radiotrophic_fitness_prerevision.md carries no D_eff table either. That is a bounded enumeration over four tables in one named file plus one fixture, which is the strong form. THE OTHER HALF IS UNRESOLVED, NOT REFUTED: the ""Deep Research synthesis (April 2026)"" is NOT IN THIS REPOSITORY - a case-insensitive search for ""deep research"" over .md, .tex and .csv returns nothing outside the R file's own header - so the claim about it is that the comment cites a provenance I CANNOT RESOLVE, and this row asserts NOTHING about what that document contains. Saying which of the two is which is the point: refuted and unresolvable take different repairs." +PP-SORP-01,repository,biofilms_radiodialysis.R:144 immobile-phase equation; manuscript section 3.12 lines 711-721,"The shipped Henry sorption form and the Langmuir form diverge by more than 10% above c_ref = X_max/29.72, and whether this solver's regime lies above or below that threshold is undetermined because no reference concentration exists.",derived_bound,29.72,dimensionless (X_max/c_ref at 10% divergence),true,true,biofilms_radiodialysis.R:144; biofilms_radiodialysis.R:245-252; analysis/henry_langmuir_bound.R,simulation_output,supported,"Nothing to fix. Recorded because section 3.12's claim that the three sorption forms 'are not interchangeable' is structural and correct and had never been given a magnitude anywhere in this repository. THIS ROW DOES NOT DISCHARGE THAT CLAIM: 3.12 is about FORMS and this is about MAGNITUDES, and a form difference does not stop mattering because a magnitude is small in some regime. The value 29.72 is reproduced by `Rscript analysis/henry_langmuir_bound.R`, which fails rather than prints if the closed form stops matching a numerical Langmuir integration.","THE CONDITIONALITY IS THE FINDING, NOT A CAVEAT ON IT. The shipped immobile-phase equation is ds/dt = U*c - (k_des+k_loss)*s with U = X_total*(k_ads + k_red*f_red) -- first order in c, no site limitation, a Henry isotherm. The kinetic Langmuir form of the same reaction has equilibrium s_L = s_H/(1 + s_H/X_max), so the shipped form's relative error is s_H/(X_max + s_H). Over the range the solver actually visits (c in [0, 0.9252], s in [0, 3.3024] at t_end=100 s, reaching 0.382 of Henry equilibrium since 1/(k_des+k_loss) = 166.7 s exceeds the run) that error is 0.0066% at c_ref = 1 mg/L with X_max = 50, and 86.9% at c_ref = 10 g/L with X_max = 5. It straddles any threshold one might pick, so this is a fact about a MISSING PARAMETER and not about the forms -- which is why it belongs here and NOT in section 3.12, the 'specification, not method' subsection whose discipline is not carrying numbers. A bound conditional on a quantity the model lacks would read there as evidence the quantity exists. NO ADEQUACY VERDICT IS AVAILABLE AND THIS ROW MUST NOT BE QUOTED AS ONE. It is tempting to read the table above as 'at trace concentrations the Henry form is fine'. NOTHING ESTABLISHES THAT A TRACE-CONTAMINANT REFERENCE CONCENTRATION IS THE RELEVANT ONE: section 3.12 states the sorbate in eqs. mobile-robin 'is dimensionless, with c_ext normalised to unity and no chemical identity attached', so there is no compound whose working concentration could be looked up. The defensible statement is the threshold and its undetermined side, which is what claim_text says. SCOPE ON THE ABSENCE: 'no reference concentration exists' is data/calibration/suspended_isotherm_proposal.csv:29-32 verbatim -- a q_max in mg/g 'needs rho_dry ... AND a reference concentration that exists nowhere in this repository'. That is a claim about this repository on this date, not about the world; supplying c_ref is a decision nobody has made, not a measurement nobody has taken. X_max IS A PRIOR ON A PRIOR AND THE CROSSOVER INHERITS IT. X_max = q_max*rho_dry multiplies a suspended-MEASURED capacity by a biofilm-density PRIOR, so the product 'inherits the weaker class' (same file, lines 42-44). Every X_max column and every crossover concentration quoted here is therefore a prior, not a measurement. WHAT THE EQUILIBRIUM CANCELLATION BUYS, STATED NARROWLY. s_H is proportional to X_total while X_max = q_max*rho_dry, so at equilibrium X_total cancels against rho_dry and the ratio reduces to (k_ads + k_red*f_red)*c*c_ref/((k_des+k_loss)*q_max). The EQUILIBRIUM crossover (K = 77.72) is therefore immune to PP-66-08, the open needs_calibration row saying X_total is a site-occupancy fraction in a g/cm^3 slot so the rate is 'wrong by an unstated rho'. IT CANCELS X_total AGAINST rho_dry AND DOES NOTHING WHATEVER FOR THE q_max UNCERTAINTY. The transient crossover (K = 29.72) cancels only partly, because the fraction of equilibrium reached in a fixed run is itself a function of U; the gap between 29.72 and 77.72 is the size of that partiality and both are reported. INSENSITIVE TO THE ONE UNVALIDATED PLACEHOLDER. f_red_active is labelled 'Unvalidated placeholder' at biofilms_radiodialysis.R:251-252. Swept across its entire domain [0,1] the transient K moves only 26.77 to 36.47, so no conclusion here rests on it. PRODUCER AND ITS CONTROLS. analysis/henry_langmuir_bound.R, three checks. Control 1 integrates the Langmuir RHS numerically on the same constants and requires the closed form to match it -- checking a derivation against its own algebra is the control-that-never-met-the-pipeline defect AGENTS.md rule 1 now records from the figure-sidecar case. Control 2 pins the crossover from BOTH sides, since a one-sided assertion passes on any monotone curve wherever the threshold sits. Control 3 asserts the span still straddles the threshold, i.e. fails if the result ever becomes unconditional -- which is the condition under which this row's framing, and the decision to keep it out of the manuscript, would have to change. Mutation-checked: perturbing the closed form fails control 1 alone; moving K by 20% fails control 2 alone." +PP-34-02,preprint,section 3.4 Symplectic Integration (overdamped-regime paragraph),"At cell scale the regime is overdamped -- Re = 2e-5 to 2e-3 and tau_p = 5.6e-8 to 2.2e-7 s, roughly ten orders of magnitude below the timescales this model addresses -- so the absence of a persisted momentum state is a correctness property rather than a gap awaiting work.",numerical_result,,dimensionless,true,true,analysis/overdamped_regime.py; calibration/tests/test_overdamped_regime.py,derived,supported,,"THE PARAGRAPH CLAIMED ITS OWN VERIFIABILITY AND NOTHING CHECKED THE CLAIM. Its last sentence says analysis/overdamped_regime.py 'reproduces every number in this paragraph'; until 2026-09-01 that producer was executed by NOTHING -- no test, no workflow, no script -- so its own four assertions had never run in CI, and the five stated numbers were bound to nothing. Its --report receipt emitted three scalars against five stated numbers, so 2e-4, 2e-3 and 2.2e-7 existed in stdout only: a guard written from that receipt would have covered two of five and looked complete. FOURTH INSTANCE OF THE ASSERTION CONSUMING SOMETHING COARSER THAN THE FUNCTION RETURNS, and the first at the SERIALISATION boundary rather than the assertion line -- the discriminating data was computed and discarded on the way out. The receipt now serialises the CASES and RADII sets whole, and the test accounts for EVERY numeric literal in lines 571-584 rather than a list of five, with the formula exemption scoped by location in the tau_p span so a stated result cannot be exempted by adding it to a set. Inputs are assumed scales, not measurements of this system; see the producer docstring." +PP-25-01,preprint,section 2.5 Bioremediation Context,a perspective that our Hamiltonian kNN decision tree operationalizes quantitatively,model_output,,none,not_applicable,false,tests/manuscript_claims_tests.jl (agreement guard; there is no implementation),absent,restate,Say the functional is specified and unexercised - done,"TWO PLACES ALREADY SAID THE OPPOSITE AND NOTHING COMPARED THEM. Section 3.2 says the H_kNN machinery is 'specified and unexercised' and section 5 says it 'remains specified but unexercised'; Related Work closed by saying it operationalizes the fitness-landscape perspective quantitatively. Two statements against one, so the one was the defect -- and it is the same shape as the withdrawn section 5 symplectic sentence (PP-5-05): a Related Work claim written from what a formalism was FOR rather than from what runs. No guard caught it because no guard compared two prose statements about the same object. THE NEW GUARD ASSERTS AGREEMENT, NOT ABSENCE OF A PHRASE: banning the withdrawn verb would be a phrase guard the next synonym walks past, so every sentence naming H_kNN must carry the unexercised vocabulary. The detector finds exactly three sentences and is asserted non-empty, so it cannot pass vacuously." +PP-XREF-01,preprint,"sections 2.6, 3.2, 5, 7.3 and 8 (five literal cross-references)","Section~2.1, Section~3.8, Section~3, Section~1, Section~7 written as literal numerals rather than \ref",repository_fact,5,cross-references,not_applicable,true,"tests/manuscript_claims_tests.jl (cross-references resolve, and none is a literal numeral)",derived,restate,Convert to \ref against a \label - done,"SAME FAMILY AS PP-FIG-01 AND AS THE BIBLIOGRAPHY-BY-NUMBER DEFECT: addressing a render artifact instead of a stable key. EVERY NUMERAL WAS RESOLVED AGAINST THE PROSE BEFORE CONVERSION, because \ref freezes whatever target the author assumes and a wrong numeral converted mechanically becomes a wrong reference that no longer looks wrong. Four resolved by subsection count. THE FIFTH DID NOT: 'where Section~7 reports a single-seed diagnostic not separated from the seeding null' has two candidates, since the diagnostic is REPORTED in section 6.3 while section 7 discusses it. The tiebreak was the quoted phrase -- line 1281, inside section 7, reads 'is not separated from the seeding null' verbatim -- so section 7 is the intended target and a mechanical conversion would have been a coin flip. AND CI CANNOT CATCH THE FAILURE THIS PREVENTS: manuscript-build runs latexmk with -halt-on-error, an undefined \ref is a WARNING not an error, so the job goes green and the PDF ships the words 'Section ??'. The guard therefore also asserts that every \ref resolves to an existing \label, which is the load-bearing half. Figure numerals are left alone: they are PP-FIG-01, delete verdict unapplied, and that row's decision." +PP-RECIP-01,preprint,section 3.12 reciprocity passage; section 3.11 mechanism sentence,"Radiolysis is the mechanism section 3.12's dose-rate reciprocity caveat is about, and computing local radical concentrations from the dose field is what would replace the assumption rather than caveat it.",structural_claim,,none,not_applicable,false,tests/manuscript_claims_tests.jl (the two sentences this row connects still exist),derived,supported,,"BOTH HALVES ARE ALREADY IN THE MANUSCRIPT, SEPARATELY AND CORRECTLY; WHAT IS RECORDED NOWHERE IS THAT THEY ARE ONE MECHANISM AND WHAT WOULD CLOSE IT. Section 3.11 names the established descriptors for the phenomenon 'radiodialysis' was coined for as radiolysis of water into reactive oxygen species and the resulting lipid peroxidation. Section 3.12 says a damage law in accumulated dose assumes dose-time reciprocity, and that in aqueous systems the dominant pathway is indirect, through radiolysis products whose steady-state concentration is set by competing generation and recombination kinetics, both rate dependent. Same mechanism, two subsections, no cross-reference. THE REPAIR, NAMED SO THAT REOPENING IS A TASK AND NOT A RE-DERIVATION: dose is already computed (the heating tally), so species concentrations follow from G-values times dose. The G-values are settled numbers -- molecules per 100 eV absorbed, the standard low-LET Co-60 set already cited in this repository to the Elliot/AECL water-radiolysis data -- so nothing there is a research question. What the model lacks is a water fraction and a calibrated material composition. SECOND, INDEPENDENT BLOCKER ON A PATH THIS LEDGER ALREADY BLOCKS ONCE: the preamble STOP CONDITION gates OpenMC dose entering the radiodialysis model on the constitutive choice between the decaying m and the growing P_eff. Reciprocity is separate -- even once that choice is made, a dose-only damage law assumes what the chemistry does not grant. Recorded rather than built, because section 3.12 is already a specification-not-method subsection and the contaminant is dimensionless." +PP-23-01,preprint,section 2.2 PSDE-framework sentence; section 2.3 Gamma_s sentence,"Our PSDE framework extends these precedents by incorporating ... phase-locking dynamics that couple species interactions to external radiation fields; and: our phase-locking kernel Gamma_s draws directly on this tradition, extending it to multispecies microbial communities",model_output,,none,not_applicable,false,tests/manuscript_claims_tests.jl (phase-locking status guard),absent,restate,State the machinery as specified rather than performing - done,"THIRD AND FOURTH INSTANCES OF THE FAMILY PP-25-01 RECORDS, AND THE GUARD BUILT FOR THE FIRST MISSED BOTH. PP-25-01 fixed the same defect at section 2.5 and shipped a guard matching H_kNN alone, so section 2.3's Gamma_s claim and section 2.2's PSDE claim were invisible to it. A GUARD WRITTEN FROM ONE INSTANCE MATCHES ONE INSTANCE. Widening it to the phase-locking thread found section 2.2 immediately, which is what widening was for. Section 3.2 lists the phase-locking adjustment among the four terms that are unrepresentable rather than merely unimplemented, so both sentences claimed machinery the model has no representation for." +PP-38-01,preprint,section 3.8 heading and Eq. knn,Hamiltonian kNN Decision Tree,design_principle,,none,not_applicable,true,tests/manuscript_claims_tests.jl (phase-locking status guard),derived,restate,"Declare the coinage as section 3.11 does, or rename - COINAGE DECLARED 2026-09-01","THE NAME ASSERTS MACHINERY THE EQUATION DOES NOT CONTAIN. kNN is defined by a distance metric, a ranking and a cutoff k; Eq. knn has none of the three and aggregates across the whole index range j=1..n under the weight P_sj, which is a phase-weighted neighbour sum rather than a selection among neighbours. It is a single product with no split, so Decision Tree names nothing present either. F_j is the fitness field section 3.2 reports absent from the simulation sources, enforced at tests/manuscript_claims_tests.jl. SAME CLASS AS RADIOTROPHY, WHICH IS WHAT SECTION 1 WAS WRITTEN FOR -- but section 1's rule covers the five PHENOMENON names as written, so a reader applying it literally would not catch a METHOD name doing the identical thing. RM-B02-01 is the sibling and prescribes the COMPLEMENTARY subset: for reactor_decision_tree.R it says drop Hamiltonian and decision tree, because that file genuinely calls class::knn(k=3). Repaired on section 3.11's own template (declare the coinage, name what the thing is, keep the identifier) rather than by renaming, because a rename cascades through eq:knn, sec:knn, sections 2.5, 3.2 and 5, and the guard. Rename remains available as a separate decision." +PP-37-01,preprint,section 3.7 Nutrient Uptake with Adaptive Feedback,"A_s(t) = gamma_s*phi_s(t) - beta_ion*I_gamma, the adaptive feedback driven by phase-locking",model_input,,none,not_applicable,false,tests/manuscript_claims_tests.jl (phase-locking status guard),absent,restate,State the term's status - done,"FOUND BY THE WIDENED GUARD, NOT BY A READER. Section 3.7 was the one subsection naming the phase-locking machinery with no status statement of any kind, while PP-T2-25's notes had recorded 'Section 3.7 is unimplemented' since the ledger was written and that was never applied to the manuscript. THE DISCLAIMER IS SCOPED TO THE TERM, NOT THE SUBSECTION, because a blanket marker would be false: update_nutrient! integrates a nutrient field at every step (biofilms_potts.jl:918, called at :1239), so the uptake law is not wholly unimplemented. It is A_s(t) that is unexercised." +PP-CF-01,preprint,section 7.3 offline counterfactual; section 7.4 inputs and threshold,"Once lattice pitch, hydrated density, water fraction, elemental composition, membrane geometry and a source model are supplied, the same snapshot-to-dose path can evaluate whether plausible material-state changes alter the local radiation field beyond transport and calibration uncertainty.",structural_claim,,none,not_applicable,false,coupling/scripts/synthetic_e2e.py is the path that would run it; the test itself is unrun,absent,needs_verification,"The six measurements section 7.4 lists, plus a prospectively declared effect threshold","THE ONLY CLAIM IN THIS REPOSITORY THAT IS FALSIFIABLE ABOUT THE WORLD AND HAD NO ROW. Seven rows carry section 7.3 and none of them is this one. KEEP IT APART FROM THE IMPLEMENTATION CLAIM: SYN-E2E-01 records that the one-way path executed end to end under a synthetic, non-calibrating reference system, and section 6.6 says in terms that this validates the implementation path and not the physical target. That claim is falsified by an axis flip, a conservation violation or a mass-normalisation error, which is what the orientation probes and test_csg_mass.py's 4/pi exist for. THIS claim is falsified by physics. FALSIFIED BY: the dose-field difference between the current biomass configuration and a plausibly perturbed one falling INSIDE the combined transport-statistical and calibration uncertainty, at which point d(mu)/d(c) ~ 0 stops being a declared simplification and becomes an empirically adequate one. REINFORCED BY: the difference exceeding that bound, showing section 3.12's two named signatures -- the sign reversal across a loaded voxel, and the matrix term varying as CPM occupancy changes. A NULL RETIRES THE TWO-WAY LOOP, NOT THE WORK, and that distinction is the point: the one-way path stays supported, the manuscript stays publishable, and the null is itself the reportable result, retiring the loop on evidence rather than on cost. Read loosely this row's falsification condition looks like project termination and makes running the test seem risky; it is the opposite. THE THRESHOLD MUST BE DECLARED PROSPECTIVELY (section 7.4) or the comparison becomes a search for a threshold the result clears, and the bound it must exceed is a COMBINATION, which is why the resolution work matters: an unconverged mesh inflates the bound and can hide a real effect, a noisy one can manufacture a difference. Section 7.4 already states that the same six measurements permit both the resolution study and this test, so there is no separate uncertainty-reduction project." diff --git a/data/parameter_provenance.csv b/data/parameter_provenance.csv index 5fe9007..5a93fd3 100644 --- a/data/parameter_provenance.csv +++ b/data/parameter_provenance.csv @@ -77,7 +77,7 @@ C_syron_casey_geometry_anchor,transport,materials.membrane.elements,Elemental MA D_engineered_composite,none,cpm.lattice_pitch_derivation,Physical edge length of one CPM lattice site,,cm,,,engineered_composite,direct_measurement,SPATIAL_DECL_2026,docs/calibration/cpm_spatial_calibration.md §1,"Must be measured in the same medium, temperature, oxygen state and growth phase the model represents","NOT IDENTIFIABLE from a cell volume alone: V_physical = V_sites * a^3 constrains only the product. Needs a second constraint — a declared domain size, a resolution criterion, or a declared site target. Enumerated by biofilm_calibration.spatial.scale_candidates, which refuses to select without declared acceptance thresholds.","BLOCKED, spatial gate PROVISIONAL. Also constrained by topology: the CPM forces L = 2R (R = N/2 recomputed as a local at seven sites, no length field), so an apparatus with an independent radius and length is unrepresentable at any pitch.",requires_transform,unmapped,blocked,unknown,cpm_kinematic,none D_engineered_composite,none,cpm.seconds_per_mcs_calibration,Physical seconds represented by one CPM Monte Carlo step,,s,,,engineered_composite,direct_measurement,SPATIAL_DECL_2026,docs/calibration/time_observable_contract.md,Time-lapse under the modeled growth conditions,"dt_MCS = a^2 * S_sim / S_exp, where S_sim is the simulated MSD slope in lattice units^2 per MCS and S_exp = 2*d*D_eff is the measured slope in um^2/s. For motile organisms fit speed, persistence time, turning-angle distribution and division interval jointly, not MSD alone.","BLOCKED, and downstream of TWO things, not one: dt_MCS = a^2 * S_sim / S_exp needs the calibrated pitch a AND a declared dynamic observable that the model represents. Single-cell tracking is not automatically valid — a cell_id is a computational biomass parcel, not an observable bacterium, so a per-cell MSD is not a parcel MSD. Four of seven candidates satisfy all three conditions; none is selected.",requires_transform,unmapped,blocked,unknown,cpm_kinematic,none D_engineered_composite,none,response.hamiltonian,RADIOTROPISM: dose-rate response of the Metropolis occupancy term — a directional spatial preference,,dimensionless,,,engineered_composite,direct_measurement,DECLARED_REPO,docs/physical_reference_system.md section 3,"Per species, per dose rate and per cumulative dose separately",Calibrated against spatial redistribution in a KNOWN dose gradient.,"BLOCKED. THIS TERM IS A TROPISM AND NOTHING ELSE. The sign of beta_ion decides whether a parcel drifts up or down the dose gradient; the Hamiltonian is a Metropolis acceptance functional in arbitrary units, not an energy budget, so a negative beta is not metabolic gain. Radiotrophy (radiation measurably ENHANCING growth or metabolism) is not represented here and cannot be — see response.growth_survival — and is in any case not established for any modelled species (docs/research/radiotrophic_compatibility_audit.md, verdict TARGETED_LAB_EXPERIMENT_REQUIRED). Note the entity semantics too: a cell ID is a computational biomass parcel, so any fitted response is a per-parcel response, not a per-organism one.",requires_transform,unmapped,blocked,unknown,biological_response,none -D_engineered_composite,none,cpm.melanin_coupling,"Coupling between the melanin field and the Metropolis acceptance: dH_mel = -k*M for the RADIOTROPIC species",0.5,dimensionless,,,declared,declared,DECLARED_REPO,biofilms_potts.jl compute_delta_H (H_melanin term),Hard-coded at the call site; absent from Table 2 and from every configuration file,"Would be fitted jointly with response.melanin against pigmentation versus exposure; it cannot be fitted separately from alpha_M, since only their product reaches the dynamics.","UNDECLARED UNTIL 2026-08-15 AND IT DOMINATES. At the shipped I0=1.0 and T_cpm=5.0, dH_rad for the radiotropic species is beta_ion*I = -5e-5, an acceptance bias of 1.000010 — one part in 1e5, numerically inert. dH_mel at the reported M=1.44 is -0.72, a bias of 1.155, or 15.5%: four orders of magnitude larger. So the radial stratification the model exhibits is driven by THIS coefficient and not by the tabulated beta_ion. Radiation still reaches the dynamics, but indirectly: melanin_drive is copied from the radiation field, so radiation -> melanin production (alpha_M, tabulated) -> M -> dH_mel (this coefficient, untabulated) -> tropism. A parameter that decides the headline result must not live only at its call site.",requires_transform,unmapped,blocked,high,biological_response,none +D_engineered_composite,none,cpm.melanin_coupling,"Coupling between the melanin field and the Metropolis acceptance: dH_mel = -k*M for the RADIOTROPIC species",0.5,dimensionless,,,declared,declared,DECLARED_REPO,biofilms_potts.jl compute_delta_H (H_melanin term),Hard-coded at the call site; absent from Table 2 and from every configuration file,"Would be fitted jointly with response.melanin against pigmentation versus exposure; it cannot be fitted separately from alpha_M, since only their product reaches the dynamics.","UNDECLARED UNTIL 2026-08-15 AND IT DOMINATES. At the shipped I0=1.0 and T_cpm=5.0, dH_rad for the radiotropic species is beta_ion*I = -5e-5, an acceptance bias of 1.000010 — one part in 1e5 FOR ONE ROLE of the two negatively signed species occupying a site, which is not the term's reach -- signed by role it reaches 7.505e-2. dH_mel at the reported M=1.44 is -0.72, a bias of 1.155, or 15.5%: larger by about an order of magnitude, 9.6 in dH. CORRECTED 2026-08-31; 'four orders' was the version-1.1 claim. So the radial stratification the model exhibits is driven by THIS coefficient and not by the tabulated beta_ion. Radiation still reaches the dynamics, but indirectly: melanin_drive is copied from the radiation field, so radiation -> melanin production (alpha_M, tabulated) -> M -> dH_mel (this coefficient, untabulated) -> tropism. A parameter that decides the headline result must not live only at its call site.",requires_transform,unmapped,blocked,high,biological_response,none D_engineered_composite,none,response.melanin,Dose response of pigment production,,dimensionless,,,engineered_composite,direct_measurement,DECLARED_REPO,docs/physical_reference_system.md section 3,Per species; exposure conditions recorded,Calibrated against a chemical pigment assay or calibrated image intensity versus exposure.,"BLOCKED. Distinct from the Hamiltonian response; fit dose rate and cumulative dose separately, since repair and adaptation cannot be reconstructed from cumulative dose alone.",requires_transform,unmapped,blocked,unknown,biological_response,none D_engineered_composite,none,response.growth_survival,Dose response of viable biomass,,dimensionless,,,engineered_composite,direct_measurement,DECLARED_REPO,docs/physical_reference_system.md section 3,Per species; dose rate and cumulative dose varied independently,Calibrated against viable counts or growth rate versus dose and dose rate.,"UNSUPPORTED BY THE CURRENT MODEL, which is distinct from blocked: a blocked quantity awaits a measurement, this one awaits a MODEL CHANGE. The CPM has no growth dynamics — divide_cell! has no trigger and state.nutrient is never read by compute_delta_H — so there is no simulated growth rate to fit a survival response against, and no quantity of survival data would clear it. A model-revision branch must introduce and validate baseline growth first. RADIORESISTANCE FOLDS HERE, and is equally unsupported: elevated SURVIVAL needs a death process, and no cell is ever removed from this model. The published D10 evidence is strong — it is what anchors the per-species beta_ion magnitudes — but the model expresses it as differential TROPISM, not as survival, and those are different quantities.",requires_transform,unmapped,unsupported_by_current_model,unknown,biological_response,none D_engineered_composite,none,response.metal_reduction,Dose response of the metal reduction / sorption rate,,dimensionless,,,engineered_composite,direct_measurement,INTEGRATION_DECL_2026,docs/calibration/material_basis_contract.md,Per species; electron-transfer conditions recorded,Calibrated against reduced-metal production or electron-transfer rate versus exposure.,"BLOCKED by a units error upstream, not only by missing data: X_red is currently a site fraction of one species divided by all interior sites, which is neither a biomass fraction nor an active-reducer fraction.",requires_transform,unmapped,blocked,unknown,biological_response,none diff --git a/data/uncertainty/feedback_parameter_distributions.csv b/data/uncertainty/feedback_parameter_distributions.csv index 5fb04f7..10be751 100644 --- a/data/uncertainty/feedback_parameter_distributions.csv +++ b/data/uncertainty/feedback_parameter_distributions.csv @@ -54,7 +54,7 @@ mass_denominator_method,transport.mass_denominator,mass denominator,n/a,discrete occupancy_mapping,spatial.occupancy.mapping,phi to binary map,n/a,discrete_model_choice,scenario_branch,,,,threshold_tau_0p5,offline+online,declared,VALID,satisfied,"Frozen as threshold at tau=0.5. A categorical modelling choice gets a named branch, never an invented categorical probability." hamiltonian_response,response.hamiltonian,dose to Metropolis term,dimensionless,biological_posterior,excluded,,,posterior fitted,,online,,NOT_IDENTIFIABLE,awaiting_measurement,"Online gate only. A survival curve is not evidence for a directional term; the one directional dataset measures hyphal elongation, which a parcel CPM cannot represent." melanin_response,response.melanin,dose to pigment production,dimensionless,biological_posterior,excluded,,melanin_product,,,online,,NOT_IDENTIFIABLE,awaiting_measurement,"STRUCTURALLY UNIDENTIFIABLE ALONE: only the PRODUCT of production and coupling reaches the dynamics. Fix one coefficient independently or reparameterize around the identifiable product; no quantity of data separates them otherwise." -melanin_coupling,cpm.melanin_coupling,melanin to acceptance,dimensionless,biological_posterior,excluded,,melanin_product,,,online,declared,NOT_IDENTIFIABLE,awaiting_measurement,"Shares melanin_product with the row above. Hard-coded at 0.5 at its call site and dominating the tropism by four orders of magnitude over beta_ion." +melanin_coupling,cpm.melanin_coupling,melanin to acceptance,dimensionless,biological_posterior,excluded,,melanin_product,,,online,declared,NOT_IDENTIFIABLE,awaiting_measurement,"Shares melanin_product with the row above. Hard-coded at 0.5 at its call site and dominating the tropism by about an order of magnitude over beta_ion's REACH (9.6 in dH); 'four orders' compared it to one role only and was the version-1.1 claim." seconds_per_mcs,time.seconds_per_mcs,CPM clock,s,biological_posterior,excluded,,,pitch + dynamic observable,,online,,NOT_IDENTIFIABLE,awaiting_measurement,"Required for any time-integrated online dose. Downstream of the pitch AND the selected temporal observable." growth_survival_response,response.growth_survival,dose to viable biomass,dimensionless,unsupported,excluded,,,,,none,,NOT_IDENTIFIABLE,unsupported_by_current_model,"The CPM has no birth and no death, so radiotrophy as enhanced growth and radioresistance as survival are structurally absent. A model-revision problem, not a missing-data one." membrane_response,membrane.P_j_law,dose to permeability,dimensionless,unsupported,excluded,,,,,none,,NOT_IDENTIFIABLE,unsupported_by_current_model,"The m-versus-P_eff constitutive contradiction is unresolved. Fixed-membrane one-way dosimetry does not need it; any dose-responsive feedback does." diff --git a/decided_moves.jl b/decided_moves.jl new file mode 100644 index 0000000..8aebecb --- /dev/null +++ b/decided_moves.jl @@ -0,0 +1,108 @@ +#!/usr/bin/env julia +# Table 4 (Share of accepted moves reversed by removing a single Hamiltonian +# term), reproduced from the shipped code. +# +# julia --project=. decided_moves.jl # Table 4, both columns +# julia --project=. decided_moves.jl 42 43 44 # per-seed at 400 MCS +# +# WHY THIS FILE EXISTS. Table 4 was published from a configuration NO ENTRY +# POINT REPRODUCED. `run_simulation` and `run_simulation_coupled` both seed +# MersenneTwister and give 14281 accepted moves at 100 MCS against the table's +# 16037; the table came from the idiom in tests/delta_h_decomposition.jl -- +# `mcs_step!` driven by hand with Random.Xoshiro(seed) and `update_melanin!` +# each sweep -- and neither b39fb8a nor d404438 committed a harness or recorded +# the parameters. A published table whose run cannot be re-executed is a number +# nobody can check, which is what PP-62-11 records and what this discharges. +# +# It uses only the shipped API: `mcs_step!` already takes the `driver` keyword +# and `DriverCounts` already accumulates the labels. Nothing here recomputes a +# label or reimplements the counterfactual -- `decisive_label` is called by +# `mcs_step!`, not by this file, so the table cannot drift from the rule that +# produces it. +# +# NO RADIODIALYSIS AND THEREFORE NO BASIS GATE. The CPM trajectory does not read +# the nutrient field (`compute_delta_H_terms` takes lattice, volumes, species, +# J, beta_ion, melanin and radiation, and never `nut`), which is why the coupled +# and uncoupled paths give identical accepted counts. This file steps the CPM +# and the melanin field only, so it acknowledges no gated basis. + +using Printf, Random + +const HERE = @__DIR__ + +# Same split-marker load as validate_serial.jl: the serial monolith minus its +# CairoMakie section, so this runs without a plotting stack. +function load_serial() + src = read(joinpath(HERE, "biofilms_potts.jl"), String) + src = split(src, "# 13. Figure export")[1] + M = Module(:SerialRef) + Base.eval(M, :(using LinearAlgebra, Statistics, Random, Printf)) + Base.include_string(M, src, "biofilms_potts.jl") + return M +end + +""" + decided_moves(SR, seed, n_mcs; N, n_cells_per_species) -> Vector{Int} + +Accepted moves by decisive-term label, in `DRIVER_LABELS` order. +""" +function decided_moves(SR::Module, seed::Int, n_mcs::Int; + N::Int = 40, n_cells_per_species::Int = 6) + p = SR.CPMParams(; N, n_cells_per_species) + st = SR.init_state(p; seed) + rng = Random.Xoshiro(seed) # NOT MersenneTwister -- see the header + d = SR.DriverCounts(p.N) + for _ in 1:n_mcs + SR.mcs_step!(st, rng; driver = d) + SR.update_melanin!(st) + end + return vec(sum(d.counts, dims = (1, 2, 3))) +end + +# The published table, so this file states what it must reproduce rather than +# printing numbers nobody can check against anything. +const PUBLISHED = Dict( + (42, 100) => [6184, 1207, 710, 0, 2, 14, 7920], + (42, 400) => [35372, 3296, 2127, 0, 23, 68, 12717], + (43, 400) => [52605, 2455, 1995, 0, 20, 75, 11315], + (44, 400) => [64439, 3041, 2044, 1, 43, 130, 14276], +) + +function report(SR, seed, n_mcs) + labels = SR.DRIVER_LABELS + c = decided_moves(SR, seed, n_mcs) + acc = sum(c) + ref = get(PUBLISHED, (seed, n_mcs), nothing) + @printf("\nseed %d, %d MCS -- %d accepted moves\n", seed, n_mcs, acc) + @printf(" %-12s %8s %8s %s\n", "term removed", "count", "share", "published") + for k in eachindex(labels) + @printf(" %-12s %8d %7.2f%% %s\n", labels[k], c[k], 100 * c[k] / acc, + ref === nothing ? "-" : + c[k] == ref[k] ? "ok" : "MISMATCH (was $(ref[k]))") + end + return ref === nothing || c == ref +end + +function main(args) + SR = load_serial() + ok = true + if isempty(args) + ok &= Base.invokelatest(report, SR, 42, 100) # Table 4, left column + ok &= Base.invokelatest(report, SR, 42, 400) # Table 4, right column + else + for s in parse.(Int, args) + ok &= Base.invokelatest(report, SR, s, 400) + end + end + println() + if ok + println("reproduces the published table") + else + println("DOES NOT reproduce the published table -- see MISMATCH above") + exit(1) + end +end + +if abspath(PROGRAM_FILE) == @__FILE__ + main(ARGS) +end diff --git a/docs/calibration/integration_contract.md b/docs/calibration/integration_contract.md index 053171f..77cfa50 100644 --- a/docs/calibration/integration_contract.md +++ b/docs/calibration/integration_contract.md @@ -158,8 +158,9 @@ Naming the radiation term correctly is not the end of it. At the shipped `I0 = 1 | `ΔH_mel` at the reported M = 1.44 | −0.720 | **1.155** | `β_ion` — the one parameter Table 2 tabulates per species, and the one the sign convention is -written around — biases acceptance by **one part in 10⁵** for exactly the species whose radial -stratification is the headline result. The melanin term biases it by 15.5%, four orders of +written around — biases acceptance by **one part in 10⁵** *for one role of the two negatively +signed species occupying a site*, which is not the term's reach: signed by role it reaches +7.505e-2. The melanin term biases acceptance by 15.5%, larger by about an order of magnitude more, through a coefficient of `0.5` hard-coded at its call site and appearing in no table and no configuration file. diff --git a/docs/calibration/sop/harvest_dewatering.md b/docs/calibration/sop/harvest_dewatering.md new file mode 100644 index 0000000..e850743 --- /dev/null +++ b/docs/calibration/sop/harvest_dewatering.md @@ -0,0 +1,74 @@ +# SOP — coupon harvest, surface-water removal, wet mass and dry mass + +Covers `D-RHOWET` (wet mass and hydrated volume) and `D-DRY` (dry mass to constant weight). +Lands in `data/calibration/materials/bulk_measurements.csv`. + +**Written from the schema, not beside it.** Every step below fills a named column of +`bulk_measurements.csv`. If a step here has no column, the step is wrong or the schema is +incomplete — resolve that before running, never afterwards. + +## Preconditions — none of this runs before all four hold + +1. `D-APPROVAL` — institutional biosafety approval, strain- and protocol-specific. +2. Strain identity verified against an accession record. **For *O. intermedium* AM7 no + culture-collection accession was located in any repository** — `radiotrophic_lab_gap.md:87`, + which also records that the strain may simply be unobtainable and that CDC's 2022-12-19 update + directs *Brucella*-identified isolates to a Class II BSC with public-health referral. For that + strain this step may **terminate** the SOP rather than delay it. +3. Balance calibration log current, and pipette calibration where volumes are dispensed. +4. **`D-BLANK` has been run and the detectability floor is known.** See + `matched_blank.md`. A biofilm mass under the blank's noise is a substrate problem no + replicate count fixes, and §3.4 of `reference_d_measurement_protocol.md` puts detectability + before replicates for that reason. + +## Procedure + +**1. Harvest.** Record `coupon_id`, `culture_batch_id`, `growth_condition_id`, `medium_batch_id`, +`sample_id`, `replicate_id`, and the `paired_sample_group` that links this coupon to its imaging +counterpart. Record `measurement_order` — the position in the session, which is what lets a drift +in the balance be separated from a difference between samples. + +**2. Surface-water removal — the measurement, not a nuisance.** §3.2 of the protocol is explicit +that this is part of the measurement. Fix and record all six: + +| Column | What to record | +|---|---| +| `drain_orientation` | the fixed angle the coupon is held at | +| `drain_time_s` | held constant across the batch | +| `blot_material` | one material for the whole study | +| `blot_contact_time_s` | held constant | +| `ambient_temperature_C` | measured, not assumed | +| `time_to_weighing_s` | from end of blot to balance reading | + +**3. Wet mass.** `wet_mass_sample_plus_substrate_g` with `wet_mass_uncertainty_g`. The substrate is +weighed with the sample; the blank supplies the subtraction. + +**4. Hydrated volume.** `hydrated_volume_cm3` with `volume_uncertainty_cm3`, `volume_method`, and +`volume_support`. **`volume_basis` must be `whole_biofilm_envelope`, or `pore_volume_fraction` must +be declared** — this is `D-RHOWET`'s acceptance criterion, not a preference. See +`imaging_segmentation.md`, where the same constraint decides the mask. + +**5. Area scaling, if the imaged and weighed areas differ.** `imaged_area_cm2`, `weighed_area_cm2`, +`scaling_method`, `scaling_uncertainty`. The uncertainty on the bridge is declared, never assumed +to be zero. + +**6. Dry mass to constant weight** — `D-DRY`. Record `drying_protocol` and `drying_endpoint`: +successive weighings agreeing within a stated tolerance, at a stated temperature. +`dry_mass_sample_plus_substrate_g` with `dry_mass_uncertainty_g`. Ash, if taken, uses +`ash_protocol` and `ash_mass_g`. + +**7. Close the row.** `blank_sample_id` naming this sample's matched blank, `quality_flag`, and +`source_id`. + +## A protocol-variance measurement this SOP is also for + +Run the wet/dry cycle on **n replicates of one biomass** and report the CV. **A large CV retires wet +mass as a normaliser** — an output that invalidates the procedure it characterises, which is why it +is stated as an expected outcome rather than a risk. If more than one operator will run this, +repeat across operators and report between-operator CV separately. + +## What this SOP does not establish + +It produces a density. It does not produce a growth rate, a survival response, or a dose response, +because the CPM has neither birth nor death — no quantity of data changes that without a model +revision (protocol §6). diff --git a/docs/calibration/sop/imaging_segmentation.md b/docs/calibration/sop/imaging_segmentation.md new file mode 100644 index 0000000..8c9c298 --- /dev/null +++ b/docs/calibration/sop/imaging_segmentation.md @@ -0,0 +1,48 @@ +# SOP — confocal acquisition, voxel calibration and the declared segmentation basis + +Produces `D-PITCH`'s artifact: time-resolved 3-D stacks with voxel calibration and a declared +segmentation basis, at least one training and one **independent held-out** stack. Lands in +`data/calibration/spatial/{sample_metadata|object_morphology|biofilm_structure}.csv`. + +**Constrained by `D-RHOWET`, which is a different requirement.** `D-PITCH` requires that *a* +segmentation basis be declared. `D-RHOWET` decides *which* basis is acceptable: `volume_basis = +whole_biofilm_envelope`, or a declared `pore_volume_fraction`. Those are two requirements and the +index records them separately, because an SOP whose stated requirement does not constrain it is the +covered-but-uncovered case the index exists to prevent. + +## The constraint most likely to be violated by a default setting + +**A cell-stain segmentation is `cells_only`, and `D-RHOWET` refuses it.** A `cells_only` mask +encloses neither the extracellular matrix nor the interstitial water, so the volume it reports is +smaller than the volume the balance weighed, and ρ_wet comes out high. The mask basis is therefore +declared **before acquisition**, not chosen afterwards from what segments cleanly. + +## Preconditions + +1. Confocal booking and account. **Not held** — this is a core-facility dependency, recorded in the + index as `blocked_by` rather than assumed. +2. Stage and voxel calibration current, with the calibration recorded per session. +3. The paired coupon exists and its `paired_sample_group` is assigned, so the stack and the mass + measurement can be joined afterwards. + +## Procedure + +**1. Declare the basis.** `whole_biofilm_envelope`, or `pore_volume_fraction` with its value. Record +it in `sample_metadata` before the first stack. + +**2. Acquire.** Voxel dimensions recorded, not inferred from the objective's nominal figure. One +training stack and one **independent** held-out stack — `select_pitch()`'s acceptance criterion is +that the coarsest pitch preserves all six declared observables within tolerance on **both**, so a +single stack cannot satisfy it however good it is. + +**3. Segment to the declared basis.** If the stain cannot resolve the matrix, the honest outcome is +that this basis is unavailable with this stain, which is a finding about the method rather than a +reason to fall back to `cells_only`. + +**4. Where the film is too thick for confocal depth**, OCT is the alternative. It has its own voxel +calibration and its own basis declaration; it does not inherit these. + +## What this does not do + +It does not select the pitch. `select_pitch()` does that from these stacks, and `D-LATTICEN` is +derived from the pitch and the domain afterwards. This SOP produces the input. diff --git a/docs/calibration/sop/matched_blank.md b/docs/calibration/sop/matched_blank.md new file mode 100644 index 0000000..6459ff6 --- /dev/null +++ b/docs/calibration/sop/matched_blank.md @@ -0,0 +1,40 @@ +# SOP — the matched blank + +Covers `D-BLANK`. Lands in `data/calibration/materials/blanks.csv`, and every row of +`bulk_measurements.csv` names one through `blank_sample_id`. + +**This SOP runs before biomass production, not after it.** §3.4 of +`reference_d_measurement_protocol.md` — "Detectability before replicates — the order is the point" — +is the argument: a biofilm mass under the blank's noise is a substrate problem, and no replicate +count fixes a substrate problem. A discipline-ordered index would file blanks under analytical +chemistry and run them fourth. + +## Preconditions + +Balance calibration log current. Water quality recorded as resistivity **and TOC** — demineralisation +removes ions and passes neutral organics, so resistivity alone does not characterise the water a +low-biomass measurement sits in. + +## Procedure + +**1. One blank per bulk measurement.** Not one per batch. `D-RHOWET`'s acceptance criterion requires +every row to name a `blank_sample_id`, so a shared blank fails the criterion whatever its quality. + +**2. The blank is the identical cycle with no biomass.** Same substrate lot, same wetting, same +`drain_orientation`, `drain_time_s`, `blot_material`, `blot_contact_time_s`, `ambient_temperature_C` +and `time_to_weighing_s` as `harvest_dewatering.md` specifies. A blank that differs in any of those +measures a different thing from the sample it is subtracted from. + +**3. Abiotic control, separately.** A no-biomass vessel through the same handling, to separate +plasticware wall adsorption from biofilm uptake. This is not the same as the substrate blank and +does not replace it. + +**4. Report the floor, not just the value.** The output is a mean **and a standard deviation across +blanks**. The standard deviation is the detectability floor, and it is what the preconditions of +`harvest_dewatering.md` require before biomass is grown. + +## The disposition, stated before the run + +If the expected biofilm mass is **within the blank's standard deviation**, the measurement does not +work on this substrate at this scale. The response is a different substrate or a larger coupon, not +more replicates. Writing that here means it is decided before a number exists to argue with. diff --git a/docs/correspondence/deng_tracer_note.md b/docs/correspondence/deng_tracer_note.md new file mode 100644 index 0000000..f7e0fcc --- /dev/null +++ b/docs/correspondence/deng_tracer_note.md @@ -0,0 +1,103 @@ +# Draft note to Dr. Deng — conservative tracer for the two-chamber diffusion cell + +> ## HELD PENDING. NOT SENT, AND NOT READY TO SEND. +> +> **Held on sequencing, not on content.** Three asks are routed through one person and only the +> first two have landed. This is the third. It does not go until the second has been answered — +> a third request arriving before the second is answered spends an introduction rather than +> using one. +> +> **This file being complete is not a signal that it is ready.** A tracked, finished, unsent note +> in a correspondence directory is exactly the artifact someone later reads as cleared. It is not. +> Whoever opens this next: the release condition is in the line above, and nothing in the body +> below discharges it. +> +> Release condition: the second ask answered. Then re-read this note before sending — the figure +> it refers to is under revision and the paragraph on tracer choice is the part most likely to +> have moved. +> +> **The opening citation is deliberately uncharacterised, and a later editor should leave it that +> way.** Hua & Deng 2008 drew a published Comment (doi:10.1021/es803175g) and a Response +> (doi:10.1021/es803528t). Nothing in that exchange bears on the transport question being asked +> here, and it is not mentioned in the note — but it is the reason the paper is cited plainly as +> the reason for writing rather than praised. Do not add an appraisal of the finding. +> +> Drafted 2026-08-31. + +--- + +**Subject:** Does an EPS gel behave like a fine-porosity system? — one tracer question + +Dr. Deng, + +**I am writing because of your work on the reductive immobilisation of U(VI) by amorphous FeS** +(Hua & Deng, *Environ. Sci. Technol.* 2008, 42(23):8703–8708, doi:10.1021/es801225z). My question +is whether the transport reasoning that applies to the fine-porosity systems you work in carries +over to a medium I have not been able to find measurements for: **an extracellular polymeric +matrix — an open, anionic hydrogel, upwards of ninety per cent water.** It comes from a biofilm, +and the wider project involves ionising radiation, but neither is the question. They are only why +the medium is anionic and why I need a defensible number for it. + +**The adjacency, said once so you do not have to raise it.** Your work is metal transport and +immobilisation; I am asking about conservative-tracer methodology in a porous medium. Those are +adjacent and not the same, and I know it. If the answer is that someone else should be answering +this, that answer is useful and I will take it. + +**The measurement.** A biofilm grown on a permeable support divides a two-chamber cell. The donor +side is held at constant concentration, the receiver near zero, both stirred, one solute per run, +with a bare-support blank run alongside every measurement and the edge seal checked separately. +Receiver concentration against time gives a breakthrough curve. + +**Two runs, and they answer different questions.** The metal run gives a lumped transport +parameter, not a diffusivity: sorption retards breakthrough, so what comes out is an apparent +diffusivity, and separating it needs the partition coefficient from a suspended-cell isotherm +done first. The conservative-tracer run is the unretarded one, and it is the run that would +**define which diffusivity the transport model ought to carry** — one symbol in that model +presently stands for more than one quantity, and the bench measurement is what would settle which +of them belongs there. That is why the tracer choice is not a side question, and it is also the +part I am least sure of. + +**The question.** Bromide or tritiated water. + +Bromide is the cheap answer: IC readout, no approvals, no disposal question, and a retardation +factor of about one, which is the property that makes it the tracer at all. What makes me +hesitate is a result from the sediment literature rather than from anything about biofilms. Hay, +Stoliker, Davis and Zachara (2011, *Water Resources Research*, doi:10.1029/2010WR010303) used +tritiated water as a diffusive tracer in uranium-contaminated sediments from beneath the former +300A process ponds at Hanford, and report that parallel experiments with bromide gave very +different results, with very little bromide penetrating the intragranular porosity. Tritiated +water resolved a pore volume that bromide did not see. + +**What I do not know is whether that transfers, and I would rather say so than imply it does.** +Hanford intragranular porosity is nanometre-scale void in a rigid mineral aggregate. An EPS +matrix is an open hydrogel, mostly water, and the charge argument is weaker there than the word +"anionic" makes it sound: the compiled biofilm diffusivity data (Stewart, 2003, *J. Bacteriol.* +185:1485–1491) put small inorganic ions at roughly half to seven-tenths of their aqueous +diffusivity — hindered, not excluded — and the well-documented charge effect in anionic EPS runs +the other way, retarding cations. I have not found the EPS-specific measurement that would say +whether bromide underreads an EPS film the way it underread those sediments — though what I have +searched is Stewart's compiled diffusivity data and the biofilm transport literature reachable +from it, which is not the sediment or tracer literature where such a measurement would as likely +sit. That is a statement about my search, not about the field. If you know of one, that is the +whole question answered. + +Against that, tritiated water carries its own burden — approval, handling, disposal, and a +counting setup — which is a real cost and possibly a disqualifying one for a first pass. + +**So, two narrow questions:** + +1. Is there an EPS or biofilm measurement showing bromide underreporting accessible pore volume — + is the retardation factor of about one one I am entitled to assume in an anionic gel — or is the + concern one I have imported from sediment work where it does not apply? +2. Is a paired bare-support blank the standard control in work like yours, or is there a better + one? I have it running alongside every measurement, and I do not know whether that is the + convention or merely the obvious thing. + +Both are answerable in a paragraph, and either answer changes what gets built. + +There is a one-page figure of the cell if it would be useful — I am happy to send it, but neither +question above needs it. + +Thank you for reading something outside your stated area. + +Hunter Kinder diff --git a/docs/correspondence/wan_v11_note.md b/docs/correspondence/wan_v11_note.md new file mode 100644 index 0000000..e0eeda6 --- /dev/null +++ b/docs/correspondence/wan_v11_note.md @@ -0,0 +1,98 @@ +# Draft note to Caixia Wan — figure correction, and the numerical one that followed + +Not sent. Send it before Andrew Hoffman opens the version she has. + +**Superseded twice since drafting, and the second time it said something false.** The note was +written for v1.1, which corrected two figures and no numbers. v1.2 corrects numbers, including +the radiation count this note quotes. Anything below that still reads as v1.1 has been marked +rather than silently swapped, because the difference is the point: a note saying "no number +changed" arriving with a paper whose correction block says two published numbers moved is worse +than a late note. + +--- + +**Subject:** Corrected version of the preprint (v1.2) — figure error, and a number, in the copy you have + +Dr. Wan, + +Thank you again for Thursday, and for offering to send the preprint to Andrew Hoffman. + +Before you do: I found an error in the figures of the version you have, and then, reviewing the +section that error sat next to, an error in one of its numbers. Attached is v1.2, which fixes +both. The figure error is the one I would have written to you about on its own; the numerical one +is more serious and I would rather you heard it from me than found it. + +Figure 2 in v1.0 carries a line inside the plot image reading "C. neoformans, +C. sphaerospermum are radiotrophic (melanin-mediated energy gain)". That contradicts the +paper's own Section 2.6, which states that radiotrophy is not established for any of the +seven species modelled, and it contradicts the caption directly beneath it. Figure 1 has a +shaded band labelled "radiotrophic niche", on the wrong side of the plot as well. + +What happened is that I corrected the figure-generating code two weeks ago and never +regenerated the committed images, and nothing in the test suite could open a figure to +notice the difference. The prose was audited to the ground; the text baked into the images +was not. It is the paper's own argument happening to the paper, which is an uncomfortable +thing to have to write and the reason I would rather you had the corrected copy. + +No number changed *in the figures*. The underlying simulation is the same run, and the values +quoted in the captions reproduce exactly. What changed is five pieces of text inside the two +images — both plot titles, the two shaded-band labels in Figure 1, and the annotation quoted +above — plus a correction note in the Software and Data Availability section recording it. + +Numbers did change in Section 6.2, and that is the second correction. An automated review of the +code found that the bound I used to argue one of that section's results was wrong by a factor of +about 1500 — I had enumerated only one of the two roles a cell can play in the energy term, and +then, on a second pass, had bounded the corrected quantity over species when it is an extremum +over pairs of them. Two published counts moved with it. The section now states what the +measurement supports rather than what the bad bound predicted, and the code carries a test that +recomputes the bound and fails if the paper and the coefficients disagree. The corrections are +recorded in the Data and Code Availability section and in the claims ledger. + +A few additions have gone in alongside the correction, most of them prompted by the gap you +identified: + +- **Section 7.3** now discusses biofilms in nuclear facilities — spent fuel pools and cooling + circuits — alongside the environmental remediation framing. The argument is about regime: + the paper already notes that reactor irradiation and contaminated-site dose rates are about + ten orders of magnitude apart, and a facility sits between them, which is where the model's + inputs are actually obtainable. There is published work on biofilms retaining Co-60 on + stainless steel and titanium coupons in spent fuel pools, and one of the isolates in that + literature is *B. subtilis*, already one of the seven species in the model. + +- **The Ethics statement** now carries the taxonomy of *Ochrobactrum intermedium* AM7, which + was reclassified into *Brucella* in 2020 and is served by NCBI as *Brucella intermedia*. + Both names are on the record now. No containment determination is claimed — that follows + the strain and belongs to an institution — but anyone reading the paper should meet the + name change in the paper rather than in a biosafety committee. + +- **Section 6.2** has a new table. It asks, for every accepted move in the simulation, whether + removing one term of the model's energy function would have reversed it. Removing the direct + radiation term reverses sixteen of 206,042 accepted moves across three seeds, and in fifteen of + those an adhesion or volume term was independently decisive as well, so the term is the sole + decider of exactly one. Two-thirds to three-quarters of moves are reversed by removing no single + term at all, so the dynamics are carried by the sum rather than by any one component. + + *(This bullet said "reversed none of 206,042" when the note was drafted for v1.1. That was the + withdrawn count, and it is the number the v1.2 correction moves. The distinction that matters is + absorption rather than absence: the term reaches far enough to have decided sixteen moves, and + is usually not the only term that could have.)* + +There is also some tightening of terminology — one coined term is no longer used before the +paragraph that explains it was coined — which changes no result. + +**Two separate things, so you do not have to work out which is which.** One is a measurement in +your own lab: the unirradiated biosorption assay in the handout, ICP readout, no reactor time and no +scheduling. The other is forwarding the memo below to Dr. Hoffman, which asks him about a material +and a facility and needs nothing from you but the introduction. They are independent — either can go +ahead without the other, and neither is a precondition for the other. + +I have also written a memo for Dr. Hoffman, if it is useful to send alongside. Four short +landscape pages: what the model computes and what it does not, the published work on biofilms +in spent fuel pools, why I am writing to a materials scientist at all — his published corrosion +work qualifies alloys against steam, hydrothermal chemistry and hydrogen permeation, and a +biofilm is none of those — and a final page that is entirely questions, since he knows what +MURR can do and I do not. + +Thank you again — for the time, and for the assistantship conversation. + +Hunter Kinder diff --git a/docs/guides/calculus_in_this_code.md b/docs/guides/calculus_in_this_code.md new file mode 100644 index 0000000..1f136af --- /dev/null +++ b/docs/guides/calculus_in_this_code.md @@ -0,0 +1,346 @@ +# The calculus in this code — Section 1: the radial operator + +One line of the solver carries the whole geometry: + +``` +∂c/∂t = (1/r) ∂/∂r (r D_eff ∂c/∂r) +``` + +Two `r`s, and neither is a coordinate trick. The one inside is flux times area. The one +outside is division by volume. Everything in Part 1 follows from a shell between `r` and +`r + dr` whose two faces have different areas, and you can get there without ever writing +down a coordinate transformation. + +The other four parts are the places where that operator meets the code: why the axis needs +no boundary condition, why this solver special-cases it anyway, how a Robin wall becomes +one line of ghost-node algebra, and why forty coupled ODEs go to LSODA rather than a +fixed step. + +**Floor:** the chain rule, the product rule, and what a partial derivative is. +**Ceiling:** ordinary differential equations as objects an integrator solves. + +Not here: general curvilinear coordinates, the divergence theorem in 3D, stability proofs, +and the sorption terms beyond noting that they set a second timescale. + +Each part ends with a *Checks against the source* table. Every row carries a `file:line` +and a fragment that line must contain, and `calibration/tests/test_guide_citations.py` +verifies all of them — a stale citation fails the suite, and the failure says whether the +code moved or the code changed. + +**Claims in the prose but not in a table are not mechanically verified.** Three here: the +reading of `bc_coef` as `P_eff` versus `k_L`, the sorption rates in Part 5, and the +stiffness characterisation in Part 5. All three are sourced in the text. None is pinned by +a test. A guide implying uniform coverage would +be making exactly the claim its own tables exist to discipline. + +`README.md`'s `## Mathematical framework` states the project's *target formalism*, under +its own disclaimer that what the programs integrate differs. This is the other side: what +the code integrates. It cross-references those equations rather than restating them, so +the two cannot drift into disagreeing. + +--- + +## 1. Where the `r` comes from + +The solver's header states the equation it integrates: + +``` +∂c/∂t = (1/r) ∂/∂r (r D_eff ∂c/∂r) +``` + +Most courses introduce this as "the Laplacian in cylindrical coordinates" and hand you a +table. That presentation makes the `r` look like bookkeeping from a change of variables. +It isn't. It is a statement about area, and you can derive it without ever mentioning +coordinates. + +**Take a shell.** Consider the region between radius `r` and radius `r + dr`, one unit +tall. Two facts about it: + +- its **volume** is `2πr · dr` (circumference times thickness), and +- its two **faces** have areas `2πr` and `2π(r + dr)` — *different areas*. + +That difference is the whole story. In a slab, the two faces of a slice have the same +area, and everything below collapses to `∂²c/∂x²`. + +**Write conservation.** The amount of solute in the shell changes at the rate it enters +minus the rate it leaves. Rate of flow through a face is flux times area, so with `J(r)` +the flux (amount per area per time, positive outward): + +``` +d/dt [ c · 2πr dr ] = J(r) · 2πr − J(r+dr) · 2π(r+dr) +``` + +Divide by the volume `2πr dr`: + +``` +∂c/∂t = − [ (r+dr)J(r+dr) − r·J(r) ] / (r · dr) +``` + +The bracket is the change in the product `rJ` across the shell. Let `dr → 0` and it is by +definition the derivative of that product: + +``` +∂c/∂t = −(1/r) ∂(rJ)/∂r +``` + +**Now put Fick's law in.** Flux runs down the concentration gradient, `J = −D ∂c/∂r`. +Substituting: + +``` +∂c/∂t = (1/r) ∂/∂r ( r D ∂c/∂r ) +``` + +which is the line in the header, derived from a conservation statement and two areas. + +**Read the two `r`s separately, because they are different things.** The `r` *inside* the +derivative is flux × area — it is there because a face's area grows with radius. The `1/r` +*outside* is ÷ volume — it is there because you asked for a concentration rather than an +amount. Neither is a coordinate artifact. + +The physical content is real. Flux converging on a shrinking face concentrates; +flux spreading onto a growing face dilutes. A slab has neither effect, which is why its +operator has no such term. If you expand the product for constant `D`: + +``` +∂c/∂t = D [ ∂²c/∂r² + (1/r) ∂c/∂r ] +``` + +the first term is ordinary diffusion and **the second is purely geometric** — it is the +dilution-onto-a-larger-face effect, and it is proportional to the gradient rather than to +its curvature. Note that it carries a `1/r`, which is where Part 2 begins. + +--- + +## 2. Why the axis isn't a boundary + +That `(1/r) ∂c/∂r` term is singular at `r = 0`. The solution is not. + +The resolution is symmetry. The model is radially symmetric — `c` depends on distance +from the axis and on nothing else. A smooth function of radius alone must have +`∂c/∂r → 0` at `r = 0`. If it did not, the profile would have a cusp on the axis, and a +cusp in a diffusion profile means a source sitting exactly there. There is no such source +in this model. + +So at the axis the singular term is `0/0`, and the limit exists. Apply L'Hôpital to the +quotient `(∂c/∂r) / r` as `r → 0`: differentiate numerator and denominator separately, +giving `(∂²c/∂r²) / 1`. The geometric term therefore contributes exactly as much as the +ordinary term, and the operator becomes + +``` +lim(r→0) D [ ∂²c/∂r² + (1/r)∂c/∂r ] = 2 D ∂²c/∂r² +``` + +**The factor of 2 is not a fudge.** It is the geometric term, evaluated in the limit, +turning out to equal the ordinary term. On the axis, diffusion is twice as effective at +flattening curvature as it is in a slab, because the shell there is closing in from every +direction at once. + +This is why the manuscript calls it symmetry rather than a wall. The condition at +`r = 0` is a *consequence of the geometry*, not a physical boundary anybody chose. There +is no membrane on the axis, nothing is sealed, and no flux is being blocked. The zero +gradient is what radial symmetry forces, and the "zero-flux" phrasing describes the +consequence rather than a mechanism. + +--- + +## 3. Three treatments, three reasons + +Here is the part where a description of the *method* and a description of *this code* come +apart, and the gap is worth being precise about. + +**The idealised finite-volume story.** Finite difference discretises the **operator** — it +approximates `∂²c/∂r²` and `(1/r)∂c/∂r` separately with difference quotients, so it meets +the `1/r` head-on and has to special-case the singular node. Finite volume discretises the +**conservation law** instead: integrate over each cell and what remains is face fluxes +times face areas. You never differentiate `1/r` at all. + +On that telling, both hard parts vanish for free. The innermost cell's inner face sits at +radius zero, so its **area** is zero, and the axis condition enforces itself by +multiplying a flux by nothing. At the wall, a Robin condition is a statement about flux — +and flux is already the quantity the scheme carries — so you set the outermost face flux +directly, with no ghost node and no one-sided derivative. + +**This solver does neither of those things, and the interior is the only place the story +holds.** + +*The interior* is genuinely flux-form. Geometry enters through two face weights and +nowhere else: + +```r +w_plus <- (r_grid + 0.5 * dr) / r_grid +w_minus <- (r_grid - 0.5 * dr) / r_grid +``` + +Those are face area over cell volume, exactly the ratio Part 1 derived, evaluated at the +half-indices `r ± dr/2` where the faces are. The stencil then reads as +`(outward face flux − inward face flux)`, weighted, and no `1/r` is ever differentiated. + +*The axis is special-cased.* The innermost node does not get a zero-area face; it gets the +L'Hôpital limit from Part 2 written in directly: + +```r +dc_dt[1] <- D_eff * 2.0 * (c_vec[2] - c_vec[1]) / dr^2 + ... +``` + +And the face weights at that node are set to `NA` on purpose, so any future code that +reaches for a metric weight there fails loudly instead of silently using one. + +*The wall uses a ghost node.* Not a directly-set face flux — the eliminated-ghost algebra +that the idealised account says finite volume avoids: + +```r +c_ghost <- c_vec[Nr - 1] - + 2.0 * dr * bc_coef * (c_vec[Nr] - c_ext) / D_eff +``` + +So the code is a hybrid, and the contrast is what makes it worth explaining. Flux-form in the +interior, L'Hôpital at the axis, ghost-node elimination at the wall — three treatments and +three reasons. The interior needs no special case *because* it is flux-form; the two +boundaries need one *because* the scheme is not carried through to the faces there. The +idealised account is correct about the method and it explains why the middle looks the way +it does. It is not a description of the two ends. + +--- + +## 4. The Robin condition + +At the outer wall the solver imposes: + +``` +−D_eff ∂c/∂r|_{r=R} = P_eff(t) · (c(R,t) − c_ext) +``` + +Read it as a flux balance. The left side is the diffusive flux arriving at the wall from +inside. The right side is what the membrane will pass, proportional to the concentration +difference across it, with `P_eff` the constant of proportionality — a permeability, in +units of velocity. + +It interpolates between the two conditions you already know. + +- `P_eff → ∞`: the right side can only stay finite if `c(R) → c_ext`. The membrane is + invisible; this is a Dirichlet condition. +- `P_eff → 0`: the right side vanishes, so `∂c/∂r|_R = 0`. Nothing crosses; this is a + sealed wall, a Neumann condition. + +The membrane is the only thing in this model that is neither, and that is exactly why the +condition has to be Robin rather than one of the two simpler kinds. + +Getting from that statement to the code is one substitution. Introduce a ghost node +`c[Nr+1]` just outside the domain and approximate the wall derivative with a centred +difference, which is second-order: + +``` +∂c/∂r|_R ≈ ( c[Nr+1] − c[Nr−1] ) / (2 dr) +``` + +Put that into the Robin condition and solve for the ghost: + +``` +−D ( c[Nr+1] − c[Nr−1] ) / (2 dr) = bc_coef ( c[Nr] − c_ext ) +c[Nr+1] = c[Nr−1] − 2 dr · bc_coef ( c[Nr] − c_ext ) / D +``` + +which is the line in the solver. The ghost is then substituted into the ordinary interior +stencil, so it never appears in the state vector — it exists only inside the arithmetic +for the last node. + +One naming caution, and the code is emphatic about it. `bc_coef` is `P_eff` in the cylindrical +geometry and `k_L`, an external liquid-film mass-transfer coefficient, in the slab. They +are different physical quantities, and the slab's must not be reported as a permeability. +The stencil is written to take whichever the producer declares. + +--- + +## 5. Method of lines, and why LSODA + +**The construction.** Discretise space and leave time alone. Forty cells, each with a +concentration, gives forty coupled ordinary differential equations — plus forty more for +the sorbed phase and one for membrane integrity, packed into a single state vector of +length `2·Nr + 1`. That is the method of lines, and the point of it is that once space is +discretised you are holding an ODE system, and ODE integrators are a solved problem you +can hand it to. + +Why not step it forward yourself? An explicit step is limited by stability, not +by accuracy. The diffusion operator's eigenvalues scale as `D/Δr²`, and an explicit Euler +step is stable only while `|1 + Δt·λ| ≤ 1` for every eigenvalue `λ` — which caps the step +at roughly `Δr²/2D`. Halve the grid spacing and the allowed step drops by four. + +That bound is not hypothetical here: the Julia port's explicit stepper substeps against +exactly it, with a safety factor: + +```julia +dt_stable = 0.4 * dr^2 / (2.0 * rd.params.D_eff) +``` + +That is only one of the timescales. Sorption runs at its own rate — an uptake of +`U = 0.056 s⁻¹` against a relaxation of `1/(k_des + k_loss) = 166.7 s`. When the fastest +process in a system forces a step far shorter than the slowest process needs for accuracy, +the system is **stiff**, and that is the whole of the definition. You are then paying tiny +steps for stability while the interesting behaviour unfolds over minutes. + +That is what LSODA is for. It monitors the solution and switches between an Adams +method (cheap, for the non-stiff stretches) and BDF (implicit, stable at large steps, for +the stiff ones), choosing its own step size to meet the tolerances it is given rather than +a stability limit you computed by hand. + +**A caveat this repository is careful about, and so is this guide.** The explicit bound +above is analysed in `analysis/verify_radiodialysis_stability.py`, which separates three +claims that are easy to conflate — continuous stability, semi-discrete stability, and +explicit-Euler stability — and computes the conservative real-axis limit directly. Its +conclusion about the Julia path is that the explicit stepper is stable at `dt = 0.5` only +because `r` is in lattice units while `D_eff` is in `cm² s⁻¹`, which is the documented +units error behind `RADIODIALYSIS: BLOCKED`. **A stability margin that comes from a units +mismatch is not evidence the step is safe**, and none of the reasoning in this part should +be read as saying the coupled path is sound. + +--- + +## Checks against the source + +Every row is verified by `calibration/tests/test_guide_citations.py`: the file must exist, +the line must exist, and it must contain the fragment. A stale line number fails the +suite, and the failure message distinguishes "the code moved" from "the code changed." + +### Part 1 — where the `r` comes from + +| claim | source | must contain | +|---|---|---| +| the solver states the cylindrical operator | `biofilms_radiodialysis.R:9` | `(1/r) ∂/∂r (r D_eff ∂c/∂r)` | +| face weights are area-over-volume at the half-indices | `biofilms_radiodialysis.R:223` | `w_plus <- (r_grid + 0.5 * dr) / r_grid` | +| and the inward face likewise | `biofilms_radiodialysis.R:224` | `w_minus <- (r_grid - 0.5 * dr) / r_grid` | +| geometry enters the stencil only through those weights | `biofilms_radiodialysis.R:120` | `(w_plus[i] * (c_vec[i + 1] - c_vec[i]) -` | + +### Part 2 — the axis + +| claim | source | must contain | +|---|---|---| +| the axis limit is named as L'Hôpital | `biofilms_radiodialysis.R:106` | `L'Hôpital limit` | +| and implemented with the factor of 2 | `biofilms_radiodialysis.R:110` | `dc_dt[1] <- D_eff * 2.0 * (c_vec[2] - c_vec[1]) / dr^2` | +| the manuscript calls it symmetry, not a wall | `preprint/modeling_radioresistance_and_radiotropic_fitness.tex:780` | `Zero-flux symmetry is imposed at $r = 0$.` | + +### Part 3 — three treatments + +| claim | source | must contain | +|---|---|---| +| the axis is special-cased, not zero-area | `biofilms_radiodialysis.R:110` | `dc_dt[1] <- D_eff * 2.0` | +| its face weights are deliberately unusable | `biofilms_radiodialysis.R:226` | `w_plus[1] <- NA_real_; w_minus[1] <- NA_real_` | +| the wall uses a ghost node, not a set face flux | `biofilms_radiodialysis.R:132` | `c_ghost <- c_vec[Nr - 1] -` | +| the scheme is named finite-volume method of lines | `biofilms_radiodialysis.R:32` | `finite-volume method of lines` | +| the manuscript claims both ends are in the operator | `preprint/modeling_radioresistance_and_radiotropic_fitness.tex:1044` | `represented explicitly in the semi-discrete operator` | + +### Part 4 — the Robin condition + +| claim | source | must contain | +|---|---|---| +| the condition as stated | `biofilms_radiodialysis.R:20` | `-D_eff ∂c/∂r\|_{r=R} = P_eff(t) · (c(R,t) − c_ext)` | +| the ghost substitution, second line | `biofilms_radiodialysis.R:133` | `2.0 * dr * bc_coef * (c_vec[Nr] - c_ext) / D_eff` | + +### Part 5 — method of lines and LSODA + +| claim | source | must contain | +|---|---|---| +| forty cells by default | `biofilms_radiodialysis.R:230` | `default_parms <- function(Nr = 40, R = 1.0)` | +| the state vector packs c, s and m | `biofilms_radiodialysis.R:50` | `y[1 .. Nr] = c_i` | +| LSODA is the integrator | `biofilms_radiodialysis.R:400` | `method = "lsoda"` | +| the Julia port substeps against the diffusion bound | `biofilms_potts.jl:1453` | `dt_stable = 0.4 * dr^2 / (2.0 * rd.params.D_eff)` | +| the explicit-Euler limit is computed, not asserted | `analysis/verify_radiodialysis_stability.py:153` | `conservative_real_axis_limit` | diff --git a/docs/guides/calculus_in_this_code.md.sha256 b/docs/guides/calculus_in_this_code.md.sha256 new file mode 100644 index 0000000..cedaa40 --- /dev/null +++ b/docs/guides/calculus_in_this_code.md.sha256 @@ -0,0 +1 @@ +eab597dede69cd79105f6d4f233107536af2c3cd365c3c046c457ac24256c5d7 diff --git a/docs/guides/calculus_in_this_code.pdf b/docs/guides/calculus_in_this_code.pdf new file mode 100644 index 0000000..fbea8a3 Binary files /dev/null and b/docs/guides/calculus_in_this_code.pdf differ diff --git a/docs/guides/calculus_in_this_code.tex b/docs/guides/calculus_in_this_code.tex new file mode 100644 index 0000000..dbf7dd0 --- /dev/null +++ b/docs/guides/calculus_in_this_code.tex @@ -0,0 +1,512 @@ +% The calculus in this code — Section 1: the radial operator +% Compile with: xelatex calculus_in_this_code.tex +% Source of record: docs/guides/calculus_in_this_code.md + +\documentclass[11pt]{article} + +\usepackage{fontspec} +\setmainfont{Latin Modern Roman} +\setsansfont{Latin Modern Sans} +\setmonofont{DejaVu Sans Mono}[Scale=0.82] + +\usepackage{unicode-math} +\setmathfont{Latin Modern Math} + +\usepackage[a4paper,margin=1.05in,top=1.1in,bottom=1.15in]{geometry} +\usepackage{fancyvrb} +\usepackage{xcolor} +\usepackage{booktabs} +\usepackage{longtable} +\usepackage{array} +\usepackage{titlesec} +\usepackage{fancyhdr} +\usepackage[most]{tcolorbox} +\usepackage{enumitem} +\usepackage{microtype} +\usepackage{hyperref} + +\definecolor{rule}{HTML}{9AA0A6} +\definecolor{panel}{HTML}{F4F5F7} +\definecolor{panelrule}{HTML}{C9CDD3} +\definecolor{codebg}{HTML}{F7F7F5} +\definecolor{coderule}{HTML}{DCDCD8} +\definecolor{heading}{HTML}{1A1A1A} + +\hypersetup{ + colorlinks=true, linkcolor=black, urlcolor=black, + pdftitle={The calculus in this code — Section 1: the radial operator}, + pdfsubject={What the radiodialysis solver actually integrates} +} + +\titleformat{\section} + {\normalfont\large\bfseries\color{heading}}{\thesection.}{0.6em}{} +\titlespacing*{\section}{0pt}{1.9\baselineskip}{0.7\baselineskip} +\titleformat{\subsection} + {\normalfont\normalsize\bfseries\color{heading}}{}{0em}{} +\titlespacing*{\subsection}{0pt}{1.2\baselineskip}{0.4\baselineskip} + +\pagestyle{fancy} +\fancyhf{} +\renewcommand{\headrulewidth}{0.4pt} +\renewcommand{\headrule}{\color{rule}\hrule height 0.4pt} +\fancyhead[L]{\footnotesize\color{rule}The calculus in this code} +\fancyhead[R]{\footnotesize\color{rule}Section 1 — the radial operator} +\fancyfoot[C]{\footnotesize\color{rule}\thepage} + +\setlength{\parskip}{0.55\baselineskip} +\setlength{\parindent}{0pt} + +% Code blocks: raw Unicode goes straight to DejaVu Sans Mono. +\DefineVerbatimEnvironment{code}{Verbatim} + {fontsize=\small, xleftmargin=1.1em, xrightmargin=0.6em, + frame=leftline, framerule=1.4pt, rulecolor=\color{coderule}, + framesep=0.85em, baselinestretch=1.02} + +\newtcolorbox{panelbox}{ + colback=panel, colframe=panelrule, boxrule=0.4pt, + arc=1.5pt, left=1.05em, right=1.05em, top=0.9em, bottom=0.9em, + breakable, + before upper={\setlength{\parskip}{0.55\baselineskip}} +} + +% Check tables: fragments are code, set small and allowed to break. +\newcolumntype{C}{>{\raggedright\arraybackslash}p{0.285\textwidth}} +\newcolumntype{S}{>{\raggedright\arraybackslash\footnotesize\ttfamily}p{0.275\textwidth}} +\newcolumntype{F}{>{\raggedright\arraybackslash\footnotesize\ttfamily}p{0.315\textwidth}} + +\newcommand{\us}{\_\allowbreak} +\newcommand{\checkhead}{% + \toprule + \textbf{\normalsize claim} & \textbf{\normalsize source} & \textbf{\normalsize must contain} \\ + \midrule +} + +\begin{document} + +\begin{center} +{\LARGE\bfseries The calculus in this code}\\[0.45em] +{\large Section 1 — the radial operator}\\[1.1em] +{\footnotesize\color{rule}% +Derived from \texttt{docs/guides/calculus\_in\_this\_code.md}, which is the checked source.\\ +This rendering is a derived artifact and can go stale independently of it.} +\end{center} + +\vspace{0.6\baselineskip} + +\begin{panelbox} +One line of the solver carries the whole geometry: + +\begin{code} +∂c/∂t = (1/r) ∂/∂r (r D_eff ∂c/∂r) +\end{code} + +Two $r$s, and neither is a coordinate trick. The one inside is flux times area. The one +outside is division by volume. Everything in Part 1 follows from a shell between $r$ and +$r + \mathrm{d}r$ whose two faces have different areas, and you can get there without ever +writing down a coordinate transformation. + +The other four parts are the places where that operator meets the code: why the axis needs +no boundary condition, why this solver special-cases it anyway, how a Robin wall becomes one +line of ghost-node algebra, and why forty coupled ODEs go to LSODA rather than a fixed step. + +\textbf{Floor:} the chain rule, the product rule, and what a partial derivative is. +\textbf{Ceiling:} ordinary differential equations as objects an integrator solves. + +Not here: general curvilinear coordinates, the divergence theorem in 3D, stability proofs, +and the sorption terms beyond noting that they set a second timescale. + +Each part ends with a \emph{Checks against the source} table. Every row carries a +\texttt{file:line} and a fragment that line must contain, and +\texttt{calibration/tests/test\_guide\_citations.py} verifies all of them — a stale citation +fails the suite, and the failure says whether the code moved or the code changed. + +\textbf{Claims in the prose but not in a table are not mechanically verified.} Three here: +the reading of \texttt{bc\_coef} as $P_{\text{eff}}$ versus $k_L$, the sorption rates in +Part 5, and the stiffness characterisation in Part 5. All three are sourced in the text. +None is pinned by a test. A guide implying uniform coverage would be making exactly the +claim its own tables exist to discipline. + +\texttt{README.md}'s \emph{Mathematical framework} states the project's \emph{target +formalism}, under its own disclaimer that what the programs integrate differs. This is the +other side: what the code integrates. It cross-references those equations rather than +restating them, so the two cannot drift into disagreeing. +\end{panelbox} + +\section{Where the \texorpdfstring{$r$}{r} comes from} + +The solver's header states the equation it integrates: + +\begin{code} +∂c/∂t = (1/r) ∂/∂r (r D_eff ∂c/∂r) +\end{code} + +Most courses introduce this as ``the Laplacian in cylindrical coordinates'' and hand you a +table. That presentation makes the $r$ look like bookkeeping from a change of variables. It +isn't. It is a statement about area, and you can derive it without ever mentioning +coordinates. + +\textbf{Take a shell.} Consider the region between radius $r$ and radius $r + \mathrm{d}r$, +one unit tall. Two facts about it: + +\begin{itemize}[leftmargin=1.4em, itemsep=0.15em, topsep=0.3em] +\item its \textbf{volume} is $2\pi r\,\mathrm{d}r$ (circumference times thickness), and +\item its two \textbf{faces} have areas $2\pi r$ and $2\pi(r + \mathrm{d}r)$ — + \emph{different areas}. +\end{itemize} + +That difference is the whole story. In a slab, the two faces of a slice have the same area, +and everything below collapses to $\partial^2 c/\partial x^2$. + +\textbf{Write conservation.} The amount of solute in the shell changes at the rate it enters +minus the rate it leaves. Rate of flow through a face is flux times area, so with $J(r)$ the +flux (amount per area per time, positive outward): +\[ +\frac{\mathrm{d}}{\mathrm{d}t}\bigl[\,c \cdot 2\pi r\,\mathrm{d}r\,\bigr] +\;=\; J(r)\cdot 2\pi r \;-\; J(r+\mathrm{d}r)\cdot 2\pi(r+\mathrm{d}r) +\] + +Divide by the volume $2\pi r\,\mathrm{d}r$: +\[ +\frac{\partial c}{\partial t} +\;=\; -\,\frac{(r+\mathrm{d}r)J(r+\mathrm{d}r) - r\,J(r)}{r\,\mathrm{d}r} +\] + +The numerator is the change in the product $rJ$ across the shell. Let +$\mathrm{d}r \to 0$ and it is by definition the derivative of that product: +\[ +\frac{\partial c}{\partial t} \;=\; -\frac{1}{r}\frac{\partial (rJ)}{\partial r} +\] + +\textbf{Now put Fick's law in.} Flux runs down the concentration gradient, +$J = -D\,\partial c/\partial r$. Substituting: +\[ +\frac{\partial c}{\partial t} +\;=\; \frac{1}{r}\frac{\partial}{\partial r}\!\left( r D \frac{\partial c}{\partial r} \right) +\] + +which is the line in the header, derived from a conservation statement and two areas. + +\textbf{Read the two $r$s separately, because they are different things.} The $r$ +\emph{inside} the derivative is flux $\times$ area — it is there because a face's area grows +with radius. The $1/r$ \emph{outside} is $\div$ volume — it is there because you asked for a +concentration rather than an amount. Neither is a coordinate artifact. + +The physical content is real. Flux converging on a shrinking face concentrates; +flux spreading onto a growing face dilutes. A slab has neither effect, which is why its +operator has no such term. If you expand the product for constant $D$: +\[ +\frac{\partial c}{\partial t} +\;=\; D\left[\, \frac{\partial^2 c}{\partial r^2} + + \frac{1}{r}\frac{\partial c}{\partial r} \,\right] +\] + +the first term is ordinary diffusion and \textbf{the second is purely geometric} — it is the +dilution-onto-a-larger-face effect, and it is proportional to the gradient rather than to its +curvature. Note that it carries a $1/r$, which is where Part 2 begins. + +\subsection*{Checks against the source} + +\begin{longtable}{CSF} +\checkhead +\endhead +the solver states the cylindrical operator + & biofilms\us radiodialysis.R:9 + & (1/r) ∂/∂r (r D\_eff ∂c/∂r) \\[0.35em] +face weights are area-over-volume at the half-indices + & biofilms\us radiodialysis.R:223 + & w\_plus <- (r\_grid + 0.5 * dr) / r\_grid \\[0.35em] +and the inward face likewise + & biofilms\us radiodialysis.R:224 + & w\_minus <- (r\_grid - 0.5 * dr) / r\_grid \\[0.35em] +geometry enters the stencil only through those weights + & biofilms\us radiodialysis.R:120 + & (w\_plus[i] * (c\_vec[i + 1] - c\_vec[i]) - \\ +\bottomrule +\end{longtable} + +\section{Why the axis isn't a boundary} + +That $(1/r)\,\partial c/\partial r$ term is singular at $r = 0$. The solution is not. + +The resolution is symmetry. The model is radially symmetric — $c$ depends on +distance from the axis and on nothing else. A smooth function of radius alone must have +$\partial c/\partial r \to 0$ at $r = 0$. If it did not, the profile would have a cusp on the +axis, and a cusp in a diffusion profile means a source sitting exactly there. There is no +such source in this model. + +So at the axis the singular term is $0/0$, and the limit exists. Apply L'Hôpital to the +quotient $(\partial c/\partial r)/r$ as $r \to 0$: differentiate numerator and denominator +separately, giving $(\partial^2 c/\partial r^2)/1$. The geometric term therefore contributes +exactly as much as the ordinary term, and the operator becomes +\[ +\lim_{r\to 0} D\left[\frac{\partial^2 c}{\partial r^2} + + \frac{1}{r}\frac{\partial c}{\partial r}\right] + \;=\; 2D\,\frac{\partial^2 c}{\partial r^2} +\] + +\textbf{The factor of 2 is not a fudge.} It is the geometric term, evaluated in the limit, +turning out to equal the ordinary term. The cleanest way to remember it: the factor equals +the number of spatial dimensions the radial coordinate is standing in for — 2 here, because +$r$ is the radius of a disc in the plane; it would be 3 for a sphere. On the axis, diffusion +is that many times as effective at flattening curvature as it is in a slab. + +This is why the manuscript calls it symmetry rather than a wall. The condition at +$r = 0$ is a \emph{consequence of the geometry}, not a physical boundary anybody chose. There +is no membrane on the axis, nothing is sealed, and no flux is being blocked. The zero +gradient is what radial symmetry forces, and the ``zero-flux'' phrasing describes the +consequence rather than a mechanism. + +\subsection*{Checks against the source} + +\begin{longtable}{CSF} +\checkhead +\endhead +the axis limit is named as L'Hôpital + & biofilms\us radiodialysis.R:106 + & L'Hôpital limit \\[0.35em] +and implemented with the factor of 2 + & biofilms\us radiodialysis.R:110 + & dc\_dt[1] <- D\_eff * 2.0 * (c\_vec[2] - c\_vec[1]) / dr\^{}2 \\[0.35em] +the manuscript calls it symmetry, not a wall + & preprint/modeling\us radioresistance\us and\us radiotropic\us fitness.tex:780 + & Zero-flux symmetry is imposed at \$r = 0\$. \\ +\bottomrule +\end{longtable} + +\section{Three treatments, three reasons} + +Here is the part where a description of the \emph{method} and a description of \emph{this +code} come apart, and the gap is worth being precise about. + +\textbf{The idealised finite-volume story.} Finite difference discretises the +\textbf{operator} — it approximates $\partial^2 c/\partial r^2$ and +$(1/r)\,\partial c/\partial r$ separately with difference quotients, so it meets the $1/r$ +head-on and has to special-case the singular node. Finite volume discretises the +\textbf{conservation law} instead: integrate over each cell and what remains is face fluxes +times face areas. You never differentiate $1/r$ at all. + +On that telling, both hard parts vanish for free. The innermost cell's inner face sits at +radius zero, so its \textbf{area} is zero, and the axis condition enforces itself by +multiplying a flux by nothing. At the wall, a Robin condition is a statement about flux — and +flux is already the quantity the scheme carries — so you set the outermost face flux +directly, with no ghost node and no one-sided derivative. + +\textbf{This solver does neither of those things, and the interior is the only place the +story holds.} + +\emph{The interior} is genuinely flux-form. Geometry enters through two face weights and +nowhere else: + +\begin{code} +w_plus <- (r_grid + 0.5 * dr) / r_grid +w_minus <- (r_grid - 0.5 * dr) / r_grid +\end{code} + +Those are face area over cell volume, exactly the ratio Part 1 derived, evaluated at the +half-indices $r \pm \mathrm{d}r/2$ where the faces are. The stencil then reads as (outward +face flux $-$ inward face flux), weighted, and no $1/r$ is ever differentiated. + +\emph{The axis is special-cased.} The innermost node does not get a zero-area face; it gets +the L'Hôpital limit from Part 2 written in directly: + +\begin{code} +dc_dt[1] <- D_eff * 2.0 * (c_vec[2] - c_vec[1]) / dr^2 + ... +\end{code} + +That line is the one Part 2's table already pins, so it is not repeated below. What the table +here adds is the second half of the evidence: the face weights at that node are set to +\texttt{NA} on purpose, so any future code that reaches for a metric weight there fails +loudly instead of silently using one. + +\emph{The wall uses a ghost node.} Not a directly-set face flux — the eliminated-ghost +algebra that the idealised account says finite volume avoids: + +\begin{code} +c_ghost <- c_vec[Nr - 1] - + 2.0 * dr * bc_coef * (c_vec[Nr] - c_ext) / D_eff +\end{code} + +So the code is a hybrid, and the contrast is what makes it worth explaining. Flux-form in the +interior, L'Hôpital at the axis, ghost-node elimination at the wall — three treatments and +three reasons. The interior needs no special case \emph{because} it is flux-form; the two +boundaries need one \emph{because} the scheme is not carried through to the faces there. The +idealised account is correct about the method and it explains why the middle looks the way it +does. It is not a description of the two ends. + +\textbf{None of that contradicts the manuscript.} \S5 describes the axis limit and the Robin +condition as represented explicitly in the semi-discrete operator, and both are: the +L'Hôpital limit and the ghost elimination are written out inside the right-hand side rather +than imposed on it from outside. ``Explicitly in the operator'' and ``through the face +structure'' are different claims, and only the second is the one this part is distinguishing +from. + +\subsection*{Checks against the source} + +\begin{longtable}{CSF} +\checkhead +\endhead +the axis node's face weights are deliberately unusable + & biofilms\us radiodialysis.R:226 + & w\_plus[1] <- NA\_real\_; w\_minus[1] <- NA\_real\_ \\[0.35em] +the wall uses a ghost node, not a set face flux + & biofilms\us radiodialysis.R:132 + & c\_ghost <- c\_vec[Nr - 1] - \\[0.35em] +the scheme is named finite-volume method of lines + & biofilms\us radiodialysis.R:32 + & finite-volume method of lines \\[0.35em] +the manuscript describes both ends as handled in the operator + & preprint/modeling\us radioresistance\us and\us radiotropic\us fitness.tex:1044 + & represented explicitly in the semi-discrete operator \\ +\bottomrule +\end{longtable} + +\section{The Robin condition} + +At the outer wall the solver imposes: +\[ +-D_{\text{eff}}\,\frac{\partial c}{\partial r}\bigg|_{r=R} + \;=\; P_{\text{eff}}(t)\,\bigl(c(R,t) - c_{\text{ext}}\bigr) +\] + +Read it as a flux balance. The left side is the diffusive flux arriving at the wall from +inside. The right side is what the membrane will pass, proportional to the concentration +difference across it, with $P_{\text{eff}}$ the constant of proportionality — a permeability, +in units of velocity. + +It interpolates between the two conditions you already know. + +\begin{itemize}[leftmargin=1.4em, itemsep=0.2em, topsep=0.3em] +\item $P_{\text{eff}} \to \infty$: the right side can only stay finite if + $c(R) \to c_{\text{ext}}$. The membrane is invisible; this is a Dirichlet condition. +\item $P_{\text{eff}} \to 0$: the right side vanishes, so + $\partial c/\partial r|_R = 0$. Nothing crosses; this is a sealed wall, a Neumann + condition. +\end{itemize} + +The membrane is the only thing in this model that is neither, and that is exactly why the +condition has to be Robin rather than one of the two simpler kinds. + +Getting from that statement to the code is one substitution. Introduce a ghost node +$c[N_r{+}1]$ just outside the domain and approximate the wall derivative with a centred +difference, which is second-order: +\[ +\frac{\partial c}{\partial r}\bigg|_R \approx + \frac{c[N_r{+}1] - c[N_r{-}1]}{2\,\mathrm{d}r} +\] + +Put that into the Robin condition and solve for the ghost: +\begin{align*} +-D\,\frac{c[N_r{+}1] - c[N_r{-}1]}{2\,\mathrm{d}r} + &= \texttt{bc\_coef}\,\bigl(c[N_r] - c_{\text{ext}}\bigr) \\[0.25em] +c[N_r{+}1] &= c[N_r{-}1] - 2\,\mathrm{d}r\;\texttt{bc\_coef}\, + \bigl(c[N_r] - c_{\text{ext}}\bigr) / D +\end{align*} + +which is the line in the solver. The ghost is then substituted into the ordinary interior +stencil, so it never appears in the state vector — it exists only inside the arithmetic for +the last node. + +One naming caution, and the code is emphatic about it. \texttt{bc\_coef} is +$P_{\text{eff}}$ in the cylindrical geometry and $k_L$, an external liquid-film mass-transfer +coefficient, in the slab. They are different physical quantities, and the slab's must not be +reported as a permeability. The stencil is written to take whichever the producer declares. + +\subsection*{Checks against the source} + +\begin{longtable}{CSF} +\checkhead +\endhead +the condition as stated + & biofilms\us radiodialysis.R:20 + & -D\_eff ∂c/∂r|\_\{r=R\} = P\_eff(t) · (c(R,t) − c\_ext) \\[0.35em] +the ghost substitution, second line + & biofilms\us radiodialysis.R:133 + & 2.0 * dr * bc\_coef * (c\_vec[Nr] - c\_ext) / D\_eff \\ +\bottomrule +\end{longtable} + +\section{Method of lines, and why LSODA} + +\textbf{The construction.} Discretise space and leave time alone. Forty cells, each with a +concentration, gives forty coupled ordinary differential equations — plus forty more for the +sorbed phase and one for membrane integrity, packed into a single state vector of length +$2N_r + 1$. That is the method of lines, and the point of it is that once space is +discretised you are holding an ODE system, and ODE integrators are a solved problem you can +hand it to. + +Why not step it forward yourself? An explicit step is limited by stability, not +by accuracy. The diffusion operator's eigenvalues scale as $D/\Delta r^2$, and an explicit +Euler step is stable only while $|1 + \Delta t\,\lambda| \le 1$ for every eigenvalue +$\lambda$ — which caps the step at roughly $\Delta r^2/2D$. Halve the grid spacing and the +allowed step drops by four. + +That bound is not hypothetical here: the Julia port's explicit stepper substeps against +exactly it, with a safety factor: + +\begin{code} +dt_stable = 0.4 * dr^2 / (2.0 * rd.params.D_eff) +\end{code} + +That is only one of the timescales. Sorption runs at its own rate — an uptake of +$U = 0.056\ \mathrm{s^{-1}}$ against a relaxation of +$1/(k_{\text{des}} + k_{\text{loss}}) = 166.7\ \mathrm{s}$. When the fastest process in a +system forces a step far shorter than the slowest process needs for accuracy, the system is +\textbf{stiff}, and that is the whole of the definition. + +\textbf{Whether \emph{this} system is stiff is not computed anywhere in this repository, and +this guide does not assert it.} Stiffness is a ratio, and no artifact here reports one: +\texttt{analysis/verify\_radiodialysis\_stability.py} computes the explicit-Euler bound and +the conservative real-axis limit, but not a stiffness ratio against the sorption timescale. +The two rates quoted above are the ingredients such a ratio would be built from, and they are +themselves among the prose claims no test pins. So the honest statement is that LSODA is a +defensible default for a system with two visibly separated timescales, not a measured +requirement — and closing that gap means shipping the ratio from a producer, in the shape the +rest of this repository already uses. + +That is what LSODA is for. It monitors the solution and switches between an Adams +method (cheap, for the non-stiff stretches) and BDF (implicit, stable at large steps, for the +stiff ones), choosing its own step size to meet the tolerances it is given rather than a +stability limit you computed by hand. + +\textbf{A caveat this repository is careful about, and so is this guide.} The explicit bound +above is analysed in \texttt{analysis/verify\_radiodialysis\_stability.py}, which separates +three claims that are easy to conflate — continuous stability, semi-discrete stability, and +explicit-Euler stability — and computes the conservative real-axis limit directly. Its +conclusion about the Julia path is that the explicit stepper is stable at +$\mathrm{d}t = 0.5$ only because $r$ is in lattice units while $D_{\text{eff}}$ is in +$\mathrm{cm^2\,s^{-1}}$, which is the documented units error behind +\texttt{RADIODIALYSIS: BLOCKED}. \textbf{A stability margin that comes from a units mismatch +is not evidence the step is safe}, and none of the reasoning in this part should be read as +saying the coupled path is sound. + +\subsection*{Checks against the source} + +\begin{longtable}{CSF} +\checkhead +\endhead +forty cells by default + & biofilms\us radiodialysis.R:230 + & default\_parms <- function(Nr = 40, R = 1.0) \\[0.35em] +the state vector packs c, s and m + & biofilms\us radiodialysis.R:50 + & y[1 .. Nr] = c\_i \\[0.35em] +LSODA is the integrator + & biofilms\us radiodialysis.R:400 + & method = "lsoda" \\[0.35em] +the Julia port substeps against the diffusion bound + & biofilms\us potts.jl:1453 + & dt\_stable = 0.4 * dr\^{}2 / (2.0 * rd.params.D\_eff) \\[0.35em] +the explicit-Euler limit is computed, not asserted + & analysis/verify\us radiodialysis\us stability.py:153 + & conservative\_real\_axis\_limit \\ +\bottomrule +\end{longtable} + +\vspace{1.2\baselineskip} +{\footnotesize\color{rule}\hrule\vspace{0.7em} +Every check-table row is verified by +\texttt{calibration/tests/test\_guide\_citations.py}: the file must exist, the line must +exist, and it must contain the fragment. A stale line number fails the suite, and the failure +message distinguishes ``the code moved'' from ``the code changed.'' Long paths are broken across lines at underscores; the breaks are typographic only.} + +\end{document} diff --git a/docs/jacc_coupling_port.md b/docs/jacc_coupling_port.md index 4a323c2..d5d743e 100644 --- a/docs/jacc_coupling_port.md +++ b/docs/jacc_coupling_port.md @@ -72,6 +72,59 @@ by 11-21x at n = 1e3-1e5 and the arms converge to within 1.07-1.23x by n = 1e7. **The near-term work on this port is kernel structure, not more FLOPs**, and a larger device would not change that. +## The decomposition is now measured (2026-08-29) + +Kernel agreement above is blind to the one defect the checkerboard can have of its +own. A parity-correlated bias in accepted moves passes `jacc_port_tests.jl` +whenever **both** kernels carry it, and `tests/fixtures/serial_seed42.csv` pins the +serial stream, which has no sublattices at all. `tests/jacc_parity_tests.jl` is that +measurement. `cpm_color!` gained two write-only per-site arrays — `st` +(0 never proposed / 1 evaluated-rejected / 2 evaluated-accepted) and `dh` (ΔH of +evaluated proposals) — reduced per sweep and **never drawn**: `d404438` refused a +spatial map for the serial decisive-label tally at 6157 of 64000 voxels touched, +median 3, and a parity bias is a global count comparison regardless. + +Three choices in that tier are not obvious and each avoids a false failure on the +first run: + +**The table is conditioned on opportunity.** The kernel's early returns — wall, +same-σ, medium-into-medium, out-of-bounds — are geometry-dependent, so a uniform +null over eight classes would report the shape of the domain as a decomposition +artifact. `st` carries the denominator and the 2×8 accepted/rejected table asks +about the acceptance *rate* per class. + +**The thresholds are effect sizes, not χ².** n is 1.3e5–3.1e5 evaluated proposals +per run and the cells are not independent — an accepted move changes the lattice +for every later pass — so χ² over-disperses from autocorrelation alone and a fixed +critical value would test "is there any asymmetry at all". Cramér's V and the max +per-class rate deviation are asserted; χ² and n are reported beside them. + +**`color_order` separates the decomposition from the `vols` staleness.** The colour +loop is sequential and `vols` accumulates across passes, so the first pass evaluates +against sweep-start volumes and the last against volumes moved by seven passes — +a deterministic, parity-correlated difference with nothing to do with the +checkerboard, and `c` indexes both spatial class and sequence position. Reversal +would separate only a monotonic position effect; random permutations drop that +assumption. The RNG step key stays `mcs*8 + c`, keyed to the colour rather than to +its position, so permuting changes the pass order and nothing else. + +Measured at N=20, 50 MCS, threads backend, seeds 42/43/44 × three orderings: +V 0.0050–0.0111, max per-class rate deviation 0.017–0.049. **No decomposition +artifact detected, and the pattern tracks neither spatial class nor pass position** — +the third disposition, which the tier reports as unresolved rather than attributing. + +### Reproducibility has a narrower scope than this document implied + +`delta_H` reads `vols` while `cpm_color!` mutates it in the same pass. The +`ponytail:` comment calls that staleness bounded and unbiased, which it is, but +bounded is not deterministic: **the port's trajectory is not reproducible across +thread counts.** Measured on the threads backend, seed 42, N=40, 100 MCS — one +thread gives the same lattice every run, four threads gave three different lattices +in three runs. Any lattice-equality check against this port is meaningful only +single-threaded, and there is deliberately no lattice fixture in `tests/fixtures/` +for that reason: it would sit in a compare-never-regenerate directory while being +hardware- and thread-count-dependent. + ## A Metal backend would need explicit Float32 conversion at the kernel boundary Not exercised today, and recorded here only as a forward-looking note: `Project.toml` has no diff --git a/docs/preprint_revision_plan.md b/docs/preprint_revision_plan.md index 0fe7bcc..a288c54 100644 --- a/docs/preprint_revision_plan.md +++ b/docs/preprint_revision_plan.md @@ -1,3 +1,5 @@ +SUPERSEDED: 2026-08-15 — the record of how the v1.1 revision was planned; its "still open" entries are history, not status. + # Preprint revision plan > **SUPERSEDED — kept as the record of how the revision was planned.** The revision it plans has @@ -170,7 +172,7 @@ versus predicted radioactive decay" — there are no measured decay data in any decay code in the repository. Withdrawn; the constant-background premise, which is what the rest of the paper actually uses, is kept. -**Provenance fixes.** The `audit2026` / [41] entry cited the audit at revision `f4e8dbf`, which +**Provenance fixes.** The `audit2026` entry — written here as "[41]" until 2026-09-01, when it was found to be entry #61 and the number was dropped in favour of the key — cited the audit at revision `f4e8dbf`, which predates the audit's own commits; now `904b9a4` / `a03bc32` at repository revision `5d1b777`. §5's "six cells per species" now reads "six biomass parcels", with the parcel semantics stated. diff --git a/docs/research/external_reviews_2026-08-31_redteam.md b/docs/research/external_reviews_2026-08-31_redteam.md new file mode 100644 index 0000000..2caf9e5 --- /dev/null +++ b/docs/research/external_reviews_2026-08-31_redteam.md @@ -0,0 +1,508 @@ +# Red-team: two commissioned external reviews vs. what this repository already holds + +2026-08-31 · not a calibration, not a claims-ledger entry, not a dataset candidate. + +## What this is + +Two long external research reviews were commissioned from outside this repository — one on +symplectic integration and biofilm rheology, one on whether biofilm morphology is +mechanically determined. Both make specific claims about this codebase and issue +recommendations. This document checks each claim against the source and records where it +was already known, where it is sharper here, and where it is wrong. + +**This document populates no calibration value and creates no ledger row.** Verdict +vocabulary borrowed from `data/claims_ledger.csv`'s closed set (`keep | restate | +requalify | delete | needs_calibration | needs_verification`, plus `supported` and +`must_not_be_claimed`). + +**Two of the reviews' errors originated in the brief, not in the reviews.** The briefs were +written here, and they asserted that §3.10 is marked specification-not-method and that the +model is CPM-with-adhesion-only. Both came back reported as findings. **A premise returned +as a finding is not independent confirmation of anything, and it reads exactly like one** — +which is why it is recorded rather than quietly corrected. The two failures take different +repairs: an *asserted-X* defect is repaired in the review; a *told-X* defect is repaired in +**how the next review is commissioned** — state the premise as a premise to be checked, not +as background. Collapsing them loses the only lesson that transfers outward. + +**Everything the reviews say about the outside literature is `needs_verification`.** Purcell, +Shaw's 18-minute relaxation time, Graner–Glazier, Yan 2019, the rheology-transfer analysis, +the modulus ranges: none of it was checked here, both documents self-flag items as resting +on aggregated reference lists, and none of it is adopted below as established. + +--- + +## Claim 1 — §3.4 should be reclassified to state that no momentum state exists + +**As stated (review):** "Reclassify the section. State explicitly that … no +independently-integrated momentum state exists or *should* exist." + +**What the repo already holds** (`preprint/…tex:527`): + +> **This subsection is specification, not method.** No program in this work performs +> symplectic integration. … no simulation reported here holds an independently-integrated +> momentum state … No identifier named `momentum` exists anywhere in this repository's +> simulation sources. + +The manuscript further distinguishes `biofilms_3d.R`'s velocity array — recomputed from +position and forces each step, never advanced by its own equation of motion — a nuance the +review does not mention. `tests/manuscript_claims_tests.jl:85-99` enforces the identifier +claim with a planted-positive control. + +**Verdict: `keep`.** Already done, in more detail than the recommendation asks for. + +--- + +## Claim 2 — a momentum state would be physically inappropriate, not merely absent + +**As stated (review):** Re ≈ 10⁻⁴–10⁻⁵, inertial relaxation ~10⁻⁷ s, ten orders below any +biological timescale, so adding momentum would be *physically wrong*. + +**Checked here by computation**, not accepted: Re = 2.0e-5 (1 µm at 20 µm/s) to 2.0e-3 +(100 µm feature); τ_p = 5.6e-8 s at a = 0.5 µm; T_bio/τ_p = 1.9e10 against the 18-minute +figure. `analysis/overdamped_regime.py` reproduces all of it. + +**Verdict: `supported`, and it is the reviews' one genuine addition.** §3.4 said the absence +is factual; it now says the absence is *appropriate*, which is the stronger claim. Applied +to §3.4 on 2026-08-31 with the inputs stated alongside the numbers, because Re is a +property of the flow and not of the organism. + +--- + +## Claim 3 — the Diele citation is a three-way bibliographic conflation + +**As stated (review):** the citation conflates three papers; the title belongs to Diele & +Marangi, *Mathematics* 8(1):25. + +**What the repo already held** (`data/claims_ledger.csv`, `PP-REF-02`, verdict `delete`, +prescribing the same replacement): + +> EVERY FIELD WRONG. The DOI resolves to a neural-network quadratic-programming paper by +> Yang, Cao & Xu. Different authors, journal, year, volume and pages. + +**Verdict: `requalify`.** The defect is real and the prescribed fix matches, but the +review's *diagnosis* is not what is wrong here, and it is the weaker one. The review +analysed title/journal/pages and **did not check the DOI**, which resolves to an unrelated +paper — a fourth error, and the worst of them. It also missed `PP-REF-01` entirely, whose +DOI resolves to an unrelated 3D MHD regularity paper. + +**What the review did surface, by rediscovery: both fixes were prescribed and never +applied.** That is the finding, and it is ours rather than theirs. + +--- + +## Claim 4 — §3.10 is marked specification-not-method + +**As stated (review):** "Keep §3.10 marked specification-not-method." + +**What the source showed:** it was not marked at all. The disclaimer appeared at §3.2, +§3.4 and §3.12 only — corroborated by `data/claims_ledger.csv:498` (`PP-V11-05`), and by +`PP-310-01`, an open row already recommending "Label as a proposed component, not an +implemented one." + +**Verdict: `delete`.** **This premise came from the brief.** It was supplied as background, +returned as a finding, and would have read as confirmation that no action was needed. The +disclaimer was added on 2026-08-31. + +--- + +## Claim 5 — the nutrient field is inert with respect to configuration + +**As stated (review):** `compute_delta_H_terms` and `mcs_step!` never read `state.nutrient`, +so the field cannot influence configuration. + +**Verified**, and already enforced: `tests/manuscript_claims_tests.jl:256-343` greps every +lattice-mutating function for nutrient reads, asserts none, and separately asserts +`update_nutrient!` *does* read it. + +**Verdict: `keep`, with one scope correction.** The narrow claim holds. **"The field is +unused" would be false** — it is live, integrated every MCS, consumed, checkpointed, and +GPU-parity-tested. Inert with respect to acceptance is not inert. + +--- + +## Claim 6 — the CPM is adhesion-and-volume only + +**As stated (review):** a bare adhesion+volume CPM produces differential-adhesion sorting, +not biofilm morphology. + +**What the source showed:** the Hamiltonian carries four terms — `adh`, `vol`, `rad`, `mel` +(`biofilms_potts.jl:590`). The radiation and melanin terms are exactly the field bias the +review's own analysis says would modify sorting. + +**Verdict: `requalify`. This premise also came from the brief.** The conclusion — that the +output is directional redistribution rather than a biofilm morphology — is one the +manuscript already states; the route to it was supplied rather than found. + +--- + +## Claim 7 — the §6.3 radial result needs an SE and direction caveat + +**Verdict: `keep`.** Already the manuscript's own position, in the abstract (`…tex:112`), +in §6.3, in `README.md:719`, and in ledger rows `RM-KR-07` / `RM-KR-08`. + +--- + +## Claim 8 — scope the CPM-for-biofilms novelty claim + +**Verdict: `delete`.** No such claim exists. The novelty claim is narrowly about coupling +OpenMC to a CPM and already self-limits: "This work therefore extends an established +paradigm to a new transport code and a new biological target rather than introducing the +paradigm." + +--- + +## Claim 9 — "no flow" should be a declared limitation + +**Verdict: `needs_verification` → CLOSED 2026-08-31, and the framing changed on the way.** +The reviews are right that flow is undeclared, but adding "no flow" beside the existing +entry would have overstated its independence. Flow reaches morphology through mechanics, and +a model with no growth generates no growth-induced stress for mechanics to act on — so the +three are one structural absence under three names. §7.5 now says that, and says what it +costs: no route by which morphology could arise, so the spatial output is directional +redistribution in an imposed field rather than a morphology. + +**The literature half stays unverified and out of the manuscript.** The reviews argue +growth-induced stress is the dominant still-condition morphogen (Yan 2019, Asally 2012). +That is not checked here, §7.5 says so explicitly, and nothing in the added sentence depends +on it. The claim carrying the weight is internal: this model has no growth, no mechanics and +no flow. + +The original finding, for the record. §7.5 declared that +"viscoelastic mechanics are absent from the executed simulations", but flow, advection, +shear and hydrodynamics appear nowhere as model or as declared limitation. SCOPE: searched +`preprint/…tex`, `README.md` and `data/claims_ledger.csv` on 2026-08-31 for those four terms; +not a claim about every file in the repository. **Not fixed in +this pass** and recorded here so it is not lost. + +--- + +## The structural finding, which neither review made + +Four ledger verdicts were recorded, prescribed, and never applied: `PP-REF-01`, +`PP-REF-02`, `PP-310-01`, `RM-G04-01`. One cause was assumed; there are **two**, and they +must not be filed as one. + +**Cause 1 — the scope of enforcement. CLOSED 2026-08-31, and building the guard split the +cause in two.** (a) The `document` column names *another* file: `RM-G04-01` was restated in +README while `biofilms_potts.jl:16` went on listing a five-term Hamiltonian. (b) Worse, and +not seen when this was first written: the column names *no* file. `document = repository` is +a `PSEUDO_DOCUMENT`, so `document_path` returns `None` and the row is never searched at all. +Measured: 37 rows sit in that class, 5 carrying an unresolved verdict that names a source. +`PP-DEFF-01` was one, and its prescribed comment fix sat unapplied at +`biofilms_radiodialysis.R:242` — **the fifth instance of the unapplied-verdict pattern, +found by building the guard for the first four.** + +`RETRACTED_IN_SOURCES` is a declared vocabulary rather than a phrase sweep, and that was +measured before it was chosen: `distinguishing_phrase` over every `delete` row against all +144 source files returns **zero** hits, because claims living in source comments are short — +a formula, a range, a symbol — and split into runs under `MIN_WORDS`, exactly as figure +labels and citations do. Third vocabulary tier, same reason as the first two. `restate` +verdicts are not swept generally: AGENTS.md is explicit that a restate is a judgement about +meaning and stays with a reviewer; only the specific withdrawn string is mechanical. + +**Cause 2 — the phrase extractor.** `PP-REF-01/02` were invisible for an unrelated reason: +a citation splits on commas into runs under `MIN_WORDS = 5`, so `distinguishing_phrase` +returns `''`. **This is a property of the extractor, not of which files are scanned**, and +no widening of the scanned set reaches it. Closed on 2026-08-31 by `RETRACTED_CITATIONS`. + +**`RETRACTED_CITATIONS` reaches `PP-REF-01` and `PP-REF-02` and nothing else.** +`RM-G04-01` was unenforced when this was written; **`RETRACTED_IN_SOURCES` shipped later the +same day and its vocabulary carries that string**, so both causes are now closed. Stated in +these terms so a later reader does not see "the systemic gap has a guard" and stop checking +— and this sentence is itself an instance of why: it said *remains unenforced* for several +hours after that stopped being true, and nothing would have caught the drift. + +**What is still open, after both guards.** The vocabulary tiers reach declared strings. +Neither reaches the 37 rows whose `document` is a pseudo-document and which are therefore +enforced nowhere by construction — 13 carrying an unresolved verdict, 6 naming a source. +That count now prints on every run of the claims-ledger suite rather than living here, for +the reason the absence gate prints its known gaps: a number in a document nobody rereads is +a number nobody acts on. Those rows want a human pass, not a guard that happens to sweep +them. + +**And the guide's prose divergence is partly closed with its remaining half stated.** +`tools/render_guide.py` writes `.md.sha256` only as a side effect of a render that +verified every citation in the artifact, so the source of record can no longer move without +one. Nothing verifies the re-sync was *faithful*: measured, the `.md`'s prose matches the +rendered text layer 38 of 48 at a six-word prefix, and the ten failures are markup +differences rather than divergence, because the `.tex` is a re-authoring rather than a +render. That is the wrong instrument for a hand-authored `.tex`, not a threshold to tune. + +A third defect surfaced while applying the fixes: §3.4 referred implementing symplectic +integration to §7.5 as future work, and §7.5 does not list it. The cross-reference was +dangling and is now removed. + +### A fourth, and it is a new member of the use-versus-mention family rather than a repeat + +The first draft of the reference correction named both withdrawn DOIs **inside LaTeX +comments in the manuscript**. `RETRACTED_CITATIONS` would have caught it. **The design +would not have**, and that gap is the finding. + +Use-versus-mention was resolved at the **file** level — which documents may name a +withdrawn DOI. A `.tex` comment defeats that resolution without violating it: it sits +inside a *permitted* file, at a location that is not part of the rendered document. It is +neither use nor mention in the sense the allow-list encodes. It is **invisible to a reader +of the PDF and fully visible to a reader of the source — and the source reader is the one +who copies the string**, which is the whole reason withdrawn identifiers are kept out of +the manuscript. + +So a file-level allow-list is not sufficient for artifacts that have a rendered form and a +source form. The repair taken here was to remove the strings rather than to teach the guard +about comments, because the manuscript has no reason to carry a withdrawn DOI in any form; +the comments now point at the ledger row. **Recorded because the next guard of this shape +should decide its scope at the level of *rendered versus source*, not only of file.** + +--- + +# Third review — melanin radiotrophy + +A third commissioned review arrived the same day, on whether melanin converts ionising +radiation into biologically usable energy. **The pattern holds for a third time:** almost +everything substantive in it was already in `docs/research/radiotrophic_compatibility_audit.md` +(2026-08-15) and carried into §2.6 before the review was commissioned. + +## Claim 10 — the manuscript should not claim more than "not demonstrated" + +**What the repo already holds** (`preprint/…tex:325`): + +> "``Not demonstrated'' is not ``disproved'', and we state it as the former." + +**Verdict: `keep`.** The manuscript is already more careful than the recommendation, and +draws a distinction the review does not. + +## Claim 11 — no growth means radiotrophy cannot be tested + +**Verdict: `keep`.** Stated in four places — abstract `:121`, §2.6 `:315`, Discussion +`:1244`, Conclusion `:1350` — and independently in `README.md:49-63`. + +## Claim 12 — §2.6 draws the radiolytic-H₂ contrast + +**Verdict: `keep`**, `:336-361`, and the manuscript already labels its own limits: +"This is a contrast and not a defence: it does not address the carbon-budget critique." + +## Claim 13 — the energy budget, and the antioxidant alternative + +**Verdict: `keep` on both.** The energy-budget critique is Walberg 2015, quoted verbatim in +the audit at `:669-677`. The antioxidant/repair account is §2.1 `:210-227`, with Cortesão's +NHEJ series. + +**One arithmetic check of ours, stated rather than quietly dropped.** The review's budget +reproduces exactly on two of three figures: 5e-18 J/h deposited into a 1e-13 kg cell at +0.05 mGy/h, and ~60 ATP-equivalents/cell/h at perfect conversion. Its third — "~500 Gy/h to +supply 1 % of maintenance" — **recomputes here as ~96 Gy/h**, a factor of about 5. Working: +1 % of 1 mmol ATP/g/h at 2e-11 g dry mass is 1.2e8 ATP/cell/h, ×8e-20 J = 9.6e-12 J/h, ÷ +1e-13 kg = 96 Gy/h. **Differs by 5×; not repeated.** The conclusion is unaffected — both +figures sit orders above any survivable chronic dose rate — which is exactly why the +disagreement is safe to state instead of omitting the number and leaving the next reader to +rediscover it. + +## Claim 14 — "your own reference [36]" + +**As stated:** "Your own reference [36] is the closest thing to evidence for this — Turick +et al. 2011." + +**Verdict: `delete`.** `turick2011` is numerically the 36th `\bibitem`, but **it had no +`\cite` in the body at all.** It was one of fifteen defined-but-uncited entries whose count +`tests/manuscript_claims_tests.jl` already pinned and printed on every run. The review cites +it as something the manuscript relies on; the repository had already classified that exact +entry as unused. + +**It is cited now, and for the review's own reason rather than to close the gap.** Turick +2011 is the one positive measurement in a section otherwise built on nulls, and it runs +against the mechanism: melanin "is continuously oxidized in the presence of gamma +radiation." SCOPE: verified from the abstract, cross-indexed on PubMed 21632287, Johns +Hopkins Pure and Semantic Scholar. The review's account of the apparatus — melanin ghosts, +carbon paste, no living cells, nanoampere currents — comes from a full text paywalled from +here and is **not** repeated in §2.6. + +`casadevall2017` was cited in the same edit, verified by extracting the ASM PDF and reading +it rather than trusting the review's quote. + +## Claim 15 — no fungus has a CO₂-fixation pathway + +**Verdict: `needs_verification`, and it is the review's one real contribution.** Searched +the manuscript, `README.md`, `data/claims_ledger.csv` and all four +`docs/research/radiotrophic_*` files: no occurrence of Calvin-Benson-Bassham, +Wood-Ljungdahl, reductive TCA, or autotrophy. **Genuinely absent from this repository.** + +It is also the sharper form of an argument §2.6 already gestures at. The manuscript names +the carbon-budget critique as unaddressed, but in Walberg's form — the medium supplied +enough carbon — which is a confounder. "No fungus has a fixation pathway" is structural: it +does not depend on any energetic estimate, and it would not be closed by a better +experiment. + +**Not adopted, and not written into the manuscript.** It is a claim about fungal genomics, +outside anything this repository can check, and the review self-flags three of its citations +plus the hydrated-electron potential as unverified against primary sources. Given that two +entries in this bibliography have already turned out to be DOIs resolving to unrelated +papers, nothing from that report enters the manuscript without a primary read. + +--- + +# What three reviews establish about commissioning, which is the durable finding + +Three commissioned reviews, on three unrelated subjects — symplectic integration and +rheology, morphology and mechanics, melanin radiotrophy. **All three substantially +rediscovered work this repository already held**, and in the radiotrophy case rediscovered +a single document, `radiotrophic_compatibility_audit.md` of 2026-08-15, whose conclusions +were already carried into §2.6 and the ledger before the review was commissioned. + +Across all three, the genuine additions were: + +1. the overdamped physical argument (Re, τ_p) — applied to §3.4; +2. that no fungus has a CO₂-fixation pathway — recorded `needs_verification`, **not + adopted**, because it is fungal genomics and nothing here can check it. + +**That is the finding, and it is about commissioning rather than about any review.** A +brief written from a repository that already holds the answer produces a report that +returns the answer. Two of the false claims in the first two reviews came from premises +supplied in the briefs and came back reported as findings — a premise returned as a finding +reads exactly like independent confirmation, which is what makes it worth recording rather +than quietly correcting. + +And the one thing all three added was **in a domain this repository has no purchase on**: +low-Reynolds hydrodynamics, and fungal carbon metabolism. Neither is checkable from here; +both are checkable by someone who knows the field. + +**So the shape of what external review is good for here is now visible, and so is the shape +of what to ask for.** Not "audit our claims" — the audits exist, and a brief describing them +will get them back. Ask instead for the thing the repository structurally cannot do: name +the domain, state the premise as a premise to be checked rather than as background, and +expect the answer to arrive as `needs_verification` because verifying it is outside what +this repository can reach. A review whose findings this repo *could* have produced is a +review that was told what to find. + +--- + +# The thirteen orphaned bibitems — dispositions pinned before the resolution + +A classification pass is running over the 13 `\bibitem` entries that no `\cite` reaches. +**The dispositions below are written before its results arrive**, for the reason 0-pre pins +its inputs before computing a curve: a rule chosen after seeing the answer is a rule chosen +by the answer. + +**The pass is classification, not citation.** An entry earns a `\cite` by supporting a +sentence, not by being unreferenced. `turick2011` and `casadevall2017` were cited because +§2.6 needed what they carry; that reasoning does not extend to the rest. Three classes: + +- **(i) supports an uncited claim** — a real gap. Claiming this requires quoting the + manuscript sentence and line the entry would support. +- **(ii) draft residue** — the claim it supported is gone. +- **(iii) deliberate context** — supports no specific sentence; removing it loses nothing + citable. + +## Two dispositions decided in advance + +**`graner1992`, if no sentence can be located.** This is the founding Cellular Potts paper +in a manuscript that implements a CPM, so class (i) is the expectation — but the +requirement is a *specific sentence*, and the sentence that most wants it may be a methods +statement rather than a claim. "The CPM core is implemented in Julia" does not obviously +want a citation; the paper belongs wherever the manuscript first says what a Cellular Potts +Model *is*. + +**If the pass returns no locatable sentence, that is a finding about the manuscript, not +about the bibitem: the model is used without ever being introduced.** It goes to a ledger +row against the manuscript, not into class (ii) or (iii). Recording that now so the empty +result cannot be quietly filed as "deliberate context". + +**A fourth outcome, which the three classes cannot hold.** An entry whose DOI resolves to an +unrelated paper is neither gap nor residue nor context. **Given the base rate here — three +such entries already found in this one bibliography — it is likelier than not among +thirteen.** It is `PP-REF-01`'s category and takes `PP-REF-01`'s treatment: a ledger row +with a `delete` verdict naming the correct reference, not a classification. Pinned now +because discovering it mid-pass invites folding it into (ii), which would lose the +distinction between *we stopped needing this* and *this was never what it said it was*. + +--- + +## Summary + +| # | Claim | Verdict | +|---|---|---| +| 1 | §3.4 should say no momentum state exists | `keep` — already done, in more detail | +| 2 | Momentum would be physically inappropriate | `supported` — the one genuine addition; applied | +| 3 | The Diele citation is a three-way conflation | `requalify` — real defect, weaker diagnosis, missed the DOI and `PP-REF-01` | +| 4 | §3.10 is marked specification-not-method | `delete` — it was not; **premise from the brief** | +| 5 | The nutrient field is inert | `keep` — verified and already tested; "unused" would be false | +| 6 | The CPM is adhesion-and-volume only | `requalify` — four terms; **premise from the brief** | +| 7 | §6.3 needs an SE/direction caveat | `keep` — already the manuscript's position | +| 8 | Scope the CPM-for-biofilms novelty claim | `delete` — no such claim is made | +| 9 | "No flow" is undeclared | **closed 2026-08-31** — declared in §7.5, but as a *relation* rather than a list item: flow is downstream of mechanics, mechanics downstream of growth | +| 10 | Do not claim more than "not demonstrated" | `keep` — the manuscript is already more careful | +| 11 | No growth ⇒ radiotrophy untestable | `keep` — four places plus README | +| 12 | §2.6 draws the radiolytic-H₂ contrast | `keep` — and already labels its own limits | +| 13 | Energy budget; antioxidant alternative | `keep` — both predate the review; one figure recomputes 5× smaller | +| 14 | "Your own reference [36]" | `delete` — it was an uncited orphan the repo already pinned; now cited, for the review's reason | +| 15 | No fungus fixes carbon | `needs_verification` — genuinely absent here, and the review's one real contribution | + +Kept so that a commissioned review's premises are not mistaken later for its findings, and +so the two causes behind four unapplied verdicts stay distinguishable. + +--- + +# Fourth review — microbial neutron RBE (red-teamed 2026-09-01) + +Commissioned to answer the question D3's gate left open: is there a neutron RBE for a +microbial target, with a stated endpoint? **It returned three claims. One is verified and +useful, one does not reproduce, and one merely confirms what D3 already held.** + +## Claim 16 — Hannan et al. 1986 gives a microbial neutron RBE of 2.7–4.1 — **VERIFIED, ADOPTED** + +Checked against NCBI eutils rather than against the review's summary, per the identifier +read-back rule. `esummary` on PMID 3533817 returns Hannan MA, Paul M, Phillips RL, *"Fast +neutron r.b.e. for lethality and genotoxicity in a wild-type and a repair-deficient strain +of yeast"*, *Int. J. Radiat. Biol.* 50(5):811–24, 1986 Nov. `efetch` returns the abstract: +r.b.e. *"varied from 2.7 to 4.1 for lethality, 2.8 to 7.1 for reverse mutation and 3.5 to +7.8 for mitotic gene conversion"*, 11 MeV cyclotron neutrons against ⁶⁰Co, *S. cerevisiae* +D7. + +**Every field the review gave — title, authors, journal, volume, issue, pages, neutron +energy, reference radiation and the three RBE ranges — matches the index**, which is an +enumeration of nine checked fields rather than a claim about the review at large. It moves +D3's premise: a microbial value exists. + +*A superlative was drafted here and withdrawn before it shipped.* The sentence had read that +this was the first commissioned claim across four reviews to survive verification. **The +absence gate flagged it as an unscoped universal and it was false** — Claim 3's bibliographic +conflation verified and produced six corrected entries, and Claim 15 is recorded above as +"the review's one real contribution." What is defensible, and checkable against the claim +list above, is narrower: **Claim 16 is the first commissioned claim to supply a usable +external value, as against correcting or scoping something this repository already held.** +The same failure as §2.6 — a sentence asserting a range it had not read — caught this time by +a tool instead of by a reviewer. + +**What it does not settle**, and the review did not overclaim this either: yeast is a +eukaryote, 11 MeV cyclotron is not a fission spectrum. The gate's remaining question is +transfer, not existence. + +## Claim 17 — ADA307995 is Distribution A and freely readable; the earlier 403 was transient — **DOES NOT REPRODUCE** + +A second fetch on a different day returned **403 again**. The AFRRI boilerplate surfaced by +search reads *"available to qualified users from DTIC, or others may contact the National +Technical Information Service"* — in tension with unlimited public release rather than +supporting it. + +**And the review contradicts itself on this claim specifically.** It states that authors and +the distribution statement were *"confirmed verbatim from the report's SF298 page"* while +stating that the year and SR number — printed on that same page — were unverified, and that +the results tables were *"not extractable."* **A claim to have read a page cannot coexist +with reporting that page's contents as unreadable**; the bibliographic fields most likely +came from a catalog record. Not adopted. + +## Claim 18 — NASBEE is a mammalian facility; do not purchase — **CONFIRMED, ALREADY HELD** + +D3 already recorded this and deferred NASBEE on it. The review returns it as a finding. + +## What the fourth review adds to the commissioning finding + +The three earlier reviews established that commissioned reviews return their own briefs as +findings and cite things that do not exist. **This one adds the discriminating case: the same +review can carry one claim that verifies perfectly and one that collapses**, and the two are +not distinguishable by tone, confidence, or specificity of citation — Claim 17 reads *more* +confidently than Claim 16 and cites a page it did not read. + +**So the unit of trust here is the individual claim rather than the review.** Claim 16 was +adopted and Claim 17 refused from the same document on the same day. What separated them was not +judgement about the source but the availability of an identifier that could be read back +against an issuing registry. Where that check exists, run it; where it does not, the claim +does not clear a gate on its own. diff --git a/docs/research/radiotrophic_compatibility_audit.md b/docs/research/radiotrophic_compatibility_audit.md index fd3fdc7..02fc8bd 100644 --- a/docs/research/radiotrophic_compatibility_audit.md +++ b/docs/research/radiotrophic_compatibility_audit.md @@ -231,6 +231,35 @@ description. | Malo 2018 | *C. neoformans* | cell-wall integrity retained in melanized cells at mid and max dose; non-melanized cells showed two failure morphologies | **melanized_radioprotective**, **radiation_responsive** | `radioresistant`: the survival clause refers to the group's prior work, not to a curve in this paper | | Averesch 2022 | *C. sphaerospermum* ATCC 11289 | 147 vs 151 CPM, n = 1 dish, p = 0.069; flight-versus-ground growth | **none_demonstrated** | see below | | Vasileiou 2020 | melanin in solution | Sr-90 beta transmission versus a cellulose control of matched elemental composition | **none_demonstrated** (a measured null) | `radiation_shielding`: the term requires transmitted radiation reduced *by biomass*, and there is no biomass and no melanin-specific reduction | +| Robertson 2012 | *W. dermatitidis* 8656 (= *E. dermatitidis*) wild type and melanin-defective *wdpks1* | cell counts/CFU under 0.005–5 R/hr 137Cs; cell size; RNA-seq (>3000 genes DE); intracellular O2− by DHE; CFU viability at 21 d and 1 month; carotenoid | **radiotrophic** — cell number measurably increased under exposure, measured here | `melanized_radioprotective`: **the increase appears in the albino *wdpks1* mutant too**, so the growth effect is reproduced and its attribution to melanin is not | + +**Robertson 2012, read in full on 2026-09-01** (PMC3490873; the abstract alone was read +first, and it was not enough). Two things the abstract does not settle, both settled by the +Results headings and text: + +- **The growth was measured in this paper, not restated.** The section is titled *"Low Dose + Ionizing Radiation Increases W. dermatitidis Growth Rate and Cell Size"*: three of four dose + rates between 0.005 and 0.5 R/hr produced *"at least 30% more cells"* than the unirradiated + control at 24 h, with growth inhibited at 5 R/hr. This is the Khajo 2011 distinction resolved + in the opposite direction — Khajo assumed protection from prior work, Robertson measured + growth here. +- **The effect is not melanin-dependent.** At 0.03 R/hr the irradiated wild type grew + significantly more than its control, *"which agreed with the result reported by Dadachova et + al"* — but *"the increased cell number was also observed when the albino wdpks1 mutant was + irradiated"*. SOD2 induction is likewise reported *"in both the wild type and wdpks1 mutant"*. + What is melanin-dependent is transcriptional: ribosomal biogenesis and transporter genes. + +**And the effect is conditional on the growth environment**, which is the same confounder class +§9.2 records: *"the enhanced growth was only observed when cells were grown in minimal media and +under static conditions"*, abrogated in rich media or with shaking. A growth effect measured +without stating medium and agitation is not interpretable, exactly as a melanin effect measured +without culture age and density is not. + +It remains **not** a source for the isogenic *survival* nulls at §9.2, which are gamma, alpha, +proton and deuteron survival contrasts in three other studies. Robertson's viability result +(CFU at 21 days, irradiated better than non-irradiated in nutrient-poor medium) is a *positive* +survival-under-starvation finding, not a melanin contrast. + ### The ISS experiment, in detail diff --git a/docs/research/spatial_dose_community_program.md b/docs/research/spatial_dose_community_program.md new file mode 100644 index 0000000..c713e9f --- /dev/null +++ b/docs/research/spatial_dose_community_program.md @@ -0,0 +1,532 @@ +# The spatial dose–community program — a design, a gate, and three places it can fail + +2026-08-30 · forward-looking · **no `source_id` claimed for anything below.** This document proposes +work. It is not evidence, nothing in it may be quoted elsewhere in this repository, and nothing here +may be promoted into `data/claims_ledger.csv` or any `data/research/*_candidates.csv` row until a +citable source exists and is pinned the way every source in `data/sources.csv` is. It follows the +posture of `docs/research/murr_facility_candidate.md`, which records a candidate lead honestly +rather than treating it as more solid than it is. + +Items below are marked **verified** where a primary was read on the stated date, and **unverified** +otherwise. Per this repository's boolean convention, unverified is never read as false. + +## Why this exists + +`preprint/hoffman_memo.tex` asks questions and does not state a program. This is the program behind +them. `docs/research/radiotrophic_lab_gap.md` specifies a different experiment — the smallest +irradiation public data cannot replace, gated on an `institutional_approval_id`. +`docs/research/radiotrophic_pilot_recommendations.md` carries Pilots A and B. Section 7.3 of the +manuscript carries the facility-regime argument. None of them makes the spatial-field claim below. + +## Scope: wet storage, essentially only + +In-core is flowing borated coolant at temperature in a large flux and nothing lives there, so MIC is +not an operational concern during irradiation. Dry cask storage is helium-backfilled and hot. +That leaves the pool: the one stage where an assembly sits in water cool enough to host anything, +for years to decades. *(In-core and dry-cask conditions: **unverified**, stated from general +knowledge and not from a source read for this document.)* + +Biofilms are documented there. The Sarró coupon study and the Karley spent-fuel-pool isolates are +**verified** and carry ledger rows (`HOFFMAN-01`, `HOFFMAN-02`). + +## The precedent, and the debt it creates + +Microbiology is already formally inside a nuclear materials safety case. SKB's Copper Sulfide Model +(TR-18-08) is a reactive transport model extended to account for microbial reduction of sulfate and +its effect on copper canister corrosion; sulfate-reducing bacteria are placed at the rock–clay +interface using dissolved organic matter and sulfate to generate sulfide, and physical, chemical, +microbial and mass-transport processes are coupled to the interfacial electrochemical reactions. +Best-estimate general corrosion is under 10 µm at one million years. **Verified 2026-08-30** +against SKB TR-18-08 and King et al., *Materials and Corrosion* (2021). + +**The counter, stated here rather than left for a committee.** Microbial activity entered that +safety case because sulfide attacks copper through a specific, identified mechanism coupled to an +electrochemical model — not because biology was added as a general term. The precedent therefore +*demands* an equivalent mechanism for FeCrAl, and the memo's own position is that no such mechanism +is established. This is a debt the program takes on. It is not a warrant it inherits. + +## What the model does today, and why it is not the test + +The implemented phenotype is radiotropism: directed redistribution along a gradient. It predicts +colonisation *toward* the source, which inverts the usual assumption that proximity to fuel is +protective. That is a claim about **total biomass near the fuel**, which is exactly the monotonic +variable the falsification test below declares uninformative. It is recorded here as current +behaviour and is deliberately not part of the argument's spine. + +Radiotropism is also not radiotrophy. Section 2.6 of the manuscript states that radiotrophy is not +established for any of the seven species modelled. + +## What this model cannot do + +Section 2.6 says it in the paper's own words: no dose-dependent survival process and no growth, so +**radiosensitivity cannot be expressed by this model at all.** The pool application is a motivation +for building that. It is not a capability on offer, and any sentence describing a comparison between +a dose-coupled model and a null is describing two models that do not exist. + +## The novelty claim, scoped to the corpus actually read + +Marciales et al., "Mechanistic microbiologically influenced corrosion modeling — a review," +*Corrosion Science* (2018), reports that most mechanistic MIC models take SRB as the main player, +that few consider biofilm-specific environmental conditions, that none correlates sessile and +planktonic populations, and that most lack lab or field validation. Its classification is organised +around MIC mechanism types. + +**Scope, and it is narrower than the claim wants to be.** The full text returns HTTP 403 from here, +so what was read is the review's abstract and reported findings, not its complete taxonomy. No +dose-coupled or radiation-coupled category appears in what was read. That is *source inaccessible, +not category absent* — the same state `HOFFMAN-11` records for NACE-2019-12944, and it changes the +day someone with access reads the category list. A second review covering radiation or repository +microbiology would be needed to extend the claim across that literature; none has been read, and one +review bounds one field. + +## Phase 0 — the pre-check that gates the handover + +**0-pre. Derive the noise floor first, as a curve. Nothing here is runnable until this is done.** +The stop condition compares a residual against a compositional noise floor. A floor that does not +exist makes the gate undecidable: Phase 0 could run in full, with pinned geometry and a passing +control, and return no verdict. The floor needs no pool — but it must be a **sensitivity curve and +not a number**, because the floor moves with the assumed effect size and the effect size is the +quantity Phase 1 exists to estimate. Assuming it large makes the design feasible by construction. + +### 0-pre, first pass — four searches on 2026-08-30, a candidate answer and not a closed one + +This is not a systematic read. **It does not close the gate; it moves the failure mode.** + +**The plausible effect size spans about eleven orders of magnitude, and the program must pick an +end.** Two anchors, both verified: + +- **Chronic, decade-scale selection.** A Th-232-contaminated site near Chengdu, irradiated over ten + years, four sample groups spanning **193 ± 5 to 911 ± 41 nGy/h**. PCA clusters High and Medium + fungi away from Low and Blank, putting a threshold near **480 nGy/h**; the effect on fungal + diversity exceeds that on bacterial, and bacterial α-diversity does not differ significantly + across groups. **Verified 2026-08-30.** +- **Within-experiment population response.** Shuryak et al., *PLoS One* (2017): effects reported at + **36–126 Gy/h**, with 78 of 145 phylogenetically diverse fungal strains growing at 36 Gy/h. + **Verified 2026-08-30.** + +Those measure different things — selection integrated over generations against population density +inside an experiment — and a spent fuel pool sits on the first mechanism. **So the program must +declare which mechanism it predicts, because the expected residual differs by orders of magnitude +depending on the answer, and a prediction that accommodates both predicts nothing.** + +**The pool sits between the two anchors, which is the awkward part.** A dose rate of **0.416 Gy/h** +is reported for areas holding irradiated fuel elements — roughly 10⁶ above the Chengdu threshold and +about 10² below Shuryak's tested rates. Neither anchor transfers directly, and no prior effect size +from the actual system was found — see the scoped absence under the novelty claim below, which names +the search and its date. + +**One finding runs in favour of feasibility.** Chengdu detected a composition threshold across a +total dose range of about **4.7×**, not orders of magnitude. A detectable effect did not require an +enormous gradient in that system. + +**And one runs against it, as a mechanism rather than a power problem.** Shuryak reports that +resistance is **cell-concentration dependent** — *D. radiodurans* grew at 67 Gy/h at high density and +growth was extinguished at a tenfold lower concentration, a roughly threefold shift in critical dose +rate — and that resistant cells **protect neighbours across phyla**: wild-type *D. radiodurans* +enhanced *E. coli* survival by more than 200-fold, apparently through catalase-mediated detoxification +of reactive oxygen species in the shared medium. If composition is buffered by density-dependent +cross-protection, it is **not decomposable into per-species radiosensitivity**, and "composition +tracks computed dose" has no monotonic form to test. That belongs here rather than being discovered +at Phase 1. + +**It also happens to be the strongest available argument for a spatial model, and the document should +not hide that it cuts both ways.** A well-mixed or per-species model cannot represent protection that +depends on local cell density and a shared extracellular medium. A lattice model with local +neighbourhoods can. So the mechanism that breaks the simple test is the one that would justify +Phase 2 — which is a reason to state it plainly rather than a reason to lead with it. + +### 0-pre, pre-registration — the inputs are fixed before the curve is computed + +**A power analysis whose inputs the analyst chooses is a device for producing whatever answer is +wanted:** pick an effect size and get a required *n*; pick an *n* and get a detectable effect. 0-pre's +entire job is to be able to refuse, so the inputs and the refusal threshold are written down first, +the same discipline as pinning the observed acceptance band before running. + +**The effect-size range comes from the anchors, not from what is achievable.** Chengdu is the low +anchor and the usable one — a threshold at 480 nGy/h inside a 4.7× total range, so composition +tracked a modest gradient. The pool's 0.416 Gy/h sits about 10⁶ above it, so there is a gradient +magnitude and no dose-response measured at that magnitude. The curve is therefore drawn across an +**assumed** range, and **the range and the end that triggers refusal are both written before +computing.** + +### D2 — the horizontal contrast, and the refusal threshold written before anything is looked up + +**The threshold and its basis are fixed first, because a look-up whose closing line is chosen +afterwards is the same instrument as a power analysis with chosen inputs, only faster.** The earlier +"about 2×" was gestured at by comparison to Chengdu and never derived, so it is replaced by a form +that can be audited: + +> **Refusal threshold — NO AVAILABLE ANCHOR BOUNDS THIS IN THE REQUIRED DIRECTION.** This is a +> finding with a decision attached, not a to-do. +> +> **The arithmetic is not repairable in the direction the original derivation assumed.** A +> conservative gate needs a **lower** bound on the necessary contrast, and Chengdu supplies only an +> upper one: a threshold at 480 nGy/h inside a 4.7× span, with Low and Medium given as sampling +> distances rather than dose rates, so the smallest contrast across which the effect was actually +> resolved is unpublished. No fraction of an upper bound is a floor — if the necessary contrast is +> 3×, a 2.35× gate admits an achievable 2.5×, a design that looks like it cleared a pre-registered +> bar. +> +> **Why unset is an improvement and not a regression:** an unset gate refuses everything, while a +> wrong gate admits exactly the cases it should not. Blocking D2b outright is the safe state. +> +> **Three options, and one has to be chosen before D2b runs:** +> 1. **Obtain the bracket** — author contact for the Low and Medium group means, or another study +> reporting a resolved adjacent pair. +> 2. **Derive the threshold from dispersion rather than from an anchor's span** — the within-group +> variance route, which needs no anchor at all and connects to the same 13-site dispersion 0-pre +> already uses for the noise floor. +> 3. **Declare that 0-pre cannot set this threshold, and close on that.** A real outcome, not a +> failure to decide. + +Written before the look-up so that the number cannot be moved by what the look-up returns. + +**The comparator is sufficient-not-necessary, and the sharper one is unavailable — checked before +D2b, deliberately.** 4.7× is the *total span* across four groups with the threshold sitting inside +it, which establishes the span was **enough**, not that it was **needed**. The tighter comparator is +the adjacent-group bracket around 480 nGy/h, and that bracket is **not published**: the paper gives +Blank as 192.906 ± 5.05 and High as 910.964 ± 41.09 nGy/h, and reports Low and Medium only as +sampling distances (2 ± 0.5 cm and 7 ± 0.5 cm from source). The threshold sits between Low and +Medium, so the contrast that actually resolved it is **≤ 4.7× and not otherwise determined**. + +**What the bracket does and does not give:** it is adjacent-group and therefore no wider than the +total span, so 4.7× bounds the necessary contrast from **above**. An upper bound alone cannot set a +floor — see below — so no direction of conservatism follows from it. The comparator was looked up +before D2b so that a revision could not be selection dressed as sharpening; what that look-up +established is that the interval is (0, 4.7×] and nothing narrower. + +**Two independent defects sat in one sentence, and fixing the second first left the first +untouched.** The withdrawal of "conservative by construction" weakened the claim along the +*transfer* axis — 4.7× bounds necessary contrast at Chengdu's absolute scale and the pool sits ~10⁶ +above it, so a saturating response would need *more* contrast, not less. That argument stands. But +the sentence was **also** wrong arithmetically, for a reason independent of scale: **halving an +upper bound does not yield a lower bound.** Knowing only that the necessary contrast is at most 4.7× +tells you nothing about whether 2.35× clears it. Raised by Codex on pull request #23, and the +threshold is now unset rather than re-justified. + +**What would pin it:** the adjacent-group bracket around 480 nGy/h. The paper gives Blank as +192.906 ± 5.05 and High as 910.964 ± 41.09 nGy/h and reports Low and Medium only as sampling +distances (2 ± 0.5 cm and 7 ± 0.5 cm from source), so the two means needed are unpublished. Until +they are obtained the necessary contrast is known only to lie in (0, 4.7×], and no gate can be set +on that interval. + +**D2a — field steepness. Arithmetic, and it appears to be satisfied.** Water's linear attenuation +coefficient at Cs-137 and ~1 MeV is of order 0.07–0.09 cm⁻¹, so the bare half-value layer is roughly +8–10 cm; buildup from scattered photons stretches the effective falloff, plausibly to 15–30 cm per +halving at several mean free paths. Either way a contrast of a few-fold needs **tens of centimetres** +of lateral separation, not metres — which is the shape of the answer regardless of where the +threshold eventually lands. + +*Provenance, in the register's own idiom: this is a **derivation** from tabulated attenuation +coefficients with named inputs. It is not a facility measurement and any real dose map supersedes it. +Filed as `coverage = derivation` so it cannot later be mistaken for something measured.* + +**Scope: D2a establishes a NEGATIVE, not a dose.** What it shows is that *steepness is not the +binding constraint*, across a range of roughly 15–30 cm per halving with buildup and depending on +geometry. **It is not a dose estimate and must not be cited as one** for any other purpose — a +range that answers "is the gradient steep enough" is not a value that answers "what dose is at this +position." + +**D2b — samplable positions. Facility access, and unresolved.** If the field is steep enough, the +binding constraint is not steepness but whether **two or more samplable surfaces exist at adequate +lateral separation at the same depth**. That is a question about a pool, not about physics. + +**Depth tolerance and lateral separation are ONE parameter, and it is a ratio.** Dose varies +vertically at a comparable rate, so positions differing by ±10 cm in depth while separated by 20 cm +laterally would carry depth-induced variation comparable to the signal — the confound returning as +noise rather than as collinearity. **Depth tolerance ≤ 20% of lateral separation**, stated as a +ratio so that changing one constrains the other automatically. + +**The look-up is asymmetric: it can close the gate, and it cannot open it.** Conditions (1) and (3) — +no detectable residual across plausible effect sizes, and buffering with no monotonic form to test — +are untouched by any contrast measurement. So the pass branch is **"proceed to D2b"**, never +"feasible", and a favourable steepness result advances nothing on its own. The gate's failure branch +is a disjunction of three conditions, so its pass condition is their conjunction. + +**If no published horizontal dose map exists for an accessible pool, that is UNRESOLVED, not a +close.** An operational dose map is not the kind of thing that gets published — it is facility data, +not a finding — so absence of one is a **search-scope claim and not a physical one**, and reading it +as "the gradient must be inadequate" would close a gate on the wrong grounds. That branch is written +now precisely because it is the likely outcome and would otherwise be decided on the day. It +converts into a question for Hoffman under the rule that already governs the memo: state what the +design needs, ask whether the sampling exists, assert nothing about his facility from secondhand +reading. + +**"Detectable residual" has no definition without a stated metric and test.** Bray–Curtis +dissimilarity with PERMANOVA and dose as a continuous covariate is the conventional shape, and its +power depends on within-group dispersion. That number is available: the amplicon survey verified on +2026-08-30 sampled 13 locations across a real basin. It carries no dose axis, but **dispersion across +a real basin's sites is the one number in it that transfers**, and the document says it was taken +from there. + +**And that study is a near-miss rather than a bare absence, which is the better framing and the same +fact.** It is not evidence that nobody can sample a basin. It is evidence that **someone already +has** — 13 locations and depths, no dose measured, no field computed. The gap is that the group who +sampled a basin did not compute a field, and the missing half is exactly the half this repository +holds. That is a collaboration framing rather than an absence. + +### D3 — the neutron weight on a microbial target, and a gate that refuses without closing + +**The four dose figures this program reasons from are all gamma, and a research reactor is a mixed +field.** They are: Chengdu's ambient rates at the Th-232 site, Shuryak's Cs-137 exposure rates, the +0.416 Gy/h reported for areas holding irradiated fuel elements, and the 248 Gy–2 kGy D₁₀ range for +the six spent-fuel-pool isolates cited in the Hoffman memo. That is the whole set — an enumeration +of what this document cites, not a claim about dosimetry at large. A neutron component does not enter any of those through absorbed +dose alone; it needs a biological weight, and **no weight for a microbial target has been +verified here. So the gate refuses: no neutron-bearing exposure is designed, costed or asked for +until one is sourced.** + +> **SUPERSEDED 2026-09-01 as to existence, not as to the refusal.** A microbial value was +> verified on that date — see the UPDATE below. The gate still refuses; the reason is now +> transfer rather than absence. The sentence stands as the record of what was true on +> 2026-08-31, which is the point of dating it. + +**The refusal is typed OPERATIONAL-pending, not STRUCTURAL, and the distinction is the reason for +writing it down.** STRUCTURAL is the one label in this register that says stop looking, and it +would be the most consequential verdict in the document resting on the least scoped claim — in a +document going to a facility whose own people are the ones who would know. Nothing found so far +earns it. Operationally nothing changes today: the gate refuses either way. What changes is that +the refusal stays reopenable by someone searching the way the field is actually organised. + +**Four web searches, 2026-08-31, term sets verbatim.** + +1. `microbial neutron RBE relative biological effectiveness bacteria fungi` +2. `bacterial spore inactivation D10 fast neutron mixed field gamma comparison reactor sterilization dosimetry` +3. `neutron RBE bacterial spore inactivation D10 mixed neutron gamma field` +4. `fungi neutron irradiation relative biological effectiveness spacecraft shielding microbial inactivation` + +**The term-set diagnosis is confirmed, and it is only half the barrier.** A microbial neutron RBE +is largely not indexed under "RBE": it sits in sterilisation dosimetry, BNCT bystander work, +spacecraft-shielding and planetary-protection validation, and reactor inactivation studies, and it +is reported there as a D₁₀ under a stated mixed field with a gamma comparison alongside — an RBE in +substance with no RBE in the title. Searches 1 and 3, which asked for the conjunction +*microbial + neutron + RBE*, returned nothing usable. Searches 2 and 4, which named those +literatures instead, returned three on-point documents. **So an empty result under the first term +set discriminates nothing** — it separates "the term is not how the field names it" from "no value +exists" not at all. **But the second term set did not clear the gate either, and the reason is now +retrieval rather than naming:** each of the three is a document whose full text did not open from +here. + +### UPDATE 2026-09-01 — a microbial neutron RBE exists, and the refusal changes grounds + +**Verified from a primary index, not from a review's summary.** NCBI eutils `esummary` and +`efetch` on PMID 3533817 return: Hannan MA, Paul M, Phillips RL, *"Fast neutron r.b.e. for +lethality and genotoxicity in a wild-type and a repair-deficient strain of yeast"*, +*Int. J. Radiat. Biol.* **50**(5):811–24, 1986 Nov. Abstract, verbatim: the r.b.e. *"varied +from 2.7 to 4.1 for lethality, 2.8 to 7.1 for reverse mutation and 3.5 to 7.8 for mitotic +gene conversion"*, for 11 MeV cyclotron neutrons against ⁶⁰Co gamma in *S. cerevisiae* D7. + +**This meets the clearing condition this subsection wrote for itself:** a value with a +stated endpoint (lethality by colony formation), a stated neutron energy, and a stated +photon reference. **The existence premise stated above is therefore superseded**, and is +marked as such where it stands rather than restated here. + +**The gate still refuses, on better-scoped grounds.** Yeast is a eukaryote and a fungus; +three of the seven modelled species are fungi, so this is nearer than a mammalian import, +and it is not a bacterial value. Nor is 11 MeV cyclotron the fission spectrum a pool or a +reactor presents. **The open question is now transfer — eukaryote to prokaryote, and one +neutron energy to another — rather than existence**, which is a materially different and +much smaller gap. + +**And one thing the same review claimed does NOT reproduce.** It reported DTIC ADA307995 as +Distribution A, freely readable, the earlier 403 being transient bot-detection. **A second +fetch on 2026-09-01 returned 403 again**, and the AFRRI boilerplate surfaced by search reads +*"available to qualified users from DTIC, or others may contact the National Technical +Information Service"* — which is in tension with unlimited public release. The review is +also internally inconsistent: it states authors and distribution were *"confirmed verbatim +from the report's SF298 page"* while stating that the year on that same page and the results +tables were not extractable. **A claim to have read a page that also reports that page's +contents as unreadable is not adopted here.** The document's status is unchanged: unread, +with retrieval unresolved. + +**Three leads, named so that reopening is a task and not a re-search.** + +1. **DTIC ADA307995**, *Neutron and γ-Ray Radiation Killing of Bacillus Species Spores: Dosimetry, + …* — on point by title, and the closest thing found to the exact quantity. **HTTP 403 from here, + unread.** That is *source inaccessible, not value absent* — the same state `HOFFMAN-11` records + for NACE-2019-12944, and it changes the day someone with DTIC access opens it. +2. **NASBEE**, the neutron exposure accelerator for biological-effects experiments at NIRS + (*Radiat. Phys. Chem.*, ScienceDirect, paywalled). An **RBE of 3.54 at D₁₀ for 2 MeV average + neutrons** appears in the abstract-level material returned. **The target is not established as + microbial from what was read**, and NIRS's facility is built for mammalian radiobiology, so this + is a lead and explicitly not a number to use. Recorded because it fixes the *form* the answer + takes — an RBE quoted at D₁₀ against a stated neutron energy — which is what a search should + look for. +3. **arXiv 2408.10929**, a comparison of spallation, reactor and compact neutron sources for + genetic mutation, reporting fast neutrons as an effective mutagen in plants and microorganisms. + The PDF returned image streams and no extractable text from here. + +SCOPE: the four web searches quoted above, run on 2026-08-31, plus one fetch attempt each at the +DTIC and arXiv documents, both of which failed to return readable text. No controlled-vocabulary +database was searched — not PubMed under MeSH, not INIS, not OSTI — no sterilisation-dosimetry +handbook was opened, no BNCT dosimetry review was read, and no author was contacted. **This is a +claim about a search, not about a literature**, and the three leads above are evidence that the +literature is there. + +**What would clear this, in the order it should be tried.** OPERATIONAL-pending is only meaningful +if the pending part names a task, so it does: + +1. **DTIC ADA307995, by whatever access exists.** It is the on-point document and the cheapest + thing on this list. Public access is 403 from here; a `.mil`/`.edu` affiliation, an + interlibrary-loan request, or an NTIS order all reach it. **Clearing condition: the report + states a D₁₀ under a characterised neutron field with a gamma comparison, for a named + organism.** +2. **Ask, before buying.** The Hoffman question costs nothing and a radiation-materials person at a + reactor either knows the quantity or knows who does. Note this is now a *retrieval* question and + not an existence one — "can you get at this" is a much easier thing to answer than "does this + exist," and it should be asked in that form. +3. **NASBEE, through an institutional subscription**, to settle whether the 3.54 figure is + microbial or mammalian. **Clearing condition either way: it resolves whether that number may be + cited at all**, which is worth the look even though a mammalian answer clears nothing else. +4. **A controlled-vocabulary search**, which this program has not run: PubMed under MeSH, INIS, + OSTI. This + sits below the three above because the leads already in hand are more specific than anything a + fresh search returns, but it is what would convert the SCOPE line's disclaimer into a bounded + enumeration. +5. **Write to the authors.** Last because it spends an introduction, and the four above are + cheaper. + +**None of these is scheduled and none is anyone's assignment yet.** Listing them makes the gate a +task rather than a state; it does not make the task started. + +**Why NASBEE in particular is deferred, and what would change that.** The obvious argument is +ordering — D3 fires ahead of D2's threshold and of D2b's sampling look-up, both unresolved, +so paying for NASBEE clears the third gate while the first two are open. **That argument is +contingent and should not be the one on record:** if D2b came back favourable tomorrow it +evaporates, and the purchase would still be the same purchase. + +The argument that holds under any D2b outcome is about the paper. **NASBEE's RBE of 3.54 at +D₁₀ has no established microbial target** — NIRS builds that facility for mammalian +radiobiology — so buying it resolves this gate *only if* the target turns out to be +microbial. SCOPE ON THAT: the figure and its context were read at abstract level only, from +the search results of 2026-08-31; ScienceDirect returns the full text behind a paywall from +here. **"Target not established" is a statement about what was readable, not a finding that +the target is mammalian** — which is precisely why the purchase would resolve something, and +equally why the cheaper routes should be exhausted before paying to resolve it. If it does not, the purchase returns the gate exactly where it stands, having +established that one lead does not apply. That is a real outcome and a poor thing to pay for +when the two routes ahead of it in this list would establish the same thing at no cost. + +> **Trigger for revisiting:** DTIC ADA307995 and the Hoffman question both returning nothing, +> **and** NASBEE's target independently confirmed as microbial *before* purchase rather than +> after. + +The second clause is the load-bearing one. It converts "buy it and see" into a precondition, +which is the shape the rest of this register uses: a cost paid against a stated outcome +rather than against a hope. + +**What enforces this gate, precisely, because "an unenforced paragraph" understates it.** +Nothing in the suite reads D3 — it is prose with a declared type. But the *precondition* it +rests on is guarded: `coupling/biofilm_openmc/model.py:79` sets `particle="photon"` and +`coupling/tests/test_model_build.py:25` asserts it, so switching the transport to a neutron +or mixed field fails a test loudly. **The gate cannot be walked past silently, only +deliberately** — and the deliberate case is a person reading a failing assertion and deciding +what to do, which lands them here. For a judgement that cannot be mechanised, a guarded +precondition whose failure routes to the paragraph is the strongest available form. + +**It converts into a question rather than sitting here**, under the rule that already governs the +memo: state what the design needs, ask whether the quantity exists, assert nothing about the +facility from secondhand reading. The question is now in the memo's list — whether a usable neutron +RBE for bacteria or fungi exists at all, or whether that is simply not how dosimetry is done for +microbial targets. A radiation-materials person at a reactor either knows or knows who does, and +that is a cheaper route to the answer than any search run from here. + +**0a. Inputs pinned, not chosen.** Rack pitch, array size, burnup spread and cooling times all move +the residual, so a design failing at one setting can be rescued by widening the spread or shrinking +the array. Geometry and the burnup/cooling distribution come from a published rack design or a +stated source, are fixed before the run, and are recorded with the result. A second configuration +may be reported, never substituted. + +**0b. The statistic is 0-pre's floor**, not a correlation. A threshold on `r` would pass 0.94 and +fail 0.96 on nothing. + +**0c. Covariates enumerated now**, because choosing them after seeing the residual is the freedom 0a +exists to remove: distance to the nearest assembly; depth below the water surface; distance to the +nearest pool wall; distance to the nearest rack-array edge; height above the floor. Additions are +recorded as additions. + +**0d. The control, because Phase 0 cannot check itself.** Dose at a rack surface is dominated by the +nearest assembly's gamma field, so the computed structure resembles inverse-square in distance +almost by construction and a small residual is expected physics rather than a finding. Run a +configuration where dissociation **must** occur — an extreme burnup contrast, or one assembly +position left empty: + +| | control dissociates | control stays flat | +|---|---|---| +| **residual small** | the design does not work; say so | instrumentation is wrong; conclude nothing about the design | +| **residual large** | proceed, conditional on geometry class | **the residual reports discriminating power while the field fails to respond to a configuration that must change it. Its source is a tally, geometry or mesh artifact, and it reads as success. Nothing proceeds.** | + +The bottom-right cell is the one a control that only guards nulls would miss. + +**0e. Phase 0 gates the handover, not the science.** 0a pins a published rack design; Phase 1 would +sample a different pool with different burnups and cooling times, so Phase 0 establishes feasibility +**in a geometry nobody will sample**, and recomputing for the actual pool is Phase 1's first step +rather than a refinement. It is still worth running, for three reasons needing no access: it debugs +the transport pipeline, it exercises 0d's control, and it produces 0-pre's curve. **What Phase 0 +decides is whether this document should be handed to anyone.** A failure means nothing is sent. + +## Phase 1 — the empirical study, designed to be handed over + +**Dose must vary within a substrate class.** Several positions on the same rack material, same +installation date, same cleaning history, at different distances. Sampling racks against a distant +liner cannot attribute anything to dose: that varies substrate, distance, hydrodynamics, surface +finish, cleaning history and installation date at once, and composition would differ under the null +too. That design is a correlational study reported as a falsification test. + +**The discriminating sample is where computed dose and geometric distance disagree**, since within a +rack array they are otherwise near-collinear. This is where the coupling does work a distance +covariate could not: OpenMC makes dose computed rather than a surrogate. Whether any other role for +the coupling would also be load-bearing is not a question this document has surveyed. +Whether such positions exist at usable magnitude is exactly what Phase 0 asks, and there is reason +to expect them to be scarce. + +**The sharper risk is that dose is not separable from DEPTH, and the sampling geometry decides it.** +Water is the dominant attenuator — of order half a metre per tenth-value thickness for Co-60 — and +assemblies sit tens of feet below the surface, so the field spans many orders of magnitude +vertically. Depth also carries light, settling organic carbon, oxygen from the surface, and operator +proximity. **A regression of composition on computed dose over a vertical sample is a regression on +depth wearing a dose label**, and the residual-after-covariates framing in 0c is the right response +but may be unachievable in that geometry. Not hypothetical for the design: the nearest study found +in the same dated search sampled "13 different locations and depths." + +**So the design that breaks the confound is horizontal.** Sample at **fixed depth** and varying +lateral distance from the racks, where dose falls off and every depth-linked covariate holds +approximately constant. **If horizontal sampling at fixed depth is not physically available in the +pool on offer, 0-pre closes on collinearity even though it does not close on effect size** — and that +is a sufficient reason to send nothing. + +**Temperature is a competing term, not a caveat.** It is a strong driver of community composition +and plausibly dominates any dose signal. If it can be measured at each position it enters the +comparison as a rival explanation; otherwise the null this tests against is weaker than the one a +reviewer would insist on, and the document says so. Flow is named the same way and is harder to +measure. + +**The null is the primary accepted outcome.** Individual radiosensitivity is measured directly — +$D_{10}$ values for several of the seven species are real measurements, per Table 2 of the +manuscript — so the untested claim is not organism sensitivity but community composition across a +spatial gradient. If composition does not track computed dose where it diverges from distance, the +coupling earns nothing, and this program commits in advance to accepting that. + +Executing needs an operating pool nobody here can reach. + +## Phase 2 — build the survival process, which is the thesis + +Contingent on Phase 1. Building dose-dependent survival is what would let the model predict **which +taxa shift and in which direction** — a prediction rather than a correlation. The comparison is +dose-dependent survival plus tropism against a null with no dose term, which is a model comparison +against a stated alternative rather than a methods label. **Both halves of that comparison are +unbuilt today.** + +## Three places this fails before anyone spends money + +1. **0-pre**, whose failure branch is a **disjunction** of three conditions — so its pass condition + is their **conjunction**, and satisfying one advances nothing: + (a) no range of plausible effect sizes leaves a detectable residual; + (b) the horizontal contrast is below the threshold — **currently unset, see D2** — **or** no two + samplable surfaces exist at adequate lateral separation at the same depth (D2b); + (c) cross-protection buffering leaves composition with no monotonic form to test. + **A fourth outcome is not a close:** no published dose map for an accessible pool is UNRESOLVED, + a search-scope claim rather than a physical one, and becomes a question for Hoffman. +2. **Phase 0**: the residual is below the floor with the control dissociating. Nothing is sent. +3. **Phase 1**: composition does not track computed dose. The coupling earns nothing. + +Each has its disposition written before the run. That is the program's most defensible feature. diff --git a/jacc_acceptance_figure.jl b/jacc_acceptance_figure.jl new file mode 100644 index 0000000..ffb2fb2 --- /dev/null +++ b/jacc_acceptance_figure.jl @@ -0,0 +1,104 @@ +#!/usr/bin/env julia +# Fig 5 — acceptance regime of the JACC checkerboard CPM. +# +# julia --project=. jacc_acceptance_figure.jl +# +# TWO DISTRIBUTIONS, NO MAP. d404438 refused a spatial map for the per-voxel +# decisive-label tally -- at 400 MCS only 6157 of 64000 voxels received any +# accepted move, median 3 -- and the same sparsity argument applies to anything +# drawn per voxel here. What the lattice picture cannot answer is whether the +# Metropolis is in a usable regime at all: acceptance is 1 for ΔH <= 0 and +# exp(-ΔH/T) otherwise, so T_cpm sets everything, and a rate pinned near 0 or +# near 1 renders as a simulation that is plainly running. These two panels are +# that diagnostic. +# +# basis_gate_ack: this script records NO radiodialysis quantity -- it plots +# acceptance rates and a ΔH distribution. `delta_H` reads +# lat/vols/spec/J/beta/melc/rad/mel and never `nut`, so the gated basis cannot +# reach an accepted move; tests/jacc_parity_tests.jl holds that claim to account +# by requiring 10x the uptake constants to leave the tables byte-identical. + +using CairoMakie, Statistics, Printf + +const HERE = @__DIR__ +const OUT = joinpath(HERE, "preprint", "figures") + +Port = Module(:FigPort) +Base.eval(Port, :(using LinearAlgebra, Statistics, Random, Printf)) +Base.include(Port, joinpath(HERE, "biofilms_potts_jacc.jl")) + +const RUN = Base.eval(Port, :run_coupled) +const RP = Base.eval(Port, :RadiolysisParams) + +# Provenance, printed into the figure so the artifact says what produced it. +const N = 40 +const N_MCS = 100 +const SEEDS = (42, 43, 44) +const ORDER = collect(0:7) # identity; the permuted orderings are the + # test's business, not the figure's +const T_CPM = 5.0f0 +const DH_EVERY = 10 # sweeps sampled for the ΔH pool + +function measure(seed) + rates = Float64[] + dhs = Float32[] + cb = (mcs, st, dh) -> begin + ev = count(!=(0), st) + push!(rates, ev == 0 ? NaN : count(==(UInt8(2)), st) / ev) + mcs % DH_EVERY == 1 && append!(dhs, dh[st .!= 0]) + end + rp = RP(; Nr = 40, Ddot_R = 1.0, c_ext = 1.0, basis_gate_ack = true) + RUN(; seed, n_mcs = N_MCS, N, rp, verbose = false, + color_order = ORDER, on_sweep = cb) + return rates, dhs +end + +results = [(s, measure(s)...) for s in SEEDS] + +set_theme!(Theme(fontsize = 13, + Axis = (spinewidth = 1.2, xgridvisible = false, + ygridvisible = false, xtickalign = 1, ytickalign = 1), + Lines = (linewidth = 2.0,), + Legend = (framevisible = false,))) + +fig = Figure(size = (980, 400), figure_padding = (12, 20, 10, 10)) + +ax1 = Axis(fig[1, 1], xlabel = "Monte Carlo Steps", + ylabel = "accepted / evaluated proposals", + title = "Metropolis acceptance rate per sweep") +for (seed, rates, _) in results + lines!(ax1, 1:length(rates), rates, label = "seed $seed") +end +axislegend(ax1, position = :rt) + +allrates = vcat([r for (_, r, _) in results]...) +allrates = filter(!isnan, allrates) +alldh = vcat([d for (_, _, d) in results]...) + +ax2 = Axis(fig[1, 2], xlabel = "ΔH (Metropolis acceptance functional, a.u.)", + ylabel = "evaluated proposals", + yscale = log10, + title = "ΔH over evaluated proposals") +lo, hi = quantile(alldh, 0.001), quantile(alldh, 0.999) +hist!(ax2, clamp.(alldh, lo, hi); bins = 120, color = (:steelblue, 0.75)) +vlines!(ax2, [0.0]; color = :black, linestyle = :dash, linewidth = 1.5) +vlines!(ax2, [Float64(T_CPM)]; color = :firebrick, linestyle = :dot, linewidth = 2) +text!(ax2, Float64(T_CPM), 1.0; text = " T_cpm = $(T_CPM)", color = :firebrick, + fontsize = 11, align = (:left, :bottom), space = :data) + +# The provenance line is the point of the .txt sidecar: the test asserts over +# three seeds AND three colour orderings, so a figure that does not say which +# run it shows is about a different thing than the test. +Label(fig[2, 1:2], + @sprintf("JACC port, threads backend, %d thread(s) | N=%d, %d MCS, seeds %s, color_order=identity | pooled rate %.4f (min %.4f, max %.4f), n_ΔH=%d sampled every %d sweeps", + Threads.nthreads(), N, N_MCS, join(SEEDS, ","), + mean(allrates), minimum(allrates), maximum(allrates), + length(alldh), DH_EVERY); + fontsize = 10, color = :gray30, halign = :left, tellwidth = false) + +mkpath(OUT) +base = joinpath(OUT, "fig5_acceptance_regime") +save(base * ".pdf", fig) +save(base * ".png", fig; px_per_unit = 2) +@printf("wrote %s.{pdf,png}\n pooled rate %.5f min %.5f max %.5f n_ΔH %d\n", + base, mean(allrates), minimum(allrates), maximum(allrates), length(alldh)) diff --git a/preprint/figures/fig1_radial_stratification.pdf b/preprint/figures/fig1_radial_stratification.pdf index 1514d73..7d48c94 100644 Binary files a/preprint/figures/fig1_radial_stratification.pdf and b/preprint/figures/fig1_radial_stratification.pdf differ diff --git a/preprint/figures/fig1_radial_stratification.png b/preprint/figures/fig1_radial_stratification.png index e410887..7c08fd6 100644 Binary files a/preprint/figures/fig1_radial_stratification.png and b/preprint/figures/fig1_radial_stratification.png differ diff --git a/preprint/figures/fig1_radial_stratification.sha256 b/preprint/figures/fig1_radial_stratification.sha256 new file mode 100644 index 0000000..27d41cb --- /dev/null +++ b/preprint/figures/fig1_radial_stratification.sha256 @@ -0,0 +1 @@ +d3566b1778c365856021805f038b46625bf684d4b2925a6e6fc4ccea5b631a56 diff --git a/preprint/figures/fig1_radial_stratification.txt b/preprint/figures/fig1_radial_stratification.txt new file mode 100644 index 0000000..7093d9c --- /dev/null +++ b/preprint/figures/fig1_radial_stratification.txt @@ -0,0 +1,40 @@ + Mean radial position — cylindrical CPM bioreactor + + + + 1.0 + + + + outer + 0.8 lower-radiation region + C. neoformans + + + + +Mean radial position r / R + D. radiodurans + 0.65 + C. sphaerospermum + 0.6 + 0.58 B. subtilis + + 0.50 A. niger + + S. oneidensis + 0.4 + O. intermedium + + + + inner + 0.2 high-radiation region + + + + + 0.0 + 0 50 100 + Monte Carlo Steps + \ No newline at end of file diff --git a/preprint/figures/fig2_melanin_accumulation.pdf b/preprint/figures/fig2_melanin_accumulation.pdf index 7608125..22b5f87 100644 Binary files a/preprint/figures/fig2_melanin_accumulation.pdf and b/preprint/figures/fig2_melanin_accumulation.pdf differ diff --git a/preprint/figures/fig2_melanin_accumulation.png b/preprint/figures/fig2_melanin_accumulation.png index 5e004ad..983d108 100644 Binary files a/preprint/figures/fig2_melanin_accumulation.png and b/preprint/figures/fig2_melanin_accumulation.png differ diff --git a/preprint/figures/fig2_melanin_accumulation.sha256 b/preprint/figures/fig2_melanin_accumulation.sha256 new file mode 100644 index 0000000..0325df6 --- /dev/null +++ b/preprint/figures/fig2_melanin_accumulation.sha256 @@ -0,0 +1 @@ +13f0f047a40e97c918c153c130f34779a52367ea0df275d305f86d7bbd0f0c92 diff --git a/preprint/figures/fig2_melanin_accumulation.txt b/preprint/figures/fig2_melanin_accumulation.txt new file mode 100644 index 0000000..7939960 --- /dev/null +++ b/preprint/figures/fig2_melanin_accumulation.txt @@ -0,0 +1,29 @@ + Melanin field at occupied sites — dimensionless model units + 1.5 + 1.44 + + + + +Mean melanin field value at occupied sites + 1.0 0.97 + 0.94 + + + C. neoformans + + C. sphaerospermum + + A. niger + + 0.5 + + + + + 0.0 + Ordering follows the hand-specified alpha_M_species scales; not a measured quantity + + 0 50 100 + Monte Carlo Steps + \ No newline at end of file diff --git a/preprint/figures/fig3_membrane_transport.pdf b/preprint/figures/fig3_membrane_transport.pdf index 9d0cfa9..07da3e3 100644 Binary files a/preprint/figures/fig3_membrane_transport.pdf and b/preprint/figures/fig3_membrane_transport.pdf differ diff --git a/preprint/figures/fig3_membrane_transport.png b/preprint/figures/fig3_membrane_transport.png index 4bbb18e..5de6e00 100644 Binary files a/preprint/figures/fig3_membrane_transport.png and b/preprint/figures/fig3_membrane_transport.png differ diff --git a/preprint/figures/fig3_membrane_transport.sha256 b/preprint/figures/fig3_membrane_transport.sha256 new file mode 100644 index 0000000..d6d14df --- /dev/null +++ b/preprint/figures/fig3_membrane_transport.sha256 @@ -0,0 +1 @@ +d06c37771550c225552a02efb00df5a1126e5059e17356c8338b83429fd0a7f5 diff --git a/preprint/figures/fig3_membrane_transport.txt b/preprint/figures/fig3_membrane_transport.txt new file mode 100644 index 0000000..53aa2e9 --- /dev/null +++ b/preprint/figures/fig3_membrane_transport.txt @@ -0,0 +1,33 @@ + Membrane damage and radiation-driven permeability + + + 1.0 + m = 0.779 + 2.5 + + + + +Membrane integrity m(t) + 2.0 + + + P_eff / P₀ + m(t) integrity + 2.7× baseline + + P_eff / P₀ permeability + 0.5 + + + + 1.5 + + + + + 1.0 + 0.0 + 0 50 100 + Monte Carlo Steps + \ No newline at end of file diff --git a/preprint/figures/fig4_contaminant_penetration.sha256 b/preprint/figures/fig4_contaminant_penetration.sha256 new file mode 100644 index 0000000..bf903c5 --- /dev/null +++ b/preprint/figures/fig4_contaminant_penetration.sha256 @@ -0,0 +1 @@ +d4bd66b0f8b730907f6ce83d9c08d7873e01f5274548c92af60ea9b298c69f75 diff --git a/preprint/figures/fig4_contaminant_penetration.txt b/preprint/figures/fig4_contaminant_penetration.txt new file mode 100644 index 0000000..740bd2d --- /dev/null +++ b/preprint/figures/fig4_contaminant_penetration.txt @@ -0,0 +1,27 @@ + Contaminant penetration and biosorption + + 1.0 1.0 + + c(R) = 87% c_ext + + + + + Sorbed phase s_mean (×0.0034 c_ext) +Mobile contaminant c / c_ext + c(R,t) at wall + + c_mean interior + 0.5 0.5 + s_mean sorbed + + + + + c_mean = 2.4% c_ext (98% depleted) + + + 0.0 0.0 + 0 50 100 + Monte Carlo Steps + \ No newline at end of file diff --git a/preprint/figures/fig5_acceptance_regime.pdf b/preprint/figures/fig5_acceptance_regime.pdf new file mode 100644 index 0000000..f2d68ac Binary files /dev/null and b/preprint/figures/fig5_acceptance_regime.pdf differ diff --git a/preprint/figures/fig5_acceptance_regime.png b/preprint/figures/fig5_acceptance_regime.png new file mode 100644 index 0000000..661e25d Binary files /dev/null and b/preprint/figures/fig5_acceptance_regime.png differ diff --git a/preprint/figures/fig5_acceptance_regime.sha256 b/preprint/figures/fig5_acceptance_regime.sha256 new file mode 100644 index 0000000..312567c --- /dev/null +++ b/preprint/figures/fig5_acceptance_regime.sha256 @@ -0,0 +1 @@ +ed642ee01bfe2baaeb23a32dbaa1a89bfbb6dd4290c695993f8e50edc84c3bdc diff --git a/preprint/figures/fig5_acceptance_regime.txt b/preprint/figures/fig5_acceptance_regime.txt new file mode 100644 index 0000000..b8f6708 --- /dev/null +++ b/preprint/figures/fig5_acceptance_regime.txt @@ -0,0 +1,33 @@ + Metropolis acceptance rate per sweep ΔH over evaluated proposals + + 0.4 seed 42 10 4 + seed 43 + + + + +accepted / evaluated proposals + seed 44 + 10 3 + 0.3 + + + + + evaluated proposals + 10 2 + 0.2 + + + 10 1 + 0.1 + + T_cpm = 5.0 + 10 0 + + + 0 50 100 −1000 0 1000 + Monte Carlo Steps ΔH (Metropolis acceptance functional, a.u.) + + JACC port, threads backend, 1 thread(s) | N=40, 100 MCS, seeds 42,43,44, color_order=identity | pooled rate 0.0620 (min 0.0253, max 0.4140), n_ΔH=94010 sampled every 10 sweeps + \ No newline at end of file diff --git a/preprint/figures/phase2_diffusion_cell.pdf b/preprint/figures/phase2_diffusion_cell.pdf new file mode 100644 index 0000000..e7cdb9b Binary files /dev/null and b/preprint/figures/phase2_diffusion_cell.pdf differ diff --git a/preprint/figures/phase2_diffusion_cell.png b/preprint/figures/phase2_diffusion_cell.png new file mode 100644 index 0000000..a830248 Binary files /dev/null and b/preprint/figures/phase2_diffusion_cell.png differ diff --git a/preprint/figures/phase2_diffusion_cell.sha256 b/preprint/figures/phase2_diffusion_cell.sha256 new file mode 100644 index 0000000..e6c8352 --- /dev/null +++ b/preprint/figures/phase2_diffusion_cell.sha256 @@ -0,0 +1 @@ +ea3cab9a3dabd8ab6918d746cc31125721d982075937b706efff4158dfed70fb diff --git a/preprint/figures/phase2_diffusion_cell.svg b/preprint/figures/phase2_diffusion_cell.svg new file mode 100644 index 0000000..d01aa6e --- /dev/null +++ b/preprint/figures/phase2_diffusion_cell.svg @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + Phase 2 — two-chamber diffusion cell + Biofilm grown on a permeable support; transport read as receiver breakthrough + + + + biofilm on permeable support + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Donor + concentration held constant + one solute per run + + Receiver + held near zero + C(t) gives the breakthrough curve + + + + + + + timed aliquots + metal to ICP; bromide to IC + + Both chambers stirred — unstirred boundary layers otherwise read as film resistance. + + + + Paired blank: bare support + Identical cell and support, no biofilm, + run alongside every measurement. + Gives the support’s own resistance and + its wall sorption. Without it the result + is film plus support, not film. + Edge seal checked separately: a leak + reads as fast transport. + + + Open choice: the tracer run + Bromide is the tracer because R ≈ 1: it is + not retarded, so its breakthrough gives + D_eff directly — the number the solver takes. + If bromide is excluded from part of the pore + space, that D_eff is not the metal’s. Whether + an EPS gel does that is untested. + At Hanford 300A tritiated water resolved pore + volume bromide did not (Hay et al. 2011, + doi:10.1029/2010WR010303). Tritiated water’s + approval burden: a question for Dr. Deng. + + + What the metal run yields + Breakthrough lag gives a lumped + transport parameter, not a diffusivity + alone: sorption retards it, so the metal + run yields D_app. Separating the two + needs the partition coefficient from the + Phase 1 suspended isotherm. + That ordering is why Phase 1 comes first. + + + + What this number is not + The film-scale effective diffusivity measured here is not the reactor-scale dispersion coefficient that currently carries the same name in radial_parms (1×10⁻³ cm² s⁻¹, about 43× water’s self-diffusivity). + It is the quantity slab_parms carries as D_eff (1×10⁻⁵ cm² s⁻¹) — the one default in that file below water self-diffusivity. + diff --git a/preprint/figures/phase2_diffusion_cell.svg.sha256 b/preprint/figures/phase2_diffusion_cell.svg.sha256 new file mode 100644 index 0000000..9f4fc3c --- /dev/null +++ b/preprint/figures/phase2_diffusion_cell.svg.sha256 @@ -0,0 +1,2 @@ +eea80f4e2fc544a4fcdc85c99906c1b41479e59a0678f0c3ca7db715cbd50160 +pdf=ea3cab9a3dabd8ab6918d746cc31125721d982075937b706efff4158dfed70fb diff --git a/preprint/figures/phase2_diffusion_cell.txt b/preprint/figures/phase2_diffusion_cell.txt new file mode 100644 index 0000000..65b025d --- /dev/null +++ b/preprint/figures/phase2_diffusion_cell.txt @@ -0,0 +1,44 @@ +Phase 2 — two-chamber diffusion cell +Biofilm grown on a permeable support; transport read as receiver breakthrough + + + biofilm on permeable support + + + + + Donor Receiver + concentration held constant held near zero + timed aliquots + one solute per run C(t) gives the breakthrough curve + + + + metal to ICP; bromide to IC + + + + + Both chambers stirred — unstirred boundary layers otherwise read as film resistance. + + + + Paired blank: bare support Open choice: the tracer run What the metal run yields + Identical cell and support, no biofilm, Bromide is the tracer because R ≈ 1: it is Breakthrough lag gives a lumped + run alongside every measurement. not retarded, so its breakthrough gives transport parameter, not a diffusivity + Gives the support’s own resistance and D_eff directly — the number the solver takes. alone: sorption retards it, so the metal + its wall sorption. Without it the result If bromide is excluded from part of the pore run yields D_app. Separating the two + is film plus support, not film. space, that D_eff is not the metal’s. Whether needs the partition coefficient from the + an EPS gel does that is untested. Phase 1 suspended isotherm. + Edge seal checked separately: a leak At Hanford 300A tritiated water resolved pore + volume bromide did not (Hay et al. 2011, That ordering is why Phase 1 comes first. + reads as fast transport. + doi:10.1029/2010WR010303). Tritiated water’s + approval burden: a question for Dr. Deng. + + + + What this number is not + The film-scale effective diffusivity measured here is not the reactor-scale dispersion coefficient that currently carries the same name in radial_parms (1×10⁻³ cm² s⁻¹, about 43× water’s self-diffusiv + It is the quantity slab_parms carries as D_eff (1×10⁻⁵ cm² s⁻¹) — the one default in that file below water self-diffusivity. + \ No newline at end of file diff --git a/preprint/hoffman_memo.tex b/preprint/hoffman_memo.tex new file mode 100644 index 0000000..7ad2814 --- /dev/null +++ b/preprint/hoffman_memo.tex @@ -0,0 +1,262 @@ +\documentclass[landscape,12pt]{article} +\usepackage[letterpaper,margin=0.6in]{geometry} +\usepackage[T1]{fontenc} +\usepackage{lmodern} +\usepackage{microtype} +\usepackage{amsmath,amssymb} +\usepackage{booktabs} +\usepackage{array} +\usepackage{xcolor} +\usepackage{titlesec} +\usepackage{enumitem} +\setlist[itemize]{leftmargin=1.3em,itemsep=2pt,topsep=3pt} +\pagestyle{empty} +\emergencystretch=3em + +\definecolor{model}{HTML}{0D47A1} +\definecolor{bench}{HTML}{1B5E20} +\definecolor{phys}{HTML}{B71C1C} +\definecolor{prior}{HTML}{8D6E00} + +\newcommand{\pagehead}[2]{% + {\Large\bfseries #1}\\[1pt] + {\small\itshape #2}\\[-4pt] + \rule{\linewidth}{0.6pt}\\[4pt]} + +\begin{document} + +% ================================================== PAGE 1 +\pagehead{What the model computes, and what it does not}% +{Hunter Kinder --- accompanying the preprint Caixia Wan forwarded. One page on the model, two on +why I am writing to you, one of questions I cannot answer myself.} + +\vspace{2mm} +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{model} The object}\\[3pt] +A biofilm is represented as biomass parcels on a 3-D lattice, with a photon-transport calculation +(OpenMC) supplying a static dose-rate field and a radial PDE carrying a mobile contaminant into +the film. The transport runs \emph{once}; it does not read the biology back. That is a declared +design choice, not a physical law, and the manuscript says where it weakens. + +\vspace{2mm} +{\large\bfseries\color{model} The question it is built to ask}\\[3pt] +Given a hydrated layer of known composition on a surface in a known field: what dose does the +layer receive, how much does it hold, and does its presence perturb the field around it? + +\vspace{2mm} +{\large\bfseries\color{phys} What it is not}\\[3pt] +\begin{itemize} + \item \textbf{Not calibrated.} Every parameter in the table is a literature-anchored prior, and + a literature prior is not a calibration. The manuscript declares seven of twenty-six + parameter rows inadmissible on the basis this model can accept. + \item \textbf{Not a radiotrophy result.} Radiotrophy is not established for any of the seven + species modelled, and the paper says so in its own words rather than importing the label. + One melanin coefficient is retained with its sign declared wrong. + \item \textbf{Not a measurement of anything.} The one radial result reported runs opposite to + its own coefficient and sits within 1.4 standard errors of the seeding null. It is + reported that way. +\end{itemize} +\end{minipage}\hfill +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{prior} The gap that matters for you}\\[3pt] +Dose and dose rate are not interchangeable here, and the gap is not marginal. A reactor +irradiation delivers of order Gy\,min$^{-1}$. The environmental settings the paper cites as +motivation --- Chernobyl, Hanford --- are closer to mGy\,yr$^{-1}$. That is roughly ten orders of +magnitude, and a sensitivity constant calibrated in one regime does not transfer to the other by +construction. + +\vspace{2mm} +That is the model's worst structural problem, and it is why I am not writing to you about +environmental remediation. + +\vspace{4mm} +\noindent\rule{\linewidth}{0.4pt}\\[3pt] +{\small\textbf{Two corrections, since you may have version 1.0.} Figures 1 and 2 in that version +carry text inside the images that the surrounding prose retracts --- Figure 2 annotates two +species as ``radiotrophic (melanin-mediated energy gain)'' beneath a caption disowning exactly +that. The figure generator had been corrected two weeks earlier; the committed images had not +been regenerated, and nothing in the test suite could open a figure to notice. Version 1.1 +replaces both, and no number \emph{in them} changed. Version 1.2 then corrects a number: an +automated review of the code found the bound I had used to argue one of Section 6.2's results was +wrong by a factor of $1.5\times10^{3}$, and two published counts moved with it. That section now +states what the measurement supports rather than what the bad bound predicted, and the code +carries a test that recomputes the bound and fails if the paper and the coefficients disagree. +Both are recorded in the paper's Data and Code Availability section. I mention them because they +are the paper's own argument happening to the paper, and you would have found them on page 8.} +\end{minipage} + +\newpage +% ================================================== PAGE 2 +\pagehead{Why I am writing to a materials scientist}% +{The overlap is not bioremediation. It is that a qualification envelope built from abiotic +stressors may not bound a biological one.} + +\vspace{2mm} +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{bench} The empirical fact}\\[3pt] +Sarr\'{o} and colleagues submerged a bioreactor carrying stainless steel and titanium coupons in +a spent nuclear fuel pool at a Spanish nuclear power plant. Biofilm developed on both materials. +They measured its radioactivity by gamma-ray spectrometry and found it had retained radionuclides +from the pool water, $^{60}$Co in particular.\footnote{\textit{J.\ Ind.\ Microbiol.\ Biotechnol.} +34(6):433--441, 2007. doi:10.1007/s10295-007-0215-7} + +\vspace{2mm} +Karley and colleagues isolated six bacterial species from spent-fuel-pool water with $D_{10}$ +values from 248~Gy to 2~kGy, all biofilm-formers, and measured Co and Ni removal up to +3.8~$\mu$g per mg of biomass.\footnote{\textit{Environ.\ Sci.\ Pollut.\ Res.} +25(21):20518--20526, 2018. doi:10.1007/s11356-017-0376-5} A later study of the same isolates +found \emph{dead} biomass removed both ions as well --- which is what a sorption mechanism +predicts and a metabolic one does not.\footnote{\textit{Environ.\ Monit.\ Assess.} 195(6):731, +2023. doi:10.1007/s10661-023-11266-x} One of those isolates is \textit{B.\ subtilis}, which is +already one of the seven species in the model. + +\vspace{3mm} +{\large\bfseries\color{bench} The stressor set}\\[3pt] +Your published work qualifies FeCrAl against high-temperature steam, hydrothermal water +chemistry, and hydrogen-isotope permeation.\footnote{\textit{JOM} 74(4), 2022 +(1200\,$^\circ$C steam, three metallurgical processes); \textit{Nucl.\ Mater.\ Energy} 37, 2023 +(hydrothermal, Cr coatings on Zircaloy-4); \textit{Metall.\ Mater.\ Trans.\ A} 53(3), 2022 +(permeation review). Also \textit{Corros.\ Sci.} 209, 2022 and 229, 2024 on Al and Mo in steam.} +A biofilm is none of the three, and I would not assume from outside your field whether it falls +inside the envelope anyway. +\end{minipage}\hfill +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{phys} What that literature does not compute}\\[3pt] +What dose the biofilm itself receives, and whether a hydrated layer 50--200~$\mu$m thick +perturbs the local field at the metal surface. Those papers measure what the biofilm holds; the +transport question next to it is not asked. A facility is also where the inputs to it stop being assumptions --- a bounded geometry, a +characterised source, a measurable metal inventory, and a dose rate in the regime the $D_{10}$ +priors were measured in. + +\vspace{3mm} +{\large\bfseries\color{prior} The mixed Cr/Al case}\\[3pt] +Searching \emph{FeCrAl microbiologically influenced corrosion biofilm} on 30 August 2026 returned no +study of FeCrAl; changing one word to \emph{304L stainless steel} returned four on the first page. +That is a web search, so it reaches ranked results and not a corpus, and conference proceedings +sit largely outside it, where such a study would most likely be. + +\vspace{2mm} +Every substrate those searches did return passivates through chromia, or is aluminium, or is carbon +steel. FeCrAl passivates through a mixed Cr(III)/Al(III) film.\footnote{\textit{J.\ Electrochem.\ Soc.} 172(12):121501, 2025 --- model +Fe--Cr10--Al$_x$ in sulphate at pH~1, room temperature. Commercial grades in borated water show an +Al--Cr-rich layer at 360\,$^\circ$C; on 30 August 2026 I found none at pool temperature.} +Whether that matters to a biofilm is a question I would be asking you. + +\vspace{2mm} +Irradiated FeCrAl in water has been studied, at reactor conditions and without a +film.\footnote{\textit{Corros.\ Sci.} 174:108824, 2020: 400-keV Fe$^+$, corroded at +340\,$^\circ$C, and the irradiated material corroded \emph{more slowly} at first. \textit{Scripta +Mater.}: neutron $\alpha'$ in APMT and Alkrothal 720 to 7.0~dpa, microstructure only.} What +neither addresses is either surface at pool temperature with a biofilm on it --- three conditions, +not one. +\end{minipage} + +\newpage +% ================================================== PAGE 3 +\pagehead{The corrosion question, stated precisely}% +{One correction to the obvious framing, and one thing the model could actually contribute.} + +\vspace{2mm} +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{prior} Radiation selects; it does not corrode}\\[3pt] +Microbes do not corrode because they are irradiated. Microbiologically influenced corrosion is a +chemical attack that happens to occur in a radiation field --- sulfide from sulfate reducers, +iron-oxidisers shifting local redox, differential aeration and concentrated chloride under the +film --- and every one of those mechanisms works identically at background dose. + +\vspace{2mm} +What the radiation does is \emph{select} which organisms are present to do it. A $D_{10}$ of +248~Gy to 2~kGy in pool isolates is a picture of that selection and not of a new mechanism. + +\vspace{2mm} +I labour the point because the opposite reading --- that the radiation makes them corrosive --- +is the one that would make this letter sound like the radiotrophy claim the paper spends a +section disowning. +\end{minipage}\hfill +\begin{minipage}[t]{0.485\linewidth} +{\large\bfseries\color{phys} The transport half, which is the part that is mine}\\[3pt] +A hydrated layer attenuates and scatters, so the dose at the metal is not the dose in the bulk +water --- and radiolysis products reaching the surface drive corrosion independently of any +organism. Whether a film enhances or suppresses them there is a transport question, it is +computable, and as far as I can find nobody has computed it. + +\vspace{2mm} +Whether localised attack under discrete colonies then follows an abiotic ranking is your +question, not mine. So the one-line version is: your alloys are qualified against hydrothermal +and radiolytic attack; a biofilm changes the local radiolysis field \emph{and} adds a biological +driver, and only the first of those is something I could compute for you. + +\vspace{4mm} +\noindent\rule{\linewidth}{0.4pt}\\[3pt] +{\small\textbf{What this is, and what it is not.} A reframing of application, not of content --- +the model does not change, only who the answer is for. And the geometry is not yet yours: the +implemented domain is a cylinder in water with a Robin boundary at its outer radius, with no +metal, no planar film and no interface in it. A dose field beneath a film on steel is a change +of geometry, not a run of the existing code. I would rather say that now than have you find it.} +\end{minipage} + +\newpage +% ================================================== PAGE 4 +\pagehead{What the measurement needs}% +{Questions, not an inventory. You know what is available at MURR and I do not.} + +\vspace{2mm} +\begin{minipage}[t]{0.45\linewidth} +{\large\bfseries\color{bench} The one thing the model cannot proceed without}\\[3pt] +A \textbf{closed composition for a hydrated biofilm}, mass fractions summing to unity, because +that is what a photon-transport code consumes. It needs three separable pieces: + +\begin{itemize} + \item \textbf{Water fraction} --- hydrated versus dry mass, gravimetric, with the drying + protocol fixed and its own variance measured first. + \item \textbf{The light elements} --- C, H, N, O, which are most of the mass and which I + believe activation methods cannot see. + \item \textbf{Trace metals, P, and any actinides} --- the part that binds, and the part the + pool-water literature already measures. +\end{itemize} + +\vspace{2mm} +The third is the piece I would most expect a reactor facility to be good at; the first two I would +expect to go elsewhere. That is my reading of the technique, not of your facility. + +\vspace{3mm} +{\large\bfseries\color{phys} What this is, said plainly}\\[3pt] +The preprint's conclusion is that what this needs next is measurement and not more architecture. +That campaign is called Reference~D, it stays formally unevaluated until an institution says +otherwise, and \textbf{it is a project rather than a capability I am offering}. It is also not the +same project as the previous page: Reference~D would calibrate a biofilm model, the FeCrAl questions +are about a material, and the overlap is the facility rather than the measurement. + +\end{minipage}\hfill +\begin{minipage}[t]{0.51\linewidth} +{\large\bfseries\color{model} What I would actually like to ask you}\\[3pt] +\begin{itemize} + \item Which of those three, if any, is something MURR does routinely --- and which would you + send elsewhere? + \item Is there a coupon-exposure path at all: a defined field, a known water chemistry, a + surface that can be recovered and sectioned? + \item Is biofilm on cladding-candidate alloys a question anyone in your area is already + asking, or is it treated as a water-chemistry problem upstream of the materials work? + \item Would a computed dose field inside a surface film be useful to that work, or is the + quantity that matters entirely the corrosion outcome? + \item A dosimetry question rather than a materials one, and it is about access rather than + existence: the $D_{10}$ values I have for pool isolates are all gamma, and a reactor is a + mixed field. The closest thing I have found is DTIC ADA307995, \emph{Neutron and + $\gamma$-Ray Radiation Killing of Bacillus Species Spores} --- which I cannot open. Can + you, or is there a standard reference for microbial neutron RBE that I am simply not + naming correctly? + \item And the blunt one: is there a version of this that is worth a materials scientist's time, + or is the honest answer that the interesting part is all microbiology? +\end{itemize} + +\vspace{3mm} +\rule{\linewidth}{0.4pt}\\[3pt] +{\small\textbf{One thing I will not do.} There is an organism in the paper, \textit{Ochrobactrum +intermedium} AM7, reclassified in 2020 as \textit{Brucella intermedia}. It is named under both +throughout. No culture has been grown, no strain has been procured, and no containment +determination is asserted anywhere in this work --- containment follows strains, not species, and +it is not mine to declare. If any of this ever moves toward a bench, that determination is the +first step and it belongs to an institution, in a document, with an identifier.} +\end{minipage} + +\end{document} diff --git a/preprint/modeling_radioresistance_and_radiotropic_fitness.tex b/preprint/modeling_radioresistance_and_radiotropic_fitness.tex index af0134b..c7aef3e 100644 --- a/preprint/modeling_radioresistance_and_radiotropic_fitness.tex +++ b/preprint/modeling_radioresistance_and_radiotropic_fitness.tex @@ -84,7 +84,7 @@ } {\small \textit{Preprint --- not peer reviewed}\\ - \textit{Version 1.0 --- August 2026} + \textit{Version 1.2 --- August 2026} } \end{center} \vspace{4pt} @@ -107,16 +107,16 @@ Auditing the CPM's radiation-linked terms against their shipped parameterisation gives two measured results. The melanin term changes Metropolis acceptance by approximately 15.5\%, whereas -the direct per-species radiation term changes it by approximately one part in $10^5$ for the two -negatively signed species; only the product of melanin production and melanin coupling reaches -the dynamics, so the two are not separately identifiable. A single-seed radial pattern runs +the direct per-species radiation term reaches $7.505\times10^{-2}$, an acceptance bias about ten +times smaller rather than four orders smaller; only the product of melanin production and melanin +coupling reaches the dynamics, so the two are not separately identifiable. A single-seed radial pattern runs opposite to the direction implied by the direct tropism coefficient and is not clearly separated from the approximate seeding null at six parcels per species. The boundaries are declared rather than discovered late. A proposed partial stochastic differential equation organises diffusion, directed motility, nutrient, radiation, melanin, interaction and forcing terms; it is a specification, and the four executed -simulations---two Langevin models, the CPM, and a radial radiodialysis solver---realise only +simulations---two Langevin models, the CPM, and a radial membrane-transport solver---realise only subsets of it. The CPM represents seven named species as computational biomass parcels rather than individual organisms, and has neither biomass growth nor a dose-dependent survival process, so the implemented phenotype is radiotropism and the model cannot test radiotrophy or @@ -138,6 +138,7 @@ % ---- 1. Introduction ----------------------------------------- \section{Introduction} +\label{sec:intro} Microbial communities possess an extraordinary ability to adapt and survive under extreme environmental stressors, such as elevated radiation levels. Within such communities, melanized @@ -167,10 +168,15 @@ \section{Introduction} material density. Throughout this paper we use each word only for the endpoint it names. The model presented here expresses the first of the five, and the paper's title says so. +The same test excludes as includes: a word enters this vocabulary only if the endpoint it names can +be stated. \emph{Reasoning} has no statable endpoint in these systems and is therefore not used +here, including as metaphor. + % ---- 2. Related Work ----------------------------------------- \section{Related Work} \subsection{Melanized Fungi, Radioresistance, and the Contested Radiotrophy Claim} +\label{sec:melanin_radiotrophy} The observation that melanized fungi colonise high-radiation environments prompted a proposal that melanin transduces ionizing radiation into metabolically usable energy. That proposal remains @@ -193,6 +199,18 @@ \subsection{Melanized Fungi, Radioresistance, and the Contested Radiotrophy Clai melanized cells in the same experiment, so the melanized/albino contrast carries a baseline asymmetry present before any radiation was applied \cite{audit2026}. +Robertson et al.\ \cite{robertson2012} returned to \textit{W.\ dermatitidis} in a +$^{137}$Cs photon field rather than the $^{188}$Re beta arm. Three of four dose rates +between $0.005$ and $0.5$~R~h$^{-1}$ produced at least 30\% more cells than the +unirradiated control at 24~hours, and growth was inhibited at $5$~R~h$^{-1}$. At +$0.03$~R~h$^{-1}$ --- $0.3$~mGy~h$^{-1}$ under the paper's own stated equivalence, six +times the founding experiment's field --- the irradiated wild type grew significantly more +than its control, agreeing with Dadachova. The same increase appeared in the +melanin-defective \textit{wdpks1} mutant, and the effect was abrogated when cells were +grown in rich media or with shaking. The growth result reproduces; its attribution to +melanin does not, and what the experiment isolates is a growth condition rather than a +pigment \cite{audit2026}. + Shunk et al.\ \cite{shunk2022} cultivated \textit{C.\ sphaerospermum} aboard the International Space Station for 26 days, reporting a growth ratio of $1.21 \pm 0.37$-fold against the ground control and lower counts beneath the fungal biomass. Neither result is a demonstration of @@ -229,32 +247,44 @@ \subsection{Mathematical Models of Biofilm Communities} The mathematical modeling of biofilm communities has a rich history beginning with the foundational one-dimensional multispecies model of Wanner and Gujer \cite{wanner1986}, which coupled reaction-diffusion equations for substrate transport with biomass conservation laws to -predict biofilm thickness and spatial species distributions. Xavier et al.\ \cite{xavier2005} -extended this framework to multiple spatial dimensions, providing a deterministic continuum -approach to heterogeneous biofilm development. Individual-based modeling approaches, exemplified +predict biofilm thickness and spatial species distributions. Deterministic continuum treatments +extended this framework to multiple spatial dimensions: Eberl et al.\ \cite{eberl2001} gave a +spatio-temporal continuum model for biofilm development, and Alpkvist and Klapper +\cite{alpkvist2007} a multidimensional multispecies formulation for heterogeneous biofilms. +Xavier et al.\ \cite{xavier2005} is a different work --- a framework for multidimensional +modelling of the activity and structure of multispecies biofilms, as its title states --- and +the continuum description above was previously attributed to it in error. We do not +characterise its method here beyond what that title says. Individual-based modeling +approaches, exemplified by the iDynoMiCS platform of Lardon et al.\ \cite{lardon2011}, complement continuum models by resolving cell-level stochastic interactions. Cross-diffusion biofilm models have been analyzed by Sonner et al.\ \cite{sonner2015}, who established mathematical properties of volume-filling -multispecies systems that exhibit porous-medium-type degeneracy. Our PSDE framework extends -these precedents by incorporating radiation-dependent fitness terms and phase-locking dynamics -that couple species interactions to external radiation fields, moving beyond the -substrate-limited growth paradigms that dominate existing biofilm models. +multispecies systems that exhibit porous-medium-type degeneracy. Our PSDE framework is specified to +extend these precedents with radiation-dependent fitness terms and phase-locking dynamics +coupling species interactions to external radiation fields, moving beyond the +substrate-limited growth paradigms that dominate existing biofilm models. Both additions remain +unexercised: Section~\ref{sec:psde} places the phase-locking adjustment among the four terms it +classes as unrepresentable, and the fitness field is reported there as absent from this +repository's simulation sources. \subsection{Hamiltonian and Stochastic Approaches in Theoretical Ecology} The application of Hamiltonian mechanics to ecological systems remains comparatively underexplored, despite the natural conservation laws that govern energy flow in ecosystems. Symplectic integration methods, which preserve the geometric structure of Hamiltonian flows, -have been advocated for ecological modeling by Diele et al.\ \cite{diele2015} to ensure that +have been advocated for ecological modeling by Diele and Marangi \cite{diele2015} to ensure that numerical solutions maintain essential qualitative dynamics. The Kuramoto model of coupled phase oscillators \cite{kuramoto1984} provides a well-established framework for synchronization phenomena in biological systems, from neural networks to circadian rhythms. Aceb\'r\'on et al.\ \cite{acebron2005} reviewed the model as a paradigm for synchronization, noting its generalization to systems with time delays, frequency-weighted coupling, and heterogeneous -interaction topologies. Our phase-locking kernel $\Gamma_s(t,\mathbf{x})$ draws directly on -this tradition, extending it to multispecies microbial communities where species-level -oscillatory dynamics in growth and resource acquisition couple through shared radiation and -nutrient fields. The use of Langevin stochastic dynamics to describe microbial population +interaction topologies. Blasius et al.\ \cite{blasius1999} treat phase synchronization in +spatially extended ecological systems, which is the setting our phase-locking kernel +$\Gamma_s(t,\mathbf{x})$ is written for: it is specified within this tradition, extended to +multispecies microbial communities in which species-level oscillatory dynamics in growth and +resource acquisition would couple through shared radiation and nutrient fields. It remains +unexercised, and Section~\ref{sec:framework} places the phase-locking adjustment among the four +terms it classes as unrepresentable. The use of Langevin stochastic dynamics to describe microbial population fluctuations follows naturally from the Fokker-Planck formalism and has been applied to competing microbial populations in chemostat systems \cite{campillo2017}, where stochastic differential equations revealed long-term competitive outcomes differing from deterministic @@ -283,7 +313,9 @@ \subsection{Bioremediation Context} The practical motivation for modeling these communities lies in nuclear bioremediation. \textit{Shewanella oneidensis} MR-1 is a model dissimilatory metal-reducing bacterium capable of reducing soluble U(VI) to insoluble U(IV) via extracellular electron transfer through -c-type cytochromes \cite{heidelberg2002,veeramani2011}. \textit{Ochrobactrum intermedium} AM7, +c-type cytochromes \cite{heidelberg2002,veeramani2011}. \textit{Ochrobactrum intermedium} AM7 +--- reclassified in 2020 as \textit{Brucella intermedia}, and named under both here for the +reason given in the Ethics statement --- isolated from soil near the Kakrapar Atomic Power Station in India, tolerates high concentrations of thorium and produces exopolysaccharides (EPS) that complex with Th(IV), offering a pathway toward bioremediation of actinide-contaminated environments \cite{shukla2020}. The combination @@ -292,8 +324,8 @@ \subsection{Bioremediation Context} remediation. The fitness landscape framework introduced by Wright \cite{wright1932} and formalized through the NK model of Kauffman \cite{kauffman1989} provides a conceptual foundation for understanding how epistatic interactions among species traits shape adaptation under -radiation stress, a perspective that our Hamiltonian kNN decision tree operationalizes -quantitatively. +radiation stress. Our Hamiltonian kNN functional (Eq.~\ref{eq:knn}) is specified to carry +that perspective quantitatively but remains unexercised, as Section~\ref{sec:results} records. \subsection{Radiotrophy Is Not Established for Any Species Modelled Here} \label{sec:radiotrophy_status} @@ -314,10 +346,66 @@ \subsection{Radiotrophy Is Not Established for Any Species Modelled Here} same-species report of no substantial melanin protection under gamma, a same-species negative melanin-pathway result at 1 and 3~kGy, a same-genus null growth result under a TLD-anchored $^{137}$Cs field, an isogenic null in \textit{A.\ niger} across three ionizing modalities -(Section~2.1), and a formal energy-budget critique holding that the deposited energy is +(Section~\ref{sec:melanin_radiotrophy}), and a formal energy-budget critique holding that the deposited energy is insufficient and the media carbon sufficient to account for the observed growth \cite{walberg2015}. ``Not demonstrated'' is not ``disproved'', and we state it as the former. +\emph{Added in version 1.2:} a reader may reasonably ask what \emph{does} live in a spent fuel pool, +given that the mechanism above is withdrawn. The answer is worth stating because it is repeatedly +confused with the one this section rejects. + +Nothing feeds on the metal. Fe(II) released by a corroding surface is an electron donor for iron +oxidisers, which is an energy source and not a carbon skeleton; neither alloy offers one. Organic carbon arrives from outside: a pool surface sits +open to a staffed building, ion-exchange resins shed fragments under radiolysis, and refuelling +introduces lubricants and decontamination chemicals. Demineralisation removes ions and passes +neutral organics, so even makeup water carries carbon at parts per billion, which is a concentration +oligotrophs work at. The primary production is done by organisms outside this model: radiolysis +splits water into oxidants and H$_2$ together, hydrogen-oxidising bacteria fix CO$_2$ with that +donor --- the indoor pond at Sellafield carries a stable hydrogen-driven microbiome, with +\textit{Hydrogenophaga} among the taxa \cite{sellafield2020} --- and algal blooms develop in the +same site's ponds under lighting \cite{sellafield2023}. The radiation field that makes a pool +hostile is part of what makes it habitable. + +\textbf{This model resolves none of that.} A nutrient field exists on the lattice and is +integrated by diffusion at every step, and the acceptance rule never consults it: +\texttt{compute\_delta\_H\_terms} and \texttt{mcs\_step!} read the lattice, volumes, species, +adhesion couplings, $\beta_{s,\mathrm{ion}}$ and melanin, and no nutrient term appears in either +(enforced by \texttt{tests/manuscript\_claims\_tests.jl}). The model therefore does not resolve +carbon as a driver of the trajectory. Neither does it treat carbon as an input arriving from +outside: the field is there, it is integrated, and nothing in the acceptance path reads it. Nothing in this paragraph is a claim about the metabolism of the +seven species modelled, and we make none --- two of them, \textit{S.\ oneidensis} and +\textit{O.\ intermedium}, have documented or congeneric CO$_2$-fixing capability under conditions a +pool does not supply, so a blanket statement about their carbon source would be wrong in the +direction a reader could check. + +The distinction this paragraph exists to draw is between two things that sound alike. +Radiolysis-fed chemolithotrophy is established and mechanistically ordinary, and it runs through +water chemistry: microbes consume H$_2$ that radiation happened to produce. Radiotrophy as this +paper uses the term is direct energy capture through melanin, with no such intermediate. Both can be +described as radiation supplying the energy and only the first is demonstrated. This is a contrast +and not a defence: it does not address the carbon-budget critique of \cite{walberg2015}, which +concerns melanised fungi in culture and stands as stated. + +Two further pieces of the record cut the same way, and one of them is not a null result. +Every other item cited here reports an absence: an isogenic knockout that made no +difference, a shielding experiment that found none. \textbf{Turick et al. +\cite{turick2011} is a positive measurement, and it runs opposite to the mechanism.} +Using chronoamperometry, chronopotentiometry and cyclic voltammetry, they report that +microbial melanin ``is continuously oxidized in the presence of gamma radiation,'' with +sustained oxidation producing electric current and the effect most pronounced in the +presence of a reductant. A mechanism that supplied reducing power to a cell would need +radiation to \emph{reduce} melanin; the one direct measurement of gamma acting on melanin +reports the opposite direction. We cite what that paper's abstract states and make no claim +here about its apparatus or the magnitudes involved, neither of which we have read. + +And the proponents say as much. Casadevall et al. \cite{casadevall2017}, reviewing the +energy-transduction hypothesis they originated, write that ``whereas some features of the +way melanin-related energy transduction works can be discerned by linking various +observations and circumstantial data, the mechanistic details remain to be discovered.'' +That review is otherwise affirmative about transduction, which is what makes the sentence +useful: it is a concession from the position's own authors, and better support for ``not +demonstrated'' than any of the nulls above. + Radioresistance, by contrast, is well evidenced for several of the seven, and the $D_{10}$ values in Table~\ref{tab:params} are real measurements. Radiotropism has its own candidate --- Zhdanova et al.'s 2004 report of directed hyphal growth toward a $^{109}$Cd source, @@ -381,6 +469,7 @@ \subsection{Symbols and Notation} \end{table} \subsection{Partial Stochastic Differential Equation (PSDE) for Species Fitness} +\label{sec:psde} The fitness of each species $s$ is governed by the following PSDE: \begin{align} @@ -436,7 +525,7 @@ \subsection{Partial Stochastic Differential Equation (PSDE) for Species Fitness} deterministic reaction $R_s$ is nutrient-dependent \emph{growth}, and the CPM has none: \texttt{state.nutrient} is written each step but never read by \texttt{compute\_delta\_H}, and \texttt{divide\_cell!} has no trigger. The phase-locking adjustment $\gamma_s\Delta_s$ depends on -the $H_{\mathrm{kNN}}$ machinery of Section~3.8, which is specified and unexercised. The +the $H_{\mathrm{kNN}}$ machinery of Section~\ref{sec:knn}, which is specified and unexercised. The non-ionising term $\alpha_{s,\mathrm{nir}}N$ and the external forcing $C_s$ have no counterpart in any state variable. Only diffusion, directed motility, noise and the ionising term have anything in the implemented models that corresponds to them, and even those correspond by analogy rather @@ -493,8 +582,27 @@ \subsection{Symplectic Integration} full from the current position and forces at every step ($\dot{\mathbf{x}} = \mu\mathbf{F} + \boldsymbol{\xi}$, the defining property of the overdamped limit), never advanced by its own equation of motion, and so is not the persisted inertial degree of freedom a symplectic -integrator requires; the word survives in a comment where the physics does not. Implementing -genuine symplectic integration is listed in Section~\ref{sec:limitations}. +integrator requires; the word survives in a comment where the physics does not. + +\emph{This subsection previously referred implementing symplectic integration to +Section~\ref{sec:limitations} as future work. That cross-reference was dangling --- +Section~\ref{sec:limitations} does not list it --- and the paragraph below explains why it +should not be listed there either.} + +\emph{And the absence is appropriate, not merely factual --- which is the stronger claim and +the one this paragraph should make.} At cell scale the regime is overdamped. Taking water +($\rho = 10^{3}\,\mathrm{kg\,m^{-3}}$, $\eta = 10^{-3}\,\mathrm{Pa\,s}$), a cell of +length $L = 1$--$10\,\mu$m moving at $U = 20\,\mu\mathrm{m\,s^{-1}}$ gives +$\mathrm{Re} = \rho U L/\eta \approx 2\times10^{-5}$ to $2\times10^{-4}$, and even a +$100\,\mu$m biofilm feature at that speed reaches only $2\times10^{-3}$. The inertial +relaxation time $\tau_p = 2\rho a^{2}/(9\eta)$ is $5.6\times10^{-8}\,$s for +$a = 0.5\,\mu$m and $2.2\times10^{-7}\,$s for $a = 1\,\mu$m --- roughly ten orders of +magnitude below the timescales this model addresses, cell division among them. The length and +speed are stated because $\mathrm{Re}$ is a property of the flow and not of the organism +alone: a figure quoted without them is not reproducible. A persisted, independently-integrated +momentum state would therefore inject dynamics the physical system does not have, so its +absence is a correctness property rather than a gap awaiting work. +\texttt{analysis/overdamped\_regime.py} reproduces every number in this paragraph. \subsection{Radiation Field Models} @@ -547,7 +655,16 @@ \subsection{Nutrient Uptake with Adaptive Feedback} where $\phi_s(t) = \cos(\omega_s t - \theta_s)$ is the phase-alignment function and $A_s(t)$ is the adaptive feedback driven by phase-locking and local radiation stress. +\paragraph{The adaptive-feedback term is specification, not method.} +A nutrient field is integrated at every step by \texttt{update\_nutrient!}, so this uptake law +is not wholly unimplemented; the adaptive term $A_s(t)$ is the part that remains unexercised. +Its phase-alignment factor requires a phase variable, and Section~\ref{sec:psde} places the +phase-locking adjustment among the four terms it classes as unrepresentable, while the fitness +field $F_s$ in the saturation factor is reported there as absent from this repository's +simulation sources. + \subsection{Hamiltonian kNN Decision Tree} +\label{sec:knn} To model species transition probabilities modulated by neighbourhood phase states: \begin{equation} @@ -555,6 +672,18 @@ \subsection{Hamiltonian kNN Decision Tree} H_{\mathrm{kNN}} = \frac{1}{\sigma_n}\sum_{j=1}^{n} P_{sj}(t)\,F_j(t,\mathbf{x})\,\Gamma_s(t,\mathbf{x}) \end{equation} +\paragraph{The name is a coinage, and it is not a description of the form.} +\emph{kNN} names an operation defined by a distance metric, a ranking and a cutoff $k$. +Eq.~\ref{eq:knn} has none of the three: it aggregates across the whole index range $j=1,\dots,n$ +under the weight $P_{sj}$, which makes it a phase-weighted neighbour sum rather than a selection +among neighbours. It is likewise a single product with no split, so \emph{Decision Tree} +describes nothing in the form either. Section~\ref{sec:intro} holds each word to the endpoint it +names; that discipline was written for the five phenomenon terms, and it applies to method names +on the same grounds. The established description of this expression is a phase-weighted +neighbour sum over a transition matrix. $F_j$ is the fitness field of +Section~\ref{sec:psde}, reported there as absent from this repository's simulation sources, so +the quantity aggregated here is itself specified rather than implemented. + \subsection{Radiation Damage and Resilience} \begin{equation} @@ -571,6 +700,23 @@ \subsection{Radiation Damage and Resilience} reduced mutation accumulation without altering survival --- an effect on damage that must not be generalised into a survival term. +\emph{Two same-species results are cited here, and stating what they do not measure is the +point.} Khajo et al.\ \cite{khajo2011} and Malo et al.\ \cite{malo2018} both concern +melanin and ionizing radiation in \textit{Cryptococcus neoformans}, one of the seven species +modelled. Neither performs the survival comparison. Khajo's endpoints are electron spin +resonance, 263~nm absorbance, TBARS and $^{213}$Bi binding, and the protection its title +names is carried over from prior work rather than measured there \cite{audit2026}. Malo's +endpoint is cell-wall integrity morphology, and its survival clause likewise refers to the +group's earlier results \cite{audit2026}. + +So they do not contradict the isogenic nulls, and the reason is the one this paper's +vocabulary predicts: \emph{melanized radioprotective} and \emph{radioresistant} are +different endpoints, and a damage or morphology result cannot stand against a survival +result in either direction. That is the same rule as the sentence above --- an effect on +damage must not be generalised into a survival term --- applied to evidence that appears to +favour the assumption rather than to undercut it. The rule is not a device for discounting +inconvenient results, so it is applied here in the direction that costs us the citation. + \subsection{Biofilm Mechanical Properties} The EPS matrix is modelled as a Finite Extensible Nonlinear Elastic (FENE) entropic spring: @@ -586,7 +732,23 @@ \subsection{Biofilm Mechanical Properties} \end{equation} with stress relaxation time $\tau = \eta/E$ governing biofilm restructuring dynamics. -\subsection{Radiodialysis Membrane Transport} +\paragraph{This subsection is specification, not method.} Nothing described here is +implemented. No elastic, viscoelastic or mechanical term exists in any source file: +\texttt{compute\_delta\_H\_terms} returns adhesion, volume, radiation and melanin and +nothing else, so neither Eq.~\ref{eq:fene} nor Eq.~\ref{eq:kelvinvoigt} enters the +acceptance rule or any equation of motion. The executed simulations carry no stress, no +strain and no modulus. + +\emph{And a measured modulus could not enter the model as written.} The CPM Hamiltonian +carries contact energies and a volume-constraint stiffness, not a bulk modulus in Pascals, +and its clock is Monte Carlo steps rather than seconds, so a relaxation time $\tau$ in +seconds has no place to land. A bench modulus would inform an immersed-boundary, particle, +or continuum viscoelastic model --- a different model class from the one this work runs. +This is recorded so that the specification is not read as a plan the present code could +absorb by fitting coefficients. Corrects \texttt{claims\_ledger} row \texttt{PP-310-01}, +which recommended labelling this subsection as proposed rather than implemented. + +\subsection{Membrane Transport Under Radiation-Driven Permeability Change} \label{sec:radiodialysis} To model contaminant ingress through the bioreactor membrane under radiation-driven @@ -687,7 +849,20 @@ \subsection{Planned Radiation--Transport Feedback, and What It Would Assume} once and never returning the composition field asserts $\partial\mu(\mathbf{r})/\partial c(\mathbf{r},t) \approx 0$, where $\mu$ is the local linear attenuation coefficient. That is a statement about what was computed, not about physics, and it -is a reasonable simplification only while the sorbate is dilute and low-$Z$. For a high-$Z$ +is a reasonable simplification only while the sorbate is dilute and low-$Z$. \emph{The reason is the +interaction rather than the chemistry, and it carries its own boundary.} At the photon energies +of interest, attenuation through C, H, N and O is dominated by Compton scattering, which scales +with electron density per unit volume and is insensitive to atomic number, so two light-element +phases attenuate almost alike whatever their molecular identity. Hydrogen sharpens the point +rather than softening it: it carries the highest electron count per unit mass of any element, so +a hydrated organic phase and water converge instead of separating. The same mechanism fixes where +the condition lapses, and that boundary is checkable without any measurement of this system: +photoelectric absorption reasserts itself below roughly a few tens of keV for light elements, so +the simplification holds in the present energy regime and fails beneath it --- a statement about +the interaction rather than about this biofilm. The residual contrast between hydrated biomass +and its medium is a separate quantity and is left unstated here, since obtaining it requires the +water fraction and closed hydrated composition that Section~\ref{sec:limitations} lists among the +measurements this work still lacks. For a high-$Z$ sorbate such as uranium, a sink term that concentrates mass by design raises the local electron density and effective atomic number exactly where the model has put it, and the assumption is weakest precisely where the interesting behaviour is. \textbf{The resulting error is not @@ -835,7 +1010,6 @@ \section{Parameter Estimation}\label{sec:params} Carrying cap. & $K_s$ & \textit{B.\ subtilis} & $10^5$--$10^7$ & cells mm$^{-3}$ & Dense biofilm with EPS matrix & \cite{guttenplan2013} \\ Carrying cap. & $K_s$ & \textit{S.\ oneidensis} & $10^5$--$10^7$ & cells mm$^{-3}$ & Planktonic and biofilm modes & \cite{heidelberg2002} \\ \midrule -Phase-lock freq. & $\omega_s$ & All species & $0.01$--$1.0$ & rad hr$^{-1}$ & Circadian/ultradian metabolic oscillation & \cite{kuramoto1984,acebron2005} \\ Noise intensity & $\sigma_s$ & All species & $0.001$--$0.05$ & --- & Thermal and demographic stochasticity & \cite{campillo2017} \\ \end{longtable} \end{landscape} @@ -845,7 +1019,7 @@ \section{Computational Methods} \label{sec:methods} The repository contains four executed simulation programs: three in R and one in Julia. The PSDE -in Section~3 is a proposed organising formalism and is not discretised by any of these programs. +in Section~\ref{sec:framework} is a proposed organising formalism and is not discretised by any of these programs. No optimisation, Sobol analysis, or Morris screening was performed. \paragraph{Two-dimensional Langevin trajectories (\texttt{biofilms.R}).} @@ -874,7 +1048,7 @@ \section{Computational Methods} AMDGPU support checkpoint exchange and the portability port, while CairoMakie is used for figure export. The lattice is cubic with a cylindrical biological domain. The reported coupled run uses $N=40$, six computational biomass parcels per species, and 100~MCS. One MCS is $N^3$ attempted copy -operations. The active Metropolis rule contains four terms, +operations, following the extended-Potts construction of Graner and Glazier \cite{graner1992}. The active Metropolis rule contains four terms, $H_{\mathrm{CPM}}=H_{\mathrm{adh}}+H_{\mathrm{vol}}+H_{\mathrm{rad}}+H_{\mathrm{mel}}$, at $T_{\mathrm{CPM}}=5.0$. The code has a manually invoked division primitive for lifecycle and lineage testing, but no growth law or automatic division trigger; it likewise contains no @@ -923,6 +1097,7 @@ \subsection{Scope of the Executed Auxiliary Analyses} premise for treating the radiation field as static on the simulated timescale. \subsection{CPM Radiation-Linked Term Magnitudes} +\label{sec:cpm_term_magnitudes} The CPM acceptance rule reveals a strong scale separation between its two radiation-derived terms. At the shipped $I_0=1.0$ and $T_{\mathrm{CPM}}=5.0$, their per-move energy changes and Metropolis @@ -932,21 +1107,35 @@ \subsection{CPM Radiation-Linked Term Magnitudes} \centering \small \caption{Magnitude of the radiation-linked terms in the CPM acceptance rule at the shipped -constants $I_0=1.0$ and $T_{\mathrm{CPM}}=5.0$.} +constants $I_0=1.0$ and $T_{\mathrm{CPM}}=5.0$. \emph{Corrected, version 1.2:} the sign of +$\Delta H_{\mathrm{rad}}$ is set by the role a cell plays as well as by $\beta_{s,\mathrm{ion}}$, +and the third row --- a positively signed species \emph{vacating} a site --- was absent from +version 1.1. Its omission is what made the largest acceptance-favouring radiation contribution +look like $5\times10^{-5}$. The rows are single roles; the reach of the term is the extremum over +\emph{pairings} of them, $7.505\times10^{-2}$, given beneath the table.} \label{tab:term_magnitudes} \begin{tabular}{@{}llr@{}} \toprule \textbf{Term} & $\Delta H$ & \textbf{Acceptance bias} \\ \midrule -$\Delta H_{\mathrm{rad}}$, negative $\beta_{\mathrm{ion}}=-5\times10^{-5}$ & $-5.0\times10^{-5}$ & $1.000010$ \\ -$\Delta H_{\mathrm{rad}}$, $\beta_{\mathrm{ion}}=7.5\times10^{-2}$ & $+7.5\times10^{-2}$ & $0.985$ \\ +$\Delta H_{\mathrm{rad}}$, source \emph{gains}, $\beta_{\mathrm{ion}}=-5\times10^{-5}$ & $-5.0\times10^{-5}$ & $1.000010$ \\ +$\Delta H_{\mathrm{rad}}$, source \emph{gains}, $\beta_{\mathrm{ion}}=7.5\times10^{-2}$ & $+7.5\times10^{-2}$ & $0.985$ \\ +$\Delta H_{\mathrm{rad}}$, target \emph{loses}, $\beta_{\mathrm{ion}}=7.5\times10^{-2}$ & $\mathbf{-7.5\times10^{-2}}$ & $\mathbf{1.0151}$ \\ $\Delta H_{\mathrm{mel}}$ at $M=1.44$ & $-0.720$ & $\mathbf{1.155}$ \\ \bottomrule \end{tabular} \end{table} -For the two negatively signed species, the direct radiation term changes acceptance by about one -part in $10^5$, whereas the melanin term changes it by 15.5\%. Radiation can therefore influence +For the two negatively signed species \emph{occupying} a site, the direct radiation term changes +acceptance by about one part in $10^5$, whereas the melanin term changes it by 15.5\%. That +comparison is between one role of one pair of species and the melanin term, and it does not bound +$\Delta H_{\mathrm{rad}}$: the third row is the same term at $1.5\times10^{3}$ times the magnitude +and in the acceptance-favouring direction, smaller than the melanin term by a factor of $9.6$ in +$\Delta H$ rather than by four orders. \emph{Corrected again, version 1.2:} this sentence read +``fifteen times'' when first written, which is the acceptance-bias ratio computed against a reach of +$5\times10^{-2}$ --- a value appearing nowhere in this paper. The correction to this paragraph was +applied twice at different times, and the pre-pairwise ratio survived inside the sentence whose other +half had already been fixed. Radiation can therefore influence the CPM indirectly through the chain \[ I_\gamma \longrightarrow \text{melanin production} \longrightarrow M @@ -956,6 +1145,92 @@ \subsection{CPM Radiation-Linked Term Magnitudes} so the two are not separately identifiable from this output. This comparison ranks only the two radiation-derived terms: hand-specified adhesion differences remain larger than both. +\paragraph{The same ranking as a count of decided moves.} +Table~\ref{tab:term_magnitudes} compares terms at a single move. The same comparison can be made +over a run by asking, for each \emph{accepted} move, whether removing one term would have reversed +it. The question is settled by arithmetic on the uniform variate the acceptance test already drew, +so no additional randomness enters and the trajectory is unchanged; the byte-level serial contract +is satisfied with the tally attached and without it. + +Two cases arise and they do not overlap. When $\Delta H \le 0$ the move is accepted outright and no +variate is drawn, so removing a term can raise $\Delta H$ above zero but cannot be shown to reverse +the move --- it removes the certainty, and the outcome would depend on a number that was never +generated. These moves are recorded as \emph{contingent} rather than assigned to a term. When +$\Delta H > 0$ a variate exists, every counterfactual is decidable, and only a term that lowered +$\Delta H$ can be decisive, since removing one that raised it leaves the move accepted. + +\begin{table}[h!] +\centering +\small +\caption{Share of accepted moves reversed by removing a single Hamiltonian term, at $N=40$, six +parcels per species, seed 42. \emph{None} means no single term reverses the move and the sum +carries it; \emph{contingent} is the $\Delta H \le 0$ case described above. Percentages are of +accepted moves, not of attempts. \emph{Added in version 1.2:} the run is the CPM stepped directly +with \texttt{Random.Xoshiro(seed)} and the melanin field updated each sweep, with no radiodialysis +coupling --- \emph{not} \texttt{run\_simulation} or \texttt{run\_simulation\_coupled}, which seed +\texttt{MersenneTwister} and give 14\,281 accepted moves at 100~MCS rather than 16\,037. Version 1.1 +recorded neither the generator nor that distinction, so the table could not be re-executed; it is +now reproduced by \texttt{julia --project=. decided\_moves.jl}, which compares its own output +against the values printed here.} +\label{tab:decided_moves} +\begin{tabular}{@{}lrr@{}} +\toprule +\textbf{Term removed} & \textbf{100 MCS} & \textbf{400 MCS} \\ + & (16\,037 accepted) & (53\,603 accepted) \\ +\midrule +none (the sum) & 38.56\% & 65.99\% \\ +$\Delta H_{\mathrm{adh}}$ & 7.53\% & 6.15\% \\ +$\Delta H_{\mathrm{vol}}$ & 4.43\% & 3.97\% \\ +$\Delta H_{\mathrm{rad}}$ & \textbf{0.00\%} & \textbf{0.00\%} \\ +$\Delta H_{\mathrm{mel}}$ & 0.01\% & 0.04\% \\ +more than one term & 0.09\% & 0.13\% \\ +contingent & 49.39\% & 23.72\% \\ +\bottomrule +\end{tabular} +\end{table} + +\emph{Corrected, version 1.2; see the correction note in the Data and Code Availability section.} +The direct radiation term was the sole decisive term in one accepted move of 206\,042 across seeds +42, 43 and 44 at 400~MCS --- none under seeds 42 and 43, one under seed 44 --- and in none at +100~MCS. That count is small, but it does \emph{not} follow from the one-part-in-$10^5$ row of +Table~\ref{tab:term_magnitudes}, and the argument that it did was wrong. + +$\Delta H_{\mathrm{rad}}$ is signed by the \emph{role} a cell plays, not only by +$\beta_{s,\mathrm{ion}}$: a source of species $s$ gaining a site contributes +$+\beta_{s,\mathrm{ion}} I_\gamma$, and a target of species $t$ losing one contributes +$-\beta_{t,\mathrm{ion}} I_\gamma$. A move is favoured when +the total is negative, so a \emph{positively} signed species vacating a site favours acceptance +exactly as much as a negatively signed one occupying it. A copy between two \emph{occupied} +parcels carries both roles at once and contributes $(\beta_{s,\mathrm{ion}} - \beta_{t,\mathrm{ion}}) +I_\gamma$, so the reach is an extremum over source/target pairings rather than over species: +$\max_{s,t}(\beta_{t,\mathrm{ion}} - \beta_{s,\mathrm{ion}})\,I_\gamma = 7.505\times10^{-2}$, which is +$1.5\times10^{3}$ times the $5\times10^{-5}$ that version 1.1 of this paragraph claimed as a bound. +Measured over the three runs of Table~\ref{tab:decided_moves} --- $3\,968\,838$ evaluated proposals +across seeds 42, 43 and 44 at 400~MCS --- $26.7\%$ carry +$\Delta H_{\mathrm{rad}} < -5\times10^{-5}$, and the most acceptance-favouring value reached is +$-7.5\times10^{-2}$, a vacating \emph{S.\ oneidensis} target with no occupied source. The pairwise +extremum itself is attained but rare: a \emph{C.\ sphaerospermum} source copying into an +\emph{S.\ oneidensis} target reaches $-7.505\times10^{-2}$ four times in $1\,298\,668$ proposals at +$N=20$; the three $N=40$ runs of Table~\ref{tab:decided_moves} do not reach it, which is why the +single-role figure went unchallenged by the measurement that accompanied it. + +Recomputed on that basis, the expected number of accepted moves that removing +$\Delta H_{\mathrm{rad}}$ alone would reverse is $16.7$ across the three seeds, against $0.26$ under +the withdrawn bound; the measured number is $16$ (four, three and nine by seed). The reason the +table nonetheless reports the term as decisive only once is \emph{absorption}, not absence: in +fifteen of those sixteen an adhesion or volume term was independently decisive as well, so the move +is labelled \emph{more than one term}. The defensible statement is that the direct radiation term +could have decided sixteen accepted moves in 206\,042 and was rarely the only term that did, which +is a weaker claim than the one this paragraph previously made and is the one the data supports. The +melanin term reversed 23 moves in 53\,603. Neither figure is a measurement of anything outside this +model's own acceptance arithmetic. + +The complement is the more useful number. Between 66\% and 77\% of accepted moves are reversed by +removing no single term at all, so the dynamics are carried by the sum rather than owned by any +component, and the per-voxel statistics are too thin to say more: at 400~MCS the median voxel that +was written to at all received three accepted moves. A spatial map of which term dominates where is +therefore not reported, and would be noise if it were. + \subsection{Single-Seed Radial Diagnostic} \label{sec:cpm_results} @@ -1048,14 +1323,15 @@ \subsection{Synthetic One-Way CPM--OpenMC Integration} % ---- 7. Discussion ------------------------------------------- \section{Discussion} +\label{sec:discussion} \subsection{What the Implemented Models Establish} The implemented programs support a narrower conclusion than the full PSDE specification. The Langevin models demonstrate stochastic spatial grouping under chosen drift and interaction rules; they do not demonstrate radiation-enhanced growth or survival. The CPM term audit establishes that, -within its radiation-derived pathway, the melanin term dominates the direct species-specific -radiation term by several orders of magnitude. The single-seed radial output does not establish a +within its radiation-derived pathway, the melanin term exceeds the direct species-specific +radiation term by about an order of magnitude in $\Delta H$ ($9.6$), not by several. The single-seed radial output does not establish a radiotropic pattern, because its direction conflicts with the direct coefficient and its magnitude is not separated from the seeding null. The radiodialysis solvers establish a reproducible numerical solution of the stated semi-discrete equations, but the coupled Julia path does not yet possess a @@ -1091,6 +1367,32 @@ \subsection{Implications for Bioremediation and Transport Modelling} physical system is specified; dose-responsive membrane transport requires a separate constitutive and experimental programme. +The regime in which those inputs are obtainable is not the one this paper's motivating sites +occupy. Section~\ref{sec:planned_feedback} put the gap at about ten orders of magnitude: a +reactor irradiation delivers of order $\mathrm{Gy}\,\mathrm{min}^{-1}$ and the environmental +settings cited in Section~\ref{sec:intro} are closer to $\mathrm{mGy}\,\mathrm{yr}^{-1}$, so a sensitivity +constant calibrated in one is not transferable to the other. Engineered nuclear facilities lie +between them, and biofilms are documented there. Sarr\'{o} et al.\ submerged a bioreactor +carrying stainless steel and titanium coupons in a spent nuclear fuel pool at a Spanish nuclear +power plant, characterised the biofilm that developed on both materials, and measured its +radioactivity by gamma-ray spectrometry: the biofilms retained radionuclides from the pool water, +$^{60}$Co in particular \cite{sarro2007}. Karley et al.\ isolated six bacterial species from +spent-fuel-pool water with $D_{10}$ values from 248~Gy to 2~kGy and biofilm-forming capability, +and reported biofilm-mediated Co and Ni removal up to 3.8~$\mu$g per mg of biomass +\cite{karley2018}; a later study of the same isolates --- among them \textit{B.\ subtilis}, one +of the seven modelled here --- found that dead biomass also removed both ions, which is what a +sorption rather than a metabolic mechanism predicts \cite{karley2023}. + +We record this as an observation about regime and available inputs, not as a proposed deployment. +Three quantities the offline counterfactual requires --- a bounded geometry, a characterised +source, and a measurable metal inventory in the biofilm --- are ordinarily available in such a +facility and are not available at a contaminated field site. What this framework would compute +there is the dose a biofilm on a surface receives and whether its presence perturbs the local +field: the same one-way question posed above, with its inputs supplied rather than assumed. +Whether the perturbation is large enough to matter is an empirical question, and nothing in this +paper answers it. The model is unchanged by the observation; only the setting in which its inputs +could be measured is. + \subsection{Limitations and Future Directions} \label{sec:limitations} @@ -1103,6 +1405,21 @@ \subsection{Limitations and Future Directions} a Neighbour-Sensing- or VSC-style tip-growth term \cite{meskauskas2004,bartnickigarcia1989} --- rather than an unbounded open problem. +Fluid flow and advection are absent as well, and the relation between these absences is more +informative than a longer list would be. Flow reaches morphology through mechanics, so its +absence is downstream of the missing mechanics rather than a separate gap; and a model with +no growth generates no growth-induced stress for mechanics to act on, so the mechanical gap +is in turn downstream of the growth one. The three are better read as one structural absence +under three names than as three independent items. What that costs is stated rather than +implied: this model has none of the three routes named above, and its spatial output is +therefore directional redistribution of parcels in an imposed field rather than a morphology. +Whether those three exhaust the routes is a question about the literature and not one this +model answers. +External review has argued that growth-induced stress is the dominant morphogen in still +conditions; that claim is literature this work has not verified, is recorded as such in +\texttt{docs/research/external\_reviews\_2026-08-31\_redteam.md}, and nothing here +depends on it. + \paragraph{Identifiability.} Only the product of the melanin-production scale and the melanin-coupling scale reaches the CPM acceptance dynamics. Those parameters cannot be estimated separately from the same output without @@ -1137,8 +1454,9 @@ \section{Conclusion} This study separates a broad mathematical proposal from the smaller set of simulations that were actually executed. The active CPM represents seven species as computational biomass parcels and contains adhesion, volume, radiation and melanin terms. At the shipped constants, the melanin term -is the dominant radiation-derived contribution to Metropolis acceptance, while the direct -species-specific radiation term is negligible for the two negatively signed classes. The resulting +is the dominant radiation-derived contribution to Metropolis acceptance, exceeding the direct +species-specific radiation term by about an order of magnitude in $\Delta H$ rather than rendering it +negligible. The resulting single-seed radial pattern is diagnostic rather than established: it is opposite to the direct-term direction and is not separated from the seeding null at six parcels per species. @@ -1158,10 +1476,9 @@ \section{Conclusion} \section*{Software and Data Availability} All source code, configuration schemas, tests, provenance ledgers, and figure-generation materials are available in the public repository at -\url{https://github.com/aurascoper/Biofilms}. The supporting repository revision for this -manuscript is \texttt{c7539a9}, the parent of the commit that adds this manuscript to the -repository: a document cannot cite the commit that contains it, so it names the revision it was -prepared against. No new biological specimens or experimental measurements were generated. +\url{https://github.com/aurascoper/Biofilms}. Version 1.0 was prepared against repository +revision \texttt{c7539a9}; this revision is prepared against \texttt{4d8a5f8}. Each names the +parent of the commit that adds it, because a document cannot cite the commit that contains it. No new biological specimens or experimental measurements were generated. Third-party public datasets are cited by their original records and are not redistributed as target calibration data. @@ -1169,10 +1486,57 @@ \section*{Software and Data Availability} numerical results published in it were subsequently corrected by the project's own review: a false-positive control that could not fail was replaced, a squared difference reported as a variance was relabelled, an unprovenanced sensitivity table was withdrawn and re-measured, and -one synthetic-gate verdict was withdrawn as not resolution-converged. None of those numbers -appears in this manuscript, whose transport section reports the execution of a path rather than -its results. The corrections are recorded in \texttt{data/claims\_ledger.csv} and in -\texttt{docs/calibration/}. +one synthetic-gate verdict was withdrawn as not resolution-converged. The corrections are +recorded in \texttt{data/claims\_ledger.csv} and in \texttt{docs/calibration/}. + +\emph{Correction, version 1.1: Figures~\ref{fig:radial} and~\ref{fig:melanin} have been replaced.} The versions distributed with +version 1.0 carried claims inside the images that the surrounding text retracts. Figure~\ref{fig:melanin} +annotated two species as ``radiotrophic (melanin-mediated energy gain)'' beneath a caption +disowning radiation-derived energy production and against +Section~\ref{sec:radiotrophy_status}; Figure~\ref{fig:radial} labelled a shaded band a ``radiotrophic niche'', +and labelled it on the wrong side, since the imposed field is maximal on the axis. Three further +in-plot strings asserted findings the text does not support: a title reading ``radial +stratification'' where Section~\ref{sec:discussion} reports a single-seed diagnostic not separated from the seeding +null, a title attributing melanin accumulation to radiation-driven production, and an annotation +asserting drift toward the source when the run's own ordering runs the other way. No numerical +value in either figure changed --- the underlying run is the same, and the mean radial positions +and melanin values quoted in the captions are reproduced exactly --- and no other figure was +regenerated. Figure~\ref{fig:contaminant} still carries a ``per cent depleted'' label that should read +non-penetration, and is not regenerated here: its quantities are downstream of the gated biomass +basis. Figure~\ref{fig:membrane} is not --- it plots $m(t)$ and $P_{\mathrm{eff}}/P_0$, neither of which reads that +basis --- and it has now been rebuilt; see the version 1.2 note below. The defect was that the +figure generator had been corrected two weeks earlier and the committed images had not, with no +check able to detect the difference; this is recorded as \texttt{FIG-01} to \texttt{FIG-07} in the +claims ledger. + +\emph{Correction, version 1.2: two items, one numerical and one in an image.} +Section~\ref{sec:cpm_term_magnitudes} previously bounded the acceptance-favouring contribution of +$\Delta H_{\mathrm{rad}}$ at $5\times10^{-5}$ and inferred from that bound that the term deciding +no accepted move was analytically expected rather than an under-sampling artifact. The bound was +wrong by a factor of $1.5\times10^{3}$: it enumerated only the source-gains-a-site role, and a +positively signed species vacating a site favours acceptance by up to $7.5\times10^{-2}$. A second +pass over the same paragraph, also in review, found that the replacement bound was still stated over +\emph{species} when the quantity is an extremum over source/target \emph{pairings}: a copy between +two occupied parcels carries both roles, so the reach is $7.505\times10^{-2}$ and +$\max_s|\beta_{s,\mathrm{ion}}|$ is not a bound on it. The two values differ by $0.067\%$ and the +larger is attained in a shipped configuration. The fraction of evaluated proposals carrying +$\Delta H_{\mathrm{rad}} < -5\times10^{-5}$ is likewise restated as $26.7\%$ over the three runs of +Table~\ref{tab:decided_moves}; the $29.8\%$ first written here came from a run this paper did not +identify, which is the same defect as \texttt{PP-62-11} and is why the configuration is now named in +the sentence. \texttt{tests/prose\_bounds.jl} recomputes the bound from the shipped coefficients and +fails on either withdrawn form. +Table~\ref{tab:term_magnitudes} gains the missing row and the paragraph beneath +Table~\ref{tab:decided_moves} is rewritten. \textbf{Two published numbers move.} The count of moves +for which the direct radiation term was solely decisive is one of 206\,042, not zero --- seed 44 +carries one --- and the number that removing $\Delta H_{\mathrm{rad}}$ alone would reverse is +sixteen, against $0.26$ predicted under the withdrawn bound and $16.7$ under the corrected one. The +remaining entries of Table~\ref{tab:decided_moves} were re-measured and reproduce exactly; that is +a statement about those entries and not about any other result in this paper. Recorded as +\texttt{PP-62-04} and \texttt{PP-62-09} to \texttt{PP-62-11} in the claims ledger; raised in review +by Codex on pull request \#23. Figure~\ref{fig:membrane} has separately been rebuilt to remove an in-plot +``50~Gy cumulative'' annotation that the generator dropped on 14 August 2026 and the committed +artifact kept, and to separate two labels that had been printing on top of one another; no value in +it changed. \section*{Ethics, Biosafety, and Regulatory Statement} This manuscript reports software development, computational simulation, code audit, and analysis of @@ -1184,8 +1548,31 @@ \section*{Ethics, Biosafety, and Regulatory Statement} institutional approvals have been issued; no such campaign or approval is claimed in this manuscript. +One organism named here requires a nomenclature note, because the name it is published under is +no longer the name a laboratory would receive it as. \textit{Ochrobactrum intermedium} was +reclassified into \textit{Brucella} on genomic grounds \cite{hordt2020}, the combination +\textit{Brucella intermedia} was validly published the same year \cite{oren2020}, and NCBI +Taxonomy serves taxon 94625 as \textit{Brucella intermedia} with \textit{Ochrobactrum +intermedium} retained as a synonym and a lineage reading \texttt{Brucellaceae; Brucella/} +\texttt{Ochrobactrum group; Brucella}. The consequence is operational rather than taxonomic: CDC's +laboratory update of 19 December 2022 records that the reclassification is reflected in the rapid +identification systems clinical laboratories use, and directs that organisms identified as +\textit{Brucella} be handled in a Class II biosafety cabinet and that presumptive isolates be +referred to a state public health laboratory \cite{cdc2022}. That same notice designates the +former \textit{Ochrobactrum} species non-brucellosis-causing. + +We state the record and not a determination. Containment level, select-agent status and +procurement clearance follow the individual strain, are the responsibility of the institution +that issues them, and are outside what a computational study can establish; this manuscript +asserts none of them. Two facts are recorded because they bear on anyone reading this as a +procurement plan: no culture-collection accession for AM7 was located in any repository searched, +so the strain may be unobtainable; and the repository's own research record carries the +determination as unresolved, with procurement held pending it. Both names are carried on every +record here for the same reason the \textit{Wangiella}/\textit{Exophiala} mapping is carried +--- a search under one name does not return the other. + % ---- References --------------------------------------------- -\begin{thebibliography}{53} +\begin{thebibliography}{61} \bibitem{radcell2021} Liu, R., Higley, K.A., Swat, M.H., Chaplain, M.A.J., Powathil, G.G., Glazier, J.A. (2021). @@ -1293,16 +1680,27 @@ \section*{Ethics, Biosafety, and Regulatory Statement} \url{https://doi.org/10.1111/j.1462-2920.2011.02414.x} \bibitem{sonner2015} -Sonner, S., Efendiev, M.A., Eberl, H.J. (2015). -On the well-posedness of a mathematical model of quorum-sensing in patchy biofilm communities. -\textit{Mathematical Methods in the Applied Sciences}, 38(3), 3037--3042. -\url{https://doi.org/10.1002/mma.3237} +Rahman, K.A., Sudarsan, R., Eberl, H.J. (2015). +A mixed-culture biofilm model with cross-diffusion. +\textit{Bulletin of Mathematical Biology}, 77(11), 2086--2124. +\url{https://doi.org/10.1007/s11538-015-0117-1} +% CORRECTED 2026-08-31. The entry previously here had wrong bibliographic data, +% a DOI resolving to an unrelated paper, and was the wrong paper for the +% cross-diffusion claim it was cited for. The withdrawn fields are recorded in +% data/claims_ledger.csv, row PP-REF-01 (verdict delete) -- deliberately NOT +% repeated here, so the withdrawn DOI exists in the recording layer only and +% cannot be copied out of the manuscript. \bibitem{diele2015} -Diele, F., Marangi, C., Ragni, S. (2015). +Diele, F., Marangi, C. (2020). Geometric numerical integration in ecological modelling. -\textit{Mathematics and Computers in Simulation}, 110, 40--52. -\url{https://doi.org/10.1016/j.matcom.2014.02.006} +\textit{Mathematics}, 8(1), 25. +\url{https://doi.org/10.3390/math8010025} +% CORRECTED 2026-08-31. EVERY FIELD of the previous entry was wrong -- authors, +% year, journal, volume, pages -- and its DOI resolved to an unrelated +% quadratic-programming paper. Two authors, not three; the body citation was +% updated to match. The withdrawn fields are in data/claims_ledger.csv, row +% PP-REF-02 (verdict delete), and are deliberately not repeated here. \bibitem{kuramoto1984} Kuramoto, Y. (1984). @@ -1317,10 +1715,13 @@ \section*{Ethics, Biosafety, and Regulatory Statement} \url{https://doi.org/10.1103/RevModPhys.77.137} \bibitem{campillo2017} -Campillo, F., Joannides, M., Larramendy-Valverde, I. (2017). +Voulgarelis, D., Velayudhan, A., Smith, F. (2018). Stochastic analysis of a full system of two competing populations in a chemostat. -\textit{Chemical Engineering Science}, 175, 424--440. -\url{https://doi.org/10.1016/j.ces.2017.10.052} +\textit{Chemical Engineering Science}, 175, 424--444. +% CORRECTED 2026-08-31 per claims_ledger PP-REF-03, which holds the withdrawn wording. +% The journal and volume were right; the authors, year, title and pages were not. +% The key is left as campillo2017 so the citation sites need no edit. +\url{https://doi.org/10.1016/j.ces.2017.10.016} \bibitem{heidelberg2002} Heidelberg, J.F., et al. (2002). @@ -1341,6 +1742,47 @@ \section*{Ethics, Biosafety, and Regulatory Statement} \textit{Journal of Hazardous Materials}, 388, 122047. \url{https://doi.org/10.1016/j.jhazmat.2020.122047} +\bibitem{sarro2007} +Sarr\'{o}, M. I., Garc\'{i}a, A. M., Moreno, D. A., Montero, F. (2007). +Development and characterization of biofilms on stainless steel and titanium in spent nuclear +fuel pools. +\textit{Journal of Industrial Microbiology \& Biotechnology}, 34(6), 433--441. +\url{https://doi.org/10.1007/s10295-007-0215-7} + +\bibitem{karley2018} +Karley, D., Shukla, S. K., Rao, T. S. (2018). +Isolation and characterization of culturable bacteria present in the spent nuclear fuel pool +water. +\textit{Environmental Science and Pollution Research}, 25(21), 20518--20526. +\url{https://doi.org/10.1007/s11356-017-0376-5} + +\bibitem{karley2023} +Karley, D., Shukla, S. K., Rao, T. S. (2023). +Sequestration of cobalt and nickel by biofilm forming bacteria isolated from spent nuclear fuel +pool water. +\textit{Environmental Monitoring and Assessment}, 195(6), 731. +\url{https://doi.org/10.1007/s10661-023-11266-x} + +\bibitem{hordt2020} +H\"{o}rdt, A., Garc\'{i}a L\'{o}pez, M., Meier-Kolthoff, J. P., Schleuning, M., Weinhold, L.-M., +Tindall, B. J., Gronow, S., Kyrpides, N. C., Woyke, T., G\"{o}ker, M. (2020). +Analysis of 1,000+ type-strain genomes substantially improves taxonomic classification of +\textit{Alphaproteobacteria}. +\textit{Frontiers in Microbiology}, 11, 468. +\url{https://doi.org/10.3389/fmicb.2020.00468} + +\bibitem{oren2020} +Oren, A., Garrity, G. M. (2020). +List of new names and new combinations previously effectively, but not validly, published. +\textit{International Journal of Systematic and Evolutionary Microbiology}, 70(7), 4043--4049. +\url{https://doi.org/10.1099/ijsem.0.004244} + +\bibitem{cdc2022} +Centers for Disease Control and Prevention (2022). +Lab update: reclassification of \textit{Ochrobactrum} species into the \textit{Brucella} genus. +Laboratory Outreach Communication System, 19 December 2022. +\url{https://www.cdc.gov/locs/2022/12-19-2022-Lab-Update-Reclassification_Ochrobactrum_species_Brucella_genus.html} + \bibitem{wright1932} Wright, S. (1932). The roles of mutation, inbreeding, crossbreeding and selection in evolution. @@ -1383,31 +1825,13 @@ \section*{Ethics, Biosafety, and Regulatory Statement} \textit{PLoS ONE}, 7(11), e48674. \url{https://doi.org/10.1371/journal.pone.0048674} -\bibitem{malo2018} -Malo, M.E., et al. (2018). +\bibitem{malo2018} +Malo, M.E., Bryan, R.A., Shuryak, I., Dadachova, E. (2018). Morphological changes in melanized and non-melanized \textit{Cryptococcus neoformans} cells -post exposure to ionizing radiation. +post exposure to sparsely and densely ionizing radiation demonstrate protective effect of +melanin. \textit{Fungal Biology}, 122(6), 449--456. -\url{https://doi.org/10.1016/j.funbio.2017.08.012} - -\bibitem{newsome2014} -Newsome, L., Morris, K., Lloyd, J.R. (2014). -The biogeochemistry and bioremediation of uranium and other priority radionuclides. -\textit{Chemical Geology}, 363, 164--184. -\url{https://doi.org/10.1016/j.chemgeo.2013.10.034} - -\bibitem{brim2000} -Brim, H., et al. (2000). -Engineering \textit{Deinococcus radiodurans} for metal remediation in radioactive mixed -waste environments. -\textit{Nature Biotechnology}, 18(1), 85--90. -\url{https://doi.org/10.1038/71986} - -\bibitem{kazy2009} -Kazy, S.K., D'Souza, S.F., Sar, P. (2009). -Uranium and thorium sequestration by a \textit{Pseudomonas} sp. -\textit{Journal of Hazardous Materials}, 163(1), 65--72. -\url{https://doi.org/10.1016/j.jhazmat.2008.06.076} +\url{https://doi.org/10.1016/j.funbio.2017.08.010} \bibitem{blasius1999} Blasius, B., Huppert, A., Stone, L. (1999). @@ -1422,9 +1846,10 @@ \section*{Ethics, Biosafety, and Regulatory Statement} \url{https://doi.org/10.1007/s11538-006-9168-7} \bibitem{eberl2001} -Eberl, H.J., Parker, D.F., van Loosdrecht, M.C.M. (2001). +Eberl, H.J., Parker, D.F., van Loosdrecht, M.C.M. (2000). A new deterministic spatio-temporal continuum model for biofilm development. -\textit{Journal of Theoretical Medicine}, 3(3), 161--175. +\textit{Journal of Theoretical Medicine} (now \textit{Computational and Mathematical +Methods in Medicine}), 3(3), 161--175. \url{https://doi.org/10.1080/10273660108833072} \bibitem{hairer2006} @@ -1433,30 +1858,12 @@ \section*{Ethics, Biosafety, and Regulatory Statement} Springer-Verlag. \url{https://doi.org/10.1007/3-540-30666-8} -\bibitem{eisenman2012} -Eisenman, H.C., Casadevall, A. (2012). -Synthesis and assembly of fungal melanin. -\textit{Applied Microbiology and Biotechnology}, 93(3), 931--940. -\url{https://doi.org/10.1007/s00253-011-3777-2} - -\bibitem{lloyd2005} -Lloyd, J.R., Renshaw, J.C. (2005). -Bioremediation of radioactive waste. -\textit{Current Opinion in Biotechnology}, 16(3), 254--260. -\url{https://doi.org/10.1016/j.copbio.2005.04.012} - \bibitem{khajo2011} Khajo, A., et al. (2011). Protection of melanized \textit{Cryptococcus neoformans} from lethal dose gamma irradiation. \textit{PLoS ONE}, 6(9), e25092. \url{https://doi.org/10.1371/journal.pone.0025092} -\bibitem{battista1997} -Battista, J.R. (1997). -Against all odds: the survival strategies of \textit{Deinococcus radiodurans}. -\textit{Annual Review of Microbiology}, 51, 203--224. -\url{https://doi.org/10.1146/annurev.micro.51.1.203} - \bibitem{casadevall2017} Casadevall, A., et al. (2017). Melanin, radiation, and energy transduction in fungi. @@ -1506,6 +1913,17 @@ \section*{Ethics, Biosafety, and Regulatory Statement} (LD$_{90}$ values for N402 and the pigmentation-deficient $\Delta fwnA$ mutant MA93.1 under X-ray, He-ion and Fe-ion exposure; open access, CC BY 4.0.) +\bibitem{sellafield2020} +Foster, L., Boothman, C., Ruiz-Lopez, S., et al. (2020). +Identification of a stable hydrogen-driven microbiome in a highly radioactive storage facility on +the Sellafield site. \textit{Frontiers in Microbiology}, 11, 587556. + +\bibitem{sellafield2023} +Ruiz-Lopez, S., et al. (2023). +Identification of algal rich microbial blooms in the Sellafield Pile Fuel Storage Pond and the +application of ultrasonic treatment to control the formation of blooms. +\textit{Frontiers in Microbiology}, 14, 1261801. + \bibitem{walberg2015} Walberg, K. (2015). Assessment of the evidence for radiosynthesis in melanized fungi. diff --git a/rad_proposals.jl b/rad_proposals.jl new file mode 100644 index 0000000..d417b2d --- /dev/null +++ b/rad_proposals.jl @@ -0,0 +1,108 @@ +#!/usr/bin/env julia +# The per-proposal ΔH_rad statistics quoted in Section 6.2, reproduced from the +# shipped code. +# +# julia --project=. rad_proposals.jl # the three Table 4 runs +# julia --project=. rad_proposals.jl 42 # one seed +# +# WHY THIS FILE EXISTS. Section 6.2 states that 26.7% of evaluated proposals +# carry ΔH_rad < -5e-5, over 3968838 proposals across seeds 42, 43 and 44 at +# 400 MCS. VERSION 1.2 STATED 29.8% OVER A RUN IT DID NOT NAME, and nothing in +# the repository could produce either number: `decided_moves.jl` counts accepted +# moves by decisive label and never touches evaluated proposals, and the +# measurement behind the restatement was an in-memory rewrite of +# `biofilms_potts.jl` that was never committed. A corrected number nobody can +# re-run is the defect PP-62-11 records, committed inside the fix for it. +# Raised by Codex on pull request #23. +# +# It uses the `on_proposal` hook on `mcs_step!`, which is called for every +# evaluated proposal before the acceptance draw and never consults the +# generator. tests/rad_proposals_tests.jl pins that the hook does not move the +# trajectory, with a different-seed control so the comparison can fail. +# +# NO RADIODIALYSIS AND THEREFORE NO BASIS GATE, for the reason decided_moves.jl +# gives: compute_delta_H_terms never reads the nutrient field. + +using Printf, Random + +const HERE = @__DIR__ + +function load_serial() + src = read(joinpath(HERE, "biofilms_potts.jl"), String) + src = split(src, "# 13. Figure export")[1] + M = Module(:SerialRef) + Base.eval(M, :(using LinearAlgebra, Statistics, Random, Printf)) + Base.include_string(M, src, "biofilms_potts.jl") + return M +end + +"The threshold §6.2 reports against: the bound version 1.1 claimed." +const THRESH = -5e-5 + +""" + rad_proposals(SR, seed, n_mcs; N, n_cells_per_species) -> NamedTuple + +`n` evaluated proposals, `below` of them carrying ΔH_rad < THRESH, and the +extremes of ΔH_rad over the run. + +Counters rather than a stored vector: the three runs evaluate about four million +proposals and holding them costs more than the simulation. +""" +function rad_proposals(SR::Module, seed::Int, n_mcs::Int; + N::Int = 40, n_cells_per_species::Int = 6) + p = SR.CPMParams(; N, n_cells_per_species) + st = SR.init_state(p; seed) + rng = Random.Xoshiro(seed) # matches decided_moves.jl, NOT MersenneTwister + n = Ref(0); below = Ref(0) + lo = Ref(Inf); hi = Ref(-Inf) + hook = function (terms, _ΔH) # never touches `rng` -- see the header + n[] += 1 + terms.rad < THRESH && (below[] += 1) + lo[] = min(lo[], terms.rad) + hi[] = max(hi[], terms.rad) + end + for _ in 1:n_mcs + SR.mcs_step!(st, rng; on_proposal = hook) + SR.update_melanin!(st) + end + return (; n = n[], below = below[], frac = below[] / n[], min = lo[], max = hi[]) +end + +# Measured 2026-08-29 at N=40, 6 parcels per species, 400 MCS, Xoshiro(seed). +# `min` is the acceptance-favouring extreme; `max` is its mirror, and at seeds 43 +# and 44 it reaches +0.07505 -- the pairwise extremum, in the disfavouring +# direction. The acceptance-favouring -0.07505 is NOT reached at N=40, which is +# what §6.2 says and what makes the single-role bound survive its own run. +# THESE COUNTS ARE MEASURED. The first draft of this table back-computed them +# from the reported fractions -- 1287796 * 0.2783 -- which is a number that looks +# like evidence and is not one. Running this file printed the real counts and +# they disagreed in the third digit. +const PUBLISHED = Dict( + 42 => (n = 1_287_796, below = 358_404, min = -0.071342207), + 43 => (n = 1_340_171, below = 344_591, min = -0.061028003), + 44 => (n = 1_340_871, below = 357_192, min = -0.075), +) +const POOLED_N = 3_968_838 +const POOLED_FRAC = 0.267 + +function main(args) + seeds = isempty(args) ? [42, 43, 44] : parse.(Int, args) + SR = load_serial() + tn = 0; tb = 0; lo = Inf; hi = -Inf + @printf("%-6s %12s %12s %8s %14s %14s\n", + "seed", "evaluated", "< -5e-5", "frac", "min dH_rad", "max dH_rad") + for s in seeds + # invokelatest for the same reason decided_moves.jl uses it: the module + # is built at run time and its bindings do not exist in this world yet. + r = Base.invokelatest(rad_proposals, SR, s, 400) + @printf("%-6d %12d %12d %8.4f %14.8g %14.8g\n", s, r.n, r.below, r.frac, r.min, r.max) + tn += r.n; tb += r.below; lo = min(lo, r.min); hi = max(hi, r.max) + end + @printf("%-6s %12d %12d %8.4f %14.8g %14.8g\n", "pooled", tn, tb, tb / tn, lo, hi) + @printf("\nSection 6.2 states %.1f%% over %d evaluated proposals.\n", + 100 * POOLED_FRAC, POOLED_N) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + main(ARGS) +end diff --git a/regenerate_fig3.jl b/regenerate_fig3.jl new file mode 100644 index 0000000..b9e15b4 --- /dev/null +++ b/regenerate_fig3.jl @@ -0,0 +1,85 @@ +#!/usr/bin/env julia +# Regenerate fig3_membrane_transport ONLY. +# +# julia --project=. regenerate_fig3.jl +# +# WHY THIS EXISTS RATHER THAN `julia --project=. biofilms_potts.jl`. The script's +# default entry point is main_coupled(), and it cannot run: the coupled loop +# reconstructs X_total from occupancy and RADIODIALYSIS: BLOCKED refuses to +# integrate. So figs 3-4 have not been regenerable since the gate landed, and +# 1b51126 -- which regenerated figs 1-2 through main()'s --no-radiolysis path -- +# could only add .sha256/.txt sidecars for figs 3-4 over pdfs it could not +# rebuild. A generator nobody can run is the same liability that commit was +# written about, so this is committed rather than kept in a scratch directory. +# +# WHY FIG3 AND NOT FIG4. export_figures writes both, so this renders into a +# temporary directory and copies exactly one file across. +# +# fig3 plots m(t) and P_eff/P0. dm/dt = -k_dam*Ddot_R*m and +# P_eff = P0*exp(alpha_P*Ddot_R*t). Neither reads X_total or X_red. +# fig4 plots c_wall, c_mean, s_mean. The gated basis enters through +# uptake = k_ads*X_total + k_red*X_red, which drives c and s and nothing else. +# +# basis_gate_ack: the exemption is that claim, and NOTHING HERE TAKES IT ON +# TRUST. The run is repeated at 10x the uptake constants and fig3's two series +# must come back identical while fig4's must NOT -- a control that fails in both +# directions, so it cannot pass by measuring nothing. The copy is refused if +# either half is violated. Mirrors the "exemption's claim is true" testset in +# tests/radiodialysis_basis_gate.jl. + +using Printf + +const HERE = @__DIR__ +const OUT = joinpath(HERE, "preprint", "figures") +const NAME = "fig3_membrane_transport" + +M = Module(:Fig3Regen) +Base.eval(M, :(using LinearAlgebra, Statistics, Random, Printf)) +Base.include(M, joinpath(HERE, "biofilms_potts.jl")) + +const CPMParams = Base.eval(M, :CPMParams) +const RadiolysisParams = Base.eval(M, :RadiolysisParams) +const run_simulation_coupled = Base.eval(M, :run_simulation_coupled) +const export_figures = Base.eval(M, :export_figures) + +function render(outdir; k_ads = 0.05, k_red = 0.02) + params = CPMParams(N = 40, n_cells_per_species = 6, snapshot_interval = 20) + rp = RadiolysisParams(Nr = 40, Ddot_R = 1.0, c_ext = 1.0, + k_ads = k_ads, k_red = k_red, basis_gate_ack = true) + _, _, _, ctraj = run_simulation_coupled(params, rp, 100; seed = 42) + export_figures(ctraj, ctraj, params; outdir) + return ctraj +end + +base_dir = mktempdir(; cleanup = false) +pert_dir = mktempdir(; cleanup = false) +base = render(base_dir) +pert = render(pert_dir; k_ads = 0.5, k_red = 0.2) + +series(ct, f) = [f(cs) for cs in ct] +fig3_invariant = series(base, cs -> cs.m) == series(pert, cs -> cs.m) && + series(base, cs -> cs.P_eff) == series(pert, cs -> cs.P_eff) +fig4_moves = series(base, cs -> cs.c_wall) != series(pert, cs -> cs.c_wall) + +@printf("\n fig3 series invariant to 10x uptake : %s (required true)\n", fig3_invariant) +@printf(" fig4 series moves under 10x uptake : %s (required true -- the control must bite)\n", + fig4_moves) + +fig3_invariant || error("REFUSING TO COPY: m or P_eff moved with the gated basis, so fig3 " * + "does record a basis-dependent quantity and the exemption is void.") +fig4_moves || error("REFUSING TO COPY: the control did not bite -- c_wall was unchanged " * + "by a 10x uptake perturbation, so it proves nothing about fig3.") + +for ext in ("pdf", "png") + cp(joinpath(base_dir, "$NAME.$ext"), joinpath(OUT, "$NAME.$ext"); force = true) +end +rm(base_dir; recursive = true); rm(pert_dir; recursive = true) + +@printf("\n copied %s.{pdf,png} into %s\n", NAME, OUT) +@printf(" m(t) = %s\n", round.(series(base, cs -> cs.m), digits = 4)) +@printf(" P_eff/P0 = %s\n", + round.(series(base, cs -> cs.P_eff) ./ series(base, cs -> cs.P_eff)[1], digits = 4)) +println("\n Now refresh the sidecars:") +println(" cd preprint/figures") +println(" pdftotext -layout $NAME.pdf $NAME.txt") +println(" sha256sum $NAME.pdf | cut -d' ' -f1 > $NAME.sha256") diff --git a/tests/decided_moves_tests.jl b/tests/decided_moves_tests.jl new file mode 100644 index 0000000..c5efe89 --- /dev/null +++ b/tests/decided_moves_tests.jl @@ -0,0 +1,49 @@ +# ---------- Table 4 is re-executable, and this is what executes it ---------- +# +# The table was published from a configuration no entry point reproduced: +# `run_simulation` and `run_simulation_coupled` both seed MersenneTwister and +# give 14281 accepted moves at 100 MCS against the table's 16037. The run came +# from the idiom in delta_h_decomposition.jl -- `mcs_step!` by hand with +# Random.Xoshiro(seed) and `update_melanin!` each sweep -- and nothing recorded +# it, so for two weeks the table could not be checked against anything. +# PP-62-11 records that; decided_moves.jl discharges it; this tier is what makes +# the discharge automatic rather than something a person has to remember to run. +# +# ONE SOURCE FOR THE NUMBERS. The expected counts are read out of the script's +# own PUBLISHED table rather than restated here. A second copy would let the +# script and the suite drift and each look green. + +include(joinpath(REPO, "decided_moves.jl")) # defines functions only; its + # PROGRAM_FILE guard does not fire + +@testset "Table 4 reproduces from the shipped entry point" begin + @test isfile(joinpath(REPO, "decided_moves.jl")) + @test !isempty(PUBLISHED) + + for ((seed, n_mcs), expected) in sort(collect(PUBLISHED)) + got = decided_moves(SR, seed, n_mcs) + @test got == expected + end + + # THE TWO NUMBERS THE VERSION 1.2 CORRECTION MOVED, pinned directly, because + # they are the ones a regression would quietly restore. `rad` is the fourth + # label and 206042 is the denominator PP-62-04 published. + rad_idx = findfirst(==(:rad), SR.DRIVER_LABELS) + @test rad_idx == 4 + at400 = [PUBLISHED[(s, 400)] for s in (42, 43, 44)] + @test sum(sum(c) for c in at400) == 206_042 + @test sum(c[rad_idx] for c in at400) == 1 # NOT zero -- seed 44 carries it + + # ...and the control: the comparison must be able to fail. MersenneTwister + # is what every shipped run_simulation* path uses, and it is precisely what + # does NOT reproduce the table. + p = SR.CPMParams(N = 40, n_cells_per_species = 6) + st = SR.init_state(p; seed = 42) + d = SR.DriverCounts(p.N) + rng = Random.MersenneTwister(42) + for _ in 1:100 + SR.mcs_step!(st, rng; driver = d) + SR.update_melanin!(st) + end + @test sum(vec(sum(d.counts, dims = (1, 2, 3)))) != sum(PUBLISHED[(42, 100)]) +end diff --git a/tests/delta_h_decomposition.jl b/tests/delta_h_decomposition.jl new file mode 100644 index 0000000..1600387 --- /dev/null +++ b/tests/delta_h_decomposition.jl @@ -0,0 +1,175 @@ +# The four Hamiltonian terms, separately — and the proof that splitting them out +# changed nothing. +# +# `compute_delta_H` always computed ΔH_adh, ΔH_vol, ΔH_rad and ΔH_mel as four +# locals and discarded them on its return line. `compute_delta_H_terms` is that +# body with the sum removed; `compute_delta_H` now adds its fields back in the +# same order. The claim is bit-identity, and the claim is not free: summation +# order is OBSERVABLE here, so "same operands, same order" is doing work rather +# than describing an associativity that would hold anyway. +# +# contract_csv.jl covers the trajectory, and it has a floor: perturbing the +# melanin coefficient by one part in 5e6 leaves its CSV byte-identical, because +# a ~4e-8 shift in exp(-ΔH/T) has to straddle one of ~6.4M uniform draws to show +# up at all. That is fine for what it guards and useless for this: a refactor of +# the acceptance arithmetic needs an exact check, not a probabilistic one. + +using Random # runtests.jl brings in Test only; the sandbox module has its + # own Random and this file must not borrow from it. + +function _stepped_state(; seed = 7, mcs = 3) + p = SR.CPMParams(N = 20, n_cells_per_species = 3) + st = SR.init_state(p; seed = seed) + rng = Random.Xoshiro(seed) + # Melanin starts at zero everywhere and only grows through update_melanin!. + # Without these steps ΔH_mel is 0.0 on every sampled move and the melanin + # branch is dead surface a passing test would walk straight over. + for _ in 1:mcs + SR.mcs_step!(st, rng) + SR.update_melanin!(st) + end + return p, st, rng +end + +function _sample_moves(p, st, rng, n_draws) + out = NTuple{6, Int}[] + for _ in 1:n_draws + sx, sy, sz = rand(rng, 1:p.N), rand(rng, 1:p.N), rand(rng, 1:p.N) + st.interior[sx, sy, sz] || continue + d = SR.NEIGHBORS_26[rand(rng, 1:26)] + tx, ty, tz = sx + d[1], sy + d[2], sz + d[3] + (1 <= tx <= p.N && 1 <= ty <= p.N && 1 <= tz <= p.N) || continue + st.interior[tx, ty, tz] || continue + st.lattice[sx, sy, sz] == st.lattice[tx, ty, tz] && continue + push!(out, (sx, sy, sz, tx, ty, tz)) + end + return out +end + +@testset "ΔH decomposition" begin + p, st, rng = _stepped_state() + moves = _sample_moves(p, st, rng, 200_000) + @test length(moves) > 1_000 + + exact = 0 + reordered_differs = 0 + nonzero = zeros(Int, 4) + for (sx, sy, sz, tx, ty, tz) in moves + t = SR.compute_delta_H_terms(st, sx, sy, sz, tx, ty, tz) + total = SR.compute_delta_H(st, sx, sy, sz, tx, ty, tz) + # `===`, not `==`: -0.0 == 0.0 and NaN != NaN, and neither answer is + # "the bits are the same". + (t.adh + t.vol + t.rad + t.mel) === total && (exact += 1) + (t.mel + t.rad + t.vol + t.adh) === total || (reordered_differs += 1) + for (i, v) in enumerate((t.adh, t.vol, t.rad, t.mel)) + v != 0.0 && (nonzero[i] += 1) + end + end + + @test exact == length(moves) + + # THE CONTROL. If float addition were associative over these magnitudes the + # bit-identity claim above would hold no matter what order the split summed + # in, and this file would be asserting a tautology. It is not: adhesion and + # volume are O(1), the radiation term is O(1e-5), and the melanin term is + # O(0.5), so the small ones vanish or survive depending on where they land. + @test reordered_differs > 0 + + # SCOPE: the four branches of compute_delta_H_terms on the fixture below, not +# every path through the stepper. +# ALL FOUR BRANCHES MUST BE EXERCISED. A decomposition test that never sees + # a nonzero melanin term proves nothing about the melanin term, and the + # first version of this file did exactly that. + for (i, name) in enumerate(("adh", "vol", "rad", "mel")) + @test nonzero[i] > 0 + end +end + +@testset "decisive labelling" begin + T = 5.0 + mk(a, v, r, m) = (adh = a, vol = v, rad = r, mel = m) + + # --- the ΔH <= 0 branch: no draw was taken, u is NaN --------------------- + # Removing the -2.0 adhesion term would put ΔH at +1.0, so the certainty is + # gone -- but nothing here shows the move would have been REJECTED, and + # saying so would need a number nobody drew. + @test SR.decisive_label(mk(-2.0, 1.0, 0.0, 0.0), -1.0, NaN, T) == + SR.DRIVER_CONTINGENT + # Every single removal still leaves ΔH <= 0: the move was never close. + @test SR.decisive_label(mk(-5.0, -3.0, 0.0, 0.0), -8.0, NaN, T) == + SR.DRIVER_NONE + + # --- the ΔH > 0 branch: a draw exists, so nothing is contingent ---------- + # ΔH = 0.5, T = 5 -> threshold exp(-0.1) = 0.9048. u = 0.90 clears it. + # Removing the -1.0 melanin term gives ΔH' = 1.5 -> exp(-0.3) = 0.7408, + # which u does not clear. Melanin decided this move. + @test SR.decisive_label(mk(1.5, 0.0, 0.0, -1.0), 0.5, 0.90, T) == 0x05 + # A term that HURT cannot be decisive: removing it only lowers ΔH. + @test SR.decisive_label(mk(0.5, 0.0, 0.0, 0.0), 0.5, 0.90, T) == + SR.DRIVER_NONE + # Two terms each individually decisive. + @test SR.decisive_label(mk(2.5, 0.0, -1.0, -1.0), 0.5, 0.90, T) == + SR.DRIVER_MULTIPLE + + # --- THE DISJOINTNESS IS AN ASSERTION, NOT A REMARK --------------------- + # If `contingent` ever appeared in the drawn branch, or a named term in the + # undrawn one, the label would be answering two different questions under + # one name and the colour key would be meaningless. + rng = Random.Xoshiro(3) + for _ in 1:20_000 + t = mk(randn(rng) * 2, randn(rng) * 2, randn(rng) * 1e-4, + randn(rng) * 0.7) + ΔH = t.adh + t.vol + t.rad + t.mel + if ΔH <= 0 + @test SR.decisive_label(t, ΔH, NaN, T) in + (SR.DRIVER_NONE, SR.DRIVER_CONTINGENT) + else + u = rand(rng) * exp(-ΔH / T) # accepted by construction + lab = SR.decisive_label(t, ΔH, u, T) + @test lab != SR.DRIVER_CONTINGENT + end + end + + # --- 0 IS NOT `none` ---------------------------------------------------- + d = SR.DriverCounts(4) + SR.record_driver!(d, 2, 2, 2, SR.DRIVER_NONE) + m = SR.modal_driver(d) + @test m[2, 2, 2] == SR.DRIVER_NONE + @test m[1, 1, 1] == 0x00 # never touched + @test SR.n_accepted(d)[2, 2, 2] == 1 + @test SR.n_accepted(d)[1, 1, 1] == 0 +end + +@testset "the driver tally is inert" begin + # THE INSTRUMENTATION MUST NOT MOVE THE TRAJECTORY. `u` is now kept rather + # than discarded and the ternary is evaluated where an if/else was, so the + # generator must still be consulted on exactly the moves it was before. + # contract_csv.jl covers this at 100 MCS with its own floor; this covers it + # directly, on the lattice itself, with and without the tally attached. + function run(driver) + p = SR.CPMParams(N = 16, n_cells_per_species = 3) + st = SR.init_state(p; seed = 5) + rng = Random.Xoshiro(5) + for _ in 1:6 + SR.mcs_step!(st, rng; driver = driver === nothing ? nothing : + SR.DriverCounts(p.N)) + SR.update_melanin!(st) + end + return copy(st.lattice), copy(st.melanin) + end + bare_lat, bare_mel = run(nothing) + tally_lat, tally_mel = run(:on) + @test bare_lat == tally_lat + @test bare_mel == tally_mel + + # THE CONTROL: the comparison must be capable of failing. A different seed + # must produce a different lattice, or the equality above is vacuous. + p = SR.CPMParams(N = 16, n_cells_per_species = 3) + other = SR.init_state(p; seed = 6) + rng = Random.Xoshiro(6) + for _ in 1:6 + SR.mcs_step!(other, rng) + SR.update_melanin!(other) + end + @test other.lattice != bare_lat +end diff --git a/tests/jacc_parity_tests.jl b/tests/jacc_parity_tests.jl new file mode 100644 index 0000000..6d7c3fa --- /dev/null +++ b/tests/jacc_parity_tests.jl @@ -0,0 +1,234 @@ +# ---------- Checkerboard parity: the measurement nothing else makes --------- +# +# The port is an 8-color (2x2x2) checkerboard sweep. The characteristic defect +# of a checkerboard decomposition is a parity-correlated bias in accepted moves, +# and BOTH existing guards are blind to it: `jacc_port_tests.jl` compares +# kernels on identical inputs, so it passes when both kernels carry the same +# artifact, and `fixtures/serial_seed42.csv` pins the SERIAL stream, which has +# no sublattices at all. +# +# WHY A DISTRIBUTION AND NOT A MAP. d404438 refused a spatial map for the +# decisive-label work -- at 400 MCS only 6157 of 64000 voxels received any +# accepted move, median 3, so a mode over three samples is noise. The same +# argument applies here and the conclusion is stronger: a parity bias is a +# GLOBAL count comparison, so the map was never the instrument for it. +# +# WHY THE TABLE IS CONDITIONED ON OPPORTUNITY. A uniform null over eight +# classes assumes equal opportunity per class, and the kernel's early returns +# (wall, same-sigma, medium-into-medium, out-of-bounds) are all +# geometry-dependent -- an aggregate at an arbitrary lattice position has no +# reason to spread them evenly over a 2x2x2 decomposition. So the null "equal +# ACCEPTS per class" is false by construction and would report the shape of the +# domain as a decomposition artifact. `st` carries the denominator: a 2x8 +# accepted/rejected contingency table asks about the acceptance RATE per class, +# which is the question. +# +# WHY RANDOM PERMUTATIONS AND NOT REVERSAL. `for c in color_order` is +# sequential and `vols` accumulates across passes (delta_H reads vols at :167 +# while cpm_color! mutates it at :208 -- the documented `ponytail:` staleness), +# so the FIRST pass evaluates against sweep-start volumes and the LAST against +# volumes moved by seven passes. That is a deterministic, parity-correlated +# acceptance difference with nothing to do with the decomposition, and `c` +# indexes both spatial class and sequence position. Reversal maps +# position(c) = 7-c, which separates a MONOTONIC position effect but is blind to +# any position effect symmetric about the midpoint -- one transformation, one +# invariance. Random permutations drop the assumption about the effect's shape. +# +# WHY EFFECT SIZE AND NOT CHI-SQUARE. chi2 power scales with n and n here is +# 1e5-1e7 evaluated proposals, so a fraction-of-a-percent deviation clears any +# fixed critical value; a chi2 threshold would test "is there any asymmetry at +# all", and there always is. Worse, the cells are not independent -- an accepted +# move changes the lattice for every later pass -- so the nominal null does not +# apply and over-dispersion is expected from autocorrelation alone. Cramer's V +# and the max per-class rate deviation are the thresholds; chi2 and n are +# reported beside them and never asserted alone. +# +# basis_gate_ack: this file records NO radiodialysis quantity -- it records CPM +# acceptance counts. In the port, `delta_H` reads lat/vols/spec/J/beta/melc/rad/ +# mel and never `nut`, and `melanin_k!` never reads it either, so the gated +# basis cannot reach an accepted move. Same claim as validate_serial.jl's, and +# the 10x-uptake testset below is what holds it to account. + +using Random + +const JACC_PARITY_PATH = joinpath(REPO, "biofilms_potts_jacc.jl") + +# Spatial class DERIVED from target coordinates, never stored: deriving it is +# what keeps the class spatial when `color_order` permutes the pass sequence. +parity_class(tx, ty, tz) = (tx - 1) % 2 + 2 * ((ty - 1) % 2) + 4 * ((tz - 1) % 2) + +# 2x8 accepted/rejected contingency statistics, conditioned on opportunity. +function parity_stats(a::Vector{Int}, e::Vector{Int}) + n = sum(e) + p = n == 0 ? 0.0 : sum(a) / n + χ2 = 0.0 + for c in 1:8, (o, ex) in ((a[c], e[c] * p), (e[c] - a[c], e[c] * (1 - p))) + ex > 0 && (χ2 += (o - ex)^2 / ex) + end + rates = [e[c] == 0 ? NaN : a[c] / e[c] for c in 1:8] + maxdev = p == 0 ? Inf : maximum(abs.(rates .- p)) / p + return (; χ2, n, V = n == 0 ? Inf : sqrt(χ2 / n), rate = p, maxdev, rates) +end + +const ParityPort = Module(:JaccParityPort) +Base.eval(ParityPort, :(using Random, Statistics, Printf)) +Base.include_string(ParityPort, read(JACC_PARITY_PATH, String), + "biofilms_potts_jacc.jl") + +# Separate top-level statement: `include` advances world age between them, so +# everything below can call into the freshly defined module directly. +const P_RP = Base.eval(ParityPort, :RadiolysisParams) +const P_RUN = Base.eval(ParityPort, :run_coupled) +const P_INIT = Base.eval(ParityPort, :init_host) +const P_JMAT = Base.eval(ParityPort, :build_J_matrix) +const P_KERN = Base.eval(ParityPort, :cpm_color!) +const P_ARR = Base.eval(ParityPort, :(JACC.array)) +const P_HOST = Base.eval(ParityPort, :(JACC.to_host)) +const P_PFOR = Base.eval(ParityPort, :(JACC.parallel_for)) +const P_BETA = Base.eval(ParityPort, :BETA_ION) +const P_MELC = Base.eval(ParityPort, :MEL_COEF) + +const IDENTITY = collect(0:7) +const PERMS = ["identity" => IDENTITY, + "perm1" => shuffle(MersenneTwister(1), collect(0:7)), + "perm2" => shuffle(MersenneTwister(2), collect(0:7))] + +# OBSERVED, NOT DERIVED. T_cpm = 5.0f0 gives no derivable band, and a bound read +# off the quantity's domain -- an acceptance rate is a probability, so +# `0 < rate < 1` cannot fail for the reason it exists -- would report green at +# 1e-9. These come from the spread over 3 seeds x 3 orderings at N=20/50 MCS on +# the threads backend (V 0.0050-0.0111, maxdev 0.017-0.049, rate 0.171-0.191), +# widened ~2x. They are settings-specific: at N=40 the rate is ~0.05, because +# V_target is fixed while lattice volume is not. +const V_MAX = 0.025 +const MAXDEV_MAX = 0.12 +const RATE_BAND = (0.14, 0.23) + +mk_rp(; kw...) = P_RP(; Nr = 40, Ddot_R = 1.0, c_ext = 1.0, + basis_gate_ack = true, kw...) + +# Per-sweep (accepted, evaluated) per spatial class, plus the finiteness guard +# on the discriminator. +function run_tables(seed, order; N = 20, n_mcs = 50, rp = mk_rp()) + A = zeros(Int, n_mcs, 8); E = zeros(Int, n_mcs, 8); nonfinite = 0 + cb = (mcs, st, dh) -> begin + nonfinite += count(!isfinite, @view dh[st .!= 0]) + for tz in 1:N, ty in 1:N, tx in 1:N + s = st[tx, ty, tz]; s == 0 && continue + c = parity_class(tx, ty, tz) + 1 + E[mcs, c] += 1; s == UInt8(2) && (A[mcs, c] += 1) + end + end + P_RUN(; seed, n_mcs, N, rp, verbose = false, + color_order = order, on_sweep = cb) + return A, E, nonfinite +end + +pooled(A, E, rows) = parity_stats(vec(sum(A[rows, :], dims = 1)), + vec(sum(E[rows, :], dims = 1))) + +@testset "JACC checkerboard parity" begin + @testset "the class encode/decode pair agrees with the kernel" begin + # The kernel encodes c -> (ox,oy,oz) -> tx = 2*(i-1)+ox+1; this file + # decodes coordinates back to a class. Two mappings maintained apart, + # and the class cannot be read back from the kernel because deriving it + # is what makes it survive permutation. So assert the round trip against + # the REAL kernel: one color pass in isolation, every site it touched + # must decode to exactly the color that was passed. + N = 20; Nh = N ÷ 2 + lat_h, spec_h, vols_h, rad_h, mel_h, _ = + P_INIT(N, 6, 42, 1.0, 2.0, 1.0) + lat = P_ARR(lat_h); vols = P_ARR(vols_h); spec = P_ARR(spec_h) + J = P_ARR(P_JMAT()) + βv = P_ARR(P_BETA); melc = P_ARR(P_MELC) + rad = P_ARR(rad_h); mel = P_ARR(mel_h) + st = P_ARR(zeros(UInt8, N, N, N)); dh = P_ARR(zeros(Float32, N, N, N)) + for c in 0:7 + fill!(st, UInt8(0)) + P_PFOR((Nh, Nh, Nh), P_KERN, + lat, vols, spec, J, βv, melc, rad, mel, + Int32(c & 1), Int32((c >> 1) & 1), Int32((c >> 2) & 1), + UInt64(7), UInt64(c), Int32(N), 10.0f0, Int32(120), 5.0f0, st, dh) + st_h = P_HOST(st) + touched = [(tx, ty, tz) for tz in 1:N, ty in 1:N, tx in 1:N + if st_h[tx, ty, tz] != 0] + @test !isempty(touched) + @test all(t -> parity_class(t...) == c, touched) + end + end + + @testset "color_order must be a permutation of 0:7" begin + # Without this guard [0,0,1,2,3,4,5,6] double-updates one class, never + # updates another, and yields a silently wrong simulation. + for bad in ([0, 0, 1, 2, 3, 4, 5, 6], collect(0:6), collect(1:8)) + @test_throws AssertionError P_RUN(; N = 20, n_mcs = 1, seed = 42, + rp = mk_rp(), verbose = false, color_order = bad) + end + end + + @testset "per-sweep reset, discriminator, and all eight classes" begin + A, E, nonfinite = run_tables(42, IDENTITY; n_mcs = 3) + # A per-COLOR-PASS reset would leave only the last color populated and + # would look perfectly clean; this is what catches it. + @test all(>(0), sum(E, dims = 1)) + # `dh` carries no sentinel, so it must be finite wherever `st` says a + # proposal was evaluated -- otherwise "never proposed" and "NaN" collapse + # and every denominator below silently shrinks. + @test nonfinite == 0 + end + + @testset "the guard can fire" begin + # A threshold nobody has seen fail is not a guard. Synthetic table with + # one class held 20% below the others, at the n this suite actually + # reaches: it must clear BOTH thresholds. + e = fill(17_000, 8); a = round.(Int, e .* 0.185); a[4] = round(Int, 17_000 * 0.148) + s = parity_stats(a, e) + @test s.V > V_MAX + @test s.maxdev > MAXDEV_MAX + end + + @testset "no decomposition artifact across seeds and orderings" begin + results = Pair{String,Any}[] + for seed in (42, 43, 44), (name, ord) in PERMS + A, E, _ = run_tables(seed, ord) + s = pooled(A, E, 1:size(A, 1)) + push!(results, "seed $seed $name" => s) + + @test RATE_BAND[1] < s.rate < RATE_BAND[2] + @test s.V < V_MAX + @test s.maxdev < MAXDEV_MAX + + # Stationarity before any pooled number is believed: an + # initialization transient and a persistent bias are different + # findings and only the second is a decomposition artifact. + h = size(A, 1) ÷ 2 + s1 = pooled(A, E, 1:h); s2 = pooled(A, E, h+1:size(A, 1)) + @test s1.V < V_MAX && s2.V < V_MAX + end + + # Reported, not asserted: below V_MAX the imbalance is not attributable + # to either spatial class or pass position, and the permuted run is a + # DIFFERENT trajectory rather than the same system observed differently, + # so a third outcome -- matching neither -- is reachable and is what the + # measured data shows. If a threshold above ever fails, these two rows + # are what attributes it: a bias tracking spatial class under every + # ordering is a real decomposition artifact; one tracking pass position + # is the `vols` staleness and is not a finding about the checkerboard. + for (label, s) in results + @info "parity" label n=s.n rate=round(s.rate, digits=5) V=round(s.V, digits=5) χ2=round(s.χ2, digits=1) maxdev=round(s.maxdev, digits=4) rates=round.(s.rates, digits=4) + end + end + + @testset "the exemption's claim is true: acceptance does not see the basis" begin + # basis_gate_ack above is acknowledged on the grounds that acceptance + # counts cannot depend on the gated biomass basis. That is a CLAIM. The + # basis enters only through `uptake`/`nut`, so 10x the uptake constants + # must leave the contingency table byte-identical. The day acceptance + # reads the nutrient field, this fails and the exemption is re-argued. + base = run_tables(42, IDENTITY; n_mcs = 10) + pert = run_tables(42, IDENTITY; n_mcs = 10, + rp = mk_rp(k_ads = 0.5, k_red = 0.2)) + @test base[1] == pert[1] + @test base[2] == pert[2] + end +end diff --git a/tests/manuscript_claims_tests.jl b/tests/manuscript_claims_tests.jl index ccae656..4d35c8e 100644 --- a/tests/manuscript_claims_tests.jl +++ b/tests/manuscript_claims_tests.jl @@ -108,9 +108,14 @@ end Every `\\bibitem{key}` not reached by any `\\cite{...}` (comma-separated lists included). """ function _unused_bibitem_keys(tex_text::AbstractString) + # STRIP LaTeX COMMENTS FIRST. A `\cite{foo}` inside a `%` comment counted as a + # citation, so an orphan could be hidden behind one and never reported. The + # Python side's normalise_markup has stripped `%` to end-of-line for exactly + # this reason since it was written; this function did not. + stripped = replace(tex_text, r"(?m)(? "") defined = [m.captures[1] for m in eachmatch(r"\\bibitem\{([^}]*)\}", tex_text)] cited = Set{String}() - for m in eachmatch(r"\\cite\{([^}]*)\}", tex_text) + for m in eachmatch(r"\\cite\{([^}]*)\}", stripped) for key in split(m.captures[1], ',') push!(cited, strip(key)) end @@ -143,9 +148,126 @@ let # in both directions; @test_broken stays alongside it so reaching zero still reports an # UNEXPECTED PASS and forces someone to delete this whole block rather than let the # finding rot. - KNOWN_UNUSED = 15 - @test length(unused) == KNOWN_UNUSED - @test_broken isempty(unused) + # TWO CHANGES HERE FOR TWO DIFFERENT REASONS, AND THE COMMIT MUST NOT CONFLATE + # THEM. + # + # (1) THE ASSERTION IS ON THE SET, NOT ON A COUNT, AND THAT IS A REPAIR + # INDEPENDENT OF ANY NUMBER MOVING. `_unused_bibitem_keys` already RETURNS a + # Set and the previous assertion threw it away to compare `length(...)` to a + # constant. Cite one entry and orphan another in the same commit and the count + # is unchanged and the test passes -- membership changed and nothing could see + # it. That is the count-where-a-set-is-meant shape this repository has now had + # caught three times, sitting in the guard for the bibliography. + # + # (2) The membership shrank from 15 to 13 because `turick2011` and + # `casadevall2017` are now cited in section 2.6 -- as content, not as + # bibliography tidying: the first is the one positive measurement in that + # section and it runs against the mechanism, the second is the proponents + # conceding the mechanism is undiscovered. A future shrink because somebody + # deleted a bibitem to clear a red is a DIFFERENT event, and only the named + # form below can tell the two apart. + # THE SET CARRIES A CLASSIFICATION, NOT A LIST OF KEYS, so that a change in + # membership says WHICH KIND of change it was. A bare key list makes "cited a + # gap" and "deleted a bibitem to clear a red" the same edit. Resolved against + # Crossref with PubMed/Europe PMC as second index on 2026-08-31; every entry + # below had its DOI checked, not assumed, because four DOIs in this + # bibliography have now resolved to unrelated papers or to nothing. + # + # (i) SUPPORTS AN UNCITED CLAIM -- a real gap, with the sentence identified. + # Citing these is a manuscript decision, not bibliography tidying, and two + # of them are corrections rather than footnotes. NOT applied here. + # Four entries left this set on 2026-08-31 by being CITED, each for its own + # reason and none of them bibliography tidying: alpkvist2007 and eberl2001 + # because the sentence that misattributed their content to xavier2005 was + # corrected, and khajo2011 and malo2018 because section 2.6 cannot rest its + # argument on naming what runs against it while omitting the two positives its + # own audit files. + # graner1992 LEFT THIS SET ON 2026-09-01 BY BEING CITED, on the Methods sentence + # describing the copy-attempt construction. An origin cite for a named formalism is a + # PROVENANCE claim -- the paper's title states the construction -- so it needed no + # endpoint reading, which is what separated it from the two below. + UNUSED_GAP = Set([ + # robertson2012's ENTRY IN THIS SET USED TO SAY "primary source for L228-229", + # AND THAT WAS WRONG. Read from PubMed on 2026-09-01 (PMID 23139812), Robertson + # 2012 reports that ionizing radiation "enhanced cell growth by increasing cell + # division and cell size" and that low-dose exposure "significantly increased + # survivability of BOTH the wild-type and the wdpks1 mutant" -- a positive growth + # result, not a null -- with ribosomal biogenesis up in the irradiated wild type + # but NOT in the mutant, a melanin-DEPENDENT difference. L228-229 claims comparable + # NULL results across several modalities, and its actual primaries are the three + # studies in radiotrophic_compatibility_audit.md 9.2 (GSE142318, GSE152116, + # Microbiol Spectr 2023), reached through audit2026. Citing robertson2012 there + # would have inserted a growth-enhancement paper as a null result: the section 2.6 + # defect, one key over, caught by reading the endpoint before writing the cite. + # robertson2012 LEFT THIS SET ON 2026-09-01 BY BEING CITED IN 2.1, after the FULL TEXT + # was read (PMC3490873) rather than the abstract. The abstract was not enough: it says + # "we confirmed that ionizing radiation enhanced cell growth", and whether that was + # measured or restated is the Khajo 2011 distinction. The Results heading settles it -- + # "Low Dose Ionizing Radiation Increases W. dermatitidis Growth Rate and Cell Size" -- + # and the same section reports the increase in the albino wdpks1 mutant too. So it is a + # positive growth result whose melanin attribution fails, which is what 2.1 needed and + # is NOT what L228-229 needed. Two readings, two different dispositions. + # blasius1999 LEFT THIS SET ON 2026-09-01. Its gate was PP-T2-25's unapplied delete + # of the Table 2 omega_s row; that delete is now applied, so the gate is gone and the + # entry is cited in §2.3 where the Kuramoto lineage is discussed. Verified against + # CROSSREF before citing, not reconstructed from the key: 10.1038/20676 returns + # Blasius, Huppert & Stone (1999), Nature 399, 354-359, matching the bibitem on + # title, all three authors, container, year, volume and pages. The key could equally + # have been the Blasius boundary layer; reading it is what settled that. + ]) + # (ii) DRAFT RESIDUE and (iii) DELIBERATE CONTEXT are both EMPTY as of 2026-09-01: the + # six entries were DELETED from the bibliography, which is the disposition the + # classification already recorded for them. brim2000 and kazy2009 supported claims that + # are gone; battista1997, eisenman2012, lloyd2005 and newsome2014 supported no specific + # sentence. The sets stay named rather than being removed, so that a future entry + # arriving in either category lands somewhere that says what the category means. + UNUSED_RESIDUE = Set(String[]) + UNUSED_CONTEXT = Set(String[]) + KNOWN_UNUSED = union(UNUSED_GAP, UNUSED_RESIDUE, UNUSED_CONTEXT) + + if unused != KNOWN_UNUSED + for (name, s) in (("gap", UNUSED_GAP), ("residue", UNUSED_RESIDUE), + ("context", UNUSED_CONTEXT)) + left = setdiff(s, unused) + !isempty(left) && println(" no longer unused, was $name: ", + join(sort(collect(left)), ", ")) + end + println(" newly unused: ", + join(sort(collect(setdiff(unused, KNOWN_UNUSED))), ", ")) + end + @test unused == KNOWN_UNUSED + + # THE ASSERTION MUST CONSUME THE FINEST-GRAINED THING THE FUNCTION RETURNS. + # The line above compares the UNION, so moving graner1992 from UNUSED_GAP (a + # real citation gap) to UNUSED_CONTEXT ("dropping it loses nothing citable") + # -- opposite meanings -- leaves the union unchanged and the suite green. The + # categories were printed on mismatch and never compared, which made the + # classification documentation rather than an assertion, in the guard built + # so that a decrement could not hide a category change. + # + # THIRD INSTANCE OF ONE SHAPE: count-where-a-set-was-meant (this same + # assertion, earlier), subset-where-full-coverage-was-meant (the strict row + # parser in test_guide_citations.py), and now + # categories-printed-where-membership-was-meant. Every time the discriminating + # data was already computed and discarded at the assertion line. + @test isdisjoint(UNUSED_GAP, UNUSED_RESIDUE) + @test isdisjoint(UNUSED_GAP, UNUSED_CONTEXT) + @test isdisjoint(UNUSED_RESIDUE, UNUSED_CONTEXT) + @test "graner1992" ∉ UNUSED_GAP # cited 2026-09-01, so it must have LEFT the set + @test "robertson2012" ∉ UNUSED_GAP # cited 2026-09-01 in §2.1, after the FULL TEXT + @test "blasius1999" ∉ UNUSED_GAP # cited 2026-09-01, gate removed with PP-T2-25 + @test isempty(UNUSED_GAP) && isempty(UNUSED_RESIDUE) && isempty(UNUSED_CONTEXT) + + # FLIPPED FROM @test_broken ON 2026-09-01, AND THE TWO ARE NOT THE SAME CLAIM. + # @test_broken said "we expect orphans and want to be told when there are none" -- it + # tracked a goal. @test says "an uncited bibitem is a failure" -- it enforces a rule. + # THE COST OF THE STRICT FORM IS REAL AND IS BEING ACCEPTED DELIBERATELY: the natural + # workflow is add-the-bibitem-then-cite-it, and this makes that intermediate state red. + # That is the intended trade -- every entry now in the bibliography is cited, and the + # classification sets above exist to make a future exception a recorded decision rather + # than a silent one. Someone who needs the intermediate state adds the key to the right + # category set, which is a one-line edit that says which kind of orphan it is. + @test isempty(unused) end # ------------------------------------------------- planned feedback is unimplemented @@ -179,3 +301,413 @@ let # checked rather than asserted. @test !isempty(_scan(SIM_FILES, r"\bk_des\b"i)) end + +# ------------------------------------------- the exclusion, ASSERTED not inspected +# +# THE CHECK ABOVE IS SCOPED TO A CURATED NAME LIST, AND THAT CUTS BOTH WAYS. +# SIM_FILE_NAMES is nine hand-maintained entries, not a glob. So "X_max appears in +# no simulation file" stays green not only when no such file has one, but also when +# a file that has one simply is not on the list -- and nothing states which of those +# two is the case. That is the fixed-name-list hazard arriving from the far side: a +# guard passing for the wrong reason, which is the shape this file already records +# for the prose-broader-than-the-check defect one level up. +# +# It became live when analysis/henry_langmuir_bound.R was added. That producer +# necessarily contains X_max, q_max and Langmuir -- it exists to compare the forms +# -- and it is correctly outside SIM_FILE_NAMES because it is not a repo-root +# simulation source. But "correctly outside" was, until this block, a fact somebody +# confirmed once by reading, not a property anything enforced. +# +# So the enumeration is inverted: every file in the repository carrying any of the +# three terms must be on a DECLARED list. A new file gets added deliberately or the +# suite fails. The list is the claim; the scan is what checks it. +CEILING_VOCAB_ALLOWED = [ + # the manuscript states the forms and contrasts them -- section 3.12 + "preprint/modeling_radioresistance_and_radiotropic_fitness.tex", + "preprint/wan_meeting_handout.tex", + "calibration/tests/fixtures/wan_meeting_handout_prefix.tex", # its known-bad + # the register and the ledger record that the ceiling is absent + "data/calibration/suspended_isotherm_proposal.csv", + "data/calibration/sop_index.csv", + "data/claims_ledger.csv", + # the producer for PP-SORP-01, and this file's own assertions + "analysis/henry_langmuir_bound.R", + "tests/manuscript_claims_tests.jl", +] + +function _ceiling_vocab_files(root) + hits = String[] + for (dir, _, files) in walkdir(root) + occursin(joinpath(root, ".git"), dir) && continue + for f in files + p = joinpath(dir, f) + rel = relpath(p, root) + startswith(rel, ".git") && continue + text = try + read(p, String) + catch + continue # binary or unreadable: cannot carry the vocabulary as text + end + if occursin(r"\bX_max\b"i, text) || occursin(r"\bq_max\b"i, text) || + occursin(r"\bLangmuir\b"i, text) + push!(hits, replace(rel, '\\' => '/')) + end + end + end + sort(hits) +end + +@testset "every file carrying capacity-ceiling vocabulary is declared" begin + found = _ceiling_vocab_files(REPO) + @test found == sort(CEILING_VOCAB_ALLOWED) + + # CONTROL: the enumeration must be able to find a file that is not declared. + # Without this, an allow-list check passes identically when the walk is broken, + # which is the always-passes half of a mutation harness wearing a set equality. + planted = joinpath(REPO, "_ceiling_vocab_control.tmp") + try + write(planted, "q_max <- 1.0\n") + @test "_ceiling_vocab_control.tmp" in _ceiling_vocab_files(REPO) + @test _ceiling_vocab_files(REPO) != sort(CEILING_VOCAB_ALLOWED) + finally + rm(planted; force = true) + end + @test _ceiling_vocab_files(REPO) == sort(CEILING_VOCAB_ALLOWED) # restored +end + +# ---------------------------------------------- the acceptance path reads no nutrient +# +# §2.6 states that the CPM does not resolve carbon as a driver of the trajectory. That +# rests on a COUNT taken on one day -- zero nutrient reads inside two function bodies -- +# and a counted zero expires exactly like the F_s grep above: nothing stops a later commit +# adding a nutrient term to the acceptance rule, at which point the manuscript's claim +# becomes false and the manuscript is the artifact that cannot notice. +# +# SCOPE, AS NARROW AS THE CODE ACTUALLY COUNTED. Not "the CPM ignores nutrients" and not +# "the field is unused" -- both are broader than what was checked and the second is FALSE. +# `state.nutrient` is initialised by `init_nutrient!`, integrated every step by +# `update_nutrient!`, serialised in export_checkpoint.jl, round-tripped in +# checkpoint_io_tests.jl and parity-checked in the JACC port. It is a live field that the +# ACCEPTANCE PATH does not consult, which is the claim, and calibration's +# spatial/time_observable.py records the same fact independently. + +"Every column-0 `function NAME(...)` in `path`, as name => body lines." +function _function_bodies(path::AbstractString) + lines = readlines(path) + out = Dict{String,Vector{String}}() + for (i, l) in enumerate(lines) + m = match(r"^function\s+([A-Za-z_][A-Za-z0-9_!]*)\(", l) + m === nothing && continue + j = findnext(x -> x == "end", lines, i) + j === nothing && continue + out[m[1]] = lines[i:j] + end + out +end + +"Body of `function NAME(` in `path`, up to the first column-0 `end`." +function _function_body(path::AbstractString, name::AbstractString) + lines = readlines(path) + i = findfirst(l -> startswith(l, "function $name("), lines) + i === nothing && error("no `function $name(` at column 0 in $path") + j = findnext(l -> l == "end", lines, i) + j === nothing && error("unterminated `function $name` in $path") + return lines[i:j] +end + +let + # A READ, not a mention: `state.nutrient` or `nutrient[...]`. A comment naming the + # field must not fail this, or the check becomes a trap for the next person who + # documents why the field is absent here. + reads = r"\.nutrient\b|\bnutrient\s*\["i + + # THE DETECTOR FINDS A PLANTED READ BEFORE IT IS TRUSTED TO FIND NONE. + planted = ["function f(x)", " C = state.nutrient", " y = nutrient[1, 2, 3]", "end"] + @test count(l -> occursin(reads, l), planted) == 2 + # ...and a mention that is not a read does not trip it. + @test !occursin(reads, " # never reads the nutrient field") + + serial = joinpath(REPO, "biofilms_potts.jl") + + # THE SENTENCE SAYS "THE TRAJECTORY", SO THE CHECK COVERS EVERY FUNCTION THAT MOVES + # THE LATTICE, not just the two whose bodies were first counted. Enforcing at one + # scope while stating at another is the defect the F_s check above exists for. + writes_lattice = r"^\s*(lat|lattice)\[[^\]]*\]\s*=[^=]" + movers = String[] + for (name, body) in _function_bodies(serial) + any(l -> occursin(writes_lattice, l), body) && push!(movers, name) + end + + # The detector must find a planted write before the set it produces is trusted. + @test occursin(writes_lattice, " lat[tx, ty, tz] = sigma") + @test !occursin(writes_lattice, " sigma = lat[tx, ty, tz]") # a read is not a write + + # AND THE SET ITSELF IS PINNED. A new lattice mover appearing fails here, which forces + # someone to check it rather than letting it inherit a claim made before it existed. + # THIS LIST WAS WRONG WHEN WRITTEN BY HAND: it named divide_cell! and missed + # place_cell!. divide_cell! mutates the lattice THROUGH place_cell! rather than + # directly, so a manual grep for the writers produced a set that was wrong in both + # directions, and this assertion is what said so on its first run. + @test sort(movers) == sort(["compute_delta_H_terms", "init_state", "mcs_step!", + "place_cell!"]) + + for fn in movers + body = _function_body(serial, fn) + @test !isempty(body) + @test isempty(filter(l -> occursin(reads, l), body)) + end + + # THE FIELD IS LIVE, WHICH IS WHY THE CLAIM IS ABOUT THE ACCEPTANCE PATH AND NOT ABOUT + # THE FIELD. If this ever came back empty the §2.6 sentence would be describing dead + # state and would need rewriting, not re-passing. + integrator = _function_body(serial, "update_nutrient!") + @test any(l -> occursin(reads, l), integrator) +end + +# ------------------------------------------------- the phase-locking thread agrees with itself +# +# SCOPE: PROSE paragraphs naming the phase-locking machinery -- H_kNN, Gamma_s, phi_s, +# "phase-lock" -- in the manuscript .tex. NOT the symbol table row, NOT the equations that +# define these symbols: a definition is not a claim that the model runs it, and widening this +# into the equation blocks would make every definition require a disclaimer. That exclusion is +# written here rather than left implicit, because "prose only" alone reads as a preference and +# the next person would widen it. +# +# SECTION 2.5 SAID THE FUNCTIONAL DID THE WORK; SECTIONS 3.2 AND 5 SAID IT WAS NEVER RUN, AND +# NOTHING COMPARED THEM. That was fixed in 567b135 -- and the guard written there matched +# `H_kNN` alone, so it did not catch §2.3 saying the Gamma_s kernel "draws directly on this +# tradition, extending it to multispecies microbial communities". Same defect, different +# symbol, invisible to a guard scoped to the symbol that prompted it. A GUARD WRITTEN FROM ONE +# INSTANCE MATCHES ONE INSTANCE; this one is scoped to the class. +# +# THE ASSERTION IS AGREEMENT, NOT ABSENCE OF A PHRASE. Banning a withdrawn verb is a guard the +# next synonym walks past. What must hold is that any paragraph claiming something about this +# machinery also says what its status is, in the vocabulary §3.2 and §5 already settled. +# +# PARAGRAPH-LEVEL, NOT SENTENCE-LEVEL, and that is load-bearing: §2.3 names Gamma_s in one +# sentence and carries "It remains unexercised" in the next, so a sentence-split check would +# fail on correct prose and invite someone to weaken it. +@testset "every prose paragraph naming the phase-locking machinery states its status" begin + tex = read(joinpath(REPO, "preprint", + "modeling_radioresistance_and_radiotropic_fitness.tex"), String) + + names = r"Hamiltonian kNN|H_\{\\mathrm\{kNN\}\}|\\Gamma_s|\\phi_s|phase-lock" + + # MASK NON-PROSE FIRST, THEN MAP EACH PROSE HIT TO ITS SUBSECTION. + # A definition is not a claim: the symbol-table row "$\Gamma_s$ & Phase-locked kernel" and + # the equations that define these symbols must not require a disclaimer, or every + # definition in the paper would. Masking preserves offsets so the containing subsection + # can still be found. + # + # SUBSECTION GRANULARITY IS A CORRECTION MADE AFTER MEASURING. A first version checked + # PARAGRAPHS and produced two false positives -- §3.4's leapfrog paragraph, whose + # subsection carries "specification, not method" in a SIBLING paragraph, and §7.4's + # structural-limits paragraph, which disclaims correctly with "absent from the executed + # simulations". The manuscript marks status at subsection level, so a paragraph-level + # guard cries wolf, and a guard that cries wolf gets weakened rather than obeyed. + # The third thing it flagged was REAL and is why the widening happened: §2.2 said "Our + # PSDE framework extends these precedents by incorporating ... phase-locking dynamics + # that couple species interactions to external radiation fields" -- a fourth instance. + masked = replace(tex, + r"\\begin\{(equation|align|array|tabular)\}.*?\\end\{\1\}"s => (m -> " "^length(m))) + masked = join([occursin(r"&.*\\\\", l) ? " "^length(l) : l + for l in split(masked, "\n")], "\n") + + heads = [m.offset for m in eachmatch(r"\\(sub)?section\{", tex)] + owning(i) = maximum(h for h in heads if h <= i) + function body(h) + nxt = filter(x -> x > h, heads) + tex[h:(isempty(nxt) ? lastindex(tex) : prevind(tex, minimum(nxt)))] + end + + hits = unique(owning(m.offset) for m in eachmatch(names, masked)) + @test !isempty(hits) # the detector must find something to be trusted + @test length(hits) >= 4 # §2.2, §2.3, §3.2, §3.8, §5 at minimum + + # THE VOCABULARY IS DISCOVERED, NOT INVENTED: every phrase below was read out of a + # subsection that already disclaims correctly. Adding one is a claim that the manuscript + # uses it as a disclaimer, so it must be quoted from the manuscript. + # ONE LITERAL, NOT THREE JOINED BY `*`. Regex `*` in Julia CONCATENATES: r"a|b" * r"c|d" + # becomes (?:a|b)(?:c|d), which requires both in sequence. The first version of this line + # was three alternation groups multiplied together and therefore matched almost nothing, + # reporting four subsections as undisclaimed when only one was. It failed loudly, but it + # failed as a FALSE POSITIVE -- the direction that gets a guard weakened. + status = r"unexercised|unrepresentable|specified rather than implemented|specified but not|absent from the executed simulations|specification, not method|intended numerical treatment" + for h in hits + @test occursin(status, body(h)) + end + + @testset "the controls, one per symbol" begin + # Each name must be detected, or a widened matcher that finds nothing passes silently. + @test occursin(names, raw"Our phase-locking kernel $\Gamma_s(t,\mathbf{x})$ draws on") + @test occursin(names, raw"the $H_{\mathrm{kNN}}$ machinery") + @test occursin(names, raw"where $\phi_s(t) = \cos(\omega_s t - \theta_s)$") + @test occursin(names, "Hamiltonian kNN Decision Tree") + @test !occursin(names, "an unrelated paragraph about mesh convergence") + + # And the two withdrawn clauses must fail the status check, or it is decorative. + @test !occursin(status, "a perspective that our Hamiltonian kNN decision tree " * + "operationalizes quantitatively.") + @test !occursin(status, raw"Our phase-locking kernel $\Gamma_s(t,\mathbf{x})$ draws " * + "directly on this tradition, extending it to multispecies " * + "microbial communities.") + end +end + +# ------------------------------------------------- cross-references resolve, and are not numerals +# +# SCOPE: \ref and \label in the manuscript .tex, and literal `Section~` forms. +# +# FIGURE NUMERALS ARE NOW CHECKED TOO. They were excluded until 2026-09-01 with a reason and +# an owner -- PP-FIG-01, verdict `delete` -- and that exclusion expired when the row closed. +# AN EXCLUSION OUTLIVING ITS REASON IS THE STALE-RECORD FAMILY, so the comment is rewritten +# rather than left standing. +# +# PP-FIG-01 DESCRIBED A DEFECT THAT HAD ALREADY BEEN FIXED. Its notes record four PHANTOM +# figures in sections 6.1-6.4 aliasing onto the four real ones ("Figure 1 in section 6.1 +# RESOLVES TO THE CPM RADIAL-STRATIFICATION PLOT"), and its required_to_fix says "Delete or +# replace the references; the source files do not exist". Section 6 now contains zero literal +# figure references: the phantoms were removed in earlier work and the row was never updated. +# What remained were six literals in the Conclusion, all naming REAL figures, each verified +# against the caption and filename rather than against its position before conversion. That is +# how the __pycache__ exclusion went wrong: the reasoning was recorded somewhere the rule was +# not reached for. +# +# THE SECOND ASSERTION IS THE LOAD-BEARING ONE, BECAUSE CI CANNOT MAKE IT. +# manuscript-build runs `latexmk -pdf -interaction=nonstopmode -halt-on-error`. An undefined +# \ref is a LaTeX *warning*, not an error, so -halt-on-error does not stop, the job goes green, +# and the PDF ships the words "Section ??" -- a failure that looks like a typo to a reader and +# like success to the pipeline. Nothing else in the repository catches it. This file does. +@testset "section cross-references resolve, and none is a literal numeral" begin + tex = read(joinpath(REPO, "preprint", + "modeling_radioresistance_and_radiotropic_fitness.tex"), String) + + # Figures FLOAT, which makes the literal-numeral defect worse for them than for sections: + # a section number is one the author controls, but a figure number is wherever LaTeX put + # the float, so the prose can become wrong with nothing moving in the source. + numerals = [m.match for m in eachmatch(r"Section~[0-9]|Figures?~?\s?[0-9]", tex)] + @test isempty(numerals) + + labels = Set(m.captures[1] for m in eachmatch(r"\\label\{([^}]*)\}", tex)) + refs = Set(m.captures[1] for m in eachmatch(r"\\ref\{([^}]*)\}", tex)) + dangling = sort(collect(setdiff(refs, labels))) + @test isempty(dangling) # a dangling \ref renders as "??" and CI stays green + @test !isempty(refs) # non-vacuity: the detector must find references at all + + @testset "the controls, in both directions" begin + # Each assertion must fail on the form it was built from, or it is asserting over an + # empty set and would pass on any manuscript whatsoever. + pat = r"Section~[0-9]|Figures?~?\s?[0-9]" + for pre in ("see Section~3.8, which", "Figure~4 still carries", "Figures 1 and 2", + "retracts. Figure 2") + @test !isempty([m.match for m in eachmatch(pat, pre)]) + end + for post in (raw"see Section~\ref{sec:knn}, which", raw"Figure~\ref{fig:contaminant}") + @test isempty([m.match for m in eachmatch(pat, post)]) + end + + fake_labels = Set(["sec:intro"]) + fake_refs = Set(["sec:intro", "sec:nonexistent"]) + @test !isempty(setdiff(fake_refs, fake_labels)) # a dangling ref is detected + @test isempty(setdiff(Set(["sec:intro"]), fake_labels)) + end + + # The five converted references must each resolve, named so that deleting one is loud. + for k in ("sec:intro", "sec:melanin_radiotrophy", "sec:knn", "sec:framework", + "sec:discussion") + @test k in labels + @test k in refs + end +end + +# ------------------------------------------------- PP-RECIP-01's two sentences still exist +# +# SCOPE: the two manuscript sentences claims_ledger row PP-RECIP-01 connects. Nothing else. +# +# THIS GUARD IS NOT WHAT KEEPS THE ROW OFF THE ENFORCED NOWHERE REPORT, and saying so was +# wrong when it was proposed. That report's predicate is document-based -- +# `r["document"] in PSEUDO_DOCUMENTS or GENERATED_ARTIFACTS`, and PSEUDO_DOCUMENTS is +# {"repository", "correspondence"} -- so a row whose document is `preprint` is off the report +# whatever its code_location says. (The corollary matters for the thirty-one rows that ARE on +# it: they cannot be cleared by adding guards, because they are listed for naming no file.) +# +# WHAT IT ACTUALLY BUYS: PP-RECIP-01 records that two separately-stated sentences are one +# mechanism. That is a claim ABOUT A RELATION, so it goes stale the moment either end is +# edited away -- and a record of a connection outliving the thing connected is the +# absence-record-going-stale family, caught here before it happens rather than after. +@testset "the two sentences PP-RECIP-01 connects are still in the manuscript" begin + tex = read(joinpath(REPO, "preprint", + "modeling_radioresistance_and_radiotropic_fitness.tex"), String) + + mechanism = "radiolysis" # §3.11, the established descriptor + reciprocity = "assumes dose--time reciprocity" # §3.12, the caveat + kinetics = "competing generation and recombination kinetics" + + @test occursin(mechanism, tex) + @test occursin(reciprocity, tex) + @test occursin(kinetics, tex) + + # The relation, not just the ends: §3.12's caveat must still be ABOUT radiolysis. If the + # kinetics clause were rewritten without naming radiolysis products, PP-RECIP-01's claim + # that these are one mechanism would be the thing that went stale. + i = findfirst(reciprocity, tex) + @test i !== nothing + window = tex[first(i):min(lastindex(tex), first(i) + 600)] + @test occursin("radiolysis", window) + + @testset "the control" begin + # Each string must be absent from text that does not contain it, or these are + # occursin calls that could not fail. + @test !occursin(reciprocity, "an unrelated paragraph about mesh convergence") + @test !occursin("radiolysis", "an unrelated paragraph about mesh convergence") + end +end + +# ------------------------------------------------- 3.12's condition keeps its derivation +# +# SCOPE: the "dilute and low-Z" clause in section 3.12 and the mechanism paragraph under it. +# +# THE CONDITION SHIPPED WITHOUT ITS DERIVATION FOR THE WHOLE OF v1.1 AND v1.2. §3.12 said the +# one-way assumption is "a reasonable simplification only while the sorbate is dilute and +# low-Z" and never said WHY low-Z makes it reasonable -- Compton, photoelectric-as-mechanism, +# electron density and "indistinguishable" appeared nowhere in the .tex. A reader could not +# check the condition and a future author could not tell where its boundary lay. That is the +# repository's own "state the derivation, not the result" rule pointed at the manuscript. +# +# IT WAS FOUND BY A PREMISE CHECK, NOT BY A GUARD, and the shape is worth naming because it +# differs from the other misattributions this repository has caught: those were claims about +# documents nobody had read. This was a document read CORRECTLY with the reasoning underneath +# supplied silently -- the conclusion was right and its derivation was imported from outside. +# Nothing in the text was wrong, which is what makes it hard to catch. +# +# THE TWO HALVES HAVE DIFFERENT EVIDENTIARY STATUS AND THE GUARD KEEPS BOTH. The mechanism and +# the energy boundary are statements about the interaction, checkable with no measurement of +# this system. The MAGNITUDE of the biomass/medium contrast is not, and §3.12 declines it +# explicitly, pointing at the water fraction and composition §7.4 lists as missing. A future +# edit that supplies a number without those measurements is the failure this scopes against. +@testset "3.12's dilute-and-low-Z condition still carries its mechanism" begin + tex = read(joinpath(REPO, "preprint", + "modeling_radioresistance_and_radiotropic_fitness.tex"), String) + + condition = "dilute and low-\$Z\$" + @test occursin(condition, tex) + + # The derivation must sit with the condition, not merely somewhere in the document. + i = findfirst(condition, tex) + @test i !== nothing + window = tex[first(i):min(lastindex(tex), first(i) + 1400)] + @test occursin("Compton scattering", window) # the mechanism + @test occursin("electron density per unit volume", window) + @test occursin("tens of keV", window) # the boundary, checkable here + @test occursin("left unstated here", window) # the magnitude, explicitly declined + + @testset "the control" begin + # Each string must be absent from text lacking it, or these are occursin calls that + # could not fail -- and the condition alone must NOT satisfy the mechanism check, + # which is the exact state the manuscript shipped in before this. + bare = "is a reasonable simplification only while the sorbate is dilute and low-\$Z\$." + @test occursin(condition, bare) + @test !occursin("Compton scattering", bare) + @test !occursin("tens of keV", bare) + end +end diff --git a/tests/prose_bounds.jl b/tests/prose_bounds.jl new file mode 100644 index 0000000..aaf5b84 --- /dev/null +++ b/tests/prose_bounds.jl @@ -0,0 +1,258 @@ +# ---------- The prose gate: a bound stated in the paper must be the bound the +# ---------- code has. +# +# Section 6.2 asserted that the largest acceptance-favouring contribution of +# ΔH_rad was 5e-5, "so ... reversing the move would require the drawn variate to +# fall within about 1e-5 of the acceptance threshold", and concluded from that +# that observing zero radiation-decided moves was analytically expected rather +# than an under-sampling artifact. The bound was wrong by 1.5e3 and no check in +# the repository could have said so: contract_csv.jl guards the trajectory, +# delta_h_decomposition.jl guards that the four terms sum to the scalar, and +# test_claims_ledger.py guards that retracted PHRASES do not survive. None of +# them reads a NUMBER out of the prose and compares it to the model. +# +# WHAT MAKES THE BOUND CHECKABLE. ΔH_rad is +β_ion[source]·I when the source +# gains a site and -β_ion[target]·I when the target loses one, so a move is +# favoured whenever the total is negative and BOTH SIGNS OF β_ion CAN DO THAT -- +# a positively signed species vacating a site favours acceptance exactly as much +# as a negatively signed one occupying it. +# +# THE BOUND IS OVER PAIRINGS, NOT OVER SPECIES, which is the second thing this +# gate got wrong. A copy between two occupied parcels contributes +# (β_source - β_target)·I, so the acceptance-favouring reach is the extremum over +# the (source, target) pairings the lattice permits, not the largest single +# coefficient. max|β_ion|·I₀ = 7.5e-2 takes one role at a time and is therefore +# not a bound at all: a CS source (-5e-5) copying into an SO target (7.5e-2) +# reaches -7.505e-2, and DOES SO IN A SHIPPED CONFIGURATION -- four times in +# 1298668 evaluated proposals at N=20, seed 42, 400 MCS. Raised by Codex on pull +# request #23; the withdrawn version of this file computed max|β_ion| and would +# have rejected the correct value. +# +# max(0, ·) ON EACH ROLE rather than (max β - min β): an absent parcel +# contributes nothing, so if every coefficient shared one sign the bound would +# come from one role alone. Writing the difference directly would bake in a +# property of today's seven coefficients that no test states. +# +# SCOPE, STATED RATHER THAN IMPLIED. This gates ONE number: the ΔH_rad bound in +# §6.2. It is not a general prose-versus-code checker and does not pretend to +# be. What it establishes is that this particular class of defect -- a bound +# asserted in prose that the coefficients contradict -- now has somewhere to +# fail, for the one bound that has already failed once. + +const TEX = joinpath(REPO, "preprint", + "modeling_radioresistance_and_radiotropic_fitness.tex") + +"Parse a LaTeX scientific literal, e.g. `7.5\\times10^{-2}` -> 0.075." +function _latex_number(s::AbstractString) + m = match(r"([0-9]*\.?[0-9]+)\s*\\times\s*10\^\{(-?[0-9]+)\}", s) + m === nothing && return nothing + return parse(Float64, m[1]) * 10.0^parse(Int, m[2]) +end + +"The bound §6.2 states for the acceptance-favouring reach of ΔH_rad." +function stated_rad_bound(tex::AbstractString) + # THE WHOLE EXPRESSION, NOT A LANDMARK AND A NUMBER. The first version of + # this required only that \max_{s,t} be followed somewhere by \beta_{t,...} + # and the right literal, so `\max_{s,t}\beta_t = 7.505e-2` and + # `\max_{s,t}(\beta_s + \beta_t)I_\gamma = 7.505e-2` both passed every + # assertion below. A semantic regression could keep the corrected number and + # invalidate the bound it names, which is the failure this file exists to + # catch. Raised by Codex on pull request #23. + # + # So the shape is required in full: both subscripts in their roles, the + # subtraction between them in that order, and I_γ. The single-species form + # stays unmatched on purpose -- a sentence bounding max_s |β| is one role at + # a time and reading a number out of it would launder that. + m = match(r"""\\max_\{s,t\}\s*\(\s*\\beta_\{t,\\mathrm\{ion\}\}\s*-\s*\\beta_\{s,\\mathrm\{ion\}\}\s*\)\s*(?:\\,)?\s*I_\\gamma\s*=\s*([^,\$]+)"""x, tex) + m === nothing && return nothing + return _latex_number(m[1]) +end + +@testset "the ΔH_rad bound in prose is the bound in the coefficients" begin + @test isfile(TEX) + tex = read(TEX, String) + p = SR.CPMParams() + + # BOTH ROLES AND BOTH ENDS OF THE PAIRING. Not `minimum(abs, ...)` over the + # negatively signed entries (version 1.1), and not `maximum(abs, ...)` over + # species (version 1.2): see the header. + βmax, βmin = maximum(p.β_ion), minimum(p.β_ion) + true_bound = (max(0.0, βmax) + max(0.0, -βmin)) * p.I0 + stated = stated_rad_bound(tex) + + @test stated !== nothing + @test isapprox(stated, true_bound; rtol = 1e-6) + + # THE BOUND IS ATTAINED, NOT MERELY ASSERTED. Both ends come from a distinct + # species, so two distinct parcels can realize it; if argmax and argmin + # coincided the extremum would need one parcel in both roles at once, which + # the copy rule forbids, and the bound would be loose without saying so. + @test argmax(p.β_ion) != argmin(p.β_ion) + + # ...and the same sentence must not still be asserting the withdrawn one. + @test !occursin("largest radiation contribution that could favour an accepted move is of that order", tex) + + @testset "the control: the version 1.1 sentence must fail this" begin + # Committed inline rather than recovered with `git show`: a squash-merge + # makes a pinned sha unreachable and the control degrades into a skip. + withdrawn = raw""" + $\beta_{s,\mathrm{ion}}$ is negative for two of the seven species at $-5\times10^{-5}$, so + the largest radiation contribution that could favour an accepted move is of that order, + and reversing the move would require the drawn variate to fall within about $10^{-5}$ of + the acceptance threshold.""" + # It states no max| | at all, so the extractor returns nothing -- which + # is a failure of this gate, not a pass. Assert that explicitly, or + # "no bound stated" and "the right bound stated" would look identical. + @test stated_rad_bound(withdrawn) === nothing + + # A sentence in the withdrawn SHAPE states no bound on ΔH_rad at all -- + # max_s |β| is one role at a time -- so the extractor must refuse it + # whatever number it carries. Both the 1.1 magnitude and the 1.2 one. + @test stated_rad_bound(raw"$\max_s |\beta_{s,\mathrm{ion}}| = 5\times10^{-5}$") === nothing + @test stated_rad_bound(raw"$\max_s |\beta_{s,\mathrm{ion}}| = 7.5\times10^{-2}$") === nothing + end + + @testset "the control: pairwise-SHAPED formulas that are not the bound" begin + # SCOPE: the four synthetic sentences listed in this block, and no + # location in the manuscript. Manuscript coverage is the dH_sites loop. + # EACH OF THESE CARRIES THE CORRECT LITERAL AND STATES A DIFFERENT + # QUANTITY. They are what a semantic regression looks like when the + # number survives the edit, and the previous extractor accepted all of + # them. Codex named the first two. + @test stated_rad_bound(raw"$\max_{s,t}\beta_{t,\mathrm{ion}} = 7.505\times10^{-2}$") === nothing + @test stated_rad_bound(raw"$\max_{s,t}(\beta_{t,\mathrm{ion}} + \beta_{s,\mathrm{ion}})\,I_\gamma = 7.505\times10^{-2}$") === nothing + # roles swapped: this is the DISfavouring extremum, a different quantity + @test stated_rad_bound(raw"$\max_{s,t}(\beta_{s,\mathrm{ion}} - \beta_{t,\mathrm{ion}})\,I_\gamma = 7.505\times10^{-2}$") === nothing + # I_gamma dropped: a bound on the coefficients, not on ΔH_rad + @test stated_rad_bound(raw"$\max_{s,t}(\beta_{t,\mathrm{ion}} - \beta_{s,\mathrm{ion}}) = 7.505\times10^{-2}$") === nothing + + # AND THE POSITIVE CONTROL, or the four above pass by the extractor + # being broken rather than by being strict. + @test stated_rad_bound(raw"$\max_{s,t}(\beta_{t,\mathrm{ion}} - \beta_{s,\mathrm{ion}})\,I_\gamma = 7.505\times10^{-2}$") ≈ 7.505e-2 + end + + @testset "the control: the version 1.2 bound must fail this" begin + # 7.505e-2 against 7.5e-2 is 0.067% -- larger than the rtol above, so the + # assertion can tell them apart. Stated, because a control that cannot + # separate the two values it names proves nothing about either. + v12 = maximum(abs, p.β_ion) * p.I0 + @test v12 ≈ 7.5e-2 + @test !isapprox(v12, true_bound; rtol = 1e-6) + @test true_bound > v12 + + wrong12 = raw"$\max_{s,t}(\beta_{t,\mathrm{ion}} - \beta_{s,\mathrm{ion}})\,I_\gamma = 7.5\times10^{-2}$" + @test stated_rad_bound(wrong12) ≈ 7.5e-2 + @test !isapprox(stated_rad_bound(wrong12), true_bound; rtol = 1e-6) + end + + @testset "the parser reads what LaTeX actually writes" begin + # isapprox, not ==: 7.5 * 10.0^-2 is 0.07500000000000001, and pinning + # the parser to exact binary equality would fail on arithmetic rather + # than on parsing. + @test _latex_number(raw"7.505\times10^{-2}") ≈ 7.505e-2 + @test _latex_number(raw"7.5\times10^{-2}") ≈ 7.5e-2 + @test _latex_number(raw"5\times10^{-5}") ≈ 5e-5 + @test _latex_number(raw"1.5\times10^{3}") ≈ 1.5e3 + @test _latex_number("no number here") === nothing + end +end + +# ---------------------------------------------- the melanin/radiation RATIO +# +# A DIFFERENT DEFECT SIGNATURE FROM THE BOUND ABOVE. §6.2 said the corrected +# reach is "only fifteen times smaller than the melanin term rather than four +# orders". Neither ratio the shipped values produce is fifteen: 15.41 is the +# acceptance-bias ratio computed against a reach of 5e-2, a magnitude appearing +# nowhere in the paper. The sentence was written mid-correction -- the "four +# orders" half was already fixed while the ratio still used the pre-pairwise +# number -- so this is not a place a correction failed to REACH, it is a place +# two corrections landed at different times and the earlier input survived +# inside the later one's sentence. +# +# You do not find that by asking where a number went. You find it by +# RECOMPUTING the number from values in the same document, which is what the +# bound check above already does, which is why this belongs beside it. +# +# TWO RATIOS, 6% APART, AND EACH LOCATION USES THE ONE ITS REGISTER DEMANDS. +# §6.2, §7.1 and the Conclusion compare Hamiltonian terms, so they carry the ΔH +# ratio. The abstract quotes 15.5%, an acceptance bias, so it carries the bias +# ratio. An unlabelled number in two places is how the next sweep finds one +# claim disagreeing with itself. + +"The melanin coupling is hard-coded in compute_delta_H_terms, not tabulated." +function _melanin_coupling(src::AbstractString) + m = match(r"ΔH_mel\s*-=\s*([0-9.]+)\s*\*\s*M_local", src) + m === nothing && return nothing + return parse(Float64, m[1]) +end + +@testset "the melanin/radiation ratio in prose is the ratio in the constants" begin + tex = read(TEX, String) + p = SR.CPMParams() + src = read(joinpath(REPO, "biofilms_potts.jl"), String) + + coupling = _melanin_coupling(src) + @test coupling !== nothing + @test coupling ≈ 0.5 + + M_REPORTED = 1.44 # the melanin level Table 3's row is stated at + dH_mel = coupling * M_REPORTED + @test dH_mel ≈ 0.720 + + βmax, βmin = maximum(p.β_ion), minimum(p.β_ion) + reach = (max(0.0, βmax) + max(0.0, -βmin)) * p.I0 + + ratio_dH = dH_mel / reach + ratio_bias = (exp(dH_mel / p.T_cpm) - 1) / (exp(reach / p.T_cpm) - 1) + @test isapprox(ratio_dH, 9.6; atol = 0.05) + @test isapprox(ratio_bias, 10.2; atol = 0.05) + + # ...and the two are NOT interchangeable, which is why each location names + # its register. If they ever converge this assertion says so. + @test !isapprox(ratio_dH, ratio_bias; rtol = 1e-3) + + # EVERY LOCATION, NOT THE FIRST MATCH. `match` returns one hit, so the first + # version of this read §6.2 alone while the docstring claimed each location + # carries the ratio its register demands. Mutating §7.1's 9.6 to 15.0 left + # stated_dH at §6.2's value and the whole suite passed; the abstract and the + # Conclusion were never inspected at all. A test contract that exceeds its + # assertion is the F_s defect living in a comment. Raised by Codex on #23. + # + # Each location gets its own pattern AND the count is pinned, so a location + # losing its phrasing fails rather than silently dropping out of the sweep. + dH_sites = [ + ("6.2", r"smaller than the melanin term by a factor of \$([0-9.]+)\$ in"), + ("7.1", r"radiation term by about an order of magnitude in \$\\Delta H\$ \(\$([0-9.]+)\$\)"), + ] + for (where, pat) in dH_sites + hits = collect(eachmatch(pat, tex)) + @test length(hits) == 1 # the location exists, exactly once + for h in hits + @test isapprox(parse(Float64, h[1]), ratio_dH; atol = 0.05) + end + end + + # The Conclusion and the abstract state the ratio in words rather than + # digits, so they are pinned by phrase. Naming that difference is the point: + # an unlabelled sweep would report them as covered. + @test occursin("exceeding the direct\nspecies-specific radiation term by about an order of magnitude in \$\\Delta H\$", tex) + @test occursin("an acceptance bias about ten\ntimes smaller rather than four orders smaller", tex) + + @testset "the control generalises past the value it was built from" begin + # A synthetic sentence stating FIFTEEN failing would only re-test the + # training case. The control is a DIFFERENT wrong ratio, plus the + # correct one passing -- that pins an arithmetic relation instead of one + # string, and the next half-correction will not land on fifteen either. + wrong = raw"smaller than the melanin term by a factor of $12.0$ in $\Delta H$" + parsed = match(r"smaller than the melanin term by a factor of \$([0-9.]+)\$ in", wrong) + @test parsed !== nothing + @test !isapprox(parse(Float64, parsed[1]), ratio_dH; atol = 0.05) + + right = raw"smaller than the melanin term by a factor of $9.6$ in $\Delta H$" + ok = match(r"smaller than the melanin term by a factor of \$([0-9.]+)\$ in", right) + @test isapprox(parse(Float64, ok[1]), ratio_dH; atol = 0.05) + + # And fifteen, as a special case rather than as the test. + @test !isapprox(15.41, ratio_bias; atol = 0.05) + end +end diff --git a/tests/rad_proposals_tests.jl b/tests/rad_proposals_tests.jl new file mode 100644 index 0000000..b2a2343 --- /dev/null +++ b/tests/rad_proposals_tests.jl @@ -0,0 +1,118 @@ +# ---------- The producer for §6.2's per-proposal ΔH_rad statistics, and the +# ---------- proof that instrumenting them does not move the trajectory. +# +# §6.2 states 26.7% of evaluated proposals carry ΔH_rad < -5e-5 over 3968838 +# proposals across seeds 42, 43 and 44 at 400 MCS. Version 1.2 stated 29.8% over +# an unnamed run and nothing in the repository could produce either figure. This +# pins the restated one to the code that makes it. + +include(joinpath(REPO, "rad_proposals.jl")) + +@testset "§6.2's proposal statistics reproduce from the shipped hook" begin + SR_rp = load_serial() + + @testset "the hook does not move the trajectory" begin + # SAME SHAPE AS `the driver tally is inert`. `on_proposal` is called + # before the acceptance draw, so a harness that consulted the generator + # would reorder every subsequent move; the shipped one does not, and + # this is what says so. + function run(hooked::Bool) + p = SR_rp.CPMParams(N = 16, n_cells_per_species = 3) + st = SR_rp.init_state(p; seed = 5) + rng = Random.Xoshiro(5) + seen = Ref(0) + for _ in 1:6 + SR_rp.mcs_step!(st, rng; + on_proposal = hooked ? ((t, dh) -> (seen[] += 1)) : nothing) + SR_rp.update_melanin!(st) + end + return copy(st.lattice), copy(st.melanin), seen[] + end + bare_lat, bare_mel, bare_seen = run(false) + hook_lat, hook_mel, hook_seen = run(true) + @test bare_lat == hook_lat + @test bare_mel == hook_mel + + # THE HOOK MUST HAVE FIRED. Equality between a bare run and a run whose + # hook was never called is vacuous, and that is how a broken hook looks. + @test bare_seen == 0 + @test hook_seen > 0 + + # CONTROL 1, AND WHAT IT IS FOR: a different seed must give a different + # lattice. This establishes that the COMPARISON varies with input at all, + # so equality at seed 5 is not vacuously true. It says nothing about + # whether the hook channel could have disturbed anything. + other = SR_rp.init_state(SR_rp.CPMParams(N = 16, n_cells_per_species = 3); seed = 6) + rng6 = Random.Xoshiro(6) + for _ in 1:6 + SR_rp.mcs_step!(other, rng6) + SR_rp.update_melanin!(other) + end + @test other.lattice != bare_lat + + # CONTROL 2, WHICH IS THE ONE THAT MAKES THE NULL READABLE. Firing and + # firing WITH THE POWER TO DISTURB are different properties, and only the + # second makes the equality above evidence about `on_proposal`. The JACC + # port's guarantee was structural -- a counter-based RNG keyed on (seed, + # step, site) has no stream position to advance -- and this file does not + # inherit it: `mcs_step!` draws from a sequential generator and the hook + # sits immediately before the acceptance draw. So the claim is empirical + # and needs its own sensitivity evidence. + # + # BOTH READINGS OF A NULL, DECIDED BEFORE IT IS SEEN. If the drawing hook + # does NOT diverge, that is ambiguous between "the comparison is blind" + # and "`on_proposal` has no reach into the stream", which would be the + # stronger result. Object identity separates them, so it is asserted + # first: `mcs_step!` consumes from the generator it is passed and + # re-seeds nothing, therefore divergence is the expected outcome and a + # null would mean the harness, not the hook. + rng5 = Random.Xoshiro(5) + drew = SR_rp.init_state(SR_rp.CPMParams(N = 16, n_cells_per_species = 3); seed = 5) + captured = Ref{Any}(nothing) + greedy = (t, dh) -> (captured[] = rng5; rand(rng5); nothing) + for _ in 1:6 + SR_rp.mcs_step!(drew, rng5; on_proposal = greedy) + SR_rp.update_melanin!(drew) + end + @test captured[] === rng5 # same object the stepper draws from + @test drew.lattice != bare_lat # the hook channel CAN perturb + end + + @testset "the published numbers" begin + tn = 0; tb = 0; lo = Inf; hi = -Inf + for seed in (42, 43, 44) + r = Base.invokelatest(rad_proposals, SR_rp, seed, 400) + ref = PUBLISHED[seed] + @test r.n == ref.n + @test r.below == ref.below + @test r.min ≈ ref.min + tn += r.n; tb += r.below; lo = min(lo, r.min); hi = max(hi, r.max) + end + @test tn == POOLED_N + @test round(tb / tn; digits = 3) == POOLED_FRAC + + # The acceptance-favouring extreme at N=40 is the single-role value, and + # the pairwise extremum is reached only in the DISFAVOURING direction. + # That asymmetry is the sentence §6.2 makes, so it is pinned here: if + # `lo` ever reached -0.07505 the manuscript would be wrong. + @test lo ≈ -0.075 + @test hi ≈ 0.07505 + @test lo > -0.07505 + end + + @testset "the manuscript states what this measures" begin + tex = read(joinpath(REPO, "preprint", + "modeling_radioresistance_and_radiotropic_fitness.tex"), String) + @test occursin("26.7", tex) + @test occursin(raw"3\,968\,838", tex) + + # THE DEFECT WAS THE UNNAMED RUN, NOT THE DIGITS. A correction record has + # to be able to name the number it withdraws -- 29.8% still appears once, + # in the version 1.2 note, and forbidding the string outright would forbid + # the paper from recording its own correction. What must not survive is + # the phrasing that reported a measurement over a run nothing identified. + @test !occursin("Measured over the run", tex) + @test occursin(raw"Measured over the three runs of Table~\ref{tab:decided_moves}", tex) + @test count(_ -> true, eachmatch(r"29\.8", tex)) == 1 + end +end diff --git a/tests/radiodialysis_basis_gate.jl b/tests/radiodialysis_basis_gate.jl index cf5382e..da77e0b 100644 --- a/tests/radiodialysis_basis_gate.jl +++ b/tests/radiodialysis_basis_gate.jl @@ -191,6 +191,15 @@ "tests/checkpoint_io_tests.jl", # snapshot/restart round trip "export_checkpoint.jl", # interchange export; labels the file "tests/radiodialysis_basis_gate.jl", # this file, testing the gate + "tests/jacc_parity_tests.jl", # CPM acceptance counts; the + # 10x-uptake testset in that file + # holds the claim to account + "jacc_acceptance_figure.jl", # fig5; plots the same acceptance + # quantities, same claim + "regenerate_fig3.jl", # fig3 is m(t) and P_eff/P0, which + # the gated basis does not reach; + # that script proves it before it + # copies anything ]) repo = dirname(@__DIR__) found = Set{String}() diff --git a/tests/runtests.jl b/tests/runtests.jl index 407c9ab..15c7454 100644 --- a/tests/runtests.jl +++ b/tests/runtests.jl @@ -28,6 +28,8 @@ end include("deterministic_radiation.jl") end +include("delta_h_decomposition.jl") + include("radiodialysis_basis_gate.jl") @testset "Lifecycle, dose contract, windowed API" begin @@ -52,3 +54,22 @@ end @testset "Manuscript claims" begin include("manuscript_claims_tests.jl") end + +# The checkerboard decomposition had no acceptance measurement on either branch. +# Per-kernel agreement above passes when both kernels carry the same artifact, +# and the serial fixture pins a stream with no sublattices, so nothing here +# would have reported a parity-correlated bias in accepted moves. +include("jacc_parity_tests.jl") + +# A bound stated in the paper must be the bound the coefficients have. §6.2 +# asserted 5e-5 where the model gives 7.505e-2, and nothing here could read a +# number out of the prose to say so. +include("prose_bounds.jl") + +# §6.2's per-proposal statistics had no producer either: the 26.7% that replaced +# an unnamed-run 29.8% was measured by an uncommitted rewrite of the stepper. +include("rad_proposals_tests.jl") + +# Table 4 was published from a configuration nothing shipped could reproduce. +# decided_moves.jl is that entry point; this runs it. +include("decided_moves_tests.jl") diff --git a/tools/absence_gate.py b/tools/absence_gate.py new file mode 100644 index 0000000..0d2c0ae --- /dev/null +++ b/tools/absence_gate.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Mechanical candidate list for the absence-sentence gate. + +Grep proposes; a human disposes. The point is to stop reading one's own prose for +a shape one is primed to miss, so this errs toward flagging and expects a human +'no' on some of what it returns. + +THIS SURFACES CANDIDATES AND DOES NOT PROVE COVERAGE. It matches surface forms +for a semantic property, and English has unbounded ways to say a thing is absent. +KNOWN_UNHANDLED below carries the forms it is known to miss, asserted NOT to flag +and printed as a standing gap count on every run, so a clean pass over a document +can never be read as an all-clear. That list exists because the first version +matched `not found` and missed `did not find`, and an absence went through +unflagged on the same diff that introduced the tool. +""" +import re, sys + +HAZARD = [ + (r"\bno\b", "bare negation"), + (r"\bnone\b", "bare negation"), + (r"\bnobody\b", "bare negation"), + (r"\bnot found\b", "explicit absence"), + (r"\bdid not find\b", "explicit absence"), + (r"\bfound none\b", "explicit absence"), + (r"\bno study\b|\bno such\b", "explicit absence"), + (r"\bnever\b", "universal"), + (r"\balways\b", "universal"), + (r"\bevery\b", "universal"), + (r"\bonly\b", "universal in POSITIVE grammar"), + (r"\bunstudied\b", "explicit absence"), + (r"\babundant\b", "comparative corpus claim"), + (r"\bacross\b", "possible venue scope"), + (r"\bruns\b|\buses\b", "corpus habit stated as assertion"), +] + +def scan(text): + out = [] + for sent in re.split(r"(?<=[.?!])\s+", text.strip()): + if not sent: + continue + hits = sorted({why for pat, why in HAZARD if re.search(pat, sent, re.I)}) + if hits: + out.append((sent, hits)) + return out + +# --- controls ------------------------------------------------------------- +# SHOULD FLAG. Four are the known-bads from this planning round -- and they are a +# TRAINING SET, since the gate was derived from them. #5 is the test-set item: a +# universal wearing positive grammar, a shape none of the four has. +SHOULD_FLAG = [ + "Not found across ScienceDirect, OSTI, NACE and AMPP.", + "Not found in a search on 30 August 2026 using the term set below.", + "There is abundant MIC literature on 304L, 316 and high-entropy alloys.", + "Screening runs unirradiated coupons in sterile electrolyte.", + "The only work in this area is on chromia-passivated substrates.", # test set +] + +# SHOULD NOT FLAG. Ordinary page-2 prose, asserting nothing about existence or +# universality. A gate that flags everything scores perfectly on SHOULD_FLAG and +# is useless, which is the serial_seed42 over-count control in another costume. +SHOULD_PASS = [ + "Biofilm developed on both materials.", + "They measured its radioactivity by gamma-ray spectrometry and found it had " + "retained radionuclides from the pool water.", + "One of those isolates is B. subtilis, which is already one of the seven " + "species in the model.", + "What dose the biofilm itself receives, and whether a hydrated layer 50 to 200 " + "micrometres thick perturbs the local field at the metal surface.", +] + +# KNOWN GAPS, KEPT VISIBLE. These are absence claims the matcher does NOT catch. +# They are asserted not to flag, so incompleteness prints on every run instead of +# living in a comment nobody reads. If one starts flagging, that is an UNEXPECTED +# PASS: a gap closed, and this list needs updating rather than celebrating. +KNOWN_UNHANDLED = [ + "This question has yet to be characterised for the alloy in question.", + "The literature is silent on the behaviour of that surface.", + "The topic remains uncharacterized at pool temperature.", + "Work in this area is confined to chromia-passivated substrates.", + "It is an open question whether the film changes.", +] + + +# --------------------------------------------------------------------------- +# SECOND SURFACE: TEST CONTRACTS THAT OVERSTATE THEIR ASSERTIONS +# --------------------------------------------------------------------------- +# Three defects found in one review round shared a signature: the guard bounded +# its own claim correctly while the DOCSTRING ABOVE IT overstated the reach. A +# sweep over one file under a ledger note claiming the repository; `match` +# reading one hit under a docstring claiming every location; a set intersection +# under a comment claiming the registry. That is not three bugs, it is one seam +# -- between what a check does and what its description claims -- and it is the +# seam this project's discipline is organised around, unguarded in the one place +# that describes the guards. +# +# THE CHECK IS NOT "NO UNIVERSAL QUANTIFIER". Universals fire legitimately here: +# "asserts every registered absence still holds" is accurate. The check is that a +# universal's SCOPE IS STATED, in a form a reader can compare against the +# assertion below it. So the gate does not infer scope -- it requires the +# docstring to declare one, because inferring it is the same guess that produced +# the defect. +# +# AND IT DECLARES ITS OWN SCOPE, or it reproduces the defect one level up: it +# reads the files it is given and claims nothing about any file it was not, +# and it reports TWO TIERS because a proximity window alone has a +# false-negative mode -- see scan_contracts. As of 2026-08-31 the attached +# tier is triaged and the module-level tier is a BACKLOG, not a judged set: +# 17 blocks across six test files remain unscoped and unjudged. Saying so is +# the difference between a backlog and a silent gap. + +UNIVERSAL = re.compile(r"\b(every|all|each|any|no)\b", re.I) +SCOPE_LINE = re.compile(r"^\s*(#\s*)?SCOPE:\s*\S", re.M) + + +def _doc_blocks(text: str): + """(line number, block) for each docstring or run of >=2 comment lines.""" + blocks, buf, start = [], [], None + for i, line in enumerate(text.split("\n"), 1): + st = line.strip() + if st.startswith("#"): + if start is None: + start = i + buf.append(st.lstrip("# ").rstrip()) + else: + if buf and len(buf) >= 2: + blocks.append((start, "\n".join(buf))) + buf, start = [], None + if buf and len(buf) >= 2: + blocks.append((start, "\n".join(buf))) + for m in re.finditer(r'"""(.*?)"""', text, re.S): + blocks.append((text[:m.start()].count("\n") + 1, m.group(1))) + return blocks + + +# A CONTRACT IS A BLOCK THAT DESCRIBES A CHECK, not any prose containing "every". +# The first version flagged 19 blocks across three files, most of them ordinary +# commentary -- which is the noise failure that gets a gate muted within a week. +# A block counts only if a test definition sits within DEF_WINDOW lines of it, so +# what is judged is the boundary between what a check does and what its +# description claims, which is the seam this exists for. +DEF_WINDOW = 4 +TEST_DEF = re.compile(r"^\s*(def\s+test_|@testset|function\s+test_)", re.M) + + +# THE WINDOW'S TWO ERROR MODES ARE NOT SYMMETRIC, and the second is the one this +# tool exists to catch. A false positive costs a judgement call, which is cheap +# and gets recorded. A false NEGATIVE is a contract the gate silently does not +# cover -- and enumerating the drops showed the set is NOT empty: a module-level +# block stating "ALL FOUR BRANCHES MUST BE EXERCISED", and one stating which +# run_by values are exempt from a check, both sit further than DEF_WINDOW from +# any test definition. A gate claiming to guard the check-versus-description +# boundary while missing module-level contracts overstates its own reach, which +# is the subset defect inside the tool built to detect it. +# +# So there are two tiers and the caller is told which is which. Attached +# contracts are near a test definition. Module-level contracts are further away +# and are admitted only when they speak NORMATIVELY -- must, never, cannot, +# only, exempt -- because that is what separates a rule about a check from +# commentary about the physics. +NORMATIVE = re.compile(r"\b(must|never|cannot|only|exempt|required)\b", re.I) + + +def scan_contracts(text: str, *, module_level: bool = False): + """Blocks that describe a check and assert a universal without declaring a scope. + + SCOPE: the text given. `module_level=False` returns contracts within + DEF_WINDOW lines of a test definition; `module_level=True` returns the + normatively-worded blocks outside that window. Neither set is the other's + superset and both are reported. + """ + lines = text.split("\n") + def_lines = {i for i, l in enumerate(lines, 1) if TEST_DEF.match(l)} + out = [] + for lineno, block in _doc_blocks(text): + if not (UNIVERSAL.search(block) and not SCOPE_LINE.search(block)): + continue + # ASSOCIATE WITH THE ENCLOSING TEST, NOT ONLY A NEARBY DEFINITION. The + # proximity window missed the defect that motivated this gate: the + # "EVERY LOCATION" contract in prose_bounds.jl sits inside a @testset + # whose header is 25 lines above it, so no definition was within four + # lines and attached-mode returned nothing for the very file it was + # built from. + # + # BUT "IS THERE A DEFINITION ABOVE ME" IS NOT THE SAME QUESTION AS "AM I + # INSIDE ONE", AND USING IT MADE THE MODULE-LEVEL TIER UNREACHABLE. Once + # a file's first test definition is passed, every later block satisfied + # `preceding` -- so a block that had returned to module scope was + # reported as attached, and module-level mode returned nothing for any + # file with a test in it. Measured on calibration/tests/test_sop_index.py: + # 8 attached, 0 module-level, while the no-signal registry at line 164 + # and three further module blocks sat there being misfiled. + # + # THAT IS RULE 3 INSIDE THE TOOL BUILT FOR RULE 3. The header advertises + # the module-level count as a standing backlog printed every run so a + # clean pass is never an all-clear; it printed 0, and an unreachable + # tier and an empty tier print the same 0. Raised by Codex on pull + # request #23. + # + # Membership is now INDENTATION, which is what "inside a body" actually + # means in both languages this reads: a block at column 0 is module + # scope, an indented block belongs to the definition enclosing it. + raw = lines[lineno - 1] if lineno - 1 < len(lines) else "" + indent = len(raw) - len(raw.lstrip()) + preceding = [d for d in def_lines if d <= lineno] + attached = indent > 0 and bool(preceding) + if module_level: + if indent == 0 and def_lines and NORMATIVE.search(block): + out.append((lineno, block.strip()[:180])) + elif attached: + out.append((lineno, block.strip()[:180])) + return out + + +# Controls in DOCSTRING FORM, because the scanner reads comment runs and +# triple-quoted blocks -- a bare sentence exercises nothing, which the first +# version of these controls did and reported as a miss. +CONTRACT_MUST_FLAG = ( + "def test_coverage():\n" + " # Every measured requirement has an SOP or a named gap.\n" + " # The check compares the index against the register.\n") +# IN-BODY CONTROL: the motivating defect's own shape -- a contract documented +# beside assertions well inside a test body, far from its header. The proximity +# window returned nothing for this and reported the file clean. +CONTRACT_IN_BODY_MUST_FLAG = ( + "@testset \"ratios\" begin\n" + " x = 1\n y = 2\n z = 3\n w = 4\n v = 5\n" + " # EVERY LOCATION, NOT THE FIRST MATCH. `match` returns one hit.\n" + " # The docstring claimed every location and covered one.\n" + " @test x == 1\nend\n") +CONTRACT_MUST_PASS = ( + "def test_coverage():\n" + " # SCOPE: the 14 measured rows of reference_d_requirements.csv, no other row.\n" + " # Every measured requirement has an SOP or a named gap.\n") +# THE SHAPE THAT WAS INVISIBLE: a module-level contract sitting AFTER a completed +# test. Under the "is there a def above me" rule this was filed as attached, so +# the module-level tier reported nothing and its backlog count read as zero. It +# must appear in module-level mode and must NOT appear in attached mode -- both +# directions, because classifying it into the wrong tier is the actual defect and +# a one-sided check cannot see it. +CONTRACT_MODULE_AFTER_TEST_MUST_FLAG = ( + "def test_one():\n" + " assert True\n" + "\n" + "# EVERY registered absence must back an index row.\n" + "# Nothing here declares which rows those are.\n") + + +if __name__ == "__main__": + if len(sys.argv) > 2 and sys.argv[1] == "--contracts": + # SCOPE: the files named on this command line, and nothing else. + print(f"contract scope: {len(sys.argv) - 2} file(s) named on the command line") + n = 0 + m = 0 + for path in sys.argv[2:]: + text = open(path).read() + for lineno, block in scan_contracts(text): + n += 1 + print(f"\nUNSCOPED CONTRACT (attached) {path}:{lineno}\n {block}") + for lineno, block in scan_contracts(text, module_level=True): + m += 1 + print(f"\nUNSCOPED CONTRACT (module-level) {path}:{lineno}\n {block}") + print(f"\n{n} attached and {m} module-level universal(s) without a declared SCOPE. " + "A clean run is not an all-clear: this reads only the files it was given, " + "and module-level blocks are admitted only when worded normatively.") + sys.exit(0) + + if len(sys.argv) > 1: + for sent, hits in scan(open(sys.argv[1]).read()): + print(f"FLAG {hits}\n {sent}\n") + sys.exit(0) + + ok = True + print("=== should flag (5th is the test-set item) ===") + for s in SHOULD_FLAG: + hits = scan(s) + mark = "ok " if hits else "MISS" + ok &= bool(hits) + print(f" {mark} {s[:70]:<70} {hits[0][1] if hits else '-'}") + print("\n=== should pass clean ===") + for s in SHOULD_PASS: + hits = scan(s) + mark = "ok " if not hits else "FALSE POSITIVE" + ok &= not hits + print(f" {mark} {s[:70]:<70} {hits[0][1] if hits else ''}") + print("\n=== known gaps: absence claims this matcher does NOT catch ===") + surprises = 0 + for s_ in KNOWN_UNHANDLED: + hits = scan(s_) + if hits: + surprises += 1 + print(f" UNEXPECTED PASS {s_[:66]:<66} {hits[0][1]}") + else: + print(f" gap (expected) {s_[:66]}") + print(f"\n{len(KNOWN_UNHANDLED) - surprises} known gaps still open. " + "A clean run over a document is NOT an all-clear.") + if surprises: + print(f"{surprises} entr{'y' if surprises == 1 else 'ies'} now flag -- " + "a gap closed; move them out of KNOWN_UNHANDLED.") + + print("\n=== contract mode: a universal must declare a scope ===") + c_bad = scan_contracts(CONTRACT_MUST_FLAG) + c_ok = scan_contracts(CONTRACT_MUST_PASS) + print(f" {'ok ' if c_bad else 'MISS'} universal without SCOPE flags") + print(f" {'ok ' if not c_ok else 'FALSE POSITIVE'} the same universal WITH a SCOPE line passes") + c_body = scan_contracts(CONTRACT_IN_BODY_MUST_FLAG) + print(f" {'ok ' if c_body else 'MISS'} a contract INSIDE a test body, far from its header, flags") + # BOTH DIRECTIONS: the defect was misclassification, not absence, so a check + # that only asserted "module-level finds it" would pass while attached mode + # also claimed it and the counts stayed wrong. + m_after = scan_contracts(CONTRACT_MODULE_AFTER_TEST_MUST_FLAG, module_level=True) + a_after = scan_contracts(CONTRACT_MODULE_AFTER_TEST_MUST_FLAG) + print(f" {'ok ' if m_after else 'MISS'} a MODULE-LEVEL contract after a completed test " + "reaches the module tier") + print(f" {'ok ' if not a_after else 'MISFILED'} ...and is not also reported as attached") + ok &= (bool(c_bad) and not c_ok and bool(c_body) + and bool(m_after) and not a_after) + + print("\nGATE USABLE" if ok else "\nGATE NOT USABLE -- fix before running it on new prose") + sys.exit(0 if ok else 1) diff --git a/tools/render_figure_svg.py b/tools/render_figure_svg.py new file mode 100644 index 0000000..58199bb --- /dev/null +++ b/tools/render_figure_svg.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Render an SVG figure to PDF and write the source hash THE CONVERSION SAW. + +WHY THE HASH IS WRITTEN HERE AND NOWHERE ELSE. An SVG source-of-truth plus a +committed derived PDF is the generator-and-artifact pair that already failed in +this repository: the generator was corrected two weeks before the committed +images were, and nothing could open a figure to notice. + +A plain `svg.sha256` sidecar does NOT fix that. Compared against the SVG it binds +the SVG to a record of itself -- edit the SVG, the hash mismatches, and the +obvious repair is to regenerate the hash. That is green again with the PDF still +stale, so the check meant to catch staleness has been satisfied without catching +it. + +So the hash file is written ONLY by this script, as a side effect of actually +converting. The only way to make the guard green is to run the conversion, which +produces the PDF. This is contract_csv.jl's compare-never-regenerate reasoning +inverted: there the fixture must never be rewritten by the thing it checks; here +the record must be writable only by the generator. + + python3 tools/render_figure_svg.py preprint/figures/phase2_diffusion_cell.svg +""" +from __future__ import annotations + +import hashlib +import subprocess +import sys +from pathlib import Path + +CONVERTERS = [ + ("rsvg-convert", lambda src, dst: ["rsvg-convert", "-f", "pdf", "-o", str(dst), str(src)]), +] +CAIRO_PY = Path("/home/aurascoper/.local/share/mamba/envs/openmc-biofilms/bin/python") + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def render(svg: Path) -> Path: + pdf = svg.with_suffix(".pdf") + import shutil + for name, argv in CONVERTERS: + if shutil.which(name): + subprocess.run(argv(svg, pdf), check=True) + return pdf + if CAIRO_PY.is_file(): + subprocess.run( + [str(CAIRO_PY), "-c", + "import sys,cairosvg;cairosvg.svg2pdf(url=sys.argv[1],write_to=sys.argv[2])", + str(svg), str(pdf)], check=True) + return pdf + raise SystemExit("no SVG converter available; install librsvg2-bin or cairosvg") + + +def sidecars(pdf: Path) -> None: + """The sidecar set preprint/figures/ already requires of every committed PDF. + + A figure entering that directory joins an existing contract: `.sha256` + of the PDF's own bytes, `.txt` from pdftotext -layout, and `.png`. + The .txt is what RETRACTED_IN_FIGURES scans, so producing it is not + bookkeeping -- it is what puts this figure's text under the phrase guard that + exists because a withdrawn claim once survived inside an image. + """ + # BARE HASH, matching what preprint/figures/ already uses. The first version + # wrote " ", a format invented beside the convention rather than + # mirroring it, and the existing comparison reads the whole file. + pdf.with_suffix(".sha256").write_text(f"{sha256(pdf)}\n", encoding="utf-8") + txt = subprocess.run(["pdftotext", "-layout", str(pdf), "-"], + capture_output=True, text=True, check=True).stdout + pdf.with_suffix(".txt").write_text(txt, encoding="utf-8") + import shutil + if shutil.which("pdftoppm"): + subprocess.run(["pdftoppm", "-png", "-r", "150", "-singlefile", + str(pdf), str(pdf.with_suffix(""))], check=True) + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + raise SystemExit(__doc__) + svg = Path(argv[1]) + pdf = render(svg) + sidecars(pdf) + # THE PAIR, NOT THE SOURCE ALONE. The docstring above argues that a record + # comparing the SVG to a record of itself can be satisfied by regenerating + # the record, leaving the PDF stale -- and then the first version of this + # line wrote exactly that record. Codex raised it on pull request #23. + # + # Recording the digest of the PDF this conversion produced states the pair + # the guard actually cares about. BE CLEAR ABOUT WHAT THIS DOES AND DOES NOT + # BUY: both lines are still hand-writable, so it does not make forgery + # impossible, it makes it require two deliberate falsehoods instead of one. + # What actually binds source to artifact is the text check in + # calibration/tests/test_figure_staleness.py, which reads the SVG's own text + # runs out of the committed .txt and cannot be satisfied by editing a hash. + # + # `split()[0]` still yields the source digest, so every existing reader of + # this file is unaffected. + digest = sha256(svg) + Path(str(svg) + ".sha256").write_text( + f"{digest}\npdf={sha256(pdf)}\n", encoding="utf-8") + print(f"rendered {pdf} from {svg}\n source sha256 {digest}\n" + f" sidecars: {pdf.with_suffix('.sha256').name}, {pdf.with_suffix('.txt').name}, " + f"{pdf.with_suffix('.png').name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tools/render_guide.py b/tools/render_guide.py new file mode 100644 index 0000000..eca267b --- /dev/null +++ b/tools/render_guide.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Render a guide's LaTeX to PDF and record the .md hash THE RENDER SAW. + + python3 tools/render_guide.py docs/guides/calculus_in_this_code.md + +WHY THIS EXISTS. docs/guides/.md is the declared source of record; +.tex is a hand-maintained rendering of it and .pdf is built from the +.tex. Nothing bound the three. On 2026-08-31 the .md's prose was edited and the +.tex and .pdf kept the old wording while every check stayed green, because the +citation binding covers line numbers only -- which that test states about itself. +The .md was hand-synced to the .tex twice in one session. + +WHAT THIS BINDS, AND IT IS NARROWER THAN "THE RENDER IS CURRENT". Measured before +choosing: matching the .md's prose paragraphs against the rendered text layer +gives 38 of 48 at a six-word prefix, and the ten failures are MARKUP differences +rather than divergence -- `r` in backticks renders as math, a literal `##` inside +prose renders as emphasis. THE .tex IS A RE-AUTHORING, NOT A RENDER, so the two +differ by design and a prose binding would be 20% false failures. That is not a +threshold to tune; it is the wrong instrument for a hand-authored .tex. + +So this binds what is real: + + 1. it renders the .tex with tectonic, so a PDF exists that came from the .tex; + 2. it verifies every `file:line` citation in the .md appears in that PDF's text + layer, which is the check that has already caught three stale renders; + 3. and only then does it write `.md.sha256`. + +The record is written ONLY as a side effect of a successful render, the same +inversion tools/render_figure_svg.py uses: the sole way to make the staleness +check green is to run this, which produces the PDF. Editing the record by hand +makes the hash check pass and leaves the citation check to fail on the next run. + +WHAT IT DOES NOT DO, STATED SO NOBODY READS MORE INTO A GREEN RUN: it does not +verify that the .tex says what the .md says in prose. A prose edit to the .md +that touches no citation will fail the hash check -- which is the point, it +forces a human to re-sync and re-render -- but nothing here checks that the +re-sync was faithful. That remains a human obligation and an open gap. +""" +from __future__ import annotations + +import hashlib +import re +import shutil +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + +ROW = re.compile( + r"^\|(?P[^|]*)\|\s*`(?P[^`:]+):(?P\d+)`\s*\|\s*`(?P.+?)`\s*\|\s*$", + re.M) + + +def citations(md_text: str) -> list[tuple[str, int]]: + return [(m.group("path").strip(), int(m.group("line"))) + for m in ROW.finditer(md_text)] + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + raise SystemExit(__doc__) + md = Path(argv[1]).resolve() + tex, pdf = md.with_suffix(".tex"), md.with_suffix(".pdf") + for f in (md, tex): + if not f.is_file(): + raise SystemExit(f"missing: {f}") + if not shutil.which("tectonic"): + raise SystemExit("tectonic not found; it is what renders the guide") + + subprocess.run(["tectonic", tex.name, "--outdir", str(tex.parent)], + cwd=tex.parent, check=True) + + if not shutil.which("pdftotext"): + raise SystemExit("pdftotext not found; the citation check needs Poppler") + text = " ".join(subprocess.run( + ["pdftotext", "-layout", str(pdf), "-"], + capture_output=True, text=True, check=True).stdout.split()) + + # The tail after the final underscore is what survives the template's + # line-breaking of long paths -- measured, see test_guide_citations.py. + cites = citations(md.read_text(encoding="utf-8")) + if not cites: + raise SystemExit(f"{md.name} yields no citations; refusing to record a hash") + missing = [f"{p}:{n}" for p, n in cites + if f"{Path(p).name.split('_')[-1]}:{n}" not in text] + if missing: + raise SystemExit( + f"REFUSING TO RECORD. {len(missing)} citation(s) in {md.name} are not " + f"in the rendered PDF: {missing}. The .tex is not a render of this " + ".md -- sync it before re-running.") + + digest = hashlib.sha256(md.read_bytes()).hexdigest() + Path(str(md) + ".sha256").write_text(f"{digest}\n", encoding="utf-8") + print(f"rendered {pdf.name} from {tex.name}\n" + f" {len(cites)} citations verified in the artifact\n" + f" source-of-record sha256 {digest}\n" + f" recorded in {md.name}.sha256") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/validate_serial.jl b/validate_serial.jl index f33d647..77128c2 100644 --- a/validate_serial.jl +++ b/validate_serial.jl @@ -1,5 +1,21 @@ #!/usr/bin/env julia # Serial-reference CSV for validating biofilms_potts_jacc.jl. +# +# THIS CHECK HAS A DETECTION FLOOR, AND IT IS NOT REDUNDANT WITH THE ONE BESIDE +# IT. Perturbing the melanin coefficient in compute_delta_H_terms from 0.5 to +# 0.5000001 leaves every line below byte-identical; 0.5 to 50.0 fails it. A +# ~2e-7 shift in ΔH moves exp(-ΔH/T_cpm) by ~4e-8, and that has to straddle one +# of roughly 6.4M uniform draws before a single move flips and the trajectory +# diverges. So this file guards the TRAJECTORY, at the scale where a trajectory +# can be said to have changed -- it does not certify bit-exactness of the +# acceptance arithmetic. +# +# That is a third kind of check-that-cannot-fail: not always-green and not +# always-red, but CONDITIONALLY green with the condition unstated. Stated now. +# tests/delta_h_decomposition.jl is the exact half of the pair -- it asserts +# under `===` that the four terms sum to the scalar on every sampled move, with +# a control showing summation order is observable on 15.7% of them. Green here +# is necessary and not sufficient; neither file alone covers what both do. # Runs the untouched serial script (minus its CairoMakie section) and # prints the identical CSV lines: CSV,seed,species,vol,ncells,mel,survived #