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.
260 lines
13 KiB
JavaScript
260 lines
13 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|
||
});
|