Files
settled-reach/docs/architecture/interstitial-fill-t1098.md
T
jpmschweitzerandClaude Fable 5 9c5aa79852 fix(simulation): PR #216 review fixes — footprint-wins enforcement, discriminating tests
Finding 2 became a real code fix: interstitial_fill_into now enforces
the footprint-wins conflict rule (column_has_voxel range probe) — the
FilledChunk absence contract was previously a documented promise the
code didn't keep against conflicting inputs; pinned by a fully-
overlapping-leaf test asserting per-tile resolution. The tautological
overlap test replaced with a real rects_overlap() geometric helper
(itself sanity-tested) applied pairwise. The degenerate-setback fix is
now a standalone pure fn shrink_lot_or_interstitial with four boundary
tests — honestly documented as unreachable from live traffic today
(every min_lot exceeds every setback), a robustness guard for future
recalibration. Both sub-chunk clip tests now reconstruct the full
32-tile union across the seam (disjoint + complete), including the
pre-existing footprint clip test (leave-cleaner). Brief's ChunkLayout
claim tightened to the verified no-production-consumer statement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 14:28:40 +02:00

342 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Interstitial Fill — Geometry Model (T-1098)"
description: Design brief pinning the geometry model for chunk-derive interstitial tile classification, before implementation
type: design
status: binding
round: T-1098
created: 2026-07-26
---
# Interstitial Fill — Geometry Model (T-1098)
**Author:** Dudley. **Status:** approved by the lead 2026-07-26 — implementing
per this shape. `dock_slip`/`market_pad` context-sensitive resolution split out
as **T-1209**.
This is the design pass T-1098 (and its parent T-959) explicitly required before
writing code: pin which chunk tiles are interstitial vs footprint-covered vs
street, how block-level metadata reaches `FillChunk`, the per-tile resolution
rule to a D-235 interstitial type or D-233 ops-surface tag, purity, and the
determinism story. Every citation below was re-read from the record body
(`pql decisions read`) before being written down here — not carried over from
memory or ticket prose.
## 1. The geometry source: BSP leaves that lost the coverage roll
`subdivide_block_footprints` (`server/src/atlas/skeleton_gen.rs:853-906`) already
computes the exact geometry this ticket needs — and already throws it away.
Walking the function: it BSP-subdivides the block's inner rect (block tile-space
minus the `BLOCK_MARGIN` perimeter street band, minus a waterfront edge's margin
when present) into lot-sized `TileRect` leaves (`bsp`, line 768). It then rolls
per-leaf coverage:
```rust
for (i, lot) in leaves.iter().enumerate() {
let roll = (splitmix64(seed.seed() ^ (i as u64 + 1)) % 100) as u8;
if roll >= coverage {
continue; // interstitial gap
}
// ... shrink by setback, push to `out` as a building footprint
}
```
The `continue`d leaves are — in the function's own comment — "interstitial
gap"s. They are never collected; only the surviving (shrunk) footprints are
returned. **This is the geometry source.** There is no need for a separate
ground-fill pass, no need to re-run BSP at `FillChunk` time, and no need to
infer interstitial tiles by negation over the footprint list at chunk-derive
time (which would require re-doing rectangle-containment against every
building in the block, an O(footprints × chunk tiles) scan for information the
plan phase already computed as a distinct set of rects for free).
**Decision: `subdivide_block_footprints` returns both lists.** It changes shape
to return `(Vec<TileRect> /* footprints */, Vec<TileRect> /* interstitial
leaves */)` (or an equivalent small struct — naming TBD at implementation, e.g.
`BlockSubdivision { footprints, interstitial }`). The BSP leaves that pass the
coverage roll go to the existing footprint path unchanged; the ones that fail
become the new interstitial rect list. `assign_block_tags`
(`skeleton_gen.rs:914`) already calls this function and is the natural place to
carry the second list forward into whatever block-level structure gets
threaded to `FillChunk` (§2).
### The three-way tile classification at a chunk
Given the above, a chunk's 64×64 tile grid partitions into exactly three kinds,
by *construction*, with no residual "what's left over" category to guess at:
1. **Footprint-covered** — inside a `BuildingPropertyTag.footprint` rect (already
consumed by `shell_derive_into` in `shell.rs`; unchanged by this ticket).
2. **Interstitial** — inside one of the new interstitial `TileRect` leaves from
§1 (a BSP leaf that lost its coverage roll, already shrunk to the *inner*
rect the same way a footprint would be — see note below on setback).
3. **Street** — the `BLOCK_MARGIN` perimeter band (2 tiles, or 0 on a flush
waterfront/rail edge per D-234b/T-1076 §4) plus, if in bounds, the
complementary strip outside the inner rect but inside the block. This band
is explicitly commented "street" at its point of origin
(`skeleton_gen.rs:872-887`, `// Perimeter street margin`) — it is claimed by
the future street-network layer (D-234), not by this ticket. `ChunkLayout`
(`spacing`/`offset`/`rotation_steps`, `generator.rs:103`) is the *only* other
street-adjacent data on `BlockSkeleton` today; grep confirms it has **no
production consumer** (its only reads are `#[cfg(test)]` assertions in
`skeleton_gen.rs` — `skeleton_has_streets_and_local_lattice` — checking that
the plan-time writer set it; the writer itself, `generate_quarter_skeleton`'s
layout-mode application, populates it but nothing downstream of plan time
consumes it yet). It is reserved for that same future street-network
step — not something this ticket needs to or should resolve.
Every block-local tile is covered by exactly one of these three at generation
time, because the BSP leaves partition the inner rect completely (§1's list
covers 100% of the inner rect's area, split only by the coverage roll) and the
inner rect plus the margin band partition the full 128×128 block. **No tile is
implicit-Void** — a chunk that reads all-interstitial (e.g. a reserved
park/plaza block, `block.reservation.is_some()`) resolves via §4's block-level
fallback, not by falling through unclassified.
One geometry subtlety: an interstitial leaf, unlike a footprint, is **not**
shrunk by `setback` — the setback shrink in the current code only applies to
leaves that *pass* the coverage roll (building lots pull back from the lot
line to leave frontage). An interstitial leaf is the *whole* BSP leaf rect,
because "the gap between buildings" includes exactly the frontage a
neighboring building's setback would have consumed — there's no second building
on the other side of a `continue`d leaf to setback *from*. This matches D-235's
own framing: setback_tier is what *drives* the interstitial character (via
`SetbackTier` on the covering block, §3), not a second shrink applied to the
interstitial rect itself.
## 2. Threading block-level metadata into `FillChunk`
Today `GenWorkItem::FillChunk` (`gen_queue.rs:186-199`) carries only
`block_tags: Vec<BuildingPropertyTag>` — no block-level data at all.
`build_fill_chunk_item` (`gen_queue.rs:1251-1267`) pulls that `Vec` from
`QuarterWorldState.block_tags: BTreeMap<(u8, u8), Vec<BuildingPropertyTag>>`
(`generator.rs:1288-1295`), keyed by block grid position — there is no sibling
map for anything block-level (interstitial leaves, `interstitial_character`,
`setback_tier`, `density_pct`).
**Decision: extend `QuarterWorldState` with a second block-keyed map, and add
the corresponding field to `FillChunk`.**
- `QuarterWorldState` gains `block_interstitial:
BTreeMap<(u8, u8), Vec<TileRect>>` (block-local, block-tile-space rects — the
§1 output), populated by `assign_all_block_tags` (or a sibling function)
alongside `block_tags`, at the same `GenerateSkeleton` plan-time pass. Reserved
blocks (`block.reservation.is_some()`) get an empty `Vec` here exactly as they
get an empty `Vec` for `block_tags` today (`assign_block_tags` early-returns
`Vec::new()` for reservations, line 924-926) — §4 covers what a reserved
block's chunk reads as instead.
- `FillChunk` gains two new fields, both pre-resolved at enqueue time (mirroring
`block_tags`'s existing pre-resolution — the whole point of the D-230/T-987
purity contract is that `run_work_item` never reads a cache):
- `interstitial_leaves: Vec<TileRect>` — the covering block's interstitial
rects, pulled from `block_interstitial` the same way `block_tags` is
pulled from `block_tags` today.
- `block_character: BlockFillContext` — a small new struct carrying exactly
the two scalars a per-tile resolution needs (§3): `interstitial_character:
InterstitialCharacter` and `setback_tier: SetbackTier`. **Not** the whole
`BlockSkeleton` — `BlockSkeleton` carries `hosted_sites`,
`chunk_layout`, `landmark`, etc. that `FillChunk` has no business touching
(D-230's derive phase reads only what shell/interstitial resolution needs,
same discipline `BuildingPropertyTag` already applies by carrying a frozen
projection rather than a `CityGenerationContext` reference).
Why `setback_tier` at block level rather than reading it off a
`BuildingPropertyTag.exterior.setback_tier`: T-1098's own deferral rationale
(quoted in the ticket) is exactly this point — "setback_tier is per-building
on exterior, but 'the gap between buildings' is block-shaped." An
interstitial leaf has no covering building to read `.exterior` from. But
`SetbackTier` is *already* deterministically a function of `block.density_pct`
alone (`derive_setback_tier`, `trait_exterior.rs:62-69`) — every building
in a block gets the *same* setback tier today, so re-deriving it once at
block level (`derive_setback_tier(block.density_pct)`) instead of reading it
off any one building's tag is not a new derivation, just relocating an
existing pure function call to the natural owner. This sidesteps needing a
"pick a neighboring building's tag" rule that would be arbitrary (which
neighbor?) and asymmetric (empty blocks have none to pick).
`build_fill_chunk_item` changes to also look up `block_interstitial.get(&block_pos)`
and call `derive_setback_tier(block.density_pct)` /
read `block.interstitial_character` off the cached `QuarterSkeleton`'s block grid,
threading both into the new `FillChunk` fields. This keeps the "no cache read
inside `run_work_item`" contract intact — all of it happens at enqueue time,
in the same place `block_tags` is already resolved.
## 3. Per-tile resolution rule
For a tile classified **interstitial** (§1), the resolution to a concrete D-235
`InterstitialType` or the D-233 `OperationsSurface` tag is a total function of
the two `BlockFillContext` scalars (§2) — no RNG needed, since D-235 already
ties character to `setback_tier` and D-233/T-1097 already ties character to
`interstitial_character`, and both are frozen block-level facts, not
per-tile ones (every interstitial tile in a block reads identically — there is
no design requirement for per-tile variation *within* one block's interstitial
character, only across blocks):
```
resolve(block_character: BlockFillContext) -> InterstitialType {
if block_character.interstitial_character == OperationsSurface {
return InterstitialType::OperationsSurface; // D-233 — never open space
}
match block_character.setback_tier { // D-233 OpenSpace path only
ZeroLot => InterstitialType::Void, // no yard at zero-lot
Tight => InterstitialType::Court,
Standard => InterstitialType::Garden,
Generous => InterstitialType::Plaza,
Campus => InterstitialType::OpenLawn,
}
}
```
This is a **new enum**, `InterstitialType` (D-235 names the vocabulary in
prose — `void / court / garden / plaza / dock_slip / market_pad / open_lawn` —
but no code enum exists yet; grepping the tree confirms it). Six of the seven
named values map cleanly onto the five-tier `SetbackTier` ladder plus the
`OperationsSurface` branch above; the remaining two (`dock_slip`, `market_pad`)
are D-235's own named examples for **specific contexts** (`dock_slip` at a
waterfront edge, `market_pad` presumably a commercial/marketplace zone
variant) that the record does not tie to a concrete derivation rule anywhere I
can find — they read as illustrative vocabulary entries, not a specified
mapping. **This brief does not invent that mapping — filed as T-1209.** T-1098's
scope is the geometry + threading + the setback_tier-driven resolution D-235
step 3 explicitly specifies; `dock_slip`/`market_pad` context-sensitivity
(waterfront edge, zone_type) is **T-1209**, not guessed at here, per the
ticket's own instruction to pin the geometry model, not invent unspecified
content. The five-value mapping above is a **subset of the vocabulary, not the
permanent shape of it** — the code comment at the resolution match arm points
at T-1209 explicitly so this doesn't read as a closed enumeration. Every
interstitial tile still resolves to a defined, non-Void value from the subset.
`InterstitialType` is `#[repr(u8)]`, integer-discriminant, append-only — same
convention as `ShellVoxel`/`SetbackTier`/the D-235 material axes (D-010).
**Output shape.** `FilledChunk` (`shell.rs:107-124`) gains a third sparse map,
symmetric with `voxels`/`surface_material`:
```rust
pub interstitial: BTreeMap<(u8, u8), InterstitialType>,
```
Keyed by chunk-local `(x, y)` only (no `z` — interstitial fill is a ground-plane
classification, one value per tile column, not a voxel-height concept; this
matches D-230's own four-way shell vocabulary being about vertical structure,
which interstitial fill is explicitly outside of per the shell.rs module doc,
`shell.rs:18-22`, "Interstitial street / open-space fill (D-215)... [is] out
of scope here"). Every ground tile in the chunk that is not `Void` (i.e. not
under a footprint or the street margin) gets an entry — dense over the
interstitial area, not sparse-by-omission the way `voxels` is sparse over
mostly-`Void` volume. Street-margin tiles (§1 kind 3) get **no** entry in any
of the three maps in this ticket's scope — D-234's street layer owns that
band's fill and is explicitly future work; this ticket's `interstitial` map
covers kind-2 tiles only.
**Absence contract (closes the implicit-Void gap one level up — coordinator
review note).** A tile with no entry in `interstitial` is not "unclassified" —
the three-way partition in §1 is exhaustive by construction, so absence from
this map means the tile is one of the *other two* kinds, never an unresolved
fourth state: either (a) footprint-covered (present instead in `voxels`/
`surface_material`), or (b) street-margin or a reserved block's chunk (present
in none of the three maps, per §1 kind 3 and §4). `FilledChunk`'s doc comment
states this explicitly — a consumer checking "is this tile interstitial" must
not read a missing key as "unknown," only as "resolve it against `voxels` to
tell footprint from street/reserved."
## 4. Reserved / all-interstitial / empty blocks
A reserved block (`block.reservation.is_some()`) has no `block_tags` and
(per §2) no `block_interstitial` entries either — `assign_block_tags` returns
early before subdivision ever runs for it. Its chunk therefore resolves via
neither §1 nor §3 mechanically; every tile in it is street-margin-or-nothing
by the current geometry. This is **correct and out of scope** for T-1098: D-230
already documents multi-block reservations (parks, terminals, plazas) as a
separate `MultiBlockReservation`/`ReservationFunction` system
(`generator.rs`) that has its own fill story, not this ticket's setback-driven
interstitial character. This brief does not touch reservation fill; a reserved
block's `FillChunk` continues to derive an all-`Void`-shell, no-`interstitial`-
entries chunk exactly as it does today (verified: no test asserts otherwise).
A **non-reserved** block with `coverage_pct` rolling zero buildings placed
(possible at low density/high setback with an unlucky BSP+roll combination,
though `roofed_coverage_pct`'s minimum is 20% for `BulkLiquid` frontier blocks
so this is rare but not impossible) still produces a full BSP leaf set, all of
which fail their coverage roll — its entire inner rect becomes interstitial,
which is exactly right: an under-built block reads as one big open lawn/plaza,
not as an error case. No special-casing needed; §1's construction already
handles it.
## 5. Purity (T-987)
Nothing above adds a cache read inside `run_work_item`/`fill_chunk`/
`shell_derive_into`. The interstitial rects and the two `BlockFillContext`
scalars are computed once, at `GenerateSkeleton` plan time (exactly where
`BuildingPropertyTag`/`BuildingExteriorTag` are already computed and frozen),
cached on `QuarterWorldState`, and pre-resolved into the `FillChunk` work item
by `build_fill_chunk_item` at enqueue time — identical shape to the existing
`block_tags` contract. The new `interstitial_fill` derive function (§3's
`resolve`, plus its caller that walks `interstitial_leaves` and stamps chunk-
local tiles) takes only its work-item fields as input and returns `FilledChunk`'s
new map: no `systems.db` touch, no `BodyWorldState`/`QuarterWorldState` read,
no RNG (the classification is a pure lookup on frozen scalars, per §3).
## 6. Determinism (D-010)
- `subdivide_block_footprints`'s BSP + coverage roll is already deterministic
(seeded `splitmix64`, no float, no wall-clock/thread-order dependence) — §1
only changes what the function *returns*, not how it computes, so this
ticket introduces no new entropy source.
- `derive_setback_tier(density_pct)` and `interstitial_character_for(bulk)` are
already pure integer functions (existing code) — re-invoking them at block
level (§2) for `BlockFillContext` produces the identical value a building's
`.exterior.setback_tier` would show, by construction (same input, same
function).
- §3's `resolve` is a total match over two enums — no RNG, no float, fully
deterministic given `BlockFillContext`.
- `interstitial: BTreeMap<...>` keeps iteration order deterministic (same
discipline as `voxels`/`surface_material`), satisfying `FilledChunk`'s
existing `PartialEq`/hashable-content contract used by the `fill_is_deterministic`-
style tests already in `shell.rs`.
- End-to-end: same seed → same `QuarterSkeleton` → same BSP leaves/coverage
rolls → same interstitial rect list → same `FillChunk` work item → same
`FilledChunk.interstitial` map. A same-seed-twice test at each new layer
(mirroring `shell.rs`'s existing `fill_is_deterministic` test) is the
verification vehicle in Phase 2.
## 7. What a Phase-5 consumer will see differently
Nothing renders yet (Phase 4 stays data-only, D-166). What changes is that
`FilledChunk` — the same struct a Phase-5 renderer will eventually consume —
gains ground-plane tile character data for every non-footprint,
non-street-margin tile in a built-up block: a specific `InterstitialType`
(or `OperationsSurface`) instead of silence. A harness/Atlas consumer asking
"what is this empty-looking tile between two buildings" gets an answer today
that it does not get at all right now (the tile simply has no `ShellVoxel`
entry and no other signal). This is Layer-5 data-completeness, not a rendering
change — consistent with T-959's closed scope ("Atlas tile-layer
visualization is T-960's scope — T-959's deliverable is DATA only").
## Open items deliberately left for a follow-up, not this ticket
- `dock_slip`/`market_pad` context-sensitive resolution (§3) — **T-1209**,
needs a D-235 amendment or a Miri/Araminta content pass to specify the
trigger condition (waterfront edge? zone_type? both?) before it can be
implemented without guessing.
- The street-margin band (kind 3, §1) getting its own fill/`StreetSurface`
per-tile stamp is D-234's future street-network step, already flagged as
out of scope by the `chunk_layout` stub's own doc comment.
- Reservation-fill (parks/terminals/plazas, §4) is a distinct system
(`MultiBlockReservation`) with its own eventual fill story — not
setback-driven interstitial character, and not this ticket.
## Summary of the code shape (for the go-ahead message)
| Item | Change |
|---|---|
| `subdivide_block_footprints` | returns `(footprints, interstitial_leaves)` instead of just footprints |
| `assign_block_tags` / `assign_all_block_tags` | also produce+return the block's interstitial leaves |
| `QuarterWorldState` | + `block_interstitial: BTreeMap<(u8,u8), Vec<TileRect>>` |
| `GenWorkItem::FillChunk` | + `interstitial_leaves: Vec<TileRect>`, + `block_character: BlockFillContext` |
| `BlockFillContext` (new) | `{ interstitial_character: InterstitialCharacter, setback_tier: SetbackTier }` |
| `InterstitialType` (new enum, `generator.rs`) | `Void \| Court \| Garden \| Plaza \| OpenLawn \| OperationsSurface` (repr(u8), append-only) |
| `FilledChunk` | + `interstitial: BTreeMap<ShellVoxelPos2D, InterstitialType>` (2-D key, ground-plane only) |
| new fn in `shell.rs` | `interstitial_fill_into(...)` — pure, mirrors `shell_derive_into`'s clip-to-window shape |
| `build_fill_chunk_item` | pulls the two new fields alongside `block_tags` |