Files
InsanityLab-web/apps/sito/docs/plans/2026-08-22-longevity-motore.md
T
AdrianoDev 510d7ca4ec Struttura: il sito esistente si sposta sotto apps/sito
Primo passo della separazione sito / piattaforme decisa da Adriano il 03/09:
due applicazioni autosufficienti, senza workspace di root, cosi' che ognuna
resti un progetto Astro completo ed estraibile.

Qui non cambia niente del codice: solo la radice. I pattern del .gitignore
che avevano una barra erano ancorati alla radice e dopo lo spostamento non
avrebbero piu' coperto niente (data/*.db*, uploads/*): resi validi a
qualunque profondita'.

Suite: 45 file, 347 test verdi prima e dopo lo spostamento.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EndnceRA5WnA6rvA5iV9WL
2026-09-03 11:05:54 +00:00

767 lines
37 KiB
Markdown

# Longevity — motore di calcolo: piano di implementazione
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** portare in TypeScript il motore di punteggio del cliente, con un verificatore numerico che dimostri che i due producono gli stessi numeri.
**Architecture:** le curve di normalizzazione e la cascata a quattro livelli diventano funzioni pure in `src/lib/longevity/motore/`. Curve e pesi si leggono dal registro nel database, non da costanti nel codice. Il tipo di ritorno rende impossibile leggere il valore di un punteggio dichiarato insufficiente. Un oracolo Python versionato genera i casi di riferimento contro cui il porting si confronta.
**Tech Stack:** TypeScript, vitest, `python3` (solo per generare il riferimento). Nessuna dipendenza npm nuova.
**Spec:** `docs/specs/2026-08-21-longevity-design.md`
**Piano precedente, già eseguito:** `docs/plans/2026-08-21-longevity-strato-dati.md` — lo strato dati è costruito (ramo `feat/longevity`, 20 commit).
## Global Constraints
- Branch **`feat/longevity`**. `main` non si tocca, non si deploya, non si pusha senza che Adriano lo chieda.
- Nessuna dipendenza npm nuova.
- Test in `tests/longevity/`, eseguiti con `npm test`, che usano `':memory:'`.
- Codice e commenti in italiano.
- Soglia di copertura: **0.40**, la costante `COPERTURA_MINIMA` già esportata da `src/lib/longevity/db.ts`. Mai un numero scritto a mano nei rami.
- `MODEL_VERSION` iniziale: **`v1.0`**.
- **Nessun dato reale di persone** nelle fixture: i casi di prova sono sintetici.
- ⚠️ **La suite parte con un rosso che non è nostro:** `tests/modifiche-agosto.test.ts` fallisce da prima (test del sito disallineato su `main`, fuori perimetro). L'atteso è **1 fallito pre-esistente**, il resto verde. Non ripararlo.
## Il principio che regge tutto il piano
Il porting delle curve è la parte più pericolosa dell'intera piattaforma, e il pericolo non è il codice che non compila: è **un coefficiente trascritto male**. Produce un punteggio sanitario sbagliato che sembra plausibile, che nessun test funzionale intercetta, e che diventa il valore "atteso" di tutto ciò che gli sta sopra.
Quindi la fedeltà **non è garantita dalla trascrizione**, ma dal confronto numerico con l'originale. L'oracolo (`tests/longevity/riferimento/isl_scoring_engine.py`, scritto dal cliente) genera i valori attesi su una griglia fitta di ingressi; il test TypeScript li rilegge e confronta. Un implementatore che sbagli una cifra se ne accorge subito, non fra sei mesi.
⚠️ **Corollario:** nessun task di questo piano è "finito" perché i suoi test passano. È finito quando il **confronto con l'oracolo** passa sulle funzioni che tocca.
## Struttura dei file
| File | Responsabilità |
|---|---|
| `tests/longevity/riferimento/isl_scoring_engine.py` | l'oracolo (già presente, del cliente, non si modifica) |
| `tests/longevity/riferimento/genera-riferimento.py` | esegue l'oracolo su una griglia e scrive `riferimento.json` |
| `src/lib/longevity/motore/curve.ts` | le cinque curve del questionario + le utility |
| `src/lib/longevity/motore/test-fisici.ts` | le curve dei test misurati in sala e dagli strumenti |
| `src/lib/longevity/motore/cascata.ts` | aggregazione, assi, macro-score, Fitness Age |
| `src/lib/longevity/motore/index.ts` | la funzione che calcola una sessione leggendo dal registro |
---
### Task 1: L'oracolo genera il riferimento
Prima di portare una sola curva, serve il metro di paragone.
**Files:**
- Create: `tests/longevity/riferimento/genera-riferimento.py`
- Create (generato): `tests/longevity/riferimento/riferimento.json`
- Test: nessuno — è uno strumento; lo verificano i task seguenti
**Interfaces:**
- Consumes: `tests/longevity/riferimento/isl_scoring_engine.py`
- Produces: `riferimento.json`, con questa forma esatta:
```json
{
"generato_da": "isl_scoring_engine.py",
"casi": [
{ "fn": "score_plank", "args": [110, "M"], "atteso": 62.5 },
{ "fn": "score_bell_curve", "args": [7.5, 4, 7, 9, 12], "atteso": 100 }
]
}
```
- [ ] **Step 1: Scrivi il generatore**
⚠️ **Questo passo è già stato eseguito.** La griglia definitiva vive in
`tests/longevity/riferimento/genera-riferimento.py`, committata e verificata: rieseguirla
riproduce `riferimento.json` byte per byte (4018 casi, 25 funzioni, nessuna eccezione).
Qui **non** ne teniamo una seconda copia, e la ragione è un incidente vero: quando il piano
conteneva anche il codice, piano e script sono divergiti — il file di riferimento è rimasto
per un momento non riproducibile dal proprio generatore, che per un oracolo è il difetto
peggiore possibile. Due copie della stessa cosa si separano sempre; il codice sta nel repo,
il piano dice cosa deve fare e perché.
**Cosa la griglia copre**, che è ciò che va verificato se un domani si tocca:
- le curve del questionario su **tutto** il dominio, non su un campione, comprese le due
coppie di parametri di `score_decreasing` — quella normale e quella dell'alcol (7→14);
- `score_increasing_plateau` con `worst` **diverso da zero**: zero è il caso degenere in
cui un errore sull'offset resterebbe invisibile;
- `vo2max_from_2km_walk` variando **un parametro alla volta** dal caso base, così una
divergenza dice anche quale dei cinque coefficienti è sbagliato;
- i confini di banda **esatti** delle funzioni a bande (composizione, pressione, plank,
flamingo, SpO2), che una griglia a passo regolare salterebbe;
- le tre tabelle dei sollevamenti sul rapporto col peso corporeo, per entrambi i sessi.
- [ ] **Step 2: Eseguilo e guarda l'esito**
Run: `python3 tests/longevity/riferimento/genera-riferimento.py`
Expected: stampa il numero di casi (nell'ordine delle migliaia) e scrive il file.
⚠️ Se una chiamata solleva un'eccezione, **non aggirarla**: significa che l'oracolo ha un dominio più stretto di quanto la griglia assume. Restringi la griglia per quella funzione e **annota nel rapporto quale dominio hai dovuto escludere** — è un'informazione che serve a chi userà il motore.
- [ ] **Step 3: Verifica che il file sia sensato**
Run: `python3 -c "import json;d=json.load(open('tests/longevity/riferimento/riferimento.json'));print(len(d['casi']),'casi');print(sorted({c['fn'] for c in d['casi']}))"`
Expected: l'elenco delle funzioni coperte, senza buchi rispetto alla griglia sopra.
- [ ] **Step 4: Commit**
```bash
git add tests/longevity/riferimento/
git commit -m "longevity: l'oracolo del motore e la griglia di riferimento"
```
---
### Task 2: Le cinque curve del questionario
**Files:**
- Create: `src/lib/longevity/motore/curve.ts`
- Test: `tests/longevity/motore-curve.test.ts`
**Interfaces:**
- Consumes: `riferimento.json` (Task 1)
- Produces:
- `clamp(x: number, lo?: number, hi?: number): number`
- `lerp(x: number, x0: number, x1: number, y0: number, y1: number): number`
- `curvaCampana(v: number, low: number, peakLow: number, peakHigh: number, high: number): number`
- `curvaDecrescente(v: number, best: number, worst: number): number`
- `curvaCrescenteConPlateau(v: number, worst: number, plateauStart: number): number`
- `curvaDirettaX10(v: number): number`
- `curvaDirettaX10Invertita(v: number): number`
- `curvaGradini(v: number, steps: [number, number][], zeroVal: number | undefined, decrescente: boolean): number`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-curve.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
clamp, lerp, curvaCampana, curvaDecrescente, curvaCrescenteConPlateau,
curvaDirettaX10, curvaDirettaX10Invertita, curvaGradini,
} from '../../src/lib/longevity/motore/curve';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
const casiDi = (fn: string) => RIF.casi.filter((c) => c.fn === fn);
describe('curve del questionario, confrontate con l oracolo', () => {
it('la campana combacia su tutto il dominio', () => {
const casi = casiDi('score_bell_curve');
expect(casi.length).toBeGreaterThan(20);
for (const c of casi) {
const [v, low, pl, ph, high] = c.args as number[];
expect(curvaCampana(v, low, pl, ph, high)).toBeCloseTo(c.atteso, 1);
}
});
it('la decrescente combacia, inclusa la soglia 7-14 dell alcol', () => {
const casi = casiDi('score_decreasing');
expect(casi.length).toBeGreaterThan(40);
for (const c of casi) {
const [v, best, worst] = c.args as number[];
expect(curvaDecrescente(v, best, worst)).toBeCloseTo(c.atteso, 1);
}
});
it('la crescente con plateau combacia', () => {
for (const c of casiDi('score_increasing_plateau')) {
const [v, worst, plateau] = c.args as number[];
expect(curvaCrescenteConPlateau(v, worst, plateau)).toBeCloseTo(c.atteso, 1);
}
});
it('la diretta per dieci combacia', () => {
for (const c of casiDi('score_direct_x10')) {
expect(curvaDirettaX10((c.args as number[])[0])).toBeCloseTo(c.atteso, 1);
}
});
// Questa curva NON esiste nell'oracolo: è la quinta, che al motore del cliente manca.
// Serve a q_calo_pomeridiano e nel prototipo HTML è `scale10_inv`: (10 - v) * 10.
it('la diretta invertita e il complemento della diretta', () => {
for (const v of [0, 2.5, 5, 7.5, 10]) {
expect(curvaDirettaX10Invertita(v)).toBeCloseTo(curvaDirettaX10(10 - v), 6);
}
expect(curvaDirettaX10Invertita(0)).toBe(100);
expect(curvaDirettaX10Invertita(10)).toBe(0);
});
it('i gradini decrescenti riproducono il prototipo: q_sigarette', () => {
const steps: [number, number][] = [[0, 100], [5, 60], [10, 40], [20, 20], [999, 0]];
expect(curvaGradini(0, steps, undefined, true)).toBe(100);
expect(curvaGradini(5, steps, undefined, true)).toBe(60);
expect(curvaGradini(10, steps, undefined, true)).toBe(40);
expect(curvaGradini(20, steps, undefined, true)).toBe(20);
expect(curvaGradini(40, steps, undefined, true)).toBeLessThan(20);
});
it('zeroVal ha la precedenza sui gradini: q_schermi a zero vale 100', () => {
const steps: [number, number][] = [[15, 85], [30, 70], [60, 40], [999, 10]];
expect(curvaGradini(0, steps, 100, true)).toBe(100);
expect(curvaGradini(15, steps, 100, true)).toBe(85);
});
it('clamp e lerp si comportano come nell oracolo', () => {
expect(clamp(150)).toBe(100);
expect(clamp(-5)).toBe(0);
expect(lerp(5, 0, 10, 0, 100)).toBe(50);
expect(lerp(-1, 0, 10, 0, 100)).toBe(0); // t viene limitato a [0,1]
expect(lerp(11, 0, 10, 0, 100)).toBe(100);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-curve.test.ts`
Expected: FAIL — modulo `motore/curve` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta le funzioni dall'oracolo `tests/longevity/riferimento/isl_scoring_engine.py`, sezione «Questionario: curve generiche» più `clamp` e `_lerp` in cima al file. Nomi italiani come da interfacce sopra.
Due avvertenze che l'oracolo non dichiara e che i test qui sopra pretendono:
- **`_lerp` limita `t` all'intervallo [0,1]**, quindi le curve non escono mai oltre gli estremi: è il motivo per cui `curvaDecrescente(0, 7, 14)` vale 100 e non di più. È esattamente ciò che rende voluta la soglia dell'alcol.
- **`curvaGradini` non è nell'oracolo**: è la logica `decstep`/`incstep` del prototipo HTML del cliente. Se il valore è zero ed esiste `zeroVal`, vince quello; altrimenti si interpola fra i gradini, partendo da `(0, 100)` per le decrescenti e `(0, 20)` per le crescenti.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-curve.test.ts`
Expected: PASS, 8 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/curve.ts tests/longevity/motore-curve.test.ts
git commit -m "longevity: le cinque curve del questionario, verificate contro l oracolo"
```
---
### Task 3: Le curve dei test fisici
**Files:**
- Create: `src/lib/longevity/motore/test-fisici.ts`
- Test: `tests/longevity/motore-fisici.test.ts`
**Interfaces:**
- Consumes: `clamp`, `lerp` da `motore/curve` (Task 2); `riferimento.json` (Task 1)
- Produces, con questi nomi esatti (l'ordine dei parametri è quello dell'oracolo):
- `scoreGrassoPercento(fatPct: number, sesso: Sesso): number`
- `scoreMuscoloPercento(musclePct: number, sesso: Sesso): number`
- `scoreWhr(whr: number, sesso: Sesso): number`
- `scoreVo2max(vo2max: number, eta: number, sesso: Sesso): number`
- `scoreSpo2(spo2: number): number`
- `scorePressione(sistolica: number, diastolica: number): number`
- `scoreRecuperoCardiaco(caloBpm1min: number): number`
- `scoreHandgrip(kg: number, eta: number, sesso: Sesso): number`
- `scorePushup(reps: number, eta: number, sesso: Sesso): number`
- `scoreSollevamentoSuPeso(caricoKg, pesoKg, rip, tiersM, tiersF, sesso): number`
- `scoreTrazioneIsometrica(sec: number, sesso: Sesso): number`
- `scoreSitToStand(reps: number, eta: number, sesso: Sesso, bmi?: number): number`
- `scorePlank(sec: number, sesso: Sesso): number`
- `scoreBackScratch(cm: number, eta: number, sesso: Sesso): number`
- `scoreMobilitaSpalla(outreachDeg: number, bucklingDeg: number): number`
- `scoreFlamingo(cadute: number): number`
- `scoreSitAndReach(cm: number, sesso: Sesso): number`
- `vo2maxDaStepTest`, `vo2maxDa2kmWalk`, `vo2maxDaMutt`, `vo2maxDaMilfit`
- `type Sesso = 'M' | 'F'`
- le sei tabelle: `TIERS_BENCH_M`, `TIERS_BENCH_F`, `TIERS_SQUAT_M`, `TIERS_SQUAT_F`, `TIERS_ROW_M`, `TIERS_ROW_F`
⚠️ **Due funzioni dell'oracolo NON vanno portate**, e non è una dimenticanza:
- `score_agility_ms` — l'agilità è stata **rimossa dallo score** dal cliente il 13/08: 548 ms reali contro i 250 attesi davano 20/100, falsi, per una scala non comparabile fra protocolli. Nel motore la funzione è rimasta ma nessun peso la richiama.
- `score_generic_range_local` — definita in fondo al file e mai chiamata.
Portarle significherebbe riportare in vita una misura che il cliente ha escluso.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-fisici.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as F from '../../src/lib/longevity/motore/test-fisici';
const RIF = JSON.parse(
readFileSync(join(process.cwd(), 'tests/longevity/riferimento/riferimento.json'), 'utf8')
) as { casi: { fn: string; args: unknown[]; atteso: number }[] };
/** Ogni funzione dell'oracolo con la sua gemella in TypeScript. */
const COPPIE: [string, (...a: never[]) => number][] = [
['score_fat_percent', F.scoreGrassoPercento as never],
['score_muscle_percent', F.scoreMuscoloPercento as never],
['score_whr', F.scoreWhr as never],
['score_vo2max', F.scoreVo2max as never],
['score_spo2', F.scoreSpo2 as never],
['score_blood_pressure', F.scorePressione as never],
['score_hrr', F.scoreRecuperoCardiaco as never],
['score_handgrip', F.scoreHandgrip as never],
['score_pushup', F.scorePushup as never],
['score_flexed_arm_hang', F.scoreTrazioneIsometrica as never],
['score_sit_to_stand_1min', F.scoreSitToStand as never],
['score_plank', F.scorePlank as never],
['score_back_scratch', F.scoreBackScratch as never],
['score_shoulder_mobility_wt', F.scoreMobilitaSpalla as never],
['score_flamingo', F.scoreFlamingo as never],
['score_sit_and_reach', F.scoreSitAndReach as never],
['vo2max_from_step_test', F.vo2maxDaStepTest as never],
['vo2max_from_2km_walk', F.vo2maxDa2kmWalk as never],
['vo2max_from_mutt', F.vo2maxDaMutt as never],
['vo2max_from_milfit', F.vo2maxDaMilfit as never],
];
describe('curve dei test fisici, confrontate con l oracolo caso per caso', () => {
for (const [nomePython, fnTs] of COPPIE) {
it(`${nomePython} combacia su tutti i casi del riferimento`, () => {
const casi = RIF.casi.filter((c) => c.fn === nomePython);
expect(casi.length, `nessun caso per ${nomePython}: la griglia non lo copre`).toBeGreaterThan(0);
const divergenti: string[] = [];
for (const c of casi) {
const ottenuto = (fnTs as (...a: unknown[]) => number)(...c.args);
if (Math.abs(ottenuto - c.atteso) > 0.05) {
divergenti.push(`${nomePython}(${c.args.join(', ')}): atteso ${c.atteso}, ottenuto ${ottenuto}`);
}
}
expect(divergenti.slice(0, 5).join('\n')).toBe('');
});
}
it('i sollevamenti sul peso corporeo combaciano su tutte e tre le tabelle', () => {
const casi = RIF.casi.filter((c) => c.fn === 'score_bw_ratio_lift');
expect(casi.length).toBeGreaterThan(50);
const perTabella = (nome: string) =>
({ bench: [F.TIERS_BENCH_M, F.TIERS_BENCH_F], squat: [F.TIERS_SQUAT_M, F.TIERS_SQUAT_F],
row: [F.TIERS_ROW_M, F.TIERS_ROW_F] } as Record<string, unknown[]>)[nome];
for (const c of casi) {
const [carico, peso, rip, tiersM, tiersF, sesso] = c.args as [number, number, number, unknown, unknown, 'M' | 'F'];
const ottenuto = F.scoreSollevamentoSuPeso(
carico, peso, rip,
tiersM as [number, number][], tiersF as [number, number][], sesso
);
expect(ottenuto, `carico ${carico} ${sesso}`).toBeCloseTo(c.atteso, 1);
}
expect(perTabella('bench')).toBeTruthy();
});
it('l agilita NON e stata portata: il cliente l ha esclusa dallo score', () => {
expect((F as Record<string, unknown>).scoreAgilita).toBeUndefined();
expect((F as Record<string, unknown>).scoreAgilityMs).toBeUndefined();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-fisici.test.ts`
Expected: FAIL — modulo `motore/test-fisici` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta le funzioni dall'oracolo, una per una, mantenendo **identici** i coefficienti, le tabelle di ancoraggio e l'ordine dei confronti. Le trovi nelle sezioni «Composizione Corporea», «Cardio-Respiratorio», «Recupero & Sistema Nervoso», «Forza & Struttura» e «Stabilità & Mobilità».
⚠️ **Non "sistemare" nulla mentre porti**, nemmeno ciò che sembra un difetto: se una funzione ha un ramo irraggiungibile o un valore di ripiego che pare arbitrario, va riprodotto tale e quale. L'oracolo è la definizione, non una proposta — e il confronto numerico ti dirà subito se hai cambiato qualcosa.
⚠️ **Attenzione a `score_handgrip`**: usa un ciclo con `break` e un ramo `else` del `for` (costrutto che in TypeScript non esiste). Riproduci il comportamento: se l'età non cade in nessun intervallo fra le ancore, vale il valore dell'ultima.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-fisici.test.ts`
Expected: PASS, 22 test
Se un test elenca divergenze, il messaggio ti dà ingressi, atteso e ottenuto: **correggi la tua funzione**, non il riferimento.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/test-fisici.ts tests/longevity/motore-fisici.test.ts
git commit -m "longevity: le curve dei test fisici, verificate contro l oracolo"
```
---
### Task 4: La cascata, e il tipo che impedisce di mostrare un numero che non c'è
**Files:**
- Create: `src/lib/longevity/motore/cascata.ts`
- Test: `tests/longevity/motore-cascata.test.ts`
**Interfaces:**
- Consumes: `COPERTURA_MINIMA` da `src/lib/longevity/db.ts`
- Produces:
- `type Punteggio = { stato: 'ok'; valore: number; copertura: number } | { stato: 'insufficiente'; copertura: number }`
- `type VocePesata = { punteggio: number | null; peso: number }`
- `aggrega(voci: VocePesata[]): Punteggio`
- `calcolaAsse(pesi: Record<string, number>, punteggi: Record<string, number | null>): Punteggio`
- `calcolaMacro(pesiMacro: Record<string, number>, assi: Record<string, Punteggio>): Punteggio`
- `calcolaFitnessAge(etaAnagrafica: number, pesi: Record<string, number>, voci: Record<string, number | null>): { fitnessAge: number | null; composito: Punteggio }`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-cascata.test.ts
import { describe, it, expect } from 'vitest';
import { COPERTURA_MINIMA } from '../../src/lib/longevity/db';
import { aggrega, calcolaAsse, calcolaMacro, calcolaFitnessAge } from '../../src/lib/longevity/motore/cascata';
describe('aggregazione e rinormalizzazione', () => {
it('con tutti i dati fa la media pesata', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: 60, peso: 0.5 }]);
expect(r.stato).toBe('ok');
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(70, 1); expect(r.copertura).toBe(1); }
});
it('un dato mancante ridistribuisce il suo peso, non vale zero', () => {
const r = aggrega([{ punteggio: 80, peso: 0.5 }, { punteggio: null, peso: 0.25 }, { punteggio: 60, peso: 0.25 }]);
expect(r.stato).toBe('ok');
// 80*0.5 + 60*0.25 = 55, su peso disponibile 0.75 -> 73.3, non 55
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(73.3, 1); expect(r.copertura).toBeCloseTo(0.75, 2); }
});
it('sotto la soglia di copertura NON esiste un valore da leggere', () => {
const r = aggrega([{ punteggio: 90, peso: 0.2 }, { punteggio: null, peso: 0.8 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBeCloseTo(0.2, 2);
// il punto dell'intero tipo: chi consuma non ha il campo da cui prendere il numero
expect((r as { valore?: number }).valore).toBeUndefined();
});
it('la soglia e quella dichiarata una volta sola, non un numero sparso', () => {
const pocoSotto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA - 0.01 }, { punteggio: null, peso: 1 - COPERTURA_MINIMA + 0.01 }]);
const esatto = aggrega([{ punteggio: 90, peso: COPERTURA_MINIMA }, { punteggio: null, peso: 1 - COPERTURA_MINIMA }]);
expect(pocoSotto.stato).toBe('insufficiente');
expect(esatto.stato).toBe('ok'); // la soglia e inclusiva, come nell'oracolo
});
it('senza nessun dato e insufficiente con copertura zero', () => {
const r = aggrega([{ punteggio: null, peso: 1 }]);
expect(r.stato).toBe('insufficiente');
expect(r.copertura).toBe(0);
});
});
describe('assi, macro e Fitness Age', () => {
const PESI_FORZA = { handgrip: 0.25, spinta: 0.2, trazione: 0.2, arti_inferiori: 0.2, core: 0.15 };
it('un asse si calcola sui suoi sotto-domini', () => {
const r = calcolaAsse(PESI_FORZA, { handgrip: 70, spinta: 60, trazione: 65, arti_inferiori: 80, core: 50 });
expect(r.stato).toBe('ok');
if (r.stato === 'ok') expect(r.valore).toBeCloseTo(66.25, 1);
});
it('un asse insufficiente NON entra nel macro-score, invece di entrarci come zero', () => {
const assi = {
A: { stato: 'ok', valore: 80, copertura: 1 } as const,
B: { stato: 'insufficiente', copertura: 0.1 } as const,
};
const r = calcolaMacro({ A: 0.5, B: 0.5 }, assi);
expect(r.stato).toBe('ok');
// se B entrasse come zero il risultato sarebbe 40: la rinormalizzazione lo esclude
if (r.stato === 'ok') { expect(r.valore).toBeCloseTo(80, 1); expect(r.copertura).toBeCloseTo(0.5, 2); }
});
it('la Fitness Age scende sotto l eta quando il composito supera 50', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: 75, hrv_isolato: 75, forza_resto: 75, composizione: 75, stabilita: 75 });
// 40 - (75 - 50) * 0.4 = 30
expect(r.fitnessAge).toBeCloseTo(30, 1);
});
it('senza dati sufficienti la Fitness Age non esiste', () => {
const pesi = { cardio: 0.3, handgrip_isolato: 0.2, hrv_isolato: 0.2, forza_resto: 0.15, composizione: 0.1, stabilita: 0.05 };
const r = calcolaFitnessAge(40, pesi, { cardio: 75, handgrip_isolato: null, hrv_isolato: null, forza_resto: null, composizione: null, stabilita: null });
expect(r.composito.stato).toBe('insufficiente');
expect(r.fitnessAge).toBeNull();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-cascata.test.ts`
Expected: FAIL — modulo `motore/cascata` non trovato
- [ ] **Step 3: Write minimal implementation**
Porta `aggregate`, `compute_axis`, `compute_macro_scores` e `compute_fitness_age` dall'oracolo, **con una differenza deliberata**:
⚠️ Nell'oracolo `aggregate()` restituisce il punteggio pieno **anche quando lo dichiara insufficiente** — la docstring dice il contrario di ciò che il codice fa. È una trappola: chi consuma deve ricordarsi di guardare lo stato, e prima o poi qualcuno non lo fa. Qui il tipo `Punteggio` la chiude: nel ramo `insufficiente` **il campo `valore` non esiste**, quindi la regola di prodotto — *«tratteggiato, mai un numero pieno fasullo»* — diventa impossibile da violare per distrazione.
Il resto va riprodotto fedelmente: la soglia è **inclusiva** (`copertura < MINIMA` è insufficiente, quindi esattamente 0.40 è ok), i pesi si rinormalizzano su quelli disponibili, la Fitness Age è `eta - (composito - 50) * 0.4`.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/longevity/motore-cascata.test.ts`
Expected: PASS, 10 test
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/cascata.ts tests/longevity/motore-cascata.test.ts
git commit -m "longevity: la cascata a quattro livelli, con il tipo che chiude la trappola dell insufficiente"
```
---
### Task 5: Il motore legge dal registro, non da costanti
È il task che collega il motore allo strato dati: curve, parametri e pesi vengono dal database, così aggiungere o togliere un test resta una modifica ai dati.
**Files:**
- Create: `src/lib/longevity/motore/index.ts`
- Modify: `src/lib/longevity/registro.ts` — aggiungere `MODEL_VERSION` e `pesiDi`
- Test: `tests/longevity/motore.test.ts`
**Interfaces:**
- Consumes: tutto quanto sopra, più `createLongevityDb`, `seedRegistro`, `seedPesi`, `apriSessione`, `registraMisure`
- Produces:
- `MODEL_VERSION: string` (`'v1.0'`, esportata da `registro.ts`)
- `pesiDi(db, modelVersion, livello, contenitore): Record<string, number>` in `registro.ts`
- `applicaCurva(voce: VoceRegistro, valore: number): number | null` — normalizza un valore grezzo secondo la curva dichiarata nel registro
- `calcolaSessione(db, sessioneId): { assi, macro, fitnessAge, modelVersion, questVersion }`
- `salvaScore(db, sessioneId, risultato): void` — congela i punteggi nella tabella `score`
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION, pesiDi } from '../../src/lib/longevity/registro';
import { salvaCompilazione } from '../../src/lib/longevity/questionario';
import { applicaCurva, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore';
function dbPronto() {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
return db;
}
const voce = (db: ReturnType<typeof dbPronto>, id: string) =>
db.prepare(`SELECT * FROM registro_test WHERE test_id = ?`).get(id) as Record<string, unknown>;
describe('il motore legge le curve dal registro', () => {
it('applica la curva dichiarata per il test, non una scritta nel codice', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_ore_sonno'), params: JSON.parse(voce(db, 'q_ore_sonno').params as string) };
expect(applicaCurva(v as never, 8)).toBe(100); // dentro il picco 7-9
expect(applicaCurva(v as never, 4)).toBeLessThan(20);
});
it('rispetta la soglia dell alcol come e scritta nel registro', () => {
const db = dbPronto();
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 0)).toBe(100);
expect(applicaCurva(v as never, 7)).toBe(100);
expect(applicaCurva(v as never, 14)).toBe(0);
});
it('cambiare i parametri nel registro cambia il punteggio, senza toccare il codice', () => {
const db = dbPronto();
db.prepare(`UPDATE registro_test SET params = ? WHERE test_id = 'q_alcol_life'`)
.run(JSON.stringify({ best: 0, worst: 7 }));
const v = { ...voce(db, 'q_alcol_life'), params: JSON.parse(voce(db, 'q_alcol_life').params as string) };
expect(applicaCurva(v as never, 7)).toBe(0); // con i parametri nuovi, 7 non vale piu 100
});
it('un test disattivato non entra nel calcolo', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 0 },
});
const prima = calcolaSessione(db, 1);
db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_ore_sonno'`).run();
const dopo = calcolaSessione(db, 1);
expect(JSON.stringify(prima)).not.toBe(JSON.stringify(dopo));
});
});
describe('calcolo e congelamento di una sessione', () => {
it('produce i sette assi, e quelli senza dati sono insufficienti', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_ore_sonno: 8, q_riposato: 8, q_min_addorm: 10, q_risvegli: 0, q_caffeina: 0, q_sonnolenza_diurna: 1 },
});
const r = calcolaSessione(db, 1);
expect(Object.keys(r.assi).length).toBe(7);
// il questionario copre il sonno, che pesa 0.20 dentro Recupero: sotto il 40%
expect(r.assi['Recupero & Sistema Nervoso'].stato).toBe('insufficiente');
// Stile di Vita e coperto al 100% dal solo questionario, ma qui non abbiamo risposto
expect(r.assi['Forza & Struttura'].stato).toBe('insufficiente');
});
it('congela i punteggi con le DUE versioni', () => {
const db = dbPronto();
salvaCompilazione(db, {
client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
risposte: { q_riposato: 7 },
});
salvaScore(db, 1, calcolaSessione(db, 1));
const righe = db.prepare(`SELECT tipo, elemento, valore, stato, quest_version, model_version FROM score`).all() as Record<string, unknown>[];
expect(righe.length).toBeGreaterThan(0);
for (const r of righe) {
expect(r.model_version).toBe(MODEL_VERSION);
expect(r.quest_version).toBe('v1.0');
if (r.stato === 'insufficiente') expect(r.valore).toBeNull();
}
});
it('i pesi arrivano dal registro e sono quelli della versione richiesta', () => {
const db = dbPronto();
const pesi = pesiDi(db, MODEL_VERSION, 'asse', 'Forza & Struttura');
expect(pesi.handgrip).toBe(0.25);
expect(Object.values(pesi).reduce((a, b) => a + b, 0)).toBeCloseTo(1, 6);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore.test.ts`
Expected: FAIL — modulo `motore` non trovato
- [ ] **Step 3: Write minimal implementation**
In `registro.ts` aggiungi:
```ts
/** Versione del modello di calcolo: curve e pesi. Distinta da QUEST_VERSION, che versiona le domande. */
export const MODEL_VERSION = 'v1.0';
export function pesiDi(
db: Database.Database, modelVersion: string,
livello: 'asse' | 'macro' | 'fitness_age', contenitore: string
): Record<string, number> {
const righe = db.prepare(
`SELECT elemento, peso FROM pesi WHERE model_version = ? AND livello = ? AND contenitore = ?`
).all(modelVersion, livello, contenitore) as { elemento: string; peso: number }[];
return Object.fromEntries(righe.map((r) => [r.elemento, r.peso]));
}
```
In `motore/index.ts`: `applicaCurva` smista sulla curva dichiarata nel registro e passa i parametri letti da lì; `calcolaSessione` legge le misure attive della sessione, le normalizza, raggruppa per sotto-dominio (facendo la **media** dei test dello stesso sotto-dominio), calcola i sette assi, i tre macro e la Fitness Age; `salvaScore` scrive in `score` una riga per asse, macro e Fitness Age, con `valore` a `null` quando lo stato è insufficiente.
⚠️ **La copertura si calcola sui pesi, non sul numero di test.** Un sotto-dominio senza nessuna misura è `null` e il suo peso si ridistribuisce; è la regola dell'oracolo e vale a ogni livello.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS su tutta la suite nuova; atteso il solo rosso pre-esistente.
- [ ] **Step 5: Commit**
```bash
git add src/lib/longevity/motore/index.ts src/lib/longevity/registro.ts tests/longevity/motore.test.ts
git commit -m "longevity: il motore legge curve e pesi dal registro, e congela i punteggi"
```
---
### Task 6: La prova sul caso reale documentato
L'ultimo controllo non è sulle funzioni: è sul fatto che l'insieme produca i numeri che il cliente ha già visto.
**Files:**
- Test: `tests/longevity/motore-caso-reale.test.ts`
**Interfaces:**
- Consumes: tutto il motore
⚠️ **I dati di Donata e Nicola non entrano nelle fixture** — sono dati sanitari di due persone identificabili, e le fixture stanno in git. Il caso qui sotto è **sintetico**, costruito per esercitare le stesse condizioni descritte nella documentazione: un profilo con composizione corporea completa e tutto il resto mancante.
- [ ] **Step 1: Write the failing test**
```ts
// tests/longevity/motore-caso-reale.test.ts
import { describe, it, expect } from 'vitest';
import { createLongevityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { apriSessione, registraMisure } from '../../src/lib/longevity/misure';
import { calcolaSessione } from '../../src/lib/longevity/motore';
/**
* Riproduce la situazione descritta nella documentazione del cliente: una persona di cui
* si conosce solo la composizione corporea, misurata dalla Wellness Tower. Un asse pieno,
* tutti gli altri scoperti. È il caso in cui un motore ingenuo mostrerebbe sei zeri.
* Dati sintetici: nessuna persona reale.
*/
describe('un profilo con una sola area misurata', () => {
it('mostra l asse coperto e dichiara insufficienti gli altri sei', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','M')`).run();
// i tre test di composizione esistono nel registro solo dopo il piano degli import:
// finche non ci sono, questo test dimostra il comportamento con zero misure fisiche
const s = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 38 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
const insufficienti = Object.values(r.assi).filter((a) => a.stato === 'insufficiente').length;
expect(insufficienti).toBe(7);
expect(r.fitnessAge).toBeNull();
});
it('nessun asse insufficiente porta con se un valore da mostrare per sbaglio', () => {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0002','F')`).run();
const s = apriSessione(db, { client_code: 'ISL-0002', data: '2026-08-22', tipo: 'checkup', eta_alla_data: 35 });
registraMisure(db, s, 'questionario', []);
const r = calcolaSessione(db, s);
for (const [nome, asse] of Object.entries(r.assi)) {
if (asse.stato === 'insufficiente') {
expect((asse as { valore?: number }).valore, `${nome} espone un valore`).toBeUndefined();
}
}
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/longevity/motore-caso-reale.test.ts`
Expected: FAIL se qualcosa nella catena non regge il caso senza misure.
- [ ] **Step 3: Correggi ciò che il caso reale smonta**
Non c'è codice nuovo da scrivere: se questo test fallisce, il difetto è in un task precedente. Correggilo lì e **annota nel rapporto quale task ha dovuto essere corretto** — è l'informazione che dice se il porting regge davvero o solo sui casi comodi.
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS, con il solo rosso pre-esistente.
- [ ] **Step 5: Commit**
```bash
git add tests/longevity/motore-caso-reale.test.ts
git commit -m "longevity: la prova sul profilo con una sola area misurata"
```
---
## Cosa esiste alla fine di questo piano
Il motore completo: le curve verificate una per una contro l'oracolo del cliente su migliaia di casi, la cascata a quattro livelli, la lettura di curve e pesi dal registro, il congelamento dei punteggi con le due versioni distinte.
**Non esiste ancora nessuna pagina.** È il piano successivo, e da lì in poi il radar ha numeri veri da mostrare.
## Nota per il piano dell'interfaccia
I sette assi che il motore restituisce sono `Punteggio`, cioè o `{stato:'ok', valore}` o `{stato:'insufficiente'}` senza valore. La dashboard **non può** stampare un numero dove non c'è: è il tipo a impedirlo, ed è il motivo per cui il tratteggio del radar non dipende dalla disciplina di chi scrive la vista.
⚠️ Con i dati di oggi — solo questionario, nessun test fisico — **sei assi su sette risultano insufficienti**. È corretto e atteso: i test fisici entrano col piano degli import. Chi guarderà la prima dashboard non deve scambiarlo per un difetto.