• Welcome
  • Start/Sit
  • Projections
  • Edge Finder
  • Track Record
  • About
    • About FFHedge
    • Methodology
    • Media

    • reluctant criminologists

Start/Sit

Should you start X or Y? The odds each clears its floor, target, and ceiling, with an honest verdict when it’s a coin flip.

Pick two receivers in the same week and compare their projected distributions and threshold probabilities side by side.

This page helps with start/sit decisions by comparing the probability that each player will clear a position-specific floor, target, or ceiling scoring threshold — and in many cases by showing that the choice between two players is close enough to decide by flipping a coin.

Code
palette = ({
  accent: "#93c54b",
  accentDark: "#7aa83c",
  expert: "#b5651d",   // warm brown for the expert-driven (Model A) signal
  data:   "#3a6ea5",   // muted blue for the data-driven (Model B) signal
  mixture: "#3e3f3a",  // ink for the blended predictive
  sand: "#f8f5f0",
  bust: "#c9b8a3",
  held: "#dfe7c8",
  strong: "#b9d68a",
  leagueWinner: "#93c54b"
})

// Probability formatting with the "no false precision" rule: anything that
// would round to a flat tiny number is shown as "<1%"; high values mirror it.
fmtPct = function (p) {
  if (p == null || isNaN(p)) return "—";
  if (p < 0.01) return "<1%";
  if (p > 0.99) return ">99%";
  return (100 * p).toFixed(0) + "%";
}

// Fantasy-point formatting to one decimal.
fmtFp = function (x) {
  if (x == null || isNaN(x)) return "—";
  return x.toFixed(1);
}

// Four narrative-bin probabilities from the three exceedance probabilities.
// Inputs are P(exceed floor), P(exceed target), P(exceed ceiling).
narrativeProbs = function (pFloor, pTarget, pCeiling) {
  return {
    bust: Math.max(0, 1 - pFloor),
    held_up: Math.max(0, pFloor - pTarget),
    strong: Math.max(0, pTarget - pCeiling),
    league_winner: Math.max(0, pCeiling)
  };
}

// Map an ECR rank to its tier label (matches the export's ecr_tier bins).
ecrTier = function (ecr) {
  if (ecr == null || isNaN(ecr)) return null;
  if (ecr <= 5) return "1-5";
  if (ecr <= 12) return "6-12";
  if (ecr <= 24) return "13-24";
  if (ecr <= 48) return "25-48";
  if (ecr <= 96) return "49-96";
  return "97+";
}

// Position-prefixed ECR label. In Flex mode WR and RB ranks collide (both
// have a "#6"), so we prefix with the player's position (RB6 / WR6); in
// single-position mode the bare rank is unambiguous. The middle argument is a
// rank (the dense posRank from addPosRank below), not the raw continuous ECR.
ecrDisp = function (pos, ecr, isFlex) {
  if (ecr == null || isNaN(ecr)) return "—";
  const n = Math.round(ecr);
  return isFlex ? `${pos ?? "WR"}${n}` : `${n}`;
}

// Dense positional ECR rank (1..N within each position for a week's active
// pool, gapless). ecr_rank is the continuous FantasyPros average rank, so
// rounding it for display yields duplicate/skipped integers; dense-ranking
// recovers a clean ordinal rank. Mutates rows (adds `posRank`), returns rows.
addPosRank = function (rows) {
  const byPos = d3.group(rows, d => d.position ?? "WR");
  for (const [, ps] of byPos) {
    ps.slice()
      .sort((a, b) => d3.ascending(a.ecr ?? 9999, b.ecr ?? 9999))
      .forEach((p, i) => { p.posRank = i + 1; });
  }
  return rows;
}

// Linear mix of the Expert and Data marginals at lean w (w = weight on Expert).
// Exact for exceedance probabilities and the mean; use for all Blend numbers.
// (Percentiles are NOT linear — never synthesize blend percentiles with this.)
blendField = (em, dm, w, field) => {
  const e = em?.[field], d = dm?.[field];
  if (e == null || d == null) return e ?? d ?? null;
  return w * e + (1 - w) * d;
}

// Reference: the data-optimal stacked weight per position (what we used to
// deploy before Stage 1's 0.50 hedge), for the slider caption. Flex omitted
// (mixed pool, no single stacked weight).
stackedLean = ({ WR: 0.378, RB: 0.077, TE: 0.286, QB: 0.085 })

