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
This commit is contained in:
@@ -10,14 +10,31 @@
|
||||
* 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.
|
||||
* 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';
|
||||
@@ -96,9 +113,17 @@ export function calcolaMacro(pesiMacro: Record<string, number>, assi: Record<str
|
||||
* 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,
|
||||
etaAnagrafica: number | null,
|
||||
pesi: Record<string, number>,
|
||||
voci: Record<string, number | null>
|
||||
): { fitnessAge: number | null; composito: Punteggio } {
|
||||
@@ -107,7 +132,7 @@ export function calcolaFitnessAge(
|
||||
peso,
|
||||
}));
|
||||
const composito = aggrega(vociPesate);
|
||||
if (composito.stato === 'insufficiente') {
|
||||
if (composito.stato === 'insufficiente' || etaAnagrafica === null) {
|
||||
return { fitnessAge: null, composito };
|
||||
}
|
||||
const fitnessAge = arrotonda1(etaAnagrafica - (composito.valore - 50) * 0.4);
|
||||
|
||||
@@ -19,6 +19,19 @@
|
||||
* `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).
|
||||
* `leggiScore` fa il percorso inverso — legge `score` e la ritipa come
|
||||
* `Punteggio` — perché nessun altro punto del codice deve fare quella query
|
||||
* a mano su una colonna nullable.
|
||||
*
|
||||
* Tre barriere aggiunte in revisione, tutte nella direzione "dato insufficiente
|
||||
* → null, mai un numero fasullo": l'età mancante (nullable nello schema) non
|
||||
* usa più un ripiego a zero — senza età la Fitness Age è `null`, non un'età di
|
||||
* forma negativa; `applicaCurva` valida i parametri della curva prima di
|
||||
* applicarla, e restituisce `null` se il registro (dato modificabile senza
|
||||
* migrazioni) ha una riga incompleta, invece del punteggio pieno che dava
|
||||
* `lerp` con estremi indefiniti; e la lettura delle misure esclude
|
||||
* `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma
|
||||
* (punto aperto in §12).
|
||||
*/
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
@@ -36,15 +49,51 @@ type ParamsCurva = {
|
||||
steps?: [number, number][]; zeroVal?: number;
|
||||
};
|
||||
|
||||
/** Vero solo per un numero finito: esclude `undefined`, `NaN`, stringhe, ecc. */
|
||||
function numeroValido(x: unknown): x is number {
|
||||
return typeof x === 'number' && Number.isFinite(x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica che `params` contenga tutto cio' che la curva dichiarata richiede,
|
||||
* PRIMA di passarlo alla funzione di curva. Il registro e' dato modificabile
|
||||
* senza migrazioni (la promessa della spec, §5): una riga con `params` vuoti
|
||||
* o incompleti deve fermarsi qui, non arrivare a `lerp` con estremi
|
||||
* indefiniti - dove `x1 === x0` (`undefined === undefined`) restituisce il
|
||||
* punteggio pieno, la peggior direzione di guasto possibile per un motore
|
||||
* clinico.
|
||||
*/
|
||||
function paramsValidi(curva: VoceRegistro['curva'], p: ParamsCurva): boolean {
|
||||
switch (curva) {
|
||||
case 'bell':
|
||||
return numeroValido(p.low) && numeroValido(p.peakLow) && numeroValido(p.peakHigh) && numeroValido(p.high);
|
||||
case 'lin_dec':
|
||||
return numeroValido(p.best) && numeroValido(p.worst);
|
||||
case 'inc_plateau':
|
||||
return numeroValido(p.worst) && numeroValido(p.plateauStart);
|
||||
case 'x10':
|
||||
case 'x10_inv':
|
||||
return true; // nessun parametro richiesto
|
||||
case 'decstep':
|
||||
case 'incstep':
|
||||
return Array.isArray(p.steps) && p.steps.length > 0 &&
|
||||
p.steps.every((s) => Array.isArray(s) && s.length === 2 && numeroValido(s[0]) && numeroValido(s[1]));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* al calcolo di sotto-dominio. Lo stesso vale per una curva coi parametri
|
||||
* mancanti o incompleti: mai un punteggio inventato (vedi `paramsValidi`).
|
||||
*/
|
||||
export function applicaCurva(voce: VoceRegistro, valore: number): number | null {
|
||||
if (!voce.curva) return null;
|
||||
const p = (voce.params ?? {}) as ParamsCurva;
|
||||
if (!paramsValidi(voce.curva, p)) return null;
|
||||
switch (voce.curva) {
|
||||
case 'bell':
|
||||
return curvaCampana(valore, p.low as number, p.peakLow as number, p.peakHigh as number, p.high as number);
|
||||
@@ -94,8 +143,12 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu
|
||||
if (!sessione) throw new Error(`sessione ${sessioneId} inesistente`);
|
||||
|
||||
const attivi = testAttivi(db, sessione.data);
|
||||
// fuori_range = 1 esclusa: §9 della spec, "non entrano nello score finche' qualcuno
|
||||
// non le conferma". La colonna della conferma non esiste ancora (punto aperto in
|
||||
// §12 della spec) - quando ci sara', questo filtro andra' rilassato per lasciar
|
||||
// passare le misure confermate, non tolto del tutto.
|
||||
const misurate = new Map<string, number>(
|
||||
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ?`).all(sessioneId) as
|
||||
(db.prepare(`SELECT test_id, valore_num FROM misure WHERE sessione_id = ? AND fuori_range = 0`).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])
|
||||
@@ -156,7 +209,7 @@ export function calcolaSessione(db: Database.Database, sessioneId: number): Risu
|
||||
stabilita: valoreAsse('Stabilità & Mobilità Funzionale'),
|
||||
};
|
||||
const { fitnessAge, composito } = calcolaFitnessAge(
|
||||
sessione.eta_alla_data ?? 0,
|
||||
sessione.eta_alla_data,
|
||||
pesiDi(db, MODEL_VERSION, 'fitness_age', 'fitness_age'),
|
||||
vociFitnessAge
|
||||
);
|
||||
@@ -201,3 +254,54 @@ export function salvaScore(db: Database.Database, sessioneId: number, risultato:
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/** Esito di `leggiScore`: gli assi, i macro-score e la Fitness Age, tipati come `Punteggio`. */
|
||||
export type PunteggiSessione = {
|
||||
assi: Record<string, Punteggio>;
|
||||
macro: Record<string, Punteggio>;
|
||||
/** `null` = nessun calcolo mai salvato per questa sessione (non un composito insufficiente). */
|
||||
fitnessAge: Punteggio | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legge i punteggi congelati di una sessione dalla tabella `score`, tipati come
|
||||
* `Punteggio` — la stessa unione discriminata del resto del motore. Nessuno
|
||||
* legge ancora `score` (la scrive solo `salvaScore`): la prima query cruda che
|
||||
* l'interfaccia scriverà su una colonna nullable perderebbe la barriera del
|
||||
* tipo se restasse un semplice `SELECT *`. Qui non la perde.
|
||||
*
|
||||
* `salvaScore` non ha vincolo di unicità di proposito — la storia si tiene,
|
||||
* non si sovrascrive (discende dal congelamento della §6: sovrascrivere
|
||||
* cancellerebbe cio' che il congelamento protegge). Una sessione ricalcolata
|
||||
* più volte ha quindi più righe per lo stesso tipo/elemento: questa query
|
||||
* dichiara ESPLICITAMENTE il criterio dell'ultimo calcolo (il `MAX(id)` per
|
||||
* tipo+elemento, non l'ordine naturale con cui SQLite restituisce le righe,
|
||||
* che non è un criterio).
|
||||
*/
|
||||
export function leggiScore(db: Database.Database, sessioneId: number): PunteggiSessione {
|
||||
const righe = db.prepare(
|
||||
`SELECT s.tipo, s.elemento, s.valore, s.copertura, s.stato
|
||||
FROM score s
|
||||
JOIN (
|
||||
SELECT tipo, elemento, MAX(id) AS ultimo_id
|
||||
FROM score
|
||||
WHERE sessione_id = ?
|
||||
GROUP BY tipo, elemento
|
||||
) ultimo ON ultimo.tipo = s.tipo AND ultimo.elemento = s.elemento AND ultimo.ultimo_id = s.id
|
||||
WHERE s.sessione_id = ?`
|
||||
).all(sessioneId, sessioneId) as
|
||||
{ tipo: 'asse' | 'macro' | 'fitness_age'; elemento: string; valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }[];
|
||||
|
||||
const aPunteggio = (r: { valore: number | null; copertura: number; stato: 'ok' | 'insufficiente' }): Punteggio =>
|
||||
r.stato === 'ok' ? { stato: 'ok', valore: r.valore as number, copertura: r.copertura } : { stato: 'insufficiente', copertura: r.copertura };
|
||||
|
||||
const assi: Record<string, Punteggio> = {};
|
||||
const macro: Record<string, Punteggio> = {};
|
||||
let fitnessAge: Punteggio | null = null;
|
||||
for (const r of righe) {
|
||||
if (r.tipo === 'asse') assi[r.elemento] = aPunteggio(r);
|
||||
else if (r.tipo === 'macro') macro[r.elemento] = aPunteggio(r);
|
||||
else fitnessAge = aPunteggio(r);
|
||||
}
|
||||
return { assi, macro, fitnessAge };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user