Commit Graph
100 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 5 de69bd70b4 feat(config): T-1280 — job log retention, and the corpse that would never die
Pruning happens at spawn time rather than on a schedule: a retention pass that
depends on someone remembering to run it is one that silently never happens.
reach jobs prune is the explicit escape hatch for reclaiming space now.

The cap was measured rather than guessed, which is why this ticket ran last. A
chatty short job writes ~1.8 KB across its three files, so 100 jobs is
single-digit megabytes even if a generator emits per-body progress — inside
.cache/, where being wrong costs disk and never data. SR_JOB_KEEP overrides it.

The interesting part is what "a running job is never pruned" has to mean. Not
"the file says running" — a process killed outright never updates its own
status, so that reading would make every crashed job immortal. Those are
exactly the ones that accumulate, so the naive rule produces the opposite of
retention: the only logs that never go away are the ones nobody wants. The
check consults the process table instead.

Verified both directions. Live, a running 30-second job survived a prune to
--keep 1. Pinned with a fixture holding a finished job, a corpse (record says
running, pid gone), and a genuinely live one — asserting the live one survives
and the corpse does not. Proven to fail by dropping the liveness check.

One false alarm worth recording: my first live test looked exactly like the bug,
showing a running job pruned. It was not — my commands ran two minutes apart, so
the "20-second" job had finished long before. The test was invalid, not the
guard. A timing-sensitive check across separate shell turns proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:27:25 +02:00
jpmschweitzerandClaude Opus 5 3b211a5450 feat(config): T-1279 — a detached failure reaches its caller
The non-negotiable from D-263, pointed at its worst hiding place: a foreground
command that swallows a failure at least does it in front of someone, while a
background runner that reports "started" and loses the failure does it where
nothing is watching.

Testing the two timing cases the ticket names — fails before the parent exits,
fails long after — needs a command slow enough to tell them apart, and every
verb in reach finishes in milliseconds. So `reach dev selftest` exists: emits
progress for N seconds, then optionally fails with a chosen code. A genuine
diagnostic rather than a test hook, in the dev domain the map already planned,
and the only way to answer "does streaming work here, can I tail it, does a
failure survive detach" by observation instead of argument.

The slow case is the one that proves the design. --detach returned in 75ms
while the child ran six seconds, so the parent was demonstrably gone long
before the child failed — and wait still relayed exit 7. That is the half of
the recording path only this case reaches, and why T-1277 moved completion
recording into the child.

Also pinned: --detach exits 0 for starting and SAYS "not succeeded" in words,
which the test asserts on rather than trusting the code to be read correctly;
a failed job nobody waited on shows as failed in jobs list; and every event a
detached job emits carries its job id.

Closed T-1278's open gap in passing — jobs log --follow had never run against a
genuinely long job because none existed. It now has: attached mid-flight,
streamed the remaining steps live, and caught the final verdict after the job
ended.

Proven to fail by making effective_exit_code always return 0 — the trap itself.
Both timing cases failed by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:14:23 +02:00
jpmschweitzerandClaude Opus 5 6f08cc9156 feat(config): T-1278 — the jobs domain, and typer.Exit is not a SystemExit
reach jobs list / status / log --follow / wait. A domain rather than core/,
because these verbs carry logic and state: they reconcile recorded status
against process liveness, tail a file from an offset, and relay an exit code.

Found a latent bug in already-committed code before building on it. typer.Exit
is a RuntimeError, not a SystemExit, so @handle_errors caught it like any other
unexpected exception: `raise typer.Exit(3)` inside a decorated command printed
"unexpected Exit: 3" and exited 1, silently discarding the requested code.
Nothing hit it because the check router had been converted to ReachError — but
jobs wait needs exactly this and it is what anyone would naturally write. Added
core/errors.ReachExit as the sanctioned control-flow exit, passed straight
through with no verdict. ReachError would have been wrong twice: a failure
verdict for a command that worked, and a demand for a fix= where there is no
remedy.

Reconciliation proved out on a real corpse rather than a simulated one — the
job stranded by the T-1277 bug, status "running" with its process long gone,
now reports as died. DIED is derived, never recorded, because a process killed
outright cannot write its own ending. It relays 137, never 0: a died job has no
exit code of its own and borrowing success points the exit-0 trap straight at
whatever gated on the run.

Second UTC bug of the same family as T-1276's: jobs list reported a job started
minutes earlier as running for 133m, because _parse used mktime on a UTC stamp
and silently added the offset to every duration.

console.render() is public now, so jobs log replays stored events through the
same path a live run prints them — a second renderer would drift, and the
divergence would surface exactly when someone is reading a log to find out what
went wrong.

test_jobs.py closes the gap T-1257 named: D-263 claims services are callable
without a CLI round trip, and nothing had ever demonstrated it, which left the
layering as unverified decoration. Every test here calls the service directly.

Not yet exercised, and said plainly: log --follow against a genuinely
long-running job. Nothing in reach runs long enough to tail yet. The offset
mechanics underneath are tested; the live loop waits for a slow domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:02:36 +02:00
jpmschweitzerandClaude Opus 5 c924b0934e feat(config): T-1277 — detach, and a failed job that looked busy
core/process.py spawns a child that outlives its parent: its own session, so a
signal to the parent's group or a timeout kill does not take the work with it;
re-execing reach by BARE NAME, because an absolute path would freeze the child
to whichever checkout was current at spawn time and silently run the wrong
source after a repoint; and streams kept separate exactly as in the foreground,
events to <id>.jsonl and real output to <id>.out.

Testing a case the ticket did not name found a real hole. Recording completion
inside @command looked right and was wrong: a child that fails BEFORE any
command runs — bad arguments, an unknown verb, an import error — never reaches
that decorator. `reach --detach check bogus` left its metadata reading
"running" forever with the process long gone. That is the exit-0 trap wearing a
new disguise and worse than the original, because a failed job that looks busy
sits somewhere nobody is watching, and a caller polling for completion would
wait indefinitely on something that failed in milliseconds.

So completion is recorded at the PROCESS's exit instead. main.py gains main(),
wrapping cli() in a single try/finally, and the entry point moves to
main:main. Every exit path now passes through one place. Removed from @command
rather than left in both — two writers of one field is how they drift.

Verified on three paths: success records done/0, a real drift failure records
failed/1, and the parse failure that exposed the hole now records failed/2.

One narrow conformance exemption, with its reason inline so it does not read as
an oversight: the no-domain-imports-core.jobs invariant fired on main.py,
correctly by its letter and wrongly by its purpose. main.py is not a command;
it is the entry point, and it already owns --detach.

Still open, and carried to T-1278: a child killed outright cannot record
anything, so jobs list must reconcile against process liveness rather than
trusting the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 16:46:19 +02:00
jpmschweitzerandClaude Opus 5 5d83e1d2eb feat(config): T-1276 — every invocation is a job, carried ambiently
Streaming as a decorator, first half. Each invocation of reach gets an id and
every event it emits is tagged with it, which is what will let a detached run's
log be read back and what correlates the lines of a run that streamed for nine
minutes. No command signature changed and no command imports core.jobs — that
is the point, per the D-263 amendment: a command must not know jobs exist,
because the alternative is call-site discipline wearing a different hat.

A ContextVar rather than a module global. A global is correct only until
something runs two invocations in one process — which a test harness or a
future batch verb does immediately, and which would then interleave two jobs'
events under one id with nothing reporting an error.

The job context is the OUTERMOST wrapper, and it has to be. @logged emits from
its finally and @handle_errors emits its verdict while unwinding, so a context
established inside either would already be reset by the time the two most
important events are written — leaving them the only untagged lines in the log,
and they are precisely the ones a detached run gets read back for.

Fixed in passing: the job id used local time while every event's ts is UTC, so
an id read 155327 beside its own first log line reading 13:53:27. Two hours
apart reads as a logging bug every time someone correlates them by eye.

New conformance invariant — nothing outside core/ may import core.jobs. My
first version of it inspected only the module path, so it missed
`from tooling.core import jobs`, where the name is in the import LIST and which
is the form anyone would actually write. It passed while checking nothing.
Rewritten to catch all three reachable forms and then verified by committing a
real violation, which it named by file and line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:59:39 +02:00
jpmschweitzerandClaude Opus 5 b5beda0df7 feat(config): T-1275 — bare reach is discovery, so it exits 0
Bare `reach` and bare `reach <domain>` printed help and exited 2, Click's
usage-error convention. Running reach with no arguments is the DISCOVERY
action — it is how the tool gets learned from nothing — and a caller that
branches on exit status would read its own onboarding as a failure. Now they
exit 0.

D-263's exit-code contract is untouched: it governs failures, and printing a
command list is not one. Verified across the whole matrix, because this change
flirts with the exit-0 trap that record opens with — bare 0, bare domain 0,
--help 0, unknown domain 2, unknown verb 2, real failure 1. All five are now
pinned as a sixth conformance invariant, since an exit code regresses silently
and nothing else would notice. Proven to fail by putting the 2 back.

The implementation also collapses a duplicated class. core/cli.py holds
ReachGroup with both shared behaviours — no-args-prints-help-and-exits-0, and
unknown-name-enumerates — and LazyDomainGroup now extends it instead of
subclassing TyperGroup directly, keeping only the laziness and the
domain-specific wording. The enumeration logic previously existed twice in
slightly different forms, which is how the root and the domains would have
drifted into disagreeing about their own conventions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:49:08 +02:00
jpmschweitzerandClaude Opus 5 91e25a3e7a docs(governance): D-263 — the primary user is an agent, and that changes things
Stated plainly because the record was quietly assuming otherwise: Jeroen runs
make and plays the game; the caller typing reach all day is Claude.

It resolves several arguments in the opposite direction from human-CLI
instinct. --help is a discovery mechanism rather than documentation, since it
is how the tool gets relearned from nothing every session — which makes the
domain list and closed-set enumeration load-bearing rather than polish. Output
volume is a context cost, so quiet-by-default is right for a better reason than
not spamming a hook. Latency matters less than legibility: nobody drums their
fingers at 300 ms, but a multi-minute silence is expensive because a wedge is
indistinguishable from work. And errors that name the next command are the
highest-value requirement here, because the reader is usually deciding what to
run next — "no" costs a whole exploratory turn.

One correction follows directly. D-263 had scoped streaming to "callers with no
escape — a human terminal, a Makefile, a git hook", reasoning that Claude
Code's background mode already solved the timeout for agents. That got the
audience backwards. Background mode solves the timeout and nothing else: it
returns when the process exits, so a nine-minute wedge still looks exactly like
nine minutes of work. Streaming is what makes a long run legible while it runs,
and reattach is worth most to the caller whose attention is not continuous.
Both are primary-user features, not fallbacks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:41:29 +02:00
jpmschweitzerandClaude Opus 5 6b31111cd2 docs(governance): D-263 — make and reach split by kind, streaming as a decorator
Two decisions taken before the 160-file move, because both change what the
move produces.

The Makefile has 84 targets and is today's front door, so "one CLI for all
repo tooling" was not yet true. The split is by what a target DOES: make keeps
genuine build and test orchestration, and targets that are really tooling
wrappers are retired in favour of reach verbs — retired, not wrapped. A
wrapper leaves two ways to invoke every tool, and then reach --help stops
being the answer to "what tooling exists" because the Makefile is still a
competing index. Two doors is the condition this record exists to end, so
keeping both would defeat it while looking like caution.

Streaming becomes a decorator rather than an API commands call. @command
already wraps every invocation, and that is exactly the seam where job
identity, progress correlation and detach belong: the decorator assigns the
job id, tags the events, and forks on --detach. A command must not know that
jobs exist. The alternative — each command opening a job and remembering to
close it — is call-site discipline wearing a different hat, and it fails the
same way the fortieth command into a porting session, with the failure
vanishing from the log and nothing to indicate anything is missing. Logging
and error handling are decorators for this reason; streaming is the third
cross-cutting concern, not a special case.

Consequent resequencing: T-1264 lands before the T-1250 move, so every ported
command arrives already streaming. Old scripts now retire per domain as each
port passes its parity test, rather than in one sweep at the end — a
continuous shrink, instead of months where every tool exists twice and an edit
can land in the dead copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:38:26 +02:00
jpmschweitzerandClaude Opus 5 91a57b8304 docs(config): T-1271 — the domain map, before anything moves
Every Python file and executable in tooling/ assigned to one of 15 domains,
with the ambiguous cases carrying their reasoning. The per-domain port tickets
are written from this rather than guessed, so their boundaries do not have to
be renegotiated halfway through a 160-file move.

Three things counting turned up that reading would not have.

The Blender carve-out is 35 files, not the 13 visible at top level — 22 more
are inside garment-fit/, which turns out to be a payload directory wearing a
domain's name. The epic said 35 and an earlier survey of mine said 14; the
epic was right. That is not cosmetic: `character` is a far smaller domain than
directory sizes imply, and a port ticket written from the listing would have
been wrong about both it and the carve-out.

The "28 singleton prefixes" were an artefact of splitting filenames on the
first token, which scattered coherent families — sculpt-star-map,
tune-star-map-topology and generate-star-map* are one group counted as three
orphans. Counting families instead, the genuinely ambiguous set is small
enough to enumerate with reasons.

And tooling/db/ is misnamed: it holds the audio/image/Trellis connectors and
wiki_sync, while the actual database work is in economy-db/. Naming a domain
after that directory would have carried the misnomer forward.

Judgment calls settled with reasons, since each sets a precedent. Registries
stay data rather than becoming verbs nobody would type. Gate tests do not
become a `test` domain implying a runner that does not exist. pql-migrate is
provenance — archived, not deleted and not importable. `pr` is a domain the
epic omitted, kept out of `dev` so dev does not become the drawer everything
ambiguous goes into. And `atlas` is overloaded across three unrelated places —
map data, terrain quality analysis, and systems.db index tables — which stay
with their owners rather than being collected into a domain whose only common
thread is a noun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:26:55 +02:00
jpmschweitzerandClaude Opus 5 49fa6ada95 feat(config): T-1249 — the contract is a decorator, and now a test
Every non-zero exit names the command that would fix it, and still exits
non-zero. Both halves matter; the second is the one that gets lost, because a
tool that explains itself beautifully and exits 0 looks MORE correct while
having silently disabled its own gate.

core/errors.py holds ReachError(message, fix=) and @handle_errors.
core/logging.py holds @logged, emitting through console rather than a second
sink — one output path, so there is nothing to drift. core/command.py composes
them, and the order is load-bearing: handle_errors wraps logged, so the logger
sees the original exception. Inverted, every failure would be recorded as
"SystemExit" and the log would say nothing about what went wrong while looking
like it worked.

core/ raises SystemExit, not typer.Exit. A service must be callable from a
test, another service, or a future second front end, and an exception type that
only makes sense inside a CLI leaks the transport into every layer.

The check router is retrofitted off its hand-rolled verdict-and-exit pattern —
exactly the boilerplate this removes — and test_check_parity.py passes
unchanged across the retrofit. That test predates the decorators and pins exit
codes against the old script, so it is independent evidence, not a test tuned
to match new behaviour.

Unknown domains and unknown verbs now enumerate what exists instead of only
saying no. That needed a shared group class, which collided with "no typer
outside main.py and router.py" — resolved by sharpening the invariant rather
than breaking it, since its purpose is that a SERVICE never knows it was called
from a CLI. Transport now lives in main.py, router.py and core/cli.py; never in
service.py, schemas.py or helpers.py. The upside is that cli.domain() carries
the settings that were previously per-router decisions, including the
load-bearing rich_markup_mode=None that one forgetful domain could have undone.

test_conformance.py makes five invariants executable, AST-based rather than
grep. Scoped to the package, not the 123 legacy scripts — and deliberately so:
as T-1250 moves each script into domains/, it lands inside the scope and the
rules start applying automatically, so the test's reach grows with the
migration.

Proven to fail before being trusted: removing @command and removing a fix= each
produced a failure naming the file, the line and the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 15:03:34 +02:00
jpmschweitzerandClaude Opus 5 1eb30a1460 chore(config): T-1263 — one permission rule for the whole tool surface
Bash(reach) and Bash(reach *) join .claude/settings.json beside the pql pair.
Two entries, not the one the ticket asked for: a rule ending in " *" does not
match the bare word, and bare `reach` is a real invocation now that it prints
the domain list. pql, make, cargo test and ruff check each carry a bare-form
entry alongside the wildcard for exactly this reason, and adding only the
wildcard would have left `reach` prompting while `reach check ...` did not.

This is the line Q-124 was actually filed about. Ten hand-written
Bash(tooling/...) entries each cover a single script and every unlisted tool
prompts; one command with subcommands is one rule covering everything. The ten
stay for now — the old scripts are still the working tools until T-1253.

On verification, since the ticket warned specifically against declaring this
done on the wrong evidence: real calls run clean, but that is NOT proof the
rule matched. The same calls succeeded before the rule existed — there was no
Bash(reach ...) entry in either settings file and no blanket grant — so the
session was already permitting them and the observation cannot distinguish "the
rule matched" from "the rule was never consulted". settings.json is read at
session start, so this cannot be self-verified from the session that wrote it.
Proof is a later session, in a prompting mode, where reach runs without asking.

One accepted limitation, documented rather than worked around: rules
prefix-match the whole command string, so an env-prefixed call like
SR_REPO_ROOT=... reach ... will still prompt. An environment override is a real
departure from normal invocation; the ordinary form is what needs to be
frictionless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:52:45 +02:00
jpmschweitzerandClaude Opus 5 5cdb3e9327 feat(config): T-1262 — parity is facts and exit codes, not bytes
schemas.py becomes pydantic, so the reference domain is the normal pattern
rather than an exception carrying a footnote. Frozen: a result is a statement
about what was found, and nothing downstream should edit the finding on its way
to being reported. pydantic stays off the --help path — test_lazy_domains still
passes, which is precisely the assertion that it loads with the domain and not
with the CLI.

The acceptance criterion could not be met as written, and that is the finding
worth keeping. It asked for byte-for-byte parity with the old script; D-263 was
amended after this ticket to give reach a streaming model that puts the verdict
on stderr, while the old script writes its success line to stdout. Measured:
the text is byte-identical in text mode, only the stream differs. Matching both
would mean abandoning streaming or special-casing every ported gate.

So parity is redefined, and it is stronger than bytes where it counts: exit
codes match exactly, no fact the old message carried is lost, and failures name
a remedy as a structured field. That governs every port in T-1251, not just
this one, so it is in D-263 rather than only here.

