Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,46 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project a

## [Unreleased]

### Fixed, thirty fields published a zero for a comparison that never happened

- **A field with no published coded-value domain reported `outside_published_domain: 0`.**
`FieldSpec.outside_domain` answers False for every cell of a field the layer publishes no
domain for, and `field_coverage` summed that answer for every field alike, so thirty of
the fifty-four measured fields published a zero meaning "nothing was counted" in the same
key where `CAUSE` publishes a zero meaning "counted, none found". `FIRE_NAME`, `UNIT_ID`,
`COUNTY` and twenty-seven others are free text; both publishers say which of their fields
they constrain. The zero said the file and the domain agree, about a domain that does not
exist. The four out-of-domain measures are now `null` together for such a field, the way
`present_tenths_pct` is already `null` where there is no denominator, and zero keeps the
narrow meaning it should always have had. See ADR-0010.
- **The fact was known one layer down and lost one layer up.**
`tests/test_schema.py::test_free_text_fields_have_no_domain_to_be_outside_of` has always
asserted that a free-text field has nothing to be outside of. Nothing carried that into
the counting, and nothing tested what the count published, so the artifact contradicted
the test in the same suite. `tests/test_measurements.py` now separates the two zeros at
the count, `tests/test_artifacts_and_pages.py` checks both sides of the line in both
published artifacts, and both new tests fail against the previous code.
- **Both pages say it in words rather than leaving the row silent.** A row with no note
about the published domain read the same whether the domain held every value or did not
exist. Every free-text row now carries "The layer publishes no coded-value domain for this
field, so no value here is counted as outside one", and the paragraph under each field
table names all three states a row can be in, so silence means exactly one of them.
- **The capped list of named out-of-domain values now says how many it names.**
`outside_published_domain_values` stops at twelve so a field with a thousand spellings
does not publish a thousand keys. Nothing said it stops. No field reaches the cap today,
which is why nothing has been misread yet; a truncated list that does not declare itself
reads as the whole set. `outside_published_domain_values_listed` is published beside
`outside_published_domain_distinct`, and where they differ the list stopped.
- **`site/` was rebuilt from the acquired files, and `make site-check` is what keeps it
honest.** `tests/test_published_site_is_current.py` compares the shape of the published
artifacts, so the new key would have failed the build until `site/` was rebuilt; it
compares pages only outside `<main>`, so the thirty new page notes would have passed
unnoticed. `make site-check` builds from `data/raw/` into `build/site-current` and diffs
it against `site/`, never writing into `site/`, and refuses outright when the acquired
files are absent, because a check that could not run is not a check that passed. It
cannot run in CI: those files are not in git and never will be. Verified to fail on both
inputs it exists to catch, a drifted page and a missing source file.

### Fixed, the committed ruleset would have locked the owner out on first apply

- **`.github/rulesets/main.json` carried `"bypass_actors": []`.** No ruleset has ever
Expand Down
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ so it exits 0 on drift, while `uv lock --check` and `uv sync --locked` both exit
structural floor and the contrast measurement still run if node is unavailable, but a
change to `render.py` is not finished until `make pages` is green.

## After a change to what the pages publish

`site/` is committed, is what the Pages workflow uploads, and is built from CAL FIRE's
acquired files, which are not in git and never in CI. So a change to `render.py`,
`artifacts.py`, `coverage.py` or the field registry leaves the published pages saying the
old thing until somebody rebuilds them.
`tests/test_published_site_is_current.py` catches everything about that which can be
decided without the acquired files: the chrome, the provenance tables, the quoted caveats,
the three-state key, and the shape of both JSON artifacts. It cannot decide a count.

On a machine holding `data/raw/`, run:

```sh
make site # rebuild the committed site/ from the acquired files
make site-check # or, to check without rewriting: build elsewhere and diff
```

`make site-check` refuses rather than passing when `data/raw/` is absent. A check that
could not run is not a check that passed.

## The rule that governs changes here

This project publishes counts about other people's data. Two things follow.
Expand Down
30 changes: 29 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.PHONY: verify lock-check sync lint format typecheck test audit site site-offline \
acquire pages node-sync htmlvalidate a11y node-audit determinism \
site-check acquire pages node-sync htmlvalidate a11y node-audit determinism \
browser-sync a11y-browser browser-audit

# CI / `make verify` body: the two MUST stay byte-for-byte identical.
Expand Down Expand Up @@ -51,6 +51,34 @@ site:
--dins data/raw/dins_postfire.json \
--out site

# Is the committed site/ what the current pipeline produces from CAL FIRE's files?
#
# tests/test_published_site_is_current.py decides everything about site/ that can be
# decided without those files: the chrome, the provenance tables, the quoted caveats, the
# three-state key, and the shape of both JSON artifacts. What it cannot decide is every
# count, because the fixtures hold ten records each. This target is the rest of it, and it
# needs data/raw/, so it does not run in CI and is not part of `make verify`. Run it after
# any change to render.py, artifacts.py, coverage.py or the field registry, before
# committing.
#
# It builds into build/site-current and never into site/. A gate that regenerates the
# artifact where the committed copy lives repairs the drift it exists to report and then
# has nothing to report. It refuses outright when the acquired files are absent, rather
# than comparing site/ against a build that could not happen: a check that could not run
# is not a check that passed.
site-check:
@test -f data/raw/frap_perimeters.json || { \
echo "site-check: data/raw/frap_perimeters.json is missing. Acquire it (see PROVENANCE.md) and re-run; not checked is not checked." >&2; exit 2; }
@test -f data/raw/dins_postfire.json || { \
echo "site-check: data/raw/dins_postfire.json is missing. Acquire it (see PROVENANCE.md) and re-run; not checked is not checked." >&2; exit 2; }
rm -rf build/site-current
uv run python -m perimeter.cli \
--perimeters data/raw/frap_perimeters.json \
--dins data/raw/dins_postfire.json \
--out build/site-current
diff -r site build/site-current
@echo "site-check: site/ is byte-identical to a fresh build from data/raw/"

