Wave 6: Combat Intelligence Layer (honest free v1)
Net-new MMA/UFC vertical — fight-card discovery, tale-of-the-tape,
style-blend archetypes, ML + round-total odds, and a style-edge VERDICT
(a MODEL read, explicitly NOT a settled grade). Built to
specs/combat-intelligence.md.
Backend:
- combatAdapter: ESPN MMA scoreboard (date-pinned, free JSON) -> fight
cards + tale-of-tape (record/weight class/rounds/ESPN athlete id);
defensive parse (null on unknown shape, never throws); injectable
fetchImpl + cache; pure normalizeCombatOdds (odds-api h2h/totals ->
ML + round total, allow-listed books, best price). Number(null) guard.
- archetypeService: 6 pinned combat styles in a SEPARATE COMBAT_ARCHETYPES
registry (FINISHER collides with soccer + its green trips the signal-
green gate); classify('mma') blends range/tempo/outcome, honest-empty on
thin data (no forced fallback); styleMatchup() honest verdict.
- oddsService: SPORT_KEYS.mma + MMA_MARKETS=['h2h','totals'] + SPORT_MARKETS
(no spreads suffix). oddsNormalizer MARKET_MAP h2h/totals.
- config/sports.js + web mirror: mma.active=true (collectData stays false;
NOT in the graded-props pipeline SPORT_CONFIG or snapshot/settle loop).
- routes/combat.js: GET /api/combat/:date + GET /api/fight/:id (public,
cached, honest empty off-card) + Next proxies.
Frontend:
- FightCard: two-fighter tale-of-the-tape (initials monogram — no photos),
GRAPPLER/STRIKER blend bars, discipline pedigree tags, shared
ArchetypeBadge (sport="mma", unicode glyphs), CENTER VERDICT, ML +
round-total real; method/round/KO = honest "data-limited", never
fabricated. Self-hides on a non-two-fighter bout.
- /fight/[id] page (server wrapper + client), EmptyState off-season.
- MMA SportBadge token (#D4AF37); archetypes.js sport-aware resolution.
DEFERRED (per spec, NOT built): matchup-GRADE engine, method/round/props
board, combat settlement, ufcstats scraping.
Tests: +3 suites (31 tests) — combat archetype cross-file color/glyph
match, classify blends, styleMatchup honesty, adapter defensive parse +
odds normalize, FightCard honesty grep; extended oddsNormalizer +
sportMarkets. Full suite 253/253 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -272,7 +272,40 @@ const ARCHETYPES = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Combat archetype registry (Wave 6 — MMA/UFC) ────────────────────
|
||||
// PINNED by specs/combat-intelligence.md. Kept in a SEPARATE registry
|
||||
// (NOT merged into ARCHETYPES) for two reasons:
|
||||
// 1. FINISHER collides with the soccer archetype name — combat FINISHER
|
||||
// is a DIFFERENT color/glyph, and the global getArchetype()/ARCHETYPES
|
||||
// lookup is keyed by uppercase name with no sport dimension.
|
||||
// 2. Combat FINISHER's green (#12B886) sits close to the signal green,
|
||||
// which the colorContract gate forbids for shared player archetypes.
|
||||
// Isolating combat keeps that gate (edge-green purity) intact while
|
||||
// honoring the pinned combat palette.
|
||||
// The frontend mirror lives in web/src/lib/archetypes.js COMBAT_ARCHETYPE_MAP;
|
||||
// tests/unit/combatArchetypes.test.js asserts the two agree (colors + glyphs),
|
||||
// same discipline as the cross-sport color-match test.
|
||||
const COMBAT_ARCHETYPES = {
|
||||
STRIKER: { tag: 'STRIKER', sport: 'mma', color: '#E8703A', glyph: '✦', axis: 'range', description: 'Wins on the feet — volume + power at range.' },
|
||||
GRAPPLER: { tag: 'GRAPPLER', sport: 'mma', color: '#2FA4E7', glyph: '⊗', axis: 'range', description: 'Fight hits the mat on his terms — control + subs.' },
|
||||
PRESSURE: { tag: 'PRESSURE', sport: 'mma', color: '#E4574C', glyph: '➤', axis: 'tempo', description: 'Forward, relentless, breaks the pace.' },
|
||||
COUNTER: { tag: 'COUNTER', sport: 'mma', color: '#8E7BE0', glyph: '◊', axis: 'tempo', description: 'Patient — punishes what you show him.' },
|
||||
FINISHER: { tag: 'FINISHER', sport: 'mma', color: '#12B886', glyph: '▲', axis: 'outcome', description: 'Ends nights — high KO/SUB rate.' },
|
||||
GRINDER: { tag: 'GRINDER', sport: 'mma', color: '#B0883B', glyph: '▦', axis: 'outcome', description: 'Goes the distance, wins the rounds.' },
|
||||
};
|
||||
|
||||
// Discipline pedigree tags — VERIFIABLE credentials only (rendered separately
|
||||
// from the archetype blend). Never inferred/guessed: absent when unknown.
|
||||
const DISCIPLINE_PEDIGREES = [
|
||||
'Combat Sambo', 'Dagestan Wrestling', 'BJJ', 'Wrestling Base',
|
||||
'Kickboxing', 'Muay Thai', 'Boxing',
|
||||
];
|
||||
|
||||
const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0);
|
||||
// Strict presence check — an ABSENT stat must not score an axis (Number(null)
|
||||
// === 0 would fabricate a "0 output" claim). Only a real finite number counts.
|
||||
const has = (v) => typeof v === 'number' && Number.isFinite(v);
|
||||
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||||
|
||||
/** NBA scorers — VYNDR Original keys. */
|
||||
function scoreNBA(s) {
|
||||
@@ -358,7 +391,76 @@ function scoreMLB(s) {
|
||||
};
|
||||
}
|
||||
|
||||
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB };
|
||||
/**
|
||||
* MMA fighter scorer. Style is a BLEND across three axes:
|
||||
* range → STRIKER (strikes at distance) ↔ GRAPPLER (mat control + subs)
|
||||
* tempo → PRESSURE (forward volume) ↔ COUNTER (patient, high defense)
|
||||
* outcome → FINISHER (KO/SUB rate) ↔ GRINDER (goes the distance)
|
||||
*
|
||||
* Best-effort from what the ESPN feed exposes; ESPN's striking/grappling
|
||||
* granularity is THINNER than ufcstats. Every axis scores ONLY when its
|
||||
* inputs are real finite numbers — thin data yields fewer style claims,
|
||||
* never a fabricated one. Inputs (all optional):
|
||||
* slpm/sapm sig strikes landed/absorbed per min
|
||||
* strAcc/strDef striking accuracy / defense (0-1)
|
||||
* tdAvg takedowns per 15
|
||||
* subAvg sub attempts per 15
|
||||
* koRate/subRate/decRate fraction of WINS by method (0-1)
|
||||
* koWins/subWins/decWins method counts (rates derived if rates absent)
|
||||
*/
|
||||
function scoreMMA(s = {}) {
|
||||
const out = {};
|
||||
|
||||
// ── range axis ──
|
||||
if (has(s.slpm)) {
|
||||
let v = clamp01((s.slpm - 2) / 4); // ~2/min floor, ~6/min elite
|
||||
if (has(s.tdAvg) && s.tdAvg < 1) v += 0.15; // low takedown reliance = pure striker
|
||||
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.4) * 1.2) * 0.15;
|
||||
if (v > 0) out.STRIKER = clamp01(v);
|
||||
}
|
||||
if (has(s.tdAvg) || has(s.subAvg)) {
|
||||
let v = has(s.tdAvg) ? clamp01(s.tdAvg / 4) : 0; // 4 TD/15 ≈ elite control
|
||||
if (has(s.subAvg)) v += clamp01(s.subAvg / 3) * 0.5;
|
||||
if (v > 0) out.GRAPPLER = clamp01(v);
|
||||
}
|
||||
|
||||
// ── tempo axis ──
|
||||
if (has(s.slpm) && has(s.sapm)) {
|
||||
const vol = clamp01((s.slpm + s.sapm - 6) / 6); // heavy two-way volume = forward pressure
|
||||
if (vol > 0) out.PRESSURE = vol;
|
||||
}
|
||||
if (has(s.strDef) || has(s.strAcc)) {
|
||||
let v = has(s.strDef) ? clamp01((s.strDef - 0.55) * 2.2) * 0.6 : 0;
|
||||
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.45) * 2.2) * 0.4;
|
||||
if (has(s.slpm) && s.slpm > 4.5) v -= 0.2; // a high-output striker isn't a patient counter
|
||||
if (v > 0) out.COUNTER = clamp01(v);
|
||||
}
|
||||
|
||||
// ── outcome axis ──
|
||||
const koR = has(s.koRate) ? s.koRate : deriveRate(s.koWins, s);
|
||||
const subR = has(s.subRate) ? s.subRate : deriveRate(s.subWins, s);
|
||||
const decR = has(s.decRate) ? s.decRate : deriveRate(s.decWins, s);
|
||||
if (koR != null || subR != null) {
|
||||
const finish = (koR || 0) + (subR || 0);
|
||||
if (finish > 0) out.FINISHER = clamp01(finish);
|
||||
}
|
||||
if (decR != null && decR > 0) out.GRINDER = clamp01(decR);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Derive a method rate from a win count when explicit rates are absent.
|
||||
// Returns null (not 0) when totals are unknown — absent, never fabricated.
|
||||
function deriveRate(count, s) {
|
||||
if (!has(count)) return null;
|
||||
const total = has(s.totalWins)
|
||||
? s.totalWins
|
||||
: (has(s.koWins) ? s.koWins : 0) + (has(s.subWins) ? s.subWins : 0) + (has(s.decWins) ? s.decWins : 0);
|
||||
if (!total || total <= 0) return null;
|
||||
return clamp01(count / total);
|
||||
}
|
||||
|
||||
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB, mma: scoreMMA };
|
||||
|
||||
/** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */
|
||||
function getArchetype(name) {
|
||||
@@ -370,6 +472,13 @@ function getArchetype(name) {
|
||||
return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null;
|
||||
}
|
||||
|
||||
/** Look up a COMBAT archetype descriptor by name (case-insensitive). */
|
||||
function getCombatArchetype(name) {
|
||||
if (!name) return null;
|
||||
const key = String(name).toUpperCase();
|
||||
return COMBAT_ARCHETYPES[key] ? { name: key, ...COMBAT_ARCHETYPES[key] } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a player. Returns:
|
||||
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
|
||||
@@ -384,7 +493,12 @@ function classify(sport, stats = {}) {
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
// Combat is HONEST-empty on thin data: no forced fallback archetype (the
|
||||
// other sports fall back to a low-usage role, but inventing a fighter's
|
||||
// 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 }] };
|
||||
}
|
||||
@@ -393,23 +507,88 @@ function classify(sport, stats = {}) {
|
||||
const total = top.reduce((sum, [, v]) => sum + v, 0) || 1;
|
||||
const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) }));
|
||||
|
||||
const primary = getArchetype(ranked[0][0]);
|
||||
const primary = resolver(ranked[0][0]);
|
||||
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
|
||||
? getArchetype(ranked[1][0])
|
||||
? resolver(ranked[1][0])
|
||||
: null;
|
||||
|
||||
return { sport: sp, primary, secondary, blend };
|
||||
}
|
||||
|
||||
/** Weight of an archetype within a blend (0 when absent). */
|
||||
function blendWeight(blend, name) {
|
||||
const hit = (blend || []).find((b) => b.archetype === name);
|
||||
return hit ? hit.weight : 0;
|
||||
}
|
||||
|
||||
// Accept either a classify() result ({ blend }) or a raw blend array.
|
||||
function asBlend(x) {
|
||||
if (Array.isArray(x)) return x;
|
||||
if (x && Array.isArray(x.blend)) return x.blend;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* styleMatchup(a, b) — a DESCRIPTIVE MODEL style-edge read (the mockup's
|
||||
* CENTER VERDICT). NOT a settled grade, NOT an edge %, NO fabricated
|
||||
* confidence. Compares two style blends; when the data is too thin or the
|
||||
* styles are too close to call, it says so honestly.
|
||||
*
|
||||
* a/b may be classify('mma', …) results or raw blend arrays.
|
||||
* Returns { verdict, edgeSide: 'a'|'b'|null, summary }.
|
||||
*/
|
||||
const STYLE_AXES = ['GRAPPLER', 'STRIKER', 'PRESSURE', 'COUNTER', 'FINISHER', 'GRINDER'];
|
||||
const MIN_EDGE = 0.2; // below this stylistic gap → too close to call
|
||||
|
||||
function styleMatchup(a, b) {
|
||||
const A = asBlend(a);
|
||||
const B = asBlend(b);
|
||||
if (A.length === 0 || B.length === 0) {
|
||||
return {
|
||||
verdict: 'INSUFFICIENT READ',
|
||||
edgeSide: null,
|
||||
summary: 'Not enough style data to call this matchup — a MODEL read needs both fighters profiled.',
|
||||
};
|
||||
}
|
||||
|
||||
let best = null;
|
||||
for (const ax of STYLE_AXES) {
|
||||
const diff = blendWeight(A, ax) - blendWeight(B, ax);
|
||||
if (!best || Math.abs(diff) > Math.abs(best.diff)) best = { ax, diff };
|
||||
}
|
||||
|
||||
if (!best || Math.abs(best.diff) < MIN_EDGE) {
|
||||
return {
|
||||
verdict: 'STYLES EVEN',
|
||||
edgeSide: null,
|
||||
summary: 'Two closely matched styles — no clear stylistic edge. A MODEL read, not a graded pick.',
|
||||
};
|
||||
}
|
||||
|
||||
const edgeSide = best.diff > 0 ? 'a' : 'b';
|
||||
return {
|
||||
verdict: `${best.ax} EDGE`,
|
||||
edgeSide,
|
||||
summary: `${best.ax} advantage tilts this on style — a MODEL read, not a settled grade.`,
|
||||
};
|
||||
}
|
||||
|
||||
const classifyNBA = (stats) => classify('nba', stats);
|
||||
const classifyWNBA = (stats) => classify('wnba', stats);
|
||||
const classifyMLB = (stats) => classify('mlb', stats);
|
||||
|
||||
const classifyMMA = (stats) => classify('mma', stats);
|
||||
|
||||
module.exports = {
|
||||
ARCHETYPES,
|
||||
COMBAT_ARCHETYPES,
|
||||
DISCIPLINE_PEDIGREES,
|
||||
getArchetype,
|
||||
getCombatArchetype,
|
||||
classify,
|
||||
classifyNBA,
|
||||
classifyWNBA,
|
||||
classifyMLB,
|
||||
classifyMMA,
|
||||
styleMatchup,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user