islaApocalypse-v2/Tools/Scripts/CoastalFragmentTool.cs
beezm bddd3636dd chat2/09: coastal fragmentation — a perimeter-wide, band-limited noise, amplitude laddered at a fixed stretch
A dedicated term, not the edge noise scaled: pass 1's edge noise is positive-only, squircle-
modulated, 2.5x the base frequency - the coastline's jitter; scaling it would roughen the whole
rim and bias the coast inward. CoastalFragment adds, to the PRE-power falloff, amp * window(f) *
noise(x + off, y + off) with its own deterministic field (seed offset 9109, coordinate offset
0.37 map widths - D-059), zero-mean, at 12 periods per map width (the lobe/neck scale, chosen by
probe against 20 and 32: 20 climbs into smalls, 32 is fuzz), weighted by a smooth window on the
falloff value itself, centred 0.66 (the coast's falloff for a median base noise, from the 08
diagnosis), half-width 0.18, EXACTLY ZERO beyond - so the interior, the massif and the deep sea
are bit-identical by construction. Thin necks of barely-land flip first; nothing is detected,
nothing is stamped. FragmentBitesOnly is exposed (probed: it erodes the coast inward - mainland
-3/-8/-14 % at 0.06/0.15/0.30 - rather than detaching pieces; off by default).

Batch: BatchRoot(9, "coastal_fragment") - 4 amplitudes (0.06, 0.15, 0.30, 0.50) x task 08's two
seeds at 4096, stretch fixed at 2 (08's stretch_3 rung, whose dump is the bit-identical baseline,
a8), sinker untouched, speck revert at < 4 cells, offshore/shelf off; lean render (regions overlay
+ relief + .f32), the hemisphere-split count/size table with largest-three and histograms.

Oracle, all passing: a1 (the curve untouched), a8 x2 (frag OFF at stretch 2 == the 08 field,
16.8 M cells each), interior locked (r: every cell clear of the window bit-identical, 8/8),
high-ground report (s, informational), centre is land, tag/coastline, classify == raw, ids and
heights deterministic.

The read: both hemispheres fragment; the north more readily (N 10 -> 28, 13 -> 40 islands across
the ladder; S 4 -> 31, 14 -> 28) and the south's big stretch pieces are the zero-mean noise's
other edge: on 999999937 the 83k-cell southern fragment is BRIDGED back onto the mainland at amp
0.50 (mainland +183k). The working range is amp 0.15-0.30; 0.50 is the bookend. Graduation held.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-22 07:09:27 -04:00