// Human-readable archetype labels for badges (WR archetype set).
archetypeLabel = ({
  fill_in_situation: "fill-in situation",
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  rookie_or_low_sample: "rookie / low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})

// RB archetype set. The RB build ships a different seven flags: fill_in_rb,
// is_rookie, and low_sample come straight from the feature table; the other four
// are carry-share analogs of the WR snap-share archetypes (see methodology).
rbFlagKeys = ["fill_in_rb","is_rookie","low_sample","late_season_expansion",
              "recent_role_change","stable_veteran","star_returning","dual_threat_rb"]
rbArchetypeLabel = ({
  fill_in_rb: "fill-in (handcuff)",
  is_rookie: "rookie",
  low_sample: "low sample",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  stable_veteran: "stable veteran",
  star_returning: "star returning",
  dual_threat_rb: "dual-threat back"
})
rbCompactLabel = ({
  fill_in_rb: "fill-in", is_rookie: "rookie", low_sample: "low-smp",
  late_season_expansion: "late-exp", recent_role_change: "role-chg",
  stable_veteran: "stable", star_returning: "star-ret",
  dual_threat_rb: "dual-threat"
})

// TE archetype set (7 flags; target-share based — see te_archetype_flags.md).
teFlagKeys = ["emerging_player_elevation","late_season_expansion","recent_role_change",
              "is_rookie","low_sample","stable_veteran","star_returning"]
teArchetypeLabel = ({
  emerging_player_elevation: "emerging player",
  late_season_expansion: "late-season expansion",
  recent_role_change: "recent role change",
  is_rookie: "rookie",
  low_sample: "low sample",
  stable_veteran: "stable veteran",
  star_returning: "star returning"
})
teCompactLabel = ({
  emerging_player_elevation:"emerging", late_season_expansion:"late-exp",
  recent_role_change:"role-chg", is_rookie:"rookie", low_sample:"low-smp",
  stable_veteran:"stable", star_returning:"star-ret"
})

// QB archetype set (8 flags; usage + ECR-tier based — see qb_archetype_flags.md).
// stable_starter is RANK-based (trailing-4wk mean ECR in the top 12), distinct from
// the usage-based WR/TE/RB stable_veteran; rushing_qb is the QB-specific dual-threat
// flag (a stable type from prior-season carries/game — see methodology).
qbFlagKeys = ["stable_starter","rushing_qb","new_starter","emerging_elevation",
              "late_season_expansion","star_returning","is_rookie","low_sample"]
qbArchetypeLabel = ({
  stable_starter: "stable starter",
  rushing_qb: "dual-threat QB",
  new_starter: "new starter",
  emerging_elevation: "emerging role",
  late_season_expansion: "late-season expansion",
  star_returning: "star returning",
  is_rookie: "rookie",
  low_sample: "low sample"
})
qbCompactLabel = ({
  stable_starter:"stable", rushing_qb:"dual-threat", new_starter:"new-str",
  emerging_elevation:"emerging", late_season_expansion:"late-exp",
  star_returning:"star-ret", is_rookie:"rookie", low_sample:"low-smp"
})

// Slot thresholds by position. Decoupled from the locked-config files so the
// Flex position's combined slots (WR1/RB1, WR2/RB2) resolve to a single set of
// thresholds — the WR and RB tiers share identical floor/target/ceiling values.
positionThresholds = ({
  WR: { WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  RB: { RB1: { floor: 12, target: 16, ceiling: 20 },
        RB2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  TE: { TE1: { floor: 5, target: 8, ceiling: 12 },
        WR1: { floor: 12, target: 16, ceiling: 20 },
        WR2: { floor: 10, target: 12, ceiling: 15 },
        Flex: { floor: 6, target: 10, ceiling: 15 } },
  QB: { QB1: { floor: 15, target: 20, ceiling: 25 } },
  Flex: { "WR1/RB1": { floor: 12, target: 16, ceiling: 20 },
          "WR2/RB2": { floor: 10, target: 12, ceiling: 15 },
          Flex: { floor: 6, target: 10, ceiling: 15 } }
})

// Slot dropdown options per position. WR/RB keep their own tiers; Flex mixes the
// two pools with combined tier labels (no position-specific tier filtering).
slotOptionsFor = function (position) {
  if (position === "RB") return ["RB1", "RB2", "Flex"];
  if (position === "TE") return ["TE1", "Flex"];
  if (position === "QB") return ["QB1"];
  if (position === "Flex") return ["WR1/RB1", "WR2/RB2", "Flex"];
  return ["WR1", "WR2", "Flex"];
}

// Map an exceedance-probability slot to the per-row column name. RB and WR rows
// carry the same slot column names as their own position; the Flex combined
// slots read the underlying-position column on each row (WR1/RB1 -> WR1 on a WR
// row, RB1 on an RB row).
slotColFor = function (slot, rowPosition) {
  if (slot === "WR1/RB1") return rowPosition === "RB" ? "RB1" : "WR1";
  if (slot === "WR2/RB2") return rowPosition === "RB" ? "RB2" : "WR2";
  return slot;
}

// Per-row archetype keys and compact/full labels, branching on the row's
// position. Used in Flex mode where WR and RB rows are interleaved.
flagKeysForRow = (rowPosition) => rowPosition === "RB" ? rbFlagKeys : rowPosition === "TE" ? teFlagKeys : rowPosition === "QB" ? qbFlagKeys : FLAG_KEYS_WR;
compactLabelForRow = (rowPosition) => rowPosition === "RB" ? rbCompactLabel : rowPosition === "TE" ? teCompactLabel : rowPosition === "QB" ? qbCompactLabel : compactLabelWR;
fullLabelForRow = (rowPosition) => rowPosition === "RB" ? rbArchetypeLabel : rowPosition === "TE" ? teArchetypeLabel : rowPosition === "QB" ? qbArchetypeLabel : archetypeLabel;

// WR flag keys / compact labels live here too so the per-row resolvers above
// work on every page without each page having to define the WR set first.
FLAG_KEYS_WR = ["fill_in_situation","emerging_player_elevation","late_season_expansion",
                "recent_role_change","rookie_or_low_sample","stable_veteran","star_returning"]
compactLabelWR = ({
  fill_in_situation:"fill-in", emerging_player_elevation:"emerging",
  late_season_expansion:"late-exp", recent_role_change:"role-chg",
  rookie_or_low_sample:"rookie/ls", stable_veteran:"stable", star_returning:"star-ret"
})

// Biggest within-tier predictive surprise: among players of `position` whose ECR
// rank falls in [lo, hi] (one tier), the pair the experts ranked furthest apart
// that the deployed blend (cross_blend) still calls a coin flip — within `margin`
// on the floor, target, AND ceiling of `slot` (the roster slot you'd start that
// tier in). Ranks the position pool by true ECR each week; both players must sit in
// the tier. Falls back to the closest in-tier pair if no coin flip exists; null if
// the tier is empty. Returns { week, a, b, gapRanks, slot } with a/b =
// { id, name, team, position, ecr, rank, blend }.
mostSurprisingCoinflip = function (rows, weeks, position, lo, hi, slot, margin = 0.03) {
  const col = t => `p_${slot}_${t}`;
  let best = null, closest = null;
  for (const wk of weeks) {
    const wkRows = rows.filter(d => Number(d.week) === Number(wk) && (d.position ?? "WR") === position);
    const byPlayer = d3.group(wkRows, d => d.player_id);
    const pool = [];
    for (const [id, rs] of byPlayer) {
      const blend = rs.find(r => r.predictive === "cross_blend");
      if (!blend || rs[0].ecr_rank == null) continue;
      pool.push({ id, name: rs[0].player_display_name, team: rs[0].team,
                  position: rs[0].position ?? "WR", ecr: rs[0].ecr_rank, blend });
    }
    pool.sort((a, b) => d3.ascending(a.ecr, b.ecr));
    pool.forEach((p, i) => p.rank = i + 1);
    const tier = pool.filter(p => p.rank >= lo && p.rank <= hi);
    for (let i = 0; i < tier.length; i++) {
      for (let j = i + 1; j < tier.length; j++) {
        const a = tier[i], b = tier[j];
        const dF = Math.abs(a.blend[col("floor")] - b.blend[col("floor")]);
        const dT = Math.abs(a.blend[col("target")] - b.blend[col("target")]);
        const dC = Math.abs(a.blend[col("ceiling")] - b.blend[col("ceiling")]);
        if ([dF, dT, dC].some(v => v == null || isNaN(v))) continue;
        const gap = Math.abs(a.rank - b.rank);
        const comb = dF + dT + dC;
        if (dF < margin && dT < margin && dC < margin) {
          if (!best || gap > best.gapRanks) best = { week: wk, a, b, gapRanks: gap, slot };
        }
        if (!closest || comb < closest._comb) closest = { week: wk, a, b, gapRanks: gap, slot, _comb: comb };
      }
    }
  }
  return best ?? closest;
}

// Multi-select "filter by situation" chip row. Returns a viewof-compatible
// element whose .value is the array of selected archetype keys for `position`
// (empty when `hidden`, e.g. the season/in-dev views). OR semantics: a table row
// matches if it carries any selected flag. Used by Projections and Edge Finder.
archFilterChips = function (position, hidden) {
  if (hidden) { const e = html`<div></div>`; e.value = []; return e; }
  const keys = flagKeysForRow(position);
  const labels = fullLabelForRow(position);
  const sel = new Set();
  const box = html`<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0.3rem 0 0.5rem;"></div>`;
  box.appendChild(html`<span style="font-size:0.8rem;color:var(--rc-muted);margin-right:2px;">Filter by situation:</span>`);
  const paint = (chip, on) => { chip.style.cssText = `cursor:pointer;font-size:0.75rem;padding:2px 9px;border-radius:11px;border:1px solid var(--rc-sand-panel);background:${on ? "rgba(147,197,75,0.22)" : "var(--rc-sand)"};color:${on ? "#3e3f3a" : "#6b6b6b"};font-weight:${on ? 600 : 400};`; };
  for (const k of keys) {
    const chip = html`<button type="button">${labels[k]}</button>`;
    paint(chip, false);
    chip.onclick = () => { sel.has(k) ? sel.delete(k) : sel.add(k); paint(chip, sel.has(k)); box.value = Array.from(sel); box.dispatchEvent(new CustomEvent("input", { bubbles: true })); };
    box.appendChild(chip);
  }
  box.value = [];
  return box;
}
Loading most recent projections…
Code
// Hide the static loading placeholder once the position's projections resolve.
hideLoading = { predictives; const el = document.getElementById("ffhedge-loading"); if (el) el.style.display = "none"; }
Code
// Position selector (WR / RB / Flex), persisted within the session.
viewof position = (() => {
  const saved = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_position") : null) ?? "WR";
  const radio = Inputs.radio(["QB","WR","RB","TE","Flex"], { value: saved, label: "Position" });
  radio.addEventListener("input", () => {
    try { sessionStorage.setItem("ffhedge_position", radio.value); } catch (e) {}
  });
  return radio;
})()
Code
// Season selector. The seasons present in the data (newest first) plus any
// upcoming season flagged "(in development)". Defaults to the newest season with
// data (2025 today; auto-advances when a new season's projections land), and is
// persisted across pages within the session like the position selector.
viewof season = (() => {
  const UPCOMING = [2026];
  const inData = Array.from(new Set(wrPredictives.map(d => Number(d.season ?? 2025)))).sort((a, b) => b - a);
  const upcoming = UPCOMING.filter(y => !inData.includes(y));
  const options = upcoming.concat(inData);
  const dflt = inData[0];
  const saved = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_season") : null);
  const init = (saved != null && options.includes(Number(saved))) ? Number(saved) : dflt;
  const fmt = y => upcoming.includes(y) ? `${y} (in development)` : `${y}`;
  const sel = Inputs.select(options, { value: init, label: "Season", format: fmt });
  sel.addEventListener("input", () => { try { sessionStorage.setItem("ffhedge_season", sel.value); } catch (e) {} });
  return sel;
})()
Code
dataSeasons = Array.from(new Set(wrPredictives.map(d => Number(d.season ?? 2025)))).sort((a, b) => b - a)
latestDataSeason = dataSeasons[0]
seasonInDev = !dataSeasons.includes(Number(season))
Code
seasonInDev
  ? html`<div style="padding:0.9rem 1.1rem;margin:0.8rem 0;border:1px solid #e6ac00;border-radius:6px;background:#fff8e6;"><strong>${season} is in development.</strong> Live ${season} projections arrive with the NFL schedule. Switch Season back to ${latestDataSeason} to explore the validation archive.</div>`
  : html``
Code
db = DuckDBClient.of({
  wrPred: FileAttachment("data/predictives.parquet"),
  rbPred: FileAttachment("data/rb_predictives.parquet"),
  tePred: FileAttachment("data/te_predictives.parquet"),
  qbPred: FileAttachment("data/qb_predictives.parquet")
})
wrPredictives = db.query(`SELECT * FROM wrPred`)
rbPredictives = db.query(`SELECT * FROM rbPred`)
tePredictives = db.query(`SELECT * FROM tePred`)
qbPredictives = db.query(`SELECT * FROM qbPred`)
// Position-active comparison pool. Flex mixes WR, RB, and TE (TE rides the shared Flex bars).
predictives = seasonInDev ? []
            : position === "RB" ? rbPredictives
            : position === "TE" ? tePredictives
            : position === "QB" ? qbPredictives
            : position === "Flex" ? wrPredictives.concat(rbPredictives).concat(tePredictives)
            : wrPredictives
cfg = position === "RB" ? FileAttachment("data/rb_locked_config.json").json()
    : position === "TE" ? FileAttachment("data/te_locked_config.json").json()
    : position === "QB" ? FileAttachment("data/qb_locked_config.json").json()
                        : FileAttachment("data/locked_config.json").json()
weekOptions = Array.from(new Set(predictives.map(d => Number(d.week)))).sort((a, b) => b - a)
mutable selectedWeek = 17
Code
{
  const sel = Inputs.select(weekOptions, { value: selectedWeek, label: "Week" });
  sel.addEventListener("input", () => { mutable selectedWeek = sel.value; });
  return sel;
}
Code
weekPlayers = {
  if (selectedWeek === "all weeks") return [];
  const rows = predictives.filter(d => Number(d.week) === Number(selectedWeek));
  const byPlayer = d3.group(rows, d => d.player_id);
  const arr = [];
  for (const [id, rs] of byPlayer) {
    const blend = rs.find(r => r.predictive === "cross_blend");
    arr.push({ id, name: rs[0].player_display_name, team: rs[0].team, ecr: rs[0].ecr_rank, position: rs[0].position ?? "WR", blendMean: blend ? blend.mean : null });
  }
  // Sort by the deployed blend projection (best first), matching the Projections
  // board, so a mixed Flex pool reads as one cross-position list rather than
  // interleaving each position's ECR ranks.
  arr.sort((a, b) => d3.descending(a.blendMean ?? -Infinity, b.blendMean ?? -Infinity) || d3.ascending(a.name, b.name));
  addPosRank(arr);
  return arr;
}
Code
rankFmt = p => `${ecrDisp(p.position, p.posRank, position === "Flex")} ${p.name} (${p.team ?? "—"})`
Code
// Seed Start/Sit with the same "biggest surprise coin flip" the Welcome page
// features: among the startable pool (top 36 WR/RB by ECR, top 18 TE), the two
// players the experts rank furthest apart that the deployed blend still calls a
// coin flip, for the current position and week. Player A is the higher-ranked of
// the two (matching the Welcome card). Flex (mixed pool) and "all weeks" have no
// single-position coin-flip band, so they fall back to the top-two-by-blend
// default (defaultPair = null -> the selects keep their first-option default).
defaultPair = {
  if (selectedWeek === "all weeks" || position === "Flex") return null;
  const band = { WR: { lo: 1, hi: 36, slot: "Flex" },
                 RB: { lo: 1, hi: 36, slot: "Flex" },
                 QB: { lo: 1, hi: 12, slot: "QB1" },
                 TE: { lo: 1, hi: 18, slot: "TE1" } }[position];
  if (!band) return null;
  const cf = mostSurprisingCoinflip(predictives, [selectedWeek], position, band.lo, band.hi, band.slot);
  if (!cf) return null;
  let a = cf.a, b = cf.b;
  if (a.rank > b.rank) { const t = a; a = b; b = t; }
  // Map the helper's own a/b objects back onto the weekPlayers option objects by
  // id; Inputs.select matches its `value:` by object identity.
  const pa = weekPlayers.find(p => p.id === a.id);
  const pb = weekPlayers.find(p => p.id === b.id);
  return (pa && pb) ? { a: pa, b: pb } : null;
}
Code
viewof searchA = Inputs.search(weekPlayers, { placeholder: "Search Player A by name or rank…", label: "Player A" })
Code
viewof playerA = Inputs.select(searchA, { format: rankFmt, label: " ", value: defaultPair ? defaultPair.a : undefined })
Code
eligibleB = weekPlayers.filter(p => p.id !== playerA?.id)
Code
viewof searchB = Inputs.search(eligibleB, { placeholder: "Search Player B by name or rank…", label: "Player B" })
Code
viewof playerB = Inputs.select(searchB, { format: rankFmt, label: " ", value: defaultPair ? defaultPair.b : undefined })
Code
viewof cmpSource = Inputs.radio(["Blend", "Expert", "Data"], { value: "Blend", label: "Source" })
Code
viewof cmpSlot = Inputs.radio(slotOptionsFor(position), { value: position === "QB" ? "QB1" : position === "TE" ? "TE1" : slotOptionsFor(position)[2], label: "Roster slot" })
Code
// Expert<->Data lean slider (w = weight on Expert), shared across the explorer
// and compare pages via sessionStorage. Default 0.50 is the deployed hedge, so
// the Blend source matches the stored cross_blend at the default.
viewof lean = (() => {
  const s0 = (typeof sessionStorage !== "undefined" ? sessionStorage.getItem("ffhedge_lean") : null);
  const s = (s0 != null && !isNaN(+s0)) ? +s0 : 0.5;
  const rng = html`<input type=range min=0 max=1 step=0.05 value=${s} style="width:240px;accent-color:#3e3f3a;">`;
  const val = html`<span style="font-size:0.78rem;font-weight:600;min-width:34px;text-align:right;">${(+s).toFixed(2)}</span>`;
  const box = html`<div style="display:flex;align-items:center;gap:10px;margin:0.5rem 0;">
    <span style="font-size:0.82rem;font-weight:700;color:${palette.data};">Data</span>
    ${rng}
    <span style="font-size:0.82rem;font-weight:700;color:${palette.expert};">Expert</span>
    ${val}
  </div>`;
  box.value = +rng.value;
  rng.addEventListener("input", () => {
    box.value = +rng.value;
    val.textContent = (+rng.value).toFixed(2);
    try { sessionStorage.setItem("ffhedge_lean", rng.value); } catch (e) {}
    box.dispatchEvent(new CustomEvent("input", { bubbles: true }));
  });
  return box;
})()
Code
{
  const ref = (position === "WR" || position === "RB" || position === "TE" || position === "QB")
    ? `; the data-optimal stacked weight was ${Math.round(100 * stackedLean[position])}% expert.`
    : ".";
  return html`<div style="font-size:0.8rem;color:var(--rc-muted);margin:-0.2rem 0 0.6rem;">
    Blend = ${Math.round(100 * lean)}% expert / ${Math.round(100 * (1 - lean))}% data. Default 0.50 is the deployed hedge${ref}</div>`;
}
Code
{
  if (seasonInDev) return html``;
  // Position-level fallback (all-weeks mode, or before both players are picked).
  const fallback = {
    WR: "For receivers, Expert and Data are both well-calibrated and stay close at every line — the lean is essentially a toss-up.",
    RB: "For running backs, the Data side is the better-calibrated read of the floor.",
    TE: "For tight ends, the Data side is the better-calibrated read at every line — floor, target, and ceiling.",
    QB: "For quarterbacks, the Data side is the better-calibrated read of the floor.",
    Flex: "In the flex pool it depends on the player: Data reads running back and tight end floors better, while receivers are about a toss-up."
  }[position];
  const caveat = " Either way, the lean is a tiebreaker, not a rule: that calibration edge is a season-long average, and the Expert consensus can be pricing in a demotion, injury, or role change the usage data hasn't caught up to.";
  const wrap = msg => html`<div style="display:block;font-size:0.8rem;line-height:1.4;color:var(--rc-muted);margin:0.5rem 0 0.8rem;">${msg}</div>`;

  if (selectedWeek === "all weeks" || !playerA || !playerB)
    return wrap(html`The lean matters most where Expert and Data disagree. ${fallback}${caveat}`);

  const calBetter = { WR: null, RB: "Data", TE: "Data", QB: "Data" };
  const posWord  = { WR: "receiver", RB: "running back", TE: "tight end", QB: "quarterback" };
  const gapOf = (pid) => {
    const rs = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === pid);
    const em = rs.find(r => r.predictive === "expert_marginal");
    const dm = rs.find(r => r.predictive === "data_marginal");
    return (em && dm && em.mean != null && dm.mean != null) ? dm.mean - em.mean : null;
  };
  const gA = gapOf(playerA.id), gB = gapOf(playerB.id);
  const AGREE_FP = 2;

  if (gA == null || gB == null)
    return wrap(html`The lean matters most where Expert and Data disagree. ${fallback}${caveat}`);

  if (Math.abs(gA) < AGREE_FP && Math.abs(gB) < AGREE_FP)
    return wrap(html`Expert and Data agree closely on both <strong>${playerA.name}</strong> and <strong>${playerB.name}</strong> this week (within ${AGREE_FP} fp), so the slider barely changes this call.`);

  const big = Math.abs(gA) >= Math.abs(gB) ? { p: playerA, g: gA } : { p: playerB, g: gB };
  const reliable = calBetter[big.p.position];
  const pw = posWord[big.p.position] ?? "player";
  const rec = reliable
    ? html`For a ${pw} floor read, the <strong>${reliable}</strong> side is the more reliable one, so lean that way for the better-calibrated floor.`
    : html`For receivers the two sides are about equally calibrated, so this is a judgment call — weigh the matchup more than the lean.`;
  return wrap(html`Expert and Data split most on <strong>${big.p.name}</strong> — about ${Math.abs(big.g).toFixed(1)} fp apart this week. ${rec}${caveat}`);
}
Code
SRC_KEY = ({ Blend: "cross_blend", Expert: "expert_marginal", Data: "data_marginal" })

quantileDensity = function(r) {
  const q = [r.p10, r.p25, r.p50, r.p75, r.p90];
  if (q.some(v => v == null || isNaN(v))) return [];
  const minW = 0.5;
  const w = [Math.max(q[1]-q[0],minW), Math.max(q[2]-q[1],minW), Math.max(q[3]-q[2],minW), Math.max(q[4]-q[3],minW)];
  const dens = [0.15/w[0], 0.25/w[1], 0.25/w[2], 0.15/w[3]];
  return [
    { x: q[0] - 0.6*w[0], y: 0 },
    { x: q[0], y: dens[0] },
    { x: q[1], y: (dens[0]+dens[1])/2 },
    { x: q[2], y: (dens[1]+dens[2])/2 },
    { x: q[3], y: (dens[2]+dens[3])/2 },
    { x: q[4], y: dens[3] },
    { x: q[4] + 0.6*w[3], y: 0 }
  ];
}

// Abramowitz & Stegun approximation for the standard normal CDF.
normCDF = function(z) {
  const t = 1 / (1 + 0.2316419 * Math.abs(z));
  const d = 0.3989423 * Math.exp(-z * z / 2);
  const p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.7814779 + t * (-1.8212560 + t * 1.3302744))));
  return z > 0 ? 1 - p : p;
}

