HormoneTrack-web/tests/lab-trajectory-model.test.js
Siphonight bd338decbf v1.6.0 (web, moteur) : prolongation du tracé labs au-delà du dernier lab
Miroir fidèle du Kotlin (doc Android §7.10.bis) :
- extendBeyondLastLab (ρ_last constant au-delà du dernier ancre),
  lastAnchorMs, extensionHorizonEndMs (dernière dose E2 + cutoff du
  moteur — même sémantique d'extinction, une seule source de vérité).
- 8 nouveaux tests unitaires miroirs (140 verts au total) : identité
  M×ρ_last, suture exacte, horizon cutoff, dose EV après le dernier lab,
  garde #61, flag off = v1.5.0 bit-compatible, gardes du helper,
  lastAnchorMs.
2026-09-12 23:51:20 +02:00

260 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
* fenêtre bornée aux labs, indépendance du scaleFactor. v1.6.0 :
* PROLONGATION au-delà du dernier lab (ρ constant, horizon = cutoff du
* traitement, doses après le dernier lab, garde #61, flag off = v1.5.0).
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import {
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
} from './helpers.js';
import { e2At, cutoffHours } from '../js/pk/pk-engine.js';
import {
computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs,
} from '../js/pk/lab-trajectory-model.js';
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
function treatment(scaleFactor = 1.0) {
return makeTreatment({ scaleFactor });
}
function doses() {
return [makeDose(0, 5)];
}
const lab = (value, ts) => makeLab('E2', value, 'pg/mL', ts);
const tLab = (ts) => makeLab('T', 0.35, 'ng/mL', ts);
describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => {
before(() => initProfiles());
test('passe exactement sur chaque lab', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const labs = [lab(300, t2), lab(260, t7)];
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2);
assertClose(curve.points.find((p) => p.timestamp === t2).e2, 300, 1e-6, 'ancrage lab 1');
assertClose(curve.points.find((p) => p.timestamp === t7).e2, 260, 1e-6, 'ancrage lab 2');
});
test('rho interpole en log entre deux labs (math exacte)', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const tr = treatment();
// Labs DATA-DRIVEN (miroir du fix du test Kotlin : M(2 d) ≠ le pic de référence)
const m2 = e2At([tr], doses(), t2, null, null);
const m7 = e2At([tr], doses(), t7, null, null);
const labs = [lab(0.9 * m2, t2), lab(1.2 * m7, t7)];
// 1) La fonction PURE ratioAt interpole exactement
const pure = [[t2, 0.9], [t7, 1.2]];
const expectedMid = Math.exp((Math.log(0.9) + Math.log(1.2)) / 2.0);
assertClose(ratioAt(pure, t2), 0.9, 1e-12);
assertClose(ratioAt(pure, t7), 1.2, 1e-12);
assertClose(ratioAt(pure, (t2 + t7) / 2), expectedMid, 1e-9);
// 2) La courbe restitue le même ρ
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
const mid = curve.points[Math.trunc(curve.points.length / 2)];
const m = e2At([{ ...tr, scaleFactor: 1.0 }], doses(), mid.timestamp, null, null);
assertClose(mid.e2 / m, expectedMid, 1e-9);
});
test('un seul lab significatif donne une courbe vide', () => {
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.points.length, 0);
assert.equal(curve.anchoredLabs, 1, 'le lab compte mais ne suffit pas');
});
test('garde 61 - un lab hors fenêtre d action n est pas un ancrage', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(300, t2), lab(260, t7), lab(3000, 60 * DAY_MS)], 0, 90 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2, 'le lab tardif est rejeté');
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde');
assert.ok(curve.points.every((p) => p.timestamp <= t7), 'fenêtre bornée au dernier lab significatif');
});
test('labs avant la première dose ne produisent aucun ancrage', () => {
const curve = computeLabAnchoredCurve([treatment()], doses(),
[lab(200, -10 * DAY_MS), lab(210, -5 * DAY_MS)], -15 * DAY_MS, 0, HOUR_MS);
assert.equal(curve.points.length, 0);
assert.equal(curve.anchoredLabs, 0);
});
test('la fenêtre du tracé est bornée par les labs et par la demande', () => {
const t2 = 2 * DAY_MS;
const t7 = 7 * DAY_MS;
const labs = [lab(160, t2), lab(140, t7)];
const wide = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
assert.ok(wide.points.length > 0);
assert.ok(wide.points.every((p) => p.timestamp >= t2 && p.timestamp <= t7));
const narrow = computeLabAnchoredCurve([treatment()], doses(), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS);
assert.ok(narrow.points.length > 0);
assert.ok(narrow.points.every((p) => p.timestamp >= 3 * DAY_MS && p.timestamp <= 5 * DAY_MS));
});
test('indépendant du scaleFactor stocké (sinon double correction)', () => {
const labs = [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)];
const a = computeLabAnchoredCurve([treatment(1.0)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
const b = computeLabAnchoredCurve([treatment(0.55)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.deepEqual(a.points, b.points, 'M diffère mais ρ compense exactement');
});
test('modelOverride - chaque modèle ancre avec sa prédiction (sémantique 60)', () => {
// ESE vs WHS (ESE≈TFS quasi identiques par construction — seuil doc §8)
const trEv = { ...treatment(), esterType: 'EV', route: 'INJECTION_IM' };
const labs = [lab(320, 2 * DAY_MS), lab(240, 7 * DAY_MS)];
const ese = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'ESE');
const whs = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'WHS');
assertClose(ese.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
assertClose(whs.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
const midEse = ese.points[Math.trunc(ese.points.length / 2)].e2;
const midWhs = whs.points[Math.trunc(whs.points.length / 2)].e2;
const relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs));
assert.ok(relDiff > 0.02, `empreinte intermédiaire ESE=${midEse} vs WHS=${midWhs}`);
});
test('les labs T sont ignorés (E2 uniquement)', () => {
const labs = [lab(300, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240, 7 * DAY_MS)];
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
assert.equal(curve.anchoredLabs, 2);
});
test('doublons de timestamp - le deuxième ratio gagne', () => {
const t = 5 * DAY_MS;
const anchors = [[t, 1.0], [t, 2.0], [10 * DAY_MS, 3.0]];
assertClose(ratioAt(anchors, 10 * DAY_MS), 3.0, 1e-12);
const expected = Math.exp(Math.log(2) + (7 - 5) / 5 * (Math.log(3) - Math.log(2)));
assertClose(ratioAt(anchors, 7 * DAY_MS), expected, 1e-9);
});
test('ratioAt - gardes defensives aux bornes', () => {
const anchors = [[0, 1.0], [10, 2.0]];
assertClose(ratioAt(anchors, -5), 1.0, 1e-12);
assertClose(ratioAt(anchors, 100), 2.0, 1e-12);
});
});
// ── Prolongation au-delà du dernier lab (v1.6.0 — miroir Android) ───────────
// Garanties (cf doc web §7.10.bis / KDoc du module) :
// - au-delà du dernier lab : courbe(t) = M(t) × ρ_last (ρ CONSTANT) ;
// - continuité au point de suture (le dernier lab reste exact) ;
// - horizon = dernière dose E2 + cutoff de son traitement (JAMAIS au-delà) ;
// - une dose loguée après le dernier lab (autre ester compris) refait
// monter la courbe via M(t) — « dosage + ester + évolution classique » ;
// - dernier lab non significatif : ni ancre ni départ de prolongation (#61) ;
// - flag OFF (défaut) = comportement v1.5.0 bit-compatible.
describe('LabTrajectoryModel prolongation (miroir Android v1.6.0)', () => {
before(() => initProfiles());
/** Labs DATA-DRIVEN ancrés à 2 j (ρ=0,9) et 7 j (ρ=1,2) sur la fixture EEn/TFS. */
function anchoredLabs(tr, ds) {
const m2 = e2At([tr], ds, 2 * DAY_MS, null, null);
const m7 = e2At([tr], ds, 7 * DAY_MS, null, null);
return [lab(0.9 * m2, 2 * DAY_MS), lab(1.2 * m7, 7 * DAY_MS)];
}
const RHO_LAST = 1.2;
test('extension - la courbe continue après le dernier lab avec le rho du dernier lab', () => {
const tr = treatment();
const ds = doses();
const curve = computeLabAnchoredCurve([tr], ds, anchoredLabs(tr, ds), 0, 60 * DAY_MS, HOUR_MS, null, true);
assert.ok(curve.points.some((p) => p.timestamp > 7 * DAY_MS), 'prolongation présente');
// Suture : le dernier lab reste EXACT (continuité par construction)
const m7 = e2At([{ ...tr, scaleFactor: 1.0 }], ds, 7 * DAY_MS, null, null);
assertClose(curve.points.find((p) => p.timestamp === 7 * DAY_MS).e2, 1.2 * m7, 1e-6,
'le point au dernier lab vaut toujours le lab (1,2 × M)');
// Identité de la zone extrapolée : courbe(t) = M(t) × ρ_last, exact
for (const day of [10, 20, 30]) {
const t = day * DAY_MS;
const p = curve.points.find((q) => q.timestamp === t);
const m = e2At([{ ...tr, scaleFactor: 1.0 }], ds, t, null, null);
assertClose(p.e2, m * RHO_LAST, 1e-9, `courbe(${day} j) == M × ρ_last`);
}
});
test('extension - horizon borné par le cutoff de la dernière dose', () => {
const tr = treatment();
const curve = computeLabAnchoredCurve([tr], doses(), anchoredLabs(tr, doses()), 0, 90 * DAY_MS, HOUR_MS, null, true);
const horizonEnd = extensionHorizonEndMs([tr], doses(), 7 * DAY_MS);
assert.ok(horizonEnd > 7 * DAY_MS, 'l\'horizon EEn/TFS dépasse le dernier lab (sinon le test ne prouve rien)');
assert.ok(curve.points.every((p) => p.timestamp <= horizonEnd), 'aucun point au-delà de l\'extinction du modèle');
const last = curve.points[curve.points.length - 1].timestamp;
assert.ok(last <= horizonEnd && last + HOUR_MS > horizonEnd, 'le dernier point s\'arrête AU cutoff (pas au-delà d\'un pas)');
});
test('extension - une dose après le dernier lab refait monter la courbe (dose et ester)', () => {
const tr = treatment();
const ds = doses().concat([makeDose(10 * DAY_MS, 4, { esterType: 'EV' })]);
const curve = computeLabAnchoredCurve([tr], ds, anchoredLabs(tr, doses()), 0, 20 * DAY_MS, HOUR_MS, null, true);
const extension = curve.points.filter((p) => p.timestamp > 7 * DAY_MS);
assert.ok(extension.length > 0);
// L'identité vaut AUSSI avec la dose EV : M(t) inclut les deux doses
const t12 = 12 * DAY_MS;
const m12 = e2At([{ ...tr, scaleFactor: 1.0 }], ds, t12, null, null);
assertClose(extension.find((p) => p.timestamp === t12).e2, m12 * RHO_LAST, 1e-9);
// La FORME suit l'injection EV : montée nette après le 10 j (Tmax ~45 h)
const at8 = extension.find((p) => p.timestamp === 8 * DAY_MS).e2;
const peak = Math.max(...extension
.filter((p) => p.timestamp >= 10 * DAY_MS && p.timestamp <= 14 * DAY_MS)
.map((p) => p.e2));
assert.ok(peak > at8 * 1.3, `le pic EV (${peak}) doit dépasser le niveau pré-injection (${at8})`);
});
test('extension désactivée - rien après le dernier lab (comportement initial)', () => {
const tr = treatment();
const labs = anchoredLabs(tr, doses());
const byDefault = computeLabAnchoredCurve([tr], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
const explicitOff = computeLabAnchoredCurve([tr], doses(), labs, 0, 60 * DAY_MS, HOUR_MS, null, false);
assert.deepEqual(byDefault.points, explicitOff.points);
assert.ok(byDefault.points.every((p) => p.timestamp <= 7 * DAY_MS));
assert.equal(byDefault.lastAnchorMs, 7 * DAY_MS, 'lastAnchorMs positionné même sans prolongation');
});
test('extension - un lab non significatif ne prolonge pas (garde 61)', () => {
const tr = treatment();
const labs = anchoredLabs(tr, doses()).concat([lab(3000, 60 * DAY_MS)]);
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 90 * DAY_MS, HOUR_MS, null, true);
assert.equal(curve.anchoredLabs, 2, 'le lab tardif reste rejeté');
assert.equal(curve.lastAnchorMs, 7 * DAY_MS, 'la prolongation part du dernier lab SIGNIFICATIF');
assert.ok(curve.points.some((p) => p.timestamp > 7 * DAY_MS), 'des points prolongés existent après le dernier ancre');
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde du lab rejeté');
});
test('extension - demande finissant avant le dernier lab ne prolonge rien', () => {
const tr = treatment();
const curve = computeLabAnchoredCurve([tr], doses(), anchoredLabs(tr, doses()), 0, 5 * DAY_MS, HOUR_MS, null, true);
assert.ok(curve.points.every((p) => p.timestamp <= 5 * DAY_MS));
assert.ok(curve.points.every((p) => p.timestamp <= 7 * DAY_MS));
});
test('extensionHorizonEndMs - gardes (aucune dose ou modèle déjà éteint)', () => {
const tr = treatment();
const trs = [tr];
assert.equal(extensionHorizonEndMs(trs, [], 7 * DAY_MS), null, 'aucune dose E2 → rien à prolonger');
// Dose à t=0 → horizon = cutoff du traitement (EEn TFS : 10 t½ term.)
const expected = cutoffHours(tr) * HOUR_MS;
assert.equal(extensionHorizonEndMs(trs, doses(), 7 * DAY_MS), expected);
// Modèle DÉJÀ éteint au dernier lab → null (retour fenêtre initiale)
assert.equal(extensionHorizonEndMs(trs, doses(), expected + HOUR_MS), null);
});
test('lastAnchorMs - null si moins de 2 ancres, positionné sinon', () => {
const tr = treatment();
const one = computeLabAnchoredCurve([tr], doses(), [lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
assert.equal(one.points.length, 0);
assert.equal(one.lastAnchorMs, null);
const two = computeLabAnchoredCurve([tr], doses(), [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
assert.equal(two.lastAnchorMs, 7 * DAY_MS);
});
});