diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
index 87b7e8ce..fd358627 100644
--- a/docs/CLAUDE.md
+++ b/docs/CLAUDE.md
@@ -20,7 +20,7 @@ tab wiring. Everything else lives in `js/`:
| `sim.js` | the client-side Monte Carlo: the cutline replay, the per-event points draws, and the 2027 drop-in |
| `movers.js` | the biggest-movers panel |
| `forecast.js` | the standings table, column guide, expanded row, what-if |
-| `possible.js` | the six "what's left" panels |
+| `possible.js` | the "what's left" panels (six, or five with one event left) |
| `race.js` | the event-odds chart and table |
| `projection.js` | the two experimental tables (2027, EuroTour) |
| `whatif.js` | the What-if tab: sliders for an invented player, dropped into the 2027 field |
diff --git a/docs/js/possible.js b/docs/js/possible.js
index 864d8061..4cc80f7e 100644
--- a/docs/js/possible.js
+++ b/docs/js/possible.js
@@ -1,10 +1,12 @@
-/* The "what's left" tab: six reads of the remaining possibility space.
+/* The "what's left" tab: six reads of the remaining possibility space, or five
+ once the calendar is down to its last event.
Panels 1-5 are pure re-reads of fields the bundle already ships; 3 and 4
also price a gap as a finishing place, which is arithmetic on the closing
event's own curve rather than a simulation. Panel 6 re-runs the cutline
replay under a fixed result at one event, which is why it is computed
- lazily, chunked one player per tick, and cached per division. */
+ lazily, chunked one player per tick, and cached per division — and why it is
+ not drawn at all when one event is left and it has nothing to compare. */
import { $, fmtPct, fmtPts, ordinal, state } from "./core.js";
import { probe, tipAttrs } from "./tooltip.js";
@@ -18,8 +20,8 @@ import { samplePoints } from "./sim.js";
These answer the shape questions it can't: how wide is everyone's range,
which positions are actually still contested, how far the chasers are from
the line and what closing that gap costs, what it takes to pass the player
- one row up, which door they walk through, and where their season gets
- decided.
+ one row up, which door they walk through, and — while there is still a
+ choice of event — where their season gets decided.
Panels 1-5 are pure re-reads of fields the bundle already ships (hist,
cutline, p_cut, p_mvp_qual, att) — no pipeline change. Panel 6 re-runs the
@@ -230,17 +232,39 @@ function closingEvent(d) {
return d.events.find((e) => e.tid === d.meta.mvp_tid) || d.events[d.events.length - 1] || null;
}
-/* The three lines worth pricing against. "Low" and "high" are the cutline's
- 10th and 90th percentiles — the same season in its kind and its cruel
- version — and the median between them is the headline. Named rather than
- numbered because a table column headed "10th pct" full of ordinals ("10th
- pct: 12th") is two different kinds of place in four words. */
+/* The lines worth pricing against, hardest first — which is the order a chaser
+ reads them in, since the question is "what do I have to do to stop worrying".
+ Named rather than numbered because a column headed "10th pct" full of
+ ordinals ("10th pct: 12th") is two different kinds of place in four words.
+
+ `sure` is the cutline at its 99.9th percentile: clear that and you are in
+ under all but one simulated season in a thousand, which is as close to a
+ guarantee as 25,000 seasons can express. It is a column and not a mark on
+ the chart because it sits outside the histogram's own drawn range — the bars
+ stop at the 99.5th percentile — so a guide line for it would hang off the
+ right edge with no bars under it. */
const CUT_BANDS = [
- { f: 0.1, label: "low", head: "Low", pct: "10th percentile", blurb: "a season where the line lands low" },
- { f: 0.5, label: "median", head: "Median", pct: "median", blurb: "where the line usually lands" },
- { f: 0.9, label: "high", head: "High", pct: "90th percentile", blurb: "a season where the line lands high" },
+ { f: 0.999, key: "sure", label: "guaranteed", head: "Guaranteed", pct: "99.9th percentile",
+ blurb: "all but the very worst season for the bubble" },
+ { f: 0.9, key: "hi", label: "high", head: "High", pct: "90th percentile",
+ blurb: "a season where the line lands high" },
+ { f: 0.5, key: "mid", label: "median", head: "Median", pct: "median",
+ blurb: "where the line usually lands" },
+ { f: 0.1, key: "lo", label: "low", head: "Low", pct: "10th percentile",
+ blurb: "a season where the line lands low" },
];
const cutBands = (q) => CUT_BANDS.map((b) => ({ ...b, pts: q(b.f) }));
+// by key, never by index: the display order above is a presentation choice and
+// the chart's median line must not move when it is reordered again
+const band = (bands, key) => bands.find((b) => b.key === key);
+
+/* How deep a finishing place at `ev` can be. The roster is the field, plus a
+ few places of slack: entries move right up to the first tee, and a ladder
+ that stops at today's headcount would be missing real finishing positions by
+ the time anyone plays. Used by every search and every ladder here, so the
+ place a table asks for and the place it prices are the same place. */
+const FIELD_SLACK = 5;
+const fieldDepth = (ev) => Math.max(1, Math.round(ev.field_size)) + FIELD_SLACK;
/* What a finish at `ev` is worth to `p`, place by place, after the per-class
counting caps — a result only counts for what it beats. The Jomez bonus is
@@ -274,7 +298,7 @@ function pricer(d, ev, p) {
function needAt(d, ev, p, target, price = pricer(d, ev, p)) {
if (p.points > target) return { kind: "clear" };
if (!price) return { kind: "out" };
- const size = Math.max(1, Math.round(ev.field_size));
+ const size = fieldDepth(ev);
const need = target - p.points;
let worst = 0;
for (let k = 1; k <= size; k++) if (price(k) > need) worst = k;
@@ -316,7 +340,7 @@ function cutlineHtml(d, q) {
if (!cl || cl.length < 100) return "";
const rug = contenders(d);
const bands = cutBands(q), ev = closingEvent(d);
- const p50 = bands[1].pts, blo = q(0.005), bhi = q(0.995);
+ const p50 = band(bands, "mid").pts, blo = q(0.005), bhi = q(0.995);
const NB = 44, bwPts = (bhi - blo) / NB;
const bins = new Array(NB).fill(0);
for (const x of cl) if (x >= blo && x <= bhi) bins[Math.min(NB - 1, Math.floor((x - blo) / bwPts))]++;
@@ -361,18 +385,19 @@ function cutlineHtml(d, q) {
axis += `