Files
vyndr/tests/unit/archetypeService.test.js
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

135 lines
5.7 KiB
JavaScript

// Session 42 — archetype classification service.
const svc = require('../../src/services/archetypeService');
describe('archetypeService — registry', () => {
it('exposes the full design archetype set (41: 15 NBA + 5 WNBA + 15 MLB + 6 soccer)', () => {
expect(Object.keys(svc.ARCHETYPES).length).toBe(41);
const bySport = {};
for (const a of Object.values(svc.ARCHETYPES)) bySport[a.sport] = (bySport[a.sport] || 0) + 1;
expect(bySport).toEqual({ nba: 15, wnba: 5, mlb: 15, soccer: 6 });
});
it('every archetype has name/tag/color/glyph/description/propDNA/education/legacyName', () => {
for (const [name, a] of Object.entries(svc.ARCHETYPES)) {
expect(typeof a.tag).toBe('string');
expect(a.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(typeof a.glyph).toBe('string');
expect(typeof a.description).toBe('string');
expect(a.propDNA).toBeTruthy();
expect(Array.isArray(a.propDNA.reliable)).toBe(true);
expect(Array.isArray(a.propDNA.volatile)).toBe(true);
expect(a.education.length).toBeGreaterThan(20);
expect(name).toBe(name.toUpperCase());
expect(typeof a.legacyName).toBe('string'); // VYNDR Originals keep the old label
}
});
it('uses VYNDR Original names, not the old descriptive labels', () => {
const keys = Object.keys(svc.ARCHETYPES);
expect(keys).toContain('TORCH');
expect(keys).toContain('BOMBER');
expect(keys).toContain('ALPHA');
expect(keys).not.toContain('VOLUME SCORER');
expect(keys).not.toContain('POWER SLUGGER');
expect(keys).not.toContain('ACE');
});
it('colors are unique WITHIN each sport (reused across sports by design)', () => {
const bySport = {};
for (const a of Object.values(svc.ARCHETYPES)) {
bySport[a.sport] = bySport[a.sport] || [];
bySport[a.sport].push(a.color);
}
for (const [sport, colors] of Object.entries(bySport)) {
expect(new Set(colors).size).toBe(colors.length); // no dup within sport
}
});
it('getArchetype is case-insensitive, resolves legacy names, returns null for unknown', () => {
expect(svc.getArchetype('alpha').name).toBe('ALPHA');
expect(svc.getArchetype('Fortress').name).toBe('FORTRESS');
// legacy names still resolve to their VYNDR Original
expect(svc.getArchetype('ACE').name).toBe('ALPHA');
expect(svc.getArchetype('POWER SLUGGER').name).toBe('BOMBER');
expect(svc.getArchetype('not a real one')).toBeNull();
});
});
describe('archetypeService — NBA classification', () => {
it('classifies a volume scorer', () => {
const r = svc.classifyNBA({ ppg: 30, rpg: 5, apg: 4, usg: 33, threes: 2.5, pos: 'G' });
expect(r.primary.name).toBe('TORCH');
expect(r.sport).toBe('nba');
});
it('returns primary + secondary for a hybrid (Point Forward / Floor General)', () => {
const r = svc.classifyNBA({ ppg: 22, rpg: 7, apg: 8, usg: 27, pos: 'F', threes: 1.5 });
expect(r.primary).toBeTruthy();
expect(r.secondary).toBeTruthy();
expect(r.primary.name).not.toBe(r.secondary.name);
});
it('classifies a two-way anchor (Wembanyama-type)', () => {
const r = svc.classifyNBA({ ppg: 24, rpg: 11, apg: 3, bpg: 3.2, usg: 31, threes: 1.5, pos: 'C' });
expect(['FORTRESS', 'PAINT BOSS', 'ARTILLERY']).toContain(r.primary.name);
});
it('produces a normalized blend that sums to ~1', () => {
const r = svc.classifyNBA({ ppg: 28, rpg: 8, apg: 7, usg: 30, pos: 'F' });
const sum = r.blend.reduce((s, b) => s + b.weight, 0);
expect(sum).toBeGreaterThan(0.98);
expect(sum).toBeLessThan(1.02);
});
});
describe('archetypeService — MLB classification', () => {
it('differentiates an Ace from an Innings Eater', () => {
const ace = svc.classifyMLB({ role: 'SP', era: 2.9, k9: 11.5, whip: 0.98, ip_per_start: 6.2 });
const eater = svc.classifyMLB({ role: 'SP', era: 4.1, k9: 7.0, whip: 1.3, ip_per_start: 6.5 });
expect(ace.primary.name).toBe('ALPHA');
expect(eater.primary.name).toBe('WORKHORSE');
});
it('classifies a closer', () => {
const r = svc.classifyMLB({ role: 'CL', era: 2.2, k9: 12, saves: 24, ip_per_start: 1 });
expect(r.primary.name).toBe('HAMMER');
});
it('classifies a contact hitter (BRUSH) and a power hitter (BOMBER) differently', () => {
const contact = svc.classifyMLB({ avg: 0.315, hr: 8, rbi: 40, k_rate: 12, ops: 0.82 });
const power = svc.classifyMLB({ avg: 0.235, hr: 34, rbi: 88, k_rate: 30, ops: 0.86 });
expect(contact.primary.name).toBe('BRUSH');
expect(power.primary.name).toBe('BOMBER');
});
});
describe('archetypeService — WNBA classification', () => {
it('classifies a dominant post / interior anchor (Wilson-type)', () => {
const r = svc.classifyWNBA({ ppg: 27, rpg: 12, apg: 2, bpg: 2.3, usg: 31, pos: 'F' });
expect(['ANCHOR', 'TORCH', 'RANGE']).toContain(r.primary.name);
});
it('classifies a floor general', () => {
const r = svc.classifyWNBA({ ppg: 16, rpg: 4, apg: 7, usg: 24, pos: 'G' });
expect(r.primary.name).toBe('CONDUCTOR');
});
});
describe('archetypeService — HONEST ABSENCE on empty stats (Session 69)', () => {
it('returns NO archetype for empty stats — never a fallback bucket', () => {
// Was: a fallback archetype at weight 1.0 (FLEX/SHIELD/CONNECTOR), i.e.
// "could not classify" rendered as a confident classification with
// descriptive education copy. 8 of 18 MLB players carried it.
for (const s of [svc.classifyNBA({}), svc.classifyMLB({}), svc.classify('badsport', {})]) {
expect(s.primary).toBeNull();
expect(s.blend).toEqual([]);
}
});
it('still never crashes on junk input', () => {
expect(() => svc.classify('mlb', null)).not.toThrow();
expect(() => svc.classify(null, {})).not.toThrow();
});
});