islaApocalypse-v2/Tools/Scripts/CurveBaselineTool.cs
beezm 03fe75b378 rivers/01: re-baseline — bare defaults reproduce terrain-shape-v1 (calibration pinned family-off); retire stale oracles; namespace batch root by chat
The bare TerrainGenConfig defaults did NOT reproduce the terrain the developer
locked, so "run the default generator" was not "the terrain in the gallery" —
the single most expensive fact in the codebase, and the reason a fresh chat
would spend an afternoon chasing differences that were CONFIGURATION, not
regression. This is the deliberate task that ends that, before any river work.

A1 — the defaults ARE the locked shape now. Five fields actually move:
SpeckRevert false->true, MinLandComponentFrac 3e-5->2.5e-7 (120x smaller; the
config default would have eaten real islands, not specks), SouthStretch 0->2,
FragmentAmp 0->0.5, Erosion false->true. Seven more were already correct via
SouthernStretch.Default* / CoastalFragment.Default* and are now pinned as
literals, because TerrainShapeV1 used to do that pinning and this default set
inherits the job. CoastShelf stays OFF — the locked shape has no shelf, and
evaluating it (D-041) is its own later task once water renders. Offshore stays
Off permanently (D-063): islands are organic-only, made by the stretch +
fragmentation and identified by the region layer, never placed.

A2 — the preserve mechanism. The curve knots are percentiles of the FAMILY-OFF
land distribution; flipping the defaults would have moved the pool, the knots,
and with them the render field of every batch including terrain-shape-v1
itself. So the pool is pinned family-off (TerrainGenConfig.WithFamilyOff /
CalibrationPool) rather than the knots being baked: calibration stays live, its
INPUT distribution is held still. The pin was a no-op by construction — it sets
the values the defaults carried the instant before the flip — and re-measuring
after confirms it: pool, all six knots, per-seed spread, shaped max,
monotonicity spikeMax and all seven band shares identical.

  Applied wider than "in CalibrateCurve": OffshoreIslandsTool,
  RegionLabelingTool and SouthernStretchTool generate their own family-off
  field for the Phase-1 anchor, so the pool pin alone would NOT have covered
  them and their a1 would have failed for a configuration reason. TerrainGenTool
  too — it AUTHORED 02_pass1_port and must stay able to regenerate its own
  anchor.

  Recorded as a judged-and-parked property: knots measured family-off, applied
  family-on. Deliberate, not an oversight. Same disposition as the mid-slope
  feather.

A4 — no oracle may pass against a superseded baseline. Six anchors retired
(01/03/04/06/08/09) with their checks and ISLA_T0x_SOURCE defaults; three kept
(chat1/02_pass1_port as the family-off pass-1 guard, chat2/10 and chat2/11 as
the shape and erosion acceptance anchors). Two invariants were RE-POINTED
rather than lost — the southern stretch's north-lock and the coastal-fragment
interior-lock now compare against SAME-RUN fields, which is scale-free and
cannot be invalidated by a moved dump. The retired dumps are kept, not deleted,
and marked superseded in their INDEX.md.

  A missing anchor is now LOUD. The old pattern skipped silently, so a moved
  anchor did not make its oracle fail — it made it not RUN, and a batch with a
  skipped check prints an all-PASS table that reads like a clean one. That is
  the INVERSE of the hazard the re-baseline guards against, and the migration
  below is exactly the event that would have triggered it, on nine anchors at
  once. ShapingOracle.LoadAnchor now separates the two cases: absent -> throw;
  present at another size -> loud INCONCLUSIVE, which is a fail, never a pass.

  TerrainShapeV1 inverted from PRESET to GUARD and moved to its own file.
  Apply() is gone — stamping the values on top of the defaults would MASK a
  drift instead of catching it. Its constants are now the assertion target, and
  Assert() refuses a run whose defaults have drifted off the locked shape.

B/C — batches are namespaced by chat: batches/<chat>/NN_slug/. Task numbers
restart at 00 per chat, so a flat root collided the moment a second chat
existed — four colliding prefixes across 25 batches, separable only by slug.
ToolingPaths.ChatSlug is REQUIRED (throws if unset) and defaults per tool to
its authoring chat, so re-running reproduces a batch in place while ISLA_CHAT
redirects — which is also what stops an acceptance run from overwriting the
very anchor it checks against. Writes go through BatchRoot; historical READS
compose against BatchesRoot and so carry the prefix in their own source string
("chat1/02_pass1_port"). The 25 existing batches were migrated moves-only.

ACCEPTANCE — 16 of 16 byte-identical, 0 failed. All 8 gallery seeds at 8192
from the bare defaults are byte-identical to chat2/10_frag4_seed_gallery
(= terrain-shape-v1, a59e52f); all 8 erosion fields byte-identical to
chat2/11_erosion (= ea291ea). Every gallery table row and every erosion
statistic reproduces its recorded value exactly. DrainageTool's a11 passes
bit-identical over 67,108,864 cells, and its analysis reproduces batch 12
exactly — so the whole chain rivers depends on (shape -> erosion -> drainage)
is unchanged. All 12 edited tools re-run clean; both new guards negative-tested.

