Skip to content

Annual IO LULC series for bulk, necr, lnth, kotl (#79) - #82

Merged
NewGraphEnvironment merged 5 commits into
mainfrom
79-annual-io-lulc-series-2017-2023-for-bulk
Sep 5, 2026
Merged

Annual IO LULC series for bulk, necr, lnth, kotl (#79)#82
NewGraphEnvironment merged 5 commits into
mainfrom
79-annual-io-lulc-series-2017-2023-for-bulk

Conversation

@NewGraphEnvironment

@NewGraphEnvironment NewGraphEnvironment commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes #79.

03_lulc_classify.R built years as the change-interval endpoints plus the midpoint, so every
area published 2017/2020/2023 only. drift's dft_rast_break_class() needs the full annual series
to tell a sustained land-cover switch from flicker. lulc_annual: true in area.yml makes years
the whole interval; the transition is untouched either way, because it reads yrs
(change_interval), never years.

Turned on for bulk, necr, lnth, kotl and step 3 re-run for each.

Scope changed during planning, and the issue body was rewritten to match

PINE is out. data/pine/ has no provenance.json and its rasters/bt_ff04/*.tif are dated
2026-07-12 — before flooded 0.5.0, the bankfull-units vintage CLAUDE.md calls dead, not
merely superseded
. Step 3 there would classify land cover over a floodplain the repo has already
declared wrong and stamp fresh landcover provenance beside two absent sections. PINE and MCGR are
the only 2 of 23 area dirs in that state; both are tracked by #76, which now carries a note asking
for PINE's annual run to happen there so it is not run twice.

The proposed A/B gate could not have failed, so it was replaced. drift's stac_cache_key()
excludes years, and dft_stac_fetch builds the STAC query as min(years)..max(years) — identical
for three years and seven. The committed log
scripts/floodplain_lcc/logs/runs/20260902_032619_run-area_neexdzii_prov-m4.log:461 already
recorded 14 items returned (2 tiles x 7 years) for a three-year request. Comparing the shared
years across a 3-year and a 7-year run would have asserted that a file equals itself.

The 23.6 min cost figure described a path these runs do not take. No area sets tile_size in a
committed config, and CLAUDE.md records tiling as benchmarked and rejected under #8. Untiled
numbers are below.

The change

  • lulc_annual in fp_read_config beside tile_size, with an FP_LULC_ANNUAL env twin — the
    comment at run_area.R:73-77 exists specifically to forbid committing a flag to the parity
    fixture and reverting it.
  • Two guards, because the failure is silent in both places. The config value must be a single
    unquoted logical: yaml gives TRUE for true/yes/on, but a quoted "true" is a character
    vector that isTRUE() reads as off, so the area would run three years under a config that
    reads as annual. And FP_LULC_ANNUAL takes a closed vocabulary rather than a truthiness
    test — %in% c("1","TRUE",...) reads a typo as FALSE, the same silent-off failure one layer
    out. That second one was found reviewing my own diff, after writing the first.
  • No new available_years guard. The issue asked for one; 03:90-95 already does
    setdiff(years, lc_available), which covers a seven-element set unedited.
  • Nothing recorded in provenance for the flag itself. inputs$years already carries the
    resolved set and provenance-check.R already asserts it matches the digest year set. A boolean
    beside the vector it produced is a second source of truth, and recording it would force a
    KEYS_LANDCOVER edit for no information.
  • lulc_annual is area-owned: the annual areas span three regions, so a region file must never
    set or clear it. region_config-check.R asserts it survives a region run, and the assertion was
    proven to fire — adding lulc_annual to FP_REGION_OWNED turns it PASS -> FAIL.
  • README prose drops the literal year list rather than restating a new one; a count in prose is
    what Modernize the README, publish it via GitHub Pages, and set the repo description + homepage #77 exists to stop, and this is per-area now. Re-rendered, determinism check green on all
    three properties.

The runs

Step 3 only, so every area's floodplain geometry and sub-basins are the bytes step 2 last wrote.
Smallest area first on each machine, so a scaling data point landed before the large one.

area machine bbox Mcells wall peak RSS change patches
necr m4 55 9.2 min 17.8 GB 5 692
kotl m4 203 32.4 min 54.3 GB 4 929
lnth m1 62 14.4 min 16.5 GB 2 753
bulk m1 168 34.9 min 20.6 GB 7 161

49.3 min wall against 91.9 sequential. bulk's 7 161 change patches match the figure
CLAUDE.md records for its 2026-09-02 run exactly — independent corroboration that the transition
did not move.

Peak RSS does not track grid size, and I am not claiming to know why. KOTL at 203 Mcells peaked
2.6x higher than BULK at 168 Mcells; NECR at 55 Mcells peaked above LNTH at 62. The two large runs
were on different hosts (m4 128 GB, m1 64 GB), and the plausible cause is terra sizing its working
set against available RAM — so the number may describe the host rather than the job. It was not
isolated and should not be quoted as a per-area requirement. What the data does support: 64 GB
sufficed for the largest area run on it.

Acceptance — a named expected-failure set, greppable rather than eyeballed

provenance_ab-compare.R reports a differing inputs_hash as a failure, and under this change
landcover[<scen>].inputs_hash must differ. It also fails when run.datetime_utc did not move, and
steps 1-2 deliberately do not re-run. So reading its exit code would have inverted the result:

entry inputs outputs datetime
landcover[<scen>] DIFFER (7 years, 7 digests, new item_hash, drift 0.8.0 -> 0.13.0) same moved
network[*], floodplain[*] same same SAME

All four areas returned rc=0 on the full set: years = 2017..2023, seven per-year digests, the
2017/2020/2023 digests unchanged element-wise, transition_content_sha256 and
transition_patches unchanged, outputs_hash unchanged, inputs_hash moved, exactly seven
classified_* gpkg layers and seven .tifs with no eighth, provenance-check.R green, and
bridge-check.R green on the three areas carrying attribute_by.

Baselines were backed up and asserted real (schema v2, three years, digests present) before
anything overwrote them — a v1 record would have degraded the acceptance to nothing, which is #73's
trap.

Disturbance attribution survived on all four (in_fire / in_harvest populated), m4 reaching m1's
fresh-db over tailscale. Not incidental: readme_functions.R stops when those columns are
missing, and bulk is the README's FIG_AREA.

Splitting across two machines needed a control first

Anything differing between the machines is a confound. m4 was levelled to m1 on drift 0.8.0 ->
0.13.0, sf -> 1.1.2, gdalcubes -> 0.7.4. The drift bump is not cosmetic: 0.8.0 fetches a
single STAC page
, and paging to exhaustion landed at 0.10.0 — it would have truncated KOTL's item
set into a wrong raster with no error. An 8-operation capability probe ran before and after with no
regression (versions are not the assertion).

terra could not be matched — m1 has 1.9.34, CRAN current is 1.9-46 — so it was left at 1.9.11,
which made terra the single remaining variable and the control a test of exactly it:

field m1 baseline m4 control
outputs_hash sha256:504624f6… identical
transition_content_sha256 sha256:1e379aee… identical
transition_patches 2032 2032
2017 / 2020 / 2023 digests all three identical
terra 1.9.34 1.9.11
drift 0.8.0 0.13.0

So the content survived a drift minor jump and the terra gap. necr and kotl then reproduced the
same result on real published areas
— regenerated on m4 under terra 1.9.11 / drift 0.13.0, shared-year
digests matching baselines built on m1 under terra 1.9.34 / drift 0.8.0. That is
fp_raster_content_sha256() (#64) demonstrated rather than asserted.

Every year was genuinely re-fetched. All four baselines were built under drift 0.8.0, whose
untiled cache keys predate the 0.10.0 change, so nothing was cache-served. That matters: drift's
stac_cache_key() excludes years, so a warm cache would have made the shared-year assertion
vacuous. A 1.1 GB copy of m1's cache was rsynced to m4 beforehand expecting a hit; it did not hit,
for that reason. Harmless, but the rationale was wrong and is recorded rather than repeated.

What a consumer will see move — neither is a defect

Both flagged on stac_floodplains_bc#59:

  • floodplain_landcover.gpkg bytes move for every area even where content does not — rewriting
    one layer into an existing GeoPackage is not byte-stable (GeoPackage writes are not byte-deterministic: gpkg_contents.last_change churns every rebuild #45). Byte equality answers "same
    build?", not "same content?".
  • nge:landcover_key moves for all four with no land-cover change, because the publish layer
    maps it to inputs$item_hash, built from the requested years — seven year-lines instead of
    three, over an item set the widened request did not change. Item counts are per-AOI, not a
    constant: necr and kotl each record 7 ids, one per year.
  • lulc_summary.rds gains four years of rows. Nothing in this repo reads its contents
    (readme_functions.R builds both figures from the GeoPackages, run_region.R tests existence
    only), but scripts/floodplain_lcc/README.md calls it the store the report reads — a report repo
    plotting land cover by year off it will silently gain four categories.

Follow-ups filed rather than folded in

Review findings closed in fc3b5ce

An adversarial review of the branch diff returned seven findings. Four were real and are fixed:

  • An empty lulc_annual silently ran three years. lulc_annual:, ~ and null all parse to
    NULL, so the !is.null() short-circuit skipped the type guard and isTRUE(NULL) is FALSE —
    the exact silent-off class the guard exists to close, reached by blanking a value or commenting
    out the true. Measured on all three spellings; now keyed on %in% names(cfg).
  • The drift floor was two minor versions low, and this branch is what documented it. fp_lulc
    asserted >= 0.6.0 (where tile_size arrived) while the evidence log states that before
    0.10.0 the fetch pages nothing, so an AOI spanning more than one page is built from a partial
    item set — a wrong raster, silently. Correctness floor, not a feature floor, and KOTL is one of
    the four areas turned annual. Raised, with CLAUDE.md Prerequisites to match.
  • The one-way door had no detector. provenance-check.R only compared the digest year set
    against inputs$years — two values the same run wrote, which cannot disagree. Nothing read
    rasters/<scen>/ or the gpkg layer list, so a reverted area recorded three years, passed green,
    and kept four orphan layers and four orphan tifs (Stale transition layers published alongside the current one (three layers, two obsolete) #55's class, which gpkg_prune-legacy.R does
    not sweep). 7c now reconciles both artefact year sets. Proven to fire: mutating necr's
    provenance to three years turns both arms red naming the disk and recorded sets; the restore
    leaves it green.
  • The env vocabulary was narrower than the config'slulc_annual: y is legal YAML but
    FP_LULC_ANNUAL=y was refused, as was T/F. They now agree, and the value is trimmed.

Also: CLAUDE.md gained the lulc_annual bullet its sibling keys all have (plus the one-way-door
and RSS caveats, and a note that FP_LULC_ANNUAL is inherited by run_region.R's children), and I
corrected a number I had generalized wrongly — the 14-item STAC response is neexdzii's two-tile
figure, not the four areas'. Fixed here, in the evidence log and in stac#59.

Verified clean and deliberately left alone: the four area.yml appends touch nothing else and
cannot land inside another key's block; no live code assumes three years; no three-year claim
survives in the README; change_interval length validation is pre-existing and unchanged in kind.

37 guard paths re-exercised after the fixes, all passing. provenance-check.R green on all four
areas plus the fixture on both machines, with the new arms confirmed by grep rather than exit
code.

Verification

Rscript scripts/floodplain_lcc/provenance_ab-compare.R <baseline>.json data/<area>/provenance.json <area> before after
Rscript scripts/floodplain_lcc/provenance-check.R <area>   # 7c now reconciles classified years vs artefacts
Rscript scripts/floodplain_lcc/bridge-check.R <area> <scenario>     # scenario is NOT optional: it defaults to co_ff04
Rscript scripts/floodplain_lcc/region_config-check.R
bash scripts/readme_determinism-check.sh && python3 scripts/readme_content-check.py

Evidence: scripts/floodplain_lcc/logs/20260905_lulc-annual_split-run.md, over the gitignored
logs/runs/20260905_lulc-annual_* set.

Closes #79.

🤖 Generated with Claude Code

https://claude.ai/code/session_014ZBWEFxANHj7gRrmQYcENt

NewGraphEnvironment and others added 5 commits September 5, 2026 11:51
Annual IO LULC series for bulk, necr, lnth, kotl. Scope corrected during
planning: PINE dropped (pre-flooded-0.5.0 AOI, tracked by #76), the proposed
A/B gate replaced with a provenance_ab-compare expected-failure set, and the
run split across m1 and m4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZBWEFxANHj7gRrmQYcENt
`03_lulc_classify.R` built `years` as the change-interval endpoints plus the
midpoint, so every area published 2017/2020/2023 only. drift's
`dft_rast_break_class()` needs the full annual series to separate a sustained
land-cover switch from flicker.

`lulc_annual: true` in `area.yml` makes `years` the whole interval. The
transition is untouched either way -- it reads `yrs` (change_interval), never
`years` -- so turning the flag on adds classified years and moves no transition.
The `available_years` guard already covered a seven-element set unedited.

Turned on for bulk, necr, lnth, kotl. PINE is deliberately NOT in that list: its
`data/` predates flooded 0.5.0, so step 3 there would classify land cover over a
floodplain the repo has already declared dead rather than superseded (#76).

Two guards, because the failure is silent in both places:

- the config value must be a single unquoted logical. yaml gives TRUE for
  true/yes/on, but a QUOTED "true" is a character vector that `isTRUE()` reads as
  off, so the area would run three years under a config that reads as annual.
- `FP_LULC_ANNUAL` takes a CLOSED vocabulary rather than a truthiness test.
  `%in% c("1","TRUE",...)` would read a typo as FALSE, which is the same
  silent-off failure one layer out.

`lulc_annual` is area-owned, not region-owned: the five annual areas span three
regions, so a region file must never set or clear it. `region_config-check.R`
now asserts it survives a region run, and the assertion was proven to fire --
adding `lulc_annual` to `FP_REGION_OWNED` turns it PASS -> FAIL.

Nothing is recorded in provenance for the flag itself. `inputs$years` already
carries the resolved set and the guard already asserts it matches the digest
year set; a boolean beside the vector it produced is a second source of truth.

README prose drops the literal year list rather than restating a new one -- a
count in prose is what #77 exists to stop, and this is now per-area. Rendered;
determinism check green on all three properties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZBWEFxANHj7gRrmQYcENt
Step 3 re-run with `lulc_annual: true` for bulk, necr, lnth and kotl. Steps 1-2
untouched, so every area's floodplain geometry and sub-basins are the bytes step
2 last wrote.

All four pass the full acceptance set: `years` = 2017..2023, seven per-year
digests, the 2017/2020/2023 digests unchanged element-wise,
`transition_content_sha256` and `transition_patches` unchanged, `outputs_hash`
unchanged, `inputs_hash` moved, exactly seven `classified_*` layers and tifs
with no eighth, `provenance-check.R` green, `bridge-check.R` green where
`attribute_by` is set. bulk's 7,161 change patches match the figure CLAUDE.md
records for its 2026-09-02 run.

The acceptance is greppable rather than eyeballed, because
`provenance_ab-compare.R` reports a differing `inputs_hash` as a FAILURE and
under this change it must differ. The expected failure set is written down in
the evidence log: landcover inputs DIFFER / outputs same, and network and
floodplain datetimes SAME because steps 1-2 did not re-run.

Splitting the run needed a control, since anything differing between the two
machines is a confound. m4 was levelled to m1 on drift (0.8.0 -> 0.13.0; >=
0.10.0 is not optional, 0.8.0 fetches a single STAC page and would have
truncated KOTL's item set into a wrong raster with no error), sf and gdalcubes.
terra could not be matched, which left it the only variable, and neexdzii on m4
reproduced m1's `outputs_hash`, transition digest, patch count and all three
per-year digests across terra 1.9.11 vs 1.9.34 AND drift 0.8.0 vs 0.13.0. necr
and kotl then did the same on real published areas.

Every year was genuinely re-fetched: all four baselines were built under drift
0.8.0, whose cache keys predate the 0.10.0 change, so nothing was cache-served
and the shared-year assertion is not vacuous.

Peak RSS does not track grid size -- KOTL at 203 Mcells peaked at 54.3 GB and
BULK at 168 Mcells at 20.6 GB, on different hosts. Recorded with that caveat
rather than as a per-area requirement, because it was not isolated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZBWEFxANHj7gRrmQYcENt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZBWEFxANHj7gRrmQYcENt
…ector (#79)

Seven findings from an adversarial review of the branch diff. Four are fixed
here, three were verified clean or left alone deliberately.

An EMPTY `lulc_annual` silently ran three years. `lulc_annual:`, `~` and `null`
all parse to NULL, so the `!is.null()` short-circuit skipped the type guard
entirely and `isTRUE(NULL)` is FALSE -- the exact silent-off class the guard
exists to close, reached by blanking a value or commenting out the `true`.
Measured on all three spellings. Now keyed on `%in% names(cfg)`, which is TRUE
for a present-empty key while `lulc_annual: false` stays legal.

The drift floor was two minor versions low, and this branch is what documented
it. `fp_lulc` asserted `>= 0.6.0` (where `tile_size` arrived) while the branch's
own evidence log states that before 0.10.0 the fetch issues a single
`get_request()` with no paging, so an AOI spanning more than one page is built
from a partial item set -- a wrong raster, silently, with `item_ids_complete`
structurally unable to report it (#81). That is a correctness floor, not a
feature floor, and KOTL is one of the four areas this branch turned annual.

The one-way door had no detector. `provenance-check.R` only ever compared
`classified_content_sha256`'s year set against `inputs$years` -- two values the
same run wrote, which cannot disagree. Nothing read `rasters/<scen>/` or the
gpkg layer list, so an area reverted to three years recorded three, passed
green, and kept four orphan `classified_*` layers and four orphan tifs (#55's
class, which `gpkg_prune-legacy.R`'s transition-only pattern does not sweep).
7c now reconciles both artefact year sets against `inputs$years`. Proven to
fire: mutating necr's provenance to three years turns both arms red naming the
disk and recorded sets, and the restore leaves it green.

The env vocabulary was narrower than the config's -- `lulc_annual: y` was legal
in YAML but `FP_LULC_ANNUAL=y` was refused, and `T`/`F` too. They now agree, and
the value is trimmed so a trailing newline from a shell capture is not a typo.
Also noted that `FP_LULC_ANNUAL` is inherited by `run_region.R`'s children, so
setting it for a region run flips every not-yet-cached group with no trace in
any `area.yml` -- which interacts badly with the one-way door.

CLAUDE.md gained the `lulc_annual` bullet its sibling keys all have, the
one-way-door and RSS caveats, the corrected drift floor, and a note on bulk's
entry that it is annual since 2026-09-05 with its recorded numbers unmoved.

Corrected a number I got wrong: the evidence log generalized neexdzii's 14-item
STAC response to all four areas. Measured, necr and kotl each record 7 item ids,
one per year, on one-tile AOIs. Fixed here and in stac#59. Also flagged that
`lulc_summary.rds` now carries seven years of rows, which nothing in this repo
reads but a report repo might.

Verified clean and left alone: the four `area.yml` appends touch nothing else
and cannot land inside another key's block; no live code assumes three years;
the README carries no surviving three-year claim; `change_interval` length
validation is pre-existing and unchanged in kind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZBWEFxANHj7gRrmQYcENt
@NewGraphEnvironment
NewGraphEnvironment merged commit 2826240 into main Sep 5, 2026
@NewGraphEnvironment
NewGraphEnvironment deleted the 79-annual-io-lulc-series-2017-2023-for-bulk branch September 5, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Annual IO LULC series (2017-2023) for bulk, necr, lnth, kotl: rerun step 3 with every year

1 participant