orbit/dev/economy-bands-audit.mjs

139 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Band invariant audit (dev diagnostic — run with Node, no browser):
*
* node dev/economy-bands-audit.mjs
* ECON_BANDS=0.75,1.15,1.85,2.75,3.75 node dev/economy-bands-audit.mjs
*
* The three invariants every distance-band set must keep (the ones
* dev/economy.test.mjs pins, re-run against an A/B band override —
* ECON_BANDS applies the factors to the existing minHops ladder, in
* memory, before anything is built):
*
* 1. ANTI-LOOP: no shop pays more than it charges for a good on its
* OWN floor (sellPrice ≤ buyPrice, every system × class × floor row).
* 2. MONOTONE: farther demanders pay ≥ nearer demanders (±jitter-safe:
* each band gap must beat the ±10% jitter on both ends).
* 3. TRIP-PAYS: a far demander (≥ 4 hops) pays MORE than the origin's
* buy price — even at the worst jitter.
* Plus a printout of the band gaps vs the jitter envelope.
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
if (process.env.ECON_BANDS) {
const factors = process.env.ECON_BANDS.split(',').map((x) => Number(x.trim()));
const bands = configData.economy?.pricing?.distanceBands ?? [];
for (let i = 0; i < bands.length && i < factors.length; i++) bands[i].factor = factors[i];
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const E = await import(pathToFileURL(join(__dirname, '../js/economy/Economy.js')).href);
const bands = config.get('economy.pricing.distanceBands', []);
const jitter = Number(config.get('economy.pricing.jitter', 0.1));
const demandBonus = Number(config.get('economy.pricing.demandBonus', 0.1));
const originDiscount = Number(config.get('economy.pricing.originDiscount', 0.5));
const buyFactor = 1 - originDiscount;
console.log(`bands: ${bands.map((b) => `${b.minHops}+h ×${b.factor}`).join(' ')} (buy ×${buyFactor}, jitter ±${jitter * 100}%, demand +${demandBonus * 100}%)`);
// Band-gap vs jitter envelope: for "farther ≥ nearer" to hold for ALL
// (good, place) pairs, the band ratio must beat the worst jitter ratio.
let gapOk = true;
for (let i = 1; i < bands.length; i++) {
const ratio = bands[i].factor / bands[i - 1].factor;
const envelope = (1 + jitter) / (1 - jitter);
if (ratio < envelope) gapOk = false;
console.log(` gap ×${bands[i - 1].factor} → ×${bands[i].factor}: ratio ${ratio.toFixed(2)} vs jitter envelope ${envelope.toFixed(2)} ${ratio >= envelope ? '✔' : '✘ TOO NARROW'}`);
}
console.log(` (jitter-safe monotonicity: ${gapOk ? 'OK' : 'AT RISK — 70%-pass tests may still hold, but some far<near pairs will appear'})`);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
for (const SEED of ['orbit-economy-test', 'ABC-123']) {
const g = Galaxy.create(SEED);
const tradeable = config.get('goods.goods', []).filter((x) => x.tradeable !== false);
const base = (id) => tradeable.find((x) => x.id === id)?.baseValue ?? 0;
// 1. ANTI-LOOP — sweep every system × class × floor row.
let floors = 0, loops = 0;
for (const [id, set] of g.economy.classBySystem) {
for (const cls of set) {
floors++;
for (const b of E.shopFor(cls, id, g).buy) {
if (E.sellPrice(b.id, cls, id, g) > b.price + 1e-6) loops++;
}
}
}
check(`[${SEED}] no shop is a positive same-shop spread (${floors} floors, ${loops} loops)`, floors > 0 && loops === 0);
// 2. MONOTONE — farther demanders pay ≥ nearer (the test's 70% bar).
let passed = 0, total = 0;
for (const good of tradeable) {
const hops = g.economy.producerHops[good.id] ?? {};
const demanders = [...g.economy.classBySystem.entries()]
.filter(([, s]) => good.demand.some((c) => s.has(c)))
.map(([id]) => ({ id, h: hops[id] ?? 99 }))
.sort((a, b) => a.h - b.h);
if (demanders.length < 2) continue;
const near = demanders[0], far = demanders[demanders.length - 1];
if (far.h <= near.h + 1) continue;
const nearCls = [...g.economy.classBySystem.get(near.id)].find((c) => good.demand.includes(c));
const farCls = [...g.economy.classBySystem.get(far.id)].find((c) => good.demand.includes(c));
total++;
if (E.sellPrice(good.id, farCls, far.id, g) >= E.sellPrice(good.id, nearCls, near.id, g)) passed++;
}
check(`[${SEED}] distance term: farther demanders pay ≥ nearer (${passed}/${total}, bar ≥70%)`, total > 0 && passed >= total * 0.7);
// 3. TRIP-PAYS — far demander (≥ 4 hops) pays MORE than the origin buy,
// worst-case jitter included (90% sell jitter vs 110% buy jitter).
let ok = 0, checked = 0;
for (const good of tradeable) {
const origin = g.economy.origins[good.id];
if (!origin) continue;
const far = [...g.economy.classBySystem.entries()].find(
([id, s]) => id !== origin && good.demand.some((c) => s.has(c)) && (g.economy.producerHops[good.id]?.[id] ?? 0) >= 4,
);
if (!far) continue;
const farId = far[0];
const farCls = [...far[1]].find((c) => good.demand.includes(c));
const b = base(good.id);
const band4 = bands.filter((x) => x.minHops <= 4).pop().factor;
const worstSell = b * band4 * (1 + demandBonus) * (1 - jitter);
const worstBuy = b * buyFactor * (1 + jitter);
checked++;
if (worstSell > worstBuy) ok++;
}
check(`[${SEED}] ≥4-hop demander ALWAYS pays more than origin buy (worst-jitter, ${ok}/${checked})`, checked > 0 && ok === checked);
// 1-hop viability: is the FIRST jump still profitable at worst jitter?
const band1 = bands[0].factor;
const oneHopWorst = band1 * (1 - jitter) > buyFactor * (1 + jitter);
check(`[${SEED}] 1-hop sale (×${band1}) beats origin buy (×${buyFactor}) even at worst jitter`, oneHopWorst);
}
if (failures) {
console.error(`\n${failures} invariant(s) FAILED`);
process.exit(1);
}
console.log('\nAll band invariants hold ✔');