The baseline moved in DEFAULTS, not in TERRAIN.

-> XX_Human/output/rivers/01_rebaseline_and_batch_namespace.report.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhkXBQh2tDmcWKpXYcj8vj
2026-08-23 04:45:10 -04:00

674 lines
36 KiB
C#
Raw Permalink 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.

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using Godot;
using IslaApocalypse.Core;
namespace IslaApocalypse.Tools
{
/// <summary>
/// ⭐ THE FAITHFUL-CURVE BASELINE BATCH (chat2/01) — the control the reshape will be judged
/// against, plus the instrument that decides what the reshape should be.
///
/// ═══ WHAT IT DOES, IN ORDER ═══
///
/// 1. CALIBRATE — pool the LAND distribution across several seeds and take
/// P60/73/83/88/96/99. Those quantiles ARE the curve's knots, which is what makes the band
/// shares 60/13/10/5/8/3/1 exact by construction. Reports them against the reference's
/// shipped literals: that delta is the port-fidelity evidence.
/// 2. VARIANTS — per seed, `curve_off` (the control) and `curve_on` (the faithful baseline).
/// Grayscale + raw `.f32` + relief, because a pretty render alone cannot be argued with.
/// 3. HISTOGRAMS — raw and shaped land distributions for the showpiece seed, with the knots and
/// the output anchors overlaid, plus the per-band mass table.
/// 4. ORACLE — the four automatic checks, run before anything is looked at.
/// 5. SHOWPIECE — one render at a larger profile for the judging plate.
///
/// ═══ ⚠ CALIBRATE SMALL, CONFIRM BIG ═══
///
/// Knots are measured at the iteration profile (2K) and used at every profile. That rests on the
/// land distribution's SHAPE being scale-invariant — which Phase 1 established by construction
/// (every frequency and distance is a fraction of MapSize). The tool does not take that on trust:
/// the showpiece re-measures its own quantiles and reports the delta, so the assumption is
/// evidence rather than a footnote.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveBaselineTool.tscn
///
/// (`--headless` also works — this tool awaits no render frames — but `xvfb-run` is correct in
/// both cases and costs nothing. → Tools/README.md §1.)
///
/// ISLA_TASK authoring task number (default 1)
/// ISLA_BATCH descriptor, NO prefix (default "curve_baseline")
/// ISLA_MAPSIZE calibration/variant profile (default 2048)
/// ISLA_SEEDS comma-separated positive (default: the 6 pinned below)
/// ISLA_SHOWPIECE_SIZE larger confirmation profile (default 8192)
/// ISLA_SHOWPIECE "0" to skip the big render
/// ISLA_VARIANTS "0" to skip the per-seed variants (calibration-only probe)
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "chat1/02_pass1_port")
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
/// </summary>
public partial class CurveBaselineTool : Node
{
/// <summary>
/// PINNED POSITIVE SEEDS — the four Phase-1 seeds plus two, for a six-seed calibration pool.
/// Pinned, not random: a calibration you cannot reproduce is a magic number with a good story.
/// </summary>
private static readonly int[] DefaultSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 2048;
private const int DefaultShowpieceSize = 8192;
/// <summary>
/// Band-share tolerance, percentage points. The knots come from the SAME histogram the shares
/// are measured on, so the only error is in-bin interpolation — tenths, not units. A loose
/// tolerance here would make check (d) unfalsifiable.
/// </summary>
private const double ShareTolerancePp = 0.25;
public override void _Ready()
{
// ⚠ An exception out of _Ready does NOT stop Godot — it logs and the process sits there
// with no main loop to end it, so a misconfigured run HANGS instead of failing. A hang
// looks like slow work, which is worse than a crash. Catch, say what was refused, exit 2.
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
// ⭐ rivers/01: batches are namespaced by chat. The default is this tool's AUTHORING chat,
// so re-running it reproduces its own batch in place; ISLA_CHAT redirects a run to another
// chat's namespace — which is what keeps an acceptance run from overwriting its own anchor.
ToolingPaths.ConfigureChat(EnvStr(ToolingPaths.ChatVar, "chat2"));
int task = EnvInt("ISLA_TASK", 1);
string descr = EnvStr("ISLA_BATCH", "curve_baseline");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
int showSize = EnvInt("ISLA_SHOWPIECE_SIZE", DefaultShowpieceSize);
bool showpiece = EnvStr("ISLA_SHOWPIECE", "1") == "1";
bool variants = EnvStr("ISLA_VARIANTS", "1") == "1";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
// ⚠ Composed by BatchRoot, never free-form — it refuses a descriptor carrying its own
// numeric prefix, which is how the counter drifted once already.
string batchRoot = ToolingPaths.BatchRoot(task, descr);
string scratch = ToolingPaths.BatchScratch(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(scratch);
var scale = new GenerationScale(mapSize);
var anchors = CurveAnchors.Default;
float sea = 0.15f;
GD.Print("==================================================================");
GD.Print(" CURVE BASELINE — faithful redistribution port + the histogram");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
GD.Print($"yardstick : {WorldScale.Describe()}");
GD.Print($"anchors : {anchors.DescribeMetres()}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
GD.Print($"batch : {batchRoot}");
GD.Print("------------------------------------------------------------------");
GD.Print(ToolingPaths.Describe());
GD.Print("==================================================================");
// ═══════════════════════════════════════════════════════════════════
// 1. CALIBRATE — the knots ARE percentiles of the pooled land CDF
// ═══════════════════════════════════════════════════════════════════
GD.Print("\n--- 1. CALIBRATION POOL ---");
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
var perSeedKnots = new List<CurveKnots>();
foreach (int seed in seeds)
{
var cfg = TerrainGenConfig.CalibrationPool(mapSize, seed);
Pass1Result p1 = Topography.Generate(cfg);
pass1[seed] = p1;
rawPool.Accumulate(p1.Height, mapSize);
// Each seed's OWN knots too — the pool is the calibration, but the SPREAD across
// seeds is what tells us how much of any later delta is scale and how much is just
// which seed you happened to look at. Without it, the scale-invariance check below
// compares a 6-seed pool against one seed and calls the difference "scale".
var solo = new LandHistogram(sea);
solo.Accumulate(p1.Height, mapSize);
perSeedKnots.Add(MeasureKnots(solo, $"seed_{seed}"));
GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms");
}
GD.Print($" {rawPool}");
float seedSpread = KnotSpread(perSeedKnots);
GD.Print($" per-seed knot spread at {mapSize}: max range across the 6 seeds = " +
$"{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)");
CurveKnots measured = MeasureKnots(rawPool, "v2_balanced");
GD.Print("\n MEASURED KNOTS vs THE REFERENCE'S SHIPPED LITERALS");
GD.Print(" knot pct v2 measured reference delta delta (m)");
for (int i = 0; i < 6; i++)
{
float v2 = measured[i], rf = CurveKnots.Reference[i];
GD.Print($" K{i + 1} P{CurveKnots.Percentiles[i],-5:F0} {v2,11:F6} {rf,11:F6} " +
$"{v2 - rf,+9:F6} {WorldScale.MetresFromRaw(v2 - rf),+8:F2}");
}
double[] shares = ShapingOracle.RealizedShares(rawPool, measured);
GD.Print("\n REALIZED LAND SHARES (target 60/13/10/5/8/3/1)");
for (int i = 0; i < 7; i++)
GD.Print($" {CurveKnots.BandNames[i],-16} {shares[i],6:F2} % (target {CurveKnots.BandShareTargets[i],4:F0} %)");
// ═══════════════════════════════════════════════════════════════════
// 2. THE SHAPED POOL — what the curve actually produces
// ═══════════════════════════════════════════════════════════════════
GD.Print("\n--- 2. SHAPED POOL (curve on, detail on) ---");
var shapedPool = new LandHistogram(sea);
var shapedResults = new Dictionary<int, Pass2Result>();
foreach (int seed in seeds)
{
var cfg = OnConfig(mapSize, seed, measured, anchors);
Pass2Result p2 = Shaping.Shape(pass1[seed], cfg);
shapedResults[seed] = p2;
shapedPool.Accumulate(p2.Height, mapSize);
if (seed == seeds[0]) foreach (string n in p2.Notes) GD.Print(" " + n);
GD.Print($" shaped seed {seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] {p2.ElapsedMs,5} ms");
}
GD.Print($" {shapedPool}");
GD.Print("\n SHAPED LAND PERCENTILES (what the palette would need to be placed on)");
double[] paletteP = { 10, 25, 50, 75, 90, 95, 99, 99.9 };
var sb2 = new StringBuilder(" ");
foreach (double p in paletteP) sb2.Append($" p{p:G}={shapedPool.Quantile(p):F3}");
GD.Print(sb2.ToString());
GD.Print($" shaped max {shapedPool.MaxLand:F4} (peak cap {anchors.PeakCap:F4})");
// ⚠ THE PALETTE WAS CALIBRATED ON THE *RAW* DISTRIBUTION (Phase 1, chat1/03) and the
// curve moves that distribution wholesale. This is reported, NOT fixed: re-placing the
// stops now would make the render look more dramatic while the terrain is genuinely
// lower — i.e. it would disguise the exact finding the histograms exist to deliver.
// Whether to recalibrate is a PRESENTATION call the developer makes AFTER deciding
// whether this elevation profile is the one they want.
double rawLow = raw3Stop(rawPool), shapedLow = raw3Stop(shapedPool);
double rawSat = 1.0 - rawPool.FractionBelow(1.450f), shapedSat = 1.0 - shapedPool.FractionBelow(1.450f);
GD.Print("\n PALETTE FIT (CostaRica stops were placed on the RAW distribution)");
GD.Print($" land below stop 3 (0.310 raw): raw {rawLow * 100:F1} % -> shaped {shapedLow * 100:F1} %");
GD.Print($" land above top stop (1.450): raw {rawSat * 100:F3} % -> shaped {shapedSat * 100:F3} % (clamps to white)");
// ═══════════════════════════════════════════════════════════════════
// 3. THE ORACLE — before a single render is looked at
// ═══════════════════════════════════════════════════════════════════
GD.Print("\n--- 3. ORACLE ---");
var checks = new List<ShapingOracle.Check>();
int primary = seeds[0];
Pass1Result pp1 = pass1[primary];
Pass2Result offPrimary = Shaping.Shape(pp1, OffConfig(mapSize, primary));
checks.Add(ShapingOracle.RegressionCurveOff(pp1, offPrimary));
string dumpPath = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
float[,] phase1Dump = HeightField.Load(dumpPath, mapSize);
checks.Add(ShapingOracle.RegressionAgainstDump(pp1.Height, phase1Dump, mapSize, dumpPath));
checks.Add(ShapingOracle.ClassifyFidelity(pp1, shapedResults[primary]));
checks.Add(ShapingOracle.Monotonicity(shapedResults[primary]));
checks.Add(ShapingOracle.BandShares(rawPool, measured, ShareTolerancePp));
// Every seed's classify field, not just the showpiece's — the invariant is per-seed.
long classifyDrift = 0;
foreach (int seed in seeds)
{
var c = ShapingOracle.ClassifyFidelity(pass1[seed], shapedResults[seed]);
if (!c.Passed) { classifyDrift++; GD.PrintErr($" classify drift on seed {seed}: {c.Detail}"); }
}
foreach (var c in checks) GD.Print(" " + c);
bool oracleOk = checks.TrueForAll(c => c.Passed) && classifyDrift == 0;
GD.Print($" ORACLE: {(oracleOk ? "ALL PASS" : "FAILURES PRESENT")} " +
$"(classify checked on all {seeds.Length} seeds: {seeds.Length - classifyDrift} clean)");
// ═══════════════════════════════════════════════════════════════════
// 4. VARIANTS — plain data beside the pretty render
// ═══════════════════════════════════════════════════════════════════
var rows = new List<string>();
if (variants)
{
GD.Print("\n--- 4. VARIANTS (curve_off / curve_on) ---");
foreach (int seed in seeds)
{
rows.Add(WriteVariant(batchRoot, pass1[seed], OffConfig(mapSize, seed), skipRaw, sea, 1.45f));
rows.Add(WriteVariant(batchRoot, pass1[seed], OnConfig(mapSize, seed, measured, anchors),
skipRaw, sea, anchors.PeakCap));
}
}
else GD.Print("\n--- variants skipped (ISLA_VARIANTS=0) ---");
// ═══════════════════════════════════════════════════════════════════
// 5. HISTOGRAMS — the diagnostic, for the showpiece seed
// ═══════════════════════════════════════════════════════════════════
GD.Print("\n--- 5. HISTOGRAMS ---");
var rawSeed = new LandHistogram(sea);
rawSeed.Accumulate(pass1[primary].Height, mapSize);
var shapedSeed = new LandHistogram(sea);
shapedSeed.Accumulate(shapedResults[primary].Height, mapSize);
DrawRawHistogram(rawSeed, measured, primary, mapSize, batchRoot);
DrawShapedHistogram(shapedSeed, rawSeed, measured, anchors, primary, mapSize, batchRoot);
// ═══════════════════════════════════════════════════════════════════
// 6. SHOWPIECE — the judging plate, and the scale-invariance check
// ═══════════════════════════════════════════════════════════════════
string showNote = "skipped (ISLA_SHOWPIECE=0)";
if (showpiece)
{
GD.Print($"\n--- 6. SHOWPIECE at {showSize} ---");
showNote = WriteShowpiece(batchRoot, primary, showSize, measured, anchors, sea, skipRaw, rows, seedSpread);
}
else GD.Print("\n--- showpiece skipped (ISLA_SHOWPIECE=0) ---");
WriteIndex(batchRoot, mapSize, showSize, scale, seeds, primary, measured, anchors,
rawPool, shapedPool, shares, checks, rows, oracleOk, showNote, variants, seedSpread);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(oracleOk ? "ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(oracleOk ? 0 : 3);
}
// ---- configs --------------------------------------------------------
/// <remarks>
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. This is chat-2 CURVE development: authored
/// and judged before the shape family existed, on the family-off distribution the knots are
/// percentiles of. The re-baseline flipped the bare defaults family-ON, so the pin is what
/// keeps this tool measuring the thing it was written to measure.
/// → <see cref="TerrainGenConfig.WithFamilyOff"/>.
/// </remarks>
private static TerrainGenConfig OffConfig(int mapSize, int seed) =>
new TerrainGenConfig { MapSize = mapSize, Seed = seed, VariantLabel = "curve_off", Curve = false, ShelfDetail = false }
.WithFamilyOff();
private static TerrainGenConfig OnConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a) =>
new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = "curve_on",
Curve = true, ShelfDetail = true, Knots = k, Anchors = a,
// ⚠ PINNED, not defaulted: this tool exists to reproduce the task-01 staircase
// bit-for-bit. chat2/02 moved the config DEFAULT to Continuous for its exploration;
// a control batch must not move with a default. (chat2/02.)
CurveMode = CurveModeKind.Staircase,
}.WithFamilyOff(); // ⭐ rivers/01 — see OffConfig: the staircase control is pre-family too
/// <summary>
/// Fraction of land below the CostaRica palette's third stop (0.310 raw). A blunt
/// "how much of the island is painted with the first two colours" number — the palette's own
/// stops are at measured RAW percentiles, so this is how far the curve moved the picture.
/// </summary>
private static double raw3Stop(LandHistogram h) => h.FractionBelow(0.310f);
/// <summary>
/// The widest maxmin range of any single knot across a set of measurements — "how much does
/// K_n move if you just change which seed you looked at". The yardstick every other knot
/// delta in this batch has to be judged against.
/// </summary>
private static float KnotSpread(List<CurveKnots> sets)
{
float worst = 0f;
for (int i = 0; i < 6; i++)
{
float lo = float.MaxValue, hi = float.MinValue;
foreach (CurveKnots s in sets) { lo = MathF.Min(lo, s[i]); hi = MathF.Max(hi, s[i]); }
worst = MathF.Max(worst, hi - lo);
}
return worst;
}
/// <summary>The knots ARE the quantiles. This one method is the whole calibration.</summary>
private static CurveKnots MeasureKnots(LandHistogram pool, string name) => new CurveKnots(
2, name,
pool.Quantile(CurveKnots.Percentiles[0]), pool.Quantile(CurveKnots.Percentiles[1]),
pool.Quantile(CurveKnots.Percentiles[2]), pool.Quantile(CurveKnots.Percentiles[3]),
pool.Quantile(CurveKnots.Percentiles[4]), pool.Quantile(CurveKnots.Percentiles[5]));
// ---- output ---------------------------------------------------------
private static string WriteVariant(string batchRoot, Pass1Result p1, TerrainGenConfig cfg,
bool skipRaw, float sea, float legendTop)
{
Pass2Result p2 = Shaping.Shape(p1, cfg);
string dir = Path.Combine(batchRoot, $"{cfg.Seed}_{cfg.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
// ⭐ PLAIN DATA BESIDE THE PRETTY RENDER, always — the point is judging the terrain, and
// you cannot judge a distribution through a palette.
var (gMin, gMax) = GrayscaleRenderer.SavePng(p2.Height, p2.MapSize, Path.Combine(dir, "grayscale.png"));
if (!skipRaw) HeightField.Save(p2.Height, p2.MapSize, Path.Combine(dir, "height.f32"));
var look = new LookConfig
{
Name = "gradient_flat", Palette = ReliefPalette.Kind.CostaRica,
HillshadeStrength = 0f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
Image withLegend = LegendRenderer.WithLegend(map, look.Palette, sea, legendTop,
cfg.VariantLabel.ToUpperInvariant());
withLegend.SavePng(Path.Combine(dir, "relief.png"));
float land = p2.LandFraction(sea);
GD.Print($" {cfg.VariantLabel,-10} seed {cfg.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " +
$" land {land * 100,5:F1}% {p2.ElapsedMs,5} ms");
return $"| `{cfg.Seed}_{cfg.VariantLabel}` | {cfg.Seed} | {cfg.VariantLabel} | " +
$"{p2.HMin:F3} | {p2.HMax:F3} | {land * 100:F1}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |";
}
private static void DrawRawHistogram(LandHistogram raw, CurveKnots k, int seed, int mapSize, string batchRoot)
{
// Coarse enough to draw, fine enough to keep the shape: ~360 bins across the range.
float top = MathF.Ceiling(raw.MaxLand * 20f) / 20f;
var display = raw.Rebin((top - raw.SeaLevel) / 360f);
var o = new HistogramRenderer.Options
{
Title = $"RAW LAND HEIGHTS - SEED {seed}",
Subtitle = "PRE-CURVE. THE KNOTS ARE PERCENTILES OF THIS DISTRIBUTION.",
XAxisLabel = "RAW HEIGHT (PRE-CURVE)",
XTop = top,
Footer = $"{raw.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW",
};
float[] edges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6, top };
for (int i = 0; i < 7; i++)
o.Bands.Add(new HistogramRenderer.Band
{
Lo = edges[i], Hi = edges[i + 1],
Label = CurveKnots.BandNames[i],
SharePercent = i < 6
? raw.FractionBetween(edges[i], edges[i + 1]) * 100.0
: Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0),
});
for (int i = 0; i < 6; i++)
o.Markers.Add(new HistogramRenderer.Marker
{
Value = k[i], Label = $"K{i + 1} P{CurveKnots.Percentiles[i]:F0}", Strong = true,
});
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_raw.png"));
GD.Print($" histogram_raw.png ({raw.TotalLand:N0} land columns, max {raw.MaxLand:F4})");
}
private static void DrawShapedHistogram(LandHistogram shaped, LandHistogram raw, CurveKnots k,
CurveAnchors a, int seed, int mapSize, string batchRoot)
{
float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f;
var display = shaped.Rebin((top - shaped.SeaLevel) / 360f);
var o = new HistogramRenderer.Options
{
Title = $"SHAPED LAND HEIGHTS - SEED {seed}",
Subtitle = "POST-CURVE. BANDS SIT AT THE STORM-LADDER OUTPUT ANCHORS.",
XAxisLabel = "RAW HEIGHT (POST-CURVE)",
XTop = top,
Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize} - BIN {display.BinWidth:F4} RAW",
};
// The OUTPUT bands: each input band's share, drawn where the curve puts it.
float benchTop = a.BenchBase + a.ShelfSpanMin;
float plateauTop = a.PlateauBase + a.ShelfSpanMin;
float[] outEdges = { a.Sea, a.OrangeCeil, a.RedCeil, a.BenchBase, benchTop, a.PlateauBase, plateauTop, top };
float[] inEdges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6 };
for (int i = 0; i < 7; i++)
o.Bands.Add(new HistogramRenderer.Band
{
Lo = outEdges[i], Hi = outEdges[i + 1],
Label = CurveKnots.BandNames[i],
SharePercent = i < 6
? raw.FractionBetween(inEdges[i], inEdges[i + 1]) * 100.0
: Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0),
});
o.Markers.Add(new HistogramRenderer.Marker { Value = a.OrangeCeil, Label = "ORANGE" });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "RED" });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.BenchBase, Label = "BENCH" });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PlateauBase, Label = "PLATEAU" });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" });
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, "histogram_shaped.png"));
GD.Print($" histogram_shaped.png ({shaped.TotalLand:N0} land columns, max {shaped.MaxLand:F4})");
}
private static string WriteShowpiece(string batchRoot, int seed, int showSize, CurveKnots k,
CurveAnchors a, float sea, bool skipRaw, List<string> rows, float seedSpread)
{
var cfg = OnConfig(showSize, seed, k, a);
cfg.VariantLabel = "curve_on_showpiece";
Pass1Result p1 = Topography.Generate(cfg);
GD.Print($" pass1 {showSize}: h[{p1.HMinSeed:F3} .. {p1.HMaxSeed:F3}] {p1.ElapsedMs} ms");
// ⭐ THE SCALE-INVARIANCE CHECK. Knots were measured at the iteration profile; if the
// land distribution's shape really is scale-free, this profile's own quantiles land on
// the same numbers. Measured, not assumed.
var bigPool = new LandHistogram(sea);
bigPool.Accumulate(p1.Height, showSize);
var bigKnots = MeasureKnots(bigPool, $"at_{showSize}");
var deltas = new StringBuilder();
float worst = 0f;
for (int i = 0; i < 6; i++)
{
float d = bigKnots[i] - k[i];
if (MathF.Abs(d) > MathF.Abs(worst)) worst = d;
deltas.Append($" K{i + 1}{d:+0.0000;-0.0000}");
}
// ⚠ THE COMPARISON IS CONFOUNDED, AND SAYING SO IS THE POINT. This is ONE seed at the
// big profile against a SIX-SEED POOL at the small one, so the delta mixes scale effects
// with seed-to-seed variation. `seedSpread` is how far a knot moves from seed choice
// alone at a fixed size — if the delta sits inside it, scale is not what moved.
bool withinSeedNoise = MathF.Abs(worst) <= seedSpread;
GD.Print($" scale-invariance: knots re-measured at {showSize} differ by{deltas}");
GD.Print($" worst {worst:+0.000000;-0.000000} raw = {WorldScale.MetresFromRaw(worst):+0.00;-0.00} m " +
$"vs per-seed spread {seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m) " +
$"-> {(withinSeedNoise ? "WITHIN seed variation" : "EXCEEDS seed variation")}");
rows.Add(WriteVariant(batchRoot, p1, cfg, skipRaw, sea, a.PeakCap));
return $"seed {seed} at {showSize}; knots re-measured there differ by at most " +
$"{MathF.Abs(worst):F6} raw ({MathF.Abs(WorldScale.MetresFromRaw(worst)):F2} m), " +
$"{(withinSeedNoise ? "within" : "beyond")} the {WorldScale.MetresFromRaw(seedSpread):F2} m per-seed spread";
}
// ---- the index ------------------------------------------------------
private static void WriteIndex(string batchRoot, int mapSize, int showSize, GenerationScale scale,
int[] seeds, int primary, CurveKnots k, CurveAnchors a, LandHistogram rawPool,
LandHistogram shapedPool, double[] shares, List<ShapingOracle.Check> checks,
List<string> rows, bool oracleOk, string showNote, bool variants, float seedSpread)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 01 — the faithful curve baseline");
sb.AppendLine();
sb.AppendLine("The redistribution curve and shelf detail, ported faithfully and **re-calibrated against");
sb.AppendLine("v2's own pass-1 output**. This is the CONTROL every later reshape is judged against —");
sb.AppendLine("not a taste gate. No erosion, no rivers, no water, no crater, no coast shelf, no islets.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{primary}_curve_on_showpiece/relief.png`** — the judging plate ({showNote}).");
sb.AppendLine($"2. **`histogram_raw.png`** and **`histogram_shaped.png`** — the diagnostic that says");
sb.AppendLine(" *why* the upper terrain looks the way it does. Read them before forming an opinion.");
sb.AppendLine($"3. **`{primary}_curve_off/relief.png`** vs **`{primary}_curve_on/relief.png`** — the A/B.");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png` | **keep** — the judging plates |");
sb.AppendLine("| `histogram_*.png` | **keep** — the finding |");
sb.AppendLine("| `INDEX.md` | **keep** |");
sb.AppendLine("| `grayscale.png` | ♻ **regenerable** from the `.f32` — safe to clear |");
sb.AppendLine("| `height.f32` | ♻ **regenerable** from seed + code — safe to clear, but it is the byte-level oracle |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine("## Setup");
sb.AppendLine();
sb.AppendLine($"- **Calibration/variant profile:** {mapSize} (scaleFactor {scale.ScaleFactor:F3})");
sb.AppendLine($"- **Showpiece profile:** {showSize}");
sb.AppendLine($"- **Seeds (pooled as one calibration set):** {string.Join(", ", seeds)}");
sb.AppendLine($"- **Yardstick:** {WorldScale.Describe()}");
sb.AppendLine($"- **Output anchors:** {a.DescribeMetres()}");
sb.AppendLine($"- **Curve:** v{HeightCurve.Version} · **detail:** v{TerrainDetailPass.Version}");
sb.AppendLine();
sb.AppendLine("## ⭐ The re-measured knots");
sb.AppendLine();
sb.AppendLine($"Pooled land CDF: **{rawPool.TotalLand:N0} samples** from {rawPool.FieldsPooled} seeds, ");
sb.AppendLine($"range [{rawPool.MinLand:F4} .. {rawPool.MaxLand:F4}] raw, bin {rawPool.BinWidth:G3}.");
sb.AppendLine();
sb.AppendLine("| Knot | Percentile | v2 measured | reference literal | delta (raw) | delta (m) |");
sb.AppendLine("|---|---|---|---|---|---|");
for (int i = 0; i < 6; i++)
{
float v2 = k[i], rf = CurveKnots.Reference[i];
sb.AppendLine($"| K{i + 1} | P{CurveKnots.Percentiles[i]:F0} | `{v2:F6}` | `{rf:F6}` | " +
$"{v2 - rf:+0.000000;-0.000000} | {WorldScale.MetresFromRaw(v2 - rf):+0.00;-0.00} |");
}
sb.AppendLine();
sb.AppendLine("> **This delta is the port-fidelity check.** Near-identical means v2's pass-1 height");
sb.AppendLine("> distribution matches the reference's — the crown-jewel port is faithful. A large gap");
sb.AppendLine("> would mean pass 1 diverged, and would be the finding rather than a nuisance.");
sb.AppendLine();
sb.AppendLine($"**Scale for judging any knot delta:** across the six seeds at {mapSize}, a single knot");
sb.AppendLine($"moves by up to **{seedSpread:F6} raw ({WorldScale.MetresFromRaw(seedSpread):F2} m)** from seed choice alone.");
sb.AppendLine("Anything smaller than that is seed noise, not a difference.");
sb.AppendLine();
sb.AppendLine($"**Scale invariance:** {showNote}.");
sb.AppendLine();
sb.AppendLine("## Realized land shares");
sb.AppendLine();
sb.AppendLine("| Band | Realized | Target | Output lands at |");
sb.AppendLine("|---|---|---|---|");
float benchTop = a.BenchBase + a.ShelfSpanMin, plateauTop = a.PlateauBase + a.ShelfSpanMin;
string[] lands =
{
$"{WorldScale.MetresFromRaw(a.Sea - a.Sea):F0}{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0} m",
$"{WorldScale.MetresFromRaw(a.OrangeCeil - a.Sea):F0}{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0} m",
$"{WorldScale.MetresFromRaw(a.RedCeil - a.Sea):F0}{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m",
$"~{WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m (bench)",
$"{WorldScale.MetresFromRaw(benchTop - a.Sea):F0}{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m",
$"~{WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m (plateau)",
$"{WorldScale.MetresFromRaw(plateauTop - a.Sea):F0}{WorldScale.MetresFromRaw(a.PeakCap - a.Sea):F0} m",
};
for (int i = 0; i < 7; i++)
sb.AppendLine($"| {CurveKnots.BandNames[i]} | {shares[i]:F2} % | {CurveKnots.BandShareTargets[i]:F0} % | {lands[i]} |");
sb.AppendLine();
sb.AppendLine($"Shaped land range: **[{shapedPool.MinLand:F4} .. {shapedPool.MaxLand:F4}] raw** " +
$"= {WorldScale.MetresFromRaw(shapedPool.MinLand - a.Sea):F0}{WorldScale.MetresFromRaw(shapedPool.MaxLand - a.Sea):F0} m above sea.");
sb.AppendLine();
sb.AppendLine("## ⭐ The elevation profile this produces");
sb.AppendLine();
sb.AppendLine("Land height percentiles, before and after the curve — **this is the finding**:");
sb.AppendLine();
sb.AppendLine("| Percentile | raw | shaped | shaped, metres above sea |");
sb.AppendLine("|---|---|---|---|");
foreach (double p in new[] { 10.0, 25.0, 50.0, 75.0, 90.0, 95.0, 99.0, 99.9 })
{
float r = rawPool.Quantile(p), s = shapedPool.Quantile(p);
sb.AppendLine($"| p{p:G} | {r:F3} | {s:F3} | **{WorldScale.MetresFromRaw(s - a.Sea):F0} m** |");
}
sb.AppendLine();
double belowBench = shapedPool.FractionBelow(a.BenchBase) * 100.0;
double belowPlateau = shapedPool.FractionBelow(a.PlateauBase) * 100.0;
sb.AppendLine($"- **{belowBench:F1} %** of land sits below the bench " +
$"({WorldScale.MetresFromRaw(a.BenchBase - a.Sea):F0} m).");
sb.AppendLine($"- **{belowPlateau:F1} %** of land sits below the plateau " +
$"({WorldScale.MetresFromRaw(a.PlateauBase - a.Sea):F0} m).");
// ⚠ Do NOT state the RAW median in metres. Raw pre-curve height has no metre meaning —
// the yardstick applies to CURVED output, which is what the storm-ladder anchors define.
// Converting the raw median would invent a "before" elevation the world never had.
sb.AppendLine($"- The median land column ends up **{WorldScale.MetresFromRaw(shapedPool.Quantile(50) - a.Sea):F0} m** " +
$"above sea: the curve maps raw {rawPool.Quantile(50):F3} → {shapedPool.Quantile(50):F3}.");
sb.AppendLine();
sb.AppendLine("> ⚠ **Read `histogram_shaped.png` before concluding the terrain is broken.** The curve is");
sb.AppendLine("> doing exactly what its share targets say: 60 % of land into the bottom band, 73 % below");
sb.AppendLine("> the red ceiling, 4 % above the plateau. If the island reads flat, that is a decision");
sb.AppendLine("> showing up in a render — not a bug. Changing it is the RESHAPE, and the reshape is a");
sb.AppendLine("> later task with its own gate.");
sb.AppendLine();
sb.AppendLine("## ⚠ Palette fit — reported, not fixed");
sb.AppendLine();
sb.AppendLine("The CostaRica stops were placed on **measured percentiles of the RAW distribution**");
sb.AppendLine("(Phase 1). The curve moves that distribution, so the ramp no longer sits where the land is:");
sb.AppendLine();
sb.AppendLine("| | raw | shaped |");
sb.AppendLine("|---|---|---|");
sb.AppendLine($"| land below palette stop 3 (0.310 raw) | {raw3Stop(rawPool) * 100:F1} % | **{raw3Stop(shapedPool) * 100:F1} %** |");
sb.AppendLine($"| land above the top stop (1.450, clamps white) | {(1.0 - rawPool.FractionBelow(1.450f)) * 100:F3} % | {(1.0 - shapedPool.FractionBelow(1.450f)) * 100:F3} % |");
sb.AppendLine();
sb.AppendLine("**Deliberately left alone.** Re-placing the stops now would make the render look more");
sb.AppendLine("dramatic while the terrain is genuinely lower — it would disguise the very finding above.");
sb.AppendLine("Recalibrating the palette is a presentation call to make *after* the elevation profile is");
sb.AppendLine("settled, not before.");
sb.AppendLine();
sb.AppendLine("## The oracle");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(checks));
sb.AppendLine($"**{(oracleOk ? "ALL PASS" : " FAILURES PRESENT do not judge this batch until they are resolved")}**");
sb.AppendLine();
if (variants)
{
sb.AppendLine("## Results");
sb.AppendLine();
sb.AppendLine("| Folder | Seed | Variant | h min | h max | land % | grayscale range | time |");
sb.AppendLine("|---|---|---|---|---|---|---|---|");
foreach (string row in rows) sb.AppendLine(row);
sb.AppendLine();
}
sb.AppendLine("`scratch/` is persistent and is never cleaned.");
string index = Path.Combine(batchRoot, "INDEX.md");
using var f = Godot.FileAccess.Open(index, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {index}"); return; }
f.StoreString(sb.ToString());
}
// ---- env helpers ----------------------------------------------------
private static string EnvStr(string k, string fallback)
{
string v = System.Environment.GetEnvironmentVariable(k);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}
private static int EnvInt(string k, int fallback)
=> int.TryParse(EnvStr(k, null) ?? "", out int v) ? v : fallback;
private static int[] EnvSeeds(string k, int[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<int>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (int.TryParse(part.Trim(), out int s) && s > 0) outp.Add(s);
return outp.Count > 0 ? outp.ToArray() : fallback;
}
}
}