islaApocalypse-v2/Tools/Scripts/SeedGalleryTool.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

589 lines
27 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 SEED GALLERY (chat2/04) — is <c>continuous_restored</c> good across seeds, or is
/// 1063685222 a lucky draw?
///
/// ═══ ⚠⚠ THIS TOOL RENDERS. IT DOES NOT TUNE. ═══
///
/// The curve is the one committed at tag <c>terrain-curve-v1</c> and it is PINNED HERE IN CODE —
/// <see cref="MountainLift"/>, <see cref="PeakSharpness"/> and <see cref="LowlandCeilingM"/> are
/// constants with no environment override, deliberately. Every other tool in this phase exposes
/// its knobs to <c>ISLA_*</c> so they can be probed; a GALLERY must not, because a stray
/// environment variable left over from a probe would silently render eight plates of a curve
/// nobody chose and they would look exactly like the real thing. The knobs are printed in the
/// run header and written into the INDEX so the plates can always be traced to a curve.
///
/// ═══ THE CALIBRATION IS RE-MEASURED, NOT RE-INVENTED ═══
///
/// <c>continuous_restored</c> is defined by a calibration measured on task 01's six-seed pool at
/// the ITERATION size, then applied at any size. This tool reproduces that measurement exactly —
/// same pool, same size, same primary seed for the normalization anchor — so the gallery renders
/// the same curve task 03 gated, not a look-alike. → <see cref="ClimbCalibration"/>.
///
/// ⚠ The gallery seeds are NOT the calibration pool. The pool stays fixed at task 01's six; the
/// gallery is eight separate draws, seven of them never previously rendered.
///
/// ═══ THE CHARACTER NOTE IS A HUMAN JUDGEMENT, AND IS TREATED AS ONE ═══
///
/// The INDEX carries a one-word note per seed ("clean" / "sharp mid-slope" / "flat draw"). That is
/// an EYE call, so this tool will not invent it. It reads the notes from
/// <c>scratch/character_notes.tsv</c> if that file exists and prints "(pending)" if it does not.
/// Write the notes after looking at the plates, then re-run with <c>ISLA_INDEX_ONLY=1</c> to
/// rebuild the INDEX from <c>scratch/metrics.tsv</c> without re-rendering a single pixel.
///
/// What the tool DOES contribute is an objective companion, and getting it right took one correction
/// worth recording:
///
/// ⚠ THE CURVE'S NORMALIZED MID-SLOPE CANNOT DISCRIMINATE BETWEEN SEEDS. It is
/// <c>dv/du</c> on a fixed set of control points, and each seed's denormalization is an affine
/// rescale of both axes — which leaves <c>dv/du</c> untouched. It is the SAME NUMBER on every
/// seed by construction, so measuring it per seed answers nothing. (The first cut of this tool
/// reported exactly that and printed an identical 1.86 for every draw.)
///
/// So the metric that ships is the one the eye is actually reacting to: the **spatial height
/// gradient through the 100220 m band**, in metres per pixel — how fast the ground climbs
/// through the heights where the yellow→orange transition sits. That is
/// <c>(dOutput/dRaw) × (dRaw/dPixel)</c>: the first factor varies by seed because a taller
/// <c>spikeMax</c> spreads the same curve over more raw range, and the second is pure terrain.
/// Both are seed-dependent, and their product is what a render shows.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/SeedGalleryTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SEEDS / ISLA_SKIP_RAW
/// ISLA_MAPSIZE gallery render size (default 8192 — judging size, not calibration size)
/// ISLA_CALIB_SIZE calibration pool size (default 2048 — task 01's, do not change casually)
/// ISLA_INDEX_ONLY "1" to rebuild INDEX.md from scratch/metrics.tsv and skip all rendering
/// </summary>
public partial class SeedGalleryTool : Node
{
// ═══ THE CURVE, PINNED. No environment override, by design — see the type header. ═══
/// <summary>task-03 `continuous_restored`: reproduce the staircase's mountain.</summary>
private const float MountainLift = 1.0f;
/// <summary>task-03 `continuous_restored`: a straight run to the cap, no extra summit steepening.</summary>
private const float PeakSharpness = 1.0f;
/// <summary>The flood line — hands over at exactly (K2, RED_CEIL).</summary>
private const float LowlandCeilingM = 30f;
/// <summary>
/// ⚠ TASK 01'S CALIBRATION POOL, VERBATIM. Not the gallery seeds. Changing this changes the
/// curve, which is the one thing this task must not do.
/// </summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
/// <summary>
/// ⭐ THE GALLERY. `1063685222` is the ANCHOR — the plate every previous task was judged on —
/// and the other seven are fresh draws never rendered before.
///
/// ⚠⚠ THESE WERE FIXED BEFORE THE FIRST RENDER AND WERE NEVER SCREENED OR REPLACED. That is
/// the whole point of a spread: hand-picking flattering draws would answer the question
/// "can this curve ever look good?" when the question asked is "does it look good generally?"
/// If a seed in this list turns out ugly, it stays in the gallery and goes in the report.
///
/// None of the seven appear in the calibration pool, so none of them shaped the curve they
/// are being used to test.
/// </summary>
private static readonly int[] GallerySeeds =
{
1063685222, // ⭐ the anchor — Phase 1's primary, the plate 01/02/03 were judged on
20260821, // the date this gallery was drawn
8675309,
123456789,
271828182,
42424242,
555000111,
999999937, // a large prime, for no reason beyond being an unconsidered draw
};
private const int DefaultGallerySize = 8192;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
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", 4);
string descr = EnvStr("ISLA_BATCH", "seed_gallery");
int gallerySize = EnvInt("ISLA_MAPSIZE", DefaultGallerySize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", GallerySeeds);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
bool indexOnly = EnvStr("ISLA_INDEX_ONLY", "0") == "1";
string batchRoot = ToolingPaths.BatchRoot(task, descr);
string scratch = ToolingPaths.BatchScratch(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(scratch);
string metricsPath = Path.Combine(scratch, "metrics.tsv");
string notesPath = Path.Combine(scratch, "character_notes.tsv");
var anchors = CurveAnchors.Default;
float sea = 0.15f;
int anchor = seeds[0];
GD.Print("==================================================================");
GD.Print(" SEED GALLERY (chat2/04) — the committed curve, across 8 draws");
GD.Print("==================================================================");
GD.Print($"curve : continuous_restored, PINNED — lift {MountainLift:F2}, " +
$"sharpness {PeakSharpness:F2}, ceiling {LowlandCeilingM:F0} m (no env override)");
GD.Print($"gallery : {gallerySize} calibration pool at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)} (anchor: {anchor})");
GD.Print($"batch : {batchRoot}");
GD.Print($"yardstick : {WorldScale.Describe()}");
GD.Print("==================================================================");
// ═══ INDEX-ONLY: rebuild the contact sheet from the recorded metrics ═══
if (indexOnly)
{
var recorded = ReadMetrics(metricsPath);
if (recorded.Count == 0)
throw new InvalidOperationException(
$"ISLA_INDEX_ONLY=1 but no metrics at {metricsPath}. Run the gallery first — " +
"the index is rebuilt FROM a render, never instead of one.");
GD.Print($"\n--- INDEX ONLY — {recorded.Count} seeds from {metricsPath} ---");
WriteIndex(batchRoot, recorded, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
GD.Print($" INDEX.md rebuilt. No pixels were rendered.");
GetTree().Quit(0);
return;
}
// ═══ 1. THE CURVE — re-measured exactly as task 03 did ═══
GD.Print($"\n--- 1. CALIBRATION (task-01 pool at {calibSize}, as task 03) ---");
var rawPool = new LandHistogram(sea);
var poolPass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(calibSize, s));
poolPass1[s] = p1;
rawPool.Accumulate(p1.Height, calibSize);
}
var knots = new CurveKnots(2, "v2_balanced",
rawPool.Quantile(CurveKnots.Percentiles[0]), rawPool.Quantile(CurveKnots.Percentiles[1]),
rawPool.Quantile(CurveKnots.Percentiles[2]), rawPool.Quantile(CurveKnots.Percentiles[3]),
rawPool.Quantile(CurveKnots.Percentiles[4]), rawPool.Quantile(CurveKnots.Percentiles[5]));
// Above-ceiling land, gated on RAW height, output measured off the staircase.
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = BaseConfig(calibSize, s, knots, anchors, "staircase");
scfg.CurveMode = CurveModeKind.Staircase;
scfg.ShelfDetail = true;
Pass2Result st = Shaping.Shape(poolPass1[s], scfg);
rawAbove.AccumulateWhere(poolPass1[s].Height, poolPass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, poolPass1[s].Height, calibSize, ceilingRaw);
}
var pcts = ClimbCalibration.DefaultPercentiles;
var rawQ = new float[pcts.Length];
var outQ = new float[pcts.Length];
for (int i = 0; i < pcts.Length; i++)
{
rawQ[i] = rawAbove.Quantile(pcts[i]);
outQ[i] = outAbove.Quantile(pcts[i]);
}
// ⚠ Anchored on the POOL PRIMARY's spikeMax, exactly as task 03 did — reproducing the
// gated curve matters more here than improving it. (Task 03 §7.4 flags the anchor choice
// as worth revisiting; a gallery is not the place to revisit it.)
var calibration = ClimbCalibration.FromPercentiles(
pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(poolPass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, MountainLift, PeakSharpness);
GD.Print($" {knots}");
GD.Print($" {calibration.Describe()}");
// ═══ 2. THE GALLERY ═══
GD.Print($"\n--- 2. GALLERY at {gallerySize} ---");
var metrics = new List<SeedMetrics>();
foreach (int seed in seeds)
{
ulong t0 = Time.GetTicksMsec();
var cfg = BaseConfig(gallerySize, seed, knots, anchors, "restored");
cfg.CurveMode = CurveModeKind.Continuous;
cfg.ClimbCalibration = calibration;
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
// ⚠ The two invariants that must hold on EVERY plate, checked per seed rather than
// assumed from task 03's two. A gallery that quietly rendered a broken seed would be
// the worst possible artifact: eight plates, one of them lying.
var classify = ShapingOracle.ClassifyFidelity(p1, p2);
var offCfg = BaseConfig(gallerySize, seed, knots, anchors, "off"); offCfg.Curve = false;
var seaId = ShapingOracle.SeaIdentity(Shaping.Shape(p1, offCfg), p2, sea);
if (!classify.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {classify}");
if (!seaId.Passed) GD.PrintErr($" ⚠⚠ seed {seed}: {seaId}");
var m = WriteSeed(batchRoot, p1, p2, sea, anchors, skipRaw);
m.ClassifyOk = classify.Passed;
m.SeaOk = seaId.Passed;
m.ElapsedMs = Time.GetTicksMsec() - t0;
metrics.Add(m);
GD.Print($" {seed,-11} land {m.LandPct,5:F1}% >100m {m.Above100,5:F2}% >220m {m.Above220,5:F2}% " +
$"p90 {WorldScale.MetresFromRaw(m.P90 - sea),6:F1}m " +
$"grad100-220 p50 {m.BandGradP50:F2} p95 {m.BandGradP95:F2} m/px hMax {p1.HMaxSeed:F3} " +
$"{(m.ClassifyOk && m.SeaOk ? "ok" : " CHECK FAILED")} {m.ElapsedMs / 1000.0:F0}s");
}
WriteMetrics(metricsPath, metrics);
WriteIndex(batchRoot, metrics, ReadNotes(notesPath), anchor, gallerySize, calibSize, notesPath);
bool allOk = metrics.TrueForAll(m => m.ClassifyOk && m.SeaOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" per-seed invariants: {(allOk ? "ALL PASS" : "*** A SEED FAILED see above ***")}");
GD.Print($" ⚠ character notes are a HUMAN call — write {notesPath}");
GD.Print($" then re-run with ISLA_INDEX_ONLY=1 to fill them in.");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- per-seed record -------------------------------------------------
private sealed class SeedMetrics
{
public int Seed;
public float HMin, HMax, HMaxSeed;
public double LandPct, Above100, Above220;
public float P50, P75, P90, P99;
public float MaxMidSlope, MinSlope; // curve-space; identical every seed by construction
public float BandGradP50, BandGradP95; // ⭐ metres per pixel through the 100-220 m band
public long BandCells;
public float GrayMin, GrayMax;
public bool ClassifyOk = true, SeaOk = true;
public ulong ElapsedMs;
}
/// <remarks>
/// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. chat2/04 is the PRE-FAMILY committed-curve
/// gallery (`terrain-curve-v1`); the re-baseline flipped the bare defaults family-ON, and this
/// tool must keep producing the curve gallery it was judged as. → TerrainGenConfig.WithFamilyOff().
/// </remarks>
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a, string label)
=> new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, Knots = k, Anchors = a,
LowlandCeilingM = LowlandCeilingM,
}.WithFamilyOff();
private static SeedMetrics WriteSeed(string batchRoot, Pass1Result p1, Pass2Result p2,
float sea, CurveAnchors anchors, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}");
DirAccess.MakeDirRecursiveAbsolute(dir);
// ⭐ Plain data beside the pretty render. The grayscale is the honest instrument; the
// relief is for eye-appeal and hillshade.
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 = "hillshade_even", Palette = ReliefPalette.Kind.ProvisionalEven,
ZExaggeration = 18f, LightAzimuth = 315f, LightAltitude = 45f,
HillshadeStrength = 0.30f, SeaLevel = sea,
};
Image map = ReliefRenderer.Render(p2.Height, p2.MapSize, look);
LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap, $"SEED {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
var (a100, a220) = ShapingOracle.LandAbove(p2, sea);
var land = new LandHistogram(sea);
land.Accumulate(p2.Height, p2.MapSize);
// ⚠ Curve-space slope: recorded for completeness, but it is the SAME on every seed by
// construction (see the type header). It is NOT the discriminator.
var (minN, _, maxMid, _, _, _) = p2.Continuous.SampleClimbSlopes();
// ⭐ THE ACTUAL DISCRIMINATOR: how fast the ground climbs through 100-220 m, in metres
// per pixel. This is what "the yellow-to-orange step looks abrupt" is a reaction to.
var (bandP50, bandP95, bandCells) = MidBandGradient(p2.Height, p2.MapSize, sea);
return new SeedMetrics
{
Seed = p2.Seed, HMin = p2.HMin, HMax = p2.HMax, HMaxSeed = p1.HMaxSeed,
LandPct = p2.LandFraction(sea) * 100.0, Above100 = a100, Above220 = a220,
P50 = land.Quantile(50), P75 = land.Quantile(75),
P90 = land.Quantile(90), P99 = land.Quantile(99),
MaxMidSlope = maxMid, MinSlope = minN, GrayMin = gMin, GrayMax = gMax,
BandGradP50 = bandP50, BandGradP95 = bandP95, BandCells = bandCells,
};
}
/// <summary>
/// Median and p95 of the spatial height gradient, in METRES PER PIXEL, over land cells whose
/// height falls in the 100-220 m band — the stretch the developer flagged as reading abrupt.
///
/// Central differences on the interior; edge cells are skipped rather than one-sided, because
/// the map border is the Trench's synthetic wall and its gradient is not terrain.
///
/// ⚠ Gradients are collected into a coarse histogram rather than a list: at 8192 the band can
/// hold millions of cells and sorting them all to take two quantiles would cost more than the
/// render. 0.01 m/px bins are far finer than any difference worth reading.
/// </summary>
private static (float p50, float p95, long cells) MidBandGradient(float[,] h, int n, float sea)
{
float lo = sea + WorldScale.RawFromMetres(100f);
float hi = sea + WorldScale.RawFromMetres(220f);
const float binW = 0.01f; // metres per pixel
const int bins = 4000; // up to 40 m/px — far beyond anything real
var hist = new long[bins + 1];
long cells = 0;
for (int x = 1; x < n - 1; x++)
{
for (int y = 1; y < n - 1; y++)
{
float v = h[x, y];
if (v < lo || v > hi) continue;
float gx = (h[x + 1, y] - h[x - 1, y]) * 0.5f;
float gy = (h[x, y + 1] - h[x, y - 1]) * 0.5f;
float g = WorldScale.MetresFromRaw(MathF.Sqrt(gx * gx + gy * gy));
int b = (int)(g / binW);
hist[b >= bins ? bins : b]++;
cells++;
}
}
if (cells == 0) return (0f, 0f, 0);
float Q(double q)
{
long target = (long)(q * cells), cum = 0;
for (int i = 0; i <= bins; i++)
{
cum += hist[i];
if (cum >= target) return (i + 0.5f) * binW;
}
return bins * binW;
}
return (Q(0.50), Q(0.95), cells);
}
// ---- metrics + notes round-trip -------------------------------------
private static void WriteMetrics(string path, List<SeedMetrics> ms)
{
var sb = new StringBuilder();
sb.AppendLine("seed\tland\ta100\ta220\tp50\tp75\tp90\tp99\tmidslope\tminslope\thmaxseed\tgmin\tgmax\tok\tms\tgradp50\tgradp95\tbandcells");
foreach (var m in ms)
sb.AppendLine($"{m.Seed}\t{m.LandPct:F2}\t{m.Above100:F3}\t{m.Above220:F3}\t{m.P50:F4}\t{m.P75:F4}\t" +
$"{m.P90:F4}\t{m.P99:F4}\t{m.MaxMidSlope:F4}\t{m.MinSlope:F4}\t{m.HMaxSeed:F4}\t" +
$"{m.GrayMin:F4}\t{m.GrayMax:F4}\t{(m.ClassifyOk && m.SeaOk ? 1 : 0)}\t{m.ElapsedMs}\t" +
$"{m.BandGradP50:F4}\t{m.BandGradP95:F4}\t{m.BandCells}");
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(sb.ToString());
}
private static List<SeedMetrics> ReadMetrics(string path)
{
var outp = new List<SeedMetrics>();
if (!Godot.FileAccess.FileExists(path)) return outp;
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
if (f == null) return outp;
string all = f.GetAsText();
bool header = true;
foreach (string line in all.Split('\n'))
{
if (header) { header = false; continue; }
if (string.IsNullOrWhiteSpace(line)) continue;
string[] c = line.Split('\t');
if (c.Length < 15) continue; // older rows without the gradient columns still load
outp.Add(new SeedMetrics
{
Seed = int.Parse(c[0]), LandPct = double.Parse(c[1]),
Above100 = double.Parse(c[2]), Above220 = double.Parse(c[3]),
P50 = float.Parse(c[4]), P75 = float.Parse(c[5]),
P90 = float.Parse(c[6]), P99 = float.Parse(c[7]),
MaxMidSlope = float.Parse(c[8]), MinSlope = float.Parse(c[9]),
HMaxSeed = float.Parse(c[10]), GrayMin = float.Parse(c[11]), GrayMax = float.Parse(c[12]),
ClassifyOk = c[13].Trim() == "1", SeaOk = c[13].Trim() == "1",
ElapsedMs = ulong.Parse(c[14].Trim()),
BandGradP50 = c.Length > 15 ? float.Parse(c[15]) : 0f,
BandGradP95 = c.Length > 16 ? float.Parse(c[16]) : 0f,
BandCells = c.Length > 17 ? long.Parse(c[17].Trim()) : 0L,
});
}
return outp;
}
/// <summary>
/// The human character notes, if they have been written yet. Format: <c>seed\tnote</c>.
/// Absent is a normal state, not an error — the first run cannot have them.
/// </summary>
private static Dictionary<int, string> ReadNotes(string path)
{
var notes = new Dictionary<int, string>();
if (!Godot.FileAccess.FileExists(path)) return notes;
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Read);
if (f == null) return notes;
foreach (string line in f.GetAsText().Split('\n'))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue;
string[] c = line.Split('\t');
if (c.Length < 2) continue;
if (int.TryParse(c[0].Trim(), out int s)) notes[s] = c[1].Trim();
}
return notes;
}
// ---- the contact sheet ----------------------------------------------
private static void WriteIndex(string batchRoot, List<SeedMetrics> ms, Dictionary<int, string> notes,
int anchor, int gallerySize, int calibSize, string notesPath)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 04 — seed gallery: the committed curve across 8 draws");
sb.AppendLine();
sb.AppendLine("**A contact sheet, not a tuning batch.** Every plate is the SAME curve — the one committed");
sb.AppendLine("at tag `terrain-curve-v1` (chat2/03 `continuous_restored`). The question is whether it holds");
sb.AppendLine("up across draws, or whether the anchor seed was lucky.");
sb.AppendLine();
sb.AppendLine($"- **Curve (pinned, no env override):** `mountainLift {MountainLift:F2}` · " +
$"`peakSharpness {PeakSharpness:F2}` · `lowlandCeiling {LowlandCeilingM:F0} m`");
sb.AppendLine($"- **Rendered at:** {gallerySize} · **calibration pool measured at:** {calibSize} (task 01's six seeds)");
sb.AppendLine($"- **Anchor:** `{anchor}` — the plate tasks 0103 were judged on. **Compare the others to it.**");
sb.AppendLine($"- **Palette:** ⚠ `ProvisionalEven` — evenly spaced SEA → 420 m, **provisional**. The");
sb.AppendLine(" grayscale is the honest instrument; the relief is for shape and hillshade.");
sb.AppendLine();
sb.AppendLine("## ⭐ The contact sheet");
sb.AppendLine();
bool anyNotes = notes.Count > 0;
sb.AppendLine("| Seed | Character | Relief | Grayscale | land | >100 m | >220 m | p90 | 100220 m grade (p50 / p95) |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (var m in ms)
{
string tag = m.Seed == anchor ? " ⭐" : "";
string note = notes.TryGetValue(m.Seed, out string n) ? n : "*(pending)*";
string warn = m.ClassifyOk && m.SeaOk ? "" : " ⚠⚠ INVARIANT FAILED";
sb.AppendLine($"| `{m.Seed}`{tag} | {note}{warn} | [`{m.Seed}/relief.png`]({m.Seed}/relief.png) | " +
$"[`grayscale.png`]({m.Seed}/grayscale.png) | {m.LandPct:F1}% | {m.Above100:F2}% | " +
$"{m.Above220:F2}% | {WorldScale.MetresFromRaw(m.P90 - 0.15f):F0} m | " +
$"{m.BandGradP50:F2} / {m.BandGradP95:F2} m per px |");
}
sb.AppendLine();
if (!anyNotes)
{
sb.AppendLine("> ⚠ **Character notes are pending.** They are an EYE call and this tool will not invent");
sb.AppendLine($"> them. Write `{Path.GetFileName(notesPath)}` in `scratch/` as `seed<TAB>note` lines, then");
sb.AppendLine("> re-run with `ISLA_INDEX_ONLY=1` to rebuild this table without re-rendering.");
sb.AppendLine();
}
sb.AppendLine("**100220 m grade** is the spatial height gradient through the band where the yellow→orange");
sb.AppendLine("transition sits — metres of climb per pixel, median and p95 over the land in that band. It is");
sb.AppendLine("the objective companion to the eye's *\"is that step abrupt?\"*: near-constant across seeds");
sb.AppendLine("means abruptness is a **curve trait** worth a tuning pass; a wide spread means it is a **draw**.");
sb.AppendLine();
sb.AppendLine("> ⚠ The curve's own normalized mid-slope is deliberately NOT tabulated: it is `dv/du` on a");
sb.AppendLine("> fixed control polygon, and each seed's denormalization rescales both axes, so it is the same");
sb.AppendLine("> number on every seed **by construction** and can discriminate nothing.");
sb.AppendLine();
sb.AppendLine("## Spread");
sb.AppendLine();
if (ms.Count > 0)
{
double lo100 = double.MaxValue, hi100 = double.MinValue, sum100 = 0;
float loMid = float.MaxValue, hiMid = float.MinValue;
double sumG = 0;
foreach (var m in ms)
{
lo100 = Math.Min(lo100, m.Above100); hi100 = Math.Max(hi100, m.Above100); sum100 += m.Above100;
loMid = MathF.Min(loMid, m.BandGradP50); hiMid = MathF.Max(hiMid, m.BandGradP50);
sumG += m.BandGradP50;
}
sb.AppendLine($"- **land >100 m:** {lo100:F2} % … {hi100:F2} % (mean {sum100 / ms.Count:F2} %)");
sb.AppendLine($"- **100220 m grade (p50):** {loMid:F2} … {hiMid:F2} m per px " +
$"(mean {sumG / ms.Count:F2}) — spread {(hiMid - loMid) / (sumG / ms.Count) * 100:F0}% of the mean");
sb.AppendLine();
}
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png`, `INDEX.md` | **keep** — the gallery |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + the tagged curve — **large, clear freely** |");
sb.AppendLine("| `scratch/metrics.tsv`, `scratch/character_notes.tsv` | **keep** — the INDEX is rebuilt from them |");
sb.AppendLine();
sb.AppendLine("Every `.f32` here is reproducible from `git checkout terrain-curve-v1` plus the seed, so the");
sb.AppendLine("bulk of this batch is safe to delete once the gallery has been judged.");
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;
}
}
}