// Approximate std from 10th and 90th percentiles: (p90 - p10) / (2 * qnorm(0.9) = 2.564).
stdFromPercentiles = function(r) {
  if (r.p90 == null || r.p10 == null || r.p90 <= r.p10) return 1;
  return (r.p90 - r.p10) / 2.564;
}

// Difference distribution quantiles (A − B) under independence, normal approximation.
diffQuantiles = function(rA, rB) {
  if (!rA || !rB || rA.mean == null || rB.mean == null) return null;
  const mu = rA.mean - rB.mean;
  const sig = Math.sqrt(stdFromPercentiles(rA) ** 2 + stdFromPercentiles(rB) ** 2);
  const pAgtB = sig > 0 ? normCDF(mu / sig) : (mu > 0 ? 1 : mu < 0 ? 0 : 0.5);
  return {
    mean: mu,
    p10: mu - 1.282 * sig,
    p25: mu - 0.674 * sig,
    p50: mu,
    p75: mu + 0.674 * sig,
    p90: mu + 1.282 * sig,
    pAgtB
  };
}
Code
{
  if (seasonInDev) return html``;
  if (selectedWeek === "all weeks" || !playerA || !playerB)
    return html`<div style="color:var(--rc-muted);margin-top:0.5rem;">Select a week and two receivers above.</div>`;

  const key = SRC_KEY[cmpSource];
  const th = positionThresholds[position][cmpSlot];
  const colA = palette.accent, colB = palette.data;
  const MARGIN = 0.03;

  const rowsA = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === playerA.id);
  const rowsB = predictives.filter(d => Number(d.week) === Number(selectedWeek) && d.player_id === playerB.id);
  const mapA = new Map(rowsA.map(r => [r.predictive, r]));
  const mapB = new Map(rowsB.map(r => [r.predictive, r]));
  const rowA = mapA.get(key);
  const rowB = mapB.get(key);

  if (!rowA || !rowB)
    return html`<div style="color:var(--rc-muted);">No ${cmpSource} projection for both players in week ${selectedWeek}.</div>`;

  // For the Blend source, threshold bars and probability numbers use the slider's
  // lean mix of each player's two marginals (blendField); Expert/Data read their
  // own row. Density and difference-distribution visuals keep each source's own
  // percentiles (at lean=0.5 the stored cross_blend already equals the mix).
  // rowLead/rowLose in the takeaway are object-identical to rowA/rowB.
  const margOf = new Map([[rowA, [mapA.get("expert_marginal"), mapA.get("data_marginal")]],
                          [rowB, [mapB.get("expert_marginal"), mapB.get("data_marginal")]]]);
  const pget = (row, t) => {
    const col = `p_${slotColFor(cmpSlot, row.position)}_${t}`;
    if (cmpSource === "Blend") { const m = margOf.get(row) || []; return blendField(m[0], m[1], lean, col); }
    return row[col];
  };
  const pctTxt = v => v != null ? Math.round(100 * v) + "%" : "—";

  // ---- same-team warning ----------------------------------------------------
  const sameTeam = playerA.team && playerB.team && playerA.team === playerB.team;
  const teamWarning = sameTeam
    ? html`<div style="background:#fff3cd;border:1px solid #e6ac00;border-radius:4px;padding:0.65rem 1rem;margin-bottom:1rem;font-size:0.85rem;">
        <strong>⚠️ Same-team comparison (${playerA.team}).</strong> ${playerA.name} and ${playerB.name} share a game environment and compete for targets, so their scores are positively correlated on game conditions and negatively correlated on target share. The difference distribution below assumes independence and will be narrower than the true distribution — treat the contrast as a rough guide only.
      </div>`
    : html``;

  // ---- probability bars -----------------------------------------------------
  const pAF = pget(rowA,"floor"), pBF = pget(rowB,"floor");
  const pAT = pget(rowA,"target"), pBT = pget(rowB,"target");
  const pAC = pget(rowA,"ceiling"), pBC = pget(rowB,"ceiling");

  const bar = (v, col) => {
    const w = v != null ? 100 * v : 0;
    return html`<div style="display:flex;align-items:center;gap:6px;margin:1px 0;">
      <div style="flex:1;background:var(--rc-sand-panel);border-radius:3px;height:14px;">
        <div style="width:${w.toFixed(1)}%;background:${col};height:100%;border-radius:3px;"></div>
      </div>
      <span style="width:38px;text-align:right;font-size:0.78rem;">${pctTxt(v)}</span>
    </div>`;
  };
  const pairBar = (t) => html`<div style="margin-bottom:0.7rem;">
    <div style="font-size:0.85rem;margin-bottom:2px;"><strong>${t[0].toUpperCase()+t.slice(1)} — ${th[t]} fp</strong></div>
    <div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:2px;">${playerA.name}</div>${bar(pget(rowA,t), colA)}
    <div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:2px;">${playerB.name}</div>${bar(pget(rowB,t), colB)}
  </div>`;

  // ---- individual density plot ----------------------------------------------
  const densA = quantileDensity(rowA), densB = quantileDensity(rowB);
  const xs = [];
  for (const r of [rowA, rowB]) {
    if (r.p90 != null) xs.push(r.p90);
    if (r.mean != null) xs.push(r.mean);
    if (r.realized_fp != null) xs.push(r.realized_fp);
  }
  const xdom = [0, Math.ceil((d3.max(xs) ?? 40) + 2)];
  const ymax = Math.max(d3.max(densA, d => d.y) ?? 1, d3.max(densB, d => d.y) ?? 1);
  const indivMarks = [
    Plot.areaY(densA, { x:"x", y:"y", fill:colA, fillOpacity:0.18, curve:"basis" }),
    Plot.areaY(densB, { x:"x", y:"y", fill:colB, fillOpacity:0.18, curve:"basis" }),
    Plot.lineY(densA, { x:"x", y:"y", stroke:colA, strokeWidth:1.5, curve:"basis" }),
    Plot.lineY(densB, { x:"x", y:"y", stroke:colB, strokeWidth:1.5, curve:"basis" }),
    Plot.ruleX([rowA.mean], { stroke:colA, strokeDasharray:"4,3" }),
    Plot.ruleX([rowB.mean], { stroke:colB, strokeDasharray:"4,3" }),
    Plot.ruleY([0], { stroke:"#ddd" })
  ];
  if (rowA.realized_fp != null) indivMarks.push(Plot.ruleX([rowA.realized_fp], { stroke:colA, strokeWidth:3 }));
  if (rowB.realized_fp != null) indivMarks.push(Plot.ruleX([rowB.realized_fp], { stroke:colB, strokeWidth:3 }));
  const indivPlot = Plot.plot({
    width:700, height:175, marginTop:4, marginBottom:28, marginLeft:10, marginRight:10,
    x:{ domain:xdom, label:"fantasy points (0.5 PPR) →", labelAnchor:"center" },
    y:{ axis:null, domain:[0, ymax*1.15] },
    marks: indivMarks
  });

  // ---- difference distribution ----------------------------------------------
  const dq = diffQuantiles(rowA, rowB);
  let diffPanel = html``;
  if (dq) {
    const diffDens = quantileDensity(dq);
    const xlo = Math.floor(dq.p10 - 1), xhi = Math.ceil(dq.p90 + 1);
    const xdiff = [Math.min(xlo, -1), Math.max(xhi, 1)];
    const dymax = d3.max(diffDens, d => d.y) ?? 1;
    // Interpolate density y at x=0 so both shaded areas meet at one point,
    // producing a single continuous distribution rather than two separate humps.
    const y0 = (() => {
      for (let i = 0; i < diffDens.length - 1; i++) {
        const lo = diffDens[i], hi = diffDens[i + 1];
        if (lo.x <= 0 && hi.x >= 0) {
          if (hi.x === lo.x) return (lo.y + hi.y) / 2;
          return lo.y + (0 - lo.x) / (hi.x - lo.x) * (hi.y - lo.y);
        }
      }
      return 0;
    })();
    const splitPt = { x: 0, y: y0 };
    const negArea = [...diffDens.filter(d => d.x < 0), splitPt];
    const posArea = [splitPt, ...diffDens.filter(d => d.x > 0)];
    const diffMarks = [
      Plot.areaY(negArea, { x:"x", y:"y", fill:"#aaa", fillOpacity:0.40, curve:"linear" }),
      Plot.areaY(posArea, { x:"x", y:"y", fill:colA, fillOpacity:0.30, curve:"linear" }),
      Plot.lineY(diffDens, { x:"x", y:"y", stroke:"#555", strokeWidth:1.5, curve:"basis" }),
      Plot.ruleX([0], { stroke:"#bbb", strokeWidth:1 }),
      Plot.ruleX([dq.mean], { stroke:colA, strokeDasharray:"4,3", strokeWidth:1.5 }),
      Plot.ruleY([0], { stroke:"#ddd" })
    ];
    const diffPlot = Plot.plot({
      width:700, height:130, marginTop:4, marginBottom:28, marginLeft:10, marginRight:10,
      x:{ domain:xdiff, label:"Player A − Player B (fantasy points) →", labelAnchor:"center" },
      y:{ axis:null, domain:[0, dymax*1.2] },
      marks: diffMarks
    });
    const pAgtBPct = Math.round(100 * dq.pAgtB);
    const meanSign = dq.mean >= 0 ? "+" : "";
    diffPanel = html`<div style="margin-top:1.2rem;">
      <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.35rem;">${cmpSource} Expected FP Difference Distribution (Player A − Player B)</div>
      <div style="font-size:0.9rem;margin-bottom:0.4rem;">
        <strong>P(${playerA.name} outscores ${playerB.name}): ~${pAgtBPct}%</strong>
        <span style="color:var(--rc-muted);margin-left:0.7rem;font-size:0.78rem;">normal approximation · independence assumed${sameTeam ? " · same-team caveat applies" : ""}</span>
      </div>
      ${diffPlot}
      <div style="font-size:0.74rem;color:var(--rc-muted);margin-top:2px;">This curve shows the full range of possible score gaps for this week. Green shading covers the outcomes where Player A outscores Player B — that region's share of the total is the P(A outscores B) figure above. Grey covers the reverse. Dashed line: mean projected difference (${meanSign}${dq.mean.toFixed(1)} fp).</div>
    </div>`;
  }

  // ---- three-case takeaway --------------------------------------------------
  const dF = pAF - pBF, dT = pAT - pBT, dC = pAC - pBC;
  const allSmall = Math.abs(dF) < MARGIN && Math.abs(dT) < MARGIN && Math.abs(dC) < MARGIN;
  const floorLeadsA = dF >= 0, targetLeadsA = dT >= 0, ceilingLeadsA = dC >= 0;
  // Divergent: floor and ceiling favour different players (any magnitude).
  const divergent = !allSmall && floorLeadsA !== ceilingLeadsA;

  let takeaway;
  if (allSmall) {
    takeaway = `At the ${cmpSlot} position, ${playerA.name} and ${playerB.name} have nearly identical projected odds at every threshold — all differences fall within the model's calibration margin of roughly two to three percentage points. This is a coin flip; go with your gut or consider matchup context not captured by the model.`;
  } else if (divergent) {
    // Floor leader and ceiling leader are always opposite in this branch.
    const floorName  = floorLeadsA   ? playerA.name : playerB.name;
    const ceilName   = ceilingLeadsA ? playerA.name : playerB.name;
    const pF  = floorLeadsA   ? Math.round(100*pAF) : Math.round(100*pBF);
    const pFo = floorLeadsA   ? Math.round(100*pBF) : Math.round(100*pAF);
    const pC  = ceilingLeadsA ? Math.round(100*pAC) : Math.round(100*pBC);
    const pCo = ceilingLeadsA ? Math.round(100*pBC) : Math.round(100*pAC);
    if (Math.abs(dT) >= MARGIN) {
      // Target gap is meaningful — name a clearer safer vs upside pick.
      const saferName  = targetLeadsA ? playerA.name : playerB.name;
      const upsideName = targetLeadsA ? playerB.name : playerA.name;
      const saferT  = targetLeadsA ? Math.round(100*pAT) : Math.round(100*pBT);
      const upsideT = targetLeadsA ? Math.round(100*pBT) : Math.round(100*pAT);
      takeaway = `Floor-vs-ceiling tradeoff at the ${cmpSlot} position. ${saferName} is the safer play — ${saferT}% vs ${upsideT}% chance of clearing the target (${th.target} fp) — and the more reliable floor (${pF}% vs ${pFo}% at ${th.floor} fp). ${upsideName} carries more upside: ${pC}% vs ${pCo}% at the ceiling (${th.ceiling} fp). Start ${saferName} if you need a dependable week; lean ${upsideName} if you're chasing a spike.`;
    } else {
      // Target within margin — coin flip on target, but profiles differ.
      takeaway = `These two are a coin flip at the ${cmpSlot} target (${th.target} fp) — that difference falls within the model's calibration margin of roughly two to three percentage points. Their profiles differ, though: ${floorName} has the edge as a floor play (${pF}% vs ${pFo}% at ${th.floor} fp), while ${ceilName} carries more upside (${pC}% vs ${pCo}% at the ceiling). Lean ${floorName} if you need a safe start; lean ${ceilName} if you're chasing a spike week.`;
    }
  } else {
    const leadsA = (dF + dT + dC) >= 0;
    const rowLead = leadsA ? rowA : rowB;
    const rowLose = leadsA ? rowB : rowA;
    const leaderName = leadsA ? playerA.name : playerB.name;
    const gaps = [
      { label:"floor",   threshold:th.floor,   diff:Math.abs(dF) },
      { label:"target",  threshold:th.target,  diff:Math.abs(dT) },
      { label:"ceiling", threshold:th.ceiling, diff:Math.abs(dC) }
    ].filter(g => g.diff >= MARGIN).sort((a,b) => b.diff - a.diff);
    const bigGap = gaps[0];
    let txt = `${leaderName} projects as the stronger ${cmpSlot} play overall.`;
    if (bigGap) {
      txt += ` The clearest edge is at the ${bigGap.label} (${bigGap.threshold} fp): ${pctTxt(pget(rowLead, bigGap.label))} vs ${pctTxt(pget(rowLose, bigGap.label))}.`;
    }
    txt += ` Differences of less than two to three percentage points are within the model's calibration margin.`;
    takeaway = txt;
  }

  // ---- legend ---------------------------------------------------------------
  const legend = html`<div style="font-size:0.76rem;color:var(--rc-muted);margin-bottom:0.5rem;">
    <span style="display:inline-block;width:10px;height:10px;background:${colA};border-radius:2px;margin-right:4px;vertical-align:middle;"></span>${playerA.name}
    <span style="display:inline-block;width:10px;height:10px;background:${colB};border-radius:2px;margin:0 4px 0 12px;vertical-align:middle;"></span>${playerB.name}
  </div>`;

  const caveatNote = html`<div style="background:#e8f4f8;border-left:4px solid #5bc0de;border-radius:0 4px 4px 0;padding:0.6rem 1rem;margin:0.9rem 0 0.5rem;font-size:0.83rem;">
    <strong>Note — beta approximation.</strong> The difference distribution below assumes the two players' scores are statistically independent. This is reasonable for players on different teams; for teammates, the true distribution is wider (shared game environment, competing targets). The normal approximation is fit to five percentile points, so treat it as an indication of direction and rough magnitude rather than a precise probability.
  </div>`;

  const diffBox = dq
    ? html`<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:1rem 1.1rem;margin-top:0.2rem;">${diffPanel}</div>`
    : html``;

  return html`<div class="plot-panel" style="margin-top:0.8rem;">
    <div style="font-weight:700;font-size:1.1rem;margin-bottom:0.3rem;">Week ${selectedWeek}: ${playerA.name} <span style="font-weight:400;color:var(--rc-muted);">(ECR #${ecrDisp(playerA.position, playerA.posRank, position === "Flex")})</span> vs ${playerB.name} <span style="font-weight:400;color:var(--rc-muted);">(ECR #${ecrDisp(playerB.position, playerB.posRank, position === "Flex")})</span></div>
    ${legend}
    <div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start;">
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.4rem;">Chance of clearing each line (${cmpSource})</div>
        ${["floor","target","ceiling"].map(pairBar)}
      </div>
      <div>
        <div style="font-size:0.8rem;font-weight:600;color:var(--rc-muted);margin-bottom:0.3rem;">${cmpSource} Expected FP Distributions</div>
        ${indivPlot}
        <div style="font-size:0.76rem;color:var(--rc-muted);margin-top:2px;">Dashed line: projected mean — <span style="color:${colA}">${playerA.name} ${fmtFp(rowA.mean)} fp</span> and <span style="color:${colB}">${playerB.name} ${fmtFp(rowB.mean)} fp</span>. Solid line: realized score (completed weeks only).</div>
      </div>
    </div>
    <p style="margin-top:0.8rem;">${takeaway}</p>
    ${caveatNote}
    ${teamWarning}
    ${diffBox}
  </div>`;
}
Glossary — the terms used on this page
  • fp — fantasy points, scored half-PPR (half a point per reception).
  • ECR — Expert Consensus Rank, the FantasyPros average of expert rankings; the published order before any modeling.
  • Floor / Target / Ceiling — three scoring lines for a roster slot: a baseline week you can live with (floor), a strong startable week (target), and an elite, league-winning week (ceiling).
  • Expert — the model anchored to expert consensus, recalibrated against past results.
  • Data — the model built from player usage and game environment (snaps, targets, air yards, Vegas lines).
  • Blend — the deployed projection: an even 50/50 hedge of Expert and Data.
  • Chance of clearing (a line) — the probability a player scores at or above that floor, target, or ceiling.
  • 10–90% — the 10th-to-90th percentile range of the projected score: the middle 80% of likely outcomes.
  • Situation flags — the small badges (stable veteran, rookie, role change…) marking a player’s usage situation; hover a badge for its full name.

FFHedge · 2025 season validation archive · a reluctant criminologists project.