A refresh began from a crash, one refusal at a time - #82
Merged
Merged
Conversation
`SentinelDriftError` fails the build the first time a cell holds something that reads like a missing-data marker in a field nobody has reviewed for it. That is the right behaviour and a poor way to find out: it reports one cell, in one field, and says nothing about the next one, so a new retrieval is worked through one refusal at a time. `python -m perimeter.survey` (`make survey`) reads the same files the build reads and reports every candidate at once, per measured field, with counts. It declares nothing: no schema, no registry edit, and no part of the build reads its output. Every candidate carries `basis: unreviewed`, in `survey.json` and in the `survey.md` draft written in docs/MARKERS.md's shape. The sentinel decision moved out of `FieldSpec.classify` into `FieldSpec.undeclared_marker`, which both callers now use, so the survey and the gate read one list rather than two that can drift. Three distinctions carried over from the artifacts: a field above the listing bound reports its distinct count and says the listing was withheld, never an empty list; a field with no published domain reports null for values outside one, never 0 (ADR-0010); a numeric field in which nothing parsed as a number has no zero share, not a zero one.
The first run of the negative control on this test passed with the sabotage in place, and that first run is the honest measurement. The control reordered `classify` so the sentinel net ran before the recorded-absence check, which is the realistic way the two readers come apart. It stayed green because the planted values were only `SUSPECTED_SENTINELS`, which is lowercase, while `recorded_absences` holds the publisher's own casing: `None` for a street type, `N/A` for a propane-tank distance. Nothing in the fixture reached the branch where the order decides anything. Planting each field's own declared values alongside the vocabulary makes the control fire, on `PROPANETANKDISTANCE` and `N/A`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
A refresh began from a crash.
SentinelDriftErrorfails the build the first time a cell holds something that reads like a missing-data marker in a field nobody has reviewed for it. That is the right behaviour and a poor way to find out: it reports one cell, in one field, and says nothing about the next one. Working through a new retrieval meant running the build, reading a refusal, reviewing a field, and running it again, once per undeclared marker in the file.What changed
python -m perimeter.survey(make survey) reads the same files the build reads and reports every candidate at once. Per measured field: the distinct values with counts, the values that read like a missing-data marker the field has not declared, the values outside a published domain, and for a numeric field how many records hold a zero. Output issurvey.jsonand asurvey.mddraft indocs/MARKERS.md's shape.It declares nothing. No schema, no registry edit, and no part of the build reads its output. Every candidate carries
basis: unreviewed, in both files and in the artifact's own header text, so it cannot be mistaken for a decision somebody made. The sequence is survey, review, declare, build, diff, and the reviewing step is a person's.The survey and the gate read one list. The sentinel decision moved out of
FieldSpec.classifyintoFieldSpec.undeclared_marker, which both callers now use. A survey carrying its own copy of the rule would go quiet at exactly the moment the rule moved.SentinelDriftErroralso now names the survey command, so the first refusal of a refresh points at the tool that lists the rest.Three distinctions carried over from the artifacts, because this module is a third place they can be lost:
nullfor values outside one, never0. ADR-0010.A zero the registry has already ruled on is reported and not flagged.
YEARBUILTdeclares the literal0, and re-asking that question every run is how a review aid stops being read.What it found on the pinned retrieval
Run over
data/raw/at the 2026-08-07 pin (132,522 DINS records, 23,334 FRAP records, 4.6s):No undeclared marker candidates in either file. The registry covers every marker in the pinned retrieval, which is what a green build already implied and is now measured rather than inferred.
Five numeric fields whose zeros nobody has ruled on, four of them severe:
NOOFCARSONPROPERTYNOOUTBUILDINGSDAMAGEDNOOUTBUILDINGSNOTDAMAGEDNUMBEROFUNITPERSTRUCTUREASSESSEDIMPROVEDVALUEThis is ADR-0006's shape exactly, and it is the same shape as
YEARBUILT: 58,411 structures recorded as containing zero units is not a plausible reading of a count. Nothing here declares any of them and no artifact or page moves in this pull request. They are a reviewer's call and are written up as a separate issue.One check on the arithmetic worth stating: the survey independently reproduces
YEARBUILT's 12,148 zeros, the figure section 7 ofdocs/MARKERS.mdalready records, and correctly reports it as declared rather than open.How it was verified
make lint,make format,mypy --strict: green.uv run pytest: 978 passed, branch coverage 98.80% overall and 99% onsurvey.py.Two negative controls, both fired.
The survey and the gate read one list. Baseline
git hash-object src/perimeter/schema.py=e3bd6ca…. Reorderedclassifyso the sentinel net ran before the recorded-absence check, which is how the two readers realistically come apart; re-hashed and asserted the hash moved to7e932c7…. Restored and asserted the hash returned toe3bd6ca…exactly, with__pycache__cleared between every run.The first run of this control passed, and that first run is the honest measurement. The planted values were only
SUSPECTED_SENTINELS, which is lowercase, whilerecorded_absencesholds the publisher's own casing (Nonefor a street type,N/Afor a propane-tank distance). Nothing in the fixture reached the branch where the order decides anything: the fixture sat where the failure was impossible. Widening it to plant each field's own declared values in their published casing made the control fire, onPROPANETANKDISTANCEandN/A. That widening is its own commit.Absence is not a value. Baseline
git hash-object src/perimeter/survey.py=b032766…. Changed the no-published-domain branch fromNoneto(); hash moved tocc030db…;test_no_published_domain_is_null_and_not_an_empty_listwent red namingSITEADDRESSandassert () is None. Restored, hash back tob032766…exactly.The determinism test runs across processes, not twice in one interpreter, under
PYTHONHASHSEEDvalues 0, 1 and 524287, over the full ten-record DINS fixture rather than one crafted row. A companion test pins that the fixture holds at least eight fields with more than one distinct value, so the determinism test cannot quietly become vacuous: one row is always in order.Closes #66
Prepared with AI assistance; reviewed before submission.