longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* UNICA DIVERGENZA VOLUTA dall'oracolo: 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. 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 };
|
||||
|
||||
/** Arrotonda a due cifre decimali, come `round(x, 2)` per la copertura nell'oracolo. */
|
||||
function arrotonda2(x: number): number {
|
||||
return Math.round(x * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 copertura = pesoTotale ? arrotonda2(pesoDisponibile / pesoTotale) : 0;
|
||||
|
||||
if (disponibili.length === 0) {
|
||||
return { stato: 'insufficiente', copertura: 0 };
|
||||
}
|
||||
|
||||
if (copertura < 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.
|
||||
*/
|
||||
export function calcolaFitnessAge(
|
||||
etaAnagrafica: number,
|
||||
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') {
|
||||
return { fitnessAge: null, composito };
|
||||
}
|
||||
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
|
||||
return { fitnessAge, composito };
|
||||
}
|
||||
Reference in New Issue
Block a user