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
+10 -1
View File
@@ -10,7 +10,7 @@ import { GradePill } from '@/components/GradeCard';
// below as intelligence layers on top of the raw odds.
import Slate from '@/components/Slate';
// Session 55 — the self-learning loop's track record, live in the header.
import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr';
import { AccuracyBadge, Skeleton, SkeletonList, TierRecord } from '@/components/vyndr';
// Session 57 (Phase 0) — honest per-sport empty-slate copy.
import { emptyStateCopy } from '@/lib/emptyState';
// Session 59 (work-order 2.3) — the real pipeline schedule for waiting states.
@@ -306,6 +306,15 @@ export default function DashboardPage() {
}}
/>
{/* Wave 3 (Addition 2) — the model's record BY GRADE TIER. Self-fetches
/api/ledger/model (scoped to the selected sport) and fully self-hides
until a tier has a settled read — no empty header. The SAME shared
TierRecord that renders on /u and the ledger; the calibration
gradient is the proof higher grades win more. */}
<div style={{ marginTop: 28 }}>
<TierRecord sport={sport} title="MODEL RECORD BY GRADE" dense />
</div>
{/* Top grades tonight — DS2 (#1, #13, Part 6). Tonight's grades RANKED on
the varying signal (selectTopGrades: tier → confidence → edge) so the
row isn't identical-weight noise; when tonight is empty we fall back to
+4 -26
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
import { useAuth } from '@/contexts/AuthContext';
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark } from '@/components/vyndr';
import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr';
import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay';
/**
@@ -177,30 +177,6 @@ export default function LedgerPage() {
);
}
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
/** Session 60 (5.5) — per-tier calibration: the separation between tiers is
* the proof the grades mean something. A tier under n≥20 shows "building",
* never a small-sample percentage. Self-hides until ANY tier is ready. */
function TierCalibration({ agg, minSample }: { agg: ModelAggregate; minSample: number }) {
const tiers = agg.by_tier || {};
const anyReady = TIER_ORDER.some((t) => tiers[t] && tiers[t].hit_pct != null);
if (!anyReady) return null;
return (
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{TIER_ORDER.filter((t) => tiers[t] && tiers[t].settled > 0).map((t) => {
const b = tiers[t];
const ready = b.hit_pct != null;
return (
<span key={t} className="mono" style={{ fontSize: 11.5, fontWeight: 700, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-hi)', color: ready ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>
{t}-TIER · {ready ? `${b.hits}-${b.misses} · ${b.hit_pct}%` : `building (${b.settled}/${minSample})`}
</span>
);
})}
</div>
);
}
/** S6 (A1 board) · reframed DS4 — settled-CLV display. The n≥20 gate lives
* in the server (clv_distribution set). DESIGN-SPEC Part 3 #16: a near-flat
* distribution renders as one giant bar + six empty stubs — it reads as
@@ -305,7 +281,9 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe
</div>
)}
<ClvDistribution agg={agg} />
<TierCalibration agg={agg} minSample={minSample} />
{/* Wave 3 (Addition 2) — the ONE shared record-by-grade-tier table,
identical here, on the dashboard, and on /u. */}
<TierRecord byTier={agg.by_tier} minSample={minSample} />
</div>
);
}
+15
View File
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
import { GradePill } from '@/components/GradeCard';
import PlayerAvatar from '@/components/vyndr/PlayerAvatar';
import BookWordmark from '@/components/vyndr/BookWordmark';
import TierRecord from '@/components/vyndr/TierRecord';
import { buildTierRows } from '@/lib/tierRecord';
/**
* PublicProfile (A1 Session 10) — the public ledger record for one handle.
@@ -37,6 +39,8 @@ interface ProfileRow {
revised_from_grade?: string | null;
}
interface TierBucket { settled: number; hits: number; misses: number; pushes?: number; hit_pct: number | null }
interface ProfileAggregate {
settled: number;
hits: number;
@@ -47,6 +51,9 @@ interface ProfileAggregate {
clv_beat: number;
beat_close_pct: number | null;
pending: number;
// Wave 3 (Addition 2) — per-tier calibration (survives the API + proxy
// untouched, inside the aggregate). The credibility centerpiece.
by_tier?: Record<string, TierBucket>;
}
interface ProfilePayload {
@@ -126,6 +133,14 @@ export default function PublicProfile({ handle }: { handle: string }) {
{/* Record header — never a percentage under min_sample settles. */}
<RecordHeader agg={agg} minSample={minSample} />
{/* Wave 3 (Addition 2) — the record BY GRADE TIER, the proof that higher
grades win more. Same shared component as the dashboard + ledger. */}
{agg && buildTierRows(agg.by_tier || {}).length > 0 && (
<div className="surface diagonal-cut" style={{ padding: 20, marginBottom: 20 }}>
<TierRecord byTier={agg.by_tier} minSample={minSample} dense />
</div>
)}
{data.entries.length === 0 ? (
<div className="surface diagonal-cut" style={{ padding: 48, textAlign: 'center' }}>
<p className="mono" style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-tertiary)', marginBottom: 8 }}>