brackt/app/routes/admin.sports-seasons.$id.expected-values.tsx
Claude 7ec17e417d
Fix EV reporting 20 pts for both LLWS 5-6 and 7-8 locked tiers
After an LLWS simulation, a team locked into the 5th-6th tier and one
locked into the 7th-8th tier both showed 20 points EV. They should show
25 and 15.

The simulator and calculateEV were both right. A team locked into the
5-6 tier comes out of llws-simulator at probFifth = probSixth = 0.5, and
against DEFAULT_SCORING_RULES (100/70/50/40/25/25/15/15) that is 25 —
matching calculateBracketPoints, which already knows llws_20 splits 5-8
into two tiers. The Admin -> Expected Values page just wasn't using that
table. It hardcoded its own stale copy:

  const SCORING = [100, 70, 45, 45, 20, 20, 20, 20] as const;

0.5*20 + 0.5*20 = 20 for either tier.

It is not LLWS-specific. Four places carried that same stale table, and
it stayed invisible because a standard single-elimination bracket puts
all four quarterfinal losers in one tier worth avg(25,25,15,15) = 20 —
the same number. It only diverges for the templates that split 5-8
(llws_20, afl_10) and those with a distinct 3rd/4th (llws_20, fifa_48,
where 45/45 should be 50/40). Two of the four *persist* EVs computed
that way, so the wrong values reached the database:

  - expected-values.tsx      displayed EV, the total, and the sort order
  - expected-values.server   manual EV entry, written to expected_value
  - golf-skills.tsx          simulation EVs + snapshots, written
  - surface-elo.tsx          simulation EVs + snapshots, written

All four now use the shared DEFAULT_SCORING_RULES. probability-updater
had a fourth inline copy with the right values; it is folded in too so
there is one table left. The page's 340 total-EV invariant is unchanged
— both tables sum to 340.

A second path collapses the same two tiers, this time in real fantasy
points. calculateBracketPoints falls back to the flat avg([5,6,7,8])
when bracketTemplateId is null, and four call sites resolved the
template by taking an arbitrary scoringEvents row for the sports season
— unordered, and not filtered to rows that actually carry a template. A
season can own several events (a bracket plus schedule events, or a
re-created bracket beside a stale one), so a null row wins at random and
llws_20 is lost. New getBracketTemplateIdsForSportsSeasons in
models/bracket-template.ts filters to events with a template and takes
the most recent, the same rule llws-simulator uses to pick its bracket
event; standings, calculateTeamScore, calculateTeamProjectedScore and
getDraftedParticipantsWithPoints all go through it.

Tests: evFromProbs pinned to 25 / 15 / 20-for-a-single-5-8-tier and the
340 invariant; the new lookup against a mixed set of events; and two
llws-simulator tests that play out a full U.S. side so a team really is
locked into each tier and must come out at exactly 50/50 across it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
2026-08-27 16:11:59 +00:00

178 lines
7.8 KiB
TypeScript
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.

