9bafc76092
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>
100 lines
3.6 KiB
JavaScript
100 lines
3.6 KiB
JavaScript
/* ============================================================
|
|
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,
|
|
};
|