Skip to content

feat: read like R, and explain what cannot be translated - #389

Merged
mhovd merged 7 commits into
mainfrom
feat/dsl-language
Sep 16, 2026
Merged

mhovd merged 7 commits into
mainfrom
feat/dsl-language

Conversation

@Siel

@Siel Siel commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Makes model equations behave like R wherever the DSL can follow, and makes every rejection name the call and the fix.

Built and verified against pharmsol 0.29.3, which carries the derived-value fix this branch depends on (LAPKB/pharmsol#372). No PMcore change was needed: pmcore 0.29.0 requires pharmsol = "0.29.2" (a caret), so cargo update -p pharmsol moves pharmsol, pharmsol-dsl and pharmsol-macros to 0.29.3 while pmcore stays at 0.29.0.

Expressions

  • Conditionals work in any position. The DSL accepts one only as a whole right-hand side, so nested conditionals are hoisted into derived helpers: 2 * if (wt > 70) v1 else v2 and if (a > 0) (if (b > 0) 1 else 2) else 3 both work.
  • pmin/pmax, variadic max/min, ifelse, log(x, base), trunc and sign are translated instead of rejected.
  • round(x, digits) follows R's half-to-even rule, not the DSL intrinsic, which rounds halves away from zero. round(2.5) is 2 in R and was 3 in the DSL.
  • estimate_effect_2(u, v, alpha, h1, h2) and estimate_effect_3(...) are available with a checked signature, so a wrong arity is reported against the R call.
  • The retired six-argument get_e2(a, b, w, h1, h2, alpha_s) gets a migration message that states the internal w = alpha * u * v convention, so a reader can check their own w.
  • Unsupported calls quote the offending expression and list the supported alternatives.

Two silent failures fixed on the way: every unknown function (including get_e2) crashed with subscript out of bounds because the lookup that produced the helpful message could not be reached; and missing or non-finite values now fail with a fix instead of reaching the backend.

Errors point at the model, not at generated text

  • compile() validates the rendered source, so a model that cannot compile fails where it is defined rather than at the start of a fit.
  • Every emitted line records the R statement behind it, so a backend diagnostic names that statement: From your model, output block: y[1] <- undeclared_value * x[1].
  • The fit-time note apologising for generated line numbers is removed, because it is no longer true.

Verification

  • Numeric conformance corpus: 24 expressions simulated through the compiled DSL and compared with R's own evaluation -- 0 mismatches, including every round() tie case.
  • Full testthat suite against released pharmsol 0.29.3: 0 failures.
  • data/*.rda regenerated, as the data-currency test requires after a change to the R6 method bodies. The five data-only fixtures that were merely re-serialized were restored so the diff shows only what changed.

Note on the branch

Branched from issue-385-dose-inputs (the PMcore 0.29 upgrade, #388), so the diff may include that change until #388 lands. It will be rebased onto main afterwards.

Pin pmcore =0.29.0, which brings pharmsol 0.29.2. ODE dose inputs and their
scales now stay on the derivative RHS instead of being declared as separate
routes; analytical models keep explicit routes, lag and fa.

- `R[j]`/`rateiv[j]` and `B[j]`/`bolus[j]` may be a standalone additive term
  or one exact product with a scale, in either operand order.
- A bolus scale may not depend on state or dose inputs.
- A PM_model rebuilt from another PM_model renders its DSL from `arg_list`
  rather than inheriting a previously rendered string, which could be stale.
- Regenerate data/*.rda, which the data-currency test requires after any
  change to the R6 method bodies.
Model equations now behave like R wherever the DSL can follow, and every
rejection names the call and the fix rather than generated DSL text.

Expressions
- Conditionals work in any position. The DSL accepts one only as a whole
  right-hand side, so nested conditionals are hoisted into derived helpers.
- pmin/pmax, variadic max/min, ifelse, log(x, base), trunc and sign are
  translated instead of rejected.
- round(x, digits) follows R's half-to-even rule, not the DSL intrinsic, which
  rounds halves away from zero.
- estimate_effect_2/estimate_effect_3 are available with a checked signature.
  The retired six-argument get_e2 spelling gets a migration message that states
  the internal w = alpha * u * v convention.
- Unsupported calls quote the R expression. Every unknown function previously
  crashed with `subscript out of bounds`, so the message written for it was
  never reached.
- Missing and non-finite values are rejected with a fix.
- A dose input inside a conditional is refused with the supported alternative.

Diagnostics
- compile() validates the rendered source, so an invalid model fails where it is
  defined instead of at the start of a fit.
- Every emitted line records the R statement behind it, so backend diagnostics
  name that statement.
- The fit-time note about generated line numbers is removed: it is no longer
  true.

Requires pharmsol with the derived-value type fix (LAPKB/pharmsol#372): a
derived value initialised with a whole number was typed Int while stored in an
f64 buffer, so hoisted helpers returned the bit pattern instead of the value.

data/*.rda is regenerated, which the data-currency test requires after a change
to the R6 method bodies.
Picks up the derived-value type fix (LAPKB/pharmsol#372): a derived value
initialised with a whole number was typed Int while stored in the f64 derived
buffer, so reading it returned the bit pattern. The hoisted helpers this branch
introduces rely on that fix.

pharmsol 0.29.3 pins its sibling crates exactly, so pharmsol-dsl and
pharmsol-macros move with it. pmcore 0.29.0 requires `pharmsol = "0.29.2"` (a
caret), so no pmcore change is needed and pmcore stays at 0.29.0.
@Siel
Siel marked this pull request as ready for review September 15, 2026 13:33
Siel and others added 4 commits September 15, 2026 14:41
CI pins `roxygen2@8.1.0` in the R-CMD-check workflow and fails when
`man`/`NAMESPACE` differ from a fresh `roxygenise()`. The previous commit
hand-edited the Rd in the style of the locally installed 7.3.3, so the list
indentation and paragraph wrapping did not match what CI generates.

Regenerated with 8.1.0: only PM_model.Rd changes, which confirms the rest of the
documentation was already produced by that version.
…390)

* feat: close the remaining R gaps and make the emitter self-checking

Language
- Braced conditionals (`if (cond) { a <- 1; b <- 2 } else { ... }`) are accepted
  in the secondary, equation, output and initial-condition blocks, which is how
  R users write alternative parameter sets. A branch that assigns a derivative,
  output or initial condition is refused with the conditional-equation form to
  write instead, because the DSL accepts only plain assignments inside an `if`.
- A `for` loop is refused with an explanation rather than a generic message:
  loops are not part of the model language.

Correctness fixes found by the new tests
- `p1[1]` used to be rewritten into the identifier `p11`, so it failed later as
  an unknown name. Indexing is now limited to what can be indexed - `x[i]` and
  the dose inputs - and anything else is reported.
- `base::exp(x)` and other calls whose head is itself a call raised a base R
  error ("the condition has length > 1") because `as.character()` on that head
  returns several elements. Every call head is now extracted by one helper that
  returns a single name or nothing, and the model gets a diagnostic.
- `round(x, -1)` was rejected: a negative literal is a call to `-` in R's parse
  tree, so the digit check did not see a number.

Emitter structure
- Generated lines are carried as a value type that keeps each line and the R
  statement behind it together, so provenance cannot drift. This replaces the
  parallel `*_src` vectors that five emitters had to keep in step by hand; the
  renderer now fails loudly if line and label counts ever disagree.
- The `fa` block multiplies the bolus term while the derivative is rendered
  instead of patching the finished text with `gsub()`, which depended on the
  term appearing exactly once and would have edited the wrong occurrence if
  that ever changed.

Tests
- The numeric corpus grew from 24 to 53 cases: half-even ties, negative digits,
  nested and chained conditionals, literal-branch conditionals, a
  covariate-driven branch, and a helper-name clash. Each case runs the model
  and compares with R's own evaluation, because a translation that parses and
  computes something else passes every structural check.
- Every rejection is now asserted to be a structured error that names the fix,
  so a raw R error cannot reach a user unnoticed again.

* feat: name the file and line in a diagnostic when R kept them

`keep.source` is TRUE for an interactive session, for `source()` and for a test
run, so model blocks defined there carry source references. The statement label
now includes them:

    From your model, output block (model.R:15): y[1] <- undeclared_name * p1

A plain `Rscript` run has `keep.source = FALSE` and no line information exists,
so the label stays as it was; the diagnostic is complete either way. The label
is a rendering detail only - the generated DSL is unchanged, which the frozen
model corpus confirms.
The previous commit added skip_on_covr() to the R6 members test, but the
test that actually fails under covr::package_coverage() is the method-bodies
check: covr instruments every R6 method body in place (covr:::traverse_R6),
so the live generator can never match the uninstrumented bodies frozen in
data/*.rda. The member-set check is unaffected because instrumentation
preserves method names, and it passed under real covr in CI (FAIL 1, the
method-bodies test). The comment now sits with the test it describes.

Verified: both tests pass without covr (3 passes); under R_COVR=true the
method-bodies test skips while the members test still runs and passes.
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.45485% with 81 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.77%. Comparing base (5613287) to head (d2ed6e3).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
R/model_dsl.R 87.26% 73 Missing ⚠️
R/PM_model.R 68.00% 8 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #389      +/-   ##
==========================================
+ Coverage   62.73%   64.77%   +2.04%     
==========================================
  Files          51       52       +1     
  Lines       22410    22964     +554     
==========================================
+ Hits        14059    14876     +817     
+ Misses       8351     8088     -263     
Files with missing lines Coverage Δ
R/PM_model.R 67.40% <68.00%> (+0.91%) ⬆️
R/model_dsl.R 89.01% <87.26%> (+5.52%) ⬆️

... and 15 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mhovd
mhovd merged commit ef474a1 into main Sep 16, 2026
10 checks passed
@mhovd
mhovd deleted the feat/dsl-language branch September 16, 2026 08:58
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.

3 participants