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

591 lines
29 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 CONTINUOUS-GRADE EXPLORATION BATCH (chat2/02, rev 3) — smooth the upper staircase,
/// preserve the lowlands, and prove both claims before anyone looks at a render.
///
/// ═══ THE TARGET SILHOUETTE (judge every shaped histogram against THIS) ═══
///
/// The bottom shoulder stays exactly where it is — the broad low pile, ~75 % of land below
/// 30 m, UNLIFTED. Above it, the bench and plateau spikes dissolve and the empty valleys fill
/// in: one smooth continuous falloff out to a thin dramatic peak at 420 m. Only the top ~25 %
/// of land moves.
///
/// ═══ THE VARIANTS — tight, one axis each ═══
///
/// staircase the faithful M3 control (task 01, bit-identical — oracle a2)
/// continuous_default lowland to 30 m, feather 0.40, drama 2.5 — the centerpiece
/// continuous_hold_higher lowland ceiling raised to 50 m — the primary axis explored up
/// continuous_dramatic_peak drama 4.5 — how pointy the peak gets
/// continuous_lifted_WRONG ⚠ the even whole-island lift — DELIBERATELY the wrong direction,
/// a contrast bookend. Its oracle-(d) failure is EXPECTED and is
/// reported as confirmation of why the direction is wrong.
///
/// ═══ ⚠ WHY THIS TOOL RE-MEASURES THE KNOTS INSTEAD OF USING CurveKnots.V2Baseline ═══
///
/// Task 01's batch was generated with the knots MEASURED IN THAT RUN, at full float precision.
/// The baked V2Baseline constants are those values quoted to six decimals — off by &lt;1e-7 raw,
/// which is invisible to every consumer EXCEPT a bit-identity oracle. Oracle (a2) demands the
/// staircase reproduce task 01's `.f32` byte-for-byte, so this tool re-runs the identical
/// 6-seed calibration (same seeds, same size, same instrument) and uses the measured knots.
/// It prints the measured-vs-baked deltas so the equivalence is evidence, not assumption.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CurveContinuousTool.tscn
///
/// ISLA_TASK authoring task number (default 2)
/// ISLA_BATCH descriptor, NO prefix (default "curve_continuous")
/// ISLA_MAPSIZE variant profile (default 2048)
/// ISLA_SEEDS variant seeds, comma-separated (default: the 2 pinned below)
/// ISLA_SHOWPIECE_SIZE the big confirmation render (default 8192)
/// ISLA_SHOWPIECE "0" to skip it
/// ISLA_PHASE1_SOURCE batch holding Phase-1 .f32 (default "chat1/02_pass1_port")
/// ISLA_SKIP_RAW "1" to skip the .f32 dumps
/// ISLA_CEILING_M probe override: lowland ceiling, metres (default 30)
/// ISLA_FEATHER probe override: climb feather, 0..1 (default 0.4)
/// ISLA_DRAMA probe override: summit drama, >= 1 (default 2.5)
///
/// ⚠ The three knob overrides move the BASE config only. Each batch variant sets its own axis
/// explicitly in its mutator, so a probe env can shift `continuous_default` but can never
/// silently move `hold_higher`'s ceiling or `dramatic_peak`'s drama out from under their names.
/// </summary>
public partial class CurveContinuousTool : Node
{
/// <summary>Two representative variant seeds — the primary Phase-1 seed and the tallest one.</summary>
private static readonly int[] DefaultSeeds = { 1063685222, 777001 };
/// <summary>
/// ⚠ THE TASK-01 CALIBRATION POOL, VERBATIM — same six seeds, so the measured knots (and
/// with them the staircase control) reproduce task 01 bit-for-bit. Do not "simplify" this
/// to the variant seeds; the pool is part of the knots' identity.
/// </summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
private const int DefaultMapSize = 2048;
private const int DefaultShowpieceSize = 8192;
public override void _Ready()
{
// ⚠ An exception out of _Ready does not stop Godot — it logs and the process HANGS with
// no main loop to end it. Catch, say what was refused, exit 2. (The standing rule.)
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", 2);
string descr = EnvStr("ISLA_BATCH", "curve_continuous");
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";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "chat1/02_pass1_port");
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
string batchRoot = ToolingPaths.BatchRoot(task, descr); // composed, never free-form
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(ToolingPaths.BatchScratch(batchRoot));
var anchors = CurveAnchors.Default;
float sea = 0.15f;
int primary = seeds[0];
GD.Print("==================================================================");
GD.Print(" CURVE CONTINUOUS (rev 3) — smooth the upper staircase,");
GD.Print(" preserve the lowlands. The staircase rides along as the control.");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} showpiece {(showpiece ? showSize.ToString() : "off")}");
GD.Print($"yardstick : {WorldScale.Describe()}");
GD.Print($"seeds : {string.Join(", ", seeds)} (calibration pool: {string.Join(", ", CalibrationSeeds)})");
GD.Print($"batch : {batchRoot}");
GD.Print("------------------------------------------------------------------");
GD.Print(ToolingPaths.Describe());
GD.Print("==================================================================");
// ═══ 0. RE-MEASURE THE KNOTS — task 01's calibration, verbatim (see the type header) ═══
GD.Print("\n--- 0. CALIBRATION (the task-01 pool, re-run for bit-identity) ---");
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int seed in CalibrationSeeds)
{
var p1 = Topography.Generate(TerrainGenConfig.CalibrationPool(mapSize, seed));
pass1[seed] = p1;
rawPool.Accumulate(p1.Height, mapSize);
GD.Print($" pooled seed {seed,-11} h[{p1.HMinSeed,7:F3} .. {p1.HMaxSeed,6:F3}] {p1.ElapsedMs,5} ms");
}
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]));
GD.Print(" measured vs baked V2Baseline (6-decimal roundings — expect <1e-6):");
for (int i = 0; i < 6; i++)
GD.Print($" K{i + 1}: measured {knots[i]:G9} baked {CurveKnots.V2Baseline[i]:G9} " +
$"delta {knots[i] - CurveKnots.V2Baseline[i]:E2}");
// ═══ 1. THE VARIANTS ═══
var variants = new List<(string label, Action<TerrainGenConfig> mutate)>
{
("staircase", c => { c.CurveMode = CurveModeKind.Staircase; c.ShelfDetail = true; }),
("continuous_default", c => { c.CurveMode = CurveModeKind.Continuous; }),
("continuous_hold_higher", c => { c.CurveMode = CurveModeKind.Continuous; c.LowlandCeilingM = 50f; }),
("continuous_dramatic_peak", c => { c.CurveMode = CurveModeKind.Continuous; c.SummitDrama = 4.5f; }),
("continuous_lifted_WRONG", c => { c.CurveMode = CurveModeKind.LiftedWrong; }),
};
GD.Print("\n--- 1. VARIANTS ---");
var results = new Dictionary<(int seed, string label), Pass2Result>();
var offs = new Dictionary<int, Pass2Result>();
var rows = new List<string>();
bool notesPrinted = false;
foreach (int seed in seeds)
{
Pass1Result p1 = pass1[seed];
offs[seed] = Shaping.Shape(p1, BaseConfig(mapSize, seed, knots, anchors, "curve_off", off: true));
foreach (var (label, mutate) in variants)
{
var cfg = BaseConfig(mapSize, seed, knots, anchors, label, off: false);
mutate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
results[(seed, label)] = p2;
if (!notesPrinted) foreach (string n in p2.Notes) GD.Print(" " + n);
rows.Add(WriteVariant(batchRoot, p2, sea, anchors, skipRaw));
}
notesPrinted = true;
}
// ═══ 2. THE ORACLE — before anything is looked at ═══
GD.Print("\n--- 2. ORACLE ---");
var hard = new List<ShapingOracle.Check>();
var soft = new List<ShapingOracle.Check>();
ShapingOracle.Check bookend;
{
Pass1Result pp1 = pass1[primary];
// (a1) curve off == Phase 1's own dump. ⭐ KEPT at rivers/01: the family-off pass-1 guard,
// the last link between today's generator and the Phase-1 port. The config is pinned
// family-off so it still means what it says. ⚠ A missing dump now THROWS.
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{primary}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF == Phase-1 .f32 dump",
offs[primary].Height, ShapingOracle.LoadAnchor("a1", "ISLA_PHASE1_SOURCE", p1Dump, mapSize), mapSize, p1Dump));
// ⚑ RETIRED at rivers/01 — a2, the staircase == `01_curve_baseline` control.
// The staircase curve is SUPERSEDED by the continuous grade (→ D-062). A control that
// reproduces a curve nothing ships is scaffolding, and holding it green cost a
// 4-variant batch run to prove a mode no design doc describes any more.
// The dump is NOT deleted (file-safety; it is regenerable and it is the record of what
// was judged); its `INDEX.md` is marked superseded. The check is gone so nothing can
// pass against a superseded baseline. → XX_Human/output/rivers/01_*.report.md §A4.
// (b) classify == raw, every seed × every variant.
long bFail = 0;
foreach (int seed in seeds)
foreach (var (label, _) in variants)
{
var c = ShapingOracle.ClassifyFidelity(pass1[seed], results[(seed, label)]);
if (!c.Passed) { bFail++; GD.PrintErr($" classify drift: seed {seed} {label}: {c.Detail}"); }
}
hard.Add(new ShapingOracle.Check
{
Id = "b", Name = "classify == raw, all seeds × all variants",
Passed = bFail == 0,
Detail = bFail == 0
? $"bit-identical on {seeds.Length} seeds × {variants.Count} variants"
: $"{bFail} (seed, variant) pairs drifted",
});
// (c) monotone — the staircase's sweep and the continuous strict-increase sample
// both throw on violation, so reaching here means they passed; state it explicitly.
bool cStair = results[(primary, "staircase")].Notes.Exists(n => n.Contains("Monotonicity assertion passed"));
bool cCont = results[(primary, "continuous_default")].Notes.Exists(n => n.Contains("strict-increase sample passed"));
hard.Add(new ShapingOracle.Check
{
Id = "c", Name = "monotone — staircase sweep + continuous strict-increase sample",
Passed = cStair && cCont,
Detail = $"staircase sweep {(cStair ? "confirmed" : "MISSING")}, " +
$"continuous sample {(cCont ? "confirmed" : "MISSING")} (both throw-and-refuse on violation)",
});
// (d) ⭐ lowlands preserved — every continuous variant, both seeds. HARD.
foreach (int seed in seeds)
foreach (string label in new[] { "continuous_default", "continuous_hold_higher", "continuous_dramatic_peak" })
{
var c = ShapingOracle.LowlandsPreserved(pass1[seed], results[(seed, "staircase")], results[(seed, label)]);
c.Name += $" [seed {seed}]";
hard.Add(c);
}
// (d, bookend) the WRONG lift is EXPECTED to fail this — its failure is the point.
bookend = ShapingOracle.LowlandsPreserved(pp1, results[(primary, "staircase")],
results[(primary, "continuous_lifted_WRONG")]);
// (e) upper climb profile — SOFT (exploration): warn loudly, do not gate the exit.
foreach (int seed in seeds)
foreach (string label in new[] { "continuous_default", "continuous_hold_higher", "continuous_dramatic_peak" })
{
var c = ShapingOracle.UpperClimbProfile(results[(seed, label)]);
c.Name += $" [seed {seed}]";
soft.Add(c);
}
// (f) sea identity — every variant, both seeds, per cell. HARD.
foreach (int seed in seeds)
foreach (var (label, _) in variants)
{
var c = ShapingOracle.SeaIdentity(offs[seed], results[(seed, label)], sea);
c.Name += $" [seed {seed}]";
hard.Add(c);
}
}
foreach (var c in hard) GD.Print(" " + c);
foreach (var c in soft)
{
if (c.Passed) GD.Print(" " + c);
else GD.PrintErr(" ⚠ SLOPE PROFILE: " + c);
}
GD.Print($" bookend (expected FAIL): {bookend}");
if (bookend.Passed)
GD.PrintErr(" ⚠⚠ the lifted_WRONG bookend PRESERVED the lowlands — it is not doing its job as a contrast.");
bool hardOk = hard.TrueForAll(c => c.Passed) && !bookend.Passed;
GD.Print($" ORACLE: {(hardOk ? "ALL HARD CHECKS PASS" : "*** HARD FAILURES ***")}" +
$"{(soft.TrueForAll(c => c.Passed) ? "" : " (soft slope warnings present see above)")}");
// ═══ 3. HISTOGRAMS — the contrast, adjacent by filename ═══
GD.Print("\n--- 3. HISTOGRAMS ---");
foreach (int seed in seeds)
{
var rawSeed = new LandHistogram(sea);
rawSeed.Accumulate(pass1[seed].Height, mapSize);
DrawRawHist(rawSeed, knots, seed, mapSize, batchRoot);
int order = 1;
foreach (var (label, _) in variants)
{
DrawShapedHist(results[(seed, label)], rawSeed, anchors, seed, mapSize, batchRoot, order++);
}
}
// ═══ 4. SHOWPIECE — continuous_default at the big profile ═══
string showNote = "skipped (ISLA_SHOWPIECE=0)";
if (showpiece)
{
GD.Print($"\n--- 4. SHOWPIECE at {showSize} (continuous_default, seed {primary}) ---");
var cfg = BaseConfig(showSize, primary, knots, anchors, "continuous_default_showpiece", off: false);
cfg.CurveMode = CurveModeKind.Continuous;
Pass1Result p1 = Topography.Generate(cfg);
GD.Print($" pass1 {showSize}: h[{p1.HMinSeed:F3} .. {p1.HMaxSeed:F3}] {p1.ElapsedMs} ms");
Pass2Result big = Shaping.Shape(p1, cfg);
foreach (string n in big.Notes) GD.Print(" " + n);
// The cheap checks that transfer to the big profile: classify fidelity + sea identity.
var cb = ShapingOracle.ClassifyFidelity(p1, big);
var offBig = Shaping.Shape(p1, BaseConfig(showSize, primary, knots, anchors, "off", off: true));
var cf = ShapingOracle.SeaIdentity(offBig, big, sea);
GD.Print($" {cb}");
GD.Print($" {cf}");
if (!cb.Passed || !cf.Passed) hardOk = false;
rows.Add(WriteVariant(batchRoot, big, sea, anchors, skipRaw));
showNote = $"seed {primary} at {showSize} — classify + sea identity re-verified there";
}
WriteIndex(batchRoot, mapSize, showSize, seeds, primary, knots, anchors,
results, hard, soft, bookend, rows, hardOk, showNote);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(hardOk ? "HARD CHECKS ALL PASS" : "*** HARD FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(hardOk ? 0 : 3);
}
// ---- configs ----------------------------------------------------------
private static TerrainGenConfig BaseConfig(int mapSize, int seed, CurveKnots k, CurveAnchors a,
string label, bool off) => new TerrainGenConfig
{
MapSize = mapSize, Seed = seed, VariantLabel = label,
Curve = !off, ShelfDetail = false, Knots = k, Anchors = a,
// Continuous knob defaults; variant mutators override per axis.
//
// ⚠ ENV-OVERRIDABLE so the knobs can be probed WITHOUT editing source and rebuilding —
// they are "eye-iteration knobs" per the task, and a knob you have to recompile to turn
// is not one. A probe run redirects ISLA_OUTPUT_DIR to scratch and sweeps these; the
// BATCH variants set them explicitly in their mutators, so a probe env cannot silently
// move the batch of record.
LowlandCeilingM = EnvFloat("ISLA_CEILING_M", 30f),
ClimbFeather = EnvFloat("ISLA_FEATHER", 0.4f),
SummitDrama = EnvFloat("ISLA_DRAMA", 2.5f),
}
// ⭐ rivers/01 — FAMILY-OFF PINNED, not defaulted. chat2/02 is CURVE development, measured on
// the family-off distribution the knots are percentiles of; the re-baseline flipped the bare
// defaults family-ON. → TerrainGenConfig.WithFamilyOff().
.WithFamilyOff();
// ---- output -----------------------------------------------------------
private static string WriteVariant(string batchRoot, Pass2Result p2, float sea,
CurveAnchors anchors, bool skipRaw)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{LabelOf(p2)}");
DirAccess.MakeDirRecursiveAbsolute(dir);
// Plain data beside the pretty render, always.
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"));
// ⚠ HILLSHADED, deliberately — the continuous grade is the first terrain with coherent
// shape worth lighting (Phase 1's rule was "flat is the hero" BECAUSE raw noise fuzzes;
// that reasoning inverts once the climb is smooth). Subtle settings, and the palette is
// the PROVISIONAL even ramp — flagged as such in the palette, the INDEX and the report.
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);
Image withLegend = LegendRenderer.WithLegend(map, look.Palette, sea, anchors.PeakCap,
LabelOf(p2).ToUpperInvariant());
withLegend.SavePng(Path.Combine(dir, "relief.png"));
float land = p2.LandFraction(sea);
GD.Print($" {LabelOf(p2),-26} seed {p2.Seed,-11} h[{p2.HMin,7:F3} .. {p2.HMax,6:F3}] " +
$" land {land * 100,5:F1}% {p2.ElapsedMs,5} ms");
return $"| `{p2.Seed}_{LabelOf(p2)}` | {p2.Seed} | {LabelOf(p2)} | {p2.HMin:F3} | {p2.HMax:F3} | " +
$"{land * 100:F1}% | {gMin:F3}..{gMax:F3} | {p2.ElapsedMs} ms |";
}
/// <summary>The variant folder name — read off the result, never re-derived.</summary>
private static string LabelOf(Pass2Result p2) => p2.VariantLabel;
private static void DrawRawHist(LandHistogram raw, CurveKnots k, int seed, int mapSize, string batchRoot)
{
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. K2 IS THE LOWLAND CEILING - EVERYTHING LEFT OF IT IS PRESERVED.",
XAxisLabel = "RAW HEIGHT (PRE-CURVE)",
XTop = top,
Footer = $"{raw.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize}",
};
o.Markers.Add(new HistogramRenderer.Marker { Value = k.K2, Label = "K2 LOWLAND CEIL" });
o.Bands.Add(new HistogramRenderer.Band
{
Lo = raw.SeaLevel, Hi = k.K2, Label = "preserved lowland",
SharePercent = raw.FractionBelow(k.K2) * 100.0,
});
o.Bands.Add(new HistogramRenderer.Band
{
Lo = k.K2, Hi = top, Label = "the climb's input",
SharePercent = (1.0 - raw.FractionBelow(k.K2)) * 100.0,
});
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, $"hist_{seed}_0_raw.png"));
GD.Print($" hist_{seed}_0_raw.png");
}
private static void DrawShapedHist(Pass2Result p2, LandHistogram rawSeed, CurveAnchors a,
int seed, int mapSize, string batchRoot, int order)
{
var shaped = new LandHistogram(rawSeed.SeaLevel);
shaped.Accumulate(p2.Height, mapSize);
float top = MathF.Ceiling(shaped.MaxLand * 20f) / 20f;
var display = shaped.Rebin((top - shaped.SeaLevel) / 360f);
string label = LabelOf(p2);
var o = new HistogramRenderer.Options
{
Title = $"SHAPED - {label.ToUpperInvariant()} - SEED {seed}",
XAxisLabel = "RAW HEIGHT (POST-CURVE)",
XTop = top,
Footer = $"{shaped.TotalLand} LAND COLUMNS AT MAPSIZE {mapSize}",
};
if (p2.Continuous != null)
{
var c = p2.Continuous;
var (onsetRaw, onsetOut) = c.SummitOnsetPoint();
o.Subtitle = "TARGET: BOTTOM PILE UNMOVED - ABOVE IT ONE SMOOTH FALLOFF TO A THIN PEAK.";
o.Markers.Add(new HistogramRenderer.Marker { Value = c.CeilingOut, Label = $"LOWLAND {c.LowlandCeilingM:F0}M" });
o.Markers.Add(new HistogramRenderer.Marker { Value = onsetOut, Label = "SUMMIT ONSET", Strong = false });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" });
o.Bands.Add(new HistogramRenderer.Band
{
Lo = rawSeed.SeaLevel, Hi = c.CeilingOut, Label = "preserved lowland",
SharePercent = rawSeed.FractionBelow(c.CeilingRaw) * 100.0,
});
o.Bands.Add(new HistogramRenderer.Band
{
Lo = c.CeilingOut, Hi = onsetOut, Label = "the climb",
SharePercent = rawSeed.FractionBetween(c.CeilingRaw, onsetRaw) * 100.0,
});
o.Bands.Add(new HistogramRenderer.Band
{
Lo = onsetOut, Hi = top, Label = "summit",
SharePercent = (1.0 - rawSeed.FractionBelow(onsetRaw)) * 100.0,
});
}
else if (p2.CurveModeLabel == "staircase")
{
o.Subtitle = "THE CONTROL: THE M3 STAIRCASE - BENCH AND PLATEAU SPIKES BY DESIGN.";
o.Markers.Add(new HistogramRenderer.Marker { Value = a.OrangeCeil, Label = "ORANGE", Strong = false });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "RED", Strong = false });
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" });
}
else
{
o.Subtitle = "THE WRONG DIRECTION: THE WHOLE ISLAND LIFTED OFF ITS SHORELINE. A BOOKEND.";
o.Markers.Add(new HistogramRenderer.Marker { Value = a.RedCeil, Label = "OLD 30M LINE", Strong = false });
o.Markers.Add(new HistogramRenderer.Marker { Value = a.PeakCap, Label = "CAP 420M" });
}
string file = $"hist_{seed}_{order}_{label}.png";
HistogramRenderer.SavePng(display, o, Path.Combine(batchRoot, file));
GD.Print($" {file}");
}
// ---- the index ----------------------------------------------------------
private static void WriteIndex(string batchRoot, int mapSize, int showSize, int[] seeds,
int primary, CurveKnots knots, CurveAnchors a,
Dictionary<(int, string), Pass2Result> results,
List<ShapingOracle.Check> hard, List<ShapingOracle.Check> soft,
ShapingOracle.Check bookend, List<string> rows, bool hardOk, string showNote)
{
var sb = new StringBuilder();
sb.AppendLine("# Batch 02 — continuous grade: smooth the upper staircase, preserve the lowlands");
sb.AppendLine();
sb.AppendLine("**rev 3.** The lowlands are GOOD and are preserved bit-for-bit (oracle d). Everything");
sb.AppendLine("above the 30 m flood line is replaced by one smooth monotone climb to the 420 m cap.");
sb.AppendLine("The staircase rides along as the control; the WRONG-direction whole-island lift rides");
sb.AppendLine("along as a contrast bookend. **Exploration, not convergence** — a tuning pass follows");
sb.AppendLine("once a direction is picked.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{primary}_continuous_default_showpiece/relief.png`** — the centerpiece ({showNote}).");
sb.AppendLine($"2. **`hist_{primary}_1_staircase.png` → `hist_{primary}_2_continuous_default.png`** — the");
sb.AppendLine(" contrast, adjacent by filename: bottom pile unchanged, the bench/plateau spikes above");
sb.AppendLine(" it dissolved into a smooth falloff.");
sb.AppendLine($"3. **`hist_{primary}_5_continuous_lifted_WRONG.png`** — the bookend: the whole pile shoved");
sb.AppendLine(" up off the shoreline. This is what \"make the island stand up\" would have done.");
sb.AppendLine($"4. `{primary}_staircase/relief.png` vs `{primary}_continuous_default/relief.png` — the A/B.");
sb.AppendLine();
sb.AppendLine("## The variants and their knobs");
sb.AppendLine();
sb.AppendLine("| Variant | Mode | Knobs | Control points |");
sb.AppendLine("|---|---|---|---|");
sb.AppendLine("| `staircase` | staircase | task-01 defaults (ShelfDetail on) | the v5 bands |");
foreach (string label in new[] { "continuous_default", "continuous_hold_higher", "continuous_dramatic_peak" })
{
var c = results[(primary, label)].Continuous;
sb.AppendLine($"| `{label}` | continuous | ceiling {c.LowlandCeilingM:F0} m · feather {c.ClimbFeather:F2} · " +
$"drama {c.SummitDrama:F2} | {c.DescribeControlPoints()} |");
}
sb.AppendLine("| `continuous_lifted_WRONG` | lifted | none — an even ×~1.47 lift of all land | n/a |");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven` — the CostaRica colours re-spaced **evenly** SEA → 420 m, so equal");
sb.AppendLine("colour = equal height and nothing is emphasized. Final palette calibration waits for the");
sb.AppendLine("chosen curve profile. **Grayscale + the histograms are the honest instruments.**");
sb.AppendLine();
sb.AppendLine("## The oracle");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine($"**{(hardOk ? "ALL HARD CHECKS PASS" : " HARD FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("Soft slope profile (e) — warn-and-report, exploration:");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(soft));
sb.AppendLine($"**The bookend, expected to FAIL (d):** {bookend.Detail}");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `relief.png`, `hist_*.png`, `INDEX.md` | **keep** — the judging plates and the finding |");
sb.AppendLine("| `grayscale.png` | ♻ regenerable from the `.f32` |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code (it is the byte-level oracle) |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
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($"Setup: MapSize {mapSize}, showpiece {showSize}, seeds {string.Join(", ", seeds)}, " +
$"knots re-measured on the task-01 pool. {WorldScale.Describe()}.");
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 float EnvFloat(string k, float fallback)
=> float.TryParse(EnvStr(k, null) ?? "", System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out float 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;
}
}
}