2026-03-09 15:34:31 -07:00
|
|
|
|
import { Link } from "react-router";
|
2025-11-17 22:19:46 -08:00
|
|
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
|
2026-03-10 12:10:52 -07:00
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
|
import { loader } from "./admin.sports-seasons.$id.expected-values.server";
|
2025-11-17 22:19:46 -08:00
|
|
|
|
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";
|
2026-03-09 15:34:31 -07:00
|
|
|
|
import { ArrowLeft, Calculator } from "lucide-react";
|
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
|
|
|
|
import { DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
|
|
|
|
|
|
import { calculateEV } from "~/services/ev-calculator";
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
2026-03-10 12:10:52 -07:00
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
|
|
|
|
return [{ title: `Expected Values — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
|
export { loader };
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
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
|
|
|
|
// 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 5th–8th 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 5th–6th
|
|
|
|
|
|
// at probFifth=probSixth=0.5 (EV 25) and one locked into 7th–8th at
|
|
|
|
|
|
// probSeventh=probEighth=0.5 (EV 15). A flat table reports both as 20.
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
//
|
|
|
|
|
|
// 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)
|
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
|
|
|
|
export function evFromProbs(ev: {
|
2026-03-09 15:34:31 -07:00
|
|
|
|
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
|
|
|
|
|
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
|
|
|
|
|
}): number {
|
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
|
|
|
|
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
|
|
|
|
|
|
);
|
2026-03-09 15:34:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmt(val: string | number) {
|
|
|
|
|
|
return (parseFloat(val as string) * 100).toFixed(1) + "%";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) {
|
2025-11-17 22:19:46 -08:00
|
|
|
|
const { sportsSeason, participants, existingEVs } = loaderData;
|
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
|
// 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.
|
2026-03-21 09:44:05 -07:00
|
|
|
|
const sorted = [...participants].toSorted((a, b) => {
|
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
|
|
|
|
const evDataA = existingEVs.get(a.id);
|
|
|
|
|
|
const evA = evDataA ? evFromProbs(evDataA) : 0;
|
|
|
|
|
|
const evDataB = existingEVs.get(b.id);
|
|
|
|
|
|
const evB = evDataB ? evFromProbs(evDataB) : 0;
|
2026-03-09 15:34:31 -07:00
|
|
|
|
return evB - evA;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const totalEV = sorted.reduce((sum, p) => {
|
|
|
|
|
|
const ev = existingEVs.get(p.id);
|
|
|
|
|
|
return ev ? sum + evFromProbs(ev) : sum;
|
|
|
|
|
|
}, 0);
|
2026-02-19 15:57:05 -08:00
|
|
|
|
|
2025-11-17 22:19:46 -08:00
|
|
|
|
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>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
Probability distributions from the last simulation run. Sorted by EV descending.
|
2025-11-17 22:19:46 -08:00
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
<CardContent>
|
|
|
|
|
|
{participants.length === 0 ? (
|
|
|
|
|
|
<p className="text-center text-muted-foreground py-8">
|
|
|
|
|
|
No participants found.
|
2025-11-17 22:19:46 -08:00
|
|
|
|
</p>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
) : 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>
|
|
|
|
|
|
) : (
|
2026-02-19 15:57:05 -08:00
|
|
|
|
<Table>
|
|
|
|
|
|
<TableHeader>
|
|
|
|
|
|
<TableRow>
|
|
|
|
|
|
<TableHead>Participant</TableHead>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
<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>
|
2026-02-19 15:57:05 -08:00
|
|
|
|
<TableHead className="text-center">EV</TableHead>
|
|
|
|
|
|
</TableRow>
|
|
|
|
|
|
</TableHeader>
|
|
|
|
|
|
<TableBody>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
{sorted.map((participant) => {
|
|
|
|
|
|
const ev = existingEVs.get(participant.id);
|
2026-02-19 15:57:05 -08:00
|
|
|
|
return (
|
2026-03-09 15:34:31 -07:00
|
|
|
|
<TableRow key={participant.id}>
|
2026-02-19 15:57:05 -08:00
|
|
|
|
<TableCell className="font-medium">{participant.name}</TableCell>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
<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>
|
2026-02-19 15:57:05 -08:00
|
|
|
|
</TableRow>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{existingEVs.size > 0 && (
|
|
|
|
|
|
<TableRow className="border-t-2 font-bold bg-muted/50">
|
Add NCAAM and NCAAW bracket Monte Carlo simulators (#150)
- NCAAM: KenPom AEM logistic formula (1/(1+exp(-diff/7.5))), data through 2025-26 March 15
- NCAAW: Barttorvik Barthag Log5 formula (A*(1-B)/(A*(1-B)+B*(1-A))), same bracket structure
- Both simulators: 50,000-iteration Monte Carlo, First Four simulation, honors completed matches
- Track E8+ placements only: champion, finalist, FF losers (3rd/4th), E8 losers (5th–8th)
- Add bracket configuration validation: null R64 slots must exactly match First Four mapping
- Fix DEFAULT_SCORING_RULES to 100/70/45/45/20/20/20/20 (3rd/4th=45, 5th–8th=20)
- Align scoring constants across simulate route, expected-values display, and server action
- Zero out EVs for non-bracket participants on every simulation run (prevents EV inflation)
- Add EV total invariant warning (expected ~340) on expected-values admin page
- 98 unit tests across NCAAM, NCAAW, and UCL simulators — all passing
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 23:31:47 -07:00
|
|
|
|
<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>
|
2026-02-19 15:57:05 -08:00
|
|
|
|
<TableCell className="text-center">{totalEV.toFixed(2)}</TableCell>
|
2025-11-17 22:19:46 -08:00
|
|
|
|
</TableRow>
|
2026-02-19 15:57:05 -08:00
|
|
|
|
)}
|
|
|
|
|
|
</TableBody>
|
|
|
|
|
|
</Table>
|
2026-03-09 15:34:31 -07:00
|
|
|
|
)}
|
2025-11-17 22:19:46 -08:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|