Wave 3: Record by grade tier — shared TierRecord across dashboard, /u, ledger

Addition 2 (non-negotiable): the model's record must show PER GRADE TIER
(A+ went X-Y, A X-Y, …) everywhere the record appears. A blended % hides the
proof that higher grades win more — the tier calibration IS the credibility.

The backend already computed `by_tier` in getModelAggregate; this is display
propagation via ONE shared component (the class fix, not five one-offs).

- web/src/lib/tierRecord.js — testable CommonJS row-builder. W-L counts ALWAYS
  (honest at any n); hit-% only when the upstream n≥20 gate passed (hit_pct !=
  null), else "RECORD BUILDING · N settled". Order A+ A B C D F. A-tier is the
  only edge (green) tier — no glow below A, red reserved for outcomes.
- web/src/components/vyndr/TierRecord.tsx — the ONE shared table. Presentational
  (byTier) for /u + ledger; self-fetch (/api/ledger/model, sport-scoped) for the
  dashboard. Fully self-hides until a tier has a settled read.
- Ledger swaps its inline TierCalibration for the shared component (single
  source). /u PublicProfile renders it below the blended hero (by_tier added to
  the aggregate type; it already flows through the route + proxy untouched).
  Dashboard gains a compact per-tier surface.
- Endpoint/proxy audit: profiles.js + ledger.js return the full aggregate
  (by_tier included); both Next proxies pass the body through — no threading
  needed. No change to the gate or math in getModelAggregate.

Tests: tests/unit/tierRecord.test.js (row logic + tier order + edge contract +
source-assert all three surfaces render the shared component). ledgerService
test gains an A+-stands-alone bucketing case. Full suite green (243 suites /
2961 tests); web next build exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 13:58:01 -04:00
parent 4303aa68a4
commit 9bafc76092
8 changed files with 454 additions and 27 deletions
+176
View File
@@ -0,0 +1,176 @@
'use client';
import { useEffect, useState } from 'react';
import { buildTierRows } from '@/lib/tierRecord';
/**
* TierRecord (Wave 3, Addition 2) — the ONE shared record-by-grade-tier
* table. Rendered identically on the dashboard, the /u public profile, and
* the ledger, so the calibration story is told the same way everywhere.
*
* The tier gradient IS the credibility: A+ hits more than A, A more than B.
* Each tier shows its W-L record ALWAYS (honest even at small n); the hit-%
* only appears when the server already cleared the n≥20 gate (`hit_pct !=
* null`) — below it, "RECORD BUILDING · N settled". The gate lives in
* getModelAggregate; this component never re-derives it.
*
* COLOR CONTRACT: A-tier (A+/A) earns the green edge color; everything below
* A stays neutral. Red is reserved for settled-negative outcomes — never a
* tier label.
*
* TWO MODES:
* • presentational — pass `byTier` (ledger + /u already hold the aggregate).
* • self-fetch — pass `sport?` (dashboard) → reads /api/ledger/model.
* All three surfaces render THIS component (the class fix).
*/
interface TierBucket {
settled: number;
hits: number;
misses: number;
pushes?: number;
hit_pct: number | null;
}
export default function TierRecord({
byTier,
minSample = 20,
sport,
title = 'RECORD BY GRADE',
dense = false,
}: {
/** Presentational mode: the getModelAggregate().by_tier map. */
byTier?: Record<string, TierBucket> | null;
minSample?: number;
/** Self-fetch mode (no byTier): scope the /api/ledger/model read. */
sport?: string;
title?: string;
/** Compact layout for tighter surfaces (dashboard). */
dense?: boolean;
}) {
const [fetched, setFetched] = useState<{ by_tier?: Record<string, TierBucket>; min_sample?: number } | null>(null);
useEffect(() => {
if (byTier) return; // presentational — nothing to fetch
let active = true;
const params = new URLSearchParams({ limit: '1' });
if (sport) params.set('sport', sport.toLowerCase());
fetch(`/api/ledger/model?${params}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (!active || !data?.aggregate) return;
setFetched({ by_tier: data.aggregate.by_tier, min_sample: data.min_sample });
})
.catch(() => { /* self-hide */ });
return () => { active = false; };
}, [byTier, sport]);
const tiers = byTier || fetched?.by_tier || {};
const ms = byTier ? minSample : Number(fetched?.min_sample) > 0 ? Number(fetched?.min_sample) : minSample;
const rows = buildTierRows(tiers, ms);
if (rows.length === 0) return null; // no settled reads in any tier yet
return (
<div
className="mono"
style={{
marginTop: dense ? 0 : 14,
paddingTop: dense ? 0 : 12,
borderTop: dense ? 'none' : '1px solid var(--border)',
}}
>
<div
style={{
fontSize: 10.5,
fontWeight: 700,
letterSpacing: '0.14em',
color: 'var(--text-tertiary, #707080)',
marginBottom: 10,
}}
>
{title} · CALIBRATION
</div>
<div
role="table"
aria-label="Model hit rate by grade tier"
style={{
display: 'grid',
gridTemplateColumns: dense
? 'repeat(auto-fit, minmax(120px, 1fr))'
: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: dense ? 8 : 10,
}}
>
{rows.map((r) => (
<div
key={r.tier}
role="row"
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
padding: dense ? '10px 12px' : '12px 14px',
borderRadius: 8,
border: `1px solid ${r.edge ? 'var(--g-a, #00D4A0)' : 'var(--border-hi, #2A2A38)'}`,
// GLOW RESERVED FOR A-TIER ONLY — a faint green wash on A-tier,
// flat surface below. No red on any tier label.
background: r.edge
? 'color-mix(in srgb, var(--g-a, #00D4A0) 8%, transparent)'
: 'var(--bg-surface, #0C0C12)',
}}
>
{/* Tier label — green only for A-tier (the edge). */}
<span
style={{
fontSize: dense ? 13 : 15,
fontWeight: 800,
letterSpacing: '0.04em',
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
fontVariantNumeric: 'tabular-nums',
}}
>
{r.tier}-TIER
</span>
{/* W-L record — ALWAYS shown, honest at any n. */}
<span
style={{
fontSize: dense ? 15 : 17,
fontWeight: 800,
letterSpacing: '0.02em',
color: 'var(--text-primary, #F0F0F0)',
fontVariantNumeric: 'tabular-nums',
}}
>
{r.record}
</span>
{/* Hit-% ONLY past the n≥20 gate; else building copy. */}
{r.ready ? (
<span
style={{
fontSize: dense ? 11 : 12,
fontWeight: 700,
letterSpacing: '0.06em',
color: r.edge ? 'var(--g-a, #00D4A0)' : 'var(--text-secondary, #B8BCC8)',
fontVariantNumeric: 'tabular-nums',
}}
>
{r.hitPct}% HIT
</span>
) : (
<span
style={{
fontSize: dense ? 9.5 : 10.5,
fontWeight: 700,
letterSpacing: '0.06em',
color: 'var(--amber, #FFB347)',
}}
>
RECORD BUILDING · {r.settled} settled
</span>
)}
</div>
))}
</div>
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } f
export { default as ClaimMeter } from './ClaimMeter';
export { default as AccuracyBadge } from './AccuracyBadge';
export { default as ModelRecord } from './ModelRecord';
export { default as TierRecord } from './TierRecord';
/* Player Intelligence (Session 42) */
export { default as ArchetypeBadge } from './ArchetypeBadge';