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:
@@ -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