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
765 lines
35 KiB
C#
765 lines
35 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using IslaApocalypse.Core;
|
||
|
||
namespace IslaApocalypse.Tools
|
||
{
|
||
/// <summary>
|
||
/// ⭐⭐ THE ORACLE — the automatic correctness checks that let the developer's eye judge ONLY
|
||
/// relief.
|
||
///
|
||
/// ═══ WHY THIS EXISTS AT ALL ═══
|
||
///
|
||
/// The prototype's single most valuable terrain lesson was not about terrain:
|
||
///
|
||
/// > *"Five rounds of taste-iteration were safe BECAUSE correctness was not being judged by eye.
|
||
/// > Where a future phase has a subjective gate, ask first what the automatic invariant is."*
|
||
/// > — `Design - Tooling - Iteration and Batching.md`, "build the oracle before the
|
||
/// > taste-iteration, not after".
|
||
///
|
||
/// The curve is a subjective gate. So before a single render is looked at, four things are
|
||
/// proven mechanically:
|
||
///
|
||
/// (a) REGRESSION curve OFF is bit-identical to Phase 1's pass-1 output.
|
||
/// ⇒ the port disturbed nothing upstream.
|
||
/// (b) CLASSIFY FIDELITY the classify field is bit-identical to the raw pre-curve field,
|
||
/// curve on or off. ⇒ the oracle field is actually an oracle.
|
||
/// (c) MONOTONICITY the effective per-seed curve is strictly increasing everywhere.
|
||
/// ⇒ no peak has become a pit.
|
||
/// (d) BAND SHARES realized land shares match the 60/13/10/5/8/3/1 targets.
|
||
/// ⇒ the calibration did what it claimed.
|
||
///
|
||
/// ⚠ BIT-IDENTICAL MEANS BIT-IDENTICAL. These compare IEEE-754 bit patterns, not values within
|
||
/// an epsilon. "Close enough" is how a drift becomes a fact — and the whole point of the `.f32`
|
||
/// dump is that two generators agree or their dumps differ.
|
||
///
|
||
/// ⚠ Any failure fails the TASK, loudly. Nothing here papers over a mismatch: a check that
|
||
/// reports "mostly passed" is a check that has stopped working.
|
||
/// </summary>
|
||
public static class ShapingOracle
|
||
{
|
||
/// <summary>One check's verdict. <see cref="Detail"/> carries the evidence either way.</summary>
|
||
public sealed class Check
|
||
{
|
||
public string Id; // "a", "b", "c", "d"
|
||
public string Name;
|
||
public bool Passed;
|
||
public string Detail;
|
||
|
||
public override string ToString() => $"[{(Passed ? "PASS" : "FAIL")}] ({Id}) {Name} — {Detail}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Compare two float fields for BIT equality. Returns the number of differing cells and the
|
||
/// first difference found, so a failure is actionable rather than just red.
|
||
/// </summary>
|
||
public static (long differing, string firstDiff) CompareBitwise(float[,] a, float[,] b, int mapSize)
|
||
{
|
||
long differing = 0;
|
||
string first = null;
|
||
|
||
for (int x = 0; x < mapSize; x++)
|
||
{
|
||
for (int y = 0; y < mapSize; y++)
|
||
{
|
||
int ba = BitConverter.SingleToInt32Bits(a[x, y]);
|
||
int bb = BitConverter.SingleToInt32Bits(b[x, y]);
|
||
if (ba == bb) continue;
|
||
|
||
differing++;
|
||
first ??= $"first at [{x},{y}]: {a[x, y]:G9} (0x{ba:X8}) vs {b[x, y]:G9} (0x{bb:X8})";
|
||
}
|
||
}
|
||
return (differing, first);
|
||
}
|
||
|
||
/// <summary>
|
||
/// (a) REGRESSION — with the curve off, shaping must return the pass-1 field untouched.
|
||
///
|
||
/// ⚠ This is the WEAKER, always-available half of check (a): it proves pass 2 is a no-op when
|
||
/// gated off. The stronger half — that pass 1 ITSELF still matches Phase 1 byte-for-byte — is
|
||
/// <see cref="RegressionAgainstDump"/>, which needs a Phase-1 `.f32` on disk.
|
||
/// </summary>
|
||
public static Check RegressionCurveOff(Pass1Result p1, Pass2Result offResult)
|
||
{
|
||
var c = new Check { Id = "a", Name = "regression: curve OFF == pass-1 output" };
|
||
|
||
if (offResult.CurveOn)
|
||
{
|
||
c.Passed = false;
|
||
c.Detail = "the result handed in was generated with the curve ON — wrong variant.";
|
||
return c;
|
||
}
|
||
|
||
var (differing, firstDiff) = CompareBitwise(p1.Height, offResult.Height, p1.MapSize);
|
||
bool aliased = offResult.FieldsAreAliased;
|
||
|
||
c.Passed = differing == 0;
|
||
c.Detail = c.Passed
|
||
? $"bit-identical over {(long)p1.MapSize * p1.MapSize:N0} cells" +
|
||
(aliased ? " (and the fields alias one array, as the reference did)" : "")
|
||
: $"{differing:N0} cells differ — {firstDiff}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (a′) REGRESSION against a Phase-1 `.f32` dump — the cross-run half.
|
||
///
|
||
/// ⚠ A MISSING DUMP IS NOT A PASS. It is reported as INCONCLUSIVE and the caller says so; a
|
||
/// check that silently succeeds when its input is absent is worse than no check, because it
|
||
/// buys confidence that was never earned.
|
||
/// </summary>
|
||
public static Check RegressionAgainstDump(float[,] current, float[,] phase1Dump, int mapSize, string dumpPath)
|
||
{
|
||
var c = new Check { Id = "a′", Name = "regression: pass-1 == Phase-1 .f32 dump" };
|
||
|
||
if (phase1Dump == null)
|
||
{
|
||
c.Passed = false;
|
||
c.Detail = $"INCONCLUSIVE — no readable Phase-1 dump at {dumpPath} for this seed/size. " +
|
||
"Not counted as a pass; set ISLA_PHASE1_SOURCE to a batch that has one.";
|
||
return c;
|
||
}
|
||
|
||
var (differing, firstDiff) = CompareBitwise(phase1Dump, current, mapSize);
|
||
c.Passed = differing == 0;
|
||
c.Detail = c.Passed
|
||
? $"bit-identical to {dumpPath} over {(long)mapSize * mapSize:N0} cells"
|
||
: $"{differing:N0} cells differ from {dumpPath} — {firstDiff}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (b) CLASSIFY FIDELITY — the classify field is the raw pre-curve field, bit-for-bit, with
|
||
/// the curve on or off.
|
||
///
|
||
/// This is the invariant every later phase's oracle rests on: biomes and water will classify
|
||
/// from this field, so if it has drifted by even one ulp the "md5-identical across shaping
|
||
/// changes" guarantee is gone before it is ever used.
|
||
/// </summary>
|
||
public static Check ClassifyFidelity(Pass1Result p1, Pass2Result p2)
|
||
{
|
||
var c = new Check { Id = "b", Name = "classify field == raw pass-1 field" };
|
||
|
||
var (differing, firstDiff) = CompareBitwise(p1.Height, p2.HeightClassify, p1.MapSize);
|
||
c.Passed = differing == 0;
|
||
c.Detail = c.Passed
|
||
? $"bit-identical over {(long)p1.MapSize * p1.MapSize:N0} cells (curve {(p2.CurveOn ? "ON" : "OFF")})"
|
||
: $"{differing:N0} cells differ — {firstDiff}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (c) MONOTONICITY — recorded rather than re-run.
|
||
///
|
||
/// <c>HeightCurve.AssertMonotonic</c> THROWS on violation and is called inside
|
||
/// <see cref="Shaping.Shape"/>, so reaching this code at all means the sweep passed. The
|
||
/// check exists so the oracle table states it explicitly instead of leaving the strongest
|
||
/// guarantee implicit in the absence of a crash.
|
||
/// </summary>
|
||
public static Check Monotonicity(Pass2Result p2)
|
||
{
|
||
var c = new Check { Id = "c", Name = "curve strictly monotonic (24-corner sweep)" };
|
||
|
||
if (!p2.CurveOn)
|
||
{
|
||
c.Passed = true;
|
||
c.Detail = "curve off — nothing to prove (identity is trivially monotonic).";
|
||
return c;
|
||
}
|
||
|
||
string note = p2.Notes.Find(n => n.Contains("Monotonicity assertion passed"));
|
||
c.Passed = note != null;
|
||
c.Detail = note ?? "no monotonicity confirmation recorded — AssertMonotonic did not run.";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (d) BAND SHARES — the realized land shares against the M3 targets.
|
||
///
|
||
/// ⚠ MEASURED ON THE RAW (INPUT) DISTRIBUTION, because that is where the knots cut. The
|
||
/// shares are exact by construction IF the quantile machinery is right — so this check is
|
||
/// really a proof that <see cref="LandHistogram"/> measured what it claimed, which is the one
|
||
/// thing the reference could never verify about its own knots.
|
||
/// </summary>
|
||
/// <param name="tolerancePercentagePoints">
|
||
/// Allowed absolute deviation per band, in percentage points. The knots come from the SAME
|
||
/// histogram, so agreement is limited only by in-bin interpolation — tenths of a point, not
|
||
/// whole ones.
|
||
/// </param>
|
||
public static Check BandShares(LandHistogram raw, CurveKnots k, double tolerancePercentagePoints)
|
||
{
|
||
var c = new Check { Id = "d", Name = "realized land band shares == 60/13/10/5/8/3/1 targets" };
|
||
|
||
double[] realized = RealizedShares(raw, k);
|
||
double worst = 0.0;
|
||
int worstBand = -1;
|
||
|
||
for (int i = 0; i < realized.Length; i++)
|
||
{
|
||
double d = Math.Abs(realized[i] - CurveKnots.BandShareTargets[i]);
|
||
if (d > worst) { worst = d; worstBand = i; }
|
||
}
|
||
|
||
c.Passed = worst <= tolerancePercentagePoints;
|
||
c.Detail = $"worst band '{CurveKnots.BandNames[worstBand]}' off by {worst:F3} pp " +
|
||
$"(tolerance {tolerancePercentagePoints:F2} pp); realized " +
|
||
string.Join("/", Array.ConvertAll(realized, v => v.ToString("F2")));
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The fraction of land, in percent, falling in each of the curve's seven INPUT bands.
|
||
/// Band edges are sea, K1..K6, +∞.
|
||
/// </summary>
|
||
public static double[] RealizedShares(LandHistogram raw, CurveKnots k)
|
||
{
|
||
float[] edges = { raw.SeaLevel, k.K1, k.K2, k.K3, k.K4, k.K5, k.K6 };
|
||
var shares = new double[7];
|
||
|
||
for (int i = 0; i < 6; i++)
|
||
shares[i] = raw.FractionBetween(edges[i], edges[i + 1]) * 100.0;
|
||
|
||
shares[6] = Math.Max(0.0, (1.0 - raw.FractionBelow(k.K6)) * 100.0);
|
||
return shares;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A bit-regression against any `.f32` dump, with the caller naming the check — the
|
||
/// chat2/02 generalization of <see cref="RegressionAgainstDump"/>, used to hold the
|
||
/// staircase mode against task 01's own batch output. The same rule applies: a missing dump
|
||
/// is INCONCLUSIVE and counted as a failure, never as a pass.
|
||
/// </summary>
|
||
public static Check DumpRegression(string id, string name, float[,] current, float[,] dump,
|
||
int mapSize, string dumpPath)
|
||
{
|
||
var c = new Check { Id = id, Name = name };
|
||
|
||
if (dump == null)
|
||
{
|
||
c.Passed = false;
|
||
c.Detail = $"INCONCLUSIVE — no readable dump at {dumpPath} for this seed/size. Not counted as a pass.";
|
||
return c;
|
||
}
|
||
|
||
var (differing, firstDiff) = CompareBitwise(dump, current, mapSize);
|
||
c.Passed = differing == 0;
|
||
c.Detail = c.Passed
|
||
? $"bit-identical to {dumpPath} over {(long)mapSize * mapSize:N0} cells"
|
||
: $"{differing:N0} cells differ from {dumpPath} — {firstDiff}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (d) ⭐ LOWLANDS PRESERVED — the rev-3 task's load-bearing check. For every cell whose RAW
|
||
/// height is at or below K2 (the toe+red band the developer likes), the continuous mode's
|
||
/// output must be BIT-IDENTICAL to the staircase's. This mechanically enforces "do not lift
|
||
/// the lowlands": the low pile cannot move if its every column is the same float.
|
||
///
|
||
/// The identity is by construction — the continuous curve DELEGATES to the staircase's own
|
||
/// toe+red code path below K2 — and this check is what keeps that construction honest
|
||
/// against refactoring, float re-association, or a ceiling knob bug.
|
||
///
|
||
/// ⚠ Run against the LIFTED_WRONG bookend this check is EXPECTED to fail — the caller
|
||
/// reports that failure as confirmation of the wrong direction, not as a defect.
|
||
/// </summary>
|
||
public static Check LowlandsPreserved(Pass1Result p1, Pass2Result staircase, Pass2Result variant)
|
||
{
|
||
var c = new Check { Id = "d", Name = $"lowlands (raw ≤ K2) bit-identical to staircase [{variant.VariantLabel}]" };
|
||
|
||
float k2 = staircase.Knots.K2;
|
||
long compared = 0, differing = 0;
|
||
string first = null;
|
||
|
||
for (int x = 0; x < p1.MapSize; x++)
|
||
{
|
||
for (int y = 0; y < p1.MapSize; y++)
|
||
{
|
||
if (p1.Height[x, y] > k2) continue;
|
||
compared++;
|
||
int ba = BitConverter.SingleToInt32Bits(staircase.Height[x, y]);
|
||
int bb = BitConverter.SingleToInt32Bits(variant.Height[x, y]);
|
||
if (ba == bb) continue;
|
||
differing++;
|
||
first ??= $"first at [{x},{y}] raw {p1.Height[x, y]:G9}: " +
|
||
$"staircase {staircase.Height[x, y]:G9} vs {variant.Height[x, y]:G9}";
|
||
}
|
||
}
|
||
|
||
c.Passed = differing == 0;
|
||
c.Detail = c.Passed
|
||
? $"bit-identical over all {compared:N0} lowland cells (raw ≤ K2 = {k2:F6})"
|
||
: $"{differing:N0} of {compared:N0} lowland cells differ — {first}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (e) UPPER CONTINUOUS — sample the climb's slope densely; no near-flat anywhere (a bench
|
||
/// reborn), no cliff below the summit onset (the summit itself may steepen — that is the
|
||
/// dramatic peak). ⚠ SOFT: this is an exploration batch, so a violation warns loudly and
|
||
/// lands in the report rather than failing the run — the tool decides the exit code.
|
||
/// </summary>
|
||
public static Check UpperClimbProfile(Pass2Result p2)
|
||
{
|
||
var c = new Check { Id = "e", Name = $"upper climb continuous — no flats, no low-mid cliffs [{p2.VariantLabel}]" };
|
||
|
||
if (p2.Continuous == null)
|
||
{
|
||
c.Passed = false;
|
||
c.Detail = "no continuous spline on this result — wrong mode handed in.";
|
||
return c;
|
||
}
|
||
|
||
var (minN, minAt, maxN, maxAt, nearFlat, cliff) = p2.Continuous.SampleClimbSlopes();
|
||
c.Passed = !nearFlat && !cliff;
|
||
c.Detail = $"slope (normalized, 1 = climb average): min {minN:F3} at raw {minAt:F4}" +
|
||
$"{(nearFlat ? " ⚠ NEAR-FLAT (a bench reborn)" : "")}, " +
|
||
$"max below onset {maxN:F3} at raw {maxAt:F4}" +
|
||
$"{(cliff ? " ⚠ CLIFF below the summit onset" : "")}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (f) SEA IDENTITY — per cell, not per count: a column is land in the variant exactly when
|
||
/// it is land with the curve off. Counting alone could hide two errors that cancel; this
|
||
/// cannot. The coastline is the one thing every mode, including the wrong one, must keep.
|
||
/// </summary>
|
||
public static Check SeaIdentity(Pass2Result off, Pass2Result variant, float seaLevel)
|
||
{
|
||
var c = new Check { Id = "f", Name = $"sea identity — per-cell landness unchanged [{variant.VariantLabel}]" };
|
||
|
||
long mismatches = 0;
|
||
string first = null;
|
||
for (int x = 0; x < off.MapSize; x++)
|
||
{
|
||
for (int y = 0; y < off.MapSize; y++)
|
||
{
|
||
bool a = off.Height[x, y] >= seaLevel;
|
||
bool b = variant.Height[x, y] >= seaLevel;
|
||
if (a == b) continue;
|
||
mismatches++;
|
||
first ??= $"first at [{x},{y}]: off {off.Height[x, y]:G9} vs {variant.Height[x, y]:G9}";
|
||
}
|
||
}
|
||
|
||
c.Passed = mismatches == 0;
|
||
c.Detail = c.Passed
|
||
? $"per-cell landness identical over {(long)off.MapSize * off.MapSize:N0} cells"
|
||
: $"{mismatches:N0} cells changed sides of the waterline — {first}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (g) ⭐ MOUNTAIN RESTORED (chat2/03) — how much land ends up above 100 m and 220 m,
|
||
/// against the staircase's own figures.
|
||
///
|
||
/// ═══ ⚠ REPORTED, NOT GATED ═══
|
||
///
|
||
/// This is a taste target the developer tunes, so a miss is a FINDING, not a build failure —
|
||
/// gating it would make `mountainLift` unusable as a knob, since every value but one would
|
||
/// fail the run. What it must never do is stay silent: chat2/02 lost two thirds of the
|
||
/// mountain and only found out because someone went looking at the dumps afterwards. This
|
||
/// check is that look, made automatic.
|
||
///
|
||
/// <paramref name="tolerancePp"/> only decides whether the row reads PASS or NOTE; the
|
||
/// numbers are always printed.
|
||
/// </summary>
|
||
public static Check MountainRestored(Pass2Result variant, Pass2Result staircase,
|
||
float seaLevel, double tolerancePp)
|
||
{
|
||
var c = new Check { Id = "g", Name = $"mountain restored vs staircase [{variant.VariantLabel}]" };
|
||
|
||
var (v100, v220) = LandAbove(variant, seaLevel);
|
||
var (s100, s220) = LandAbove(staircase, seaLevel);
|
||
|
||
double d100 = v100 - s100;
|
||
c.Passed = Math.Abs(d100) <= tolerancePp;
|
||
c.Detail = $">100 m: {v100:F2} % vs staircase {s100:F2} % ({d100:+0.00;-0.00} pp) · " +
|
||
$">220 m: {v220:F2} % vs {s220:F2} % ({v220 - s220:+0.00;-0.00} pp)";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>Percentage of LAND above 100 m and 220 m of world height. Land = at/above sea.</summary>
|
||
public static (double above100, double above220) LandAbove(Pass2Result p2, float seaLevel)
|
||
{
|
||
float t100 = seaLevel + WorldScale.RawFromMetres(100f);
|
||
float t220 = seaLevel + WorldScale.RawFromMetres(220f);
|
||
|
||
long land = 0, a100 = 0, a220 = 0;
|
||
for (int x = 0; x < p2.MapSize; x++)
|
||
{
|
||
for (int y = 0; y < p2.MapSize; y++)
|
||
{
|
||
float h = p2.Height[x, y];
|
||
if (h < seaLevel) continue;
|
||
land++;
|
||
if (h > t100) a100++;
|
||
if (h > t220) a220++;
|
||
}
|
||
}
|
||
return land == 0 ? (0.0, 0.0) : (100.0 * a100 / land, 100.0 * a220 / land);
|
||
}
|
||
|
||
// ═══ chat2/05 — the offshore checks ═══
|
||
|
||
// (h) was the chat2/05 seeded-floor check — reverted out with the floor in chat2/06. No
|
||
// count is guaranteed any more, so there is nothing for an oracle to assert; the count
|
||
// table is the evidence, and it is statistics, not a check.
|
||
|
||
/// <summary>
|
||
/// (i) ⭐ MOAT INTACT — no offshore island is 8-connected to mainland land. The moat exists
|
||
/// to make a land bridge impossible; this is the proof that it did.
|
||
/// </summary>
|
||
public static Check MoatIntact(Pass1Result p1, List<IslandComponent> comps)
|
||
{
|
||
var c = new Check { Id = "i", Name = "moat intact — no island touches the mainland" };
|
||
int bridged = OffshoreAnalysis.BridgedCount(comps);
|
||
c.Passed = p1.HasIslandTag && bridged == 0;
|
||
c.Detail = !p1.HasIslandTag ? "no island tag — nothing to check"
|
||
: bridged == 0 ? $"all {comps.Count} islands are separated from mainland by water"
|
||
: $"{bridged} island(s) BRIDGE to mainland land";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (j) ⭐ MAINLAND UNMOVED — with offshore on vs off, every cell that was LAND with it off is
|
||
/// BIT-IDENTICAL with it on. The shelf touches only below-sea cells, the islets only lift
|
||
/// below-sea cells; neither may touch existing land. (The falloff test and the moat did their
|
||
/// job if this holds.) Reports how many sea cells the shelf moved and how many were lifted.
|
||
/// </summary>
|
||
public static Check MainlandUnmoved(Pass1Result off, Pass1Result on, float sea)
|
||
{
|
||
var c = new Check { Id = "j", Name = "mainland unmoved — every offshore-OFF land cell bit-identical with offshore ON" };
|
||
long land = 0, landDiff = 0, seaChanged = 0, lifted = 0;
|
||
string first = null;
|
||
for (int x = 0; x < off.MapSize; x++)
|
||
{
|
||
for (int y = 0; y < off.MapSize; y++)
|
||
{
|
||
float a = off.Height[x, y], b = on.Height[x, y];
|
||
if (a >= sea)
|
||
{
|
||
land++;
|
||
if (BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b))
|
||
{
|
||
landDiff++;
|
||
first ??= $"first at [{x},{y}]: {a:G9} → {b:G9}";
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (a != b) seaChanged++;
|
||
if (b >= sea) lifted++;
|
||
}
|
||
}
|
||
}
|
||
c.Passed = landDiff == 0;
|
||
c.Detail = landDiff == 0
|
||
? $"all {land:N0} land cells bit-identical; {seaChanged:N0} sea cells remapped by the shelf, {lifted:N0} lifted to land"
|
||
: $"{landDiff:N0} of {land:N0} land cells CHANGED — {first}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (k) TAG ↔ COASTLINE CONSISTENT — per cell, classify-land ⇔ render-land (the curve is
|
||
/// identity at sea and monotone above, so it must be — D-046), and every TAGGED cell is land
|
||
/// in both fields. This is what lets the tag be carried through pass 2 without recomputation.
|
||
/// </summary>
|
||
public static Check TagCoastlineConsistent(Pass2Result p2, float sea)
|
||
{
|
||
var c = new Check { Id = "k", Name = "offshore tag: classify/render coastline consistent, every tagged cell is land" };
|
||
long mismatch = 0, tagNotLand = 0, tagged = 0;
|
||
for (int x = 0; x < p2.MapSize; x++)
|
||
{
|
||
for (int y = 0; y < p2.MapSize; y++)
|
||
{
|
||
bool cl = p2.HeightClassify[x, y] >= sea;
|
||
bool rl = p2.Height[x, y] >= sea;
|
||
if (cl != rl) mismatch++;
|
||
if (p2.IsIsland != null && p2.IsIsland[x, y])
|
||
{
|
||
tagged++;
|
||
if (!cl || !rl) tagNotLand++;
|
||
}
|
||
}
|
||
}
|
||
c.Passed = mismatch == 0 && tagNotLand == 0;
|
||
c.Detail = $"{mismatch:N0} classify/render landness mismatches; {tagNotLand:N0} of {tagged:N0} tagged cells not land";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (l) HMaxSeed RECOMPUTED AFTER SHELF + OFFSHORE — reported. Expected unchanged (a ~34 m
|
||
/// crest vs a ~290 m peak), but the ORDER is the fix (chat2/00 Drift §2), and the value is
|
||
/// measured rather than assumed. Always passes; the detail is the point.
|
||
/// </summary>
|
||
public static Check HMaxAfterOffshore(Pass1Result p1)
|
||
{
|
||
bool moved = p1.HMaxSeed != p1.HMaxSeedBeforeOffshore;
|
||
return new Check
|
||
{
|
||
Id = "l", Name = "HMaxSeed recomputed after shelf + offshore",
|
||
Passed = true,
|
||
Detail = $"before {p1.HMaxSeedBeforeOffshore:F6} → after {p1.HMaxSeed:F6} " +
|
||
(moved ? "— ⚠ MOVED (an islet outran the peak?)" : "— unchanged, as expected; the ORDER is now right by construction"),
|
||
};
|
||
}
|
||
|
||
// ═══ chat2/07 — the region checks ═══
|
||
|
||
/// <summary>(m) CENTRE IS LAND — the mainland definition held (the massif is centred); the fallback was not needed.</summary>
|
||
public static Check CentreIsLand(Pass1Result p1)
|
||
{
|
||
var c = new Check { Id = "m", Name = "mainland = centre component (centre cell is land, no fallback)" };
|
||
if (p1.Regions == null) { c.Passed = false; c.Detail = "no region labeling on this field"; return c; }
|
||
var m = p1.Regions.Mainland;
|
||
c.Passed = p1.Regions.CentreWasLand && m != null && (p1.RegionLedger == null || p1.RegionLedger.CentreWasLandPre);
|
||
c.Detail = c.Passed
|
||
? $"centre is land; mainland id {p1.Regions.MainlandId}, {m.SizeCells:N0} cells, centroid ({m.CentroidX:F0},{m.CentroidY:F0}); {p1.Regions.IslandCount} islands"
|
||
: "⚠ CENTRE CELL IS NOT LAND — fell back to the largest component";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (n) ⭐ THE REVERT GUARDS, RE-PROVEN ON THE FIELDS — filter OFF vs ON: every cell of the OFF
|
||
/// field's MAINLAND component is bit-identical; every cell that changed was land in a
|
||
/// sub-threshold NON-MAINLAND component of the OFF labeling (component-only) and went DOWN, to
|
||
/// below sea (lower-only); nothing else moved. The pass asserted this as it ran; this is the
|
||
/// independent proof on the finished fields.
|
||
/// </summary>
|
||
public static Check RevertGuards(Pass1Result off, Pass1Result on, float sea, long thresholdCells)
|
||
{
|
||
var c = new Check { Id = "n", Name = "speck revert: mainland bit-identical, every change is in a sub-threshold island and lower-only" };
|
||
if (off.Regions == null) { c.Passed = false; c.Detail = "the OFF field has no region labeling"; return c; }
|
||
int n = off.MapSize;
|
||
var lab = off.Regions;
|
||
long mainland = 0, mainlandDiff = 0, changed = 0, notIsland = 0, notSmall = 0, raised = 0, notSea = 0;
|
||
string first = null;
|
||
for (int x = 0; x < n; x++)
|
||
{
|
||
for (int y = 0; y < n; y++)
|
||
{
|
||
float a = off.Height[x, y], b = on.Height[x, y];
|
||
int id = lab.Id[x * n + y];
|
||
bool isMain = id != 0 && id == lab.MainlandId;
|
||
if (isMain) mainland++;
|
||
if (BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b)) continue;
|
||
changed++;
|
||
if (isMain) { mainlandDiff++; first ??= $"mainland cell [{x},{y}] {a:G9} → {b:G9}"; continue; }
|
||
if (id == 0) { notIsland++; first ??= $"sea cell [{x},{y}] changed {a:G9} → {b:G9}"; continue; }
|
||
if (lab.Regions[id - 1].SizeCells >= thresholdCells) { notSmall++; first ??= $"cell [{x},{y}] of component {id} ({lab.Regions[id - 1].SizeCells} cells ≥ {thresholdCells}) changed"; }
|
||
if (b >= a) { raised++; first ??= $"cell [{x},{y}] RAISED {a:G9} → {b:G9}"; }
|
||
if (b >= sea) { notSea++; first ??= $"cell [{x},{y}] still land after revert ({b:G9})"; }
|
||
}
|
||
}
|
||
c.Passed = mainlandDiff == 0 && notIsland == 0 && notSmall == 0 && raised == 0 && notSea == 0;
|
||
c.Detail = c.Passed
|
||
? $"all {mainland:N0} mainland cells bit-identical; {changed:N0} cells changed, every one in a sub-threshold island, lowered below sea"
|
||
: $"VIOLATION — mainland {mainlandDiff:N0} / non-island {notIsland:N0} / over-threshold {notSmall:N0} / raised {raised:N0} / still land {notSea:N0} — {first}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>(o) LABELS DETERMINISTIC — two generations of the same seed: id maps and component tables identical.</summary>
|
||
public static Check LabelsDeterministic(Pass1Result a, Pass1Result b)
|
||
{
|
||
var c = new Check { Id = "o", Name = "region ids deterministic per seed (two runs, id map + table identical)" };
|
||
if (a.Regions == null || b.Regions == null) { c.Passed = false; c.Detail = "no region labeling"; return c; }
|
||
long diff = 0; int n = a.MapSize;
|
||
for (int i = 0; i < n * n; i++) if (a.Regions.Id[i] != b.Regions.Id[i]) diff++;
|
||
bool table = a.Regions.Regions.Count == b.Regions.Regions.Count && a.Regions.MainlandId == b.Regions.MainlandId;
|
||
if (table)
|
||
for (int i = 0; i < a.Regions.Regions.Count; i++)
|
||
{
|
||
var ra = a.Regions.Regions[i]; var rb = b.Regions.Regions[i];
|
||
if (ra.SizeCells != rb.SizeCells || ra.CentroidX != rb.CentroidX || ra.CentroidY != rb.CentroidY || ra.Hemisphere != rb.Hemisphere || ra.IsMainland != rb.IsMainland) { table = false; break; }
|
||
}
|
||
c.Passed = diff == 0 && table;
|
||
c.Detail = c.Passed ? $"{a.Regions.Regions.Count} components, id map identical over {(long)n * n:N0} cells, tables identical"
|
||
: $"{diff:N0} id cells differ; tables {(table ? "identical" : "DIFFER")}";
|
||
return c;
|
||
}
|
||
|
||
// ═══ chat2/08 — the southern-stretch band checks ═══
|
||
|
||
/// <summary>
|
||
/// (p) ⭐ NORTH BIT-LOCKED, BOTH DIRECTIONS — every cell with y < <paramref name="bandRow"/> is
|
||
/// bit-identical between the two fields (no land lowered, no sea raised); reports how many cells
|
||
/// differ at/below the band (allowed — that is the relaxation). The surgical guarantee, per cell.
|
||
/// </summary>
|
||
public static Check NorthLocked(string id, string name, float[,] a, float[,] b, int mapSize, int bandRow)
|
||
{
|
||
var c = new Check { Id = id, Name = name };
|
||
long northDiff = 0, southDiff = 0, north = 0; string first = null;
|
||
for (int x = 0; x < mapSize; x++)
|
||
for (int y = 0; y < mapSize; y++)
|
||
{
|
||
bool same = BitConverter.SingleToInt32Bits(a[x, y]) == BitConverter.SingleToInt32Bits(b[x, y]);
|
||
if (y < bandRow) { north++; if (!same) { northDiff++; first ??= $"[{x},{y}] {a[x, y]:G9} vs {b[x, y]:G9}"; } }
|
||
else if (!same) southDiff++;
|
||
}
|
||
c.Passed = northDiff == 0;
|
||
c.Detail = c.Passed
|
||
? $"all {north:N0} cells north of row {bandRow} bit-identical; {southDiff:N0} cells differ in the band or below (the relaxation)"
|
||
: $"{northDiff:N0} cells north of row {bandRow} DIFFER — {first}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>(q) ⭐ NORTHERN ISLANDS INVARIANT — the multiset of north-hemisphere island components (size, centroid) is identical between two labelings.</summary>
|
||
public static Check NorthIslandsInvariant(string name, RegionLabels a, RegionLabels b)
|
||
{
|
||
var c = new Check { Id = "q", Name = name };
|
||
if (a == null || b == null) { c.Passed = false; c.Detail = "no region labeling"; return c; }
|
||
var sa = NorthSet(a); var sb = NorthSet(b);
|
||
bool same = sa.Count == sb.Count;
|
||
if (same) for (int i = 0; i < sa.Count; i++) if (sa[i] != sb[i]) { same = false; break; }
|
||
c.Passed = same;
|
||
c.Detail = same ? $"{sa.Count} northern islands, identical (size + centroid)" : $"DIFFER — {sa.Count} vs {sb.Count} northern islands, or a size/centroid moved";
|
||
return c;
|
||
}
|
||
|
||
private static List<string> NorthSet(RegionLabels l)
|
||
{
|
||
var list = new List<string>();
|
||
foreach (var r in l.Regions)
|
||
if (!r.IsMainland && r.Hemisphere == RegionLabeling.HemiNorth)
|
||
list.Add($"{r.SizeCells}:{r.CentroidX:F3}:{r.CentroidY:F3}");
|
||
list.Sort(StringComparer.Ordinal);
|
||
return list;
|
||
}
|
||
|
||
// ═══ chat2/09 — the coastal-fragmentation checks ═══
|
||
|
||
/// <summary>
|
||
/// (r) ⭐ INTERIOR LOCKED — every cell whose BASELINE pre-trench falloff is clear of the coastal
|
||
/// window (weight exactly 0: the interior, the massif, the deep sea) is bit-identical between the
|
||
/// baseline and the fragmented field. Reports how many cells changed inside the window (the
|
||
/// coast, allowed). The proof that fragmentation cannot reach inland.
|
||
/// </summary>
|
||
public static Check InteriorLocked(Pass1Result baseline, Pass1Result frag, float centre, float halfWidth)
|
||
{
|
||
var c = new Check { Id = "r", Name = "interior locked — every cell clear of the coastal window bit-identical (classify)" };
|
||
int n = baseline.MapSize; long outside = 0, outsideDiff = 0, inside = 0, insideDiff = 0; string first = null;
|
||
for (int x = 0; x < n; x++)
|
||
for (int y = 0; y < n; y++)
|
||
{
|
||
bool inWindow = MathF.Abs(baseline.PreTrenchFalloff[x, y] - centre) < halfWidth;
|
||
bool same = BitConverter.SingleToInt32Bits(baseline.Height[x, y]) == BitConverter.SingleToInt32Bits(frag.Height[x, y]);
|
||
if (inWindow) { inside++; if (!same) insideDiff++; }
|
||
else { outside++; if (!same) { outsideDiff++; first ??= $"[{x},{y}] f {baseline.PreTrenchFalloff[x, y]:F3}: {baseline.Height[x, y]:G9} → {frag.Height[x, y]:G9}"; } }
|
||
}
|
||
c.Passed = outsideDiff == 0;
|
||
c.Detail = c.Passed
|
||
? $"all {outside:N0} cells outside the window bit-identical; {insideDiff:N0} of {inside:N0} window cells changed (the coast)"
|
||
: $"{outsideDiff:N0} cells OUTSIDE the window changed — {first}";
|
||
return c;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (s) informational — HIGH GROUND: of the cells whose BASELINE raw height is at or above
|
||
/// <paramref name="rawThreshold"/>, how many changed, and the largest change. A coastal hill
|
||
/// inside the window may legitimately move in height without flipping; this reports it.
|
||
/// </summary>
|
||
public static Check HighGroundReport(Pass1Result baseline, Pass1Result frag, float rawThreshold, string thresholdLabel)
|
||
{
|
||
var c = new Check { Id = "s", Name = $"(informational) high ground ≥ {thresholdLabel}: cells changed / largest |Δ|", Passed = true };
|
||
int n = baseline.MapSize; long high = 0, changed = 0; float maxAbs = 0f;
|
||
for (int x = 0; x < n; x++)
|
||
for (int y = 0; y < n; y++)
|
||
{
|
||
float a = baseline.Height[x, y]; if (a < rawThreshold) continue;
|
||
high++;
|
||
float b = frag.Height[x, y];
|
||
if (a != b) { changed++; float d = MathF.Abs(b - a); if (d > maxAbs) maxAbs = d; }
|
||
}
|
||
c.Detail = $"{changed:N0} of {high:N0} high cells changed; largest |Δ| {maxAbs:G4} raw ({Core.WorldScale.MetresFromRaw(maxAbs):F1} m)";
|
||
return c;
|
||
}
|
||
|
||
// ═══ ⭐⭐ ANCHOR RESOLUTION — A MISSING ANCHOR IS LOUD (rivers/01) ═══════════════════════════
|
||
//
|
||
// ═══ THE FAILURE MODE THIS CLOSES ═══
|
||
//
|
||
// Every anchor check used to be written as:
|
||
//
|
||
// if (File.Exists(dump) && mapSize == 8192) hard.Add(DumpRegression(...));
|
||
// else GD.Print(" ⚠ skipped — no dump at …");
|
||
//
|
||
// so a moved, renamed or deleted anchor did not make the oracle FAIL. It made the oracle
|
||
// NOT RUN — and a batch with a silently-skipped check prints an all-PASS table and reads
|
||
// exactly like a clean one. The `INDEX.md` is then evidence for a claim nothing checked.
|
||
//
|
||
// > ### ⚠ This is the INVERSE of the hazard the re-baseline guards against.
|
||
// > The re-baseline stops an oracle PASSING FOR THE WRONG REASON. This stops one
|
||
// > DISAPPEARING FOR NO REASON. Both end with a green table and an unproven claim, and the
|
||
// > rivers/01 batch-root migration is exactly the event that would have triggered the second
|
||
// > one — nine anchors moved under `<chat>/` in a single commit.
|
||
//
|
||
// The two cases the old code conflated are now separated, in ONE place (`LoadAnchor`) so every
|
||
// anchor site behaves identically:
|
||
// MISSING FILE the anchor moved, was renamed, or was never written. THROWS — the check
|
||
// cannot run, and dropping it quietly is the failure this exists to close.
|
||
// WRONG SIZE the anchor exists, but only at the size it was captured. A legitimate case
|
||
// (a probe run at another size); reported loudly and recorded INCONCLUSIVE,
|
||
// which is a FAIL in the table. ⚠ Deliberately NOT a throw: a guard that fires
|
||
// on ordinary small-map probe work is a guard people learn to route around.
|
||
|
||
/// <summary>
|
||
/// ⭐ Load a regression anchor's `.f32`, separating the two failures the old code conflated.
|
||
///
|
||
/// FILE ABSENT → THROWS. The anchor moved, was renamed, or was never written.
|
||
/// This is the migration hazard, and it is not survivable: the
|
||
/// check cannot run and must not be quietly dropped.
|
||
/// PRESENT, WRONG SIZE → returns null, LOUDLY. A legitimate case — an anchor exists only
|
||
/// at the size it was captured, and a batch run at another size
|
||
/// genuinely cannot check against it. The caller's
|
||
/// <see cref="DumpRegression"/> records it INCONCLUSIVE, which is
|
||
/// a FAIL in the table, never a pass.
|
||
///
|
||
/// ⚠ The distinction matters because only ONE of them means something is broken. Throwing on a
|
||
/// size mismatch would make every small-map probe run refuse, and a guard that fires on ordinary
|
||
/// work is a guard people route around.
|
||
/// </summary>
|
||
/// <param name="checkId">The oracle check this anchor feeds, e.g. "a10" — named in the message.</param>
|
||
/// <param name="envVar">The env override that can re-point it, e.g. "ISLA_T10_SOURCE".</param>
|
||
/// <param name="dumpPath">The resolved absolute path.</param>
|
||
/// <param name="mapSize">The field size to read.</param>
|
||
public static float[,] LoadAnchor(string checkId, string envVar, string dumpPath, int mapSize)
|
||
{
|
||
if (!System.IO.File.Exists(dumpPath))
|
||
throw new InvalidOperationException(
|
||
$"[Oracle] MISSING ANCHOR for check '{checkId}' — nothing at:\n" +
|
||
$" {dumpPath}\n" +
|
||
"An oracle whose anchor is absent does not fail, it does not RUN — and a batch with a " +
|
||
"silently-skipped check prints an all-PASS table that reads exactly like a clean one. " +
|
||
"Refusing to render evidence for a claim nothing checked.\n" +
|
||
$"→ Re-point it with {envVar}, or regenerate the anchor. If the anchor is genuinely " +
|
||
"retired, DELETE THE CHECK — never leave one aimed at nothing. (rivers/01.)");
|
||
|
||
long expected = (long)mapSize * mapSize * 4;
|
||
long actual = new System.IO.FileInfo(dumpPath).Length;
|
||
if (actual != expected)
|
||
{
|
||
int anchorSize = (int)System.Math.Round(System.Math.Sqrt(actual / 4.0));
|
||
Godot.GD.PrintErr(
|
||
$" ⚠⚠ {checkId}: NOT CHECKED — the anchor exists but was captured at {anchorSize}, " +
|
||
$"and this run is at {mapSize} ({actual:N0} bytes, expected {expected:N0}). This is a size " +
|
||
$"mismatch, NOT a missing file: the batch is simply not proven against it at this size. " +
|
||
$"Run at {anchorSize} to check it. The oracle records INCONCLUSIVE, which is a FAIL — never a pass.");
|
||
return null;
|
||
}
|
||
|
||
return HeightField.Load(dumpPath, mapSize);
|
||
}
|
||
|
||
/// <summary>Render the whole oracle as a markdown table for the INDEX and the report.</summary>
|
||
public static string ToMarkdownTable(IEnumerable<Check> checks)
|
||
{
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.AppendLine("| | Check | Result | Evidence |");
|
||
sb.AppendLine("|---|---|---|---|");
|
||
foreach (Check c in checks)
|
||
sb.AppendLine($"| `{c.Id}` | {c.Name} | **{(c.Passed ? "PASS" : "FAIL")}** | {c.Detail} |");
|
||
return sb.ToString();
|
||
}
|
||
}
|
||
}
|