Files
InsanityLab-web/apps/sito/docs/plans/2026-08-22-longevity-motore.md
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

37 KiB

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:
{
  "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
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

// 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
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
// 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
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

// 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
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

// 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:

/** 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
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
// 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
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.