517 lines
29 KiB
C#
Raw 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 COASTAL-FRAGMENTATION BATCH (chat2/09, exploration) — at a FIXED stretch, a perimeter-wide
/// fragmentation-noise amplitude ladder: light → heavy, 4 levels × 2 seeds, options to pick from.
///
/// ═══ TWO MODES ═══
///
/// ISLA_PROBE=1 numbers only at ISLA_CALIB_SIZE: a frequency × amplitude sweep on both seeds
/// (island counts and sizes per hemisphere, mainland size) — used to fix the
/// frequency and place the amplitude ladder. Written to scratch/frag_probe.md.
/// (default) the BATCH: 4 amplitudes × 2 seeds at ISLA_MAPSIZE, lean render per field
/// (labeled-regions overlay + relief + .f32), the hemisphere-split count/size table,
/// the asymmetric oracle (interior locked, coast free).
///
/// Every field: pass 1 + stretch (FIXED) + fragmentation (the axis), region labeling ON, speck revert
/// at a LOW threshold (true 13-cell noise only), offshore OFF, shelf OFF. The curve is the tagged
/// curve, unchanged.
///
/// ═══ RUNNING IT ═══
///
/// xvfb-run -a Godot_v4.7.2-stable_mono_linux.x86_64 \
/// --path ~/celerNexus/islaApocalypse-v2 res://Tools/Scenes/CoastalFragmentTool.tscn
///
/// ISLA_TASK / ISLA_BATCH / ISLA_SKIP_RAW / ISLA_OUTPUT_DIR
/// ISLA_MAPSIZE / ISLA_CALIB_SIZE (default 4096 / 2048)
/// ISLA_SEEDS the two seeds (default 1063685222, 999999937 — task 08's)
/// ISLA_STRETCH the fixed stretch (default 2)
/// ISLA_FRAG_LEVELS the 4 amplitudes (default: the probe-chosen ladder)
/// ISLA_FRAG_FREQ the fixed frequency, periods per map width
/// ISLA_SPECK_FRAC the speck-revert threshold, fraction of map area (default 2.5e-7 ≈ 4 cells at 4096)
/// ISLA_PROBE=1 · ISLA_PROBE_FREQS · ISLA_PROBE_AMPS the probe sweep
/// ISLA_FRAG_BITES=1 bites-only noise ([0,1]) instead of zero-mean ([-1,1])
/// ISLA_SKIP_8K=1 (no 8192 check this batch — the 08 dump at 4096 is the baseline)
/// </summary>
public partial class CoastalFragmentTool : Node
{
private static readonly int[] DefaultSeeds = { 1063685222, 999999937 };
/// <summary>⚠ Task 01's pool, verbatim — the curve's identity.</summary>
private static readonly int[] CalibrationSeeds = { 1063685222, 20260819, 777001, 424242, 90210, 31337 };
/// <summary>⭐ THE LADDER — set from the probe (chat2/09 report §1): light → heavy, amplitude the only axis.</summary>
private static readonly float[] DefaultLadder = { 0.06f, 0.15f, 0.30f, 0.50f };
private static readonly float[] ProbeFreqs = { 12f, 20f, 32f };
private static readonly float[] ProbeAmps = { 0.03f, 0.06f, 0.12f, 0.20f, 0.32f, 0.5f };
private const float DefaultStretch = 2f;
private const float DefaultSpeckFrac = 2.5e-7f; // ≈ 4 cells at 4096 — true noise only
private const int DefaultMapSize = 4096;
private const int DefaultCalibSize = 2048;
public override void _Ready()
{
try { Run(); }
catch (Exception e)
{
GD.PrintErr("==================================================================");
GD.PrintErr($" REFUSED: {e.Message}");
GD.PrintErr(e.StackTrace);
GD.PrintErr("==================================================================");
GetTree().Quit(2);
}
}
private sealed class HemiStats
{
public int All, Big; public long Min, Med, Max; public double Mean; public int[] Hist; public long[] Largest = Array.Empty<long>();
}
private sealed class Row
{
public int Level; public float Amp; public int Seed;
public HemiStats N, S; public long MainlandCells; public int SpecksReverted;
public bool Ok; public ulong Ms;
}
private void Run()
{
ToolingPaths.Configure(OS.GetUserDataDir());
int task = EnvInt("ISLA_TASK", 9);
string descr = EnvStr("ISLA_BATCH", "coastal_fragment");
int mapSize = EnvInt("ISLA_MAPSIZE", DefaultMapSize);
int calibSize = EnvInt("ISLA_CALIB_SIZE", DefaultCalibSize);
int[] seeds = EnvSeeds("ISLA_SEEDS", DefaultSeeds);
float stretch = EnvFloat("ISLA_STRETCH", DefaultStretch);
float[] ladder = EnvFloats("ISLA_FRAG_LEVELS", DefaultLadder);
float freq = EnvFloat("ISLA_FRAG_FREQ", CoastalFragment.DefaultFreqPerMapWidth);
float speckFrac = EnvFloat("ISLA_SPECK_FRAC", DefaultSpeckFrac);
bool probe = EnvStr("ISLA_PROBE", "0") == "1";
bool bitesOnly = EnvStr("ISLA_FRAG_BITES", CoastalFragment.DefaultBitesOnly ? "1" : "0") == "1";
float[] probeFreqs = EnvFloats("ISLA_PROBE_FREQS", ProbeFreqs);
float[] probeAmps = EnvFloats("ISLA_PROBE_AMPS", ProbeAmps);
bool skipRaw = EnvStr("ISLA_SKIP_RAW", "0") == "1";
string p1Source = EnvStr("ISLA_PHASE1_SOURCE", "02_pass1_port");
string t08Source = EnvStr("ISLA_T08_SOURCE", "08_southern_stretch_explore");
string t08Level = EnvStr("ISLA_T08_LEVEL", "stretch_3"); // the 08 rung with stretch 2
string batchRoot = ToolingPaths.BatchRoot(task, descr);
DirAccess.MakeDirRecursiveAbsolute(batchRoot);
string scratch = ToolingPaths.BatchScratch(batchRoot);
DirAccess.MakeDirRecursiveAbsolute(scratch);
var anchors = CurveAnchors.Default;
float sea = 0.15f;
long big = Cells(RegionPass.ThresholdMidFrac, mapSize);
long speckCells = Cells(speckFrac, mapSize);
GD.Print("==================================================================");
GD.Print(" COASTAL FRAGMENTATION (chat2/09) — break more pieces off the edges, N + S");
GD.Print("==================================================================");
GD.Print($"MapSize : {mapSize} (plates) calibration / probe at {calibSize}");
GD.Print($"seeds : {string.Join(", ", seeds)}");
GD.Print($"fixed : stretch {stretch:G3} (band {SouthernStretch.DefaultBandStartFrac:F2}/{SouthernStretch.DefaultBandFeatherFrac:F2}, sinker stretched) · frag freq {freq:G3}/map · window {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2} · speck revert < {speckCells} cells ({speckFrac:G2})");
GD.Print($"ladder : FragmentAmp {string.Join(", ", ladder)} noise {(bitesOnly ? "BITES ONLY [0,1]" : "zero-mean [-1,1]")} (\"big\" island = ≥ {big:N0} cells at {mapSize})");
GD.Print($"batch : {batchRoot}{(probe ? " ISLA_PROBE numbers only" : "")}");
GD.Print("==================================================================");
GD.Print($"\n--- 0. CURVE (task-01 pool at {calibSize}, offshore off) ---");
var (knots, calibration) = CalibrateCurve(calibSize, sea, anchors);
GD.Print($" {knots}");
float highRaw = knots.K2; // the top of the preserved lowland (30 m output) — "above the toe+red band"
TerrainGenConfig Cfg(int size, int seed, string label, float amp, float fq, float st, bool revert)
{
return new TerrainGenConfig
{
MapSize = size, Seed = seed, VariantLabel = label,
Curve = true, ShelfDetail = false, CurveMode = CurveModeKind.Continuous,
Knots = knots, Anchors = anchors, ClimbCalibration = calibration, LowlandCeilingM = 30f,
CoastShelf = false, Offshore = new OffshoreSettings(),
RegionLabeling = true, SpeckRevert = revert, MinLandComponentFrac = speckFrac,
SouthStretch = st,
FragmentAmp = amp, FragmentFreqPerMapWidth = fq, FragmentBitesOnly = bitesOnly,
};
}
// ═══ PROBE ═══
if (probe)
{
GD.Print($"\n--- PROBE at {calibSize}: frequency × amplitude, stretch {stretch:G3} ---");
long bigC = Cells(RegionPass.ThresholdMidFrac, calibSize);
var sb = new StringBuilder();
sb.AppendLine($"# chat2/09 probe — fragmentation frequency × amplitude at {calibSize}, stretch {stretch:G3}, noise {(bitesOnly ? "bites only" : "zero-mean")}");
sb.AppendLine();
sb.AppendLine($"\"big\" = ≥ {bigC} cells at {calibSize}. Speck revert < {Cells(speckFrac, calibSize)} cells. Offshore off.");
sb.AppendLine();
sb.AppendLine("| seed | freq | amp | N islands all / big | N size med / max | S islands all / big | S size med / max | mainland cells | mainland Δ vs amp 0 |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|");
foreach (int seed in seeds)
{
var base0 = Topography.Generate(Cfg(calibSize, seed, "amp0", 0f, freq, stretch, true));
long main0 = base0.Regions.Mainland.SizeCells;
var (n0, s0) = Stats(base0.Regions, bigC);
sb.AppendLine($"| `{seed}` | — | 0 | {n0.All} / {n0.Big} | {n0.Med} / {n0.Max} | {s0.All} / {s0.Big} | {s0.Med} / {s0.Max} | {main0:N0} | 0 |");
GD.Print($" seed {seed} amp 0: N {n0.All}/{n0.Big} med {n0.Med} max {n0.Max} S {s0.All}/{s0.Big} med {s0.Med} max {s0.Max} mainland {main0:N0}");
foreach (float fq in probeFreqs)
foreach (float amp in probeAmps)
{
var p = Topography.Generate(Cfg(calibSize, seed, "probe", amp, fq, stretch, true));
var (n, s) = Stats(p.Regions, bigC);
long main = p.Regions.Mainland.SizeCells;
sb.AppendLine($"| `{seed}` | {fq:G3} | {amp:G3} | {n.All} / {n.Big} | {n.Med} / {n.Max} | {s.All} / {s.Big} | {s.Med} / {s.Max} | {main:N0} | {main - main0:+#,0;-#,0;0} |");
GD.Print($" seed {seed} freq {fq,4:G3} amp {amp,5:G3}: N {n.All,3}/{n.Big,3} med {n.Med,6} max {n.Max,7} S {s.All,3}/{s.Big,3} med {s.Med,6} max {s.Max,7} mainland {main:N0} ({main - main0:+#,0;-#,0;0})");
}
}
WriteText(Path.Combine(scratch, bitesOnly ? "frag_probe_bites_only.md" : "frag_probe.md"), sb.ToString());
GD.Print($"\n probe written: {Path.Combine(scratch, "frag_probe.md")}");
GetTree().Quit(0);
return;
}
// ═══ 1. REGRESSIONS ═══
GD.Print($"\n--- 1. REGRESSIONS ---");
var hard = new List<ShapingOracle.Check>();
{
var offCfg = Cfg(calibSize, seeds[0], "off", 0f, freq, 0f, false);
Pass1Result p1 = Topography.Generate(offCfg);
var curveOff = offCfg.Clone(); curveOff.Curve = false;
string p1Dump = Path.Combine(ToolingPaths.BatchesRoot, p1Source, $"{seeds[0]}_full", "height.f32");
hard.Add(ShapingOracle.DumpRegression("a1", "curve OFF, stretch OFF, frag OFF == Phase-1 .f32 dump (the curve is untouched)", Shaping.Shape(p1, curveOff).Height, HeightField.Load(p1Dump, calibSize), calibSize, p1Dump));
// ⭐ a8 — frag OFF at the fixed stretch, revert OFF == the task-08 stretch-2 field (its dump at the plate size).
foreach (int seed in seeds)
{
string t08Dump = Path.Combine(ToolingPaths.BatchesRoot, t08Source, $"{seed}_{t08Level}", "height.f32");
if (File.Exists(t08Dump) && mapSize == 4096)
{
var c8 = Cfg(mapSize, seed, "t08", 0f, freq, stretch, false);
Pass2Result q8 = Shaping.Shape(Topography.Generate(c8), c8);
hard.Add(ShapingOracle.DumpRegression("a8", $"frag OFF, stretch {stretch:G3}, revert OFF == task-08 {t08Level} dump (the baseline) [{seed}]", q8.Height, HeightField.Load(t08Dump, mapSize), mapSize, t08Dump));
}
else GD.Print($" a8 [{seed}]: ⚠ skipped — {(mapSize != 4096 ? "map size is not 4096" : $"no 08 dump at {t08Dump}")}");
}
foreach (var c in hard) GD.Print(" " + c);
}
// ═══ 2. THE LADDER — 4 amplitudes × 2 seeds ═══
GD.Print($"\n--- 2. THE LADDER at {mapSize} ---");
var rows = new List<Row>();
var baseline = new Dictionary<int, Row>();
var perField = new List<ShapingOracle.Check>();
foreach (int seed in seeds)
{
var c0 = Cfg(mapSize, seed, "frag_0", 0f, freq, stretch, true);
Pass1Result p0 = Topography.Generate(c0);
baseline[seed] = MakeRow(0, 0f, seed, p0, big, true, p0.ElapsedMs);
var b = baseline[seed];
GD.Print($" seed {seed} baseline (amp 0, stretch {stretch:G3}): N {b.N.All}/{b.N.Big} S {b.S.All}/{b.S.Big} mainland {b.MainlandCells:N0}");
for (int li = 0; li < ladder.Length; li++)
{
float amp = ladder[li];
string label = $"frag_{li + 1}";
var cfg = Cfg(mapSize, seed, label, amp, freq, stretch, true);
Pass1Result p1 = Topography.Generate(cfg);
Pass2Result p2 = Shaping.Shape(p1, cfg);
var checks = new List<ShapingOracle.Check>
{
ShapingOracle.InteriorLocked(p0, p1, cfg.FragmentBandCentre, cfg.FragmentBandHalfWidth),
ShapingOracle.HighGroundReport(p0, p1, highRaw, $"K2 ({highRaw:F3} raw, the top of the preserved lowland)"),
ShapingOracle.CentreIsLand(p1),
ShapingOracle.TagCoastlineConsistent(p2, sea),
ShapingOracle.ClassifyFidelity(p1, p2),
};
foreach (var c in checks) { c.Name += $" [{label} = {amp:G3}, {seed}]"; perField.Add(c); }
bool ok = checks.TrueForAll(c => c.Passed);
var row = MakeRow(li + 1, amp, seed, p1, big, ok, p1.ElapsedMs);
rows.Add(row);
WriteField(batchRoot, p1, p2, sea, anchors, skipRaw, amp);
GD.Print($" {label,-7} amp {amp,5:G3} seed {seed,-11} N {row.N.All,3}/{row.N.Big,3} med {row.N.Med,6} max {row.N.Max,7} S {row.S.All,3}/{row.S.Big,3} med {row.S.Med,6} max {row.S.Max,7} mainland {row.MainlandCells:N0} ({row.MainlandCells - b.MainlandCells:+#,0;-#,0;0}) specks {row.SpecksReverted} {(ok ? "ok" : " CHECK FAILED")} {p1.ElapsedMs} ms");
}
}
// determinism
{
float amp = ladder[1];
var a = Topography.Generate(Cfg(mapSize, seeds[0], "det", amp, freq, stretch, true));
var bb = Topography.Generate(Cfg(mapSize, seeds[0], "det", amp, freq, stretch, true));
var det = ShapingOracle.LabelsDeterministic(a, bb); det.Name += $" [amp {amp:G3}, {seeds[0]}]";
var bits = ShapingOracle.NorthLocked("o2", $"two generations bit-identical everywhere [amp {amp:G3}, {seeds[0]}]", a.Height, bb.Height, mapSize, mapSize);
perField.Add(det); perField.Add(bits); GD.Print(" " + det); GD.Print(" " + bits);
}
bool allOk = hard.TrueForAll(c => c.Passed) && perField.TrueForAll(c => c.Passed);
GD.Print($"\n ORACLE: {(allOk ? "ALL HARD CHECKS PASS" : "*** FAILURES ***")}");
foreach (var c in perField) if (!c.Passed) GD.PrintErr(" " + c);
WriteTable(batchRoot, mapSize, ladder, seeds, rows, baseline, big, stretch, freq, speckCells);
WriteIndex(batchRoot, mapSize, calibSize, ladder, seeds, rows, baseline, big, stretch, freq, speckCells, hard, perField, allOk);
GD.Print("\n==================================================================");
GD.Print($" DONE — {batchRoot}");
GD.Print($" ORACLE {(allOk ? "HARD CHECKS ALL PASS" : "*** FAILURES see the table ***")}");
GD.Print("==================================================================");
GetTree().Quit(allOk ? 0 : 3);
}
// ---- the instrument --------------------------------------------------
private static long Cells(float frac, int size) => Math.Max(1L, (long)Math.Round(frac * (double)size * size));
private static (HemiStats north, HemiStats south) Stats(RegionLabels l, long big)
{
var n = new List<long>(); var s = new List<long>();
foreach (var r in l.Regions)
{
if (r.IsMainland) continue;
if (r.Hemisphere == RegionLabeling.HemiSouth) s.Add(r.SizeCells); else n.Add(r.SizeCells);
}
return (Make(n, big), Make(s, big));
}
private static HemiStats Make(List<long> sizes, long big)
{
sizes.Sort();
var h = new HemiStats { All = sizes.Count, Hist = new int[RegionLabeling.HistogramEdges.Length + 1] };
if (sizes.Count == 0) return h;
double sum = 0;
foreach (long v in sizes) { h.Hist[RegionLabeling.HistogramBin(v)]++; if (v >= big) h.Big++; sum += v; }
h.Min = sizes[0]; h.Med = sizes[sizes.Count / 2]; h.Max = sizes[^1]; h.Mean = sum / sizes.Count;
int k = Math.Min(3, sizes.Count); h.Largest = new long[k];
for (int i = 0; i < k; i++) h.Largest[i] = sizes[sizes.Count - 1 - i];
return h;
}
private static Row MakeRow(int level, float amp, int seed, Pass1Result p1, long big, bool ok, ulong ms)
{
var (n, s) = Stats(p1.Regions, big);
return new Row
{
Level = level, Amp = amp, Seed = seed, N = n, S = s, MainlandCells = p1.Regions.Mainland.SizeCells,
SpecksReverted = p1.RegionLedger?.RevertedComponents ?? 0, Ok = ok, Ms = ms,
};
}
// ---- the curve --------------------------------------------------------
private static (CurveKnots, ClimbCalibration) CalibrateCurve(int calibSize, float sea, CurveAnchors anchors)
{
var rawPool = new LandHistogram(sea);
var pass1 = new Dictionary<int, Pass1Result>();
foreach (int s in CalibrationSeeds)
{
var p1 = Topography.Generate(new TerrainGenConfig { MapSize = calibSize, Seed = s });
pass1[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]));
float ceilingRaw = knots.K2;
var rawAbove = new LandHistogram(sea);
var outAbove = new LandHistogram(sea);
foreach (int s in CalibrationSeeds)
{
var scfg = new TerrainGenConfig
{
MapSize = calibSize, Seed = s, Curve = true, ShelfDetail = true,
CurveMode = CurveModeKind.Staircase, Knots = knots, Anchors = anchors, VariantLabel = "staircase",
};
Pass2Result st = Shaping.Shape(pass1[s], scfg);
rawAbove.AccumulateWhere(pass1[s].Height, pass1[s].Height, calibSize, ceilingRaw);
outAbove.AccumulateWhere(st.Height, pass1[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]); }
var cal = ClimbCalibration.FromPercentiles(pcts, rawQ, outQ, ceilingRaw,
HeightCurve.EffectiveSpikeMax(pass1[CalibrationSeeds[0]].HMaxSeed, knots, anchors),
anchors.RedCeil, anchors.PeakCap, mountainLift: 1.0f, peakSharpness: 1.0f);
return (knots, cal);
}
// ---- output -----------------------------------------------------------
private static void WriteField(string batchRoot, Pass1Result p1, Pass2Result p2, float sea, CurveAnchors anchors, bool skipRaw, float amp)
{
string dir = Path.Combine(batchRoot, $"{p2.Seed}_{p2.VariantLabel}");
DirAccess.MakeDirRecursiveAbsolute(dir);
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, $"{p2.VariantLabel.ToUpperInvariant()} (AMP {amp:G3}) {p2.Seed}")
.SavePng(Path.Combine(dir, "relief.png"));
var led = p1.RegionLedger;
RegionOverlayRenderer.SavePng(p1.Regions, led != null && led.RevertOn ? p1.RegionsPre : null, p1.MapSize,
led?.RevertedComponents ?? 0, led?.ThresholdCells ?? 0, Path.Combine(dir, "regions.png"));
}
private static string HistRow(int[] h)
{
var sb = new StringBuilder();
for (int i = 0; i < h.Length; i++) { if (i > 0) sb.Append(" · "); sb.Append(h[i]); }
return sb.ToString();
}
private static string Largest(long[] l) => l.Length == 0 ? "—" : string.Join(" / ", Array.ConvertAll(l, v => v.ToString("N0")));
private static string TableMarkdown(float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big)
{
var sb = new StringBuilder();
var histHead = new StringBuilder();
for (int i = 0; i <= RegionLabeling.HistogramEdges.Length; i++) { if (i > 0) histHead.Append(" · "); histHead.Append(RegionLabeling.HistogramLabel(i)); }
sb.AppendLine($"| Level | amp | Seed | **N islands all / ≥ {big:N0}** | N size med / mean / max | N largest three | N histogram ({histHead}) | **S islands all / ≥ {big:N0}** | S size med / mean / max | S largest three | S histogram | mainland cells (Δ vs amp 0) | specks reverted | oracle |");
sb.AppendLine("|---|---|---|---|---|---|---|---|---|---|---|---|---|---|");
foreach (int seed in seeds)
{
var b = baseline[seed];
var all = new List<Row> { b }; all.AddRange(rows.FindAll(r => r.Seed == seed));
foreach (var r in all)
sb.AppendLine($"| {(r.Level == 0 ? "*baseline*" : $"`frag_{r.Level}`")} | {r.Amp:G3} | `{seed}` | **{r.N.All} / {r.N.Big}** | {r.N.Med} / {r.N.Mean:F0} / {r.N.Max} | {Largest(r.N.Largest)} | {HistRow(r.N.Hist)} | " +
$"**{r.S.All} / {r.S.Big}** | {r.S.Med} / {r.S.Mean:F0} / {r.S.Max} | {Largest(r.S.Largest)} | {HistRow(r.S.Hist)} | {r.MainlandCells:N0} ({r.MainlandCells - b.MainlandCells:+#,0;-#,0;0}) | {r.SpecksReverted} | {(r.Level == 0 ? "" : r.Ok ? "pass" : "**FAIL**")} |");
}
return sb.ToString();
}
private static void WriteTable(string batchRoot, int mapSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
float stretch, float freq, long speckCells)
{
var sb = new StringBuilder();
sb.AppendLine($"# The hemisphere-split count/size table — {ladder.Length} amplitudes × {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.AppendLine($"Fixed: stretch {stretch:G3}, fragmentation frequency {freq:G3}/map, window {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2}, speck revert < {speckCells} cells. Offshore / shelf OFF.");
sb.AppendLine("Islands = non-mainland 8-connected land components of the classify field; hemisphere by centroid. BOTH hemispheres are signals now.");
sb.AppendLine();
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
WriteText(Path.Combine(batchRoot, "count_size_table.md"), sb.ToString());
var csv = new StringBuilder();
csv.AppendLine("level,amp,seed,n_all,n_big,n_med,n_mean,n_max,n_largest,n_hist,s_all,s_big,s_med,s_mean,s_max,s_largest,s_hist,mainland_cells,specks_reverted,oracle,ms");
var ic = System.Globalization.CultureInfo.InvariantCulture;
foreach (int seed in seeds)
{
var all = new List<Row> { baseline[seed] }; all.AddRange(rows.FindAll(r => r.Seed == seed));
foreach (var r in all)
csv.AppendLine(string.Join(",", r.Level, r.Amp.ToString("G5", ic), r.Seed,
r.N.All, r.N.Big, r.N.Med, r.N.Mean.ToString("F1", ic), r.N.Max, "\"" + Largest(r.N.Largest) + "\"", "\"" + HistRow(r.N.Hist) + "\"",
r.S.All, r.S.Big, r.S.Med, r.S.Mean.ToString("F1", ic), r.S.Max, "\"" + Largest(r.S.Largest) + "\"", "\"" + HistRow(r.S.Hist) + "\"",
r.MainlandCells, r.SpecksReverted, r.Ok ? "pass" : "FAIL", r.Ms));
}
WriteText(Path.Combine(batchRoot, "count_size_table.csv"), csv.ToString());
}
private static void WriteIndex(string batchRoot, int mapSize, int calibSize, float[] ladder, int[] seeds, List<Row> rows, Dictionary<int, Row> baseline, long big,
float stretch, float freq, long speckCells, List<ShapingOracle.Check> hard, List<ShapingOracle.Check> perField, bool allOk)
{
int first = seeds.Length > 1 ? seeds[1] : seeds[0];
var sb = new StringBuilder();
sb.AppendLine("# Batch 09 — coastal fragmentation: break more pieces off the edges, N + S");
sb.AppendLine();
sb.AppendLine("**Options, not a setting.** At a FIXED stretch, a perimeter-wide band-limited fragmentation noise on the pre-power");
sb.AppendLine("falloff — only inside the coastal window, zero-mean — is swept light → heavy. Thin necks of barely-land flip first");
sb.AppendLine("(self-targeting: nothing detected, nothing stamped); the interior is bit-identical by construction (asserted). The");
sb.AppendLine("region layer is the instrument; both hemispheres are fragmentation signals now.");
sb.AppendLine();
sb.AppendLine("## ⭐ Open this first");
sb.AppendLine();
sb.AppendLine($"1. **`{first}_frag_2/regions.png`** — a mid amplitude on the seed whose natural southern islands read fragmentation best; grey mainland, each island its own colour, dark red = a reverted speck (< {speckCells} cells).");
sb.AppendLine($"2. Walk the ladder on that seed: `{first}_frag_1/` … `_frag_{ladder.Length}/` (`regions.png` beside `relief.png`); then the same on `{seeds[0]}`.");
sb.AppendLine("3. Then the table: N and S count + size side by side, down the rows as amplitude climbs — look for counts rising while the largest pieces stay healthy.");
sb.AppendLine();
sb.AppendLine("## The fixed frame and the axis");
sb.AppendLine();
sb.AppendLine($"- **Fixed:** stretch `{stretch:G3}` (task 08's `stretch_3` rung; band {SouthernStretch.DefaultBandStartFrac:F2} / feather {SouthernStretch.DefaultBandFeatherFrac:F2}, sinker stretched — untouched this round) · fragmentation frequency `{freq:G3}` periods/map (the secondary dial, fixed) · window centre {CoastalFragment.DefaultBandCentre:F2} ± {CoastalFragment.DefaultBandHalfWidth:F2} (pre-power falloff) · speck revert < {speckCells} cells (true noise only) · offshore OFF · shelf OFF.");
sb.AppendLine($"- **The axis — `FragmentAmp`:** {string.Join(" · ", Array.ConvertAll(ladder, v => v.ToString("G3")))} (levels 1{ladder.Length}); baseline 0 = the task-08 stretch field, measured for Δ.");
sb.AppendLine($"- \"big\" island = ≥ {big:N0} cells at {mapSize} (the 07 `threshold_mid`).");
sb.AppendLine();
sb.AppendLine($"## ⭐ The hemisphere-split count/size table — {ladder.Length} amplitudes × {seeds.Length} seeds at {mapSize}");
sb.AppendLine();
sb.Append(TableMarkdown(ladder, seeds, rows, baseline, big));
sb.AppendLine();
sb.AppendLine("Also as plain data: `count_size_table.md` / `.csv`. The probe that fixed the frequency and placed the ladder: `scratch/frag_probe.md`.");
sb.AppendLine();
sb.AppendLine("## ⚠ The palette is PROVISIONAL");
sb.AppendLine();
sb.AppendLine("`ProvisionalEven`, flagged. The individually-coloured scheme is only the `regions.png` overlay.");
sb.AppendLine();
sb.AppendLine("## The oracle (asymmetric: interior locked, coast free)");
sb.AppendLine();
sb.AppendLine("Regressions (the curve untouched; frag OFF at the fixed stretch bit-identical to the task-08 field):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(hard));
sb.AppendLine("Per field (interior locked r · high-ground report s (informational) · centre-is-land m · tag/coastline k · classify b · determinism o / o2):");
sb.AppendLine();
sb.AppendLine(ShapingOracle.ToMarkdownTable(perField));
sb.AppendLine($"**{(allOk ? "ALL HARD CHECKS PASS" : " FAILURES do not judge this batch")}**");
sb.AppendLine();
sb.AppendLine("## Disposability");
sb.AppendLine();
sb.AppendLine("| Artifact | Keep? |");
sb.AppendLine("|---|---|");
sb.AppendLine("| `regions.png`, `relief.png`, `INDEX.md`, `count_size_table.md` / `.csv`, `scratch/frag_probe.md` | **keep** |");
sb.AppendLine("| `height.f32` | ♻ regenerable from seed + code — large, clear freely |");
sb.AppendLine("| `scratch/` | persistent by rule; never cleaned |");
sb.AppendLine();
sb.AppendLine($"Plates at {mapSize}, curve calibrated at {calibSize} with offshore off. {WorldScale.Describe()}.");
WriteText(Path.Combine(batchRoot, "INDEX.md"), sb.ToString());
}
private static void WriteText(string path, string text)
{
using var f = Godot.FileAccess.Open(path, Godot.FileAccess.ModeFlags.Write);
if (f == null) { GD.PrintErr($"could not write {path}"); return; }
f.StoreString(text);
}
// ---- 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 float[] EnvFloats(string k, float[] fallback)
{
string v = EnvStr(k, null);
if (v == null) return fallback;
var outp = new List<float>();
foreach (string part in v.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (float.TryParse(part.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float f)) outp.Add(f);
return outp.Count > 0 ? outp.ToArray() : 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;
}
}
}