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:
@@ -251,6 +251,25 @@ describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => {
|
||||
expect(agg.by_tier.B.settled).toBe(3);
|
||||
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
|
||||
});
|
||||
|
||||
test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => {
|
||||
const sb = fakeSb();
|
||||
const rows = [
|
||||
...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })),
|
||||
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
|
||||
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
|
||||
];
|
||||
sb._state.selectResults = [rows];
|
||||
sb._state.countResult = 0;
|
||||
const agg = await ledger.getModelAggregate({ sb });
|
||||
// A+ is its own bucket — never merged into the first-letter A bucket.
|
||||
expect(agg.by_tier['A+'].settled).toBe(5);
|
||||
expect(agg.by_tier['A+'].hits).toBe(5);
|
||||
// A + A- fold together by first letter (but NOT A+).
|
||||
expect(agg.by_tier.A.settled).toBe(6);
|
||||
expect(agg.by_tier.A.hits).toBe(4);
|
||||
expect(agg.by_tier.A.misses).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Session 61 — the odds index prefers a fully-priced book row (the null
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Wave 3 (Addition 2) — RECORD BY GRADE TIER.
|
||||
// The tier calibration IS the credibility: A+ wins more than A, A more than
|
||||
// B. This suite locks (a) the shared row-building logic, (b) the tier order,
|
||||
// and (c) that the ONE shared TierRecord component is imported + rendered on
|
||||
// all three record surfaces (dashboard, /u, ledger). Plain-JS Jest — the .tsx
|
||||
// is asserted against source (same pattern as publicProfilePage/vyndrAppShell).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const WEB = path.join(ROOT, 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
const exists = (rel) => fs.existsSync(path.join(WEB, rel));
|
||||
|
||||
const { buildTierRows, TIER_ORDER, anyTierReady, isEdgeTier } = require('../../web/src/lib/tierRecord');
|
||||
|
||||
describe('buildTierRows — honesty rules', () => {
|
||||
// A ≥20 tier carries a real hit_pct (gate applied upstream); a small tier
|
||||
// arrives with hit_pct null even though it has settles.
|
||||
const byTier = {
|
||||
'A+': { settled: 22, hits: 18, misses: 4, pushes: 0, hit_pct: 82 },
|
||||
A: { settled: 25, hits: 17, misses: 8, pushes: 0, hit_pct: 68 },
|
||||
B: { settled: 3, hits: 2, misses: 1, pushes: 0, hit_pct: null }, // small sample
|
||||
};
|
||||
|
||||
test('shows each tier W-L record ALWAYS, even at small n', () => {
|
||||
const rows = buildTierRows(byTier);
|
||||
const b = rows.find((r) => r.tier === 'B');
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.record).toBe('2-1'); // honest at n=3
|
||||
const ap = rows.find((r) => r.tier === 'A+');
|
||||
expect(ap.record).toBe('18-4');
|
||||
});
|
||||
|
||||
test('shows the hit-% ONLY when hit_pct != null; else RECORD BUILDING', () => {
|
||||
const rows = buildTierRows(byTier);
|
||||
const ap = rows.find((r) => r.tier === 'A+');
|
||||
expect(ap.ready).toBe(true);
|
||||
expect(ap.hitPct).toBe(82);
|
||||
|
||||
// Small-sample tier: no percentage, building copy with the settled count.
|
||||
const b = rows.find((r) => r.tier === 'B');
|
||||
expect(b.ready).toBe(false);
|
||||
expect(b.hitPct).toBeNull();
|
||||
expect(b.buildingLabel).toBe('RECORD BUILDING · 3 settled');
|
||||
});
|
||||
|
||||
test('never fabricates a 0% from a null hit_pct (Number(null)===0 guard)', () => {
|
||||
const rows = buildTierRows({ C: { settled: 5, hits: 0, misses: 0, pushes: 0, hit_pct: null } });
|
||||
expect(rows[0].hitPct).toBeNull();
|
||||
expect(rows[0].ready).toBe(false);
|
||||
});
|
||||
|
||||
test('omits tiers with zero settled reads (no fake empty rows)', () => {
|
||||
const rows = buildTierRows({ A: { settled: 0, hits: 0, misses: 0, hit_pct: null }, B: { settled: 4, hits: 3, misses: 1, hit_pct: null } });
|
||||
expect(rows.map((r) => r.tier)).toEqual(['B']);
|
||||
});
|
||||
|
||||
test('empty / missing map → no rows (self-hides)', () => {
|
||||
expect(buildTierRows(null)).toEqual([]);
|
||||
expect(buildTierRows(undefined)).toEqual([]);
|
||||
expect(buildTierRows({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tier order + edge color contract', () => {
|
||||
test('renders A+ → F in canonical order', () => {
|
||||
expect(TIER_ORDER).toEqual(['A+', 'A', 'B', 'C', 'D', 'F']);
|
||||
});
|
||||
|
||||
test('buildTierRows preserves A+ → F order regardless of map insertion order', () => {
|
||||
const rows = buildTierRows({
|
||||
F: { settled: 2, hits: 0, misses: 2, hit_pct: null },
|
||||
A: { settled: 3, hits: 2, misses: 1, hit_pct: null },
|
||||
'A+': { settled: 4, hits: 4, misses: 0, hit_pct: null },
|
||||
C: { settled: 5, hits: 2, misses: 3, hit_pct: null },
|
||||
});
|
||||
expect(rows.map((r) => r.tier)).toEqual(['A+', 'A', 'C', 'F']);
|
||||
});
|
||||
|
||||
test('GLOW RESERVED FOR A-TIER — only A+/A are edge tiers', () => {
|
||||
expect(isEdgeTier('A+')).toBe(true);
|
||||
expect(isEdgeTier('A')).toBe(true);
|
||||
expect(isEdgeTier('B')).toBe(false);
|
||||
expect(isEdgeTier('C')).toBe(false);
|
||||
expect(isEdgeTier('F')).toBe(false);
|
||||
});
|
||||
|
||||
test('anyTierReady reflects the upstream n≥20 gate', () => {
|
||||
expect(anyTierReady({ A: { settled: 5, hits: 3, misses: 2, hit_pct: null } })).toBe(false);
|
||||
expect(anyTierReady({ A: { settled: 25, hits: 17, misses: 8, hit_pct: 68 } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ONE shared component reaches all three record surfaces', () => {
|
||||
test('TierRecord.tsx exists and is exported from the vyndr barrel', () => {
|
||||
expect(exists('components/vyndr/TierRecord.tsx')).toBe(true);
|
||||
expect(read('components/vyndr/index.ts')).toContain("export { default as TierRecord } from './TierRecord'");
|
||||
});
|
||||
|
||||
test('TierRecord renders W-L always + % only when ready, else BUILDING', () => {
|
||||
const src = read('components/vyndr/TierRecord.tsx');
|
||||
expect(src).toContain('buildTierRows');
|
||||
expect(src).toContain('{r.record}'); // W-L always
|
||||
expect(src).toContain('{r.hitPct}% HIT'); // % only in the ready branch
|
||||
expect(src).toContain('RECORD BUILDING · {r.settled} settled');
|
||||
});
|
||||
|
||||
test('/u PublicProfile imports AND renders TierRecord', () => {
|
||||
const src = read('app/u/[handle]/PublicProfile.tsx');
|
||||
expect(src).toMatch(/import TierRecord from '@\/components\/vyndr\/TierRecord'/);
|
||||
expect(src).toContain('<TierRecord');
|
||||
expect(src).toContain('by_tier'); // the aggregate carries the tier buckets
|
||||
});
|
||||
|
||||
test('dashboard imports AND renders TierRecord', () => {
|
||||
const src = read('app/dashboard/page.tsx');
|
||||
expect(src).toMatch(/TierRecord/);
|
||||
expect(src).toContain('<TierRecord');
|
||||
});
|
||||
|
||||
test('ledger uses the shared TierRecord (single source, no inline table)', () => {
|
||||
const src = read('app/ledger/page.tsx');
|
||||
expect(src).toContain('TierRecord');
|
||||
expect(src).toContain('<TierRecord');
|
||||
// the old inline per-tier component is gone (folded into the shared one)
|
||||
expect(src).not.toContain('function TierCalibration');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* ============================================================
|
||||
VYNDR — RECORD BY GRADE TIER (Wave 3, Addition 2).
|
||||
Plain CommonJS so the TierRecord.tsx component imports it AND
|
||||
the Jest suite can require it directly (no TS/Babel transform).
|
||||
|
||||
The credibility of the model IS the tier calibration: A+ wins
|
||||
more than A, A more than B, and so on. A single blended "67%"
|
||||
hides that gradient. This module turns a `by_tier` map (from
|
||||
ledgerService.getModelAggregate) into ordered, display-ready
|
||||
rows so EVERY surface (dashboard, /u, ledger) renders the same
|
||||
honest table.
|
||||
|
||||
HONESTY RULES (do not soften):
|
||||
- The W-L record (counts) is shown ALWAYS, even at small n —
|
||||
an honest 3-1 beats hiding it.
|
||||
- The hit-% is shown ONLY when that tier's `hit_pct != null`
|
||||
(the n≥20 gate is already applied UPSTREAM in
|
||||
getModelAggregate — never re-derive it here). Below the gate:
|
||||
"RECORD BUILDING · N settled".
|
||||
- A+ stands alone; A/B/C/D/F bucket by first letter (mirrors
|
||||
outcomeService + getModelAggregate's `tierOf`).
|
||||
============================================================ */
|
||||
|
||||
/* Canonical render order. A+ is its own bucket; the rest are
|
||||
first-letter buckets. Mirrors getModelAggregate.tierOf. */
|
||||
const TIER_ORDER = ['A+', 'A', 'B', 'C', 'D', 'F'];
|
||||
|
||||
/* A-tier (A+ / A) is the only bucket that earns the green edge
|
||||
color — "GLOW RESERVED FOR A-TIER ONLY" (design reference §05).
|
||||
Everything below A stays neutral/muted; red is reserved for
|
||||
settled-negative outcomes, never a tier label. */
|
||||
function isEdgeTier(tier) {
|
||||
return tier === 'A+' || tier === 'A';
|
||||
}
|
||||
|
||||
/* One tier's display row. `hitPct` is passed through verbatim —
|
||||
null means "below the n≥20 gate" and the row renders BUILDING. */
|
||||
function buildTierRow(tier, bucket, minSample) {
|
||||
const settled = Number(bucket && bucket.settled) || 0;
|
||||
const hits = Number(bucket && bucket.hits) || 0;
|
||||
const misses = Number(bucket && bucket.misses) || 0;
|
||||
const pushes = Number(bucket && bucket.pushes) || 0;
|
||||
// hit_pct is the gated value from the server — strict null check
|
||||
// (Number(null) === 0 would fabricate a 0% record).
|
||||
const hitPct = bucket && bucket.hit_pct != null ? Number(bucket.hit_pct) : null;
|
||||
const ready = hitPct != null;
|
||||
return {
|
||||
tier,
|
||||
settled,
|
||||
hits,
|
||||
misses,
|
||||
pushes,
|
||||
record: `${hits}-${misses}`,
|
||||
hitPct,
|
||||
ready,
|
||||
edge: isEdgeTier(tier),
|
||||
// BUILDING copy the UI shows in place of a small-sample %.
|
||||
buildingLabel: `RECORD BUILDING · ${settled} settled`,
|
||||
minSample: Number(minSample) || 20,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a `by_tier` map into ordered rows, one per tier that has
|
||||
* at least one settled read. Order is always A+ → F. Tiers with
|
||||
* zero settles are omitted (nothing to prove yet — never a fake
|
||||
* empty row).
|
||||
*
|
||||
* @param {Object} byTier getModelAggregate().by_tier
|
||||
* @param {number} [minSample=20]
|
||||
* @returns {Array} ordered display rows
|
||||
*/
|
||||
function buildTierRows(byTier, minSample = 20) {
|
||||
const map = byTier && typeof byTier === 'object' ? byTier : {};
|
||||
const rows = [];
|
||||
for (const tier of TIER_ORDER) {
|
||||
const bucket = map[tier];
|
||||
if (!bucket) continue;
|
||||
const row = buildTierRow(tier, bucket, minSample);
|
||||
if (row.settled <= 0) continue; // no settled reads → not shown
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** True when at least one tier has a real (gated) hit-% to show —
|
||||
* a surface can use this to decide whether to show the "PROVEN"
|
||||
* framing vs the plain building table. */
|
||||
function anyTierReady(byTier) {
|
||||
return buildTierRows(byTier).some((r) => r.ready);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TIER_ORDER,
|
||||
isEdgeTier,
|
||||
buildTierRow,
|
||||
buildTierRows,
|
||||
anyTierReady,
|
||||
};
|
||||
Reference in New Issue
Block a user