longevity: le cinque curve del questionario, verificate contro l oracolo
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Curve di normalizzazione del questionario Longevity: portano una risposta
|
||||||
|
* grezza a un punteggio 0-100.
|
||||||
|
*
|
||||||
|
* `clamp`, `lerp`, `curvaCampana`, `curvaDecrescente`, `curvaCrescenteConPlateau`
|
||||||
|
* e `curvaDirettaX10` sono il porting letterale di `clamp`, `_lerp`,
|
||||||
|
* `score_bell_curve`, `score_decreasing`, `score_increasing_plateau` e
|
||||||
|
* `score_direct_x10` dell'oracolo del cliente
|
||||||
|
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario:
|
||||||
|
* curve generiche»). Non toccare quella logica: è verificata riga per riga
|
||||||
|
* contro `riferimento.json`.
|
||||||
|
*
|
||||||
|
* `curvaDirettaX10Invertita` e `curvaGradini` NON sono nell'oracolo Python:
|
||||||
|
* vengono dal prototipo HTML del cliente (`scale10_inv`, `decstep`/`incstep`)
|
||||||
|
* e non hanno un oracolo di riferimento — sono coperte solo dai test scritti
|
||||||
|
* a mano in `tests/longevity/motore-curve.test.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Limita x all'intervallo [lo, hi]. Come `clamp` nell'oracolo. */
|
||||||
|
export function clamp(x: number, lo = 0, hi = 100): number {
|
||||||
|
return Math.max(lo, Math.min(hi, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolazione lineare fra (x0, y0) e (x1, y1). Il fattore t è limitato a
|
||||||
|
* [0, 1]: la curva non esce mai oltre y0/y1, anche se x è fuori da [x0, x1].
|
||||||
|
* Come `_lerp` nell'oracolo.
|
||||||
|
*/
|
||||||
|
export function lerp(x: number, x0: number, x1: number, y0: number, y1: number): number {
|
||||||
|
if (x1 === x0) return y0;
|
||||||
|
let t = (x - x0) / (x1 - x0);
|
||||||
|
t = Math.max(0, Math.min(1, t));
|
||||||
|
return y0 + t * (y1 - y0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrotonda a una cifra decimale, come `round(x, 1)` in Python. */
|
||||||
|
function arrotonda1(x: number): number {
|
||||||
|
return Math.round(x * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curva a campana: punteggio massimo (100) fra peakLow e peakHigh, decresce
|
||||||
|
* verso low/high ai lati. Come `score_bell_curve` nell'oracolo.
|
||||||
|
*/
|
||||||
|
export function curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number {
|
||||||
|
if (v >= peakLow && v <= peakHigh) return 100;
|
||||||
|
if (v < peakLow) return clamp(arrotonda1(lerp(v, low, peakLow, 10, 100)));
|
||||||
|
return clamp(arrotonda1(lerp(v, peakHigh, high, 100, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curva decrescente: 100 a `best`, 0 a `worst`. Come `score_decreasing`
|
||||||
|
* nell'oracolo — con `lerp` che limita t a [0,1], non scende sotto 0 né sale
|
||||||
|
* sopra 100 fuori da [best, worst].
|
||||||
|
*/
|
||||||
|
export function curvaDecrescente(v: number, best: number, worst: number): number {
|
||||||
|
return clamp(arrotonda1(lerp(v, best, worst, 100, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curva crescente con plateau: 20 a `worst`, 100 da `plateauStart` in poi.
|
||||||
|
* Come `score_increasing_plateau` nell'oracolo.
|
||||||
|
*/
|
||||||
|
export function curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number {
|
||||||
|
return clamp(arrotonda1(lerp(v, worst, plateauStart, 20, 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Punteggio diretto: valore 0-10 moltiplicato per 10. Come `score_direct_x10` nell'oracolo. */
|
||||||
|
export function curvaDirettaX10(v: number): number {
|
||||||
|
return clamp(arrotonda1(v * 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complemento della diretta: valore 0-10, punteggio decrescente. Assente
|
||||||
|
* nell'oracolo Python (è la quinta curva, quella che al motore del cliente
|
||||||
|
* manca): serve a q_calo_pomeridiano, e nel prototipo HTML è `scale10_inv`,
|
||||||
|
* cioè (10 - v) * 10.
|
||||||
|
*/
|
||||||
|
export function curvaDirettaX10Invertita(v: number): number {
|
||||||
|
return clamp(arrotonda1((10 - v) * 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curva a gradini: interpola fra coppie [valore, punteggio] ordinate per
|
||||||
|
* valore crescente, esattamente come `decstep`/`incstep` nel prototipo HTML
|
||||||
|
* del cliente (assente nell'oracolo Python).
|
||||||
|
*
|
||||||
|
* Se v è zero e zeroVal è definito, vince zeroVal. Altrimenti si interpola a
|
||||||
|
* tratti fra i gradini, partendo da un punto d'ancoraggio (0, 100) per le
|
||||||
|
* curve decrescenti o (0, 20) per le crescenti, e restando sull'ultimo
|
||||||
|
* gradino oltre la sua ascissa (tipicamente un valore-soglia molto alto che
|
||||||
|
* funge da "infinito").
|
||||||
|
*/
|
||||||
|
export function curvaGradini(
|
||||||
|
v: number,
|
||||||
|
steps: [number, number][],
|
||||||
|
zeroVal: number | undefined,
|
||||||
|
decrescente: boolean
|
||||||
|
): number {
|
||||||
|
if (v === 0 && zeroVal !== undefined) return zeroVal;
|
||||||
|
|
||||||
|
const ancoraggio: [number, number] = [0, decrescente ? 100 : 20];
|
||||||
|
const punti: [number, number][] = [ancoraggio, ...steps];
|
||||||
|
|
||||||
|
for (let i = 0; i < punti.length - 1; i++) {
|
||||||
|
const [x0, y0] = punti[i];
|
||||||
|
const [x1, y1] = punti[i + 1];
|
||||||
|
if (v <= x1) {
|
||||||
|
return clamp(arrotonda1(lerp(v, x0, x1, y0, y1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v oltre l'ultimo gradino: resta sull'ultimo valore.
|
||||||
|
return punti[punti.length - 1][1];
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
|
||||||
|
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
|
||||||
|
} from '../../src/lib/longevity/motore/curve';
|
||||||
|
|
||||||
|
const RIF = JSON.parse(
|
||||||
|
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
|
||||||
|
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
|
||||||
|
|
||||||
|
const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn);
|
||||||
|
|
||||||
|
describe('curve del questionario, confrontate con l oracolo', () => {
|
||||||
|
it('la campana combacia su tutto il dominio', () => {
|
||||||
|
const casi = casiDi('score_bell_curve');
|
||||||
|
expect(casi.length).toBeGreaterThan(20);
|
||||||
|
for (const c of casi) {
|
||||||
|
const [v, low, pl, ph, high] = c.args as number[];
|
||||||
|
expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => {
|
||||||
|
const casi = casiDi('score_decreasing');
|
||||||
|
expect(casi.length).toBeGreaterThan(40);
|
||||||
|
for (const c of casi) {
|
||||||
|
const [v, best, worst] = c.args as number[];
|
||||||
|
expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la crescente con plateau combacia', () => {
|
||||||
|
for (const c of casiDi('score_increasing_plateau')) {
|
||||||
|
const [v, worst, plateau] = c.args as number[];
|
||||||
|
expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la diretta per dieci combacia', () => {
|
||||||
|
for (const c of casiDi('score_direct_x10')) {
|
||||||
|
expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca.
|
||||||
|
// Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10.
|
||||||
|
it('la diretta invertita e il complemento della diretta', () => {
|
||||||
|
for (const v of [0, 2.5, 5, 7.5, 10]) {
|
||||||
|
expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6);
|
||||||
|
}
|
||||||
|
expect(curvaDirettaX10Invertita(0)).toBe(100);
|
||||||
|
expect(curvaDirettaX10Invertita(10)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => {
|
||||||
|
const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]];
|
||||||
|
expect(curvaGradini(0, steps, undefined, true)).toBe(100);
|
||||||
|
expect(curvaGradini(5, steps, undefined, true)).toBe(60);
|
||||||
|
expect(curvaGradini(10, steps, undefined, true)).toBe(40);
|
||||||
|
expect(curvaGradini(20, steps, undefined, true)).toBe(20);
|
||||||
|
expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeroVal ha la precedenza sui gradini: q_schermi a zero vale 100', () => {
|
||||||
|
const steps: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]];
|
||||||
|
expect(curvaGradini(0, steps, 100, true)).toBe(100);
|
||||||
|
expect(curvaGradini(15, steps, 100, true)).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamp e lerp si comportano come nell oracolo', () => {
|
||||||
|
expect(clamp(150)).toBe(100);
|
||||||
|
expect(clamp(-5)).toBe(0);
|
||||||
|
expect(lerp(5, 0, 10, 0, 100)).toBe(50);
|
||||||
|
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
|
||||||
|
expect(lerp(11, 0, 10, 0, 100)).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user