Layer 2: multi-axis archetype classifier; the FLEX fallback is gone

A player is a blend across independent axes, not one label. Skubal is a STARTER
and a strikeout arm and a ground-ball arm and a control arm — four true things
at once, and single-label classification threw three of them away.

AXIS INDEPENDENCE WAS MEASURED, NOT ASSUMED. Correlations over the live store
(467 batters, 531 pitchers); anything |r| >= 0.70 is one underlying trait and
was collapsed so we never show one trait as two archetypes. Batter k% ~ whiff%
+0.89, hard-hit% ~ exit velo +0.88, chase% ~ swing% +0.87, chase% ~ bb% -0.72;
pitcher k% ~ whiff% +0.76, gb% ~ fb% -0.73 — all collapsed.

The survivors are genuinely orthogonal, and one result is worth stating: pitcher
velocity correlates +0.14 with K%, +0.07 with whiff% and +0.07 with GB%.
Velocity is NOT a proxy for missing bats — a hard thrower who misses no bats is
a real distinct type, so CANNON earns its own axis rather than being folded into
STRIKEOUT. Pitcher K% ~ GB% is -0.10, so PUNCHOUT and SINKER are independent,
which is exactly the multi-axis thesis.

Cut-lines are the measured p75 (distinctive) and p90 (elite), per role where the
tails differ even when the medians agree: reliever GB% p90 is 54.1 against a
starter's 48.9, both with a median of 42.5.

THE FALLBACK IS DELETED. classify() used to return FLEX (mlb) / SHIELD (wnba) /
CONNECTOR (nba) at weight 1.0 when nothing scored — "could not classify"
rendered as a fully-confident classification of a real archetype, with
descriptive education copy attached. 8 of 18 MLB players carried it, and FLEX
could never be earned because its only scoring input had zero writers. Every
sport now does what MMA already did: unclassified is absent.

Induced on real players. Skubal: STARTER, throws L, WHIFF + SEAM + PINPOINT, all
elite. Judge: BOMBER + GRINDER + WHIFF RISK — elite power, patient, strikes out,
three true things. Kwan: SURGEON + SNIPER + SLASH with NO power claimed (0.4
barrel% is absent, not "low power"). Josh Bell, who used to classify as DRIVER:
empty blend, "No standout profile — league-average across every measured axis."
Alan Roden, who was FLEX at weight 1.0 on 21 PA: every axis absent, "Not enough
plate appearances yet — no profile claimed."

Per-axis honest-absence holds: a velo-less pitcher keeps every other axis, and
NO DATA is distinguishable from LEAGUE-AVERAGE rather than collapsing into one
shrug. The full vector is stored for Layer 3; only the top three distinctive
traits surface.

Three existing tests asserted the fallback and were updated to assert absence.
One of them surfaced a real robustness gap: classify(sport, null) threw, because
an explicit null does not trigger a default parameter and every scorer
dereferences its argument. Guarded.

Every baseball name is accounted for in docs/ARCHETYPE-AXES.md — built, alias,
tier, or shelved with its unlock condition. Zero orphans; cross-sport names left
for their sport.

