Skip to content

docs(plans): re-measure the ferx feature roadmap, and split it by owner - #30

Merged
TeunP merged 2 commits into
mainfrom
docs/refresh-ferx-roadmap
Aug 21, 2026
Merged

TeunP merged 2 commits into
mainfrom
docs/refresh-ferx-roadmap

Conversation

@TeunP

@TeunP TeunP commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Why

CLAUDE.md says the "ferx feature roadmap" table is the signal to the ferx-core team about what to prioritise. It had gone materially stale: seven rows asked for ferx features that ferx already has, and two of those this package already translates.

That is not cosmetic in either direction. A stale Untranslatable row asks another team to build something that exists, and it understates what the translator can do to anyone reading the plan to decide whether this tool fits their model.

Re-measured against ferx-core origin/main (ae6c208c) and ferx-r's own example models, which CLAUDE.md names as the authority on what the format accepts.

What changed

The heading conflated two things with different owners, so they are now two tables:

  • ferx cannot express it -- a genuine ferx-core ask
  • ferx supports it, we do not translate it yet -- our backlog, not theirs

Every row now carries its evidence, and a row whose evidence is older than this pass says so rather than being silently carried forward.

Rows corrected:

Row Was Measured
Multiple DVIDs / endpoints Untranslatable We already translate it. A ferx-r example carries y[CMT=2], y[CMT=3], CMT=2: DV ~ proportional(...); pkpd_cmt.mod and qss_tmdd.mod translate with zero $unsupported
PD structural models (Emax, indirect response) Untranslatable We already translate it. The same example computes an Emax readout; pkpd_ir.mod translates with zero $unsupported
S2, S3 per-compartment scaling Untranslatable Done via obs_scale / y[CMT=N] (#24, #25)
$SIGMA BLOCK correlated residuals Untranslatable ferx has block_sigma, validate_block_sigma_single_error_order(), build_residual_correlations()
R1/D1, ALAG2/ALAG3, F2/F3 Untranslatable DoseAttr::from_indexed_name accepts f/lagtime/alag/d/r with a compartment index -- these are #16's payload, not a ferx gap
THETA (FIX) Translates with loss Not lossy. ferx's theta regex takes FIX inside or outside the parens, and .emit_theta() already emits it. Measured: $THETA 1.2 FIX -> theta THETA3(1.2, -1e15, 1e15, FIX), no warning

Rows deliberately kept but flagged SUSPECT: MIXTURE and categorical/count/TTE. ferx-core#996 landed mixture mu-referencing on 2026-08-20 and there are src/categorical, src/survival and src/markov modules, so both are probably wrong -- but I did not measure them, and deleting a row on a hunch is the same error as keeping a stale one. Flagged so the next person measures instead of trusting.

Alternatives considered

Delete the rows that look obsolete. Rejected for the two I could not measure -- see above. The whole failure being fixed here is a table asserting things nobody checked.

Fold this into a code PR. Rejected: one PR per logical change, and this one is worth reviewing on its own because it changes what another team is being asked to build.

Formats affected

  • NONMEM / [ ] nlmixr2 / [ ] Monolix / [ ] mrgsolve / [ ] Shared

Documentation only. No code, no tests, no emitted output changes.

ferx format dependency

  • No new .ferx syntax generated

Breaking changes

  • None

Tests

  • Every new test verified to FAIL against the code it guards -- n/a, no tests changed
  • R CMD check --as-cran -- unaffected; plans/ is not part of the built package
  • Non-ASCII check -- 219 bytes before, 219 after; all pre-existing em-dashes elsewhere in the file, none introduced

Documentation

  • roxygen2::roxygenize() -- no R/ change
  • Design doc updated -- that is the entire diff
  • NEWS.md -- not added; this is an internal planning document with no user-visible effect

Reviewer hints

The two SUSPECT rows are the judgement call worth a second opinion: keeping a row I believe is wrong, flagged, versus deleting it unmeasured. I chose flagged.

Skimmable: the evidence column on rows marked DONE -- those are corroborated by the corpus sweep, which reports zero $unsupported across all 18 bundled models.

Open questions

One new gap found while measuring, and it is silent. $OMEGA ... FIX emits a free omega:

$OMEGA 0.09 FIX     ->     omega ETA1 ~ 0.09

No warning, no $unsupported entry. nonmem2rx does surface the flag (ui$iniDf$fix is TRUE for eta1), ferx does accept omega NAME ~ value FIX (validated clean on the pinned engine), and .emit_omega() simply has no fix handling where .emit_theta() does. So a source that pins an IIV -- to a literature value, say -- silently gets one that is estimated instead.

$SIGMA ... FIX is presumed identical and is marked as not separately measured. Recorded in the new table; not filed as an issue yet.

CLAUDE.md says this table is the signal to the ferx-core team about what to
prioritise. It had gone materially stale: seven rows asked for ferx features that
ferx already has, and two of those this package already translates. A stale
"untranslatable" row is worse than no row -- it asks another team to build
something that exists, and it understates what the translator can do.

Re-measured against ferx-core origin/main (ae6c208c) and ferx-r's own example
models. Corrections, each with its evidence recorded in the table:

- Multiple DVIDs / endpoints, and PD structural models (Emax, indirect response),
  are not merely supported by ferx -- WE ALREADY TRANSLATE THEM. A ferx-r example
  model carries `y[CMT=2]` / `y[CMT=3]` / `CMT=2: DV ~ ...` and an Emax readout,
  and pkpd_cmt.mod, pkpd_ir.mod and qss_tmdd.mod all translate with zero
  $unsupported entries.
- `$SIGMA BLOCK` correlated residuals: ferx has `block_sigma`,
  `validate_block_sigma_single_error_order()` and `build_residual_correlations()`.
- `R1`/`D1`, `ALAG2`/`ALAG3`, `F2`/`F3`: `DoseAttr::from_indexed_name` accepts
  f / lagtime / alag / d / r with a compartment index. These are #16's payload.
- `THETA (FIX)` was listed as lossy. It is not: ferx's theta regex takes FIX inside
  or outside the parens, and `.emit_theta()` already emits it. Measured.

The heading conflated two things with different owners, so they are now separate
tables: "ferx cannot express it" is a ferx-core ask; "ferx supports it, we do not
translate it yet" is our backlog.

MIXTURE and categorical/count/TTE are left in place but flagged SUSPECT rather
than deleted -- ferx-core#996 landed mixture mu-referencing yesterday and there
are src/categorical, src/survival and src/markov modules, so both rows are
probably wrong, but I did not measure them and removing a row on a hunch is the
same error as keeping a stale one.

One new gap found while measuring, and it is silent: `$OMEGA ... FIX` emits a
FREE omega. nonmem2rx surfaces the flag (`ui$iniDf$fix` is TRUE), ferx accepts
`omega NAME ~ value FIX` (validated clean), and `.emit_omega()` simply has no fix
handling -- so a pinned IIV silently becomes an estimated one, with no warning and
no $unsupported entry. `$SIGMA ... FIX` is presumed the same and is marked as not
separately measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…that does not support it

Review of my own PR. I recorded "S2, S3 (per-compartment scaling): DONE", citing
#24 and #25. Those handle a SINGLE S<n> for a single observed compartment. Nothing
in the corpus exercises more than one - measured: pkpd_cmt.mod and pkpd_ir.mod have
no $PK scaling at all, and qss_tmdd.mod has exactly one entry - so the cited
evidence could not have supported the claim.

Constructed the case the corpus lacks: two observed compartments, S1 = VC and
S2 = VPD, and an $ERROR that does not divide. Emitted `y[CMT=1] = CENT` and
`y[CMT=2] = PD`, both unscaled, with an INFO stating obs_scale was dropped. So the
row is NOT done.

Deliberately not filed as a defect and not described as one. Dropping obs_scale
when a readout exists is CORRECT where the readout already divides - that was
measured, and applying it again double-scales. The unclear case is a readout that
does not divide, and resolving it needs NONMEM semantics for A(n) versus F that
this repo has never measured. Recording the measurement and the open question is
worth more than a confident wrong answer in either direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TeunP

TeunP commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Review -- self-review, adversarial pass on e4054c9

One row was wrong and is corrected in e345ec7. It was one of my own DONE claims, and the evidence I cited could not have supported it.

Found: S2, S3 marked DONE on evidence that does not support it

I recorded "per-compartment scaling: DONE, evidence: S<n> inference (#24) and $MODEL-order numbering (#25)". Both of those handle a single S<n> for a single observed compartment. Neither says anything about more than one.

Checked what the corpus actually contains, since that was the implicit basis:

pkpd_cmt.mod     S<n> found: NONE
pkpd_ir.mod      S<n> found: NONE
qss_tmdd.mod     S<n> found: 1=VC

No bundled model has more than one S<n>. The two multi-endpoint models have none at all -- their $ERROR blocks do the scaling inline. So nothing in the corpus could have demonstrated the claim, and I asserted it from adjacency to #24/#25 rather than from a measurement. That is the failure mode this repo keeps writing rules about, committed in the very PR whose purpose was to remove stale unmeasured claims.

Built the case the corpus lacks -- two observed compartments, S1 = VC and S2 = VPD, and an $ERROR that does not divide:

$PK
  S1 = VC
  S2 = VPD
$ERROR
  IPRED = A(1)
  IF (CMT.EQ.2) IPRED = A(2)
  Y = IPRED*(1 + EPS(1))

Emitted:

[scaling]
  y[CMT=1] = CENT      <- unscaled
  y[CMT=2] = PD        <- unscaled

INFO | [scaling] obs_scale = VPD is dropped -- the $ERROR block computes the
       observation itself ...

Both scalings gone. The row is now NOT DONE.

Why this is NOT filed as a defect

Tempting, and I decided against it. Dropping obs_scale when a readout exists is correct where the readout already divides -- that was measured when the behaviour was added, and applying it again double-scales by up to 11.37 units on the reporter's model. The unclear case is a readout that does not divide.

Resolving that needs NONMEM semantics this repo has never measured and cannot measure without NONMEM: S<n> scales F, and whether a source writing Y = A(n) is scaled at all is a real question, not a detail. A confident "fold S<n> into the readout" fix could be wrong in the opposite direction. The row now records the measurement and the open question, which is worth more than a wrong answer in either direction.

Note this cuts at an existing behaviour too: we emit obs_scale = V for $ERROR Y = A(2)*(1+EPS(1)) on the single-endpoint path. If A(n) is genuinely unscaled in NONMEM, that is also wrong. I am flagging it, not asserting it.

Probed and clear

  • Did the wholesale section replacement drop a row? All 17 rows from the old tables are accounted for: 4 lossy (one, THETA (FIX), removed with an explicit explanation rather than silently), 13 untranslatable -- 5 moved to the new "we do not translate it yet" table, 3 marked DONE there, 5 kept as ferx gaps, 2 of those flagged SUSPECT. Verified by extracting the old row list from main and grepping each against the new file; the five apparent misses were my grep splitting on backticks, confirmed present by direct count.
  • The DONE claims that survive. "Multiple DVIDs / endpoints" and "PD structural models" are corroborated two ways: a ferx-r example model carries y[CMT=2] / y[CMT=3] / CMT=2: DV ~ ... and an Emax readout, and pkpd_cmt.mod, pkpd_ir.mod and qss_tmdd.mod all translate with zero $unsupported in the corpus sweep.
  • THETA (FIX) removal from the lossy table. ferx's theta regex takes FIX in either position, and .emit_theta() emits it; measured end to end.
  • Non-ASCII: 219 bytes before, 219 after -- all pre-existing, none introduced.

Not checked

  • The "Heuristic -- output should be verified" table was not re-measured at all. It is outside this diff and I left it untouched, but at least one row is suspect on its face: "Complex $ERROR block -> pattern-matched to closest type" predates the whole of phase 6, which classified $ERROR by structure. Stating it here so it is not mistaken for having been reviewed.
  • The two SUSPECT rows (MIXTURE, categorical/count/TTE) remain unmeasured by design.

@TeunP
TeunP merged commit 7f3d5cf into main Aug 21, 2026
2 checks passed
@TeunP
TeunP deleted the docs/refresh-ferx-roadmap branch August 21, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant