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

20 KiB
Raw Blame History

title, description, type, status, round, created
title description type status round created
Interstitial Fill — Geometry Model (T-1098) Design brief pinning the geometry model for chunk-derive interstitial tile classification, before implementation design binding T-1098 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:

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 continued 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.rsskeleton_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 continued 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 BlockSkeletonBlockSkeleton 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:

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