Files
InsanityLab-web/src/lib/longevity/motore/cascata.ts
T
Adriano 469251366e motore: chiude i tre buchi laterali della barriera dell'insufficiente
- eta_alla_data NULL non usa più il ripiego a zero: senza età la
  Fitness Age è null, non un'età di forma negativa dichiarata valida.
  Gli assi e i macro-score restano calcolabili (non dipendono dall'età).
- applicaCurva valida i parametri richiesti da ciascuna curva prima di
  applicarla: un registro con params vuoti/incompleti dà null, mai il
  punteggio pieno che dava lerp con estremi indefiniti.
- calcolaSessione esclude le misure fuori_range=1 (§9 della spec): non
  entrano nello score finché non esiste la colonna di conferma (§12,
  punto aperto).
- leggiScore(db, sessioneId): legge la tabella score ritipata come
  Punteggio, con l'ultimo calcolo per tipo+elemento esplicito nella
  query (MAX(id)), non l'ordine naturale delle righe — salvaScore non
  sovrascrive di proposito (la storia degli score si tiene, discende
  dal congelamento della §6).
- documentata la seconda divergenza dall'oracolo, mai scritta finora:
  la Fitness Age esclude gli assi insufficienti, l'oracolo li include
  comunque. Comportamento giusto, ma cambia il numero (25,0 contro
  34,8 sullo stesso profilo parziale).
- nuovo test che prova che i pesi di due model_version diverse restano
  separati (pesiDi filtra su model_version, non li fonde).

Ogni fix verificato in TDD (test rosso prima, verde dopo) e ri-rotto a
mano per confermare che discrimina davvero (vedi report).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XhLbMQ1q7wHwJSykgXRwQF
2026-08-22 10:49:25 +02:00

141 lines
6.5 KiB
TypeScript

/**
* La cascata a quattro livelli: dalle sotto-metriche normalizzate ai
* punteggi di sotto-dominio, ai sette assi del radar, ai tre macro-score
* (Performance/Energy/Recovery), fino alla Fitness Age.
*
* Porting di `aggregate`, `compute_axis`, `compute_macro_scores` e
* `compute_fitness_age` dall'oracolo del cliente
* (`tests/longevity/riferimento/isl_scoring_engine.py`, sezioni LIVELLO 2-4).
* Regola di copertura, uguale a ogni livello: se un elemento manca, il suo
* peso si ridistribuisce su quelli disponibili; se la copertura di peso
* disponibile scende sotto `COPERTURA_MINIMA`, l'elemento e' insufficiente.
*
* DUE DIVERGENZE VOLUTE dall'oracolo:
*
* 1) Nell'oracolo `aggregate()` restituisce il punteggio pieno anche quando lo
* dichiara insufficiente (la docstring dice il contrario di cio' che il
* codice fa) - chi consuma deve ricordarsi di guardare lo stato, e prima o
* poi qualcuno non lo fa. Qui il tipo `Punteggio` chiude la trappola: nel
* ramo 'insufficiente' il campo `valore` non esiste, quindi la regola di
* prodotto - un asse con dati insufficienti si mostra tratteggiato, mai
* con un numero pieno fasullo - e' impossibile da violare per distrazione.
*
* 2) Nel calcolo della Fitness Age (vedi `motore/index.ts`, `valoreAsse`)
* escludiamo gli assi 'insufficiente' dal composito: `cardio`,
* `forza_resto`, `composizione` e `stabilita` entrano solo se il rispettivo
* asse e' 'ok'. L'oracolo invece li include comunque - per lo stesso motivo
* del punto 1: `compute_axis` restituisce uno `score` numerico anche
* quando lo stato e' "insufficiente", e `compute_fitness_age` lo riceve e
* lo usa senza controllare lo stato. E' il comportamento GIUSTO (un asse
* sotto soglia non e' un dato affidabile da far pesare sull'eta biologica),
* ma cambia il numero: su un profilo con assi parzialmente insufficienti,
* verificato a mano, la nostra Fitness Age e' 25,0 contro 34,8 dell'oracolo
* - quasi dieci anni di scarto. Chi confronta il nostro referto col Python
* del cliente su un profilo parziale concludera' che il porting e' rotto:
* non lo e', diverge di proposito qui.
*
* Tutto il resto e' fedele all'oracolo.
*/
import { COPERTURA_MINIMA } from '../db';
import { arrotonda1 } from './curve';
/** Esito di un'aggregazione pesata: 'ok' porta il valore, 'insufficiente' no. */
export type Punteggio =
| { stato: 'ok'; valore: number; copertura: number }
| { stato: 'insufficiente'; copertura: number };
/** Una voce da aggregare: punteggio 0-100 (o null se il dato manca) e il suo peso nominale. */
export type VocePesata = { punteggio: number | null; peso: number };
/**
* Media pesata delle voci disponibili, con i pesi delle voci mancanti
* ridistribuiti su quelle presenti. Come `aggregate` nell'oracolo, con la
* differenza di tipo descritta sopra.
*
* ATTENZIONE (bug corretto, trovato in revisione): il confronto con la soglia
* usa la copertura GREZZA (`pesoDisponibile / pesoTotale`), non quella
* arrotondata. L'arrotondamento a due decimali (`arrotonda1(.., 2)`) serve
* solo al valore esposto nel campo `copertura` del risultato. Confrontare la
* copertura gia' arrotondata avrebbe fatto passare per 'ok' una copertura
* grezza appena sotto 0.40 che arrotonda a 0.40 (es. 0.396) - esattamente il
* numero pieno fasullo che il tipo `Punteggio` esiste per impedire.
*/
export function aggrega(voci: VocePesata[]): Punteggio {
const pesoTotale = voci.reduce((s, v) => s + v.peso, 0);
const disponibili = voci.filter((v) => v.punteggio !== null);
const pesoDisponibile = disponibili.reduce((s, v) => s + v.peso, 0);
const coperturaGrezza = pesoTotale ? pesoDisponibile / pesoTotale : 0;
const copertura = arrotonda1(coperturaGrezza, 2);
if (disponibili.length === 0) {
return { stato: 'insufficiente', copertura: 0 };
}
if (coperturaGrezza < COPERTURA_MINIMA) {
return { stato: 'insufficiente', copertura };
}
const sommaPesata = disponibili.reduce((s, v) => s + (v.punteggio as number) * v.peso, 0);
const valore = arrotonda1(sommaPesata / pesoDisponibile);
return { stato: 'ok', valore, copertura };
}
/**
* Livello 2: punteggi di sotto-dominio -> punteggio Asse. Come `compute_axis`
* nell'oracolo: `pesi` e' la config del foglio "Pesi e Formule" per quell'asse,
* `punteggi` i valori 0-100 disponibili (assenti = mancanti).
*/
export function calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio {
const voci: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: punteggi[chiave] ?? null,
peso,
}));
return aggrega(voci);
}
/**
* Livello 3: i sette Assi -> un macro-score (Performance/Energy/Recovery).
* Come `compute_macro_scores` nell'oracolo: un asse insufficiente non entra
* col suo valore (che qui non esiste nemmeno), entra come mancante e il suo
* peso si ridistribuisce.
*/
export function calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio {
const voci: VocePesata[] = Object.entries(pesiMacro).map(([asse, peso]) => {
const p = assi[asse];
return { punteggio: p && p.stato === 'ok' ? p.valore : null, peso };
});
return aggrega(voci);
}
/**
* Livello 4: Fitness Age. Come `compute_fitness_age` nell'oracolo: un
* composito di sei elementi (handgrip e HRV isolati, non tramite l'intero
* asse) da cui `eta - (composito - 50) * 0.4`. Se il composito e'
* insufficiente non c'e' Fitness Age da mostrare.
*
* `etaAnagrafica` e' `number | null` perche' `sessioni.eta_alla_data` e'
* nullable nello schema: l'apertura di una sessione e il salvataggio di un
* questionario accettano l'eta come opzionale. Senza eta la Fitness Age NON
* ESISTE - deve restituire `null`, mai un numero calcolato con un ripiego
* (es. 0), che produrrebbe un'eta di forma negativa dichiarata valida. Gli
* assi e i macro-score non dipendono dall'eta e restano calcolabili a monte:
* solo questo ultimo livello si ferma.
*/
export function calcolaFitnessAge(
etaAnagrafica: number | null,
pesi: Record<string, number>,
voci: Record<string, number | null>
): { fitnessAge: number | null; composito: Punteggio } {
const vociPesate: VocePesata[] = Object.entries(pesi).map(([chiave, peso]) => ({
punteggio: voci[chiave] ?? null,
peso,
}));
const composito = aggrega(vociPesate);
if (composito.stato === 'insufficiente' || etaAnagrafica === null) {
return { fitnessAge: null, composito };
}
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
return { fitnessAge, composito };
}