Files
settled-reach/docs/workshops/planet-down-cascade/gestalt-round2.md
T
jpmschweitzerandClaude Opus 4.6 b9fd75b840 docs(workshops): planet-down cascade workshop + misc stray files
Planet-down cascade workshop (3 rounds, 5 agents): layer-by-layer
generation from empty world through population overlay, city planning,
and street rendering. Includes consultant review by Troblum.

Also commits: pre-Sprint-35 DB backup, Claude Code team-mode tmux
test log (team-test.md).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 20:18:30 +02:00

50 KiB
Raw Blame History

title, description, type, status, workshop, agent, round, created
title description type status workshop agent round created
Gestalt — Round 2: Algorithm Proposals Concrete algorithm proposals for each cascade layer, with pseudocode. Attractor-matching reconciliation. TerritorialStatus threshold proposal. Three-component district mix flesh-out. Per-layer inputs, outputs, data format, Rust tier. workshop active planet-down-cascade gestalt 2 2026-05-01

Planet-Down Cascade — Round 2 (Gestalt)

Role: Algorithm proposals. For each layer: inputs, outputs, data format, whether code is Rust runtime-background (Layers 1-2) or Rust runtime-on-demand (Layers 3-4), pseudocode for non-trivial algorithms.

Lead decisions incorporated before writing:

  • District mix: Burnelli-Sheldon's three-component model selected
  • L3-Q1: Spatial grid rotation locked (FoundingOrientation modifies district placement positions)
  • L3-Q2: Explicit spatial arrangement for CompanyTown (spine), AdminCapital (radial), FreePort (multi-node) — locked
  • L4-Q1: Threshold-crossing cache invalidation — locked
  • L4-Q4: Pre-fetch interior generation — locked
  • CL-Q1: Two-field prosperity (baseline + current), delta always derived — locked
  • Amendment 6: Population+age baseline, economic role as modifier, no zero weights — locked
  • Scatter (L4-Q5): Deferred — locked

Part 1: Reconciliation — Attractor-Matching Algorithm

The Four Positions from Round 1

Dimension Gestalt Tyre Burnelli-Sheldon Paula
Assignment method Greedy by composite score Population-sorted greedy; Hungarian recommended Hungarian (maximize compatibility sum) Priority-ordered: HQ → population → unnamed → OrganicGrowth
Overflow Less-ideal placements Proximity near existing settlements Synthetic attractors from seed OrganicGrowth at Province centroids
HQ hard constraint Score → 0.0 if incompatible Score → 0.0 if incompatible Must use compatible type Fill first by economic role constraint order; flag mismatches
FoundingOrientation Implicit (from attractor type) Explicit step after assignment Not addressed Trivial lookup; OrganicGrowth → AdminFacing

My Proposed Resolution

Take Tyre's Hungarian algorithm recommendation, Paula's priority ordering for the HQ phase, and Burnelli-Sheldon's synthetic attractor generation for overflow.

Rationale:

  • Hungarian algorithm is correct, provably optimal for N ≤ 30 cities × M ≤ 100 attractors, and Tyre's performance estimate puts it well within the Layer 2 budget (~1ms)
  • Paula's priority ordering makes the HQ constraint handling explicit — HQ-constrained cities don't compete with unconstrained cities; they are assigned first in a dedicated pass
  • Burnelli-Sheldon's synthetic attractor generation is the right overflow story: it maintains the "all settlements placed at generation" determinism principle, and it produces plausible placements rather than forced-bad placements

Disagreement with Paula on one point: Paula flags mismatches for lead review rather than silently placing. I'd rather the algorithm handle the mismatch silently with a quality-degraded score, and only flag to the lead when NO compatible attractor exists at all (truly impossible placement). The silent quality-degraded path lets the generator run autonomously; the flag path requires a human in the loop for routine edge cases.

Proposed Algorithm with Pseudocode

Step 0: Input Preparation

// From systems.db at Layer 2 start
struct NamedCity {
    name: String,
    population: u64,
    economic_role: EconomicRole,
    hq_for_corp: Option<CorporationId>,  // corp headquartered here (hard constraint)
    world_tier: WorldTier,
}

// From Layer 1 geographic feature extraction
struct GeographicAttractor {
    position: RegionalCellId,
    feature_type: GeographicFeatureType,
    quality_score: f32,      // 0.01.0; larger = better attractor of its type
    capacity: u8,            // how many cities this attractor can support (usually 1; large harbor = 2)
}

Step 1: Compatibility Score Matrix

fn compatibility(role: EconomicRole, feature: GeographicFeatureType) -> f32 {
    COMPATIBILITY_TABLE[role][feature]
}

// Full compatibility table (0.0 = incompatible, 10.0 = ideal)
//              RiverConf  CoastalHarbor  MountainPass  ArablePlain  ResourceConc  Defensible  NaturalBarrier
// manufacturing   6.0         7.0            2.0          4.0           5.0           3.0          1.0
// agricultural    7.0         5.0            1.0         10.0           2.0           3.0          1.0
// extraction      4.0         5.0            6.0          2.0          10.0           4.0          1.0
// transit        10.0         9.0            8.0          4.0           3.0           4.0          5.0
// research        5.0         6.0            4.0          6.0           5.0           7.0          2.0
// commercial      7.0         9.0            4.0          5.0           3.0           4.0          3.0
// service_mixed   8.0         8.0            5.0          6.0           4.0           5.0          3.0
// mining          2.0         3.0            6.0          2.0          10.0           5.0          2.0
// frontier        4.0         5.0            7.0          6.0           7.0           8.0          5.0
// energy          3.0         6.0            5.0          3.0           9.0           5.0          7.0

fn score(city: &NamedCity, attractor: &GeographicAttractor) -> f32 {
    let compat = compatibility(city.economic_role, attractor.feature_type);
    
    // HQ hard constraint: if city is an HQ, verify attractor type compatibility
    if let Some(corp_id) = city.hq_for_corp {
        let corp = systems_db.corporation(corp_id);
        if !attractor_compatible_with_corp(attractor, &corp) {
            return 0.0;  // hard constraint: incompatible placement is illegal
        }
    }
    
    // Composite score: compatibility × quality × log-population weight
    compat * attractor.quality_score * (city.population as f32 / 50_000.0).ln().max(0.5)
}

