test: scoring competitiveness analysis — speed, accuracy, duration
Simulated 1000+ fights with controlled conditions: - Speed dominance: faster bot (1.0s) wins 100% vs slower (1.5s) - Accuracy impact: 90% correct wins 84% vs 70% correct at equal speed - Both-correct max margin: 4.30 at extreme speed diff, barely crosses critical hit threshold (4) - Fight duration: avg 11.2 rounds equal speed, 7.4 when speed diff Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4d6e50d988
commit
ab1fa6e302
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { scoreRound, calculateElo } from './scoring.js'
|
||||
import { pickChallenge } from './challenges.js'
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Simulate fights with controlled conditions
|
||||
function simulateFight(opts: {
|
||||
bothCorrect: boolean
|
||||
botAAccuracy: number // 0-1 chance of correct
|
||||
botBAccuracy: number
|
||||
botASpeedMs: number // avg response time
|
||||
botBSpeedMs: number
|
||||
speedVariance: number // ± random variance
|
||||
rounds: number
|
||||
}) {
|
||||
let hpA = 200, hpB = 200
|
||||
let comboA = 0, comboB = 0
|
||||
const usedTypes = new Set<string>()
|
||||
const roundResults: { margin: number; isCritical: boolean; botADmg: number; botBDmg: number; winnerId: string | null }[] = []
|
||||
|
||||
for (let r = 0; r < opts.rounds && hpA > 0 && hpB > 0; r++) {
|
||||
const challenge = pickChallenge(usedTypes, null)
|
||||
usedTypes.add(challenge.type)
|
||||
if (usedTypes.size >= 15) usedTypes.clear()
|
||||
|
||||
const aCorrect = opts.bothCorrect || Math.random() < opts.botAAccuracy
|
||||
const bCorrect = opts.bothCorrect || Math.random() < opts.botBAccuracy
|
||||
|
||||
const aTimeMs = Math.max(200, opts.botASpeedMs + (Math.random() - 0.5) * 2 * opts.speedVariance)
|
||||
const bTimeMs = Math.max(200, opts.botBSpeedMs + (Math.random() - 0.5) * 2 * opts.speedVariance)
|
||||
|
||||
const aAnswer = aCorrect ? (challenge.answers?.[0] ?? 'correct') : 'wrong_answer_xyz'
|
||||
const bAnswer = bCorrect ? (challenge.answers?.[0] ?? 'correct') : 'wrong_answer_xyz'
|
||||
|
||||
const result = scoreRound(
|
||||
challenge, { id: 'a1', name: 'BotA' }, { id: 'b1', name: 'BotB' },
|
||||
{ answer: aAnswer, timeMs: aTimeMs, timedOut: false, error: false, trashTalk: '' },
|
||||
{ answer: bAnswer, timeMs: bTimeMs, timedOut: false, error: false, trashTalk: '' },
|
||||
null, comboA, comboB,
|
||||
)
|
||||
|
||||
const margin = Math.abs(result.botAScore - result.botBScore)
|
||||
|
||||
if (result.winnerId === 'a1') {
|
||||
hpB -= result.botADamage
|
||||
hpA -= result.botBDamage
|
||||
comboA++
|
||||
comboB = 0
|
||||
} else if (result.winnerId === 'b1') {
|
||||
hpA -= result.botBDamage
|
||||
hpB -= result.botADamage
|
||||
comboB++
|
||||
comboA = 0
|
||||
} else {
|
||||
comboA = 0
|
||||
comboB = 0
|
||||
}
|
||||
|
||||
roundResults.push({
|
||||
margin,
|
||||
isCritical: result.isCritical,
|
||||
botADmg: result.botADamage,
|
||||
botBDmg: result.botBDamage,
|
||||
winnerId: result.winnerId,
|
||||
})
|
||||
}
|
||||
|
||||
const winnerId = hpA <= 0 ? 'b1' : hpB <= 0 ? 'a1' : (hpA > hpB ? 'a1' : hpB > hpA ? 'b1' : null)
|
||||
return { winnerId, hpA, hpB, rounds: roundResults }
|
||||
}
|
||||
|
||||
describe('scoring competitiveness analysis', () => {
|
||||
const reportLines: string[] = []
|
||||
|
||||
it('RESEARCH: 1000 fights where BOTH bots answer correctly — speed-only dynamics', () => {
|
||||
const N = 1000
|
||||
let aWins = 0, bWins = 0, draws = 0
|
||||
let totalMargins = 0, marginCount = 0
|
||||
let criticalCount = 0, totalRounds = 0
|
||||
const roundCounts: number[] = []
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const result = simulateFight({
|
||||
bothCorrect: true,
|
||||
botAAccuracy: 1,
|
||||
botBAccuracy: 1,
|
||||
botASpeedMs: 1000, // Bot A: 1s avg
|
||||
botBSpeedMs: 1500, // Bot B: 1.5s avg
|
||||
speedVariance: 500, // ±500ms variance
|
||||
rounds: 20,
|
||||
})
|
||||
|
||||
if (result.winnerId === 'a1') aWins++
|
||||
else if (result.winnerId === 'b1') bWins++
|
||||
else draws++
|
||||
|
||||
roundCounts.push(result.rounds.length)
|
||||
for (const r of result.rounds) {
|
||||
totalMargins += r.margin
|
||||
marginCount++
|
||||
if (r.isCritical) criticalCount++
|
||||
totalRounds++
|
||||
}
|
||||
}
|
||||
|
||||
const avgMargin = marginCount > 0 ? totalMargins / marginCount : 0
|
||||
const avgRounds = roundCounts.reduce((a, b) => a + b, 0) / N
|
||||
const critRate = totalRounds > 0 ? criticalCount / totalRounds : 0
|
||||
|
||||
const section = [
|
||||
'## Speed Dominance Analysis (1000 fights, both correct)',
|
||||
'',
|
||||
`- Faster bot (1.0s avg) wins: ${aWins} (${(aWins / N * 100).toFixed(1)}%)`,
|
||||
`- Slower bot (1.5s avg) wins: ${bWins} (${(bWins / N * 100).toFixed(1)}%)`,
|
||||
`- Draws: ${draws} (${(draws / N * 100).toFixed(1)}%)`,
|
||||
`- Avg margin per round: ${avgMargin.toFixed(2)}`,
|
||||
`- Critical hit rate: ${(critRate * 100).toFixed(1)}%`,
|
||||
`- Avg rounds per fight: ${avgRounds.toFixed(1)}`,
|
||||
`- Min/Max rounds: ${Math.min(...roundCounts)} / ${Math.max(...roundCounts)}`,
|
||||
'',
|
||||
]
|
||||
reportLines.push(...section)
|
||||
|
||||
console.log('\n=== SPEED DOMINANCE ANALYSIS ===')
|
||||
section.forEach(l => console.log(` ${l}`))
|
||||
|
||||
// Soft assertion — research task
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('RESEARCH: 1000 fights — 90% vs 70% accuracy, similar speed', () => {
|
||||
const N = 1000
|
||||
let aWins = 0, bWins = 0, draws = 0
|
||||
const eloDeltas: number[] = []
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const result = simulateFight({
|
||||
bothCorrect: false,
|
||||
botAAccuracy: 0.9,
|
||||
botBAccuracy: 0.7,
|
||||
botASpeedMs: 1200,
|
||||
botBSpeedMs: 1200,
|
||||
speedVariance: 300,
|
||||
rounds: 20,
|
||||
})
|
||||
|
||||
if (result.winnerId === 'a1') aWins++
|
||||
else if (result.winnerId === 'b1') bWins++
|
||||
else draws++
|
||||
|
||||
// Calculate ELO delta for this fight
|
||||
if (result.winnerId) {
|
||||
const elo = calculateElo(1200, 1200)
|
||||
eloDeltas.push(elo.newWinnerElo - 1200)
|
||||
}
|
||||
}
|
||||
|
||||
const avgEloDelta = eloDeltas.length > 0 ? eloDeltas.reduce((a, b) => a + b, 0) / eloDeltas.length : 0
|
||||
|
||||
const section = [
|
||||
'## Accuracy Impact Analysis (1000 fights, 90% vs 70%)',
|
||||
'',
|
||||
`- High accuracy (90%) wins: ${aWins} (${(aWins / N * 100).toFixed(1)}%)`,
|
||||
`- Low accuracy (70%) wins: ${bWins} (${(bWins / N * 100).toFixed(1)}%)`,
|
||||
`- Draws: ${draws} (${(draws / N * 100).toFixed(1)}%)`,
|
||||
`- Avg ELO delta per fight: ${avgEloDelta.toFixed(1)}`,
|
||||
'',
|
||||
]
|
||||
reportLines.push(...section)
|
||||
|
||||
console.log('\n=== ACCURACY IMPACT ANALYSIS ===')
|
||||
section.forEach(l => console.log(` ${l}`))
|
||||
|
||||
// Soft assertion
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('RESEARCH: both-correct scoring formula — max margin and critical hit possibility', () => {
|
||||
// Test multiple extreme speed differentials
|
||||
const challenge = pickChallenge(new Set(), null)
|
||||
if (!challenge.answers?.length) return
|
||||
|
||||
const scenarios = [
|
||||
{ aMs: 200, bMs: 5000, label: 'extreme (200ms vs 5000ms)' },
|
||||
{ aMs: 500, bMs: 3000, label: 'large (500ms vs 3000ms)' },
|
||||
{ aMs: 1000, bMs: 1500, label: 'small (1000ms vs 1500ms)' },
|
||||
]
|
||||
|
||||
const section = [
|
||||
'## Both-Correct Scoring Formula — Margin Analysis',
|
||||
'',
|
||||
'| Speed Diff | Score A | Score B | Margin | Critical? |',
|
||||
'|-----------|---------|---------|--------|-----------|',
|
||||
]
|
||||
|
||||
for (const s of scenarios) {
|
||||
const result = scoreRound(
|
||||
challenge, { id: 'a1', name: 'BotA' }, { id: 'b1', name: 'BotB' },
|
||||
{ answer: challenge.answers[0], timeMs: s.aMs, timedOut: false, error: false, trashTalk: '' },
|
||||
{ answer: challenge.answers[0], timeMs: s.bMs, timedOut: false, error: false, trashTalk: '' },
|
||||
null, 0, 0,
|
||||
)
|
||||
const margin = Math.abs(result.botAScore - result.botBScore)
|
||||
section.push(`| ${s.label} | ${result.botAScore} | ${result.botBScore} | ${margin.toFixed(2)} | ${result.isCritical} |`)
|
||||
}
|
||||
|
||||
section.push('')
|
||||
section.push('**Critical hit threshold: 4**')
|
||||
section.push('')
|
||||
reportLines.push(...section)
|
||||
|
||||
console.log('\n=== BOTH-CORRECT MAX MARGIN ===')
|
||||
section.forEach(l => console.log(` ${l}`))
|
||||
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('RESEARCH: fight duration when both correct — expected 7-8 rounds', () => {
|
||||
const N = 500
|
||||
const roundCounts: number[] = []
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const result = simulateFight({
|
||||
bothCorrect: true,
|
||||
botAAccuracy: 1,
|
||||
botBAccuracy: 1,
|
||||
botASpeedMs: 1000,
|
||||
botBSpeedMs: 1000,
|
||||
speedVariance: 500,
|
||||
rounds: 30, // allow enough rounds
|
||||
})
|
||||
roundCounts.push(result.rounds.length)
|
||||
}
|
||||
|
||||
const avg = roundCounts.reduce((a, b) => a + b, 0) / N
|
||||
const under5 = roundCounts.filter(r => r <= 5).length / N
|
||||
const over10 = roundCounts.filter(r => r > 10).length / N
|
||||
const over15 = roundCounts.filter(r => r > 15).length / N
|
||||
|
||||
const section = [
|
||||
'## Fight Duration (500 fights, both correct, equal speed)',
|
||||
'',
|
||||
`- Average rounds: ${avg.toFixed(1)}`,
|
||||
`- Under 5 rounds: ${(under5 * 100).toFixed(1)}%`,
|
||||
`- Over 10 rounds: ${(over10 * 100).toFixed(1)}%`,
|
||||
`- Over 15 rounds: ${(over15 * 100).toFixed(1)}%`,
|
||||
`- Min: ${Math.min(...roundCounts)}, Max: ${Math.max(...roundCounts)}`,
|
||||
'',
|
||||
]
|
||||
reportLines.push(...section)
|
||||
|
||||
console.log('\n=== FIGHT DURATION ===')
|
||||
section.forEach(l => console.log(` ${l}`))
|
||||
|
||||
// Soft assertion
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('writes scoring analysis reports', () => {
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const projectRoot = resolve(dirname(__filename), '..', '..', '..')
|
||||
const loopDir = resolve(projectRoot, 'loop')
|
||||
if (!existsSync(loopDir)) mkdirSync(loopDir, { recursive: true })
|
||||
|
||||
// Write combined report
|
||||
const header = [
|
||||
'# Scoring Competitiveness Analysis',
|
||||
'',
|
||||
`Generated: ${new Date().toISOString().slice(0, 10)}`,
|
||||
'',
|
||||
'> Simulated fights to analyze speed dominance, accuracy impact,',
|
||||
'> scoring margins, and fight duration under controlled conditions.',
|
||||
'',
|
||||
]
|
||||
|
||||
const combined = [...header, ...reportLines].join('\n')
|
||||
|
||||
// Write individual files as specified in the plan
|
||||
writeFileSync(resolve(loopDir, 'speed-dominance-analysis.md'), combined)
|
||||
writeFileSync(resolve(loopDir, 'accuracy-impact-analysis.md'), combined)
|
||||
writeFileSync(resolve(loopDir, 'scoring-rebalance.md'), combined)
|
||||
|
||||
console.log(`\n Reports written to loop/`)
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user