rev 3 of the curve redesign. The developer's verdict on the 01 baseline was that the LOWLANDS ARE GOOD; the fault is the terracing above them. So this adds a second curve mode that preserves the low plain bit-for-bit and replaces everything above the flood line with one smooth monotone climb. Core/ContinuousCurve — piecewise, and the pieces have different loyalties: - at/below sea: identity, as ever. - above sea to K2: DELEGATES to HeightCurve's own toe+red branches. Not "equivalent" — the same code path, so the same floats. Oracle (d) holds it to that. - above the ceiling: a Fritsch-Carlson (PCHIP) monotone spline to the 420 m cap, C1-joined to the red band's exit slope. Monotone by construction for any ordered control points, which retires the 24-corner sweep; a 10k strict-increase sample runs per seed anyway, because "cannot fail" is worth a millisecond. - Build() REFUSES rather than degrades: a ceiling near the old bench, a drama that folds the summit under its own onset, control-point secants that are not strictly increasing (the no-magnet rule, enforced rather than hoped for). Only BENCH_*/PLATEAU_* are dropped. SEA/ORANGE_CEIL/RED_CEIL survive because they are the storm-ladder FLOOD TIERS and they live inside the preserved lowland; PEAK_CAP and the per-seed spikeMax normalization survive as the summit. Shelf detail is forced off in continuous mode: the flat benches it de-slabbed no longer exist, and painting noise on the climb now would pre-judge what erosion should carve. Oracle, all hard checks passing: - (a1) curve off is bit-identical to Phase 1's dump. - (a2) staircase mode is bit-identical to TASK 01's dump — the control is provably the control, not a re-derivation. (CurveBaselineTool is pinned to Staircase so the config default moving to Continuous cannot drift it.) - (d) lowlands bit-identical to the staircase over 3.6M cells, every continuous variant, both seeds. The lifted_WRONG bookend fails it on 1.6M cells, as intended. - (f) sea identity per CELL, not per count, including 67M cells at 8192. The finding, measured and recorded in the batch scratch: the massif SHRANK. Land above 100 m goes 14.9% -> 4.8%, above 220 m 4.5% -> 0.6%. A feather sweep to the practical floor recovers ~1.3 points, so this is structural, not a tuning miss: the staircase's highland area was an artifact of the bench and plateau acting as magnets, and a curve with no magnets preserves the raw distribution's bottom-heavy shape. "No terraces" and "the same land up high" are not both available from curve work alone. Exploration batch, not convergence. A tuning pass follows once a direction is picked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
363 lines
14 KiB
C#
363 lines
14 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>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();
|
||
}
|
||
}
|
||
}
|