Files
vyndr/tests/unit/archetypeAxes.test.js
T
builtbykev 7ac6aa73e3 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
2026-07-20 22:38:32 -04:00

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);
});
});