# The same pipeline over committed fixtures: runs anywhere, output flagged is_fixture.
site-offline:
uv run python -m perimeter.cli --fixture \
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ An ordinary value outside a published domain is deliberately *not* an error. It
and published as `outside_published_domain`, because it is a real thing about the file and
crashing on it would hide it.

A field the layer publishes **no** domain for reports that bucket as absent rather than as
zero. Thirty of the fifty-four measured fields are free text, so nothing there was
compared against anything; a zero would have said the file and the domain agree about a
domain that does not exist, and it read identically to `CAUSE`, which has a published
domain and holds every value in it. The artifacts publish `null` for those fields and the
pages say so in words. See ADR-0010.

## Which judgment calls rest on what

Twenty-eight of the fifty-four measured fields declare a marker, a code or a finding of
Expand Down
84 changes: 84 additions & 0 deletions docs/adr/0010-no-published-domain-is-not-zero-outside-one.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# A field with no published domain reports absence, not zero, outside one

- Status: Accepted
- Date: 2026-09-01
- Deciders: Chelsea Kelly-Reif

## Context

ADR-0002 decided that an ordinary value outside a published coded-value domain is
counted and published in its own bucket, `outside_published_domain`, rather than
raised as an error. That is right, and it was implemented as a property of the
value rather than of the field: `FieldSpec.outside_domain` answers False for any
cell whose field has no `domain_values`, and `field_coverage` summed that answer
for every field alike.

Thirty of the fifty-four measured fields have no published domain. `FIRE_NAME`,
`UNIT_ID`, `COUNTY`, `SITEADDRESS` and twenty-six others are free text, and both
publishers say plainly which of their fields they constrain and which they do
not. Summing "is this value outside the domain?" across a field that has no
domain produced `0`, and `0` was published: on both pages as a row with no note
about the domain, and in both JSON artifacts as
`"outside_published_domain": 0` with a distinct count of `0` and an empty value
object beside it.

That zero is a measurement nobody took. It is indistinguishable in the artifact
from `CAUSE`, which does have a published domain and does hold every value in it,
and it says the file and the domain agree about a domain that does not exist. It
is the same shape ADR-0006 found in `YEARBUILT`: a counter that had nothing to
count, publishing its emptiness as a finding. `tests/test_schema.py` already
carried `test_free_text_fields_have_no_domain_to_be_outside_of`, which asserts
exactly this at the `FieldSpec` level; the fact was known and was lost one layer
up, where the counting happens.

The list of named out-of-domain values has a second, smaller version of the same
problem. It is capped at `OUTSIDE_DOMAIN_VALUE_CAP`, twelve, so a field with a
thousand spellings does not publish a thousand-key object. Nothing said the list
was capped. No field reaches the cap today, so nothing has been misread yet; a
truncated list that does not say it is truncated reads as the whole set.

## Decision

The four out-of-domain measures are absent, together, for a field the layer
publishes no domain for. In the artifacts they are `null`, the same way
`present_tenths_pct` is `null` where there is no denominator. On the pages the
row says so in words: "The layer publishes no coded-value domain for this field,
so no value here is counted as outside one."

Zero keeps its meaning, and it is now the narrow one: the domain was published,
it was read, and the file holds nothing outside it.

Silence on a row means exactly one thing, and the paragraph under every
three-state field table says which: the field has a published domain and holds
nothing outside it. The three states a row can be in are named there, so a reader
does not have to work out what a missing note means.

`outside_published_domain_values_listed` is published beside
`outside_published_domain_distinct`: how many of the values found the object
actually names. Where they differ, the list stopped at the cap.

## Consequences

Two of the artifacts' field keys change type from `int` to `int | null` and one
object key from `{}` to `null`, for the thirty fields concerned. A consumer
summing `outside_published_domain` across fields has to skip nulls, which is the
correct arithmetic and was not available before: adding thirty zeros that meant
"not measured" to twenty-four that meant "measured, none found" produced a total
nobody could interpret.

Both measurement pages gain a note on every free-text row and a paragraph under
each field table. That is thirty rows of text bought for one distinction, and the
distinction is the project's subject.

The committed `site/` was rebuilt from the acquired files for this change.
`tests/test_published_site_is_current.py` would have caught the artifacts going
stale, because the new key changes the shape it compares; it would not have
caught the pages, which change only inside `<main>`. `make site-check` is added
for that: a full rebuild from `data/raw/` diffed against `site/`, refusing
outright when the acquired files are absent. It cannot run in CI, because those
files are not in git and never will be.

The observation that should supersede this record is a field whose domain is
published for some records and not others, which neither `FieldSpec` nor this
model can express: `domain_values` is one frozen set per field, and there is no
per-record answer to "was there a domain to check this against?".
Loading
Loading