Resolving derived-block drift: two decisions required before regenerating
Resolving derived-block drift: two decisions required before regenerating
tools/check_derived_drift.py shows that 27 of 37 committed pk.yml files have a
derived block that does not match tools/template_gen.derive_quantities. A blind
bulk regeneration is unsafe because the divergence is about conventions, not just
stale values, and one of those conventions is actively enforced by
tools/validate_library.py. Two maintainer decisions are needed first.
What the library currently looks like
The 37 drugs fall into three groups:
| Group | Count | ke convention | basis persisted | Regenerate as-is? |
|---|---|---|---|---|
| Consistent | 10 | CL/V == ln2/t_half (coincide) | yes | safe (no change) |
| ke-conflict | 19 | ke = ln2/t_half only | no | breaks validate_library ke check |
| systemic-loss | 8 | coincide | no | ke ok, but loses CL_systemic/V_systemic |
The “consistent” group (albuterol, apixaban, buprenorphine, carbamazepine, dapagliflozin, erythromycin, fluconazole, motavizumab_yte, raltegravir, sufentanil) was produced by the current pipeline and already round-trips.
Decision 1 — canonical ke convention
Three places disagree:
- committed pk.yml (19 drugs):
ke = ln2 / t_half derive_quantities:ke = CL_abs / V_absvalidate_library: enforceske == ln2 / t_half(hard FAIL otherwise)
These coincide only when t_half == ln2 * V / CL, which is exactly the case the
1-compartment attainability warning says is frequently false (e.g. aciclovir:
ln2/t_half = 0.277 vs CL/V = 0.467 per hour).
Recommendation: make ke = CL/V canonical. The spec/simulator uses CL and V as
the independent parameters (stated in every targets.yml: “CL and V are the
independent simulation parameters”), so the ke that actually governs the simulated
profile is CL/V. The literature t_half is a check target, not the ke source.
Concretely:
- change
validate_libraryto comparederived.ke_1_per_hagainstCL_abs/V_abs(notln2/t_half); - keep the existing
t_halfvs CL/V attainability WARN — it already captures the discrepancy and is the right place for it; - then
kefromderive_quantitiesis correct and the 19 drugs can be regenerated.
If instead t_half is meant to be the independent truth, the fix is the reverse
(change derive_quantities to set ke = ln2/t_half), but that contradicts how the
simulator consumes CL/V and would make derived.ke inconsistent with the generated
concentrations. That is why this is a decision, not a mechanical fix.
Decision 2 — basis persistence
Only 10/37 persist pk_parsed.clearance_basis. Where it is absent,
derive_quantities resolves basis to “unknown” and leaves CL_systemic/V_systemic as
None, so the route-auto apparent assumption baked into the committed values cannot
be reproduced from pk.yml alone.
Recommendation: persist clearance_basis / volume_basis into pk_parsed for
all drugs. Backfill the 27 unpersisted ones with the documented route-auto default
(oral -> apparent, iv -> systemic), then let the basis warning added in
validate_library (systemic-style source unit on an oral drug) flag the cases that
need manual review (aciclovir, alprazolam, cimetidine, felodipine, itraconazole,
montelukast, omeprazole, triazolam, verapamil). This makes the basis explicit and
auditable, kills the silent default, and makes regeneration deterministic.
After both decisions: safe regeneration path
- Implement Decision 1 in
validate_library(ke vs CL/V) and Decision 2 (persist basis) in the harvest/template path. - Add a
--writemode to a regen tool that recomputesderivedfrompk_parsed(now including basis) viaderive_quantities, in place. - Run
python tools/check_derived_drift.py . --strict— it should report zero drift, and wire it intomake harness-check/ CI so drift cannot reappear. - Re-run
tools/validate_library.pyand the full test suite; the basis warnings should remain (they are warnings, not failures), and the ground-truth NCA test is unaffected (it reads spec theta, not the derived block).
check_derived_drift.py --strict is the lock; until Decisions 1 and 2 are made it
stays informational (exit 0) so it does not break CI.