Step 2: Phase 1 — HQ-Constrained Cities

// HQ-constrained cities must use compatible attractors.
// Assign them first, before unconstrained cities compete for the same attractors.
let (hq_cities, free_cities): (Vec<_>, Vec<_>) = named_cities
    .iter()
    .partition(|c| c.hq_for_corp.is_some());

// Sort HQ cities by constraint tightness (extraction/mining corps = most geographically constrained)
hq_cities.sort_by_key(|c| constraint_tightness(c.economic_role));

for city in &hq_cities {
    let best_available = available_attractors
        .iter()
        .filter(|a| score(city, a) > 0.0)
        .max_by(|a, b| score(city, a).partial_cmp(&score(city, b)).unwrap());
    
    if let Some(attractor) = best_available {
        assign(city, attractor, &mut assignments);
        // attractor consumed; reduce capacity or remove
    } else {
        // Flag to lead: no compatible attractor found for this HQ city
        // This indicates a systems.db inconsistency (corp HQ with no viable planet geography)
        warn!("No compatible attractor for HQ city {}: placing at nearest body centroid", city.name);
        assign_synthetic(city, FoundingOrientation::AdminFacing, &mut assignments);
    }
}

Step 3: Phase 2 — Unconstrained Cities (Hungarian Assignment)

let remaining_cities: Vec<_> = free_cities;
let remaining_attractors: Vec<_> = available_attractors; // after HQ phase consumes some

// Build score matrix (rows = cities, cols = attractors)
let score_matrix = remaining_cities.iter()
    .map(|city| remaining_attractors.iter()
        .map(|attr| score(city, attr))
        .collect())
    .collect();

// Hungarian algorithm: maximize total compatibility
// O(N³) where N = max(cities, attractors) ≤ 30; negligible compute
let assignment = hungarian_maximize(score_matrix);

for (city_idx, attractor_idx) in assignment {
    if score_matrix[city_idx][attractor_idx] > 0.0 {
        assign(&remaining_cities[city_idx], &remaining_attractors[attractor_idx], &mut assignments);
    } else {
        // Hungarian assigned a zero-score pair — attractor supply exhausted
        overflow_cities.push(&remaining_cities[city_idx]);
    }
}

Step 4: Phase 3 — Overflow (Synthetic Attractor Generation)

// More cities than compatible attractors.
// Generate synthetic attractor positions from seed.
for city in &overflow_cities {
    // Find nearest already-placed city as anchor
    let anchor = assignments.iter()
        .min_by(|a, b| distance(a.position, city).cmp(&distance(b.position, city)));
    
    // Seed-derived offset: deterministic but appears organic
    let city_seed = child_seed(world_seed, hash(city.name));
    let offset_angle = (city_seed & 0xFF) as f32 * TAU / 256.0;
    let offset_distance = 0.15 + (city_seed >> 8 & 0x3F) as f32 / 256.0 * 0.25;
    // offset_distance is [15%, 40%] of body scale — matches sub-settlement placement rule
    
    let synthetic_pos = anchor.position + polar_to_regional(offset_angle, offset_distance);
    
    assign_at(city, synthetic_pos, FoundingOrientation::AdminFacing, false /*geographically_triggered*/, &mut assignments);
}

Step 5: FoundingOrientation Derivation

fn founding_orientation_from_attractor(feature: GeographicFeatureType) -> FoundingOrientation {
    match feature {
        RiverConfluence => FoundingOrientation::PortFacing,    // river as the original port
        CoastalHarbor => FoundingOrientation::PortFacing,
        MountainPass => FoundingOrientation::DefenseFacing,    // controls the pass
        ArablePlain => FoundingOrientation::ResourceFacing,    // agricultural resource
        ResourceConcentration => FoundingOrientation::ResourceFacing,
        Defensible => FoundingOrientation::DefenseFacing,
        NaturalBarrier => FoundingOrientation::AdminFacing,    // regional capital at chokepoint
    }
}
// Synthetic placements: AdminFacing (OrganicGrowth)

Handling NEW-Q2 (OrganicGrowth disambiguation):

Paula's proposal is correct: geographically_triggered: bool on the settlement record. Set true for Phases 1 and 2 (attractor-assigned); false for Phase 3 (synthetic). FoundingOrientation for synthetic = AdminFacing. No other disambiguation needed — OrganicGrowth settlements are those where the generator ran out of geographic attractors, not a designed settlement type.


Part 2: TerritorialStatus Threshold Proposal

Reconciliation Problem

Round 1 produced three threshold specifications — Paula (settlement + road coverage), Tyre (density formula), Burnelli-Sheldon (economic proxies). All three have merit; they're looking at the same thing from different angles.

My resolution: Use a priority-ordered classification algorithm. At Province (64×32) resolution, discrete category priority beats continuous threshold competition. The statuses are mutually exclusive; the order of classification determines which wins when multiple conditions are partially true.

Proposed Algorithm

