Files
settled-reach/tooling/canvas_sources.py
T
jpmschweitzerandClaude Opus 5 3ec35b87c8 fix(client): the deep rungs were flat because relief_q fell off the wire (T-1213)
`relief_q` is the one field with signal below District — elev_q's 80 m steps
quantise sub-district detail away, which is precisely why relief_q was invented.
The server has encoded it since 5eb394b36 and the terrain layer has asked for it
by name ever since. step_canvas_protocol.gd's decode dictionary never listed the
key, so `canvas.get("relief_q")` was always null and the plane arrived nowhere.
The server half of that change landed; the protocol half did not.

That is the whole reason Region and below rendered as a flat wash. Measured plane
variety at District before the fix:

    {morphology: 1, elev_q: 11, relief_q: 0, moisture_q: 25, vegetation: 3}

A 0 there means ABSENT, not constant — a distinction the capture could not make
until this commit adds it, and the reason two earlier sessions read the flatness
as a missing generator rather than a missing key.

Also spends the field properly. It drove a stipple PROBABILITY only, so a ridge
and a plain differed in dot density, which at one pixel per cell reads as noise;
and `_ruggedness()` took absf(relief_q - 50), discarding the sign the server
deliberately preserved ("a hollow and a rise are different ground... the reverse
is not recoverable"). Relief now shades continuously and signed — rises lighten,
hollows darken — UNDER the stipple rather than instead of it. Ruggedness
(unsigned) and elevation (signed) are different questions and both are worth
asking.

Ladder, before -> after (tooling/atlas-flatness, lum p1-p99):

    Global    145.69 -> 145.69   unchanged, correct: relief_q is flat 50 at
                                 orbital rungs by construction
    Region     33.59 ->  71.01   2.1x
    District   13.72 ->  77.01   5.6x
    Quarter    11.01 ->  42.56   3.9x

Structure retention Global->Quarter: 7.6% -> 29%.

NOT finished, and the ticket says so: Region now reads as heavy speckle, because
ruggedness is real data instead of an elev_q-gradient fallback and far more cells
earn a mark than the T-1194 tuning assumed; District reads as soft blobby relief,
form without directionality. Both are grammar/tuning follow-ups on a channel that
finally carries signal.

0.4.9 is a REQUIRED bump. The disk cache stores the DECODED canvas, so every
earlier entry physically lacks the field and would keep rendering flat against a
build that reads it — the first bump in this series where a warm cache is wrong
about CONTENT, not merely stale. tooling/canvas_sources.py gains
step_canvas_protocol.gd for the same reason: it decides which planes exist, the
cache stores its output, and the T-1242 gate would not have flagged this fix
while the registry stopped at ui/.../step_canvas/.

Regression cover: every protocol test passed throughout the weeks the plane was
missing, because each asserted a field it already knew about and none asserted
the SET. There is now a test walking all eight dense planes of EncodedStepCanvas,
verified by disabling the fix and watching it fail by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 15:22:50 +02:00

176 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""
canvas_sources — single source of truth for the Atlas canvas-generation path set.
`project.yaml`'s `version:` is the Atlas disk cache's ONLY invalidation signal
(client/scripts/build_version.gd, D-255). A change to how a canvas is GENERATED
is therefore only half a change; the other half is bumping that version, or every
warm cache keeps serving canvases built by code that no longer exists.
Nothing enforced that pairing, and it broke five times: project.yaml's own
comments record 0.4.2 (lake_margin_q semantics), 0.4.3 (coast_warp_px at orbital
sampling), 0.4.4 (the D-255 extent inversion), 0.4.5 (the Global sentinel), and
0.4.6 (T-1237 one-course-per-river) — every one of them bumped *after the fact*,
the last only after T-1239 spent eight days diagnosing a map drawn from a canvas
whose generating code had been replaced. The failure is invisible to its author:
it reproduces only where a warm cache exists, so a cold checkout looks fine.
This module is the registry `tooling/check-canvas-version` intersects against,
kept here rather than inline in the hook for the same reason
`tooling/generator_sources.py` exists (T-1067): one list, one place, imported by
everything that needs it.
WHY THIS DELIBERATELY OVER-INCLUDES
-----------------------------------
The set is collected by GLOB, not hand-listed, and covers the whole atlas module
rather than a traced dependency closure.
`step_canvas.rs` directly imports ten sibling modules and those pull in more
(district_profile -> domain_warp/detail_scatter/coast_invention/..., layer1 ->
drainage/hydrology_equilibrium/features). A hand-maintained closure of that would
be wrong within a month, and being wrong here is silent — exactly the failure
this registry exists to stop. Globbing is self-maintaining: a module added in a
future split is covered the moment it exists.
The cost asymmetry is the whole argument, and it is the ticket's own ruling
(T-1242): a false positive costs one version bump and one round of cache misses;
a false negative costs another week of a wrong map. So when the choice is
"include a file that might not change canvas bytes" versus "risk missing one that
does", this includes it.
Fail closed on an empty glob, per generator_sources.py's precedent: an empty set
would silently pass every push.
Usage:
python3 tooling/canvas_sources.py --list
"""
import argparse
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# Server: the code that PRODUCES canvas bytes
# ---------------------------------------------------------------------------
# The whole atlas module. Canvas bytes are produced by step_canvas.rs out of
# layer1/TerrainAnalysis/district_profile/river_course/hydrology and a long tail
# of invention modules; see this file's header for why the boundary is the
# module rather than a traced import closure.
ATLAS_DIR: Path = REPO_ROOT / "server" / "src" / "atlas"
# The seed chain feeds every deterministic decision the cascade makes
# (step_canvas.rs: `use crate::seed::SeedChain`), so a change to seed derivation
# changes canvas bytes without touching atlas/ at all.
SEED_RS: Path = REPO_ROOT / "server" / "src" / "seed.rs"
# ---------------------------------------------------------------------------
# Client: the code that KEYS, STORES and INTERPRETS those bytes
# ---------------------------------------------------------------------------
# The step_canvas cluster: the disk cache and its index format, the in-memory
# cache, the request/transport layer that derives extents and spacing (and
# therefore cache KEYS), and the layers that decode the payload.
#
# The render-only members (terrain/annotation layers) are included on purpose.
# A pure draw change cannot make cached bytes wrong, so including them can force
# an unnecessary re-derive — but drawing the line *inside* this directory means
# hand-judging which file is "really" wire-shaped, and T-1237 changed the server
# course shape and the client annotation layer in the same commit. That judgement
# is precisely where a false negative would come from.
CLIENT_STEP_CANVAS_DIR: Path = (
REPO_ROOT / "client" / "ui" / "implant" / "apps" / "atlas" / "step_canvas"
)
# The accessor the cache reads its invalidation tag through.
BUILD_VERSION_GD: Path = REPO_ROOT / "client" / "scripts" / "build_version.gd"
# The wire codec, which decides which planes EXIST client-side.
#
# Added 2026-08-16 after T-1213 proved the omission expensive: this file's decode
# dictionary was missing `relief_q`, so the plane never reached the renderer and
# the deep rungs rendered flat for weeks. The gate would not have flagged the fix,
# because the registry only covered ui/.../step_canvas/ and this lives under
# scripts/protocol/.
#
# It belongs here for a second, sharper reason: the disk cache stores the DECODED
# canvas, so a decode change alters what a cached entry contains. Entries written
# before that fix have no relief_q key at all and keep rendering flat until the
# version moves — exactly the stale-cache class this registry exists to catch.
STEP_CANVAS_PROTOCOL_GD: Path = (
REPO_ROOT / "client" / "scripts" / "protocol" / "step_canvas_protocol.gd"
)
def _rust_sources(directory: Path, label: str) -> tuple[Path, ...]:
"""Every .rs file in `directory`, collected by glob and fail-closed."""
sources = tuple(sorted(directory.rglob("*.rs")))
if not sources:
raise RuntimeError(
f"{label} sources not found at {directory} — the canvas-generation "
"path set would be incomplete, and this check would pass every push"
)
return sources
def _gdscript_sources(directory: Path, label: str) -> tuple[Path, ...]:
"""Every .gd file in `directory`, collected by glob and fail-closed.
`.uid` sidecars are Godot bookkeeping and carry no behaviour, so they are
excluded — a uid churn should not demand a version bump.
"""
sources = tuple(sorted(directory.rglob("*.gd")))
if not sources:
raise RuntimeError(
f"{label} sources not found at {directory} — the canvas-generation "
"path set would be incomplete, and this check would pass every push"
)
return sources
def canvas_sources() -> tuple[Path, ...]:
"""Every file whose change may alter canvas bytes or their interpretation.
This registry itself is a member: loosening the set must be as visible as
any other canvas-generation change (generator_sources.py makes the same
call for the same reason).
"""
return (
Path(__file__).resolve(),
*_rust_sources(ATLAS_DIR, "server atlas"),
SEED_RS,
*_gdscript_sources(CLIENT_STEP_CANVAS_DIR, "client step_canvas"),
BUILD_VERSION_GD,
STEP_CANVAS_PROTOCOL_GD,
)
def relative_paths() -> tuple[str, ...]:
"""The registry as repo-relative POSIX paths, for matching git output."""
return tuple(p.relative_to(REPO_ROOT).as_posix() for p in canvas_sources())
def main() -> None:
parser = argparse.ArgumentParser(
description="Single source of truth for the Atlas canvas-generation path set"
)
parser.add_argument(
"--list",
action="store_true",
help="Print the canvas-generation paths, one repo-relative path per line",
)
args = parser.parse_args()
if not args.list:
parser.print_help()
sys.exit(2)
for path in relative_paths():
print(path)
if __name__ == "__main__":
main()