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:
2026-08-22 10:49:25 +02:00
parent c4524e3a73
commit 469251366e
3 changed files with 312 additions and 14 deletions
+35 -10
View File
@@ -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);