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
+13 -4
View File
@@ -485,10 +485,13 @@ function getCombatArchetype(name) {
*/
function classify(sport, stats = {}) {
const sp = String(sport || 'nba').toLowerCase();
// An explicit null does NOT trigger the default parameter, and every scorer
// dereferences its argument. Guard it here rather than in four scorers.
const input = stats && typeof stats === 'object' ? stats : {};
const scorer = SCORERS[sp];
if (!scorer) return { sport: sp, primary: null, secondary: null, blend: [] };
const scores = scorer(stats);
const scores = scorer(input);
const ranked = Object.entries(scores)
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1]);
@@ -498,9 +501,15 @@ function classify(sport, stats = {}) {
// style from no data would be a fabrication — spec §STYLE-MATCHUP).
const resolver = sp === 'mma' ? getCombatArchetype : getArchetype;
if (ranked.length === 0) {
if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] };
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
// Session 69 — THE FALLBACK IS GONE. It used to return FLEX (mlb) / SHIELD
// (wnba) / CONNECTOR (nba) at weight 1.0, i.e. "we could not classify this
// player" rendered as a fully-confident classification of a real archetype,
// complete with descriptive education copy. 8 of 18 MLB players carried it.
// MMA already did the honest thing; every sport does now: unclassified is
// ABSENT, and the multi-axis classifier (services/archetypeAxes.js) makes a
// single fallback bucket meaningless anyway — a player is unremarkable on
// an AXIS, not as a whole.
return { sport: sp, primary: null, secondary: null, blend: [] };
}
const top = ranked.slice(0, 4);