islaApocalypse-v2/Core/Scripts/ContinuousCurve.cs
beezm 639dc5f5a9 Phase 2a: restore the mountain — calibrate the climb to the staircase, not to a guess
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
2026-08-20 04:14:47 -04:00

488 lines
23 KiB
C#
Raw Permalink 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;
/// <summary>
/// ⭐ The measured calibration this climb was shaped from (chat2/03), or null when the climb
/// came from chat2/02's ANALYTIC feather/drama points.
///
/// Non-null is the current default: "the staircase's mountain with the terraces melted out".
/// Null survives so the 02 curve stays reproducible as a contrast variant — it is the "before"
/// in the three-way histogram story, not a fallback.
/// </summary>
public readonly ClimbCalibration Calibration;
/// <summary>Where the summit begins, normalized — the calibration's onset when calibrated, else the constant.</summary>
public float EffectiveSummitOnset => Calibration?.SummitOnsetU ?? SummitOnset;
// 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, ClimbCalibration calibration = null)
{
Knots = k; Anchors = a;
CeilingRaw = ceilingRaw; CeilingOut = ceilingOut; SpikeMax = spikeMax;
JoinSlopeRaw = joinSlopeRaw;
LowlandCeilingM = lowlandCeilingM; ClimbFeather = climbFeather; SummitDrama = summitDrama;
_x = x; _y = y; _m = m; Calibration = calibration;
}
/// <summary>
/// ⭐⭐ THE CALIBRATED CLIMB (chat2/03) — control points MEASURED from the staircase's
/// above-ceiling elevation distribution rather than invented from two shape knobs.
/// → <see cref="ClimbCalibration"/> for the method and for the chat2/02 mistake it corrects.
///
/// Everything outside the climb is identical to <see cref="Build"/>: the same lowland
/// handover pinned to the exact anchors, the same C¹ join to the red band's exit slope, the
/// same per-seed <paramref name="spikeMax"/>, the same tail. Only the interior shape changes.
///
/// ⚠ THIS PATH DOES NOT REQUIRE STRICTLY-INCREASING SECANTS, and that is deliberate. The 02
/// analytic path enforced a convex control polygon as its no-magnet rule. A curve calibrated
/// to real terrain is WAVY — gentler where the staircase had a bench, steeper through its
/// risers — so convexity is the wrong invariant here. The no-magnet guarantee instead comes
/// from <see cref="ClimbCalibration.MinNormalizedSecant"/>, which floors every grade: the
/// curve may slow down, but never to a bench.
/// </summary>
public static ContinuousCurve BuildCalibrated(CurveKnots k, CurveAnchors a, float spikeMax,
float lowlandCeilingM, ClimbCalibration calibration)
{
if (calibration == null) throw new ArgumentNullException(nameof(calibration));
var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
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.");
float spanRaw = spikeMax - ceilingRaw;
float spanOut = a.PeakCap - ceilingOut;
int n = calibration.U.Length;
var x = new float[n];
var y = new float[n];
for (int i = 0; i < n; i++)
{
x[i] = ceilingRaw + calibration.U[i] * spanRaw;
y[i] = ceilingOut + calibration.V[i] * spanOut;
}
float[] m = FritschCarlsonTangents(x, y, startTangent: redSlope);
return new ContinuousCurve(k, a, ceilingRaw, ceilingOut, spikeMax, redSlope,
lowlandCeilingM, climbFeather: float.NaN, summitDrama: float.NaN, x, y, m, calibration);
}
/// <summary>
/// Where the preserved lowland hands over to the climb, and the red band's exit slope.
///
/// ⚠ THE FLOOD LINE IS PINNED TO THE EXACT ANCHORS, and "30 m" is NOMINAL: RED_CEIL SEA is
/// 0.12 raw = 30.12 m. Any requested ceiling at or below the red ceiling hands over at
/// EXACTLY <c>(K2, RED_CEIL)</c> — no derived floats — so the linear extension is empty by
/// construction and the preserved toe+red band can never be cut by a rounding. (chat2/02's
/// first run refused its own default over that 0.12 m gap; pinning is the fix, not a wider
/// tolerance.)
/// </summary>
private static (float ceilingRaw, float ceilingOut, float redSlope) ResolveHandover(
CurveKnots k, CurveAnchors a, float lowlandCeilingM)
{
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.");
float redCeilM = WorldScale.MetresFromRaw(a.RedCeil - a.Sea);
if (lowlandCeilingM <= redCeilM + 0.01f)
return (k.K2, a.RedCeil, redSlope);
float ceilingOut = a.Sea + WorldScale.RawFromMetres(lowlandCeilingM);
return (k.K2 + (ceilingOut - a.RedCeil) / redSlope, ceilingOut, redSlope);
}
/// <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 — shared with BuildCalibrated ----
var (ceilingRaw, ceilingOut, redSlope) = ResolveHandover(k, a, lowlandCeilingM);
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, {KnobSummary()}). 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 + EffectiveSummitOnset * 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 + EffectiveSummitOnset * (SpikeMax - CeilingRaw);
return (r, Apply(r));
}
/// <summary>
/// The shaping knobs, named for whichever path built this curve — chat2/02's analytic
/// feather/drama or chat2/03's measured lift/sharpness. ⚠ The analytic fields are NaN on a
/// calibrated curve, so nothing may print them unconditionally.
/// </summary>
public string KnobSummary() => Calibration != null
? $"lift {Calibration.MountainLift:F2} sharp {Calibration.PeakSharpness:F2} (calibrated)"
: $"feather {ClimbFeather:F2} drama {SummitDrama:F2} (analytic 02)";
/// <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 {KnobSummary()} · 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();
}
}
}