diff --git a/src/lib/longevity/motore/curve.ts b/src/lib/longevity/motore/curve.ts new file mode 100644 index 0000000..987bc8c --- /dev/null +++ b/src/lib/longevity/motore/curve.ts @@ -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]; +} diff --git a/tests/longevity/motore-curve.test.ts b/tests/longevity/motore-curve.test.ts new file mode 100644 index 0000000..1395c16 --- /dev/null +++ b/tests/longevity/motore-curve.test.ts @@ -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); + }); +});