import { Link } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
import { loader } from "./admin.sports-seasons.$id.expected-values.server";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { ArrowLeft, Calculator } from "lucide-react";
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import { calculateEV } from "~/services/ev-calculator";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
export { loader };
// EV is shown on the same reference scale the runner persists it with: a sports season
// is shared across leagues with different scoring, so DEFAULT_SCORING_RULES is the
// common scale and each league re-derives its own EV from the stored probabilities
// (see getPersistenceContext in services/simulations/runner.ts).
//
// Scoring: 1st=100, 2nd=70, 3rd=50, 4th=40, 5th/6th=25 each, 7th/8th=15 each.
// Sum = 100+70+50+40+25+25+15+15 = 340.
//
// The 5th8th values must stay distinct rather than collapsing to a flat 20: templates
// that split that zone into two tiers (llws_20, afl_10) put a team locked into 5th6th
// at probFifth=probSixth=0.5 (EV 25) and one locked into 7th8th at
// probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20.
//
// Total EV invariant: Σ EV across all participants = Σ scoring values = 340,
// because each probability column sums to 1.0 across all participants.
// If total drifts from 340, likely causes:
// 1. Stale EV records from a prior simulation run (fix: re-run simulation, which now
// zeros non-bracket participants automatically)
// 2. DB precision truncation (numeric(6,4) = 4dp; max drift ≈ ±1 for 68 teams)
export function evFromProbs(ev: {
probFirst: string; probSecond: string; probThird: string; probFourth: string;
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
}): number {
return calculateEV(
{
probFirst: parseFloat(ev.probFirst),
probSecond: parseFloat(ev.probSecond),
probThird: parseFloat(ev.probThird),
probFourth: parseFloat(ev.probFourth),
probFifth: parseFloat(ev.probFifth),
probSixth: parseFloat(ev.probSixth),
probSeventh: parseFloat(ev.probSeventh),
probEighth: parseFloat(ev.probEighth),
},
DEFAULT_SCORING_RULES
);
}
function fmt(val: string | number) {
return (parseFloat(val as string) * 100).toFixed(1) + "%";
}
export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) {
const { sportsSeason, participants, existingEVs } = loaderData;
// Compute total and sort from stored prob columns, not stored expectedValue.
// The simulator normalizes per-position column sums to exactly 1.0 (step 10),
// so the total EV should always equal the sum of scoring values (340).
// Sum only over participants shown in the table — excludes orphan EV records
// from prior simulation runs for participants no longer in this season.
const sorted = [...participants].toSorted((a, b) => {
const evDataA = existingEVs.get(a.id);
const evA = evDataA ? evFromProbs(evDataA) : 0;
const evDataB = existingEVs.get(b.id);
const evB = evDataB ? evFromProbs(evDataB) : 0;
return evB - evA;
});
const totalEV = sorted.reduce((sum, p) => {
const ev = existingEVs.get(p.id);
return ev ? sum + evFromProbs(ev) : sum;
}, 0);
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center gap-4">
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Sports Season
</Button>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calculator className="h-5 w-5" />
Expected Values: {sportsSeason.sport.name} {sportsSeason.year}
</CardTitle>
<CardDescription>
Probability distributions from the last simulation run. Sorted by EV descending.
</CardDescription>
</CardHeader>
<CardContent>
{participants.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No participants found.
</p>
) : existingEVs.size === 0 ? (
<p className="text-center text-muted-foreground py-8">
No EV data yet. Run a simulation from the sports season page.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
<TableHead className="text-center">1st</TableHead>
<TableHead className="text-center">2nd</TableHead>
<TableHead className="text-center">3rd</TableHead>
<TableHead className="text-center">4th</TableHead>
<TableHead className="text-center">5th</TableHead>
<TableHead className="text-center">6th</TableHead>
<TableHead className="text-center">7th</TableHead>
<TableHead className="text-center">8th</TableHead>
<TableHead className="text-center">EV</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((participant) => {
const ev = existingEVs.get(participant.id);
return (
<TableRow key={participant.id}>
<TableCell className="font-medium">{participant.name}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFirst) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSecond) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probThird) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFourth) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFifth) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSixth) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSeventh) : "—"}</TableCell>
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probEighth) : "—"}</TableCell>
<TableCell className="text-center font-semibold">{ev ? evFromProbs(ev).toFixed(2) : "—"}</TableCell>
</TableRow>
);
})}
{existingEVs.size > 0 && (
<TableRow className="border-t-2 font-bold bg-muted/50">
<TableCell colSpan={9} className="text-right">
Total EV
{Math.abs(totalEV - 340) > 1 && (
<span className="ml-2 text-xs font-normal text-destructive">
(expected ~340; re-run simulation to fix stale data)
</span>
)}
</TableCell>
<TableCell className="text-center">{totalEV.toFixed(2)}</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}