fix: restore a fit's ID column as a factor - #7
Conversation
The fit table round-trips through a plain .csv, so read.csv() brings ID back as an integer while a live fit carries a factor. Anything that joins the fit table against something derived from the fit then hits a type mismatch -- nlme::augPred() keeps its `id` a factor, so ggPMX::pmx_nlmixr() on a cached fit died in a data.table join with "Incompatible join types: x.ID (factor) and i.ID (integer)". Repaired in loadFit() rather than in the restore script written by saveFit(), so caches written by earlier versions are fixed on load too. Levels come from ranef/etaObf, which the restore script has already put back as factors with the fit's own levels. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
nlmixr2est stores parHistData compressed unless control=list(compress=FALSE),
so the fit environment holds a raw vector rather than a data frame. saveFit()
read the type levels straight off that, found no data frame, and recorded
nothing -- leaving loadFit() to fall back to its hardcoded level list.
nlmixr2est has since added types that list predates ("Analytic Gradient
(relaxed)" and friends), so those values came back as NA and the round-trip
test for the SAEM IOV fit failed (the test-coverage job on main).
Decompress with `fit$parHistData` before reading the levels, the same way the
save loop already handles every other raw item. For caches saved before the
levels were recorded, the loader now appends any unrecognized type to the
fallback list rather than dropping it to NA.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things antigravity's review of PR #7 turned up. saveFit.nlmixr2FitData() wrote the fit .csv and then called the core method with a hardcoded zip=TRUE, so `zip=FALSE` was silently ignored for every fit that carries data -- which is the common case. Pass `zip` through. The PR claimed caches from earlier versions are repaired on load but only tested freshly written ones. Add a test that blanks `..id.level..` and `..parHistType.level..` out of the env script and injects a parHistData type the loader's fallback list predates, then asserts ID comes back a factor in order of appearance (not a character sort, which would put "10" before "2") and the unknown type is appended rather than dropped to NA. Also correct the NEWS claim about that fallback: it lives in the restore script inside the zip, so an existing cache only picks it up on re-save. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second round of antigravity review on PR #7. The type levels are applied by the restore script stored *inside* the cache, so a cache written before nlmixr2est added a type ("Analytic Gradient (relaxed)" and friends) has no level for it and coerces it to NA on load. Re-saving cannot recover it -- by then the string is gone -- so the previous commit's NEWS claim that an old cache picks up the fix on re-save was wrong. Repair it in loadFit() instead, the same way the ID column is repaired: the -parHistData.csv still holds the original strings and has not been cleaned up at that point, so read the column back and append whatever levels the script was missing. Existing caches are fixed in place. Also add the test that distinguishes the two sources of ID levels: theo_sd's IDs appear in level order, so the round-trip test could not tell a ranef- derived level set from the appearance-order fallback. Reversing the recorded levels makes them disagree and pins down that ranef wins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third round of antigravity review on PR #7, adversarial pass. The list of files belonging to a fit was matched with an unanchored pattern, so a cache named "fit" also matched "myfit-env.R": saveFit() zipped the other cache's files into its own archive and then unlinked them, and loadFit() deleted them on cleanup. Previously this needed unzipped files to be lying around; the preceding commit made saveFit(zip=FALSE) actually leave them, so anchor the pattern. Folded the three copies into one helper. Two smaller hardening fixes from the same pass: - .nlmixr2saveRestoreParHistType() read the csv with inferred column types, so a level like "01" would come back as 1 and "T" as TRUE. Read as character. - .nlmixr2saveRestoreIdFactor() applied the ranef levels directly. ranef should cover every subject in the fit table, but union the two rather than let a missing one turn into NA while fixing the column's type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fourth round of antigravity review on PR #7. Anchoring the file pattern last commit left the base name interpolated into a regexp unescaped. A base name is a variable name or a nlmixr2save.prefix, so it can hold metacharacters: `my.fit` is an ordinary R name, and as a pattern its `.` also matches `my_fit`'s files -- which the caller then zips and unlinks. `fit+1` fails the other way, matching nothing, leaving a cache that cannot be loaded. Match the names literally with startsWith() instead. The old-cache test asserted the restore script appends an unrecognized type, but loadFit() repairs an NA type from the csv right afterwards, so the test passed whether or not the script did its job. Source the restore script directly to pin down the script's own behavior, then check loadFit() as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fifth round of antigravity review on PR #7. `.fit` is an ordinary R name and saveFit() takes the base name from the variable, but list.files() omits dot files unless asked, so saveFit(.fit) zipped nothing and loadFit() cleaned up nothing. This predates the PR -- the regexp the helper replaced had the same hole -- but the helper is where it belongs. Pass all.files=TRUE and drop "." / "..". Also normalize dirname("") to ".": file.path("", x) is rooted at the filesystem, so a base name with no directory part and no name would have pointed the zip and the unlink at /. Adds a fit-free unit test for the helper covering the boundaries that matter: a longer name starting the same way, the .zip itself, a metacharacter in the name, a dot name, and a nested directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Antigravity review (Gemini 3.1 Pro), 6 rounds to convergenceRan Fixed1.
That is exactly the 2. A cache whose own restore script dropped a type could not be repaired by re-saving The type levels are applied by the restore script stored inside the zip, so an old cache coerces unknown types to 3.
4. Saving or loading one cache could delete another one's files (destructive) The file list was matched with an unanchored pattern, so a cache named This was reachable before, but fix 3 makes leftover unzipped files a normal occurrence, so it mattered more. 5. Smaller hardening
Tests
Reviewed and rejected
Full suite passes locally with 🤖 Generated with Claude Code |
The test-coverage job has died twice at ~21-25 min with exit 143 and no test output at all. The runner is torn down rather than the step failing, so none of the always() diagnostic steps run and the partial testthat.Rout is never uploaded -- there is nothing to diagnose from. This does not reproduce locally: covr::package_coverage() finishes in 4-6 min and passes under a warm cache, a cold cache, 4 CPUs, and CI's exact invocation including install_path. So the evidence has to come from CI. Give the step a 20 minute timeout so it fails on its own terms and leaves the always() steps to run, and add a step that reports memory, disk, cpu and any kernel OOM messages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diagnostic only. A controlled experiment settled where the coverage job's time goes and where it does not: re-running the base commit (4adeb06) today finished its tests in 3m23s, while this branch exceeds the 20 minute step timeout on the same runners the same day. So the cost is on this branch -- but it is not reproducible locally, where the same covr run is only 6-7% slower than base at 11, 4 and 2 threads alike (3m54 vs 3m41, 6m49 vs 6m23). Messages go to stderr, so these land in testthat.Rout even when the step is killed, which pins down which block actually consumes the time on CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An earlier `git add -A` in this branch swept 107 build artifacts into the repository: a covr/ tree of .gcov files carrying absolute paths from the machine that ran them, plus the src/*.gcda coverage counters. None of that belongs in version control, and the .gcda files are actively harmful -- they are counters from one build, and CI compiles with --coverage and merges gcov output on top of whatever .gcda it finds in the tree. Untrack them, and ignore the whole family in .gitignore so `git add -A` after a local covr run cannot repeat this. Also add them to .Rbuildignore so they stay out of a source tarball if they are sitting in the working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd trips Timing markers on CI found where the coverage job's 20 minutes went. All five nlmixr2 fits took 81 seconds; three of the tests added in this branch took over 18 minutes between them, each slower than the last: a cache saved before the levels... 28s saving one fit leaves another cache 2m23s restored ID levels come from ranef 4m10s a cache whose own script dropped... >11m28s (killed) Every extra loadFit rebuilds an rxode2 model. That is nearly free against a warm cache, which is why this never showed locally -- the same run was only 6-7% slower than base at 11, 4 and 2 threads alike -- and it compounds against the cold cache CI starts from, hence the progressive slowdown. Exercise the two repair functions on hand-built objects instead: a synthetic environment for the parHistData repair, and a synthetic fit table for the ID repair. Neither needs a fit, and both pin the behavior down harder than the round trips did, because the levels can be made to disagree with the row order in a way theo_sd's IDs never do. Keeps the one end-to-end old-cache test. The file-matching helper already has its own fit-free unit test, which is what decides which files a save or load touches, so the cross-cache clobber test goes rather than paying for another round trip to reach the same assertion. Full covr run now takes 3m20 against the base commit's 3m41, with coverage up from 82.41% to 82.74%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The resource dump did its job -- it ruled out memory and disk while the real cause (extra rxode2 model rebuilds in the tests) was tracked down -- and is just noise now that the job runs in about 6 minutes again. The step timeout stays. It is what turned a runner that died silently at 65 minutes into a step that fails and leaves its testthat output behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage CI: fixed, and it was two separate problems
1. A real bug in this package (the failure the job was reporting)
That is the 2. Tests I added in this PR were far too expensive on CIAfter that fix the job started timing out at 20-25 minutes with no output. It did not reproduce locally: the same Timing markers on CI found it:
The fits were never the problem. Each extra The repair functions are now exercised on hand-built objects instead: a synthetic environment for the Result: 3m20s locally against the base commit's 3m41s, coverage up from 82.41% to 82.74%. On CI the job now runs in 6 minutes. Also
All checks green: R-CMD-check on all 5 platforms, pkgdown, test-coverage, codecov. 🤖 Generated with Claude Code |
Three more files that `git add -A` swept in and the previous cleanup missed: src/RcppExports.gcno and src/nlmixr2fix.gcno (634KB of gcov graph data, the .gcno siblings of the .gcda files already removed), and test.csv, a two-row scratch file left in the repository root by a throwaway experiment and referenced from nowhere. Both patterns were already added to .gitignore; being tracked, they needed removing by hand as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Problem
The fit table round-trips through a plain
.csv, soread.csv()bringsIDback as an integer while a live fit carries a factor. Anything that joins the fit table against something derived from the fit then hits a type mismatch —nlme::augPred()keeps itsida factor.ggPMX::pmx_nlmixr()on a cached fit died outright:(nlmixr2's
xgxr-nlmixr-ggpmxarticle, which loads its fits with:=.)Fix
Put
IDback to a factor inloadFit(). Doing it there rather than in the restore script written bysaveFit()means caches written by earlier versions are repaired on load too — the restore script is a saved artifact inside the zip, so a save-side fix would only help fits saved from here on.Levels come from
ranef/etaObf, which the restore script has already put back as factors with the fit's own levels; failing that, from the order the IDs appear.Tests
New assertions in
test-save.R's round-trip block, for both the FOCEi and SAEM fits. Verified on the real cached article fit:IDrestores as a factor with levels31, 32, 33, ...taken fromranef, and thexgxr-nlmixr-ggpmxarticle now renders.Pre-existing unrelated failure in that file (
parHistDatatypefactor levels — nlmixr2est has added"Analytic Gradient (relaxed)"and friends since the fallback level list was written) is unchanged by this PR; confirmed present on the base commit too.🤖 Generated with Claude Code