nation_optimizer / visualizer /src /utils /normalizeEpisode.js
Kr0issant
(visualizer) implement the whole thing
f2e28ca
Raw
History Blame Contribute Delete
7.78 kB
// normalizeEpisode.js
// ----------------------------------------------------------------------------
// Bridges two episode JSON shapes:
//
// 1. The synthetic shape produced by `src/sampleRun.js` (rich; includes
// thresholds, event_multipliers, treasury_before/after, debate/votes…).
//
// 2. The lean shape that `core/game.py::StepResult.to_dict()` actually
// emits today (single `treasury`, only `demands` per sector, no
// orchestrator-level data, etc.).
//
// `normalizeEpisode()` returns a uniform shape that every visualizer
// component can rely on, deriving anything that's missing from the spec
// formulas in `specification/04_ECONOMY_MODEL.md`.
// ----------------------------------------------------------------------------
const CRITICAL_RATIO = 0.4;
const SURPLUS_RATIO = 1.5;
const WASTAGE_RATIO = 2.5;
function thresholdsFromDemand(demand) {
return {
critical: demand * CRITICAL_RATIO,
demand,
surplus: demand * SURPLUS_RATIO,
wastage: demand * WASTAGE_RATIO,
};
}
function aggregateEventMultipliers(events, sectorNames) {
const out = Object.fromEntries(sectorNames.map(n => [n, 1]));
for (const ev of events || []) {
const aff = ev.affected_sectors || ev.affected_departments || {};
if (Array.isArray(aff)) continue;
for (const [name, mult] of Object.entries(aff)) {
if (out[name] !== undefined) out[name] *= Number(mult) || 1;
}
}
return out;
}
function deriveThresholds(round, sectorNames) {
const existing = round.thresholds || {};
const demands = round.demands || {};
const out = {};
for (const name of sectorNames) {
if (existing[name] && existing[name].demand !== undefined) {
const t = existing[name];
out[name] = {
critical: t.critical ?? t.demand * CRITICAL_RATIO,
demand: t.demand,
surplus: t.surplus ?? t.demand * SURPLUS_RATIO,
wastage: t.wastage ?? t.demand * WASTAGE_RATIO,
};
} else if (demands[name] !== undefined) {
out[name] = thresholdsFromDemand(Number(demands[name]) || 0);
} else {
out[name] = thresholdsFromDemand(0);
}
}
return out;
}
function average(arr) {
if (!arr.length) return 0;
return arr.reduce((a, b) => a + b, 0) / arr.length;
}
function isPositiveEvent(ev) {
if (typeof ev.is_positive === 'boolean') return ev.is_positive;
if (Number(ev.treasury_injection) > 0) return true;
if (typeof ev.category === 'string') return ev.category === 'positive';
const aff = ev.affected_sectors || {};
const mults = Object.values(aff).map(Number).filter(Number.isFinite);
if (mults.length === 0) return false;
return mults.every(m => m < 1);
}
function findFailingSectors(allocations, thresholds, sectorNames) {
const failing = [];
for (const name of sectorNames) {
const a = Number(allocations[name] ?? 0);
const t = thresholds[name] || {};
if (a < (t.critical ?? Infinity)) failing.push(name);
}
return failing;
}
export function normalizeEpisode(raw) {
const safe = raw || {};
const rawRounds = Array.isArray(safe.rounds) ? safe.rounds : [];
const sectors = safe.config?.sectors || [];
const sectorNames = sectors.map(s => s.name);
let cum = 0;
let prevTreasuryAfter = safe.config?.initial_treasury ?? 1000;
const rounds = rawRounds.map((r, i) => {
const events = (r.events || []).map(ev => ({
...ev,
affected_sectors: ev.affected_sectors || {},
severity: ev.severity ?? 1,
is_positive: isPositiveEvent(ev),
treasury_injection: Number(ev.treasury_injection) || 0,
}));
const allocations = r.allocations || {};
const revenues = r.revenues || {};
const revenueFactors = r.revenue_factors || {};
const consumptions = r.consumptions || {};
const thresholds = deriveThresholds(r, sectorNames);
const eventMultipliers =
r.event_multipliers || aggregateEventMultipliers(events, sectorNames);
const totalAllocation =
r.total_allocation ??
sectorNames.reduce((s, n) => s + (Number(allocations[n]) || 0), 0);
const totalRevenue =
r.total_revenue ??
sectorNames.reduce((s, n) => s + (Number(revenues[n]) || 0), 0);
const surplusReturned =
r.surplus_returned ??
sectorNames.reduce(
(s, n) =>
s + Math.max(0, (Number(allocations[n]) || 0) - (Number(consumptions[n]) || 0)),
0,
);
const treasuryAfter = r.treasury_after ?? r.treasury ?? prevTreasuryAfter;
const treasuryBefore = r.treasury_before ?? prevTreasuryAfter;
prevTreasuryAfter = treasuryAfter;
const treasuryInjection =
r.treasury_injection ??
events.reduce((s, e) => s + (Number(e.treasury_injection) || 0), 0);
const population = Number(r.population) || 0;
const productivity = Number(r.productivity) || 1;
const avgRf =
r.avg_revenue_factor ??
average(sectorNames.map(n => Number(revenueFactors[n]) || 0));
const prosperity =
r.prosperity ?? (population > 0 ? totalRevenue / population : 0);
const reward = r.reward || {};
const total = Number(reward.total ?? 0);
cum += total;
const cumulative = r.cumulative_reward ?? cum;
const isCriticalFailure = Boolean(
r.done &&
typeof r.termination_reason === 'string' &&
r.termination_reason.startsWith('CRITICAL_FAILURE'),
);
const failingSectors = isCriticalFailure
? findFailingSectors(allocations, thresholds, sectorNames)
: [];
const roundNum = r.round_num ?? i + 1;
return {
round_num: roundNum,
year: r.year ?? Math.floor((roundNum - 1) / 4) + 1,
quarter: r.quarter ?? ((roundNum - 1) % 4) + 1,
events,
crisis_occurred: r.crisis_occurred ?? events.some(e => (e.severity || 0) >= 4),
treasury_injection: treasuryInjection,
debate: r.debate || [],
proposal_order: r.proposal_order || sectorNames,
proposals: r.proposals || [],
votes: r.votes || [],
vote_results: r.vote_results || [],
allocations,
consumptions,
revenues,
revenue_factors: revenueFactors,
thresholds,
event_multipliers: eventMultipliers,
treasury_before: treasuryBefore,
treasury_after: treasuryAfter,
total_allocation: totalAllocation,
total_revenue: totalRevenue,
surplus_returned: surplusReturned,
population,
productivity,
avg_revenue_factor: avgRf,
prosperity,
reward: {
base_reward: Number(reward.base_reward) || 0,
productivity_bonus: Number(reward.productivity_bonus) || 0,
survival_bonus: Number(reward.survival_bonus) || 0,
over_alloc_penalty: Number(reward.over_alloc_penalty) || 0,
under_alloc_penalty: Number(reward.under_alloc_penalty) || 0,
critical_penalty: Number(reward.critical_penalty) || 0,
total,
},
cumulative_reward: cumulative,
done: Boolean(r.done),
termination_reason: r.termination_reason ?? null,
is_critical_failure: isCriticalFailure,
failing_sectors: failingSectors,
};
});
const last = rounds.length > 0 ? rounds[rounds.length - 1] : null;
let summary = safe.summary || null;
if (!summary && last) {
const finalProsperity =
(last.treasury_after + last.total_revenue) /
Math.max(last.population, 1);
summary = {
rounds_survived: last.round_num,
total_reward: cum,
final_treasury: last.treasury_after,
final_prosperity: finalProsperity,
final_productivity: last.productivity,
final_population: last.population,
termination_reason: last.termination_reason,
};
}
return {
...safe,
config: {
...(safe.config || {}),
sectors,
},
rounds,
summary,
};
}