Tests 3601 passed / 293 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-20 22:38:32 -04:00
parent 1265c23305
commit 7ac6aa73e3
7 changed files with 573 additions and 11 deletions
+279
View File
@@ -0,0 +1,279 @@
'use strict';
/**
* MULTI-AXIS ARCHETYPE CLASSIFIER (Layer 2, Session 69).
*
* A player is not one label. He is a BLEND across INDEPENDENT axes — Skubal is
* a STARTER *and* a PUNCHOUT arm *and* a CHASE-BAIT arm, three true things at
* once. Single-label classification is lossy, and its failure mode is the FLEX
* disease: when nothing matches, invent a bucket.
*
* THERE IS NO FALLBACK HERE. Unclassified on an axis = absent on that axis. A
* genuinely unremarkable player surfaces one axis, or none, and "no standout
* profile" is the honest output. `classify()`'s FLEX-at-weight-1.0 is replaced.
*
* ── AXIS INDEPENDENCE, MEASURED (not assumed) ────────────────────────────
* Correlations over the real store (467 batters PA>=50, 531 pitchers IP>=10).
* Anything |r| >= 0.70 is ONE underlying trait and was COLLAPSED, so we never
* show one trait as two archetypes (fake granularity):
*
* COLLAPSED — batter
* k% ~ whiff% r = +0.89 -> one SWING-AND-MISS axis
* hard-hit% ~ avg exit velo r = +0.88 -> folded into POWER as intensity
* chase% ~ swing% r = +0.87 -> one AGGRESSION axis
* chase% ~ bb% r = -0.72 -> same axis, inverted (discipline)
* barrel% ~ hard-hit% r = +0.70 -> at the line; barrel leads, hard-hit
* is an intensity input, not an axis
* COLLAPSED — pitcher
* k% ~ whiff% r = +0.76 -> one STRIKEOUT axis
* gb% ~ fb% r = -0.73 -> one signed BATTED-BALL TILT axis
*
* INDEPENDENT — kept as real, separate axes
* pitcher k% ~ gb% r = -0.10 PUNCHOUT and SINKER are orthogonal
* pitcher velo ~ k% r = +0.14 velocity is its OWN trait, not a
* pitcher velo ~ whiff% r = +0.07 proxy for missing bats
* pitcher velo ~ gb% r = +0.07
* pitcher arm angle ~ gb% r = -0.17
* batter barrel ~ launch r = +0.23 power and launch are separate
* batter k% ~ chase% r = +0.05 whiffing and chasing are separate
*
* ── CUT-LINES ARE REAL PERCENTILES ───────────────────────────────────────
* p75 = distinctive, p90 = elite, measured on this season's store — PER ROLE
* where the tails differ (reliever GB% p90 is 54.1 vs a starter's 48.9, even
* though the medians are identical at 42.5). No guessed numbers.
*/
/** Sample floors. Below these an axis is ABSENT — the structural prevention of
* classifying a 12-PA cameo as confidently as a 400-PA season. */
const MIN_PA = 50;
const MIN_IP = 10;
/** Tier thresholds, from the measured distribution. `hi` = p75 (distinctive),
* `elite` = p90. `dir: -1` means LOW values are the distinctive end. */
const B = (hi, elite, dir = 1) => ({ hi, elite, dir });
const BATTER_AXES = Object.freeze({
power: {
name: 'POWER', metric: 'barrel_pct', band: B(10.6, 13.3),
// hard-hit% and exit velo ride along as intensity (r=0.70/0.69) rather than
// as their own axes — one trait, one archetype.
tiers: { hi: 'SLUGGER', elite: 'BOMBER' },
},
swing_miss: {
name: 'SWING & MISS', metric: 'k_pct', band: B(27.4, 31.9),
tiers: { hi: 'WHIFF RISK', elite: 'WINDMILL' },
},
contact: {
name: 'CONTACT', metric: 'k_pct', band: B(17.7, 14.0, -1),
tiers: { hi: 'TECHNICIAN', elite: 'SURGEON' },
},
patience: {
name: 'PLATE DISCIPLINE', metric: 'chase_pct', band: B(26.2, 22.7, -1),
tiers: { hi: 'GRINDER', elite: 'SNIPER' },
},
aggression: {
name: 'AGGRESSION', metric: 'chase_pct', band: B(34.8, 39.7),
tiers: { hi: 'FREE SWINGER', elite: 'HACKER' },
},
launch: {
name: 'LAUNCH', metric: 'avg_launch_angle', band: B(17.2, 20.1),
tiers: { hi: 'TOPSPIN', elite: 'LOFT' },
},
line_drive: {
name: 'LINE DRIVE', metric: 'sweet_spot_pct', band: B(36.6, 40.0),
tiers: { hi: 'SLASH', elite: 'BARREL FINDER' },
},
});
/** Pitcher bands differ by role where the TAILS differ, even where the medians
* agree. Measured: SP n=123, RP n=408. */
const PITCHER_AXES = Object.freeze({
strikeout: {
name: 'STRIKEOUT', metric: 'k_pct',
byRole: { starter: B(25.4, 28.6), default: B(26.3, 29.5) },
tiers: { hi: 'PUNCHOUT', elite: 'WHIFF' },
},
ground_ball: {
name: 'GROUND BALL', metric: 'gb_pct',
byRole: { starter: B(45.7, 48.9), default: B(48.0, 54.1) },
tiers: { hi: 'SINKER', elite: 'SEAM' },
},
fly_ball: {
name: 'FLY BALL', metric: 'fb_pct',
byRole: { starter: B(28.8, 31.1), default: B(31.4, 35.6) },
tiers: { hi: 'FLY BALL', elite: 'ELEVATOR' },
},
control: {
name: 'CONTROL', metric: 'bb_pct',
byRole: { starter: B(6.5, 5.5, -1), default: B(7.5, 6.0, -1) },
tiers: { hi: 'SURGEON ARM', elite: 'PINPOINT' },
},
wild: {
name: 'WILDNESS', metric: 'bb_pct',
byRole: { starter: B(9.0, 10.7), default: B(12.1, 14.9) },
tiers: { hi: 'NIBBLER', elite: 'SCATTERGUN' },
},
chase: {
name: 'CHASE BAIT', metric: 'chase_pct',
byRole: { starter: B(33.1, 34.6), default: B(32.5, 35.2) },
tiers: { hi: 'BAIT', elite: 'TRAPDOOR' },
},
contact_allowed: {
name: 'HARD CONTACT ALLOWED', metric: 'barrel_pct',
byRole: { starter: B(9.7, 10.8), default: B(9.5, 12.0) },
tiers: { hi: 'HITTABLE', elite: 'BATTING PRACTICE' },
},
velocity: {
// Measured independent of k% (0.14), whiff% (0.07) and gb% (0.07) — this is
// genuinely its own trait, which is why it earns an axis rather than being
// folded into STRIKEOUT. Coverage recovered 53% -> 99% by the Session-69
// velo fix, so it is buildable rather than shelved.
name: 'VELOCITY', metric: '_velo', byRole: { default: B(96.2, 97.7) },
tiers: { hi: 'CANNON', elite: 'HOWITZER' },
},
slot: {
name: 'ARM SLOT', metric: 'arm_angle', byRole: { default: B(29.5, 20.1, -1) },
tiers: { hi: 'SIDEARM', elite: 'SUBMARINE' },
},
});
/** Role axis — from TRUE usage (statsapi gamesStarted/saves/holds), never the
* IP proxy. Always surfaced when known: it is context, not a distinction. */
const ROLE_LABEL = Object.freeze({
starter: 'STARTER', closer: 'CLOSER', setup: 'SETUP', reliever: 'RELIEVER',
});
const num = (v) => {
if (v == null || v === '') return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
};
function bandFor(axis, roleDetail) {
if (axis.band) return axis.band;
const by = axis.byRole || {};
return by[roleDetail] || by.default || null;
}
/**
* scoreAxis — returns { tier, label, value, strength } or NULL.
* NULL means one of three honest things, never "average archetype":
* the metric is absent · the sample is too thin · the player is unremarkable
* on this axis (between the tails).
*/
function scoreAxis(axis, row, roleDetail) {
const v = axis.metric === '_velo' ? primaryVelo(row) : num(row[axis.metric]);
if (v == null) return null;
const band = bandFor(axis, roleDetail);
if (!band) return null;
const { hi, elite, dir } = band;
const passHi = dir > 0 ? v >= hi : v <= hi;
if (!passHi) return null; // league-average on this axis → ABSENT, not "mid"
const passElite = dir > 0 ? v >= elite : v <= elite;
// strength: 0.5 at the p75 line, 1.0 at p90 and beyond. Used to rank which
// traits are the most distinctive, never rendered as a probability.
const span = Math.abs(elite - hi) || 1;
const over = Math.abs(v - hi) / span;
return {
axis: axis.name,
tier: passElite ? 'elite' : 'hi',
label: passElite ? axis.tiers.elite : axis.tiers.hi,
value: v,
strength: Math.min(1, 0.5 + over * 0.5),
};
}
function primaryVelo(row) {
const mix = row.pitch_mix;
if (!Array.isArray(mix) || !mix.length) return null;
const withVelo = mix.filter((p) => num(p.velo) != null);
if (!withVelo.length) return null;
// The fastest offering — a CANNON is defined by his best velo, not his mix.
return Math.max(...withVelo.map((p) => num(p.velo)));
}
/**
* classifyPlayer(row) — the multi-axis result.
*
* {
* role: 'batter'|'pitcher', roleDetail, sufficient,
* vector: { axisKey: {tier,label,value,strength} | null }, ← Layer 3 reads ALL
* blend: [ {label, axis, tier, strength} ], ← top 2-3 to SURFACE
* absent: [axisKey…], ← axes with no data (distinct from unremarkable)
* note ← honest copy when the blend is empty
* }
*/
function classifyPlayer(row = {}) {
const isPitcher = row.role === 'pitcher';
const axes = isPitcher ? PITCHER_AXES : BATTER_AXES;
const roleDetail = row.role_detail || null;
const sample = isPitcher ? num(row.sample_ip) : num(row.sample_pa);
const floor = isPitcher ? MIN_IP : MIN_PA;
const sufficient = sample != null && sample >= floor;
const vector = {};
const absent = [];
for (const [key, axis] of Object.entries(axes)) {
if (!sufficient) { vector[key] = null; absent.push(key); continue; }
const metricPresent = axis.metric === '_velo'
? primaryVelo(row) != null
: num(row[axis.metric]) != null;
if (!metricPresent) { vector[key] = null; absent.push(key); continue; }
vector[key] = scoreAxis(axis, row, roleDetail);
}
// Mutually-exclusive pairs live on ONE underlying axis; only one end can fire,
// and if both somehow did we keep the stronger rather than showing a player as
// both patient and aggressive.
for (const [a, b] of [['contact', 'swing_miss'], ['patience', 'aggression'], ['control', 'wild'], ['ground_ball', 'fly_ball']]) {
if (vector[a] && vector[b]) {
if (vector[a].strength >= vector[b].strength) vector[b] = null; else vector[a] = null;
}
}
const scored = Object.entries(vector)
.filter(([, v]) => v)
.map(([key, v]) => ({ key, ...v }))
.sort((x, y) => (y.tier === 'elite') - (x.tier === 'elite') || y.strength - x.strength);
// Surface the top 3 genuinely distinctive traits. NO filler: a player above
// no p75 line surfaces nothing.
const blend = scored.slice(0, 3).map((s) => ({
label: s.label, axis: s.axis, key: s.key, tier: s.tier, value: s.value,
strength: Math.round(s.strength * 100) / 100,
}));
let note = null;
if (!sufficient) {
note = `Not enough ${isPitcher ? 'innings' : 'plate appearances'} yet — no profile claimed.`;
} else if (!blend.length) {
note = 'No standout profile — league-average across every measured axis.';
}
return {
role: isPitcher ? 'pitcher' : 'batter',
roleDetail,
roleLabel: roleDetail ? ROLE_LABEL[roleDetail] || null : null,
sufficient,
sample,
bats: row.bats || null,
throws: row.throws || null,
vector,
blend,
absent,
note,
};
}
module.exports = {
classifyPlayer,
scoreAxis,
primaryVelo,
BATTER_AXES,
PITCHER_AXES,
ROLE_LABEL,
MIN_PA,
MIN_IP,
__internals: { bandFor, num },
};