Inputs per Province cell:

  • placed_settlements: all settlements assigned to this Province at Layer 2 generation time (active + inactive)
  • active_settlements: settlements where economic sim says currently active
  • road_edges: road segments passing through or originating in this Province
  • road_authority_types: distinct MaintenanceAuthority values on road_edges
  • geographic_features: features from Layer 1 (particularly ResourceConcentration)
  • corp_presence: corps actively operating in this Province
  • placed_at_generation: bool on each settlement (Paula's marker — set immutably at Layer 2 time)

Classification algorithm (first match wins, applied in this order):

fn classify_territorial_status(province: &Province) -> TerritorialStatus {
    
    // 1. WildernessBuffer — simplest case, check first
    if province.placed_settlements.is_empty() && province.road_edges.is_empty() {
        return TerritorialStatus::WildernessBuffer;
    }
    
    // 2. AbandonedZone — was settled, now empty
    // Requires Paula's placed_at_generation marker to distinguish from never-settled
    if !province.placed_settlements.is_empty()
       && province.active_settlements.is_empty() {
        return TerritorialStatus::AbandonedZone;
    }
    
    // 3. ExtractiveZone — corporate extraction corridor
    // Characteristic: ResourceConcentration feature + extractive corp presence
    // + road network dominated by Corporate/Commercial authority (extraction corridor)
    let has_resource = province.geographic_features.contains(ResourceConcentration);
    let has_extractive_corp = province.corp_presence.iter()
        .any(|c| c.commodity_matches_resource(&province.geographic_features));
    let road_is_extraction_corridor = province.road_authority_types.iter()
        .all(|a| matches!(a, MaintenanceAuthority::Corporate | MaintenanceAuthority::Trade));
    
    if has_resource && has_extractive_corp && road_is_extraction_corridor {
        return TerritorialStatus::ExtractiveZone;
    }
    
    // 4. ContestZone — competing claims
    // Two or more active settlements with different political_archetype or dominant_faction
    // AND road networks from different authority types
    if province.active_settlements.len() >= 2
       && province.road_authority_types.len() >= 2 {
        let archetypes: HashSet<_> = province.active_settlements.iter()
            .map(|s| s.political_archetype)
            .collect();
        if archetypes.len() >= 2 {
            return TerritorialStatus::ContestZone;
        }
    }
    
    // 5. CoreTerritory vs. FrontierTerritory
    // Only difference: road coverage within the Province
    if !province.active_settlements.is_empty() {
        let road_coverage = province.road_coverage_fraction();
        // road_coverage_fraction() = non-ocean cells within 2 road-edges of a road segment / total non-ocean cells
        
        if road_coverage >= 0.50 {
            return TerritorialStatus::CoreTerritory;
        } else {
            return TerritorialStatus::FrontierTerritory;
        }
    }
    
    // 6. Default fallback
    TerritorialStatus::WildernessBuffer
}

Threshold Values — Rationale

Threshold Value Rationale
CoreTerritory road_coverage ≥ 0.50 Half the Province within road access is administratively connected. Below this, settlements exist but infrastructure is sparse.
FrontierTerritory road_coverage < 0.50 Settlements placed but infrastructure thin. The player can be here, but it's the edge of the network.
ExtractiveZone corp trigger any qualifying corp in corp_presence Even one extractive corp creates the extraction character. Additional corps amplify it, don't change the classification.
ContestZone archetype diversity ≥ 2 distinct political_archetype Two different archetypes in the same Province = genuinely contested space.
AbandonedZone placed > 0, active = 0 Requires Paula's placed_at_generation marker.

Why the threshold values work:

The 0.50 CoreTerritory threshold maps to: in a 64×32 Province cell, about 32 cells within road access out of 64 non-ocean cells. At the road topology we're generating (MST + organic connectors), this means at least one well-connected settlement with secondary roads branching outward. Below 0.50, settlements exist as isolated points on thin roads.

AbandonedZone requires runtime evaluation. At Layer 2 generation time, all placed settlements are potentially active; the placed/active distinction is a runtime economics state. The Province-level status is therefore a live query against the economic sim's current state, not a stored value. This is consistent with CL-Q2: regional land-use classification (at Province resolution) is a runtime parameter read, not a systems.db stored value.

Disagreement with Burnelli-Sheldon's purely economic proxy approach: Using corp_financial_state.health_metric to derive TerritorialStatus directly makes sense for AbandonedZone, but isn't the right lens for CoreTerritory/FrontierTerritory. A well-funded corp can be in a FrontierTerritory (extraction corridor with minimal broader infrastructure). Road coverage is more physically accurate.

Difference from Paula's 0.6/0.3 thresholds: Paula proposes 0.6 for core, 0.3-0.6 for frontier. I'm using 0.5 as the single threshold for simplicity — one boundary, two states, no ambiguous middle range. The status is categorical, not a continuum.


Part 3: Three-Component District Mix — Fleshed Out

The Three Components

Lead selected Burnelli-Sheldon's model. My job here is to specify exact thresholds and the interaction logic.

Component 1: Population Tier Guarantees

Settlement tier Population Guaranteed district types
Waypoint < 50 1× Mixed only (no pure types at this scale)
Outpost 50999 1× dominant economic type (derived from economic_role)
Town 1,0009,999 1× Residential, 1× dominant economic type
Small City 10,00049,999 1× Residential, 1× Commercial, 1× Entertainment, 1× dominant economic type
City 50,000499,999 1× Residential, 1× Commercial, 1× Entertainment, 1× Administrative, 1× dominant economic type
Large City 500,000+ 1× all essential types (Res, Com, Ind, Adm, Log, Ent, Mix); WorldTier ceiling applies

Resolving BS-Q1 (9,999-person Town without dedicated Entertainment):

Towns (1,0009,999) do NOT guarantee a dedicated Entertainment district. They get Entertainment CHARACTER within their Mixed or Residential districts — the founding_age modifier and economic role determine how much entertainment is embedded. A 9,999-person mining town has bars; they're just in the Mixed district, not a dedicated Ent block. The dedicated Ent guarantee kicks in at Small City (10,000+).

This is physically correct: 10,000 people is the threshold where a permanent entertainment district becomes economically self-sustaining.

Component 2: Economic Role Multiplier Table (No Zero Weights)

All values ≥ 0.2. Values above 1.0 amplify; values below 1.0 suppress relative to the baseline.

economic_role Res Com Ind Adm Log Ent Mix Trn Spe
manufacturing 1.0 0.5 3.0 0.5 2.0 0.3 0.5 0.5 0.3
agricultural 1.2 0.8 0.3 0.5 1.5 0.4 1.2 0.3 0.2
extraction 1.2 0.4 2.5 0.3 2.5 0.5 0.5 0.5 0.2
transit 0.6 1.5 0.3 0.4 2.0 0.8 1.2 3.0 0.2
research 1.0 0.4 0.4 1.0 0.3 0.5 0.8 0.2 3.0
commercial 0.8 3.0 0.3 0.4 0.8 1.2 1.5 0.2 0.2
service_mixed 1.2 1.5 0.3 0.8 0.3 1.2 2.0 0.2 0.2
mining 1.5 0.4 2.0 0.2 3.0 0.5 1.0 0.4 0.2
frontier 2.0 0.8 0.8 0.3 1.5 0.5 1.5 0.2 0.2
energy 0.6 0.2 1.5 0.4 2.5 0.3 0.5 0.5 2.5

Key change from the original 10×9 table: no value is zero. Mining row gets Ent = 0.5 (not 0). Energy row gets Com = 0.2 and Ent = 0.3 (not 0). Every type is present at some weight; the guaranteed floor ensures it actually appears.

Component 3: Founding Age Modifier

Resolving BS-Q2: age modifier applies at Town tier and above only. Outposts and Waypoints don't accumulate age variety.

Age bracket founding_age_years Effect on district variety
New 049 No variety bonus. Dominant type is dominant.
Established 50199 +0.1 to Entertainment and Mixed multipliers. Second economic type starts appearing.
Mature 200499 +0.25 to Entertainment, +0.15 to Mixed, +0.10 to Commercial. Cultural layering visible.
Ancient 500+ +0.40 to Entertainment, +0.25 to Mixed, +0.15 to Specialized. High variety baseline.

The age variety bonus is additive to the multiplier (not multiplicative — avoids compounding). An ancient extraction city's Entertainment multiplier is 0.5 + 0.40 = 0.90, which is now close to par. The mining bars are old, well-established, and culturally significant. This is physically correct.

The Interaction Algorithm

fn allocate_districts(ctx: &CityGenerationContext, seed: u64) -> Vec<DistrictType> {
    let district_count = district_count_formula(ctx.population, ctx.world_tier);
    
    // STEP 1: Guaranteed floor from population tier
    let mut guaranteed = guaranteed_district_types(ctx.population);
    // guaranteed is a Vec<DistrictType> with 1 of each guaranteed type
    // e.g., Small City: [Residential, Commercial, Entertainment, dominant_economic_type]
    
    // STEP 2: Compute raw weights from multiplier table
    let mut weights: [f32; 9] = MULTIPLIER_TABLE[ctx.economic_role];
    
    // STEP 3: Apply founding_age modifier (Town tier and above only)
    if ctx.population >= 1_000 {
        let age_bonus = age_variety_bonus(ctx.founding_age_years);
        weights[DistrictType::Entertainment as usize] += age_bonus.entertainment;
        weights[DistrictType::Mixed as usize] += age_bonus.mixed;
        weights[DistrictType::Specialized as usize] += age_bonus.specialized;
        weights[DistrictType::Commercial as usize] += age_bonus.commercial;
    }
    
    // STEP 4: Apply political_archetype modifiers
    // (CompanyTown amplifies dominant type; FreePort flattens toward Mix/Transit/Commercial)
    apply_archetype_modifiers(&mut weights, ctx.political_archetype);
    
    // STEP 5: Normalize weights
    let total: f32 = weights.iter().sum();
    let normalized: [f32; 9] = weights.map(|w| w / total);
    
    // STEP 6: Allocate districts
    let mut districts: Vec<DistrictType> = guaranteed.clone();
    let remaining_slots = district_count.saturating_sub(guaranteed.len());
    
    // Fill remaining slots by seeded weighted selection
    // Already-guaranteed types can be selected again (multiple Commercial districts in a commercial city)
    let rng = child_seed(seed, DISTRICT_ALLOC_DISCRIMINANT);
    for i in 0..remaining_slots {
        let slot_seed = child_seed(rng, i as u64);
        districts.push(weighted_select(&normalized, slot_seed));
    }
    
    districts
}

Character, not just presence: The multiplier table modifies allocation probability. The character of each allocated district is further shaped downstream by the same multiplier values — a 0.3-multiplier Entertainment district gets low density_pct and rough perimeter_treatment; a 1.2-multiplier Entertainment district gets higher density and varied character. The multiplier is a characterization signal that flows through the entire Layer 3-4 pipeline.


Part 4: Layer 1 Algorithm Proposals

Execution tier: Rust runtime-background

Inputs:

  • systems.db: atlas_body_heightmaps (body_id, elevation_f32_le BLOB, 512×256 grid, sea_level, seed)
  • systems.db: atlas_regional_biomes (body_id, cell_id, biome_class, terrain_roughness, is_coastal, water_fraction)
  • systems.db: bodies (body_id, economic_role, planet_class)
  • world_seed: u64

Outputs (to BodyWorldState):

  • river_network: RiverNetwork (edge list with flow accumulation + confluence + mouth nodes)
  • geographic_features: Vec<(RegionalCellId, Vec<GeographicFeatureTag>)> (Layer 2 settlement input)
  • sub_biome_variants: Vec<(RegionalCellId, SubBiomeVariant)>
  • terrain_mod_costs: Vec<(RegionalCellId, f32)>

No output goes to systems.db. All Layer 1 data lives in BodyWorldState (session memory, Rust, reproducible from seed). This is the Amendment 1 consequence.

Algorithm 1: D8 Drainage Routing

fn compute_river_network(heightmap: &Heightmap, sea_level: f32, seed: u64) -> RiverNetwork {
    let (width, height) = (512, 256);
    
    // Step 1: D8 flow direction (8-directional steepest descent)
    let mut flow_dir: Vec<Option<CellIdx>> = vec![None; width * height];
    for cell in 0..(width * height) {
        if heightmap[cell] <= sea_level { continue; }
        let neighbors = d8_neighbors(cell, width, height);
        flow_dir[cell] = neighbors.iter()
            .filter(|&&n| heightmap[n] < heightmap[cell])
            .min_by(|&&a, &&b| heightmap[a].partial_cmp(&heightmap[b]).unwrap())
            .copied();
    }
    
    // Step 2: Flow accumulation (DFS traversal; each upstream cell contributes 1)
    let mut accumulation: Vec<u32> = vec![0; width * height];
    for cell in 0..(width * height) {
        let mut cursor = cell;
        while let Some(downstream) = flow_dir[cursor] {
            accumulation[downstream] += 1;
            cursor = downstream;
        }
    }
    
    // Step 3: River threshold — cells with enough upstream area are "rivers"
    let river_threshold = 200u32;  // 200 contributing cells → river (tunable)
    let river_cells: Vec<CellIdx> = (0..(width * height))
        .filter(|&c| accumulation[c] > river_threshold)
        .collect();
    
    // Step 4: Extract confluence nodes (cells with ≥2 incoming river-threshold tributaries)
    let confluence_nodes: Vec<ConfluenceNode> = river_cells.iter()
        .filter(|&&c| {
            d8_neighbors(c, width, height).iter()
                .filter(|&&n| river_cells.contains(&n) && flow_dir[n] == Some(c))
                .count() >= 2
        })
        .map(|&c| ConfluenceNode { cell: c, inflow_count: count_inflows(c) })
        .collect();
    
    // Step 5: River mouth nodes (river cell at sea level boundary)
    let river_mouths: Vec<RiverMouthNode> = river_cells.iter()
        .filter(|&&c| {
            d8_neighbors(c, width, height).iter()
                .any(|&n| heightmap[n] <= sea_level)
        })
        .map(|&c| RiverMouthNode { cell: c })
        .collect();
    
    RiverNetwork { river_cells, confluence_nodes, river_mouths, flow_dir, accumulation }
}

Performance note: D8 flow direction is O(N), accumulation is O(N log N) with recursive DFS. For 512×256 = 131,072 cells, well within Tyre's ~50ms estimate.

Algorithm 2: Geographic Feature Tag Extraction

fn extract_geographic_features(
    regional_grid: &RegionalBiomeGrid,   // 64×32
    river_network: &RiverNetwork,
    heightmap: &Heightmap,               // 512×256, downsampled to regional res
    economic_role: EconomicRole,
    sea_level: f32,
) -> Vec<(RegionalCellId, Vec<GeographicFeatureTag>)> {
    
    let mut features: Vec<(RegionalCellId, Vec<GeographicFeatureTag>)> = Vec::new();
    
    for cell_id in 0..(64 * 32) {
        let cell = &regional_grid[cell_id];
        let mut cell_features: Vec<GeographicFeatureTag> = Vec::new();
        
        // RiverConfluence: any confluence node within this regional cell's 8×8 heightmap bbox
        if river_network.confluence_nodes.iter()
           .any(|c| regional_cell_contains(cell_id, c.cell)) {
            cell_features.push(GeographicFeatureTag::RiverConfluence);
        }
        
        // CoastalHarbor: coastal cell with low roughness and river mouth nearby
        if cell.is_coastal && cell.terrain_roughness < 0.3 {
            let has_river_mouth = river_network.river_mouths.iter()
                .any(|m| regional_cell_contains_or_adjacent(cell_id, m.cell));
            cell_features.push(GeographicFeatureTag::CoastalHarbor);
            // Note: coastal cells without river mouth are harbors too, just lower quality
        }
        
        // MountainPass: high-roughness cell with lower cells on two opposing sides
        if cell.terrain_roughness > 0.65 {
            let opposing_lower = has_lower_cells_on_opposing_sides(cell_id, regional_grid);
            if opposing_lower {
                cell_features.push(GeographicFeatureTag::MountainPass);
            }
        }
        
        // ArablePlain: low roughness, terrestrial, non-coastal, suitable biome
        if cell.terrain_roughness < 0.25 
           && !cell.is_ocean && !cell.is_coastal
           && ARABLE_BIOMES.contains(&cell.biome_class) {
            cell_features.push(GeographicFeatureTag::ArablePlain);
        }
        
        // ResourceConcentration: economic_role biome prior (L1-Q4 applied here)
        // An extraction-economy body should have more ResourceConcentration tags
        let resource_weight = economic_role_resource_weight(economic_role, &cell.biome_class);
        if resource_weight > 0.6 {
            cell_features.push(GeographicFeatureTag::ResourceConcentration);
        }
        
        // Defensible: elevated position with limited approach vectors (≤2 non-cliff approaches)
        if cell.terrain_roughness > 0.5 {
            let approach_count = count_accessible_approaches(cell_id, regional_grid);
            if approach_count <= 2 {
                cell_features.push(GeographicFeatureTag::Defensible);
            }
        }
        
        // NaturalBarrier: ocean, mountain spine, impassable swamp
        if cell.is_ocean || (cell.terrain_roughness > 0.85) || is_impassable_biome(&cell.biome_class) {
            cell_features.push(GeographicFeatureTag::NaturalBarrier);
        }
        
        if !cell_features.is_empty() {
            features.push((cell_id, cell_features));
        }
    }
    
    features
}

Algorithm 3: Sub-Biome Variant Selection (L1-Q4 included)

fn compute_sub_biome_variants(
    regional_grid: &RegionalBiomeGrid,
    world_seed: u64,
    economic_role: EconomicRole,
) -> Vec<(RegionalCellId, SubBiomeVariant)> {
    
    regional_grid.iter().map(|(cell_id, cell)| {
        let cell_seed = child_seed(world_seed, cell_id as u64);
        let base_variants = SUB_BIOME_VARIANTS[cell.biome_class];  // 3-4 per class
        
        // L1-Q4: economic role biases sub-biome toward resources
        let weights = economic_prior_weights(base_variants, economic_role, cell);
        
        let variant = weighted_select(base_variants, &weights, cell_seed);
        (cell_id, variant)
    }).collect()
}

// terrain_modification_cost: derived analytically, no separate algorithm needed
// high roughness = high cost; dense/wet biome = higher cost; plains = low cost
fn terrain_mod_cost(cell: &RegionalCell) -> f32 {
    let roughness_cost = cell.terrain_roughness * 0.6;
    let biome_cost = BIOME_CLEARING_COST[cell.biome_class];  // forest >> grassland
    (roughness_cost + biome_cost).clamp(0.0, 1.0)
}

Part 5: Layer 2 Algorithm Proposals

Execution tier: Rust runtime-background

Inputs:

  • BodyWorldState (from Layer 1): river_network, geographic_features, sub_biome_variants, terrain_mod_costs
  • systems.db: bodies, system_economy, corp_presence, corporations, atlas_city_names (new schema per Tyre's ARCH-3)
  • world_seed: u64

Outputs (extend BodyWorldState):

  • settlements: Vec<GeneratedSettlement>
  • road_graph: RoadGraph (nodes + edges with MaintenanceAuthority)
  • territorial_grid: Vec<(RegionalCellId, TerritorialCell)>

Algorithm 1: Settlement Placement (Attractor-Matching)

Fully specified in Part 1. The Layer 2 integration:

fn place_settlements(body_state: &BodyWorldState, systems_db: &SystemsDb, world_seed: u64)
    -> Vec<GeneratedSettlement>
{
    // Read named cities from systems.db (new atlas_city_names table)
    let named_cities: Vec<NamedCity> = systems_db.named_cities_for_body(body_state.body_id);
    
    // Attractors from Layer 1 geographic features
    let attractors: Vec<GeographicAttractor> = body_state.geographic_features.iter()
        .flat_map(|(cell_id, tags)| tags.iter().map(|tag| GeographicAttractor {
            position: *cell_id,
            feature_type: *tag,
            quality_score: attractor_quality(cell_id, tag, body_state),
        }))
        .collect();
    
    // Run attractor-matching algorithm (Part 1)
    let assignments = attractor_match(named_cities, attractors, world_seed);
    
    // Convert to GeneratedSettlement
    assignments.iter().map(|a| GeneratedSettlement {
        name: a.city.name.clone(),
        position: a.position,
        economic_role: a.city.economic_role,
        population: a.city.population,
        founding_orientation: a.founding_orientation,
        geographically_triggered: a.geographically_triggered,
        political_archetype: derive_political_archetype(a),
        political_tether: None,  // only for sub-settlements
        active: true,            // runtime state; economic sim updates this
    }).collect()
}

Sub-settlement placement runs after primary settlement placement, using the same deterministic seed pattern from Round 3 / Layer 2 brief section. Sub-settlements inherit PoliticalTether from their nearest primary settlement.

Algorithm 2: Road Graph Generation

Under Amendment 3, all roads are generated. Named highway routes get identity via post-generation assignment.

fn generate_road_graph(settlements: &[GeneratedSettlement], world_seed: u64) -> RoadGraph {
    // Step 1: Build MST (minimum spanning tree) for inter-settlement connectivity
    // Weights: geographic distance × terrain_modification_cost × attractor compatibility
    let mst = prim_mst(settlements, |a, b| road_cost(a, b));
    
    // Step 2: Add organic secondary connections (not strictly MST)
    // For each non-MST pair within threshold distance, seed-derived probability of connection
    let secondary_edges = organic_road_connections(settlements, mst, world_seed);
    
    // Step 3: Assign MaintenanceAuthority per edge
    // Administrative: between two administratively tethered settlements
    // Corporate: between corp HQ and its corp-tethered outposts
    // Communal: between rural clusters and their tether towns
    // Trade: named trade routes (post-generation identity assignment)
    // Abandoned: leading to AbandonedZone settlements
    let edges_with_authority = assign_road_authority(mst + secondary_edges, settlements);
    
    // Step 4: Assign named route identity to qualifying road segments
    // A named highway route = high-quality road segment between two large settlements
    // on a body where the route name appears in systems.db route_names
    let named_routes = assign_named_routes(edges_with_authority, systems_db);
    
    RoadGraph { nodes: settlements.map(|s| s.position), edges: named_routes }
}

Algorithm 3: TerritorialStatus Classification

Fully specified in Part 2. The Layer 2 integration:

fn classify_territorial_grid(
    road_graph: &RoadGraph,
    settlements: &[GeneratedSettlement],
    geographic_features: &[(RegionalCellId, Vec<GeographicFeatureTag>)],
    corp_presence: &[CorporatePresence],
) -> Vec<(RegionalCellId, TerritorialCell)> {
    (0..(64 * 32)).map(|cell_id| {
        let status = classify_territorial_status(&ProvinceView {
            cell_id,
            settlements: settlements.iter().filter(|s| s.position == cell_id).collect(),
            road_edges: road_graph.edges_in_cell(cell_id),
            geographic_features: geographic_features.features_at(cell_id),
            corp_presence: corp_presence.in_cell(cell_id),
        });
        (cell_id, TerritorialCell { status, placed_at_generation: !settlements.is_empty() })
    }).collect()
}

Part 6: Layer 3 Algorithm Proposals

Execution tier: Rust runtime-on-demand (triggered by player proximity to city footprint)

Inputs (per city):

  • CityGenerationContext from systems.db (read at session startup from BodyWorldState handoff)
    • body_id, city_name, political_archetype, prosperity_baseline, surrounding_biome
    • road_entry_directions, footprint_radius_km, founding_orientation, world_tier
  • world_seed: u64

Outputs (in memory, never stored):

  • district_positions: Vec<(DistrictId, SimTilePos)>
  • district_skeletons: Vec<DistrictSkeleton> (Stage 1 + Stage 2)

Algorithm 1: District Grid Layout with FoundingOrientation Rotation (L3-Q1 — locked)

fn layout_district_grid(ctx: &CityGenerationContext) -> Vec<(DistrictId, SimTilePos)> {
    let district_count = district_count_formula(ctx.population, ctx.world_tier);
    
    // Base grid: districts arranged in near-square grid
    let grid_cols = (district_count as f32).sqrt().ceil() as u32;
    let grid_rows = (district_count as f32 / grid_cols as f32).ceil() as u32;
    
    // FoundingOrientation determines which edge is "primary" (the city's face)
    // Grid is rotated so the primary edge faces the founding attractor direction
    let primary_edge = founding_orientation_to_primary_edge(ctx.founding_orientation);
    
    let mut positions: Vec<(DistrictId, SimTilePos)> = Vec::new();
    let mut district_id = 0u64;
    
    for row in 0..grid_rows {
        for col in 0..grid_cols {
            if district_id >= district_count as u64 { break; }
            
            // District origin in city-local sim tiles (D-C5: district at (col×512, row×512))
            let base_pos = SimTilePos {
                x: col * 512,
                y: row * 512,
            };
            
            // Apply FoundingOrientation rotation
            let rotated_pos = rotate_district_position(base_pos, primary_edge, grid_cols, grid_rows);
            
            positions.push((district_id, rotated_pos));
            district_id += 1;
        }
    }
    
    positions
}

fn founding_orientation_to_primary_edge(orientation: FoundingOrientation) -> CardinalDirection {
    match orientation {
        FoundingOrientation::PortFacing => CardinalDirection::South,  // harbor at south edge
        FoundingOrientation::ResourceFacing => CardinalDirection::East, // resource face
        FoundingOrientation::DefenseFacing => CardinalDirection::North, // elevated/inland face
        FoundingOrientation::RailHeadFacing => CardinalDirection::West,
        FoundingOrientation::AdminFacing => CardinalDirection::North,   // administrative at top
    }
}

Algorithm 2: Political Archetype Spatial Arrangement (L3-Q2 — locked)

Three explicit arrangement patterns for primary archetypes:

fn apply_archetype_arrangement(
    district_positions: &[(DistrictId, SimTilePos)],
    district_types: &[DistrictType],
    archetype: PoliticalArchetype,
    grid_cols: u32,
) -> Vec<DistrictType> {
    
    match archetype {
        // SPINE: industrial/logistics at primary edge, residential cascading back
        PoliticalArchetype::CompanyTown => {
            // Primary edge (row 0) = Industrial + Logistics
            // Middle rows = Mixed + Residential  
            // Back edge (last row) = Residential only
            arrange_spine(district_positions, district_types, grid_cols)
        }
        
        // RADIAL: administrative at center, residential radiating outward
        PoliticalArchetype::AdminCapital => {
            // Center district = Administrative
            // Ring 1 = Commercial + Specialized
            // Ring 2+ = Residential (density decreasing outward)
            arrange_radial(district_positions, district_types, grid_cols)
        }
        
        // MULTI-NODE: no single center; transit/commercial at multiple entry points
        PoliticalArchetype::FreePort => {
            // Multiple Transit/Commercial anchors at different edges
            // No Administrative core; Mixed throughout
            // High variance — seed-derived node positions
            arrange_multi_node(district_positions, district_types, grid_cols)
        }
        
        // OVERLAY: two competing cores from different archetypes, separated spatially
        // (Contested cities have two neighborhoods with different dominant character)
        PoliticalArchetype::Contested => {
            // Split the grid in half; each half gets a different sub-archetype arrangement
            // The boundary between halves = Mixed/contested zone
            arrange_contested_overlay(district_positions, district_types, grid_cols)
        }
        
        // IRREGULAR: no explicit arrangement; weighted by local density
        // Density highest near geographic attractor (river, coastal)
        PoliticalArchetype::OrganicGrowth => {
            // Use normalized weights directly — no explicit arrangement pattern
            // The FoundingOrientation edge still has its primary type there,
            // but the rest is seed-derived from weights
            arrange_organic(district_positions, district_types, grid_cols)
        }
    }
}

Note on L3-Q2 disagreement with Gestalt Round 1: I previously proposed explicit arrangement for three primary archetypes, emergent for Contested and OrganicGrowth. Paula argued for all five. I'm now updating my position: explicit patterns for all five, but Contested and OrganicGrowth are simpler patterns than the primary three. Contested is a split-grid overlay; OrganicGrowth is a density-weighted fallback. These are not emergent — they're just simpler explicit patterns. The code above shows why: each match arm is distinct and produces a consistently legible result. Purely emergent produces less consistent results and wastes the archetype label.

Algorithm 3: District Type Allocation

Specified in Part 3. The Layer 3 integration applies this per city to produce district_type for each district position.

Algorithm 4: DistrictSkeleton Stage 1 Classification

fn classify_district(
    district_id: DistrictId,
    district_type: DistrictType,
    position_rank: u32,  // 0 = primary-edge district, increases inward
    ctx: &CityGenerationContext,
    district_seed: u64,
) -> DistrictStage1 {
    
    // prosperity_baseline from Burnelli-Sheldon formula + Paula's topographic modifier
    let base = ctx.economic_tier as f32 / 5.0;
    let role_modifier = ROLE_MODIFIER[ctx.economic_role];
    let prestige_rank = prestige_rank_for_position(position_rank, ctx.founding_orientation);
    
    let prosperity_baseline = match ctx.distribution_index {
        DistributionIndex::Stratified => {
            base + role_modifier + (prestige_rank - 0.5) * 0.7
        }
        DistributionIndex::Moderate => {
            let mean = base + role_modifier;
            clamp(normal_sample(mean, 0.1, district_seed), 0.2, 0.8)
        }
    };
    
    // Paula's topographic modifier (additive)
    let topo_modifier = topo_prosperity_modifier(position_rank, ctx.founding_orientation);
    let prosperity_baseline = (prosperity_baseline + topo_modifier).clamp(0.0, 1.0);
    
    DistrictStage1 {
        world_tier: ctx.world_tier,
        complexity_tier: complexity_from_world_tier(ctx.world_tier),
        setting_type: SettingType::Urban,  // wilderness districts are a separate path
        district_type,
        layout_mode: layout_mode_for_archetype(ctx.political_archetype, district_type),
        prosperity_baseline,
        perimeter_treatment: perimeter_for_type_and_archetype(district_type, ctx.political_archetype),
    }
}

Part 7: Layer 4 Algorithm Proposals

Execution tier: Rust runtime-on-demand (per chunk, triggered by load_chunk())

Inputs (per chunk):

  • district_skeleton: &DistrictSkeleton (Stage 1 + Stage 2, from Layer 3)
  • block_skeleton: &BlockSkeleton (4×4 grid from Stage 2 — the specific block this chunk belongs to)
  • chunk_seed: u64 (= child_seed(district_seed, cx as u64 << 32 | cy as u64))
  • prosperity_current: f32 (runtime economic state — separate from prosperity_baseline)

Outputs:

  • GeneratorChunkData: Vec<TileEntry> (64×64 = 4,096 entries)

Layout is seed-locked. Condition state is economics-variable (computed from prosperity_current, not seed).

Algorithm 1: Street Skeleton (Phase 2, confirmed algorithm)

fn generate_street_skeleton(block_skeleton: &BlockSkeleton, chunk_coord: ChunkCoord) -> StreetMask {
    // Door-per-block-edge defines connectivity (confirmed algorithm from Rounds 2-3)
    // Each block edge can have 0, 1, or 2 door positions (seed-derived)
    
    let block_seed = child_seed(chunk_seed, BLOCK_SEED_DISCRIMINANT);
    let mut street_mask = [false; 64 * 64];
    
    // Street corridors along block edges
    for edge in block_skeleton.edges() {
        // Minimum corridor width: 4 tiles (enough for two-way foot traffic + walls)
        // Maximum corridor width: 8 tiles (main arterial)
        let corridor_width = match block_skeleton.density_pct {
            d if d > 0.75 => 4,   // packed: narrow streets
            d if d > 0.40 => 6,   // moderate density: normal streets
            _ => 8,                // sparse: wide boulevards
        };
        
        mark_corridor(&mut street_mask, edge, corridor_width);
    }
    
    StreetMask(street_mask)
}

Algorithm 2: Building Fill

fn fill_buildings(
    street_mask: &StreetMask,
    block_skeleton: &BlockSkeleton,
    chunk_seed: u64,
) -> Vec<BuildingFootprint> {
    
    let available = street_mask.invert();  // non-street cells
    let target_coverage = block_skeleton.density_pct;
    
    // Maximum building rectangle: 12×12 tiles (prevents monolithic blocks)
    // Minimum building rectangle: 2×4 tiles (smallest viable structure)
    let max_rect = (12, 12);
    let min_rect = (2, 4);
    
    let mut buildings: Vec<BuildingFootprint> = Vec::new();
    let mut remaining = available.clone();
    let mut rng = chunk_seed;
    
    while coverage(&buildings, 64) < target_coverage && !remaining.is_empty() {
        // Seed-derived rectangle: random starting cell, random dimensions
        rng = child_seed(rng, BUILDING_SEED_DISCRIMINANT);
        let start = weighted_empty_cell(&remaining, rng);
        
        let w = lerp_u32(min_rect.0, max_rect.0, (rng >> 8) as f32 / u32::MAX as f32);
        let h = lerp_u32(min_rect.1, max_rect.1, (rng >> 16) as f32 / u32::MAX as f32);
        
        if can_place_rect(&remaining, start, w, h) {
            let footprint = BuildingFootprint {
                origin: start,
                width: w,
                height: h,
                door_positions: door_positions_per_edge(start, w, h, rng),
            };
            mark_occupied(&mut remaining, &footprint);
            buildings.push(footprint);
        }
    }
    
    buildings
}

Algorithm 3: TileEntry Generation (Economics-Variable Conditions)

fn generate_chunk_tiles(
    street_mask: &StreetMask,
    buildings: &[BuildingFootprint],
    prosperity_current: f32,
    prosperity_baseline: f32,
) -> Vec<TileEntry> {
    
    // Tile condition derived from prosperity_current (NOT from seed — economics-variable)
    let tile_condition = prosperity_to_condition(prosperity_current);
    
    (0..64*64).map(|idx| {
        let pos = (idx % 64, idx / 64);
        
        let tile_id = if street_mask.is_street(pos) {
            TileId::FloorStreet
        } else if buildings.iter().any(|b| b.is_wall(pos)) {
            TileId::Wall
        } else if buildings.iter().any(|b| b.is_interior(pos)) {
            TileId::FloorInterior
        } else {
            TileId::FloorStreet  // uncovered space defaults to street-like open ground
        };
        
        TileEntry {
            tile_id,
            walkable: !matches!(tile_id, TileId::Wall),
            condition: tile_condition,  // economics-variable
        }
    }).collect()
}

// LOCKED (L4-Q1): Threshold-crossing cache invalidation
// Thresholds: Intact ≥ 0.60, Worn [0.40, 0.60), Cracked [0.20, 0.40), Broken < 0.20
// Paula's offset proposal (0.63/0.43/0.23) adopted to prevent oscillation at boundaries
fn prosperity_to_condition(prosperity_current: f32) -> TileCondition {
    match prosperity_current {
        p if p >= 0.63 => TileCondition::Intact,
        p if p >= 0.43 => TileCondition::Worn,
        p if p >= 0.23 => TileCondition::Cracked,
        _ => TileCondition::Broken,
    }
}

// Chunk invalidation: called when economic sim pushes a prosperity update
fn invalidate_chunk_if_threshold_crossed(
    chunk: &mut ChunkConditionState,
    new_prosperity: f32,
) {
    let new_condition = prosperity_to_condition(new_prosperity);
    if new_condition != chunk.cached_condition {
        chunk.dirty = true;
        chunk.prosperity_snapshot = new_prosperity;
        chunk.cached_condition = new_condition;
        // chunk.tile_conditions regenerated on next render request
    }
}

Paula's offset thresholds adopted: 0.63/0.43/0.23 instead of round 0.6/0.4/0.2. This prevents oscillation at boundary values (a district at exactly 0.60 doesn't flicker between Intact and Worn each economic tick). I updated my Round 1 position here — the offset is strictly better than round numbers for no implementation cost.


Part 8: Positions on Remaining Open Items

L4-Q5 Scatter — Lead Decision: Deferred

Locked per lead. I'll add one note for the record: prop spawn points can be stored in TileEntry without props being rendered, as a spawn_category: Option<PropCategory> field. The layout is then seed-locked (which tiles are spawn points) while the entity system populates props at runtime. This is consistent with the determinism rule and would let the deferred scatter work activate without a re-generation. Not proposing this now — flagging for the implementation ticket when scatter scope is unlocked.

L3-Q7 Port/Station Direction — My Position

I previously classified this as deferred. After Ozzie's "if I'm standing in the Transit district, I want to see the beanstalk" and Paula's mechanism design (one query, one lookup, one edge-assignment), I'm upgrading this to: worth including in the minimum viable Layer 3 struct, even if rendering is deferred.

Adding orbital_approach_direction: Option<CardinalDirection> to CityGenerationContext is one systems.db query at session startup. It requires knowing the orbital station's atlas position relative to the city's regional position, which Tyre's dimension table confirms is derivable. The data field costs nothing. The rendering that uses it is separate work.

Agreed Positions (Carried Forward Unchanged)

Question Position Reason
L3-Q6 (sub-settlement codepath) Unified: district_count = 1 No implementation reason for a separate codepath at this point
CL-Q2 (regional land-use evolution) Runtime biome-cell update, not ChunkMutation ChunkMutations = player-caused tile-precision changes only
CL-Q3 (ruin lifecycle) Emergent rendering consequence, not a design question Amendment 5 settled this
NEW-Q2 (OrganicGrowth disambiguation) geographically_triggered: bool flag Paula's proposal is correct and complete

Gestalt — Round 2. Written 2026-05-01.