7ac6aa73e3
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
171 lines
7.5 KiB
JavaScript
171 lines
7.5 KiB
JavaScript
/* ============================================================
|
|
Session 69 — LAYER 2: the multi-axis archetype classifier.
|
|
|
|
A player is a BLEND across independent axes, not one label. These lock the
|
|
three things that make that honest: axes that are genuinely independent,
|
|
cut-lines at real percentiles, and NO fallback bucket anywhere.
|
|
============================================================ */
|
|
|
|
const axes = require('../../src/services/archetypeAxes');
|
|
const { classifyPlayer, BATTER_AXES, PITCHER_AXES } = axes;
|
|
|
|
// Real rows from statcast_aggregates (2026-07-21).
|
|
const SKUBAL = {
|
|
role: 'pitcher', role_detail: 'starter', throws: 'L', sample_ip: 82.2,
|
|
k_pct: 30.5, bb_pct: 3.4, chase_pct: 36.7, barrel_pct: 6.7, gb_pct: 49, fb_pct: 22.6,
|
|
arm_angle: 46.9,
|
|
pitch_mix: [{ type: 'FF', velo: 96.7 }, { type: 'CH', velo: 87.3 }, { type: 'SL', velo: 89.4 }],
|
|
};
|
|
const JUDGE = {
|
|
role: 'batter', bats: 'R', sample_pa: 261, k_pct: 27.6, bb_pct: 16.1, chase_pct: 25.8,
|
|
barrel_pct: 21.7, hard_hit_pct: 57.3, avg_launch_angle: 14.6, sweet_spot_pct: 33.6,
|
|
};
|
|
const KWAN = {
|
|
role: 'batter', bats: 'L', sample_pa: 365, k_pct: 9.9, bb_pct: 12.9, chase_pct: 22.1,
|
|
barrel_pct: 0.4, hard_hit_pct: 9.7, avg_launch_angle: 14, sweet_spot_pct: 39.2,
|
|
};
|
|
const BELL = {
|
|
role: 'batter', bats: 'S', sample_pa: 387, k_pct: 21.7, bb_pct: 7.5, chase_pct: 30.6,
|
|
barrel_pct: 10.3, hard_hit_pct: 43.4, avg_launch_angle: 13.9, sweet_spot_pct: 33.8,
|
|
};
|
|
const RODEN_THIN = { role: 'batter', bats: 'L', sample_pa: 21, k_pct: 19, chase_pct: 30.4, barrel_pct: 13.3 };
|
|
|
|
describe('multi-axis blend — several true things at once', () => {
|
|
it('Skubal is a STARTER and a strikeout arm and a ground-ball arm and a control arm', () => {
|
|
const r = classifyPlayer(SKUBAL);
|
|
expect(r.roleLabel).toBe('STARTER');
|
|
const labels = r.blend.map((b) => b.label);
|
|
expect(labels).toContain('WHIFF'); // k% 30.5 ≥ starter p90 28.6
|
|
expect(labels).toContain('SEAM'); // gb% 49 ≥ starter p90 48.9
|
|
expect(labels).toContain('PINPOINT'); // bb% 3.4 ≤ starter elite 5.5
|
|
expect(r.blend.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('surfaces at most 3 traits but stores the FULL vector for Layer 3', () => {
|
|
const r = classifyPlayer(SKUBAL);
|
|
expect(r.blend.length).toBeLessThanOrEqual(3);
|
|
expect(Object.keys(r.vector)).toEqual(Object.keys(PITCHER_AXES));
|
|
expect(Object.values(r.vector).filter(Boolean).length).toBeGreaterThan(r.blend.length);
|
|
});
|
|
|
|
it('Judge: elite power AND patient AND strikeout-prone — all three real', () => {
|
|
const labels = classifyPlayer(JUDGE).blend.map((b) => b.label);
|
|
expect(labels).toContain('BOMBER');
|
|
expect(labels).toContain('GRINDER');
|
|
expect(labels).toContain('WHIFF RISK');
|
|
});
|
|
|
|
it('Kwan: elite contact AND elite discipline, with NO power claimed', () => {
|
|
const r = classifyPlayer(KWAN);
|
|
const labels = r.blend.map((b) => b.label);
|
|
expect(labels).toContain('SURGEON');
|
|
expect(labels).toContain('SNIPER');
|
|
expect(labels).not.toContain('SLUGGER');
|
|
expect(labels).not.toContain('BOMBER');
|
|
expect(r.vector.power).toBeNull(); // 0.4 barrel% — absent, not "low power"
|
|
});
|
|
});
|
|
|
|
describe('NO FALLBACK — the FLEX disease is structurally impossible', () => {
|
|
it('an unremarkable player surfaces NOTHING and says so', () => {
|
|
const r = classifyPlayer(BELL);
|
|
expect(r.blend).toEqual([]);
|
|
expect(r.sufficient).toBe(true);
|
|
expect(r.note).toMatch(/No standout profile/);
|
|
// He used to classify as DRIVER. Being average is not an archetype.
|
|
});
|
|
|
|
it('a thin sample claims NOTHING — every axis absent, not a guess', () => {
|
|
const r = classifyPlayer(RODEN_THIN);
|
|
expect(r.sufficient).toBe(false);
|
|
expect(r.blend).toEqual([]);
|
|
expect(r.absent.length).toBe(Object.keys(BATTER_AXES).length);
|
|
expect(r.note).toMatch(/Not enough plate appearances/);
|
|
// Alan Roden was FLEX at weight 1.0 — a confident label on 21 PA.
|
|
});
|
|
|
|
it('classify() no longer emits a fallback archetype for ANY sport', () => {
|
|
const { classify } = require('../../src/services/archetypeService');
|
|
for (const sport of ['mlb', 'nba', 'wnba', 'mma']) {
|
|
const r = classify(sport, {});
|
|
expect(r.primary).toBeNull();
|
|
expect(r.blend).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it('never pads the blend to a fixed length', () => {
|
|
expect(classifyPlayer(KWAN).blend.length).toBeLessThanOrEqual(3);
|
|
expect(classifyPlayer(BELL).blend.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('honest-absent PER AXIS', () => {
|
|
it('a velo-less pitcher keeps every other axis', () => {
|
|
const r = classifyPlayer({ ...SKUBAL, pitch_mix: [{ type: 'FF', velo: null }] });
|
|
expect(r.vector.velocity).toBeNull();
|
|
expect(r.absent).toContain('velocity');
|
|
expect(r.blend.length).toBeGreaterThan(0); // other axes unaffected
|
|
});
|
|
|
|
it('distinguishes NO DATA from LEAGUE-AVERAGE', () => {
|
|
const noData = classifyPlayer({ role: 'batter', sample_pa: 300 });
|
|
expect(noData.absent).toContain('power'); // metric missing
|
|
const average = classifyPlayer(BELL);
|
|
expect(average.absent).not.toContain('power'); // metric present, just unremarkable
|
|
expect(average.vector.power).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('axis independence — collapsed pairs cannot both fire', () => {
|
|
it.each([
|
|
['contact', 'swing_miss'],
|
|
['patience', 'aggression'],
|
|
])('%s and %s are one underlying axis', (a, b) => {
|
|
for (const row of [JUDGE, KWAN, BELL]) {
|
|
const v = classifyPlayer(row).vector;
|
|
expect(v[a] && v[b]).toBeFalsy();
|
|
}
|
|
});
|
|
|
|
it('pitcher ground-ball and fly-ball never both fire (r = -0.73)', () => {
|
|
const v = classifyPlayer(SKUBAL).vector;
|
|
expect(v.ground_ball && v.fly_ball).toBeFalsy();
|
|
});
|
|
|
|
it('velocity is its OWN axis — measured independent of strikeout (r = 0.14)', () => {
|
|
// A hard thrower who misses no bats still earns CANNON; a soft-tossing
|
|
// strikeout artist still earns WHIFF. Neither implies the other.
|
|
const hardNoK = classifyPlayer({
|
|
role: 'pitcher', role_detail: 'starter', sample_ip: 80, k_pct: 18,
|
|
pitch_mix: [{ type: 'FF', velo: 98.5 }],
|
|
});
|
|
expect(hardNoK.blend.map((b) => b.label)).toContain('HOWITZER');
|
|
expect(hardNoK.vector.strikeout).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('cut-lines are the REAL measured percentiles, per role where tails differ', () => {
|
|
it('reliever GB% needs a higher bar than a starter (p90 54.1 vs 48.9)', () => {
|
|
const base = { role: 'pitcher', sample_ip: 40, gb_pct: 50 };
|
|
const sp = classifyPlayer({ ...base, role_detail: 'starter' });
|
|
const rp = classifyPlayer({ ...base, role_detail: 'reliever' });
|
|
expect(sp.vector.ground_ball.tier).toBe('elite'); // 50 ≥ SP p90 48.9
|
|
expect(rp.vector.ground_ball.tier).toBe('hi'); // 50 < RP p90 54.1
|
|
});
|
|
|
|
it('p75 is distinctive, p90 is elite', () => {
|
|
const at75 = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 10.6 });
|
|
const at90 = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 13.3 });
|
|
expect(at75.vector.power.tier).toBe('hi');
|
|
expect(at90.vector.power.tier).toBe('elite');
|
|
const below = classifyPlayer({ role: 'batter', sample_pa: 300, barrel_pct: 10.5 });
|
|
expect(below.vector.power).toBeNull();
|
|
});
|
|
|
|
it('sample floors gate every axis (PA>=50, IP>=10)', () => {
|
|
expect(classifyPlayer({ role: 'batter', sample_pa: 49, barrel_pct: 25 }).blend).toEqual([]);
|
|
expect(classifyPlayer({ role: 'pitcher', sample_ip: 9, k_pct: 40 }).blend).toEqual([]);
|
|
expect(classifyPlayer({ role: 'batter', sample_pa: 50, barrel_pct: 25 }).blend.length).toBe(1);
|
|
});
|
|
});
|