469251366e
- 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
141 lines
6.5 KiB
TypeScript
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 };
|
|
}
|