test_check_parity.py runs three paths — ok, drift, missing file — through both
implementations and compares. It builds a throwaway fixture repo and copies the
OLD script into it, because that script resolves its root from __file__ and has
no override; the new command just takes SR_REPO_ROOT. That asymmetry is part of
why the port earns its keep. It also asserts the failing paths actually exit
non-zero, without which "the exit codes matched" would be vacuous for two
checks that both silently pass.

Proven to fail twice before being trusted. Once by accident: the first version
asserted the yaml version appears on every failing path, which the old script
does not report when the client file is missing — the test was wrong, not the
code, and it now derives expected facts from what the old output actually
contains. Once on purpose: mutating the router to drop a version made it fail
and name the missing fact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:45:39 +02:00
jpmschweitzerandClaude Opus 5 f4cca69cab feat(config): T-1261 — reach is a bare name on PATH, in every context
`uv tool install --editable` puts the executable in ~/.local/bin rather than
.venv/bin, which is the difference between a command that works everywhere and
one that works only under an activated venv. Agents and git hooks never
activate one.

Verified in the three contexts that matter, with a negative control so the
passes discriminate: a stripped non-interactive shell, a REAL git hook process
(via git -c core.hooksPath ... hook run pre-push, not a simulation), and an
agent Bash call — all with VIRTUAL_ENV unset. With ~/.local/bin removed from
PATH the same check reports NOT-FOUND, so this is not passing because a venv
happens to be active.

Found a silent interpreter fork while doing it, which is this initiative's own
failure mode wearing a different hat. uv tool install without --python picked
CPython 3.11 for the tool environment while .venv and system python are 3.14 —
uv selects the lowest interpreter satisfying requires-python. reach would have
run on one interpreter and the test scripts on another, with different wheels
for numpy/scipy/PIL, and future 3.12+ syntax would break the tool while the
venv stayed green. PYTHON_VERSION now pins both.

make setup-venv is rebuilt on uv, per the T-1258 finding that it called
.venv/bin/pip against a venv that has no pip. The first fix was wrong too:
plain `uv venv` fails on an existing venv, so the target was not idempotent
where the version it replaced had been. Caught by running it twice instead of
dry-running it — which is how the original rotted unnoticed.

make install-reach self-checks that reach is actually on PATH afterwards
rather than assuming it. make reach-repoint gives a name to the situation
where uv keeps resolving a deleted worktree: reach still runs, edits in the
main checkout do nothing, and there is no error message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 14:36:31 +02:00
jpmschweitzerandClaude Opus 5 b9d81ac694 feat(config): T-1260 — reach lists its domains without importing them
`reach --help` renders from a declaration table and imports nothing. The cost
of help is now flat as the registry grows, which is the property that has to
hold going from one domain to a dozen.

The trap is real and was confirmed in typer's vendored source rather than
assumed from upstream Click: TyperGroup.format_commands loops over
list_commands calling get_command on each, purely to read a short help string
off the loaded command. With lazy loading underneath, that imports every
domain in the registry to render --help — while the output looks entirely
correct. Nothing observable changes; only the import graph does.

So the test asserts on sys.modules, and it was proven to fail before being
trusted. Disabling the format_commands override made it fail and name the
cause, listing all five leaked check modules. It also carries a positive
control — invoking a domain must import its service — because without one,
"nothing was imported" would pass equally for a loader that is simply broken,
and it fails on an empty registry, which would otherwise satisfy everything
vacuously.

The check domain is created here because the test needs a subject: a stub
raising NotImplementedError would have been committed dead code. That takes
the port out of T-1262, which is rescoped to what it still owns — pydantic
schemas, byte-for-byte output parity on the drift path, and the failure
tests. The old tooling/check-client-version script stays in place and stays
wired to the pre-push hook; the deprecation window is deliberate.

One Typer behaviour worth knowing before every future domain: a single-command
app collapses into a bare command, so `reach check client-version` failed with
"unexpected extra argument" until the router got a callback. Same mechanism as
the root callback, different symptom.

Help now works at every level, closing item 5 of T-1248.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 07:31:15 +02:00
jpmschweitzerandClaude Opus 5 8d64800fe9 feat(config): T-1259 — reach is a real command, and Typer vendors Click
`reach --help` runs from the console entrypoint in 80 ms. typer 0.27.1 and
pydantic 2.13.4 join the dependencies, both CVE-checked against NVD, OSV and
the GitHub Advisory Database.

The design in the ticket did not survive contact. It specified a click.Group
root, on the reasoning that it would keep typer off the --help path — but
typer vendors Click as of 0.26.0, so there is no top-level click package to
import and no supported way to extract typer's internal one. A click.Group
root hosting Typer sub-apps would put two Click implementations in one
process. The root is therefore a typer.Typer, and lazy registration will go
through the supported typer.Typer(cls=...) surface with a TyperGroup
subclass. T-1260 is corrected to match.

The callback is not decoration: a Typer root with no commands AND no callback
raises at build time, and lazy registration means no command is ever eager.
The ticket claimed a zero-command root always raises — half right, and the
half that matters is that a callback makes it legal.

rich_markup_mode=None is load-bearing rather than cosmetic. It takes an empty
--help from 168 ms to 74 ms, and keeps rich and pygments off the import path
entirely rather than merely skipping the render. It also stops typer drawing
box-art help, which it does even when stdout is a pipe — that would have put
box-drawing characters into every hook log and agent capture. typer-slim was
considered and rejected: deprecated since 0.22.0, now a shallow wrapper that
installs all of typer.

D-263 amended: the feels-instant ceiling goes from 250 ms to 500 ms. A ceiling
is not a typical and most invocations sit far below it; the tighter number was
buying discipline that the import-graph assertion enforces better. Stay smart
about what loads, stop worrying about tightness.

Security, checked 2026-08-23. typer has no advisories on record. pydantic
2.13.4 clears PYSEC-2026-1812 (email-regex ReDoS, fixed in 2.4.0) — and the
2026 SSRF advisories CVE-2026-25580 and CVE-2026-54249 are against
pydantic-ai, a different package that is not a dependency here, recorded in
pyproject so the next sweep does not re-panic. Transitively, pygments 2.21.0
clears CVE-2026-4539.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:01:32 +02:00
jpmschweitzerandClaude Opus 5 559f3d82dc chore(config): T-1258 — tooling/ becomes an importable package
The skeleton the reach CLI hangs off. Nothing moves yet: this adds the
package, the bounded core/, and explicit setuptools discovery.

core/console.py is the single output path, and the split it enforces is the
whole design — stdout carries the command's actual output so `reach ... | jq`
keeps working, stderr carries the event stream as JSONL. Rendering happens at
the sink: a terminal gets human text, anything else gets raw JSONL, so a live
view and a job log are one artefact in two presentations. Emitting is
optional — the gates emit nothing — and verdict() prints once, last, carrying
its remedy as a structured field.

core/config.py resolves the repo root from __file__ against a project.yaml
sentinel, with an SR_REPO_ROOT override. No subprocess and no git call: this
is on the gate path, and cwd is not a reliable signal anyway since a hook runs
from the root and an agent call may not. Both paths are validated, because a
silent fallback is how you end up editing one checkout and checking another.

Discovery is configured explicitly rather than left to flat-layout
auto-discovery, which would have had to choose between erroring on the
ambiguity and quietly shipping client/ or docs/. Verified: top_level.txt
contains exactly "tooling".

Verified beyond the happy path — the sentinel rejects SR_REPO_ROOT=/tmp and
names both remedies; debug events are suppressed at the default threshold
while the verdict is not; stdout stays clean with stderr redirected away; and
the three unconditional push-gate checks still pass now that tooling/ is a
package, which was the real regression risk.

Two findings recorded on the tickets. make setup-venv is stale — it calls
.venv/bin/pip, but the venv was created by uv and has no pip, so the recorded
procedure and the actual state have already diverged (T-1261 owns the fix).
And settled-reach-tooling had never actually been installed: site-packages
held the dependencies but no dist-info, which follows from there being no
__init__.py to expose. This is the first commit where `import tooling` means
anything.

.venv/ was only ignored via .git/info/exclude, which is machine-local, so a
fresh clone or a new worktree did not ignore it at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:53:48 +02:00
jpmschweitzerandClaude Opus 5 5df8afedb9 docs(governance): D-263 — output parity over timing, and commands that stream
Three amendments, all from pressure-testing the record against how the CLI
will actually be used.

The ~104 ms push-gate ceiling is withdrawn. It was the summed cost of three
single-sample timings, imported as a requirement without asking who pays —
and who pays is the pre-push hook, which already runs cargo test or the
gdUnit4 suite on any code push. A few hundred milliseconds is invisible
there, and on a governance-only push the whole hook is about a second. The
criterion is OUTPUT parity: a ported check must produce the same output and
the same exit code as the script it replaces, and is not required to be as
fast. What replaces the ratchet is a ceiling with headroom — under ~250 ms to
feel instant. Lazy registration stays mandatory, justified by the real
threat rather than by parity: scipy.ndimage alone is 275 ms, and an eager
entrypoint would pay ~460 ms before executing a line of its own.

That budget change removed the only argument for keeping pydantic out of the
gate domain, so the carve-out goes with it. One fewer exception, and the
reference implementation is now the normal pattern rather than a footnote.

Commands also stream. The gates are milliseconds but the generators are
minutes, and an agent Bash call gives up at two and sends nothing. Detaching
alone would fix the timeout and keep the silence; streaming fixes the part
that costs real time — you learn a generator is wedged at minute one instead
of minute nine. JSONL events on stderr, stdout reserved for actual output,
rendering at the sink so a job log and a live terminal are one artefact in
two presentations. Reattach is a byte offset into an append-only file, which
is why there is deliberately no daemon.

The trap, recorded because it would quietly undo the thing this record cares
most about: streaming is ADDITIVE to the failure contract. A remedy emitted
at line 400 of 900 is printed and invisible, so the verdict still prints
once, last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 18:53:29 +02:00
jpmschweitzerandClaude Opus 5 bbd64307ab docs(governance): D-263 — one CLI named reach, and Q-124 answered
Q-124 asked whether the 123-file Python tooling should be retooled into a
Rust CLI. The answer is no, and it is a costing rather than a preference.
All three frictions it names — per-script permission prompts, the venv/PATH
split between interactive and non-interactive shells, and interpreter
startup paid four times per push — are packaging problems, and one bare
command on PATH with lazy subcommand loading fixes all three. Rust would
additionally owe a numerical-equivalence proof on the planet-gen path,
whose heightmaps are committed build artefacts with goldens standing on
them: a large one-time cost to avoid a small recurring one, paid in the
currency the project can least afford to spend.

D-263 fixes the shape. tooling/ becomes an installable package behind the
`reach` command: a routing-only main.py, every domain under domains/<name>/
split router/service/schemas/helpers, a core/ bounded on day one to what
has no domain, logging and error handling attached as decorators rather
than call-site discipline, and pydantic confined to domain schemas —
measured at 87 ms against a whole gate check of 20-46 ms, which is why it
must never reach the push path. Failures carry the command that fixes them
and keep their exit code; a tool that explains itself and exits 0 silently
disables its own gate.

R-014 records the Rust option as costed down, not argued down, with the
condition under which it is worth reopening. T-1247 files the work as
eight dependency-ordered epics; only the skeleton is unblocked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 02:32:21 +02:00
jpmschweitzerandClaude Opus 5 284ce847c3 docs(governance): Q-124 — one door, domain split, and failures that teach
Jeroen's shape for the tooling CLI: move the Python into a package with a
proper domain split, one door that answers everything with help, and errors
that hand back instructions rather than a status.

The domain split turns out to be discoverable rather than invented. tooling/ is
85 top-level entries — 37 loose .py, ~36 extensionless executables, 11 dirs of
which only 6 hold anything — across four coexisting naming conventions. But the
domains are already encoded as filename prefixes: blender x14, atlas x8,
generate x7, check x7, then visual/validate/test x3 and
godot/garment/pql/install x2. Those prefixes are the subcommand groups, which
is what makes the consolidation mechanical enough to be safe.

Two constraints recorded against "a new prompt not an error code", because
taken literally each would break something:

- Exit codes stay. Four of these run in the pre-push hook, which fails a push
  ONLY by non-zero exit; a tool that explains itself and exits 0 silently
  disables its own gate. That exact failure was observed in clide today, where
  unsupported-format, no-such-file and unknown-subsystem all returned 0.
  So: code AND message, never either/or.
- It must not become literally interactive. Agents and git hooks have no TTY,
  and the tea scar is already written down — its prompts "crash in Claude Code
  (no TTY)", which is why every tea call passes all flags explicitly. Any
  prompt must be TTY-gated and suppressible.

pql was cited as the precedent and measured rather than assumed. The principle
holds there for unknown subcommands (full usage dump) and not for invalid
values: `ticket status <id> nonsense` says invalid without naming the six legal
values it knows, `ticket new` says "accepts 2 arg(s)" without naming which two.
The gap is the closed sets, and it is the more common failure. Logged upstream
as pql T-112 rather than worked around here — the bar for our CLI is the
stronger one: whenever the accepted set is known, print it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:06:25 +02:00
jpmschweitzerandClaude Opus 5 3a640f91f7 docs(governance): Q-124 — Typer costs the cheap option down, and moves the target
Jeroen raised Typer as the Python-CLI option. Costing it changed what the
question is actually about.

The repo is already most of the way there: pyproject.toml exists, `make
setup-venv` already does `pip install -e ".[dev]"`, and 22 tooling files
already use argparse. What is missing is a single line — there is no
[project.scripts] entry at all, so no console entrypoint exists. This is
consolidation, not authorship, and it resolves the largest friction (per-script
permission prompts) for one allowlist entry.

But the framework is the second decision, not the first. A [project.scripts]
entrypoint lands in .venv/bin/, which is on PATH only when the venv is
activated — and agents and git hooks never activate it. That is the same split
VENV_PY already papers over in the Makefile, and precisely the failure recorded
for tea: an absolute path breaks the Bash(tea *) rule and prompts every time,
fixed only by a bare name on PATH. So the deliverable is "one bare command
reliably on PATH" (uv tool / pipx into ~/.local/bin, or a symlink), and a Typer
app behind an absolute venv path would solve nothing.

Two honest costs recorded against it: Typer and Click are further venv
dependencies, so it does not help the venv friction at all; and a single
entrypoint importing every subcommand eagerly would pay all 123 modules'
import cost on every invocation, four times per push. Lazy subcommand
registration is therefore mandatory rather than an optimisation, and must be
measured before and after.

Net: this looks like the answer for the check/gate family and the day-to-day
scripts, and it leaves the numpy/scipy/PIL planet-gen path alone — the part a
Rust port would have had to prove numerical equivalence for. T-1246 updated to
start here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 02:00:39 +02:00
jpmschweitzerandClaude Opus 5 6949f800dc docs(governance): D-262 — the wiki generator flow has one canonical map
The relationship between wiki/, the generators, systems.db and the runtime is
a directed graph with two edges running opposite to the obvious direction and
one running backwards into its own producer. Prose renders that badly: every
document that has described it states a single ownership direction and is
therefore wrong about part of the tree. D-262 makes the diagram the source of
truth and points CLAUDE.md, Skill(wiki), project-structure.md and
wiki/GOVERNANCE.md at it.

The correction that matters most: body pages were described everywhere as
machine-owned and reverted on sync. They are not. scaffold_bodies.py writes
one once and never overwrites it, and import_economics then reads that
frontmatter directly as input — so a hand-edit is not reverted, it is obeyed,
and silently changes world generation. Worse than being overwritten, and the
actual reason GOVERNANCE.md forbids the edit.

New: tooling/check-dataflow-graph.py, wired into the Makefile and the pre-push
hook. It asserts every repo path named in a hand-authored diagram still
resolves — and its docstring states plainly what it cannot do: verify that an
edge still MEANS what it says. If wiki_sync.py stopped writing body pages
tomorrow, every path would still exist and the check would still pass. Edge
semantics stay a human check against the tool's source, so nobody reads a green
gate as a verified map.

Verified by breaking it: pointing one label at a moved path fails with exit 1
naming that path; restoring it passes. Building the checker also caught two
real vaguenesses in the diagram — "GJ-*/index.md" and "bodies/{id}/index.md"
were written without their wiki/star-systems/ prefix, which is precisely the
ambiguity this map exists to remove. Generated star-map .d2 files are excluded
by name; their correctness belongs to their generator under D-223.

Also files Q-124 + T-1246 (tooling): whether the 123 Python files under
tooling/ should become one Rust CLI of pql's calibre. The friction is real and
mostly not about the language — the permission gate prefix-matches whole
command strings and a blanket Bash(python3 *) grant is forbidden, so each tool
prompts near-individually, while a single binary is one allowlist entry. The
record requires pricing the cheap alternative (a Python dispatcher entrypoint)
before recommending Rust, and flags the hard constraint: import_economics is
stamped by source SHA, so any port must keep that contract intact through the
transition rather than disabled during it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:56:12 +02:00
jpmschweitzerandClaude Opus 5 0817befcba docs(diagrams): SVG replaces PNG, and a map of the wiki generator flow
d2 emits SVG natively; its PNG path wants a ~150 MB headless-Chromium
download and prompts interactively, so every PNG here was produced by an
out-of-band magick step. There is no Chromium on this system. Dropping PNG
removes the dependency rather than trading one format for another, and cuts
docs/diagrams/ from 17 MB to 3.3 MB. SVG renders in Gitea and in clide
(`clide draw --file <path>`, which takes .d2 source directly), and diffs as
text.

One PNG is kept on purpose: design/star-map-concentric.png has no .d2 source.

Also renders the 7 star-map .d2 files for the first time. star-map-plan.md
listed their renders as a deliverable in March and the step never ran; the
new `make check-diagrams` is what surfaced it.

New: docs/diagrams/data-flow/wiki-generator-flow.d2 — which way the arrows
point for any file under wiki/. Every edge was read in the tool's own source
rather than inferred. It records the trap that keeps costing us: scaffold_bodies.py
writes a body page once and never overwrites it, and the generator then reads
that frontmatter directly — so a hand-edit there is not reverted, it is obeyed,
and silently changes world generation.

Two rendering traps found the expensive way and now written down:

- A d2 `|md` block becomes an SVG <foreignObject>. ImageMagick and flutter_svg
  both silently drop it, so the legend was in the file and invisible in every
  viewer except a browser. Plain labels render as real <text> everywhere.
