feat(web): simulatore di accumulo nel browser, verificato contro il Python
scripts/web/ — motore in JavaScript (engine.js) sui ritorni VERI del book esportati da export_series.py, pagine assemblate da build.py. Da "sistema dinamico dove imposti valore iniziale, mensile e durata, e calcola la best curva fissando il tempo": la simulazione gira nel browser, quindi il motore va riscritto e VA PROVATO che dia la stessa risposta. test_engine.js confronta mediane e probabilita' con r0807_growth_yearly.py su due configurazioni, esige che il versato (deterministico) coincida al centesimo, e include un controllo positivo — un motore col fisco spento DEVE risultare fuori tolleranza, perche' un test che non sa fallire non e' un test. Il risolutore e' validato contro dep_necessario di r0727_tasse.py: -0.4 / -0.6 / -1.1%. smoke.js ESEGUE le pagine con un DOM finto. Serve perche' node --check valida solo la sintassi: ho pubblicato una pagina che lo passava e moriva alla prima riga utile (chiavi Python "0.0" ricostruite in JS come String(0) = "0"; i pesi intermedi funzionavano per caso). Altri due errori che questi strumenti hanno intercettato: - due anni di dati di un grafico scritti A MEMORIA perche' tail aveva troncato l'output -> ora i dati si INIETTANO da JSON (build.py), il passaggio manuale non esiste piu'; - una "distorsione sistematica" del motore JS (+0.75%, 8 semi tutti positivi) che erano 8 estrazioni contro UN punto Python rumoroso. Misurato bene, 8 semi per parte: -0.01%, t = -0.04; ed entrambi i campionatori cadono entro 1.7 SE dall'atteso ANALITICO della media di blocco. Scelta guidata da una misura: la banda del versamento suggerito resta +-1% da 1.200 a 3.000 percorsi -> non domina il Monte Carlo ma la granularita' della bisezione (~5 EUR). Percorsi tenuti bassi e incertezza dichiarata, invece di pagare tempo per una precisione che non arriva. Nessun impatto sulla produzione: non tocca book, pesi, cron o config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/* test_engine.js — il motore JS deve riprodurre r0807_growth_yearly.py.
|
||||
*
|
||||
* Non si confrontano i semi (non sono confrontabili fra linguaggi): si confrontano i RISULTATI
|
||||
* entro il rumore Monte Carlo, su due configurazioni gia' calcolate in Python, e si controlla
|
||||
* che le identita' deterministiche (il versato) coincidano ESATTAMENTE — quelle non hanno rumore.
|
||||
*
|
||||
* ⚠️ TRAPPOLA, in cui sono caduto scrivendo questo test: il riferimento Python e' UNA estrazione
|
||||
* (seme 807), non un valore vero. Confrontandoci 8 estrazioni JS ho visto "8/8 sopra il
|
||||
* riferimento, media +0.75%" e l'ho letto come distorsione sistematica — ma 8 segni concordi
|
||||
* rispetto a UN punto rumoroso non sono 8 osservazioni indipendenti: dicono solo che quel punto
|
||||
* sta in basso. Misurato per bene (8 semi PER LINGUAGGIO, distribuzione contro distribuzione):
|
||||
* differenza delle medie -0.01%, t = -0.04, e i due campionatori cadono entrambi entro 1.7 SE
|
||||
* dall'atteso ANALITICO della media di blocco. Le tolleranze qui sotto sono percio' tarate sul
|
||||
* rumore di UN confronto punto-a-punto (~1 SE = 0.7%), non su una distorsione che non esiste.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const { makeEngine } = require("./engine.js");
|
||||
|
||||
const CFG = JSON.parse(fs.readFileSync("series.json", "utf8"));
|
||||
const E = makeEngine(CFG);
|
||||
|
||||
/* valori di riferimento prodotti da Python (4.000 path, seme 807) */
|
||||
const REF = {
|
||||
"500": { dep: 500, py: JSON.parse(fs.readFileSync("growth.json", "utf8")) },
|
||||
"800": { dep: 800, py: JSON.parse(fs.readFileSync("growth800.json", "utf8")) }
|
||||
};
|
||||
|
||||
const PATHS = 4000;
|
||||
let fail = 0;
|
||||
const pct = x => (100 * x).toFixed(1) + "%";
|
||||
|
||||
for (const nome of Object.keys(REF)) {
|
||||
const { dep, py } = REF[nome];
|
||||
for (const regime of ["lordo", "netto"]) {
|
||||
const js = E.run({ lumpEur: 5000, depEur: dep, years: 15,
|
||||
tax: regime === "netto" ? CFG.tax : 0,
|
||||
patrimoniale: regime === "netto" ? CFG.patrimoniale : 0,
|
||||
paths: PATHS, seed: 20260807 });
|
||||
console.log(`\n--- €${dep}/mese · ${regime} ---`);
|
||||
console.log("anno versato PY versato JS med PY med JS scarto P PY P JS");
|
||||
for (const y of [1, 5, 10, 15]) {
|
||||
const a = py[regime][y - 1], b = js[y - 1];
|
||||
const capPy = a.versato + a.guadagno_med;
|
||||
const d = capPy > 0 ? b.med / capPy - 1 : 0;
|
||||
/* il versato e' deterministico: deve coincidere al centesimo */
|
||||
const vOk = Math.abs(a.versato - b.versato) < 0.01;
|
||||
/* le mediane hanno rumore Monte Carlo: tolleranza 3% */
|
||||
const mOk = Math.abs(d) < 0.03;
|
||||
/* le probabilita' hanno SE <= 0.8pp a 4.000 path: tolleranza 3pp */
|
||||
const pOk = Math.abs(a.p_traguardo - b.phit) < 0.03;
|
||||
if (!vOk || !mOk || !pOk) fail++;
|
||||
console.log(
|
||||
String(y).padStart(4) +
|
||||
("$" + Math.round(a.versato).toLocaleString("en-US")).padStart(13) +
|
||||
("$" + Math.round(b.versato).toLocaleString("en-US")).padStart(14) +
|
||||
("$" + Math.round(capPy).toLocaleString("en-US")).padStart(12) +
|
||||
("$" + Math.round(b.med).toLocaleString("en-US")).padStart(12) +
|
||||
(d >= 0 ? "+" : "") + (100 * d).toFixed(1).padStart(9) + "%" +
|
||||
pct(a.p_traguardo).padStart(8) + pct(b.phit).padStart(8) +
|
||||
(vOk && mOk && pOk ? "" : " <-- FUORI TOLLERANZA"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* controllo positivo: il test deve saper fallire. Un motore con l'imposta spenta quando
|
||||
dovrebbe essere accesa deve risultare FUORI tolleranza rispetto al riferimento netto. */
|
||||
const rotto = E.run({ lumpEur: 5000, depEur: 500, years: 15, tax: 0, patrimoniale: 0,
|
||||
paths: 1500, seed: 1 });
|
||||
const refNetto15 = REF["500"].py.netto[14];
|
||||
const capRef = refNetto15.versato + refNetto15.guadagno_med;
|
||||
const scartoRotto = rotto[14].med / capRef - 1;
|
||||
console.log("\ncontrollo positivo (motore volutamente sbagliato: fisco spento): scarto " +
|
||||
(100 * scartoRotto).toFixed(1) + "% -> " +
|
||||
(Math.abs(scartoRotto) > 0.03 ? "rilevato, il test sa fallire" : "NON RILEVATO: il test e' cieco"));
|
||||
if (Math.abs(scartoRotto) <= 0.03) fail++;
|
||||
|
||||
/* velocita': l'interfaccia deve restare utilizzabile */
|
||||
const t0 = Date.now();
|
||||
E.run({ lumpEur: 5000, depEur: 500, years: 20, tax: CFG.tax,
|
||||
patrimoniale: CFG.patrimoniale, paths: 3000, seed: 5 });
|
||||
console.log("\n20 anni x 3.000 path: " + (Date.now() - t0) + " ms");
|
||||
|
||||
console.log(fail === 0 ? "\nOK — il motore JS riproduce il Python" : "\n" + fail + " CONTROLLI FALLITI");
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user