islaApocalypse-v2/Core/Scripts/ContinuousCurve.cs
beezm 8e55326a84 Phase 2a: continuous grade — smooth the upper staircase, preserve the lowlands
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
2026-08-20 03:41:27 -04:00

418 lines
19 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.Text;
namespace IslaApocalypse.Core
{
/// <summary>
/// ⭐⭐ THE CONTINUOUS-GRADE CURVE (chat2/02) — smooth the UPPER staircase, preserve the lowlands.
///
/// ═══ WHAT THIS IS, AND WHAT IT REFUSES TO BE ═══
///
/// The faithful v5 staircase (<see cref="HeightCurve"/>) terraces the island above the flood
/// tiers: foothill riser → bench → mid riser → plateau → summit needle. The developer's verdict
/// on the 01 baseline: the LOWLANDS ARE GOOD — the broad low plain, ~75 % of land below 30 m, is
/// the thing to keep. The fault is entirely ABOVE them: flat benches read as authored terraces
/// and the summit reads as a needle on a hump.
///
/// So this curve is PIECEWISE, and the pieces have different loyalties:
///
/// raw ≤ SEA IDENTITY. The coastline must not move. (Same line as v5.)
/// SEA &lt; raw ≤ K2 ⭐ THE STAIRCASE'S OWN toe+red mapping, BY DELEGATION — the same
/// code path, so the lowland output is BIT-IDENTICAL to task 01's.
/// Not "equivalent": the same floats. Oracle (d) holds this.
/// K2 &lt; raw ≤ ceilingRaw the red band's exit slope, CONTINUED LINEARLY — only non-empty
/// when the ceiling is raised above the default 30 m, extending the
/// current gentle low grade before the climb begins.
/// ceilingRaw &lt; raw ≤ spikeMax
/// ⭐ THE NEW CLIMB — one smooth monotone FritschCarlson (PCHIP)
/// spline from the lowland ceiling to PEAK_CAP. No bench, no
/// plateau, no needle: a coherent massif steepening to a peak.
/// raw &gt; spikeMax the gentle tail, as v5: PEAK_CAP + (raw spikeMax) · TAIL_SLOPE.
///
/// ═══ ⚠⚠ WHAT IS DELIBERATELY DROPPED, AND WHAT DELIBERATELY SURVIVES ═══
///
/// DROPPED: <c>BENCH_BASE/AMP</c>, <c>PLATEAU_BASE/AMP</c>, <c>SHELF_SPAN_*</c> and their three
/// modulation noise fields — the above-flood decorative terracing. That is the entire point of
/// this mode.
///
/// SURVIVES: <c>SEA</c>, <c>ORANGE_CEIL</c> (14 m) and <c>RED_CEIL</c> (30 m), because they are
/// the STORM-LADDER FLOOD TIERS and they live inside the preserved lowland — D-036's
/// terrain-shelves-at-flood-tiers is intact where it carries meaning. <c>PEAK_CAP</c> (420 m)
/// survives as the summit ceiling, with the per-seed <c>spikeMax</c> normalization unchanged.
///
/// ═══ ⚠ MONOTONE BY CONSTRUCTION — WHY THE 24-CORNER SWEEP RETIRES HERE ═══
///
/// FritschCarlson tangent limiting guarantees a monotone interpolant for ANY monotone control
/// points: every tangent is clamped into the region where the Hermite cubic cannot overshoot.
/// The staircase needed a numeric sweep because its effective shape depended on three modulation
/// fields and a per-column warp; this curve has no per-column inputs at all — one spline per
/// seed. <see cref="AssertStrictlyIncreasing"/> still runs a cheap dense sample per seed,
/// because "cannot fail" is exactly the claim worth spending a millisecond checking.
///
/// ═══ THE TWO KNOBS (plus the ceiling) — ALL ACT ABOVE THE CEILING ONLY ═══
///
/// lowlandCeiling where the preserved low grade hands over to the climb (config, metres;
/// default 30 = RED_CEIL, i.e. the flood line — hand over exactly where the
/// staircase's lowland ends).
/// climbFeather how long the climb hugs the lowland's exit slope before steepening.
/// summitDrama how hard the top ~15 % steepens, so the peak reads pointy, not a ramp.
///
/// ⚠ NO CONTROL POINT MAY ACT AS A MAGNET. The generator enforces strictly INCREASING segment
/// secants below the summit: mass can never pile at an interior point the way it piled at the
/// bench, because no interval maps wide-in to narrow-out below the summit onset.
///
/// Engine-free (System.MathF), beside <see cref="HeightCurve"/> — the two modes are one seam.
/// </summary>
public sealed class ContinuousCurve
{
// ═══ shape constants (not config — the knobs above are the config surface) ═══
/// <summary>Where "the summit" begins, as a fraction of the climb's raw span. The top 15 %.</summary>
public const float SummitOnset = 0.85f;
/// <summary>
/// The ceiling knob's hard bound, metres. The bench sat at 100±12 m; a lowland ceiling at or
/// above it could preserve a flat bench, which is the one thing this mode exists to remove.
/// 80 m keeps clear air below the old bench's lowest excursion (88 m).
/// </summary>
public const float MaxLowlandCeilingM = 80f;
/// <summary>
/// Oracle (e) tripwires, in NORMALIZED climb slope (1 = the climb's average grade).
/// Floor: a slope this far below the join slope reads as a bench — the artifact this mode
/// removes. Ceiling: a slope this steep below the summit onset reads as a cliff.
/// Warn-and-report, not throw — this is an exploration batch.
/// </summary>
public const float NearFlatFactor = 0.25f; // × the normalized join slope
public const float CliffCeilingN = 3.5f;
// ═══ the built spline ═══
/// <summary>The knot set — only K1/K2 are consumed (the preserved toe+red).</summary>
public readonly CurveKnots Knots;
/// <summary>The anchors — Sea/Orange/Red/PeakCap/TailSlope consumed; bench/plateau ignored.</summary>
public readonly CurveAnchors Anchors;
/// <summary>Raw height where the preserved lowland hands over to the climb.</summary>
public readonly float CeilingRaw;
/// <summary>Output height at the handover — the top of the preserved lowland.</summary>
public readonly float CeilingOut;
/// <summary>This seed's raw summit: <c>EffectiveSpikeMax(hMaxSeed)</c>. The climb's right edge.</summary>
public readonly float SpikeMax;
/// <summary>The red band's exit slope — the climb's C¹ join tangent (raw out per raw in).</summary>
public readonly float JoinSlopeRaw;
/// <summary>The knob values this spline was built from, for the INDEX and the report.</summary>
public readonly float LowlandCeilingM, ClimbFeather, SummitDrama;
// Control points (raw x, out y) and the FritschCarlson tangents. x strictly increasing.
private readonly float[] _x, _y, _m;
private ContinuousCurve(CurveKnots k, CurveAnchors a, float ceilingRaw, float ceilingOut,
float spikeMax, float joinSlopeRaw, float lowlandCeilingM, float climbFeather,
float summitDrama, float[] x, float[] y, float[] m)
{
Knots = k; Anchors = a;
CeilingRaw = ceilingRaw; CeilingOut = ceilingOut; SpikeMax = spikeMax;
JoinSlopeRaw = joinSlopeRaw;
LowlandCeilingM = lowlandCeilingM; ClimbFeather = climbFeather; SummitDrama = summitDrama;
_x = x; _y = y; _m = m;
}
/// <summary>
/// Build the per-seed spline. ⚠ PER SEED, because <paramref name="spikeMax"/> is per seed —
/// exactly the same reason the staircase's monotonicity sweep ran per seed.
///
/// Throws (refusing the generation) on any configuration that cannot produce the target
/// silhouette: a ceiling at bench height, a drama that would fold the summit under its own
/// onset, a ceiling above the seed's summit.
/// </summary>
public static ContinuousCurve Build(CurveKnots k, CurveAnchors a, float spikeMax,
float lowlandCeilingM, float climbFeather, float summitDrama)
{
// ---- the preserved lowland's edge ----
float redSlope = (a.RedCeil - a.OrangeCeil) / (k.K2 - k.K1);
if (lowlandCeilingM > MaxLowlandCeilingM)
throw new InvalidOperationException(
$"[ContinuousCurve] lowlandCeiling {lowlandCeilingM:F1} m is above the {MaxLowlandCeilingM:F0} m " +
"bound — close enough to the old bench (100±12 m) to preserve a flat one, which is the " +
"artifact this mode exists to remove. Refusing.");
// ⚠ THE FLOOD LINE IS THE FLOOR, and "30 m" is NOMINAL: RED_CEIL SEA = 0.12 raw is
// actually 30.12 m through the yardstick. Any requested ceiling at or below the red
// ceiling means "hand over exactly where the preserved lowland ends", and that handover
// is pinned to THE EXACT ANCHORS — (K2, RED_CEIL), no derived floats — so the extension
// region is empty by construction and the toe+red band can never be cut. (The first
// probe run refused its own default over this 0.12 m nominal gap; pinning is the fix,
// not widening a tolerance.)
float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
float ceilingOut, ceilingRaw;
if (lowlandCeilingM <= redCeilM + 0.01f)
{
ceilingOut = a.RedCeil;
ceilingRaw = k.K2;
}
else
{
ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
// Where the linear red-slope extension reaches that output.
ceilingRaw = k.K2 + (ceilingOut - a.RedCeil) / redSlope;
}
if (ceilingRaw >= spikeMax - 1e-3f)
throw new InvalidOperationException(
$"[ContinuousCurve] lowland ceiling (raw {ceilingRaw:F4}) reaches this seed's summit " +
$"(spikeMax {spikeMax:F4}) — no room for a climb. Refusing.");
if (climbFeather < 0f || climbFeather > 1f)
throw new InvalidOperationException($"[ContinuousCurve] climbFeather {climbFeather} is outside [0,1]. Refusing.");
if (summitDrama < 1f)
throw new InvalidOperationException($"[ContinuousCurve] summitDrama {summitDrama} < 1 would make the summit SHALLOWER than the climb's average — that is a ramp, not a peak. Refusing.");
// ---- control points, in normalized climb space ----
// u = (raw ceilingRaw)/(spikeMax ceilingRaw), v = (out ceilingOut)/(PeakCap ceilingOut).
float spanRaw = spikeMax - ceilingRaw;
float spanOut = a.PeakCap - ceilingOut;
float s0 = redSlope * spanRaw / spanOut; // the join slope, normalized
// The feather point: hug the join slope until u_f, then lift. Larger feather = longer hug.
float uF = 0.20f + 0.35f * climbFeather;
float vF = s0 * uF * 1.05f; // fractionally above the pure hug, so
// the secant already rises — no dip
// The summit onset: its secant to (1,1) IS the drama. v_s = 1 drama·(1 u_s).
float uS = SummitOnset;
float vS = 1f - summitDrama * (1f - uS);
if (vS <= vF + 0.02f)
throw new InvalidOperationException(
$"[ContinuousCurve] summitDrama {summitDrama:F2} folds the summit onset (v={vS:F3}) " +
$"under the feather point (v={vF:F3}) — the mid-climb would have to be flat or " +
"descending to compensate. Lower the drama or the feather. Refusing.");
// A mid point keeps the feather→onset transition smooth, on a gently convex path so the
// segment secants stay strictly INCREASING — the no-magnet guarantee.
float uM = (uF + uS) * 0.5f;
float vM = vF + (vS - vF) * MathF.Pow((uM - uF) / (uS - uF), 1.35f);
float[] u = { 0f, uF, uM, uS, 1f };
float[] v = { 0f, vF, vM, vS, 1f };
// ⚠ THE NO-MAGNET CHECK, enforced rather than assumed: every secant below the summit
// must be strictly greater than the one before it. A wide-in→narrow-out interval below
// the onset is a bench in the making.
float prevSecant = 0f;
for (int i = 1; i < u.Length; i++)
{
float sec = (v[i] - v[i - 1]) / (u[i] - u[i - 1]);
if (sec <= prevSecant)
throw new InvalidOperationException(
$"[ContinuousCurve] control-point secants are not strictly increasing at segment {i} " +
$"({sec:F4} after {prevSecant:F4}) with feather={climbFeather:F2}, drama={summitDrama:F2} — " +
"an interior point would act as a magnet. Refusing.");
prevSecant = sec;
}
// ---- denormalize and fit ----
int n = u.Length;
var x = new float[n];
var y = new float[n];
for (int i = 0; i < n; i++)
{
x[i] = ceilingRaw + u[i] * spanRaw;
y[i] = ceilingOut + v[i] * spanOut;
}
float[] m = FritschCarlsonTangents(x, y, startTangent: redSlope);
return new ContinuousCurve(k, a, ceilingRaw, ceilingOut, spikeMax, redSlope,
lowlandCeilingM, climbFeather, summitDrama, x, y, m);
}
/// <summary>
/// FritschCarlson (1980) monotone tangents, with a PRESCRIBED start tangent for the C¹
/// join. The weighted-harmonic-mean interior tangents already satisfy the monotonicity
/// region; the prescribed start is clamped into <c>[0, 3·Δ₀]</c>, which is the classical
/// sufficient bound — so the join is C¹ wherever the lowland's exit slope permits, and
/// safely limited where it does not (which is then reported by the slope sampler, not
/// hidden).
/// </summary>
private static float[] FritschCarlsonTangents(float[] x, float[] y, float startTangent)
{
int n = x.Length;
var h = new float[n - 1]; // interval widths
var d = new float[n - 1]; // secants
for (int i = 0; i < n - 1; i++)
{
h[i] = x[i + 1] - x[i];
d[i] = (y[i + 1] - y[i]) / h[i];
}
var m = new float[n];
// Start: the C¹ join, clamped into the monotone region.
m[0] = Math.Clamp(startTangent, 0f, 3f * d[0]);
// Interior: weighted harmonic mean — zero if the secants disagree in sign (they cannot
// here, both positive, but the guard is the algorithm's own and stays).
for (int i = 1; i < n - 1; i++)
{
if (d[i - 1] * d[i] <= 0f) { m[i] = 0f; continue; }
float w1 = 2f * h[i] + h[i - 1];
float w2 = h[i] + 2f * h[i - 1];
m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]);
}
// End: one-sided three-point estimate, clamped like the start. The summit's entry
// steepness comes from the last secant (the drama), not from an extrapolated spike.
float mEnd = ((2f * h[n - 2] + (n > 2 ? h[n - 3] : h[n - 2])) * d[n - 2]
- h[n - 2] * (n > 2 ? d[n - 3] : d[n - 2]))
/ (h[n - 2] + (n > 2 ? h[n - 3] : h[n - 2]));
if (mEnd < 0f) mEnd = 0f;
m[n - 1] = MathF.Min(mEnd, 3f * d[n - 2]);
return m;
}
/// <summary>
/// The curve, for one column. Handles every range: sea identity, the preserved lowland
/// (BY DELEGATION to <see cref="HeightCurve.Apply"/> — the same code path, hence the same
/// bits), the linear extension, the climb, the tail.
/// </summary>
public float Apply(float h)
{
// ⭐ IDENTITY AT AND BELOW SEA — the same load-bearing line as v5.
if (h <= Anchors.Sea) return h;
// ⭐ THE PRESERVED LOWLAND: delegate to the staircase's own toe+red branches. Below K2,
// HeightCurve.Apply never reads the bench/plateau/edge parameters, so any values pass —
// and the output is bit-identical to task 01's staircase, which oracle (d) asserts.
if (h < Knots.K2)
return HeightCurve.Apply(h, SpikeMax,
Anchors.BenchBase, Anchors.ShelfSpanMin, Anchors.PlateauBase, Anchors.ShelfSpanMin,
Knots, Anchors, edgeShift: 0f);
// The red band's grade, continued. Empty at the default ceiling (CeilingRaw == K2);
// at h == K2 exactly this is RED_CEIL + 0 — the same value the staircase's foothill
// riser produces at its own u = 0.
if (h <= CeilingRaw)
return Anchors.RedCeil + (h - Knots.K2) * JoinSlopeRaw;
// The gentle tail, as v5 — a slope, not a clip.
if (h >= SpikeMax)
return Anchors.PeakCap + (h - SpikeMax) * Anchors.TailSlope;
// ⭐ THE CLIMB: cubic Hermite on the FritschCarlson tangents.
int i = FindInterval(h);
float dx = _x[i + 1] - _x[i];
float t = (h - _x[i]) / dx;
float t2 = t * t, t3 = t2 * t;
return (2f * t3 - 3f * t2 + 1f) * _y[i]
+ (t3 - 2f * t2 + t) * dx * _m[i]
+ (-2f * t3 + 3f * t2) * _y[i + 1]
+ (t3 - t2) * dx * _m[i + 1];
}
/// <summary>The climb's derivative at a raw height inside (CeilingRaw, SpikeMax).</summary>
public float SlopeAt(float h)
{
if (h <= CeilingRaw || h >= SpikeMax) return JoinSlopeRaw; // outside the spline proper
int i = FindInterval(h);
float dx = _x[i + 1] - _x[i];
float t = (h - _x[i]) / dx;
float t2 = t * t;
return (6f * t2 - 6f * t) * (_y[i] - _y[i + 1]) / dx
+ (3f * t2 - 4f * t + 1f) * _m[i]
+ (3f * t2 - 2f * t) * _m[i + 1];
}
private int FindInterval(float h)
{
// Four intervals — a linear scan beats a binary search at this size.
for (int i = _x.Length - 2; i > 0; i--)
if (h >= _x[i]) return i;
return 0;
}
/// <summary>
/// The cheap per-seed proof that "monotone by construction" held in float32 too: a dense
/// strict-increase sample over the whole range, sea to past the tail. Throws and refuses on
/// violation, exactly as the staircase's sweep did. ~10k samples, sub-millisecond.
/// </summary>
/// <returns>A one-line confirmation for the run log.</returns>
public string AssertStrictlyIncreasing()
{
float prevH = Anchors.Sea;
float prev = Apply(prevH);
double top = SpikeMax + 0.5;
double step = (top - Anchors.Sea) / 10000.0;
for (double hd = Anchors.Sea + step; hd <= top; hd += step)
{
float h = (float)hd;
if (h <= prevH) continue; // float32 dedupe, as the staircase's sweep
float v = Apply(h);
if (v <= prev)
throw new InvalidOperationException(
$"[ContinuousCurve] MONOTONICITY VIOLATION at h={h}: {v} <= {prev} " +
$"(ceiling {LowlandCeilingM:F0} m, feather {ClimbFeather:F2}, drama {SummitDrama:F2}). Refusing to generate.");
prev = v;
prevH = h;
}
return $"[ContinuousCurve] strict-increase sample passed (10k points, ceiling {LowlandCeilingM:F0} m, " +
$"feather {ClimbFeather:F2}, drama {SummitDrama:F2}, spikeMax {SpikeMax:F6}).";
}
/// <summary>
/// Oracle (e)'s instrument: sample the climb's slope densely and report it in NORMALIZED
/// units (1 = the climb's average grade). Returns the extremes and where they sit, plus the
/// tripwire verdicts — the caller decides how loudly to say it.
/// </summary>
public (float minN, float minAtRaw, float maxBelowOnsetN, float maxAtRaw, bool nearFlat, bool cliff)
SampleClimbSlopes(int samples = 2000)
{
float spanRaw = SpikeMax - CeilingRaw;
float spanOut = Anchors.PeakCap - CeilingOut;
float toN = spanRaw / spanOut; // raw slope → normalized
float onsetRaw = CeilingRaw + SummitOnset * spanRaw;
float s0N = JoinSlopeRaw * toN;
float minN = float.MaxValue, maxN = float.MinValue, minAt = 0f, maxAt = 0f;
for (int i = 1; i < samples; i++)
{
float h = CeilingRaw + spanRaw * i / samples;
float sN = SlopeAt(h) * toN;
if (sN < minN) { minN = sN; minAt = h; }
if (h < onsetRaw && sN > maxN) { maxN = sN; maxAt = h; }
}
bool nearFlat = minN < s0N * NearFlatFactor;
bool cliff = maxN > CliffCeilingN;
return (minN, minAt, maxN, maxAt, nearFlat, cliff);
}
/// <summary>Raw height where the summit onset sits, and its output — for histogram overlays.</summary>
public (float raw, float outp) SummitOnsetPoint()
{
float r = CeilingRaw + SummitOnset * (SpikeMax - CeilingRaw);
return (r, Apply(r));
}
/// <summary>The control points as one line for the INDEX and the report.</summary>
public string DescribeControlPoints()
{
var sb = new StringBuilder();
sb.Append($"ceiling {LowlandCeilingM:F0}m feather {ClimbFeather:F2} drama {SummitDrama:F2} · points ");
for (int i = 0; i < _x.Length; i++)
sb.Append($"({_x[i]:F4},{_y[i]:F4}{(i == 0 ? " C1" : "")}) ");
sb.Append($"· join slope {JoinSlopeRaw:F4} raw");
return sb.ToString();
}
}
}