- Container boxes fight the layout engine. Grouping nodes whose flow-depths
  differ forces long edge routes; this diagram went from an unreadable 2.4:1
  sprawl to a legible 0.75:1 by deleting five containers and changing nothing
  else. Colour classes carry the grouping instead.

make diagrams / make check-diagrams render and gate. Repo-specific rules in
.claude/rules/diagrams.md; d2 syntax and the traps live in the user-scope
d2-diagram skill, whose PNG default was flipped to SVG to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 01:49:19 +02:00
jpmschweitzerandClaude Opus 5 34e5d7b636 docs(meta): body pages are obeyed, not reverted — correcting the wiki skill again
Jeroen asked whether I had read the python that writes the frontmatter. I had not — only grepped it. Reading body_definition_parser.py and scaffold_bodies.py properly overturned what I had written twice today.

scaffold_bodies.py NEVER OVERWRITES ('Only creates files that don't exist yet... existing body index.md files are skipped'), and the generator reads that frontmatter directly. So a hand-edited body page is not reverted, it is OBEYED, and it silently changes world generation — worse than being overwritten, and the actual reason GOVERNANCE.md forbids it. System pages behave the opposite way: wiki_sync.py re-renders their READ-ONLY blocks, so edits there ARE reverted. Three cases, not two.

It also explains T-1244's whole measurement: body_definition_parser resolves each field override > direct read > derived > inferred > SEEDED RANDOM. Continuous axes vary because they fall to the random tier; categorical axes are concentrated because they are read from the bodies table. The variance question belongs to the atlas CLI catalog, not the wiki.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:53:00 +02:00
jpmschweitzerandClaude Opus 5 0dc68dc1b8 docs(meta): fork-for-sidequests rule + wiki skill corrections from the cold test
The cold test worked as an experiment: a fresh agent with Skill(wiki) cited it first, refused to hand-edit body frontmatter, knew the corp regen-db stamp trap, and knew corp_specialization is missing from its own template. It also found four things the skill had wrong or missing, all verified before folding in: body frontmatter is a MIDDLE layer (atlas CLI -> systems.db -> scaffold writes the page -> import_economics reads it back), not the origin GOVERNANCE.md implies; the four empty categories are Q-118, an open scope question rather than an invitation; some bodies are visual-regression goldens and nothing in wiki/ says so; and status is editorial, not an import gate. Also: check current state before editing, since the test's own task described a change that was already true.

T-1244 corrected in the same pass — tectonics is derived from planet_class via a lookup (body_definition_parser.py:563), so the measured 68% 'low' is a projection of the class distribution, not an authoring choice. The ticket's question changed accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:46:04 +02:00
jpmschweitzerandClaude Opus 5 9fb74bdf22 chore(meta): record the mechanism behind the roadmap gap on T-1245
Jeroen: 'I tend to restrict future side quests to not confuse your context.' A reasonable practice with a bad side effect — intent stays conversational and reaches the repo by accident. Resolution recorded as two channels rather than more sharing: working context stays narrow, forward intent gets FILED. Carries a design constraint into the initiative's form investigation — weight options by cost-to-APPEND, since these facts surface mid-bug and a ritual will not get used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:40:21 +02:00
jpmschweitzerandClaude Opus 5 4de90529ae chore(meta): file T-1245 — a roadmap that carries intent, not just work order
The cascade owns order, the ticket tree owns decomposition, the DQR tree owns individual rulings; none answer what the game is going to be. Evidence it is a real gap: three roadmap-level facts surfaced in one conversation on 2026-08-20 that exist in no artefact, and two of them were written up as suspected defects by an agent reading carefully, because nothing recorded them as intent. Pickup instructions make epics an OUTPUT of a harvest/interview/investigate-form/propose pass, explicitly not an input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:38:40 +02:00
jpmschweitzerandClaude Opus 5 c25af8d753 docs(meta): make the wiki seed reachable — blind-prediction experiment and its fix
An experiment, at Jeroen's request: predict how the wiki seed data is structured
WITHOUT reading it, seal the prediction, then score it. The prediction is
a1addf7e2, committed before wiki/ was opened so it could not be retrofitted.

The score, against a rule fixed in advance:

RIGHT — markdown + YAML frontmatter, TOML for economics tables, the body path
shape, more trees than the three I had seen.

WRONG — "a source, never an output". That holds for 253 pages and is backwards
for 3,262: star-systems/ is GENERATED from systems.db by tooling/db/wiki_sync.py,
its <!-- READ-ONLY --> blocks are renders, and body frontmatter IS the body
definition rather than a description of one. Also wrong: "probably no schema
docs" — there are 17 templates, ten authoring guides, economics/schema.md and a
GOVERNANCE.md that states the ownership models plainly.

ABSENT (the expensive bucket) — the two ownership models running in OPPOSITE
directions; the GTTR prose channel; terrain.npz/globe.png; that stations and
districts have NO wiki directories; that `description` frontmatter exists so
agents can filter before loading; and the scale, 11,864 files.

ROOT CAUSE, and it is not missing documentation. The wiki documents itself well.
It was unreachable: wiki/ appears in NEITHER CLAUDE.md's Project Structure block
NOR .claude/rules/project-structure.md, the annotated tree whose entire job is
orienting an agent. The largest tree in the repo — the seed for the whole Reach —
was invisible from both files a session reads first. Every item in the absent
bucket follows from that one omission. The proof is this session: it spent three
days fixing Ferrath's terrain rendering and never once saw
wiki/star-systems/GJ-820B/bodies/GJ820Bc/index.md, the file that defines Ferrath.

Fixed here: wiki/ enters both structure documents with the ownership split stated
where it will be read, and Skill(wiki) carries the traps — never hand-edit a
READ-ONLY block or body frontmatter, stations have no directories, the id is
spelled two ways, editing corp PROSE stales systems.db, and absent variance is
often deliberate rather than a gap.

That last point cost two false findings in one measurement and is worth the
warning: chemosynthetic:false on every body is a namespace reservation for
dextro-DNA-style biochemistry once geology and nature spawn to the 1x1m pixel,
and enabled:false on ~65% is staged rollout — clean planet types first, generator
scripts for the rest after. Both read as defects without the roadmap.

Also measured, since the seed's job is to supply variance: continuous axes are
rich (unique seed per body, 460-716 distinct values across orbit/tilt/ice/land)
while the categoricals that gate morphology are concentrated (68% tectonics low,
51% planet_class frozen). Filed as T-1244 with the design question stated first —
whether the distribution is intended — rather than as a defect.

Method caveat recorded in the findings: the aggregator reads scalar frontmatter
only, and atmosphere_color's "100% null" was a parser artefact, not a finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:31:06 +02:00
jpmschweitzerandClaude Opus 5 a1addf7e21 docs(meta): seal a blind prediction of the wiki seed structure before reading it
Written before opening wiki/, committed first so the prediction is timestamped and cannot be retrofitted once the answer is known. Contamination declared inline: paths already seen this session via generator_sources.py and visual_scenarios.gd are marked [SEEN] and discounted. Scoring rule fixed in advance, with 'load-bearing thing I did not know existed' as the bucket that decides what gets documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:18:07 +02:00
jpmschweitzerandClaude Opus 5 fab70edd5a feat(ui): the stipple says WHERE the ground is broken, not just that it is (T-1194)
RimWorld's technique 4, the reference this ticket names, works by CONTRAST: the
Rockies and Appalachians carry dense hatching and the Great Plains carry none.
Ferrath's Global carried an even wash of dots over every landmass instead —
texture present, information absent.

The cause was a constant measured on the wrong rung. RUGGEDNESS_FULL_SCALE_Q = 4
comes from Region ("d4 2.38"), but the elev_q path it gates only ever RUNS at the
orbital rungs, where relief_q is flat and the stipple falls back to it. Ferrath
Global measures a mean 1-cell elev_q gradient of 0.99, so a coherent 4-cell
baseline reaches ~4 and saturates the constant exactly: every land cell read as
fully rugged.

It now normalizes against the canvas's own measured gradient — the same
self-calibrating shape T-1240 gave the hillshade, and for the same reason: one
constant cannot serve rungs whose sample spacing differs by four orders of
magnitude. A contrast curve rides on top, because even unsaturated the linear
reading puts ordinary ground mid-range and paints grain everywhere.

Measured on Global, before -> after: 1,679 -> 1,900 distinct colours, 145.90 ->
147.39 lum spread, and the pale uplands now stipple visibly denser than the
lowlands beside them.

Recorded because it cost two wrong turns: I first guessed saturation, then
talked myself out of it after measuring a 1-CELL gradient (0.99) against a full
scale meant for the 4-CELL baseline, and shipped a contrast curve alone — which
measurably did nothing (1,679 -> 1,698), because a curve cannot separate values
already clamped to 1.0. The gamma is kept; it does its job now that there is a
range to curve. The elev_q gradient joins relief_q's in the capture readout, so
the next person tuning this can read the number instead of guessing at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:13:51 +02:00
jpmschweitzerandClaude Opus 5 646db131a9 chore(meta): close T-1240 — Region reads as terrain, acceptance ladder shot
relief_grad across the cold ladder: Global 0.00, Region 1.08, District 0.30, Quarter 0.07. Closure note records the two premises the ticket got wrong (Nyquist is the aliasing limit, not a legibility one) and the coast-warp trade taken at Region, with its reversal path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:49:24 +02:00
jpmschweitzerandClaude Opus 5 9b146f9e1f fix(simulation): derive at the octaves a rung can actually reconstruct (T-1240)
Region rendered as fine uniform stucco while District and Quarter, on identical
code, read as terrain. The cause was sampling: `min_wl_m` arrives as an LOD
request and defaults to 0, so every invented octave contributed at every rung.
MIN_WL_BANDS_M was meant to be the floor but is built from the rung's CELL SIZE
(2 x DISTRICT_M), which stopped being the sample spacing at the D-255 extent
inversion — a rung fixes EXTENT now and spacing falls out of the canvas size.
The bands were off by roughly the cell count, and the served path never consulted
them anyway.

The cutoff is now derived from the resolved spacing, which is what this ticket
asked for. Two things had to be measured rather than reasoned to get it right,
and both corrected me.

FIRST: the field was the culprit, not the renderer. I attributed the stucco to
the client stipple painting noise onto a smooth field. Surfacing the terrain
layer's own mean |relief_q gradient| in the capture readout settled it in one
shot: Region 18.24 steps per cell — 144 m of relief between NEIGHBOURING cells —
against District's 0.30 and Quarter's 0.07. The server was sending noise. That
diagnostic ships here for the same reason `plane_variety` did in T-1213: a noisy
field and a renderer inventing noise look identical, and one number separates
them.

SECOND: Nyquist is the wrong threshold. The first version floored at 2 x spacing,
the aliasing limit, and Region barely moved (56.16 -> 59.73 lum spread, gradient
still 18.24) because 2 samples per cycle is unaliased but renders jagged. The
rungs that already worked say what the real bar is: District reconstructs its
finest surviving octave at 34 samples per cycle, Quarter at 135. At 8x, Region
goes to 1.08 gradient and 70.01 spread, and shows ridges and valleys.

THE TRADE, taken deliberately and recorded in the tests: an 8x floor also
truncates the coast warp's 2,048 and 1,024 m octaves at Region, the band T-1160
added for "one coastline at every rung". An earlier test here asserted that band
must survive; it now asserts the opposite. Same reasoning as the relief: a
1,024 m coastline wiggle at 379.3 m per cell is 2.7 samples per cycle, so drawing
it draws noise rather than coastline character — a rung cannot show shape finer
than its own cell. The warp is amplitude-capped sub-pixel on the working grid, so
what is lost is small. If a future pass wants the warp exempt, the fix is a
relief-only floor threaded through derive_at_metres, NOT a lower multiple, which
takes the stucco back.

Global is exempt: its floor would be ~70 km and would truncate the whole warp
band, and it needs none — the orbital derive leaves relief_q flat at 50. District
(3.79 m spacing) and Quarter (0.948 m) floor below every octave in play and
derive byte-identically, which their own test pins.

Cache-safe by construction: the floor is a pure function of (rung, extent,
body_radius), all three already in the step-canvas cache key. 0.4.12 is required
anyway — this changes derived BYTES at Region, so a 0.4.11 entry holds a field
this build would never produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:43:46 +02:00
jpmschweitzerandClaude Opus 5 6d4c9e92c2 feat(ui): the copse and the rocky outcrop, drawn per cover class (T-1213)
D-258 invariant 2 asks the map to show the minority the orbital summary
suppressed — clearings, marsh, rock, scrub inside a cell that reads "forest" from
space. composition.rs goes to real trouble to invent it, and the conservation
harness measures it: over a District patch on Ferrath the tally is {Barren: 175,
Forest: 16209}, so 1.07% of that ground is exposed rock.

The renderer was averaging it back out. One test, `veg >= Scrub`, at one density
and one strength: Forest, Scrub and both Riparian classes drew the IDENTICAL
mark, and Barren drew none at all. A wood looked like scrub, and bare rock was
invisible by construction — on the rungs whose whole purpose is to show what the
summary hid.

Each class now has its own grammar, differing on the three axes a mark has:
density (how much of the class's ground carries it), strength (how far it moves
the base colour), and lattice (the block size marks are decided on — bigger reads
as a clump, smaller as grain). Rock LIGHTENS where everything else darkens, which
is the point rather than a flourish: bare stone catching the light is the one
cover type brighter than the ground around it, so it separates from vegetation by
sign alone and can never read as "denser plants". It gets its own ScatterField
salt so an outcrop does not preferentially land where a copse already did.

Tuned against captures, not guessed. A first pass gave Forest a 4x4 lattice at
52% density, which produced visibly axis-aligned dark SQUARES — a 4-cell block is
8 screen px at District — and read as an artefact laid over the hillshade. It
also mistook the background for a feature: Forest is 98.9% of this frame, so
marking half of it dark is not "the occasional copse", it is a second colour
layer. Pulled back to grain at a 2x2 lattice, and the occasional thing is now the
thing that catches the eye: the outcrops read as scattered pale clusters of
exposed ground, exactly the "occasional copse/tree/rocky outcropping" that was
missing.

District holds its form through the change (lum p1-p99 74.15, against 74.43
before the cover marks and 13.72 when the rung was flat).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:04:32 +02:00
jpmschweitzerandClaude Opus 5 8787ee1844 feat(ui): hillshade the deep rungs — form from light across slope (T-1213)
relief_q now reaches the renderer, and the first pass spent it on brightness:
lighten where the ground is high, darken where it is low. That moved the numbers
(District 13.72 -> 77.01 lum spread) and still looked like moss, because the eye
does not read landform from absolute brightness. It reads it from light falling
ACROSS a gradient — height-shading gives a rise and a fall the same tone, so no
ridge ever reads as a ridge.

So relief drives a proper hillshade: the local gradient of the field dotted with
a light from the upper-left. The light direction is not a free choice; lit from
the lower-right the brain inverts the read and valleys pop out as ridges.

THE SCALE IS MEASURED PER CANVAS, not fixed, and the first attempt at this failed
exactly the way this file already warned a fixed gradient constant would (see
RUGGEDNESS_BASELINE_CELLS: "the same 4-cell delta reads 21.86 at Region and 0.08
at District"). With a constant full-scale of 8:

    Region 81.72 but District 77.01 -> 20.01, Quarter 42.56 -> 16.44

because at District's 3.8 m per cell neighbouring cells barely differ. The
terrain layer now measures each canvas's own mean |gradient| once per rebuild and
the hillshade normalizes against it, so one constant works at every rung.

Ladder (tooling/atlas-flatness, lum p1-p99), flat -> shipped:

    Global    145.69 -> 145.69   unchanged; relief_q is flat 50 at orbital
    Region     33.59 ->  54.30
    District   13.72 ->  74.43
    Quarter    11.01 ->  73.72

District and Quarter now read as terrain — ridgelines, valleys, and the stipple
organised into contour-like bands. Judged by eye on the captures, not by the
metric alone.

Stipple full-scale 25 -> 60. The old value was calibrated against a relief_q that
never arrived, so it was tuned to the elev_q fallback; with the real plane nearly
every land cell earned a mark and Region read as static (17,599 distinct colours,
more than twice Global's, for a quarter of the legibility). Form comes from the
hillshade now; the stipple is grain on top of it.

REGION IS NOT FIXED, and the cause is T-1240 rather than this change. It renders
as fine uniform stucco: a Region cell is 379 m of ground while the relief field's
content sits in the 128-1024 m band, so the field is at or below Nyquist and the
gradient the hillshade reads is aliasing, not slope. min_wl_m defaults to 0 on
the served path, so nothing truncates the octaves Region cannot resolve — which
is precisely what T-1240 proposes to fix. That ticket said the stale cutoff was
"currently inert"; it is now the thing capping Region, and T-1240 is updated
with the measurement.

Three tests, on direction rather than magnitude so tuning does not rewrite them:
a hill's west flank lit and east flank shadowed, a uniform field shading nothing,
and the canvas edge not drawing a rim. That last one is a bug this nearly
shipped: `_l8_value` returns 0 out of bounds and 0 on relief_q means MAXIMUM
HOLLOW, so sampling off-canvas posts a full-scale false gradient all the way
round the frame. The sample position is clamped instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 18:30:21 +02:00
jpmschweitzerandClaude Opus 5 b9cd26429e chore(meta): file T-1243 — fog perf test flakes under gate load
The pre-push gate rejected the T-1213 push on a wall-clock fog budget (0.606 vs 0.5 ms) that passes 23/23 in isolation on the same build. Second hardening cycle for the same failure mode: min-of-7 defends against one slow sample, not the sustained core saturation the gate itself creates by running cargo and tooling suites immediately before it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 16:37:58 +02:00
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
jpmschweitzerandClaude Opus 5 e5224b1a44 chore(meta): 0.4.8 — the T-1242 gate's first false positive, paid not dodged
A test-only edit to composition.rs tripped the canvas-generation gate, which is path-based and cannot tell an assertion fix from a generator change. Bumped rather than excepted: the ruling is that a false positive costs one round of cache misses and a false negative costs a week. Second no-op bump in two days, noted in project.yaml so the rate is visible if it becomes noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 13:03:09 +02:00
jpmschweitzerandClaude Opus 5 869837f728 test(simulation): the conservation gate's monoculture check was a tautology (T-1213)
D-258 invariant 2 says descending the ladder must reveal COMPOSITION — a cell
reading forest must be able to contain the clearings and rock the vote
suppressed. One assertion stood behind that, and it read:

    assert!(tally.len() > 1 || share == 1.0, ...)

A single-class tally has a 100% share by definition, so both branches are always
satisfiable: the check could never fail, including in the exact case its own
message names, "or nothing was composed". The invariant had a test and no gate.

Split into the two bounds the invariant actually has, because it is two-sided:
conservation caps how much may be invented (majority > 50%, already asserted) and
composition sets a floor on how little (minority >= 0.1%). Verified by raising
the floor to 2% and watching it fail on the measured 1.07%, then restoring it —
the floor is a tripwire for "did anything happen", deliberately far below the
measurement rather than tuned to it.

Measured at the descent ladder's own anchor on Ferrath:
  conservation: majority class 3 at 98.9% across 2 classes {1: 175, 3: 16209}

So composition IS working in the data and conservation holds. The map is flat
anyway, and tooling/atlas-flatness (added here) says why the eye was not enough:

    rung      distinct   lum p1-p99
    Global        1581       145.69
    Region        2923        33.59
    District        53        13.72
    Quarter         46        11.01

Region carries almost TWICE Global's distinct-colour count while holding a
quarter of its structure — the dither pass adds colour noise, not information, so
a colour-count metric would have called the flattest rung the richest. Structure
falls ~92% from Global to Quarter.

The cause is a channel mismatch rather than a missing generator: composition
perturbs moisture_q/slope_q, and the base map draws morphology hue x elev_q
lightness. The ladder scenarios pass no overlays deliberately, so the composed
fields are never rendered in the very shots that judge this work. Recorded on
T-1213 with the three ways forward; the choice touches D-258 and is Jeroen's.

The gate is still #[ignore]d — noted on the ticket as worth moving into a harness
that runs, since believability and window-derivation already load real bodies in
the normal cargo test path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 12:35:19 +02:00
jpmschweitzerandClaude Opus 5 6e6218d654 chore(meta): close T-1242
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:07:01 +02:00
jpmschweitzerandClaude Opus 5 48fee8a0b6 feat(config): make the canvas-generation/version pairing a gate, not a habit (T-1242)
project.yaml's version is the Atlas disk cache's only invalidation signal, and
nothing enforced that changing canvas GENERATION also moved it. It broke five
times -- 0.4.2 lake_margin_q, 0.4.3 coast_warp_px, 0.4.4 the extent inversion,
0.4.5 the Global sentinel, 0.4.6 one-course-per-river -- each bumped only after
someone noticed a wrong map. The failure is invisible to its author: it needs a
warm cache to reproduce, so a cold checkout looks fine. T-1239 is the last one,
and it took eight days.

tooling/canvas_sources.py is the path registry; tooling/check-canvas-version
rejects a push that touches those paths without moving project.yaml's version
line. Wired into the pre-push hook, `make check-canvas-version`, and, for the
parsing units, `make test-tooling`.

Verified against real history rather than a synthetic branch: run over
4e503c356 -- the commit that actually caused T-1239 -- the gate rejects and names
the three files. Run over the commits that DID bump (bdea71953, 39f0fd8c5, and
T-1239's own fix), it passes.

The registry is globbed, not hand-listed. step_canvas.rs imports ten sibling
modules and those import more, so a traced closure would be stale within a month,
and stale here is silent. It over-includes on purpose: a false positive costs one
bump and one round of cache misses, a false negative costs another week of a
wrong map -- the ticket's own ruling.

Two deliberate calls worth naming. The registry includes ITSELF, which closes the
narrowing hole: remove a path and change that same path in one push, and the gate
still fires because the registry file is in the set. And there is no override
flag -- it would be reached for exactly when someone is certain their change is
harmless, which is the reasoning behind all five regressions.

Version bumped 0.4.6 -> 0.4.7 with NO canvas-generation change: self-inclusion
means adding the registry trips its own rule. Spent rather than special-cased,
because the first exception is how a rule like this dies.

The units cover the property no branch run can show -- that editing project.yaml's
comment block, which quotes old version NUMBERS directly above the field, is not
a bump -- plus a registry-coverage test naming the files each of the five known
regressions touched, so a future narrowing past them fails loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:06:32 +02:00
jpmschweitzerandClaude Opus 5 a1568d27c1 chore(meta): close T-1241
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:35:41 +02:00
jpmschweitzerandClaude Opus 5 086d9ed56e fix(client): bake the version into the build, so an export can invalidate its cache (T-1241)
current_schema_version() line-scanned res://../project.yaml at runtime. That
resolves to the repo root in a dev run and to nothing in an exported build, so a
shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas
disk cache's ONLY invalidation signal, every exported build stamped and compared
the same sentinel: a canvas cached by one build would be served by every later
build, forever. T-1239 is what that failure looks like once it happens.

loading_screen.gd carried a byte-for-byte copy of the same function, so the
version shown to the player was "?.?.?" in exactly the builds where a version
string is worth showing. Both call sites now share client/scripts/build_version.gd,
which reads application/config/version out of ProjectSettings — a value Godot
bakes into the PCK, identical in the editor and in an export by construction
rather than by luck. No file IO, no fallback branch.

project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors
it. A mirror nobody checks would be worse than the bug it replaces -- the old
code failed loudly everywhere, a stale mirror fails silently -- so
tooling/check-client-version compares the two and the pre-push hook runs it
unconditionally. Not gated on "were those files in this push": drift persists on
main once introduced, and gating would let an existing drift ride along.

The test this replaces asserted that current_schema_version() did not return its
fallback, and passed -- in the one environment where the code under test worked.
Three tests now pin the property that actually matters: a real version, sourced
from the baked setting, matching project.yaml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:35:33 +02:00
jpmschweitzerandClaude Opus 5 e8d522b482 chore(meta): close T-1239, file T-1241/T-1242 follow-ups
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:20:34 +02:00
jpmschweitzerandClaude Opus 5 07ed2a47ab fix(client): the Atlas was replaying a cache from a build that no longer existed (T-1239)
Ferrath's Global map drew no rivers at native resolution: 375 courses arrived
and 0 were drawn. The report suspected the D-261 length cull or the water
truncation. Both were innocent, and so was the renderer.

The client served the canvas from its own disk cache (T-1183). Every payload
for GJ820Bc predated T-1237 (4e503c356), which replaced one-course-per-D8-hop
with one-course-per-river -- so the map was drawing 375 hop fragments whose
longest run was 106 km, all of them under D-261's read-as-a-line floor. Same
build, same scenario, same 3440x1440, cache the only difference:

  stale   courses=375  runs=180  longest=6.0px  (~106 km)   drawn=0
  cold    courses=73   runs=23   longest=93.2px (~1,644 km) drawn=18

It looked resolution-dependent because it wasn't a resolution at all: 960x540
resolves to an 814x407 canvas, a key never cached, so it missed and re-derived
correctly. 3440x1440 resolves to 1080x540, which had an entry from 2026-08-06.
During the stale capture the server logged no course production whatsoever --
the canvas never came from it.

The cache's only invalidation signal is project.yaml's version, and 4e503c356
changed how canvases are generated without touching it, so hop-shaped entries
stayed valid. All 13 stale entries are stamped 0.4.5. 0.4.6 forces them to miss;
that, not clearing a local directory, is what repairs a player's Atlas.

The harness let this hide for eight days, in two ways now fixed. It ran against
the developer's persistent user:// cache, so a capture could render a canvas
built by a build that no longer existed -- and any golden shot in that window
silently inherited it; user:// is now isolated per run. And it sent server
stderr to /dev/null via an already-unlinked mktemp file, so no tracing from a
capture was ever reachable; the log now lives at .cache/visual-server.log.

The capture readout gained runs= and longest= between courses= and drawn=,
because "375 arrived, 0 drawn" is not one fact but three stages, and telling
them apart is what turned a guess between two suspects into a measurement.

Follow-ups filed: T-1241 (current_schema_version() returns its ?.?.? fallback in
an exported build, so a shipped game never invalidates on version at all) and
T-1242 (nothing enforces the generation-change/version-bump pairing -- this is
the fourth bump forced after the fact).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:20:16 +02:00
jpmschweitzerandClaude Opus 5 16348e2e89 chore(meta): drop the no-op Write() twins from the permission lists
A Write(<path>) permission rule matches nothing. File permission checks
consult only Edit(<path>) rules, which already cover every file-editing
tool — Write, Edit and NotebookEdit alike. Claude Code now warns about
the dead shape at session start.

All eight removed here sat directly beside their Edit() twin, so the
allow grant over the repo tree and the ask gates guarding settings and
hook files kept working throughout. Behaviour is unchanged.

That ask block remains the pattern worth copying to the other repos in
this tree — it is the only one that stops an agent quietly widening its
own permissions, and it has to be ask rather than deny to stay fixable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:57:57 +02:00
jpmschweitzerandClaude Opus 5 dd0f9804b5 chore(meta): install pql replication hooks and cover the format marker
This repo had no .pql/hooks/ at all — the four replication hooks were
never installed, because pql's installer used to ignore a redirected
core.hooksPath. It works with .config/hooks now, so init prepends a
two-line shim to each hook that sources the pql half. Existing hook
bodies are untouched; the shim goes above them.

What this buys: post-merge now runs `pql plan upgrade`, so a pull that
brings in a newer changelog format migrates it forward automatically
instead of replaying under superseded rules.

.gitattributes gains a rule for changelog files at the root of
.pql/changelog/. The existing `**/*.sql` pattern requires a directory
component and so did not match the new 0000-format.sql marker, which
would have made it a merge conflict rather than a union merge.

Note for a follow-up: the hand-folded pql block in .config/hooks/post-merge
(lines ~10-12) is now redundant with the shim, so plan import and
decisions sync each run twice per pull. Both are idempotent, so this is
waste rather than breakage — but that block and its stale "installer is
dead" comment can be dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:35:35 +02:00
jpmschweitzerandClaude Opus 5 962cbe83a7 chore(meta): migrate changelog to format 2.0.0, recovering 69 descriptions
pql 2.0.0 versions the changelog file format and carries older ones
forward. The rewrite touches only the inline conflict guard on each
line, which moved from a content-hash tiebreak to append position
(3992 lines in, 3992 out — no row data altered).

This repo carried real damage from the old rule. A ticket created and
appended to within one wall-clock second produced two changelog rows
tied on updated_at, and the hash decided the winner — arbitrarily, and
on every replay, so the loss reappeared on each fresh clone and branch
switch. Replaying the pre-upgrade changelog and diffing all 1232
tickets against the repaired state: 69 tickets gained description text,
none lost any, 13270 characters recovered in total. Six had no
description at all.

T-1057, where this was first noticed, keeps the description a session
hand-recovered from ticket_history in July; its later updated_at means
the tie no longer decides it. The workaround scaffolding in that field
can be tidied whenever convenient.

plan rebuild --verify reports zero rows lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 11:12:08 +02:00
jpmschweitzer 3a38322ce9 Merge remote-tracking branch 'origin/ocean-guard-synthetic' 2026-08-08 10:55:38 +02:00
jpmschweitzerandClaude Opus 5 d5e617eff6 docs(simulation): PR #218 round 3 — the round-2 fix outran its own documentation
Three findings, all doc-accuracy, and all the same root cause: folding the
spacing predicate into the ring walk changed what three comments describe, and
two of those comments were written by this same PR one round earlier.

TYRE 1 — road_graph.rs's T-1206 gap-closure comment cited `nearest_land_cell`,
which round 2 made `#[cfg(test)]`. A reader chasing that name lands on a
test-only function and reasonably wonders whether they are looking at dead
code. Repointed to `nearest_cell_matching`, and the paragraph's closing claim
that "T-1206 guarantees the placement pixel is land" is corrected: it has been
land-AND-spacing-or-skip since round 2.

TYRE 2 — `max_land_search_ring`'s doc named the same test-only wrapper as the
thing that walks the bound. It now names the production consumer and both
callers.

TYRE 3 — the D-211 amendment was written in round 1, before round 2 existed,
and still described a land-only correction. It now carries a dated refinement
recording what the code actually does: the walk satisfies BOTH of step 4's
promises in one search, and SKIP therefore also fires where land exists but
none of it clears spacing within the bound. The no-re-decision conclusion is
unaffected — position remains a deterministic, non-fabricated function of seed
and terrain — and the refinement notes the spacing promise is step 4's alone,
since Tier A/B/C placements sit on their matched attractor and were never
subject to it.

HOSHE's three findings were the same three hunks, observed uncommitted while
the review ran: accurate content, but not in the branch tip, so the PR would
have merged a governance record that misdescribes its own commit. That is this
commit.

Both reviewers independently confirmed what the round-2 fix claims. Tyre traced
the ring geometry and tie-break order by hand against the spacing predicate;
Hoshe re-ran the full 267-body corpus scan live (850s) and reproduced the
figures exactly — 267 bodies, 267 reaching Layer 3, 344 placements, 109
synthetic, 0 in water, 0 spacing violations.

The shared-ring-search-helper retraction is confirmed and settled, with NEW
grounds rather than a restatement: round 2 strengthened the case for keeping
them separate, since this walk is now parameterized by an arbitrary predicate
over native u16 terrain coordinates while road_graph's is a RouteGrid method
over downsampled routing cells with a fixed cost test and an unrelated bound.

22 module tests green; clippy and fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:53:33 +02:00
jpmschweitzerandClaude Opus 5 6cfd829445 feat(simulation): sub-cell composition — a summarised cell can contain its minority (T-1213)
D-258 invariant 2: descending the ladder must reveal COMPOSITION, so a cell
reading "forest" globally contains the clearings, marsh, rock and scrub the
vote suppressed. Below Global it contained nothing: morphology carried NINE
zones at Global and exactly ONE (AlluvialPlain) at every rung under it, and
vegetation collapsed to one class from District down.

WHY THE EXISTING TIERS COULD NOT DO IT. vegetation_invention already perturbs
moisture with a massif band and a texture band — but that pair was built for
cross-rung coherence and is weighted 70/30 specifically so "the texture term
alone can never outweigh the massif term". It is designed NOT to change a
verdict, which is the exact opposite of what composition needs.

And it could not simply be turned up. Measured: 50.2% of the texture field's
amplitude sits in its 32,768 m octave alone, and everything at or below 2,048 m
holds 5.9% of the total. Across a District window only that 5.9% varies, which
after the 30% weight and a ~29-point ceiling swings moisture by +/-0.51 points
against vegetation gates 5-15 points apart. Nothing could ever cross one. That
is the geometric series, not a tuning shortfall — raising the ceiling enough to
matter at District would make the field violent at Region.

So a third tier carries the fine band ALONE, normalized to its own full swing:
quiet where the coarse tiers are loud, loud where they have nothing left to
say. It feeds BOTH classification inputs, because moisture alone would have
left morphology just as flat.

CONSERVATION IS THE BOUND, not weighting (D-258 invariant 3). The field is
zero-mean, so a downsample returns the summary it was added to. Pinned two
ways: a field-level zero-mean test, and a real-terrain test that derives a
District-sized patch and asserts the majority vegetation class survives.

RARE INCLUSIONS, and this was a correction. The smooth term is a gentle sway
around the base, so it can only flip a verdict where the ground already sits
near a gate — which made deep-in-class ground immune, and the conservation test
duly measured a patch that was 100% Forest. A monoculture is the flat map this
ticket exists to fix, one scale down. Jeroen: "maybe a dense forest should
still sometimes produce a clearing or a rocky outcropping." D-258 says CONTAIN,
not border on. A sparse high-contrast term now rides on top — thresholded value
noise so inclusions are connected blobs rather than stray speckled cells. The
same patch now reads 98.9% Forest with 1.1% Barren outcrops.

The moisture half of an inclusion obeys the envelope rule (a world with no
patchiness ceiling grows no glades — caught by the zero-ceiling test, which the
first version failed by putting damp pockets on airless rock); the slope half
does not, because an outcrop is geology and a dead world is exactly where bare
rock should break the surface.

The slope ceiling is 6, not the 15 first written. Measured against the
window-derivation fixtures, base slope_q on ordinary ground is 2-6, so +/-15
did not vary the signal but REPLACED it — one fixture moved 6 -> 19 and two
coastal samples flipped to Wetland on invented slope alone. The morphology
gates are far apart because a cliff coast is a real landform; composition must
let marginal ground fall both ways, never manufacture a fjord on a flood plain.

NOT applied at the orbital rung, which is envelope-only by design and documents
that it never invents slope — pinned by
derive_orbital_at_metres_never_invents_slope, which caught the first version.

Both goldens move in the IMPROVING direction, checked before updating rather
than blind-refreshed:
  GJ338Bd  moisture 75->85 distinct, slope 28->34 (range 0-50), materials 3->4
  GJ244Ad  moisture 25->42, slope 15->22, morphology zones 6->7, materials 2->3

Ladder effect (was -> now): Region moisture 25->39; District moisture 3->17 and
vegetation 1->2; Quarter moisture 3->8.

45 server suites green, clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 10:01:16 +02:00
jpmschweitzerandClaude Opus 5 43a267439b feat(client): ScatterField — reusable seeded scatter for client-side paint
Extracted from the T-1194 stipple, which was the first of a family: graffiti
placement, cracks in textures, drifting cloud cover — presentation decisions
that must look the same when the player returns to a place, and which the
simulation has no opinion about and should not be burdened with.

THE LINE IT DRAWS. It answers "how is this drawn", never "what is here". A
cell's biome, a settlement's position, whether a wall exists — those are world
data, derived once by the server and sampled everywhere (D-255(f) mechanism B),
and the player eventually stands on them; inventing those here would put the map
and the ground in disagreement. Stated on the class so the next consumer does
not have to re-derive it: if the answer changes what is THERE it is not a
ScatterField question; if it only changes how it is DRAWN, it is.

Bit-identity with the server's Rust noise is explicitly NOT a requirement
(Jeroen: "a seed is a seed and the functional intended outcome is repetition
here"). Nothing here is compared against a server value or round-tripped
through a save, so the contract is stability across sessions, not agreement
across languages — which is precisely why paint belongs on this side: it buys
visual density with no cross-language determinism burden.

Seeded from GameState.world_seed, so two playthroughs scatter differently and
one playthrough is stable forever.

API: domain() resolves a name to a salt ONCE (the first consumer runs ~700,000
times per canvas rebuild, so the hot calls take an int, never a string);
value/chance/pick/jitter for discrete marks; smooth() for continuous fields
like cloud cover; an optional time axis for animation. Domains keep consumers
uncorrelated — without them graffiti and cracks at the same wall coordinate
would mark identical spots and read as one artefact.

The tests pin the CONTRACT, not the numbers — freezing outputs would make any
future improvement to the mixer a breaking change for no gain. They caught a
real defect immediately: (-x, -y) collided with (x, y), because negated
coordinates produce negated products and the sign-bit mask folded the pair
together, mirroring every mark west and south of the origin onto its north-east
counterpart. Not an edge case — the descent ladder's own anchor sits at
y = -5,675,959. Fixed by zigzag-encoding coordinates before mixing.

1853 client tests, 0 failed (15 new). Global capture re-verified unchanged
after migrating the stipple onto the service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:41:38 +02:00
jpmschweitzerandClaude Opus 5 5eb394b36f feat(simulation): relief_q — a local relief signal the deep rungs can resolve (T-1213)
The District and Quarter rungs rendered as flat colour, and the cause was not
the biome work everyone assumed. Measured on Ferrath through the production
canvas builder: at District the mean |elev_q delta| between neighbouring
gridunits is 0.02, and NOT ONE PAIR in a 1290x540 frame differs by 2.

elev_q spans 0-100 across the body's whole 8 km elevation range, so ONE STEP IS
80 METRES. A District canvas covers 2,048 m of ground, where the rolling relief
a walker navigates by is metres to tens of metres -- a fraction of a single
step. The sub-district detail IS generated (invent_primitives' scatter and
relief bands compute it) and then rounded away. Confirmed by running the
diagnostic with the octave cutoff disabled: still 0.02.

relief_q carries that same invented fine component against a scale chosen to
resolve it: 0-100 about a flat 50, RELIEF_FULL_SCALE_M = 400 m either side, so
8 m per step -- ten times finer than elev_q. elev_q keeps its body-absolute
meaning and the Atlas legend stays true.

Measured effect, elev_q vs relief_q (distinct values / mean 4-cell delta):

  Region     49 / 2.38   ->   101 / 21.86
  District   10 / 0.08   ->    35 / 0.35
  Quarter     8 / 0.02   ->    19 / 0.06

FIXED metre scale, never per-canvas normalization: the value for a piece of
ground must not depend on what else is in frame, or the same hillside changes
tone as the viewer pans. And it excludes elev_pct deliberately -- this is the
departure from the surrounding land, not height above sea level; including the
base would re-introduce the body-scale dominance that makes elev_q unusable
down here.

50 at the orbital rungs, which skip invent_primitives by design. Nothing is
lost: Global and Region still have varied elev_q (101 and 49 distinct values),
and the client takes whichever field carries signal via a max, with no
rung-name branching.

The client's ruggedness driver changes with it. It was an elev_q GRADIENT,
which cannot work across rungs -- the same 4-cell delta reads 21.86 at Region
and 0.08 at District, so any single full-scale constant either saturates one or
vanishes on the other. relief_q states relief outright, so |relief_q - 50| is
the answer directly and a fixed metre scale is immune to that by construction.

An absent plane reads FLAT, not zero -- 0 on this field means maximum relief
BELOW flat, so a payload without it would have stippled the entire map. That is
reachable: the field is #[serde(default)] so old-shape payloads decode. Two
colorize tests whose fixtures predate the plane caught it.

2004 server tests, 1838 client tests, 0 failed. clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:11:23 +02:00
jpmschweitzerandClaude Opus 5 566b566519 feat(ui): relief and vegetation texture over the terrain hue (T-1194)
RimWorld technique 4 from the reference map: texture as data, not decoration.

WHY IT WAS FLAT. The base layer reads hue from morphology and lightness from
elev_q. Below Global, morphology resolves to exactly ONE zone per canvas, so
the frame became a single colour whose only variation was a lightness ramp too
subtle to see. Measured on Ferrath at Region: 1 morphology zone, but 49
distinct elev_q values. The information was already on the wire and arriving —
the renderer was discarding it by expressing it in lightness alone.

TWO MARKS, NOT ONE SLIDER — the ticket's design question (b), settled by
looking at the reference rather than reasoning about it:
  - RELIEF stipple: fine, dense, darker, keyed to RUGGEDNESS not height. The
    reference's high flat plains carry none while its ranges are dense with it,
    so the driver is the local elev_q gradient; a high plateau stays clean.
  - VEGETATION blotch: coarser, softer, marked on a half-frequency lattice so
    it reads as patches rather than a second speckle at the same pitch.

Inline in the existing per-cell loop (question (a)) and always-on, base layer
only (question (c)). The TMP/MST/VEG toggles are ANALYTIC reads — stippling a
temperature ramp would corrupt the quantity being read.

THE BASELINE IS MEASURED, NOT GUESSED, and the first attempt got it wrong: a
1-cell ruggedness delta samples mostly quantization noise, reads
near-identically everywhere, and rendered as uniform static over flat green —
grain, not structure. The gradient saturates by about 4 cells (Region: d1 1.40,
d4 2.38, d8 2.41, d16 2.51), so the baseline is 4 and the full scale 4.
Verified by capture at native resolution: on Global the stipple now
concentrates on rugged ground and leaves plains clean.

WATER TAKES NEITHER MARK, and gets a flat tone. An earlier version excluded
Lake alone and stippled the entire ocean — the one surface with no relief to
express. Both open-water zones are excluded now.

The ocean also stops shading by elev_q, which is the same argument T-1188
already made for lakes and never applied here: elev_q on a water cell is the
bedrock UNDER the water, not the surface, so shading the sea by it paints
seabed relief nobody can see. Near a coast that bedrock rises steeply and
quantizes hard, which is exactly where it showed — a pale, pixellated, broken
fringe hugging every shore (Jeroen, on the capture). One tone for the sea reads
as water and lets the coastline be the edge.

D-255(e)-legal throughout: texture-space dithering of already-derived per-cell
values, decided per server cell by a hash of its own coordinates and values —
no sample invented between cells, identical on cache hit and miss.

Two colorize tests updated: both asserted the old ocean shading incidentally
while testing zero-fill/no-crash. Property under test unchanged.

1838 client tests, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 15:20:04 +02:00
jpmschweitzerandClaude Opus 5 8eb5cb9ff7 feat(ui): the Atlas ladder bottoms out at Quarter (D-255, T-1213)
Quarter becomes the deepest navigable rung. Block and Chunk leave the ladder.

The rule, from the amendment: the deepest Atlas rung is the one at which a
screen pixel shows one subtile. At the uniform 2x2 px display ratio a
3440x1440 window gives 540 gridunits on the short axis, so Quarter's 512 m
extent draws 0.948 m per gridunit -- about one voxel per gridunit and one
0.5 m subtile per pixel. Block (0.237) and Chunk (0.119) magnify beneath the
finest datum that can exist, and measured as exactly that on Ferrath: one
morphology zone, one vegetation class, an unbroken colour field. They were not
missing a feature; there was nothing left to show them.

CHUNK ITSELF IS UNTOUCHED. It remains D-243's 64 m stream/derive unit and is
where Phase 5 derives first-person walkable content -- D-012's
load-around-the-player is expressed in chunks. Block remains the 128 m
generator planning unit. Both keep their enum variants, their extent_m answers
and their wire vocabulary. What was retired is the claim that a MAP of one is
worth looking at.

DEEP_RUNGS moved with the floor, and this is the part worth reading twice. It
is a mandatory D-255(d) hardening: a per-body retention cap bounding how much
ground an exhaustive pan can hold resident at fine spacing. Left as
[Block, Chunk] it would have guarded rungs no client can request -- dead code
-- while the accumulation gap silently re-opened under Quarter, now the finest
navigable rung at ~0.95 m per gridunit. It is now [District, Quarter]. A
control that names its targets by rung has to follow the ladder when the
ladder moves.

Six tests pinned the old floor and were updated rather than deleted, since
each was protecting a real property: the clamp tests now clamp at Quarter, and
the disk-cache tests use Region for "shallow" (District is capped now) and
Quarter for "deep". One new test pins the distinction the change turns on --
the retired rungs are absent from RUNG_LADDER but still present in
RUNG_EXTENT_M, because viewability was retired, not vocabulary.

Also removes the four capture scenarios for the retired rungs, including the
two blank goldens that had been passing against blank captures.

1838 client tests, 0 failed. Server clippy clean, step_canvas suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:38:47 +02:00
jpmschweitzerandClaude Opus 5 b429f633e6 docs(meta): D-255 — the ladder floor is one subtile per pixel (T-1213)
Measured during T-1213, through the production canvas builder on Ferrath: at
Quarter and below, morphology collapses to ONE zone and vegetation to ONE
class. The uniform frames in the 2026-08-06 descent ladder were those rungs
drawing exactly what they contain.

The cause is arithmetic, not a missing feature. At the uniform 2x2 px display
ratio a 3440x1440 window gives 540 gridunits on the short axis, so:

  Quarter  512 m  ->  0.948 m/gridunit  ->  0.474 m/px   ~1 subtile per pixel
  Block    128 m  ->  0.237 m/gridunit                    4 gridunits per voxel
  Chunk     64 m  ->  0.119 m/gridunit                    8 gridunits per voxel

Block and Chunk magnify beneath the finest datum that can exist, so they can
only ever draw one voxel larger. Quarter lands within 5% of one subtile per
pixel and becomes the floor.

Stated as a rule so it survives the constants moving: the deepest Atlas rung
is the one at which a screen pixel shows one subtile. It is derived from the
data model rather than chosen, and it moves automatically if the subtile does.

WHAT THIS IS NOT. Chunk remains the 64 m stream/derive unit of D-243 and stays
vital — it is what Phase 5 derives first-person walkable content on, and
D-012's load-around-the-player is expressed in chunks. Block remains the 128 m
generator planning unit. Only Atlas VIEWABILITY is retired; the containment
ladder is untouched. This record governs what the map draws, not what the
generator builds.

The justification is the Atlas's purpose (Jeroen): it exists to give the player
information, and a rung earns its place by answering a question the rung above
cannot. Once a pixel is a subtile there is no finer datum to answer with.

The resulting Global -> Region -> District -> Quarter steps at ~93x -> 100x ->
4x. That unevenness is NOT from this change — the rungs removed were 4x and 2x
steps carrying no information — it is D-243's one non-power-of-2 rung, and
T-1218 already exists to re-balance it. A compensating rung above Region was
considered and declined here; it belongs with that ticket.

CLAUDE.md's cascade line updated in the same commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:05:41 +02:00
jpmschweitzerandClaude Opus 5 6beb4bbea1 chore(meta): commit the T-1212 -> T-1213 blocker removal (T-1211 re-scope)
The write-through row tombstoning that dependency edge was left untracked by
the earlier re-scope commit. It matters on its own: the tracked July file
CREATES the edge, and this August file is the only record that it was
removed. Without it a `pql plan rebuild` replays July unopposed and T-1213
comes back blocked by a measurement that was retired as a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 13:12:01 +02:00
jpmschweitzerandClaude Opus 5 49812e3127 test(client): the chaining test outlived the chaining (T-1237)
4e503c356 deleted the client's _chain_runs() when the join moved to the
server, but left the test that pinned it. It asserted that three end-to-end
edges chain into one river, which is now precisely what must NOT happen, so
the gdUnit4 suite went red on main. Caught by the push gate; the parse sweep
I did run cannot see a behavioural assertion.

Replaced with the two properties that actually hold now, rather than dropped:

- A river's length is measured WHOLE. One course of four collinear points,
  each 1,500 m segment 5.9 px and under the 15 px floor, total 17.6 px and
  over it. That is the invariant the old test was really protecting — long
  rivers must not vanish because their pieces are individually small — and it
  survives the move to the server.

- Separate courses meeting end-to-end are NOT rejoined. This is the deleted
  chaining's headstone. Water splits a course, and D-261 is explicit that a
  river crossing a lake is two visible strokes, so a client that helpfully
  reconnected them would draw a line across the lake. Three touching
  sub-threshold courses must all be culled; a resurrected chaining pass would
  report one.

1837 tests, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:35:54 +02:00
jpmschweitzerandClaude Opus 5 fc55bd897f fix(simulation): PR #218 round 2 — the guard was spending D-211's spacing promise
Hoshe and Tyre independently found the same defect, neither having seen the
other's review. They were right.

THE SPACING REGRESSION. synthetic_attractor's walk picks a candidate that
satisfies MIN_SPACING against every already-placed city, and the ocean
correction then moves that candidate up to h/2 = 128 cells with no
re-validation. D-211 step 4 promises the synthetic attractor is placed "at a
position that respects minimum city spacing" — the fix was buying the land
half of that promise with the spacing half. road_graph::collapse_colocated is
no safety net either: it dedupes by name, not position.

The spacing predicate now goes INTO the ring walk (nearest_cell_matching)
rather than running before it, so the search returns the nearest cell
satisfying land AND spacing, with the same tie-break order and the same
degrade-to-skip. A predicate on the existing walk, not a second walk.

Unmeasured, and stated rather than implied: whether this was ever a LIVE
violation or only a latent one. The old behaviour was replaced before it was
measured. What the corpus does say is that 12 of the 13 bodies showing any
sub-MIN_SPACING pair carry no synthetic placement at all.

AND THE FIRST VERSION OF THAT FIX OVER-ASSERTED. Checking MIN_SPACING across
ALL placement pairs found 13 violations corpus-wide, none of them the guard's
doing: the promise is step 4's alone, and matched placements (Tier A greedy,
Tier B/C Hungarian) sit on their terrain attractor, never subject to it. Two
real river mouths 12 cells apart is geography. Shipping that assertion would
have failed the gate and blamed this guard for pre-existing placements. Both
checks are now scoped to pairs involving a synthetic placement, with the 13
matched-pair proximities recorded in-code so they are not re-litigated.

Corpus, both invariants: 267 bodies, 267 reaching Layer 3, 344 placements,
109 synthetic, 0 in water, 0 spacing violations.

ALSO FIXED:
- t1206_verification_scan could still pass vacuously (Hoshe). The fast test
  got bodies_loaded>0 / synthetic_seen>0 guards last round; the scan — the one
  test whose entire purpose is being the re-runnable evidence — did not, and
  !bodies.is_empty() only proves the directory listing worked. Both added.
- cascade_snapshot_for_body's doc-comment claimed the snapshot "still carries
  the transient TerrainAnalysis" (Tyre). It is always None for a full-cascade
  call. Corrected in place, with the re-derivation recipe and a note that this
  sentence cost a false-clean 267-body scan.
- The passthrough test's comment described a land-island fixture and claimed
  (0,0) is not returned; the fixture is ta_all_land and the test asserts (0,0)
  IS returned (both reviewers). Rewritten to match reality.
- max_land_search_ring's cost note said ~(h/2)^2 = 16k candidates (Hoshe).
  That is one quadrant's area, not cumulative ring cost: sum of 8r over
  1..=128 is 66,048. Conclusion unchanged; the arithmetic is the executable
  recalibration rationale, so it has to be right.
- The fast test ran 25.8s, close to the long pole of the whole --lib suite
  (Hoshe). Trimmed 6 bodies to 3: 13.0s. Not to one — synthetic_seen>0 needs
  only one body, but resting on one is how the first draft ended up depending
  on GJ903c alone.

RETRACTED: the shared-ring-search-helper finding. Round 1 ruled duplicate-
over-share CORRECT for this walk and said so "stated so it isn't
re-litigated"; round 2 asks to factor it with no new evidence and no change to
either implementation. Fixing the spacing gap by predicate rather than by a
second walk moves that direction anyway.

Full cargo test green (40 binaries).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:49:39 +02:00
jpmschweitzerandClaude Opus 5 6147529fe8 test(config): reject captures where the renderer drew nothing
run-visual verified only that the captured PNG was non-empty AS A FILE. A
blank screen is a perfectly valid ~19 KB PNG, so it passed — and once a blank
capture had been recorded as a golden, every later blank capture matched it at
0.0% and the scenario PASSED. atlas_GJ338Bd_Block and atlas_GJ445c-m1_Chunk
sat green against blank goldens while the suite's other 30 scenarios failed.

That is the worst kind of test result: indistinguishable from success, and
load-bearing for exactly the work it fails to cover. e024cfb3f recorded this
same failure once already ("the Atlas Global goldens have been measuring
nothing"); it recurred because nothing checked the property, only the file.

tooling/visual-blank-check measures the share of the frame taken by its single
most common colour. On this project's real captures the classes are far apart:

  Global (real world map)     38.7% modal
  Region (flat colour wash)    7.2% modal   <- dither; least uniform of all
  District                    45.4% modal
  Block / Chunk / Quarter     92.9-94.6% modal   <- nothing drawn

Nothing falls between 45% and 93%, so the 0.85 default sits in open space
rather than being tuned against a boundary case. Deliberately NOT an aesthetic
judgement: the Region wash is a real product gap (T-1213) and scores 7.2%,
comfortably "content". The question is only whether a world reached the
screen.

Wired into both paths, and the update path is the one that matters — refusing
to RECORD a blank golden is what stops the trap being re-armed. Ad-hoc
--screenshot only warns, since capturing a rung that renders nothing is a
legitimate thing to want to do; that is how the empty deep rungs were found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:12:12 +02:00
jpmschweitzerandClaude Opus 5 aeab41555a docs(meta): D-258 — the storage question was premature (T-1211 re-scope)
The 2026-07-27 amendment closed by naming the live question: does biome
un-summarisation need a stored layer, or does it ride the existing
sample-fresh-at-every-rung mechanism. A descent ladder answers a prior one:
it is not happening in any form, stored or derived.

One body, one land-anchored point, one capture per rung, no overlays, at the
panel's native 3440x1440. Ferrath's heightmap is 1024x512 over a 38,089 km
circumference -- 37.2 km per source pixel. Global draws at 35.267 km/gridunit,
about 1:1 with the source, and reads as a world. Region draws at 0.379 --
98x finer than anything stored -- and is a uniform colour field with dither.
District, at 0.0038, is ~9,800x finer and identical in character. The Atlas is
legible exactly where it samples the heightmap and flat everywhere it invents.

So the D-227 carve-out cannot be argued yet on any basis, disproven or
measured, because there is no artefact to store. Ruling: build the expansion
as a pure function first, following the mechanism that already exists
(D-255(f) mechanism B), measure that, and reopen storage only if the numbers
force it. T-1211 re-scoped, T-1212 retired as a gate with its measurement
moved downstream, T-1213 unblocked as the epic's first child.

Recorded with the same process note the previous amendment earned: this was
found by capturing the ladder and looking at it, after the goldens had been
failing for 15 commits with two of them passing against blank screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:00:39 +02:00
jpmschweitzerandClaude Opus 5 4b75be5975 fix(simulation): PR #218 review round — real-body evidence, D-211, derived bound
Three findings from Hoshe (QA) and Tyre (architecture), plus a bug the first
of them uncovered.

HOSHE — the headline evidence lived only in a deleted scratch scan. All nine
tests used synthetic fixtures, so nothing committed held the "46 of 109
synthetic placements in water" claim on real data. Two tests now do. The
T-1206 verification scan itself is committed as an #[ignore]d full-corpus
test (267 bodies, run with --ignored), which also makes the recalibration
instruction on the search bound executable rather than aspirational; a fast
test pins the bodies it identifies.

That scan promptly caught a bug in its own first draft, and it is the reason
this commit is worth reading. `CascadeSnapshot::terrain_analysis` is
transient — the cascade nulls it the moment DistrictProfile and RoadGraph are
done (D-203/T-1048, ~2 MB a body) — so it is ALWAYS None on a returned
snapshot, whatever cascade_snapshot_for_body's doc-comment implies. Reading
the ocean mask off the snapshot and skipping when absent therefore skipped
every body while reporting success: 267 bodies "scanned", 0 findings, a green
assert over an empty set, in 697 seconds. Terrain is now re-derived through
the same run_layer1_with_moisture call the cascade used, reproducing the grid
the placements were computed against.

Two habits caught it, both prompted by Hoshe's finding: a vacuity guard that
refuses to pass when no synthetic placement was seen, and counters that stop
"none found" and "never got that far" from looking identical. Corrected
figures at seed 42: 267 bodies, all reaching Layer 3, 344 placements, 109
synthetic, 0 in water — the synthetic count matching the original scan, so
the claim is reproducible now rather than anecdotal.

TYRE 1 — MAX_LAND_SEARCH_RING was justified as grid_h/2 but written as a
literal 128, leaving the 512x256 coupling implicit. It is now derived from
the grid in scope, so the value cannot drift from its own rationale. On the
current working grid it evaluates to exactly 128: no behaviour change, and
the byte-identical-placement guarantee is untouched. Recalibration owner
recorded.

That derivation does change one test. nearest_land_cell_clamps_rows_no_wrap
uses a 16x16 fixture, so its bound drops 128 -> 8, which now sits BETWEEN the
clamped distance to the far pole (15) and the wrapped one (1). The assertion
moves from position to absence and gets sharper for it: previously both
implementations returned Some((15,0)) and only the position could be pinned;
now any Some at all proves rows wrapped.

TYRE 2 — D-211 carried no note though its behaviour changed. Dated amendment
added: step 4's outcome set is no longer total (synthetic overflow may now
resolve to a defined SKIP), and step 5's warning fires for a new legitimate
reason. No re-decision needed — position remains a pure function of seed and
terrain — and the dead-end cross-reference to D-210's closure is now a live
anchor.

Full cargo test green (30 binaries).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:55:23 +02:00
jpmschweitzerandClaude Opus 5 8e69503e77 test(client): capture offscreen under gamescope, at the native 3440x1440
Two faults, one fix.

Captures were stealing the desktop. Only the golden path even tried to go
offscreen, via xvfb-run -- which is not installed here, so it took the
"using visible window" fallback; --screenshot and --movie never wrapped at
all. Every capture opened a Godot window on the machine Jeroen is working
and gaming on. Now a single wrapper covers all three paths.

gamescope, not the alternatives, for two independent reasons. It renders on
the real AMD GPU, and the goldens are pinned to this box's Mesa/AMD output
(T-1121 -- they do not port across rendering stacks), so xvfb-run's llvmpipe
would shift every pixel: offscreen must not silently mean a different
renderer. And it is the only installed option that lets the output size be
set. cage is also present and also GPU-backed, but it is a kiosk compositor
and forces its client to the headless output's default -- measured, a
960x540 request produced a 1280x720 PNG. A wrapper that quietly changes
resolution is worse than none here.

Worse, because resolution is not cosmetic on this map. D-255's extent
inversion makes the shorter viewport axis span exactly one cell of the rung,
so the viewport decides how much world a rung shows and at what cell count
-- a small capture is a DIFFERENT map, not a scaled one. 960x540 was also
16:9, so it never exercised the ultrawide aspect added in 21e263d0a, which
is the aspect actually in daily use. Raised to the panel's native 3440x1440.

It paid for itself immediately: at native, Ferrath Global reports
courses=375 drawn=0 -- every river culled, where the same build drew them at
960x540. Filed as T-1239. That is exactly the class of bug a too-small
capture hides.

Goldens are NOT regenerated here. They are stale across 15 commits already,
and blessing the current look before it has been reviewed is the trap this
suite just spent a day proving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:47:13 +02:00
jpmschweitzerandClaude Opus 5 c91096a3c9 test(client): retire the top-down visual suite, aim the Atlas ladder at land
The visual gate had stopped measuring anything: 30 of 32 scenarios
failed, and the two that passed were the worst result of the lot.

Deleted the 15 top-down scenarios (fog, HUD, dialogue, NPC, minimap,
cursor) and their goldens. They all failed at a near-uniform ~12%, and
that uniformity across unrelated scenes is one global cause -- the
ultrawide UI stretch moved every element. They cover the renderer the
cascade freezes until Phase 5, which will need its own tests anyway, so
re-baselining would only have blessed a deprecated layer nobody is
reviewing. Jeroen's call.

The remaining problem was the goldens that PASSED. atlas_GJ338Bd_Block
and atlas_GJ445c-m1_Chunk matched at 0.0% because capture and golden
were both blank -- the same "goldens have been measuring nothing" trap
e024cfb3f caught at Global, still live at the bottom of the ladder. The
cause is that every below-Global golden descends at jump_to(ZERO), and
world-metre zero is merely the origin of the region grid, not anywhere
chosen. So _setup_atlas_golden_shot now takes an optional world_center
(default ZERO -- existing goldens are untouched), and a new
atlas_GJ820Bc_land_* set walks Region through Chunk at ONE land point,
so the rungs can be read as a descent instead of five unrelated frames.

aliveness_probe prints the placement's world metres alongside its pixel
and survey cell, since that is the coordinate the Atlas actually
navigates in.

Recorded because it will be asked again: the ladder is anchored via a
CityPlacement, but that is a match record -- a pixel, an archetype, an
orientation -- not built geography. No settlement exists anywhere yet
and none is due before T-1207, so the empty deep rungs are the expected
state. What the ladder judges is the nature layer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:33:57 +02:00
jpmschweitzerandClaude Opus 5 4e503c3565 fix(simulation): a river course was one D8 hop, not a river (T-1237)
D-261 asked for contiguous river strokes, and the client got them by
chaining hops back together after the fact. That could not work: each
hop was warped independently, so a shared confluence point arrived as
two points that no longer coincided -- 375 hops rejoined into 260
pieces, and Ferrath's Global map showed scratches rather than
watercourses.

The join belongs before invention, so it now happens on the server.
river_course::build_paths walks the D8 cell graph into whole rivers
from headwater to mouth, edge-drain, or junction with an already-walked
river (including the joint cell, so a tributary visibly meets its
trunk). step_canvas emits one course per river instead of one per cell,
which also drops the per-hop warp and resampling -- a path's shape is
the terrain's, so there is nothing left to invent. It is cheaper too:
one point per river cell rather than three.

The client's _chain_runs() and its endpoint index are deleted. Runs
survive only for the reason D-261 gives them -- water splits a course,
and a river crossing a lake is genuinely two strokes that must not be
rejoined.

Pinned by a real-terrain test on GJ380c rather than a synthetic graph,
because the bug was caught by eye on real terrain: a lake must have an
outflow that runs to sea level, and no such line existed. It asserts
the property the eye was checking -- rivers are long, at least one
reaches the sea, and every path is a contiguous walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:43:41 +02:00
jpmschweitzerandClaude 474ab90663 feat(ui): rivers as cartographic strokes (D-261, T-1237)
Rivers now appear on the whole-body map for the first time. Five on Ferrath's
Global canvas, drawn as 5 px strokes that stop at the coastline.

Four rules, all client-side over existing server data, computed once on canvas
adoption rather than per draw:

  - fixed 5 px screen-space stroke at every rung
  - contiguous geometry through the river's own cells
  - never drawn over water — ocean and lake end a run
  - culled below 15 px of on-screen length (3x the stroke: below that a line
    is a square, not a river)

TWO THINGS THE MEASUREMENT FOUND THAT THE RECORD DID NOT ANTICIPATE.

First, the cull unit was wrong. A server "course" is an EDGE of the river
network — the stretch between two confluences — not a river. Culling per
course culls per segment, so a long river assembled from many short edges
vanishes entirely. Measured on Ferrath Global: 375 courses, 180 surviving the
water clip, and ZERO surviving a per-course cull. Edges are now chained
end-to-end into rivers before the cull is applied, which also delivers the
other half of D-261's "contiguous": per-course contiguity only makes each edge
unbroken; joining is what makes a river read as one line rather than dashes.
After chaining, 5 rivers survive at Global — the "major systems only from
orbit" behaviour the record predicted, arrived at by a different route.

Second, and worse: uses_orbital_derive() still read `Global | Region` while
the client's mirror had said Global-only since 2026-07-26. The D-255 amendment
claims "Region left the orbital derive set... it now takes the full
courses-aware derive". That was implemented against the MIRROR and never
against the authority, so Region kept running envelope-only and carrying no
courses — the exact thing the amendment said it had stopped doing. Both test
suites stayed green for two days because neither compares itself to the other.
Fixed here, with a note on each side pointing at the other, since the two
cannot be cross-checked automatically.

Also removes the two gates that withheld courses from the orbital rung — the
reason the whole-body map had no rivers at all. Whether a course is worth
drawing is measured in screen pixels, which only the client knows, so the
server now supplies geometry at every rung and the client decides.

The capture harness reports "drawn" alongside "courses", because "375 courses
arrived" and "375 rivers are drawn" are different claims and conflating them
is what made an empty map look like a data problem.

Client suite 1836 / 1810 passed / 26 skipped. Server suite green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 17:51:58 +02:00
jpmschweitzerandClaude 41b6ceb47e chore(meta): close T-1236 — the capture drift was the product bug, not the harness
Filed blaming the capture harness for parking the mouse in a corner. The
harness was faithfully reproducing a real edge-scroll defect, fixed in
dac64a8a4. No harness change was needed. Kept rather than deleted because the
mis-attribution is the useful part: a tooling explanation was reached for
before the product was checked.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 17:11:39 +02:00
jpmschweitzerandClaude f5044926da docs(meta): D-261 — rivers are a stroke, not a scaled feature
A river is sub-pixel at almost every Atlas scale (a 100 m channel against
Global's ~17.6 km per screen pixel), so drawing it to scale draws nothing.
The line becomes a symbol whose job is legibility: a fixed 5 px screen-space
stroke, contiguous through the river's own cell centres, never drawn over
water, and culled when too small to read.

The cull threshold derives from the stroke rather than being stipulated. A
mark reads as a line at roughly 3x its own width, so the minimum is 15 px of
on-screen length — and the kilometre thresholds then fall out of each rung's
scale: 264 km at Global, 2.8 km at Region, 28 m at District. A level-of-detail
ladder with no hand-tuned constants, which self-corrects if the width changes.

Measured on the visible extent, not total river length: a course crossing the
window always spans it and passes, so only a course wholly inside the view and
small is culled. Correct at both ends of the ladder, and no new wire field.

Records a tension that had to be resolved rather than split. Jeroen proposed a
flat 100 km cutoff, then noted that 5x5 px still reads as a dot and asked for
15-20. Those are incompatible: the km threshold depends only on the pixel
length, so 100 km implies ~6 px, which is dot-shaped at a 5 px stroke.
Admitting 100 km rivers AND keeping them line-shaped needs a ~2 px stroke.
Thick lines mean fewer rivers; thin lines mean more. Ruled for the bold stroke.

Client-side, and provably so: the cull is measured in screen pixels, and the
display ratio is a client-side viewport-dependent parameter that never reaches
a wire request or cache key, so the server cannot know how many pixels a
course occupies. Water-clipping needs nothing new either — the per-cell
classification is already in the adopted canvas.

Built on the 2026-07-27 measurement in D-258's amendment: 375 courses present,
458 of 518,400 pixels different. The rivers were not failing to render, they
were correctly beneath notice; the defect was drawing specks instead of
drawing properly or not at all.

T-1237 implements. T-1238 restores size-varying width as polish, deliberately
deferred — noting width and cull are coupled, since a thinner stroke lowers
its own visibility threshold.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 08:41:44 +02:00
jpmschweitzerandClaude 21e263d0a4 feat(ui): stretch the UI to the window aspect — support ultrawide properly
Jeroen on a 3440x1440 panel: 'it does not fit the viewport', then 'widescreen
users are fully supported so we add stretching ui'.

client/project.godot declares a 1920x1080 base viewport with
stretch/mode=canvas_items, and stretch/aspect was unset — which defaults to
'keep'. On a 21:9 display Godot letterboxes the entire 16:9 UI inside it, so
every screen in the game was pillarboxed, not just the Atlas. Measured: the
window came out 2432x1368, exactly 16:9; with aspect=expand it becomes
3440x1368, the full panel width.

Worth being explicit that the Atlas was innocent here. It was filling its
logical viewport correctly the whole time; that viewport was being boxed
inside the monitor. The three genuine Atlas sizing bugs fixed today (the
zeroed extent, the legend-column mismatch, the integer-ratio floor) were all
real and all separate from this.

expand gives every screen more space on a wide display instead of bars, which
is the right default for a UI-heavy game with a HUD and implant panels. The
tradeoff is that layouts must tolerate a variable aspect ratio rather than
assuming 16:9 — accepted deliberately, since widescreen is a supported target.

Verified: full client suite 1833 / 1807 passed / 0 failed, cold-parse clean,
parse sweep clean across 226 scripts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 21:39:10 +02:00
jpmschweitzerandClaude dac64a8a40 fix(client): the Atlas map scrolled itself off the world
Jeroen: 'it scrolls out of screen automatically.' It did, with no input.

_gui_input only fires while the pointer is over the Control, so
_last_mouse_pos freezes at wherever it was last seen. Leaving the map ALWAYS
means crossing an edge, so the frozen value is always inside the 24px edge
margin — and the viewer went on believing the cursor was held there, panning
forever. Moving the mouse elsewhere could not stop it, because 'elsewhere'
generates no events this Control ever hears.

NOTIFICATION_MOUSE_EXIT now resets to the same (-1,-1) sentinel the field is
born with, making 'pointer is not over the map' and 'pointer has never been
over the map' the same state. Neither should scroll, and there was already a
test asserting the second case — the first had no equivalent.

Considered and rejected: reading get_local_mouse_position() live instead of
caching. It is arguably cleaner, but it cannot be injected in a headless test,
so it would have traded a bug for the inability to prove the fix — and the
existing edge-scroll suite drives _last_mouse_pos directly.

This also explains the drift I had blamed on the capture harness (T-1236):
same defect, and the harness was simply exercising it faithfully.

Client suite 1833 / 1807 passed / 0 failed / 26 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 21:36:32 +02:00
jpmschweitzerandClaude 144d35d2a5 fix(client): size Global's cell count so the display ratio divides exactly
Jeroen: 'still not filling the screen... maybe we should calculate the depth
that needs to be invented based on the canvas size somehow.' That is the fix.

The old flow picked cells from the viewport, let the SERVER impose Global's
2:1 aspect, then fitted the returned extent into the drawable area with an
INTEGER pixels-per-gridunit ratio. When that extent did not divide the area
evenly, floor() dropped a whole step — and at ratio 2 the only step below is
1, i.e. half size. Worse, because the client fitted an extent it had not
chosen, the result could fill NEITHER axis.

Inverted: global_fill_extent() chooses the cell count FROM the ratio, already
shaped 2:1, so cells * RATIO is the drawn size by construction and lands
exactly on the binding axis. Verified across window shapes — 1920x1080 fills
width, 2560x1080 fills height, 2560x1440 width, 3440x1440 height, 1280x720
width. Exactly one axis fills at every shape, which is the most a 2:1
equirectangular canvas can do in an arbitrary viewport; the other letterboxes.

Global keeps its 2:1 aspect because it is 360 degrees of longitude by 180 of
latitude — the aspect cannot follow the viewport without shearing the map.

Tests pin the invariant directly: 2:1 preserved, never overflowing the
drawable area, and never leaving slack on BOTH axes.

Client suite 1832 / 1806 passed / 0 failed / 26 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 21:24:21 +02:00
jpmschweitzerandClaude d36d458b74 fix(client): Global drew at half size — request and fit disagreed about the legend
Jeroen: 'that does not fit the viewport'. Correct — Global rendered a real map
into roughly a quarter of the available area.

_letterbox_scale_for() reserves LEGEND_COLUMN_PX before computing the Global
fit, but _request_extent() sized the request against the FULL Control width.
Because that fit is an INTEGER pixels-per-gridunit ratio, the disagreement
does not degrade gracefully: at a 1920-wide window we asked for 960 gridunits
but could only fit floor(1628/960) = 1 px each, so the canvas drew at HALF the
intended scale with room to spare on every side.

The request is now sized to the drawable area, so both sides agree: 814
gridunits at 2 px = 1628 px, plus the 292 px legend column = exactly 1920.
Verified through the capture harness — canvas_scale went 0.5 -> 1.0.

Only Global reserves the column, so only Global adjusts; the fixed rungs are
untouched.

Also corrects the regression test I wrote yesterday, which computed its
expectation from the full viewport and so encoded the bug.

Client suite 1830 / 1804 passed / 0 failed / 26 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 21:13:04 +02:00
jpmschweitzerandClaude 3a17624ddc style(simulation): cargo fmt the Global extent fix
Caught by the push gate, whose new named-failure block reported it as
'- cargo fmt' with the fix command — the information the old anonymous
'1 check(s) failed' withheld, on its first real outing.

No behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:56:46 +02:00
jpmschweitzerandClaude dc1c8ca900 docs(meta): D-258 amendment — the hydrology rationale was false
Written one day after the record, on evidence, and it narrows D-258's scope.

The Rationale claimed hydrology 'was not derivable at all under the old
model'. Not true, and not true when written: layer1.rs already solves drainage
AND settled-equilibrium hydrology once per body, folds the filled surface into
TerrainAnalysis, and every rung bilinearly samples it. The code's own comment
names it — 'a coarse continuous primitive computed once, sampled fresh at
every rung, never re-solved', mechanism B, D-255(f). The pattern rung 0.5 was
invented to provide already existed. I inferred the claim from Region having
no rivers without reading the layer-1 pipeline.

What actually made Global flat was a stale sentinel zeroing its extent, giving
a 2x1 canvas. Once sized correctly Global reads as a world with no hydrology
work at all. Rivers there measured negligible: 375 courses present, 458 of
518,400 pixels changed versus courses-off, because at ~39.7 km/gridunit most
courses are shorter than one gridunit.

Survives: reliefmap-as-plurality, composition-on-descent, the conservation
invariant, and the lake-shore amendment — none depend on hydrology moving.
Weakened: the stored expanded layer and its D-227 carve-out, since the
compute-once-sample-everywhere mechanism it argued for is already shipped.

T-1211 and T-1212 flagged needs-refinement with the reasoning attached; the
measurement as scoped would have priced work that is not required.

The amendment also records why this survived review: the sizing fix's tests
called resolve_canvas_extent directly rather than the serve path, and the
capture goldens could not have caught it either since they never supplied a
body radius. Two verification layers, both green, neither looking at the thing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:53:08 +02:00
jpmschweitzerandClaude e024cfb3f9 test(client): the Atlas Global goldens have been measuring nothing
Every atlas capture scenario built its body dict without body_radius_km.
Radius is Global's ONLY spacing input (2*pi*R / width), so every Global
golden has been a degenerate block since these scenarios were created — the
map rendered at 0.000 km/gridunit. The fixed rungs were unaffected, since they
derive spacing from the rung's own cell size rather than the body.

That means the "eyeball check against .cache/screenshots baselines"
discipline has been hollow at Global specifically: a baseline that is a solid
rectangle diffs clean against a new solid rectangle. It is how a 2x1 canvas
survived in front of two verification layers.

Adds the real radii to all ten scenarios (Lendel 6238.4, Vethis 6959.3,
Arbour 6711.0, Ferrath 6062.0, Threshold 5503.5), and makes the capture log
course and settlement counts alongside canvas_cells — an empty annotation
layer was previously indistinguishable from a populated one in the log, which
is exactly the signal needed to tell "no rivers" from "rivers not drawn".

Also updates three suites to the corrected Global cache contract: the key now
honours extent (collapsing it meant a resize could never miss), so the shared
_land_global_canvas helper must cache under the extent the viewer will
actually request, and the two make_key tests now assert the real rule —
centre collapsed, extent honoured — instead of the retired sentinel.

Client suite 1830 total / 1804 passed / 0 failed / 26 skipped.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:54 +02:00
jpmschweitzerandClaude bdea719530 fix(simulation): Global was a 2x1 canvas — a sentinel outlived the extent inversion
Jeroen's Global map has been two coloured blocks all along. Not missing
hydrology, not a missing layer: serve_step_canvas_request zeroed Global's wire
extent, so every request arrived downstream as (0,0), clamped to (1,1), and
resolved to a 2x1 canvas.

That sentinel was correct when Global's size came from the body's region grid
and the client's extent field was meaningless. The D-255 extent inversion made
Global viewport-sized and this line silently outlived it. The commit titled
"size the Global rung to the viewport" was therefore correct and completely
unreachable — its tests passed by calling resolve_canvas_extent directly
rather than through the serve path, i.e. they tested the function that changed
instead of the path the data takes.

Two more places carried the same dead premise, both meaning the first canvas
ever built answered every later request and a resize could never take effect:

  - GlobalTierCache keyed on body id alone. Now treats a size mismatch as a
    miss, so the re-derive replaces it. Deliberately still ONE entry per body
    rather than one per size: keying by size would make a tier that never
    evicts accumulate an entry per viewport a player has ever used.
  - The client's make_key collapsed Global's extent to a sentinel. Centre
    stays collapsed — Global's canvas really is whole-body and origin-anchored
    — but extent is now part of the key.

project.yaml 0.4.5 forces the 2x1 canvases already on disk to miss.

Verified through the capture harness, not by reasoning: server probe shows
req=(960,540) radius=6238.4 resolved=960x480, and Ferrath's Global now renders
continents, oceans, inland lakes and polar ice where it previously rendered
one solid rectangle.

Server suite green (45 binaries), client 1830 total / 1804 passed / 26 skipped.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 20:52:23 +02:00
jpmschweitzerandClaude b7dfc8c50f docs(meta): D-259 + D-260 — the two rulings that only existed in /tmp
Everything else from the pair session was either shipped or ticketed with its
rationale in git. These two were design decisions Jeroen made, living only in
a scratchpad under /tmp, one cleanup away from gone.

D-259 — ENCLOSED SETTLEMENTS. Open-air requires atmosphere == standard;
everything else is enclosed, as point locations on the Atlas with interiors
deferred to a separate generator and handled like embedded stations. Branch on
an explicit `enclosed` property rather than the atmosphere string, so enclosed
settlements on breathable worlds (hostile-biome posts, undersea, arcologies)
need no second code path. NULL defaults to enclosed, which fails safe.

The load-bearing claim is that D-220's density model is PHYSICALLY
INAPPLICABLE to a pressure vessel, not mistuned: it measures people per km2 of
footprint driven by utility cost per metre of street, so run airless it yields
1,500 ppl/km2 of open-air streets in vacuum. The handoff mechanism was already
planned — D-166's door boundary contract, where a dome's airlock IS that door.
Atmosphere keeps its full range for the surface-expedition layer; the binary
collapse applies to settlement layout only, and the record says so to stop the
collapse being read as atmosphere not mattering.

D-260 — GENERATOR SCOPE. Sol is encoded as Authored { deepest_rung: Global }
rather than excluded by convention. The DLC gate is a DEPTH, not a boolean, so
a Sol expansion changes one value instead of unpicking an exclusion. No new
wire status: "viewable at Global, no deeper" IS the existing rung-liveness
path. Sol art is an authored data canvas, not a finished image, so it rides
the existing wire and is drawn by the same map-art function as all 269
procedural bodies.

The hazard that made encoding necessary is recorded: systems.db still carries
terrain_reference rows for Sol bodies pointing at heightmaps deliberately never
baked, so a real Earth DEM dropped at that exact path would silently start
generating a procedural homeworld. The importer comment said Sol was excluded;
nothing enforced it.

Also files the last three parked items, which were likewise /tmp-only:
T-1233 tiled/interlaced map loading (workshop-sized D-255 revision, blocked in
spirit on the rung-0.5 cost numbers), T-1234 enumerate the flow tweaks the
fetch panel exposed but which were never written down, T-1235 zoom
discoverability — the person who specified wheel-only zoom could not find it.

T-1231 closed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 09:09:34 +02:00
jpmschweitzerandClaude 58cd87d48c chore(meta): record the atlas shutdown leak as intermittent, not constant
Observed twice today with different outcomes. The crash is deterministic —
every shutdown logs the null-instance error at server_process.gd:87 via
_stop_spawned_server. The orphaned server is NOT: one run leaked a process
that had to be killed by hand, the next reaped cleanly with the same error in
the log.

That combination is the awkward one. A fix verified by a single clean
shutdown proves nothing, so T-1224 now says to reproduce by repeated
launch/close while watching for surviving processes, rather than by reading
one log.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 09:05:40 +02:00
jpmschweitzerandClaude 363574d687 fix(config): pre-push names which check failed
The hook incremented a bare counter at 13 sites and ended with "N check(s)
failed. Fix the errors above." — naming nothing. Six of those sites (fmt,
clippy, cargo test, deny, ruff, tooling) print no FAIL line at all, so a
failure was only inferable from the ABSENCE of an "— OK" line.

Hit for real today: a push aborted on cargo fmt, and the verdict was
indistinguishable from any other failure. Finding the cause meant scrolling
past thousands of lines of unrelated test-fixture output, because the one
actionable line said only that something, somewhere, had failed.

Failed checks are now collected by name and printed in a self-contained
final block, so tailing the log always shows WHAT broke — plus a pointer to
grep the failing check's own output, and the reminder that fmt auto-fixes.

Note this is NOT a verbosity reduction, which was the tempting fix. Detail is
exactly what you want when something fails; the defect was that the verdict
carried no information, not that the log carried too much.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 01:43:46 +02:00
jpmschweitzerandClaude 145e3c8b11 style(simulation): cargo fmt the extent-inversion tests
Hand-written test bodies in step_canvas.rs did not match rustfmt. Caught by
the pre-push gate, which is exactly its job — team-patterns.md's note that
fmt auto-fixes and clippy is a quick lead patch, rather than something agents
should pre-emptively duplicate.

No behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 01:38:42 +02:00
jpmschweitzerandClaude 885a10af09 chore(meta): close the two harness tickets, file the gaps they exposed
T-1222 and T-1223 shipped today but were still sitting in backlog. Closed,
with what actually landed recorded on each — including that T-1223's title
premise was wrong: godot-cold-parse does not miss client/tests specifically,
it only ever sees the startup path, so the fix was a new tool rather than a
widened filter.

Three gaps opened after the reconciliation pass and had no ticket:

T-1230 — re-enable test_character_visual_sprint28 and fix the per-test
compositor rebuild that made it 37% of the client suite. Skipping it bought
39s; the skip must not become permanent, and the ticket says so with the
deadline (Phase 5 player rendering) and the better fix to prefer.

T-1231 — the enclosed-settlement and Sol GeneratorScope rulings still exist
only in a scratchlog under /tmp with no D-record behind them. Qatux flagged
this and correctly refused to invent the governance itself.

T-1232 — scene_helper.gd turns out to have no importers at all, which is why
its five-month parse breakage cost nothing. Delete or adopt: user's call.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 01:14:03 +02:00
jpmschweitzerandClaude 791600dd24 test(client): skip the 3D character compositor suite while 3D is not in play
Jeroen's call during the Phase-4 Atlas work: nothing in this suite's subject
is being changed, and it is by a wide margin the most expensive thing in the
client suite. Client run drops from 135s to 96s — 29% — from this one file.

The numbers, measured across all 86 suites:
  this suite     50.0s /   26 tests  (~1.9s each)  -> 37% of the whole run
  all 86 suites 127.1s / 1830 tests
  the other 78   ~33s  / 1804 tests
Every test instantiates a fresh CharacterVisual Node3D and loads the skeleton
.glb plus body and skin-tone assets, so the cost is asset loading per test,
not assertion count. The rest of the suite is close to free.

Used gdUnit4's own suite-skip (__is_skipped) rather than a hardcoded pass, as
requested but one level more honest: a test that returns success without
exercising anything reports as COVERAGE. It inflates the pass count and reads,
to anyone scanning a summary, exactly like a suite that ran and was fine.
The skip reports these 26 as SKIPPED in the statistics — and run-godot now
parses that field and excludes it from passed — so the omission stays visible
in every run rather than being laundered into a green number. It also
short-circuits before the test bodies, so the 50s is genuinely reclaimed
rather than merely hidden.

No test was modified. Deleting the _init() restores the suite exactly as it
was, and the comment says so, along with when to do it (Phase 5 player
rendering at the latest) and the better fix to prefer then — sharing the
compositor instead of rebuilding it per test.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 00:57:15 +02:00
jpmschweitzerandClaude 263a98f3ed fix(config): run-godot reported double the real test count, and could not see skips
Two parsing bugs in the summary, found while measuring suite times.

DOUBLE COUNT. gdUnit4 prints one "Statistics:" line per suite and then a
single "Overall Summary:" line whose numbers are the sum of all of them. The
pattern matched both shapes and summed all 87 lines, so every total was
exactly twice the truth: a full run reported 3,660 tests against an actual
1,830, and a 26-test suite reported 52. It was invisible because it doubled
UNIFORMLY — nothing ever looked inconsistent, only large. Every count quoted
from this harness, in this session and before it, was 2x.

Now prefers the Overall Summary, which is gdUnit4's own arithmetic over the
whole run and so cannot disagree with itself; per-suite summing survives only
as a fallback for a run that dies before printing it.

ANSI. gdUnit4 colourises output and the escape sequences sit BETWEEN the
fields of the summary line, so patterns matching the raw log silently fell
through to the weaker "Executed test cases" fallback — which cannot see skips
and reported a fully skipped suite as 26 FAILED. All parsing now runs against
a de-ANSI'd copy, including the load-error guards.

SKIPS are now parsed and surfaced as their own JSON field, and excluded from
passed. Counting a skipped test as passing is the same false-green shape the
harness guards exist to prevent, and it stops being hypothetical the moment a
suite is deliberately skipped.

Verified against a fully-skipped suite (26 total / 0 passed / 0 failed / 26
skipped, was 26 FAILED) and a full run (1,830 total / 1,804 passed / 0 failed
/ 26 skipped, was 3,660/3,660).

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 00:56:47 +02:00
jpmschweitzerandClaude bf1976613f chore(config): enforce the parse sweep at the push gate, ahead of the suite
Placed in the pre-push hook rather than /pr-process, because the hook is where
enforcement actually lives — and notably the hook never ran godot-cold-parse
at all, so until now nothing enforced "does this script parse" for any file
outside the startup path.

Ordered BEFORE the test suite deliberately. That makes failures cheaper rather
than the gate slower: a script that does not parse is caught in ~4s instead of
after ~135s of tests that could never have covered it. A clean push pays 3.7s;
a broken one saves over two minutes.

Not redundant with the suite. gdUnit4 reports the suites that DID load as a
clean pass, so an unparseable file reads as success — guarded now in
tests/run-godot, but only for test files. The sweep covers all 226 scripts,
including the roughly half of the codebase no test ever loads.

/pr-process gains a scope note instead of a second invocation: cold-parse sees
only the startup path and filters "Cannot infer the type" (which hid a
genuinely broken file for five months), so it must not be read as a general
parse check. Per team-patterns.md the skill does not duplicate the gate.

Hooks run from .config/hooks via core.hooksPath, so this is live without an
install step.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 00:42:19 +02:00
jpmschweitzerandClaude a005e48405 feat(config): parse sweep — verify every project script parses, not just the startup path
godot-cold-parse only ever sees scripts on the STARTUP path: autoloads and
the main scene chain. That is the correct scope for the job it was built for
(Sprint 36's `Could not find base class "MetaScreen"`, a registration-ORDER
bug), but it is far narrower than the name suggests, and most of the codebase
is invisible to it. Verified by deliberately breaking a non-startup UI script
and a test file in turn: cold-parse reported "clean", exit 0, for both.

That is the second half of today's false green. A parse error in
test_step_canvas_annotation_layer.gd survived cold-parse AND survived
gdUnit4, which reports the suites that DID load as a clean pass. Two gates,
one blind spot: neither verified that a file it never opened was openable.

godot-parse-sweep opens every .gd in the project (226 today, addons and
.godot excluded) and fails on any that will not parse.

The split between the two halves is forced, not stylistic. No Godot API
reports GDScript parse failure reliably:

  - ResourceLoader.load(path, "GDScript", CACHE_MODE_IGNORE) SEGFAULTS the
    engine on a script that fails to parse — it dies on exactly the input the
    tool exists to find.
  - GDScript.new() + source_code + reload() returns a clean error code but
    detaches the script from its resource_path, so class_name, preload() and
    relative extends stop resolving: it reported 150 of 226 healthy scripts
    as broken.
  - Plain ResourceLoader.load() neither crashes nor false-positives, but
    returns a NON-null object for a broken script, so its return value is
    useless.

The engine's own stderr is the only honest signal. So the GDScript half just
opens files and makes no verdict; the wrapper scrapes the diagnosis. The
wrapper also refuses to pass unless the sweep reported completion, so a
future break in the walk cannot itself become a false green.

Unlike cold-parse, "Cannot infer the type" is NOT filtered. That filter is
precisely why cold-parse stayed silent about the file below.

First run found a real one: client/tests/util/scene_helper.gd has not parsed
since 2026-02-25 — five months — because `func(a := null, ...)` cannot infer
a type from null. Fixed with explicit `: Variant` params. Blast radius is
zero (the helper has no importers, so nothing else was taken out with it),
but it went unseen by two gates for five months, which is the point.

Full suite green at 3660.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 00:32:42 +02:00
jpmschweitzerandClaude 6547482e6d docs(meta): reconcile CLAUDE.md + CHANGELOG with the extent inversion; file 19 tickets
CLAUDE.md's Phase-4 row described the pre-inversion ladder — "every step a
server-derived data canvas at its native gridunit spacing" — which the D-255
amendment reversed. Corrected, with rung 0.5 noted as ruled (D-258) but not
implemented rather than restated there. The D-243 scale-ladder section is
deliberately untouched: scale.rs still holds the old constants, so it is
still accurate, and amending it now would make it wrong in the other
direction.

CHANGELOG gains three player-facing entries for today's shipped work, with
the whole-body-map fix carrying an explicit "still open: no rivers or lakes
yet" caveat so it does not read as finished.

Tickets T-1211..T-1229 filed: the rung-0.5 epic with its cost measurement
gating every child, the scale-constant change, Sol's GeneratorScope, the two
test-harness false greens, the make-atlas shutdown bug, two data gaps and
three cleanups. Golden regeneration is blocked on both the rung-0.5 epic and
the scale-constant change so the revalidation is paid once.

Review corrections applied to the delegated pass:

- The blocker graph was reported but never created — all 8 claimed edges were
  absent. Added. `pql ticket list --under T-1211 --unblocked` now correctly
  returns only the measurement, which was the structural point of the epic.
- A changelog entry credited T-1206, which is an unrelated open bug about
  synthetic settlements landing in open water. Re-attributed to the D-255
  amendment.
- Tickets have no --decision link to D-258/D-255. Not repairable: --decision
  exists only on `ticket new` and `refine write` rejects it. Filed upstream as
  pql FR-5 rather than worked around; the descriptions reference the records
  in prose meanwhile.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 00:18:47 +02:00
jpmschweitzerandClaude b2ef73256a fix(config): run-godot reported a suite that never ran as a pass
Found by walking into it. test_step_canvas_annotation_layer.gd had a parse
error from an earlier edit in this session, so gdUnit4 could not load it and
ran the other suites instead. The harness printed 3610 passed / 0 failed and
exit 0. Fifty tests had not run for hours and nothing said so — the full
suite reports 3660 with the file repaired, and that difference was invisible.

Two states are now hard harness failures rather than test results:

  load_error — a suite failed to LOAD. Any pass count excludes it, so a green
  number is a lie. The hint names the offending file.

  no_tests  — zero tests executed. A run that executes nothing can never be
  a pass; previously a mistyped --filter printed "Tests passed".

Both add a "harness_error" field to the summary JSON and exit 2. The exit
code cannot inherit gdUnit4's, which returns 0 in both states — that is
precisely why they were invisible.

Verified by injecting each failure rather than by reasoning about it. The
load_error guard was checked in the case that actually matters: one broken
file among many, where total stays large and failed stays zero. That run now
reports 3610/0 WITH harness_error and exits 2, where before it was
indistinguishable from success.

Also repairs the file itself: a missed set_frame() argument (the parse error),
and a cell-placement test still asserting pre-inversion spacing. Rewritten to
assert the invariant that survives the extent inversion, the viewport aspect
ratio and panning — half the SHORT axis is half a rung cell — instead of a
literal. Two things it deliberately does not assert, both of which the
previous version got wrong: "the corner is half a district away" holds only
on a square canvas, and the canvas is one district WIDE without sitting ON a
district. It is a free-floating window centred wherever the player panned;
zoom is stepped, pan is continuous. A rung names a scale, not a cell you are
inside. A second test pins that with a deliberately unaligned world centre,
so a future change that snaps the canvas to the rung lattice — making pan
step instead of slide — fails here.

Pair session with Jeroen, 2026-07-27.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 00:12:26 +02:00
jpmschweitzerandClaude d58397c59f docs(meta): D-255 amendment — the extent inversion, shipped today
D-255 described a system that stopped existing this morning. It said a rung
fixes gridunit SPACING and that spacing is "never viewport-derived"; both are
now exactly backwards. Anything reading it — a refinement agent, a reviewer,
a future session — would have built against a fiction with no way to tell.

Records the inversion (a rung fixes EXTENT, the shorter viewport axis spans
one cell of that level, spacing falls out), Global moving from the body's
region grid to a viewport-sized 2:1 canvas, Region leaving the orbital derive
set, and the display-ratio band collapsing to a uniform 2x2.

Two corrections matter beyond bookkeeping.

D-255 justified Global's D-226(d) legality by it being COARSER than the region
grid. It is now finer — 40.8 km against 204.8 km. The conclusion survives,
since D-226(d) prohibits tile-level maps and caps at settlement/quarter
granularity and 40.8 km is twenty times coarser than a district, but the
premise is dead and nothing downstream should lean on it.

And the always-keep cache figure is invalidated. The "~8.85 MB across 267
bodies, trivially process-resident" number assumed ~18,073 cells per body; a
viewport-sized Global is 460,800 on a 1080p display, which is 25x — about
226 MB, and roughly 900 MB on a 4K display, with per-body derive going from
~16-21 ms to about half a second. An always-keep tier whose size scales with
the user's monitor is the wrong shape, which is an independent argument for
D-258: rung 0.5 is fixed-resolution and baked, and Global becomes a view of
it rather than a canvas retained in its own right.

Also records the two retired mechanisms (the S2 station-spacing floor,
cap_extent_to_body superseded by rung liveness) and states plainly that
Global is still broken — correctly sized now, but with no hydrology until
rung 0.5 lands. Region is eyeball-confirmed working.

Pair session with Jeroen, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 23:49:25 +02:00
jpmschweitzerandClaude 1c45cd2ec8 fix(client): Global rung derived its canvas from a pre-layout viewport
Eyeballed on Lendel: the Atlas opened on a Global map that was literally two
cells — one green, one blue — stretched across the window, reporting
19,598.512 km/gridunit, which is exactly half the body's circumference.

Two bugs, both of which the D-255 extent inversion turned from harmless into
fatal.

enter() fires its first request BEFORE this Control is laid out, and a
not-yet-laid-out size is not always exactly Vector2.ZERO — a few stray pixels
sailed past the `== Vector2.ZERO` guard, so the viewer asked for a 2x2
gridunit canvas and the server's 2:1 fit floored it to 2x1. That never
mattered while Global discarded the requested extent and took its cell counts
from the body's region grid; the moment the request became the canvas size, a
transient layout artefact became the map. Any viewport below a plausible
panel size is now treated as not-laid-out.

And Global was excluded from the refetch settle entirely, so a canvas born at
the wrong size could never heal however the window was resized. That
exclusion was correct when no viewport could change Global's extent. Global
now takes the SIZE refit like every other rung, but still never the pan
re-float — its canvas is whole-body and origin-anchored, and the server
ignores `center` for it.

Both have regression tests. The second asserts on _world_center rather than
_view_offset, because _recompute_canvas_transform() legitimately re-centres
the offset on any canvas adoption and would have made the test pass for the
wrong reason.

Worth noting for the class: no test written today could have caught this.
Every one supplies an explicit viewport. The bug lived entirely in the gap
between "scene loads" and "layout completes" — a seam a live launch
exercises and a unit test does not.

Also stages governance/README.md's pql-maintained record index (D-258).

Pair session with Jeroen, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 23:43:32 +02:00
jpmschweitzerandClaude 02fe71e9f3 docs(meta): D-258 amendment — one shore, not two; tidal energy over salinity
Jeroen, eyeballing the lakes: "they did not seem to run the same coastline
code as ocean does". Correct, in two separate ways.

The coastline warp was ocean-only. invent_primitives displaces the sample
through coast_warp_px before reading the ocean mask, but the lake test read
the UNWARPED position, so ocean coasts got invented bays and capes while lake
shores traced the bare elevation contour. It cannot be fixed by warping the
lake sample alone: a lake is where a filled surface sits above terrain, two
reads that must agree, so moving one and not the other puts water on
hillsides or holes inside lakes. Both surfaces move together in the rung-0.5
pass, or neither does.

And shore morphology was structurally unreachable at a lake edge. Every gate
keyed on ocean_fraction_q, which is always 0 in a lake basin because lakes sit
above sea level. Ruled: lakes get full shore morphology — cliffs, beaches,
deltas. Gates key on proximity to water, not to ocean. No new vocabulary
needed; MorphologyZone already carries Fjord, Delta, Wetland, CliffCoast and
DuneStrand.

The interesting part is what separates the sea-flavoured types, because it
isn't salinity. A delta builds land outward where the river deposits faster
than the water removes; an estuary is the inverse, a drowned valley widening
seaward. The discriminator is tidal energy: the microtidal Mediterranean is
ringed with deltas (Nile, Rhone, Po) despite being salt, while the macrotidal
Atlantic gives estuaries (Thames, Severn, Gironde). So lakes always resolve to
Delta — and so does a tideless sea, which an ocean-vs-lake switch would have
got wrong. Tidal energy governs TidalFlat too, so one derived quantity
replaces two stipulations and no "is it the ocean" branch survives.

Salinity is a property of water, not a landform, and is excluded from
morphology entirely. Derive it from below-sea-level connectivity if gameplay
ever needs it. Parked.

Pair session with Jeroen, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 23:38:18 +02:00
jpmschweitzerandClaude 8a9877c4bf docs(meta): D-258 — rung-0.5 expanded layer, one derived base for the ladder
Every Atlas rung currently re-derives from the heightmap independently. D-258
inserts one deterministic whole-body layer between the baked inputs and the
ladder, and points every deeper rung at it instead of at the source files.

Two failures forced it. The Global rung was deriving a five-class hue map
while a per-body artefact labelled "clean color hypsometric render (display /
Atlas)" sat unused beside it. And hydrology was not derivable at all: flow is
a global solve, so no per-window derivation could produce a coherent water
system — Region carried no courses and lakes could not fill.

The record also fixes what the reliefmap IS. It is a plurality, not a
classification: each cell names the biome dominating ~38 km, a vote already
counted and discarded. So rung 0.5 un-summarises it rather than upscaling it,
which binds three consequences — biome edges are gradients never lines
(D-243's climate rule extended to biome), descending reveals composition
rather than sharpness, and invented detail must downsample back to the
summary it came from. That last one is the acceptance gate for any sub-biome
algorithm.

Rung 0.5 is a stored derived artefact and therefore a named carve-out from
D-227's derive-don't-store. The boundary is principled: D-227 governs what is
LOCALLY computable, where storage is pure cost. A whole-body flow solve is not
locally computable by construction — that is why it must exist — so storage
here buys correctness, not convenience. Everything below rung 0.5 stays
derive-don't-store.

Record precedes implementation; no code changes here. Complements the same
session's D-255 extent inversion, which governs how a canvas is sized rather
than what it is made of.

Pair session with Jeroen, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 23:24:47 +02:00
jpmschweitzerandClaude 9c8fcc2f95 feat(client): size the Global rung to the viewport, not the region grid
Global took its cell counts from `global_cell_counts()` — one gridunit per
region — and discarded the requested extent entirely. On GJ380c that produced
a 191x95 canvas built from a heightmap stored at 512x256: roughly seven times
the available cells thrown away before anything was drawn. The count also
shrank as REGION_M grew, so tuning the scale ladder silently degraded the
opener, which is why the top of the ladder got worse rather than better as
the ladder itself was refined.

Global now fits the largest 2:1 canvas inside the requested extent. It cannot
take its ASPECT from the viewport — the canvas is equirectangular whole-body,
360 degrees of longitude by 180 of latitude, and must stay 2:1 or the cells
stop being square and the map shears — so the existing letterbox absorbs the
remainder. A hostile extent is still clamped; sizing to the request is not
trusting the request.

Global also joins the deep display ratio, making the band uniform. At 5 px
per gridunit a 1920 px window asked for 384 cells across a body whose
heightmap holds 512x256 — discarding stored detail to save work already done.
At 2 px it asks for 960, which is heightmap-native: nothing thrown away,
nothing invented, and the same screen area filled either way.

A body with no radius is not a sphere (asteroid belt, oort cloud) and has no
equirectangular surface to fit. Those degrade to the region grid — a visibly
degenerate 1x1 canvas — rather than a plausible-looking lie at whatever size
the viewport happened to ask for.

project.yaml 0.4.3 -> 0.4.4 invalidates the persisted step-canvas disk cache.
District/Quarter/Block/Chunk kept identical 960x540 cell counts through the
extent inversion, so their cache keys are byte-identical while a District
canvas now covers 3.6 km of ground instead of 1,966 km — a warm cache would
silently serve pre-inversion canvases.

Global still rides the orbital derive, so it carries no courses yet; that is
the next step and is deliberately separate, being a cost question over the
whole body rather than a sizing one.

Pair session with Jeroen, 2026-07-26.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 22:45:40 +02:00
jpmschweitzerandClaude 0a0419abcc feat(client): invert the Atlas rung relation — rung sets extent, not spacing
A rung used to fix the gridunit SPACING, with the canvas extent falling out
of spacing x cell count. That is why the top of the ladder was unusable: at
REGION_M spacing a viewport-sized canvas spanned ~251,658 km — six times
around a rocky body — so the Region rung capped to the body and redrew the
Global picture pixel-for-pixel. "Global and region look the same" was not a
rendering bug; it was this relation, stated in metres.

Inverted: a rung fixes the EXTENT and the spacing falls out of the canvas
size. The shorter viewport axis spans exactly one cell of the rung's level,
so a widescreen window shows more ground on the long axis rather than less
on the short one. Every rung now shows the ground its name promises —
Region 262x466 km, District 4.1x7.3 km — and the canvas cell count is
viewport-driven and identical at every rung, so derive cost no longer varies
with depth and resize is free.

Consequences that fell out of the inversion rather than being chosen:

- Region leaves the orbital derive set. It was envelope-only because at
  251,658 km nothing finer made sense; at 262 km it is a genuine provincial
  map and takes the full courses-aware derive. Region having no rivers at
  all was much of why the top of the ladder read flat. It also joins the
  deep display ratio for the same reason.
- The S2 station-spacing floor is deleted, not retuned. It guarded an
  O(1/spacing) blowup that the inversion makes structurally impossible (the
  canvas cell count is now constant across rungs, so stations-per-course is
  bounded however deep you scroll). Kept, it would do active harm in the
  opposite direction: a 2,048 m pitch across a 3.6 km District canvas places
  two stations and draws every river as a straight line. Station placement
  gets its own generator pass.
- cap_extent_to_body is superseded and now a documented no-op. A canvas can
  no longer over-request a body by construction. The residual question —
  whether a rung's cell exceeds the whole body — is liveness, not capping,
  and is_rung_live_on_body() answers it by omitting the rung. Empirically it
  never fires on inhabited content: all six rungs are live on all 271
  populated bodies with a radius.
- snap_to_gridunit no longer truncates its multiplier to int. Post-inversion
  the deep rungs run sub-metre (Chunk ~0.12 m at a 1080 px short axis), where
  int(spacing) floors to zero and would collapse every request centre onto
  the origin.

Both sides derive spacing from the same three inputs (rung, echoed cell
extent, body radius) rather than one telling the other, so there is nothing
to keep in sync beyond the constant table itself. Body radius already
reaches the viewer via enter(); no wire change.

Pair session with Jeroen, 2026-07-26. D-243/D-255 amendments to be backfiled.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 21:38:08 +02:00
jpmschweitzerandClaude f5b34131b0 chore(meta): update changelog
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 17:12:52 +02:00
jpmschweitzerandClaude 36482de5d6 fix(ui): Atlas fetch readout, and one settle timer for resize and pan
Two viewer fixes from the same pair session; they share
step_canvas_viewer.gd so they land together.

FETCH READOUT. A cold derive takes seconds and the map gave no honest
sign of it. Root cause found while building the replacement: a Control
paints its own _draw() BEFORE its children, so everything the viewer
drew itself — the old DERIVING TERRAIN label AND the pending wash —
was painted UNDER the terrain canvas, visible only when no texture
existed at all, i.e. never in the slow-fetch case they existed for.
That has been the state since the stepped viewer shipped. The readout
now lives in its own overlay node added after the canvas: a centered
implant-idiom panel, DOWNLOADING MAP DATA, indeterminate sweep, 250 ms
grace so cache hits never flash it, held canvas still drawing beneath.
Indeterminate by design — the server reports no derive sub-steps, and
a progress fraction we cannot source would be invented.

ONE SETTLE TIMER, THREE TRIGGERS. The viewer never re-requested a
canvas on resize, so one derived for a smaller window letterboxed
forever in a bigger one — the map not filling the frame. And the
pan-edge refetch fired from inside the per-frame pan loop the instant
its threshold was crossed, so a held edge-scroll issued a fresh
request AND snapped the view on every frame past it. Both are the same
event: the user is still moving. A shared one-shot timer now collapses
them into a single request at the resting state, with a hard pan
threshold that still fires immediately when the canvas edge is about
to enter view (waiting there would show empty background), and
drifting back inside the soft threshold cancels the pending request.
Resize reaches this Control identically whether the OS window or a
diegetic in-implant parent changed, so both sources are covered.

Two new tests pin the soft path (schedules, does not refloat or snap)
and its cancellation; the pre-existing threshold test was verified to
still discriminate — its drift trips the new hard threshold — rather
than passing vacuously.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 17:12:05 +02:00
jpmschweitzerandClaude ae03ea2de7 perf(ui): Atlas deep rungs draw one gridunit per 2x2 px block
DISPLAY_RATIO_DEEP 1.0 -> 2.0, so a viewport-fit request at District/
Quarter/Block/Chunk asks for ~4x fewer gridunits (1920x1080: ~2.07M
cells -> ~518K) and the server-side derive cost falls with it. Texel-
exactness is preserved — the ratio stays a whole number of screen px
per gridunit, so every source texel still lands on whole pixels; only
the block size changes. Non-integer ratios are not an option here:
they reintroduce exactly the sub-pixel blur D-255's texel-exactness
exists to prevent.

Judged live by Jeroen against the 1x1 build (pair session): the deep
rungs read as crisp larger pixels rather than blur, and the speedup is
substantial. Looks were the gate.

Both transport tests that pinned the old literal now assert the
RELATIONSHIP instead — deep rungs share the constant, viewport-fit
divides by it — plus a new guard that the ratio stays whole, so the
value remains tunable without editing tests that are not about it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-26 17:11:44 +02:00
jpmschweitzer e52aa027b3 Merge remote-tracking branch 'origin/main' into ocean-guard-synthetic 2026-07-26 15:28:34 +02:00
jpmschweitzerandClaude Fable 5 b58b02c98a chore(meta): pql changelog — idmap row (T-1206 flow)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:28:25 +02:00
jpmschweitzerandClaude Fable 5 dbd5c6c4f2 fix(simulation): synthetic-overflow placements get the ocean-mask guard (T-1206)
synthetic_attractor now takes the terrain analysis and land-corrects
its pure-arithmetic position via a bounded nearest-land ring walk
(T-1116's pattern: row-major tie-break, column wrap, row clamp,
MAX_LAND_SEARCH_RING=128 sized empirically — real polar ocean bands
push nearest land up to 125 cells). Land positions pass through
UNTOUCHED — verified by direct before/after scan of all 267 real
bodies: 63 land-arithmetic placements byte-identical, and every
golden/determinism harness passes unchanged. The gap was real and
widespread: 46 of 109 synthetic-overflow placements sat in open water
at seed 42 (e.g. GJ903c at a genuine polar ocean cell); post-fix zero,
with all 109 preserved (confirmed at a second seed). Degradation is
defined and pinned: no land within the bound -> the synthetic
attractor is skipped and Phase 5's existing not-placed warning
reports it — never a panic, never a fabricated water position (the
bound never triggers on any scanned real body). 9 new unit tests;
road_graph's anchor comment and the D-210 amendment record the gap
CLOSED (validated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:27:43 +02:00
jpmschweitzerandClaude Fable 5 dc403d7f73 chore(meta): changelog + pql — T-1197 done (PR #217 merged, araminta re-accepted)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:24:26 +02:00
jpmschweitzer 3821e6718f Merge remote-tracking branch 'origin/main' into header-ghost-fix 2026-07-26 15:21:11 +02:00
jpmschweitzerandClaude Fable 5 c431780164 fix(client): fog perf test measures min-of-7, not median-of-5 (T-1210 done)
Three flakes in one day (0.549/0.503/0.638 vs the 0.5ms budget) proved
median-of-5 (T-1092's mitigation) insufficient when a concurrent cargo
build inflates all samples together. The assertion asks whether the
CODE meets the D-059 budget — load can only inflate wall time, never
deflate it, so the minimum is the least-noise estimator of code
capability, while a real regression shifts the minimum too. Budget
unchanged; regression-catching power preserved. 46/46 verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 15:17:51 +02:00