chat2/02 dissolved the terraces and lost two thirds of the mountain with them, then
concluded the loss was structural and needed a Phase-1 noise change. That conclusion
was wrong, and this commit is the refutation.
A monotone curve is a free reparametrization: it may be gentle in one place and steep
in another, and can lift bottom-heavy input into a substantial massif without ever
going flat. The area above a height is set by where the percentile->height mapping
crosses it, and that mapping is entirely ours to choose. The 02 sweep that "proved"
the loss structural varied climbFeather, which shapes the JOIN, not the mass
distribution — the wrong knob, and too strong a conclusion drawn from it.
Core/ClimbCalibration — the climb's control points are now MEASURED off the staircase
instead of invented from shape knobs. For p in {10,30,50,70,85,95} of above-ceiling
land, take that percentile's raw height and its staircase output height; PCHIP through
the pairs. That reproduces the staircase's elevation envelope, so the mountain mass
returns, while MinNormalizedSecant floors every grade so the flat bench and plateau
interiors become slope. The floor bites on exactly one segment — the plateau — which
is precisely where the staircase was flat.
ContinuousCurve.BuildCalibrated joins it to the same pinned lowland handover, the same
C1 join and the same per-seed spikeMax. The 02 analytic path survives unchanged as the
"before" contrast, and deliberately keeps its strictly-increasing-secant rule: a
calibrated curve is WAVY by design, so convexity is the wrong invariant for it and the
secant floor is the right one.
peakSharpness replaces summitDrama and fixes its bad trade. Drama steepened the peak by
pulling the summit ONSET down, dragging the whole massif with it (p99 199 -> 121 m).
Sharpness reshapes only above the last measured percentile, leaving that height fixed,
so peak and massif are independent: raising it leaves p90, >100 m and >220 m untouched
and only moves land within the summit.
Measured, both seeds, 2048:
variant >100 m >220 m p90
staircase (target) 16.05% 4.53% 127.4 m
continuous_02default 4.80% 0.63% 58.4 m
continuous_restored 14.25% 3.45% 123.2 m
continuous_bigger 19.70% 5.80% 163.5 m
Oracle all hard checks pass, including (a2) staircase still bit-identical to task 01's
dump and (d) lowlands bit-identical across every calibrated variant. New (g) reports
land above 100/220 m per variant and is deliberately NOT gated — it is a taste target
the developer tunes, and gating it would make mountainLift unusable. What it must never
do is stay silent, which is how 02 lost the mountain unnoticed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
300 lines
13 KiB
C#
300 lines
13 KiB
C#
using System;
|
|
using System.Text;
|
|
|
|
namespace IslaApocalypse.Core
|
|
{
|
|
/// <summary>
|
|
/// ⭐⭐ THE CLIMB'S CONTROL POINTS, MEASURED FROM THE STAIRCASE (chat2/03) — "the staircase's
|
|
/// mountain with the terraces melted out".
|
|
///
|
|
/// ═══ THE MISTAKE THIS TYPE CORRECTS ═══
|
|
///
|
|
/// chat2/02 built the climb from ANALYTIC control points (a feather and a drama knob) and got a
|
|
/// bottom-heavy curve: land above 100 m fell from ~15 % to ~4.8 %. That report concluded the loss
|
|
/// was STRUCTURAL — that a no-magnet monotone curve must preserve the raw distribution's
|
|
/// bottom-heavy shape, so only a Phase-1 noise change could restore the mountain.
|
|
///
|
|
/// > ### ⚠ THAT CONCLUSION WAS WRONG, AND THIS TYPE IS THE PROOF.
|
|
/// >
|
|
/// > A monotone curve is a free reparametrization: it may be gentle in one place and steep in
|
|
/// > another, and can LIFT bottom-heavy input into a substantial mid-massif without ever going
|
|
/// > flat. **No-flats and lift-the-mass are compatible.** The area of land above a given height
|
|
/// > is set by where the percentile→height mapping CROSSES that height, and that mapping is
|
|
/// > entirely ours to choose.
|
|
/// >
|
|
/// > The 02 sweep that "proved" the loss structural varied <c>climbFeather</c>, which shapes the
|
|
/// > JOIN, not the mass distribution. It was the wrong knob, and the conclusion generalized from
|
|
/// > it was too strong.
|
|
///
|
|
/// ═══ WHAT THE STAIRCASE'S BENCHES ACTUALLY DID ═══
|
|
///
|
|
/// They did not CREATE highland. They LIFTED land to 100 m and 220 m. The same ~27 % of land
|
|
/// above the ceiling exists in both curves; 02's analytic climb simply placed it low. So the fix
|
|
/// is not to make more high land — it is to put the land that is already there back where the
|
|
/// staircase had it, as a smooth slope.
|
|
///
|
|
/// ═══ THE METHOD — the same percentile idea as task 01's knots, one level up ═══
|
|
///
|
|
/// Task 01 measured percentiles of the raw distribution to place the curve's INPUT knots. This
|
|
/// measures percentiles of the staircase's ABOVE-CEILING land to place the climb's OUTPUT
|
|
/// heights:
|
|
///
|
|
/// for each p in {10, 30, 50, 70, 85, 95}:
|
|
/// u_p = normalized RAW position of above-ceiling land at percentile p
|
|
/// v_p = normalized OUTPUT height of above-ceiling land at percentile p (staircase)
|
|
///
|
|
/// PCHIP through <c>(0,0), (u_p, v_p)…, (1,1)</c> reproduces the staircase's elevation envelope —
|
|
/// the same land ends up at the same heights, so the mountain mass returns — while the flat bench
|
|
/// and plateau INTERIORS become smooth grade.
|
|
///
|
|
/// ═══ ⚠ WHERE THE STAIRCASE WAS FLAT, WE MUST DEVIATE — AND THAT IS THE POINT ═══
|
|
///
|
|
/// A bench maps a wide input band onto a narrow output band, so two adjacent percentiles land at
|
|
/// nearly the same height and their secant is near zero. Reproducing THAT would rebuild the
|
|
/// bench. <see cref="MinNormalizedSecant"/> floors every segment's grade and renormalizes, so the
|
|
/// curve passes THROUGH the bench height with slope instead of running ALONG it. The floor bites
|
|
/// only where the staircase was flat; everywhere else the calibration is reproduced.
|
|
/// </summary>
|
|
public sealed class ClimbCalibration
|
|
{
|
|
/// <summary>
|
|
/// The above-ceiling land percentiles sampled. Six is a handful — enough to carry the
|
|
/// staircase's envelope, few enough that PCHIP interpolates smoothly between them rather
|
|
/// than tracing every wobble of the bench.
|
|
/// </summary>
|
|
public static readonly double[] DefaultPercentiles = { 10.0, 30.0, 50.0, 70.0, 85.0, 95.0 };
|
|
|
|
/// <summary>
|
|
/// The no-bench floor: no segment's grade may fall below this fraction of the climb's average
|
|
/// grade (1.0 = average). 0.35 is comfortably above <see cref="ContinuousCurve"/>'s own
|
|
/// near-flat tripwire and well below the grades the calibration produces outside the benches,
|
|
/// so it is a repair for the flats and a no-op everywhere else.
|
|
/// </summary>
|
|
public const float MinNormalizedSecant = 0.35f;
|
|
|
|
/// <summary>Iterations of floor-then-renormalize. It converges in a few; 24 is free insurance.</summary>
|
|
private const int RepairIterations = 24;
|
|
|
|
/// <summary>Normalized control points, strictly increasing in both. Includes (0,0) and (1,1).</summary>
|
|
public readonly float[] U, V;
|
|
|
|
/// <summary>The percentiles sampled, and the raw/output heights measured at each — for the report.</summary>
|
|
public readonly double[] Percentiles;
|
|
public readonly float[] RawAt, TargetHeightAt;
|
|
|
|
/// <summary>The knobs this calibration was shaped with.</summary>
|
|
public readonly float MountainLift, PeakSharpness;
|
|
|
|
/// <summary>
|
|
/// Normalized u of the summit onset — the LAST measured percentile. Above it,
|
|
/// <see cref="PeakSharpness"/> reshapes; below it, nothing does. That is the decoupling.
|
|
/// </summary>
|
|
public readonly float SummitOnsetU;
|
|
|
|
/// <summary>How many segments the no-bench floor had to lift. Zero means the staircase had no flats.</summary>
|
|
public readonly int SegmentsFloored;
|
|
|
|
private ClimbCalibration(float[] u, float[] v, double[] pcts, float[] rawAt, float[] targetAt,
|
|
float lift, float sharp, float onsetU, int floored)
|
|
{
|
|
U = u; V = v; Percentiles = pcts; RawAt = rawAt; TargetHeightAt = targetAt;
|
|
MountainLift = lift; PeakSharpness = sharp; SummitOnsetU = onsetU; SegmentsFloored = floored;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build the calibration from measured quantiles.
|
|
///
|
|
/// ⚠ Takes plain arrays, not a histogram: <c>LandHistogram</c> lives in <c>Tools/</c> and Core
|
|
/// depends on nothing above it. The caller measures; this shapes.
|
|
/// </summary>
|
|
/// <param name="percentiles">The percentiles sampled, ascending.</param>
|
|
/// <param name="rawQuantiles">Above-ceiling RAW height at each percentile.</param>
|
|
/// <param name="outQuantiles">Above-ceiling STAIRCASE OUTPUT height at each percentile.</param>
|
|
/// <param name="mountainLift">
|
|
/// 1.0 = reproduce the staircase's mountain. >1 lifts the mid-massif higher; <1 lowers it
|
|
/// toward chat2/02's bottom-heavy default. Applied as <c>v ← v^(1/lift)</c>, which is monotone
|
|
/// and fixes both endpoints, so it can move the massif without touching sea level or the cap.
|
|
/// </param>
|
|
/// <param name="peakSharpness">
|
|
/// ⭐ ACTS ONLY ABOVE THE LAST MEASURED PERCENTILE. 1.0 = a straight run to the cap; higher
|
|
/// defers the rise so the final approach steepens and the peak reads pointy.
|
|
/// ⚠ Unlike chat2/02's <c>summitDrama</c>, it CANNOT lower the massif — the onset's height is
|
|
/// fixed by the calibration before this is applied. That is the §3 fix.
|
|
/// </param>
|
|
public static ClimbCalibration FromPercentiles(
|
|
double[] percentiles, float[] rawQuantiles, float[] outQuantiles,
|
|
float ceilingRaw, float spikeMax, float ceilingOut, float peakCap,
|
|
float mountainLift, float peakSharpness)
|
|
{
|
|
int n = percentiles.Length;
|
|
if (rawQuantiles.Length != n || outQuantiles.Length != n)
|
|
throw new ArgumentException("[ClimbCalibration] percentile/raw/output arrays must be the same length.");
|
|
if (mountainLift <= 0f)
|
|
throw new ArgumentOutOfRangeException(nameof(mountainLift), mountainLift, "mountainLift must be positive.");
|
|
if (peakSharpness < 1f)
|
|
throw new ArgumentOutOfRangeException(nameof(peakSharpness), peakSharpness,
|
|
"peakSharpness < 1 would make the summit's final approach SHALLOWER than its own average — a ramp, not a peak.");
|
|
|
|
float spanRaw = spikeMax - ceilingRaw;
|
|
float spanOut = peakCap - ceilingOut;
|
|
if (spanRaw <= 0f || spanOut <= 0f)
|
|
throw new InvalidOperationException("[ClimbCalibration] the climb has no room — ceiling meets the summit.");
|
|
|
|
// ---- normalize the measured points, plus the two exact endpoints ----
|
|
var u = new float[n + 2];
|
|
var v = new float[n + 2];
|
|
u[0] = 0f; v[0] = 0f;
|
|
u[n + 1] = 1f; v[n + 1] = 1f;
|
|
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
u[i + 1] = Math.Clamp((rawQuantiles[i] - ceilingRaw) / spanRaw, 0f, 1f);
|
|
v[i + 1] = Math.Clamp((outQuantiles[i] - ceilingOut) / spanOut, 0f, 1f);
|
|
}
|
|
|
|
// ⚠ u must be STRICTLY increasing for PCHIP. Percentiles of a continuous distribution
|
|
// give that naturally; a degenerate seed (a plateau in the raw CDF) could not. Nudge
|
|
// rather than throw — a hair of u-spacing is not a shape decision.
|
|
const float minDu = 1e-4f;
|
|
for (int i = 1; i < u.Length; i++)
|
|
if (u[i] <= u[i - 1] + minDu) u[i] = u[i - 1] + minDu;
|
|
// Renormalize back onto [0,1] if the nudging pushed past the end.
|
|
if (u[u.Length - 1] > 1f)
|
|
{
|
|
float s = 1f / u[u.Length - 1];
|
|
for (int i = 1; i < u.Length; i++) u[i] *= s;
|
|
u[u.Length - 1] = 1f;
|
|
}
|
|
|
|
// ---- mountainLift: v ← v^(1/lift). Monotone, endpoints fixed. ----
|
|
if (Math.Abs(mountainLift - 1f) > 1e-6f)
|
|
{
|
|
float e = 1f / mountainLift;
|
|
for (int i = 1; i <= n; i++) v[i] = MathF.Pow(v[i], e);
|
|
}
|
|
|
|
// ---- the no-bench repair: floor every grade, renormalize to keep v(1) = 1 ----
|
|
float onsetU = u[n]; // the last measured percentile
|
|
int floored = RepairSecants(u, v, out _);
|
|
|
|
// ---- peakSharpness: reshape ONLY the segment above the onset ----
|
|
// Insert a midpoint whose height defers the rise, so the final approach steepens.
|
|
// v_mid = v_onset + (1 - v_onset) * 0.5^sharpness ⇒ sharpness 1 is exactly linear.
|
|
if (peakSharpness > 1f + 1e-6f)
|
|
{
|
|
float uS = u[n], vS = v[n];
|
|
float uMid = (uS + 1f) * 0.5f;
|
|
float vMid = vS + (1f - vS) * MathF.Pow(0.5f, peakSharpness);
|
|
|
|
var u2 = new float[u.Length + 1];
|
|
var v2 = new float[v.Length + 1];
|
|
Array.Copy(u, u2, n + 1); Array.Copy(v, v2, n + 1);
|
|
u2[n + 1] = uMid; v2[n + 1] = vMid;
|
|
u2[n + 2] = 1f; v2[n + 2] = 1f;
|
|
u = u2; v = v2;
|
|
|
|
// ⚠ The deferred first half must still not be a bench. Re-floor ONLY that segment,
|
|
// leaving the calibrated massif below the onset untouched — re-running the global
|
|
// repair here would renormalize the massif and undo the decoupling.
|
|
float du = uMid - uS;
|
|
float minDv = MinNormalizedSecant * du;
|
|
if (vMid - vS < minDv) v[n + 1] = vS + minDv;
|
|
}
|
|
|
|
var rawAt = (float[])rawQuantiles.Clone();
|
|
var outAt = (float[])outQuantiles.Clone();
|
|
|
|
var cal = new ClimbCalibration(u, v, (double[])percentiles.Clone(), rawAt, outAt,
|
|
mountainLift, peakSharpness, onsetU, floored);
|
|
|
|
cal.AssertUsable();
|
|
return cal;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Floor every segment's normalized grade at <see cref="MinNormalizedSecant"/> and renormalize
|
|
/// so the last point still lands exactly on 1. Iterated, because renormalizing can push a
|
|
/// floored segment back under the floor; it converges as long as the un-floored segments have
|
|
/// room to absorb the excess.
|
|
/// </summary>
|
|
private static int RepairSecants(float[] u, float[] v, out float minSecant)
|
|
{
|
|
int m = u.Length;
|
|
var s = new float[m - 1];
|
|
var du = new float[m - 1];
|
|
for (int i = 0; i < m - 1; i++)
|
|
{
|
|
du[i] = u[i + 1] - u[i];
|
|
s[i] = (v[i + 1] - v[i]) / du[i];
|
|
}
|
|
|
|
int flooredCount = 0;
|
|
for (int it = 0; it < RepairIterations; it++)
|
|
{
|
|
int hit = 0;
|
|
for (int i = 0; i < s.Length; i++)
|
|
if (s[i] < MinNormalizedSecant) { s[i] = MinNormalizedSecant; hit++; }
|
|
flooredCount = hit;
|
|
|
|
float total = 0f;
|
|
for (int i = 0; i < s.Length; i++) total += s[i] * du[i];
|
|
if (Math.Abs(total - 1f) < 1e-6f) break;
|
|
for (int i = 0; i < s.Length; i++) s[i] /= total;
|
|
}
|
|
|
|
// Rebuild v from the repaired grades.
|
|
minSecant = float.MaxValue;
|
|
v[0] = 0f;
|
|
for (int i = 0; i < s.Length; i++)
|
|
{
|
|
if (s[i] < minSecant) minSecant = s[i];
|
|
v[i + 1] = v[i] + s[i] * du[i];
|
|
}
|
|
v[m - 1] = 1f; // exact, against accumulated float drift
|
|
return flooredCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The invariants a calibration must satisfy before it is allowed to shape terrain. Throws
|
|
/// and refuses, rather than producing a curve nobody checked.
|
|
/// </summary>
|
|
private void AssertUsable()
|
|
{
|
|
for (int i = 1; i < U.Length; i++)
|
|
{
|
|
if (U[i] <= U[i - 1])
|
|
throw new InvalidOperationException(
|
|
$"[ClimbCalibration] control point {i} is not strictly right of its predecessor " +
|
|
$"(u {U[i - 1]} → {U[i]}). Refusing to generate.");
|
|
if (V[i] <= V[i - 1])
|
|
throw new InvalidOperationException(
|
|
$"[ClimbCalibration] control point {i} does not RISE (v {V[i - 1]} → {V[i]}) — that is a " +
|
|
$"bench, which is the artifact this mode exists to remove. Refusing to generate.");
|
|
}
|
|
if (Math.Abs(U[0]) > 1e-6f || Math.Abs(V[0]) > 1e-6f
|
|
|| Math.Abs(U[U.Length - 1] - 1f) > 1e-6f || Math.Abs(V[V.Length - 1] - 1f) > 1e-6f)
|
|
throw new InvalidOperationException(
|
|
"[ClimbCalibration] the endpoints must be exactly (0,0) and (1,1) — the lowland handover and " +
|
|
"the peak cap are not negotiable. Refusing to generate.");
|
|
}
|
|
|
|
/// <summary>The calibration as one line for the INDEX, the log and the report.</summary>
|
|
public string Describe()
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append($"lift {MountainLift:F2} sharp {PeakSharpness:F2} onsetU {SummitOnsetU:F3} " +
|
|
$"floored {SegmentsFloored} · uv ");
|
|
for (int i = 0; i < U.Length; i++) sb.Append($"({U[i]:F3},{V[i]:F3}) ");
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
|
|
/// <summary>The measured percentile table, for the report.</summary>
|
|
public string DescribeMeasured(float ceilingOut, float peakCap)
|
|
{
|
|
var sb = new StringBuilder();
|
|
for (int i = 0; i < Percentiles.Length; i++)
|
|
sb.Append($"P{Percentiles[i]:F0}→{WorldScale.MetresFromRaw(TargetHeightAt[i] - 0.15f):F0}m ");
|
|
return sb.ToString().TrimEnd();
|
|
}
|
|
}
|
|
}
|