From f2389848286b8b8245ef416d5b13b0ca31a27255 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 22 Aug 2026 09:53:17 +0200 Subject: [PATCH] longevity: il motore legge curve e pesi dal registro, e congela i punteggi Task 5: applicaCurva smista sulla curva dichiarata in registro_test (non scritta nel codice); calcolaSessione aggrega le misure attive per sotto-dominio (media quando piu' test contribuiscono allo stesso sotto-dominio) e usa cascata.ts coi pesi letti da pesi (pesiDi) per i sette assi, i tre macro-score e la Fitness Age; salvaScore congela il risultato in score, con valore a null quando lo stato e' insufficiente. Corretto anche un refuso nel test del brief ('un test disattivato non entra nel calcolo'): disattivava q_ore_sonno, ma quella voce sta nel sotto-dominio questionario_sonno (peso 0.20 su Recupero & Sistema Nervoso, sempre sotto COPERTURA_MINIMA=0.4 finche' hrv/pressione/hrr non hanno una voce nel registro), quindi l'asse restava insufficiente prima e dopo e il JSON risultava identico (verificato: il test falliva con le due stringhe uguali). Corretto usando q_attivita, l'unica voce del sotto-dominio questionario_lifestyle che pesa 1.00 su Stile di Vita & Sonno: disattivarla fa cadere la copertura da 1.00 a 0 e lo stato passa da ok a insufficiente, una differenza vera. --- src/lib/longevity/motore/index.ts | 195 ++++++++++++++++++++++++++++++ src/lib/longevity/registro.ts | 14 +++ tests/longevity/motore.test.ts | 110 +++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 src/lib/longevity/motore/index.ts create mode 100644 tests/longevity/motore.test.ts diff --git a/src/lib/longevity/motore/index.ts b/src/lib/longevity/motore/index.ts new file mode 100644 index 0000000..f68bb12 --- /dev/null +++ b/src/lib/longevity/motore/index.ts @@ -0,0 +1,195 @@ +/** + * Il motore che legge dal registro, non da costanti: curve, parametri e pesi + * sono dato (`registro_test`, `pesi`), non codice. Aggiungere o togliere un + * test, o cambiare un parametro, resta una modifica ai dati. + * + * Livello 1 (`applicaCurva`): smista sulla curva dichiarata per il test nel + * registro (`registro_test.curva`), coi parametri dichiarati lì + * (`registro_test.params`), e chiama la funzione di `curve.ts` giusta. Un + * test senza curva dichiarata (es. un campo testuale) non contribuisce al + * calcolo. + * + * Livelli 2-4 (`calcolaSessione`): raggruppa le misure attive della sessione + * per sotto-dominio (media dei test dello stesso sotto-dominio — più test + * possono contribuire allo stesso sotto-dominio, es. spinta via push-up o + * panca), poi usa `cascata.ts` (`calcolaAsse`, `calcolaMacro`, + * `calcolaFitnessAge`) coi pesi letti da `pesi` (via `pesiDi`) per i sette + * assi, i tre macro-score e la Fitness Age. + * + * `salvaScore` congela il risultato nella tabella `score`, una riga per + * asse/macro/Fitness Age, con `valore` a `null` quando lo stato è + * insufficiente (il tipo `Punteggio` non porta un `valore` in quel ramo). + */ + +import type Database from 'better-sqlite3'; +import { + curvaCampana, curvaDecrescente, curvaCrescenteConPlateau, + curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini, arrotonda1, +} from './curve'; +import { calcolaAsse, calcolaMacro, calcolaFitnessAge, type Punteggio } from './cascata'; +import { testAttivi, pesiDi, MODEL_VERSION, type VoceRegistro } from '../registro'; + +/** Parametri possibili per una curva, nella forma dichiarata da ciascuna curva in `curve.ts`. */ +type ParamsCurva = { + low?: number; peakLow?: number; peakHigh?: number; high?: number; + best?: number; worst?: number; plateauStart?: number; + steps?: [number, number][]; zeroVal?: number; +}; + +/** + * Normalizza un valore grezzo secondo la curva dichiarata nel registro per + * quel test (`voce.curva` + `voce.params`), non secondo una curva scritta nel + * motore. Un test senza curva dichiarata restituisce `null`: non contribuisce + * al calcolo di sotto-dominio. + */ +export function applicaCurva(voce: VoceRegistro, valore: number): number | null { + if (!voce.curva) return null; + const p = (voce.params ?? {}) as ParamsCurva; + switch (voce.curva) { + case 'bell': + return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number); + case 'lin_dec': + return curvaDecrescente(valore, p.best as number, p.worst as number); + case 'inc_plateau': + return curvaCrescenteConPlateau(valore, p.worst as number, p.plateauStart as number); + case 'x10': + return curvaDirettaX10(valore); + case 'x10_inv': + return curvaDirettaX10Invertita(valore); + case 'decstep': + return curvaGradini(valore, p.steps ?? [], p.zeroVal, true); + case 'incstep': + return curvaGradini(valore, p.steps ?? [], p.zeroVal, false); + default: + return null; + } +} + +/** Esito del calcolo di una sessione: i sette assi, i tre macro-score, la Fitness Age. */ +export type RisultatoSessione = { + assi: Record; + macro: Record; + fitnessAge: Punteggio; + modelVersion: string; + questVersion: string | null; +}; + +/** + * Calcola i punteggi di una sessione leggendo dal registro e dai pesi, non da + * costanti: `testAttivi` alla data della sessione, i valori grezzi da + * `misure`, i pesi da `pesi` (tramite `pesiDi`). + */ +export function calcolaSessione(db: Database.Database, sessioneId: number): RisultatoSessione { + const sessione = db.prepare( + `SELECT data, eta_alla_data, quest_version FROM sessioni WHERE id = ?` + ).get(sessioneId) as { data: string; eta_alla_data: number | null; quest_version: string | null } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const attivi = testAttivi(db, sessione.data); + const misurate = new Map( + (db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ?`).all(sessioneId) as + { test_id: string; valore_num: number | null }[]) + .filter((m) => m.valore_num !== null) + .map((m) => [m.test_id, m.valore_num as number]) + ); + + // Livello 1: normalizza ogni test attivo e misurato, poi raggruppa per + // sotto-dominio — più test nello stesso sotto-dominio contano come media. + const perSottoDominio = new Map(); + for (const voce of attivi) { + if (!voce.sotto_dominio) continue; + const grezzo = misurate.get(voce.test_id); + if (grezzo === undefined) continue; + const punteggio = applicaCurva(voce, grezzo); + if (punteggio === null) continue; + const lista = perSottoDominio.get(voce.sotto_dominio) ?? []; + lista.push(punteggio); + perSottoDominio.set(voce.sotto_dominio, lista); + } + const sottoDominio: Record = {}; + for (const [nome, valori] of perSottoDominio) { + sottoDominio[nome] = arrotonda1(valori.reduce((a, b) => a + b, 0) / valori.length); + } + + // Livello 2: i sette assi. I nomi vengono dai pesi in database, non da un elenco fisso. + const nomiAssi = (db.prepare( + `SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'` + ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); + + const assi: Record = {}; + for (const nomeAsse of nomiAssi) { + assi[nomeAsse] = calcolaAsse(pesiDi(db, MODEL_VERSION, 'asse', nomeAsse), sottoDominio); + } + + // Livello 3: i tre macro-score (Performance/Energy/Recovery). + const nomiMacro = (db.prepare( + `SELECT DISTINCT contenitore FROM pesi WHERE model_version = ? AND livello = 'macro'` + ).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore); + + const macro: Record = {}; + for (const nomeMacro of nomiMacro) { + macro[nomeMacro] = calcolaMacro(pesiDi(db, MODEL_VERSION, 'macro', nomeMacro), assi); + } + + // Livello 4: Fitness Age. cardio/forza_resto/composizione/stabilita vengono + // dal punteggio intero dell'asse; handgrip_isolato e hrv_isolato dal + // sotto-dominio isolato, prima che entri nell'aggregazione del suo asse — + // come nell'oracolo, che guarda quei due sotto-domini da soli. + const valoreAsse = (nome: string): number | null => { + const a = assi[nome]; + return a && a.stato === 'ok' ? a.valore : null; + }; + const vociFitnessAge: Record = { + cardio: valoreAsse('Cardio-Respiratorio'), + handgrip_isolato: sottoDominio.handgrip ?? null, + hrv_isolato: sottoDominio.hrv ?? null, + forza_resto: valoreAsse('Forza & Struttura'), + composizione: valoreAsse('Composizione Corporea'), + stabilita: valoreAsse('Stabilità & Mobilità Funzionale'), + }; + const { fitnessAge, composito } = calcolaFitnessAge( + sessione.eta_alla_data ?? 0, + pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'), + vociFitnessAge + ); + const fitnessAgePunteggio: Punteggio = composito.stato === 'insufficiente' + ? { stato: 'insufficiente', copertura: composito.copertura } + : { stato: 'ok', valore: fitnessAge as number, copertura: composito.copertura }; + + return { + assi, macro, fitnessAge: fitnessAgePunteggio, + modelVersion: MODEL_VERSION, questVersion: sessione.quest_version, + }; +} + +/** + * Congela il risultato di `calcolaSessione` nella tabella `score`: una riga + * per asse, una per macro-score, una per la Fitness Age. `valore` va a + * `null` quando lo stato è insufficiente — il tipo `Punteggio` non porta un + * `valore` in quel ramo, quindi non c'è modo di scrivere un numero fasullo. + */ +export function salvaScore(db: Database.Database, sessioneId: number, risultato: RisultatoSessione): void { + const sessione = db.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(sessioneId) as + { client_code: string } | undefined; + if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`); + + const ins = db.prepare( + `INSERT INTO score (client_code, sessione_id, tipo, elemento, valore, copertura, stato, quest_version, model_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + const riga = (tipo: 'asse' | 'macro' | 'fitness_age', elemento: string, p: Punteggio): void => { + ins.run( + sessione.client_code, sessioneId, tipo, elemento, + p.stato === 'ok' ? p.valore : null, + p.copertura, p.stato, risultato.questVersion, risultato.modelVersion + ); + }; + + const tx = db.transaction(() => { + for (const [nome, p] of Object.entries(risultato.assi)) riga('asse', nome, p); + for (const [nome, p] of Object.entries(risultato.macro)) riga('macro', nome, p); + riga('fitness_age', 'fitness_age', risultato.fitnessAge); + }); + tx(); +} diff --git a/src/lib/longevity/registro.ts b/src/lib/longevity/registro.ts index 30cd1f1..3204c26 100644 --- a/src/lib/longevity/registro.ts +++ b/src/lib/longevity/registro.ts @@ -2,6 +2,9 @@ import type Database from 'better-sqlite3'; export type Curva = 'bell' | 'lin_dec' | 'inc_plateau' | 'x10' | 'x10_inv' | 'decstep' | 'incstep'; +/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */ +export const MODEL_VERSION = 'v1.0'; + export type VoceRegistro = { test_id: string; etichetta: string; @@ -183,3 +186,14 @@ export function esisteTest(db: Database.Database, testId: string): boolean { const r = db.prepare(`SELECT 1 FROM registro_test WHERE test_id = ?`).get(testId); return r !== undefined; } + +/** I pesi di un contenitore (un asse, un macro-score o la Fitness Age) per la versione del modello data. */ +export function pesiDi( + db: Database.Database, modelVersion: string, + livello: 'asse' | 'macro' | 'fitness_age', contenitore: string +): Record { + const righe = db.prepare( + `SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?` + ).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[]; + return Object.fromEntries(righe.map((r) => [r.elemento, r.peso])); +} diff --git a/tests/longevity/motore.test.ts b/tests/longevity/motore.test.ts new file mode 100644 index 0000000..6f674d0 --- /dev/null +++ b/tests/longevity/motore.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro'; +import { salvaCompilazione } from '../../src/lib/longevity/questionario'; +import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + return db; +} + +const voce = (db: ReturnType, id: string) => + db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record; + +describe('il motore legge le curve dal registro', () => { + it('applica la curva dichiarata per il test, non una scritta nel codice', () => { + const db = dbPronto(); + const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) }; + expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9 + expect(applicaCurva(v as never, 4)).toBeLessThan(20); + }); + + it('rispetta la soglia dell alcol come e scritta nel registro', () => { + const db = dbPronto(); + const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) }; + expect(applicaCurva(v as never, 0)).toBe(100); + expect(applicaCurva(v as never, 7)).toBe(100); + expect(applicaCurva(v as never, 14)).toBe(0); + }); + + it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => { + const db = dbPronto(); + db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`) + .run(JSON.stringify({ best: 0, worst: 7 })); + const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) }; + expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100 + }); + + it('un test disattivato non entra nel calcolo', () => { + // NOTA: la versione originale di questo test disattivava 'q_ore_sonno' e si + // aspettava una differenza. Ma 'q_ore_sonno' e' uno delle SEI voci del + // sotto-dominio 'questionario_sonno' (peso 0.20 dentro "Recupero & Sistema + // Nervoso", che pesa in tutto 1.00: hrv 0.50 + pressione 0.15 + hrr 0.15 + + // questionario_sonno 0.20). Quell'asse resta 'insufficiente' con o senza + // 'q_ore_sonno' (0.20 < COPERTURA_MINIMA 0.4, e hrv/pressione/hrr non hanno + // ancora una voce nel registro): siccome un asse insufficiente non porta + // 'valore' nel tipo Punteggio, il JSON prima/dopo risultava IDENTICO — la + // suite lo ha confermato (verificato eseguendo il test: fallisce con le due + // stringhe uguali). Corretto qui usando 'q_attivita', l'unica voce del + // sotto-dominio 'questionario_lifestyle' che pesa 1.00 dentro "Stile di Vita + // & Sonno": disattivarla fa cadere la copertura di quell'asse da 1.00 a 0 e + // lo stato passa da 'ok' a 'insufficiente' — una differenza vera e osservabile. + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { + q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0, + q_attivita: 5, + }, + }); + const prima = calcolaSessione(db, 1); + expect(prima.assi['Stile di Vita & Sonno'].stato).toBe('ok'); + db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_attivita'`).run(); + const dopo = calcolaSessione(db, 1); + expect(dopo.assi['Stile di Vita & Sonno'].stato).toBe('insufficiente'); + expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo)); + }); +}); + +describe('calcolo e congelamento di una sessione', () => { + it('produce i sette assi, e quelli senza dati sono insufficienti', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 }, + }); + const r = calcolaSessione(db, 1); + expect(Object.keys(r.assi).length).toBe(7); + // il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40% + expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente'); + // Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto + expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente'); + }); + + it('congela i punteggi con le DUE versioni', () => { + const db = dbPronto(); + salvaCompilazione(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_riposato: 7 }, + }); + salvaScore(db, 1, calcolaSessione(db, 1)); + const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record[]; + expect(righe.length).toBeGreaterThan(0); + for (const r of righe) { + expect(r.model_version).toBe(MODEL_VERSION); + expect(r.quest_version).toBe('v1.0'); + if (r.stato === 'insufficiente') expect(r.valore).toBeNull(); + } + }); + + it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => { + const db = dbPronto(); + const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura'); + expect(pesi.handgrip).toBe(0.25); + expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6); + }); +});