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.
This commit is contained in:
2026-08-22 09:53:17 +02:00
parent 93ecc3d367
commit f238984828
3 changed files with 319 additions and 0 deletions
+195
View File
@@ -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<string, Punteggio>;
macro: Record<string, Punteggio>;
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<string, number>(
(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<string, number[]>();
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<string, number> = {};
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<string, Punteggio> = {};
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<string, Punteggio> = {};
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<string, number | null> = {
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();
}