// Read-only comparison: this does not replace production scoreRatio values. // Run: node scripts/validate-song-score-formula.mjs import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { scoreRatioFromFormula, finalNoteScore } from '../src/lib/engine/score.ts'; const root = new URL('../', import.meta.url); const read = async (relative) => JSON.parse(await readFile(new URL(relative, root), 'utf8')); const reference = await read('docs/research/song-score-formula-reference.json'); const susReference = await read('docs/research/sus-score-formulas.json'); const calibrations = await read('data/score-calibrations.json'); const catalog = await read('src/lib/data/exact-chart-catalog.json'); const key = (row) => `${row.songId}.${row.difficulty}`; const referenceRows = [...reference.charts, ...susReference.charts]; const byKey = new Map(referenceRows.map((row) => [key(row), row])); assert.equal(byKey.size, referenceRows.length, 'Duplicate reference chart'); function ratio(row) { assert.ok(Number.isInteger(row.perfectNoteWeight) && row.perfectNoteWeight > 0); const coefficient = 2.3 * (1 + row.liveScoreCoefficientPermil / 1000 * (row.difficultyLevel - 5)); assert.ok(coefficient > 0); return row.perfectNoteWeight / 1000 / coefficient; } // Exact rational ceiling for the documented formula; no fitted ratios or epsilon. function predict(measurement, row) { const bonusScaled = Math.round(measurement.directSongScoreBonusPercent * 100); assert.ok(Math.abs(bonusScaled / 100 - measurement.directSongScoreBonusPercent) < 1e-10); assert.ok(Number.isInteger(measurement.overallPower)); const numerator = BigInt(measurement.overallPower) * 23n * BigInt(1000 + row.liveScoreCoefficientPermil * (row.difficultyLevel - 5)) * BigInt(10000 + bonusScaled); const denominator = 10n * BigInt(row.perfectNoteWeight) * 10000n; return Number((numerator + denominator - 1n) / denominator); } const measuredRows = []; for (const calibration of calibrations.charts) { const ref = byKey.get(key(calibration)); assert.ok(ref, `Missing measured reference: ${key(calibration)}`); const predictions = calibration.measurements.map((measurement) => { const predicted = predict(measurement, ref); // ceil(A / R) = S implies R in [A/S, A/(S-1)). const amount = measurement.overallPower * (1 + measurement.directSongScoreBonusPercent / 100); const lower = amount / measurement.normalPerfectScore; const upperExclusive = amount / (measurement.normalPerfectScore - 1); assert.ok(ratio(ref) >= lower && ratio(ref) < upperExclusive, `Ratio outside measured interval: ${key(calibration)}`); assert.equal(predicted, measurement.normalPerfectScore, `Measured score mismatch: ${key(calibration)}`); assert.equal(finalNoteScore(measurement.overallPower / scoreRatioFromFormula(ref), measurement.directSongScoreBonusPercent), predicted, `Production engine mismatch: ${key(calibration)}`); return { ...measurement, predicted, ratioInterval: { lower, upperExclusive } }; }); measuredRows.push({ key: key(calibration), researchRatio: ratio(ref), storedRatio: calibration.scoreRatio, predictions }); } const missing = [], comparisons = [], noteDiagnostics = []; for (const chart of catalog.charts) { const ref = byKey.get(key(chart)); if (!ref) { missing.push(key(chart)); continue; } assert.equal(chart.difficultyLevel, ref.difficultyLevel, `Difficulty mismatch: ${key(chart)}`); const local = await read(`static${chart.path}`); assert.equal(chart.scoreCalibrationSource, 'formula-v1', `Formula not applied: ${key(chart)}`); assert.equal(chart.scoreRatio, scoreRatioFromFormula(ref)); assert.equal(local.scoreRatio, chart.scoreRatio); assert.deepEqual(local.scoreFormula, chart.scoreFormula); assert.equal(local.notes.length, ref.fullComboNoteCount, `Combo count mismatch: ${key(chart)}`); const localWeight = local.notes.reduce((sum, note) => sum + (note.type === 'mid' ? 100 : note.isFlick ? 1050 : 1000), 0); noteDiagnostics.push({ key: key(chart), localWeight, referencePlayableWeight: ref.baseWeight, referenceDenominator: ref.perfectNoteWeight, nonPlayableWeight: ref.perfectNoteWeight - ref.baseWeight, playableClassificationDifference: localWeight - ref.baseWeight }); comparisons.push({ key: key(chart), title: chart.title, source: chart.scoreCalibrationSource, // Positive means the current unrounded base score is higher than research. currentScoreRelativeToResearchPercent: 100 * (ratio(ref) / chart.scoreRatio - 1) }); } const comparisonBySource = Object.fromEntries(Object.entries(Object.groupBy(comparisons, (row) => row.source)).map(([source, rows]) => { const ordered = rows.toSorted((a, b) => a.currentScoreRelativeToResearchPercent - b.currentScoreRelativeToResearchPercent); return [source, { count: rows.length, meanAbsoluteDifferencePercent: rows.reduce((sum, row) => sum + Math.abs(row.currentScoreRelativeToResearchPercent), 0) / rows.length, over5Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 5).length, over10Percent: rows.filter((row) => Math.abs(row.currentScoreRelativeToResearchPercent) > 10).length, minimum: ordered[0], maximum: ordered.at(-1) }]; })); const report = { reference: fileURLToPath(new URL('docs/research/song-score-formula-reference.json', root)), source: reference.source, measuredSongs: new Set(calibrations.charts.map((row) => row.songId)).size, measuredCharts: measuredRows.length, exactMeasurements: measuredRows.reduce((sum, row) => sum + row.predictions.length, 0), catalogCharts: catalog.charts.length, coveredCharts: comparisons.length, missing, comparisonBySource, measuredRows, measuredNoteDiagnostics: noteDiagnostics.filter((row) => measuredRows.some((measured) => measured.key === row.key)) }; console.log(JSON.stringify(report, null, 2));