perf: 조합 탐색 정밀 계산과 Worker 처리 최적화
Publish GitHub Pages output / publish (push) Successful in 34s

카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다.
준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다.
업데이트 문구를 한 항목으로 통합합니다.
This commit is contained in:
dal4segno committed 2026-10-13 12:03:10 +09:00
1 parent f009a7fd6b
commit da40ec4238
18 files changed
+1623 -467

No files matched your search

+221 -26
View File
@@ -1,3 +1,4 @@
import { rankExactCooldownCombinations as rankExpectedCooldownCombinations } from './exact-optimizer-expected.ts';
import { effectiveActiveProbability } from './active-probability.ts';
import { calculateExactScore, finalNoteScore, specialScoreSupportForNote } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
@@ -18,6 +19,34 @@ type CandidateProfile = {
probability: Float64Array;
};
type NoteGroup = { note: ExactSongChart['notes'][number]; comboBonus: number; count: number };
/** Keep exact timing, note semantics and the original combo tier. */
export function groupExactOptimizationNotes(chart: ExactSongChart): NoteGroup[] {
const groups = new Map<string, NoteGroup>();
chart.notes.forEach((note, index) => {
const bonus = comboBonus(index + 1);
const key = `${note.timeSeconds}|${note.type === 'mid'}|${Boolean(note.isFlick)}|${Boolean(note.isHoldEnd)}|${bonus}`;
const group = groups.get(key);
if (group) group.count += 1;
else groups.set(key, { note, comboBonus: bonus, count: 1 });
});
return [...groups.values()];
}
function optimizationNotes(cards: readonly OptimizerCard[], chart: ExactSongChart, options: ExactOptimizeOptions): NoteGroup[] {
// Grouped integer additions are exact only within Number's integer precision.
// For unusually large/invalid input, retain every note in its original order.
const supportMagnitude = cards.reduce((sum, card) => sum + Math.abs(card.special.scoreUpPercent) +
Math.abs(card.scoreSupportPercent ?? 0) + (card.passiveScoreSupportEffects ?? []).reduce((n, effect) => n + Math.abs(effect.percent), 0), 0);
const boostMagnitude = Math.max(...cards.map((card) => Math.abs(card.active.scoreUpPercent))) / 100 * (1 + supportMagnitude / 100);
const totalMagnitude = ((Math.abs(options.perfectNoteScore) + 1) * 1.155 * (1 + boostMagnitude) *
Math.abs(1 + (options.directSongScoreBonusPercent ?? 0) / 100) + 1) * chart.notes.length;
return Number.isFinite(totalMagnitude) && totalMagnitude < Number.MAX_SAFE_INTEGER / 2
? groupExactOptimizationNotes(chart)
: chart.notes.map((note, index) => ({ note, comboBonus: comboBonus(index + 1), count: 1 }));
}
function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
@@ -59,26 +88,26 @@ function comboBonus(combo: number): number {
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
}
function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Array {
return Float64Array.from(chart.notes, (note, index) => {
function baseScores(groups: readonly NoteGroup[], perfectNoteScore: number): Float64Array {
return Float64Array.from(groups, ({ note, comboBonus }) => {
const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1);
return base * (1 + comboBonus(index + 1));
return base * (1 + comboBonus);
});
}
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart, groups: readonly NoteGroup[]): Map<string, CandidateProfile> {
const profiles = new Map<string, CandidateProfile>();
const noteSupport = Float64Array.from(chart.notes, (note) => specialScoreSupportForNote(order, chart, note));
const noteSupport = Float64Array.from(groups, ({ note }) => specialScoreSupportForNote(order, chart, note));
const passiveSupport = passiveScoreSupportByCard(order);
order.forEach((card, cardIndex) => {
const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
reductions.forEach((reduction) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const boost = new Float64Array(chart.notes.length);
const probability = new Float64Array(chart.notes.length);
const boost = new Float64Array(groups.length);
const probability = new Float64Array(groups.length);
const probabilityByAttempt = new Map<number, number>();
chart.notes.forEach((note, noteIndex) => {
groups.forEach(({ note }, noteIndex) => {
const attempt = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attempt <= 0) return;
const activationTime = attempt * interval;
@@ -176,21 +205,100 @@ export function buildExactSkillWindows(
return { activeWindows, effectiveActiveWindows, specialWindows };
}
/** Share prefix maxima across all cooldown assignments; round each note exactly as before. */
function scoreMaximumAssignments(
cards: readonly OptimizerCard[], profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array, directBonus: number, counts: Float64Array
): Float64Array | null {
if (!Number.isFinite(directBonus) || directBonus < -100 || base.some(value => !Number.isFinite(value) || value < 0)) return null;
const choices = cards.map(card => (card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS)
.map(reduction => profiles.get(card.id + ':' + reduction)!));
if (choices.some(row => row.some(profile => !profile || profile.boost.some(value => !Number.isFinite(value))))) return null;
const widths = choices.map(row => row.length);
const sizes = [1]; for (const width of widths) sizes.push(sizes.at(-1)! * width);
const totals = new Float64Array(sizes.at(-1)!);
const prefix = sizes.slice(0, -1).map(size => new Float64Array(size));
const offsets = [1]; for (const width of widths) offsets.push(offsets.at(-1)! + width);
// Notes with the same rounded scores for every selectable profile make identical contributions.
const states = new Map<string, { scores: number[]; count: number }>();
let magnitude = 0;
for (let noteIndex = 0; noteIndex < base.length; noteIndex++) {
const baseline = finalNoteScore(base[noteIndex], directBonus);
const scores = [baseline];
for (const row of choices) for (const profile of row) {
scores.push(finalNoteScore(base[noteIndex] * (1 + Math.max(0, profile.boost[noteIndex])), directBonus));
}
const largest = Math.max(...scores);
magnitude += largest * counts[noteIndex];
if (!Number.isSafeInteger(largest) || magnitude >= Number.MAX_SAFE_INTEGER / 2) return null;
const key = scores.join(','); const previous = states.get(key);
if (previous) previous.count += counts[noteIndex]; else states.set(key, { scores, count: counts[noteIndex] });
}
for (const { scores, count } of states.values()) {
prefix[0][0] = scores[0];
for (let member = 0; member < cards.length - 1; member++) {
const previous = prefix[member], next = prefix[member + 1], width = widths[member], offset = offsets[member];
let index = 0;
for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) {
next[index++] = Math.max(previous[p], scores[offset + choice]);
}
}
const member = cards.length - 1, previous = prefix[member], width = widths[member], offset = offsets[member];
let index = 0;
for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) {
totals[index++] += Math.max(previous[p], scores[offset + choice]) * count;
}
}
return totals;
}
function scoreMaximumAssignment(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array,
directBonus: number,
counts: Float64Array,
selected: CandidateProfile[]
): number {
cards.forEach((card, index) => {
const profile = profiles.get(`${card.id}:${reductions[card.id] ?? 0}`);
if (!profile) throw new Error(`${card.name}의 빈도 조합 프로파일이 없습니다.`);
selected[index] = profile;
});
let total = 0;
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
let largest = 0;
for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) {
const boost = selected[memberIndex].boost[noteIndex];
if (boost <= largest) continue;
// Invalid numerical inputs retain the original combined evaluator.
if (!Number.isFinite(boost)) return NaN;
largest = boost;
}
total += finalNoteScore(base[noteIndex] * (1 + largest), directBonus) * counts[noteIndex];
}
return total;
}
function scoreAssignment(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array,
directBonus: number
directBonus: number,
counts: Float64Array,
selected: CandidateProfile[],
boosts: Float64Array,
probabilities: Float64Array
): { expectedScore: number; theoreticalMaxScore: number } {
const selected = cards.map((card) => {
cards.forEach((card, index) => {
const reduction = reductions[card.id] ?? 0;
const profile = profiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`);
return profile;
selected[index] = profile;
});
const boosts = new Float64Array(cards.length);
const probabilities = new Float64Array(cards.length);
let expectedScore = 0;
let theoreticalMaxScore = 0;
@@ -215,8 +323,9 @@ function scoreAssignment(
expectedBoost += previousFailed * probabilities[position] * boosts[position];
previousFailed *= 1 - probabilities[position];
}
expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus);
theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus);
// Round each original note before applying the group's multiplicity.
expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus) * counts[noteIndex];
theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus) * counts[noteIndex];
}
return { expectedScore, theoreticalMaxScore };
}
@@ -235,12 +344,21 @@ export function rankExactCooldownCombinations(
chart: ExactSongChart,
options: ExactOptimizeOptions,
limit = 5
): ExactOptimizationResult[] {
) : ExactOptimizationResult[] {
if (options.objective === 'expected') return rankExpectedCooldownCombinations(cards, chart, options, limit);
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.');
}
const reductionAssignments = assignments(cards);
const base = baseScores(chart, options.perfectNoteScore);
const reductionTotals = new Map(reductionAssignments.map((reductions) => [reductions,
Object.values(reductions).reduce<number>((sum, value) => sum + value, 0)]));
const groups = optimizationNotes(cards, chart, options);
const base = baseScores(groups, options.perfectNoteScore);
const counts = Float64Array.from(groups, (group) => group.count);
// Local to this call: callbacks may recursively invoke the optimizer.
const selected: CandidateProfile[] = [];
const boosts = new Float64Array(cards.length);
const probabilities = new Float64Array(cards.length);
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const totalCandidates = orderCount * reductionAssignments.length;
let evaluatedCandidates = 0;
@@ -248,30 +366,107 @@ export function rankExactCooldownCombinations(
order: OptimizerCard[];
reductions: Record<string, CooldownReduction>;
theoreticalMaxScore: number;
expectedScore: number;
expectedScore: number | undefined;
reductionTotal: number;
ties?: Candidate[];
};
const bestByAssignment = new Map<Record<string, CooldownReduction>, Candidate>();
const maximumObjective = true;
// Keep a bounded number of order profiles for deferred expected-score ties.
const profileCache = new Map<readonly OptimizerCard[], Map<string, CandidateProfile>>();
let activeOrder: readonly OptimizerCard[] | null = null;
let activeProfiles: Map<string, CandidateProfile> | null = null;
const profilesFor = (order: readonly OptimizerCard[]): Map<string, CandidateProfile> => {
if (!maximumObjective) return makeProfiles(order, chart, groups);
let profiles = profileCache.get(order);
if (profiles) profileCache.delete(order);
else profiles = makeProfiles(order, chart, groups);
profileCache.set(order, profiles);
if (profileCache.size > 8) profileCache.delete(profileCache.keys().next().value!);
return profiles;
};
const expectedFor = (candidate: Candidate): number => {
if (candidate.expectedScore === undefined) {
const profiles = candidate.order === activeOrder && activeProfiles ? activeProfiles : profilesFor(candidate.order);
candidate.expectedScore = scoreAssignment(candidate.order, candidate.reductions, profiles, base,
options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities).expectedScore;
}
return candidate.expectedScore;
};
const compare = (left: Candidate, right: Candidate): number =>
(options.objective === 'expected'
? right.expectedScore - left.expectedScore || right.theoreticalMaxScore - left.theoreticalMaxScore
: right.theoreticalMaxScore - left.theoreticalMaxScore || right.expectedScore - left.expectedScore) ||
? expectedFor(right) - expectedFor(left) || right.theoreticalMaxScore - left.theoreticalMaxScore
: right.theoreticalMaxScore - left.theoreticalMaxScore || expectedFor(right) - expectedFor(left)) ||
left.reductionTotal - right.reductionTotal;
const maximumCache = new Map<string, Float64Array>();
for (const order of permutations(cards)) {
const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
const passive = passiveScoreSupportByCard(order);
const supportById = new Map(order.map((card, index) => [card.id, passive[index]]));
const inputs = [
...order.flatMap((card) => [card.special.durationSeconds, card.special.scoreUpPercent]),
...cards.flatMap((card) => [card.active.cooldownSeconds, card.active.durationSeconds,
card.active.scoreUpPercent, card.scoreSupportPercent ?? 0, supportById.get(card.id)!]),
options.directSongScoreBonusPercent ?? 0
];
const cacheable = inputs.every(Number.isFinite) && cards.every((card) => typeof card.id === 'string') &&
chart.specialStartSeconds.every((start) => start === null || start === undefined || Number.isFinite(start));
const signature = cacheable ? JSON.stringify([inputs, chart.specialStartSeconds]) : null;
let maxima = signature === null ? undefined : maximumCache.get(signature);
if (signature !== null && !maxima) {
maxima = new Float64Array(reductionAssignments.length).fill(NaN);
maximumCache.set(signature, maxima);
}
const profiles = profilesFor(order);
activeOrder = order;
activeProfiles = profiles;
if (!maxima || Number.isNaN(maxima[0])) {
const shared = scoreMaximumAssignments(cards, profiles, base, options.directSongScoreBonusPercent ?? 0, counts);
if (shared) { maxima = shared; if (signature !== null) maximumCache.set(signature, shared); }
}
for (let assignmentIndex = 0; assignmentIndex < reductionAssignments.length; assignmentIndex += 1) {
const reductions = reductionAssignments[assignmentIndex];
let maximum = maxima?.[assignmentIndex];
if (maximum === undefined || Number.isNaN(maximum)) {
maximum = scoreMaximumAssignment(order, reductions, profiles, base,
options.directSongScoreBonusPercent ?? 0, counts, selected);
if (maxima && Number.isFinite(maximum)) maxima[assignmentIndex] = maximum;
}
let score: { theoreticalMaxScore: number; expectedScore: number | undefined } = maximumObjective
? { theoreticalMaxScore: maximum, expectedScore: undefined }
: scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0,
counts, selected, boosts, probabilities);
if (!Number.isFinite(score.theoreticalMaxScore)) score = scoreAssignment(order, reductions, profiles, base,
options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities);
const reductionTotal = reductionTotals.get(reductions)!;
const candidate = { ...score, order, reductions, reductionTotal };
const previous = bestByAssignment.get(reductions);
if (!previous || compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate);
if (previous && (!Number.isFinite(candidate.theoreticalMaxScore) || !Number.isFinite(previous.theoreticalMaxScore))) {
if (compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate);
} else if (!previous || candidate.theoreticalMaxScore > previous.theoreticalMaxScore) {
bestByAssignment.set(reductions, candidate);
} else if (candidate.theoreticalMaxScore === previous.theoreticalMaxScore) {
// Resolve expected-score ties only if this assignment can reach the requested ranks.
(previous.ties ??= []).push(candidate);
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
return [...bestByAssignment.values()].sort(compare).slice(0, Math.max(1, Math.floor(limit))).map((candidate) => ({
const resultCount = Math.max(1, Math.floor(limit));
let ranked = [...bestByAssignment.values()];
if (Number.isFinite(resultCount) && ranked.every(candidate => Number.isFinite(candidate.theoreticalMaxScore))) {
const maxima = ranked.map(candidate => candidate.theoreticalMaxScore).sort((left, right) => right - left);
const cutoff = maxima[Math.min(maxima.length, resultCount) - 1];
ranked = ranked.filter(candidate => candidate.theoreticalMaxScore >= cutoff);
}
ranked = ranked.map(candidate => {
let best = candidate;
for (const tied of candidate.ties ?? []) if (compare(tied, best) < 0) best = tied;
return best;
});
return ranked.sort(compare).slice(0, resultCount).map((candidate) => ({
...calculateExactScore(candidate.order, candidate.reductions, chart, options),
order: candidate.order.map((card) => card.id),
formationOrder: cards.map((card) => card.id),