Change Log

All notable changes to this package will be documented in this file. Two things about its shape are worth stating, because neither is the usual one.

Entries are dated rather than numbered, and that is still true now that there is a release. rplatypus 0.2.1 reached CRAN on 2026-11-04, so there are two ways to have this package and they are different: install.packages() gives the submitted version, and remotes::install_github("maju116/rplatypus") gives the default branch, which is where work lands between submissions. A heading here is the version in DESCRIPTION - the one the next submission will carry - and the dated entries under it are what a commit gives you before it is sent. The 0.1.1 at the bottom is the 2020 package this one replaced.

Every entry names the engine it needs. The computation lives in pyplatypus and this package pins one exact version of it. A feature lands in the engine, the engine is published to PyPI, and only then can this package reach it - so the pin moving is part of a change here, not housekeeping alongside it. The pin is what stops a release of this package breaking because the engine drifted underneath it.

[0.3.1] - unreleased

2026-11-13 - Two tests reached the engine on a machine that has none

The engine it needs: 0.8.0a85, unchanged. Nothing about the package’s behaviour moves here: this is a test that was missing a guard, and the gate that should have found it.

0.3.0 was submitted to CRAN and came back from the incoming checks with 1 ERROR on r-devel-windows and OK on r-devel-linux. Two tests of save_class_volumes() called it and expected a refusal, on a machine with a Python and no pyplatypus, so what arrived was ModuleNotFoundError: No module named 'pyplatypus' where a message about class names was expected. Both refusals are the engine’s - save_class_volumes() checks reference and names in R and forwards the rest, and the per-class filenames and the channel count against the class names have lived in the engine since pyplatypus 0.8.0a29 - so both tests need the engine and neither carried the skip_if_no_volumes their neighbours do. They are the only two in the suite in that state, counted by running the whole of it with the engine made unreachable: FAIL 2 | SKIP 287 | PASS 1180 before, FAIL 0 | SKIP 289 | PASS 1180 after.

The reason nothing here caught it is the other half of this entry. The R-CMD-check jobs are commented “Deliberately without Python. This is how CRAN sees the package”, and nothing implemented that: a runner ships a Python and has a network, so reticulate::py_require() resolved the engine through uv the first moment a test touched the bridge. The submitted commit’s own Windows job downloaded cpython-3.13.18, torch (120.9MiB), torchvision and opencv-python-headless during checking tests and reported FAIL 0 | SKIP 313 | PASS 1047, against CRAN’s FAIL 2 | SKIP 313 | PASS 1045 - the same skip count and two passes fewer, because an unguarded test is not a skipped one and no skip count can see this. Those five jobs now set RETICULATE_PYTHON to a path that does not exist, which overrides py_require() entirely and removes the engine rather than merely declining to ask for it, and tests/testthat/test-cran-gate.R holds that line to the comment in both directions, so removing either one is loud. The engine job is unchanged and still exercises the bridge.

2026-11-11 (later) - This package now says which Python it needs

The engine it needs: 0.8.0a85, unchanged. This is about the interpreter the isolated environment is built on, which is R’s own question and not the engine’s.

maju116/rplatypus#338: reticulate::py_require() takes a python_version and this package passed none, so the Python of that environment was whatever uv’s candidate list offered first. Measured: that list begins 3.11.11, 3.10.16, 3.12.9, 3.9.21, 3.13.2 and is not sorted by version, so with no constraint the choice came off the head of a list this package had no opinion about.

It now asks for the engine’s own floor, >=3.11, and what that changes is the shape of the failure rather than the usual case. On this machine the answer is 3.11.11 either way - a no-op where it does not bite. Where it bites is a machine whose first candidate is below the floor: without the constraint uv builds an environment it then cannot resolve, and the user reads that no version of pyplatypus==0.8.0a85 matches, on a route where they never chose a Python at all. With it, reticulate stops first and names the constraint and the versions it found, in R, before anything is downloaded.

The floor is not a second opinion: tests/testthat/test-python-floor.R fetches the engine’s manifest and holds the two together, so a floor that moves there turns this package red until it follows - the pin’s ratchet one field over, and test-linter-pin.R’s shape because that is the same relationship about ruff.

Both escape hatches were measured rather than assumed. PLATYPUS_ENGINE_PATH resolves exactly as before, 3.11.11, and the warning that route prints was confirmed to predate this change by running the same probe without it. RETICULATE_PYTHON overrides py_require() entirely, so the constraint is recorded and has no effect there, and both of its startup messages still fire.

2026-11-11 - ?platypus_fit offered two devices where the engine takes any torch name

The engine it needs: 0.8.0a85, unchanged - this is R’s own prose about the engine’s table, and the engine has been right the whole time.

maju116/rplatypus#336: platypus_fit(device = ) was documented as "cuda", "cpu" or NULL, and the value is handed to torch as given - nothing in this package, the shipped shim or the engine checks it against a list. The engine’s own order of preference is three accelerators, cuda, mps and xpu, so "mps" was a value this argument has always taken and the help page did not mention.

The page is also the one place a reader would look for it. platypus_device() exists because of that backend - its own help page explains that a Mac reported cpu with a working mps backend in the same installation - so this package named mps where somebody diagnoses a problem and not where they ask for it.

The sentence now names "cuda", "mps", "cpu" and NULL, says the value is passed to torch rather than matched against a list, and offers "xpu" as forwarded rather than promised, because nothing in this project has run on one. The only claim it makes about mps is the one that was measured: an Apple M3 chose it and the same fit agreed with the processor on every figure the probe compared.

And the reason this was possible is the half worth having. device is an argument of the engine’s constructor rather than a specification field, so it is not one of the 117 names in the field-prose ledger - the one mechanism here that asks whether R’s prose still says the right thing. One function takes it, so the shared-@param census does not reach it either. It was unheld prose about a table in another language, and it has a receipt now: tests/testthat/test-device-param.R compares the devices the sentence offers against the engine’s own list, in both directions, through the running engine.

2026-11-10 - The pin moves to 0.8.0a85: an Apple machine was told its build was CPU-only

The engine it needs: 0.8.0a85, and this is a release whose whole value to an R user arrives through the pin moving.

maju116/pyplatypus#468: the engine answered cuda_build with "cpu-only build" whenever torch.version.cuda was falsy, which on a macOS wheel it always is - while that same wheel carries a working mps backend. platypus_device() forwards the engine’s device_report() verbatim, so an Apple Silicon user read three consecutive lines with the first denying the two under it:

torch      : 2.15.0
cuda build : cpu-only build      <- and the fit ran on the GPU
device     : mps
backend    : mps - not CUDA, so scores are not directly
             comparable with the published weights, which were trained on it

Measured on a borrowed Apple M3 Pro, macOS 26.7.2, torch 2.15.0, and it is the first time that path has executed anywhere: the machine this package is developed on is Linux with an NVIDIA card, and the macOS CI runner is a virtual machine whose graphics device has no Metal 2.0, so torch skips MPS there. The value answers about CUDA and about nothing else now - none where there is no CUDA build - because backend, device and gpu_present_but_unusable already carry everything the old string was reaching for.

One move for five releases. The pin sat at 0.8.0a80 on purpose: 0.8.0a81 is a pad colour this package does not expose, 0.8.0a82 is three sentences of that release’s own refusal, and 0.8.0a83 and 0.8.0a84 are prose and metadata whose R half was the sentence maju116/rplatypus#327 added. 0.8.0a85 is the first of the five an R user can feel, which is the ratchet working rather than a gap.

This package had written the impossible pair down as well. tests/testthat/test-bridge.R pinned cuda_build = "cpu-only build" beside backend = "mps" as its Apple Silicon fixture - a machine that cannot exist, since a macOS wheel has no CUDA and a working mps backend at once - and nothing in either suite compared the two, so it sat there as expected output while the printing method stayed indifferent to the value.

And the pin move produced a reading from the ledger of field prose that is new. All 117 engine digests held, so no description was rewritten; what moved was decode’s list of help topics, which gained evaluate_cases(). That list is read off this package’s own roxygen rather than the engine’s schema, so it went stale when evaluate_cases() gained the argument in maju116/rplatypus#318 the day before, the suite stayed green, and the regeneration a pin move triggers repaired it silently. The test holds a claimed topic to one that really documents the field and not the other direction - it says so in its own comment - so maju116/rplatypus#333 is that direction.

2026-11-09 (the evening) - Two guards that held the name of their subject, and a cache this page was clearing on the wrong machine

The reviewer’s seventeenth pass, which is the second repeat pass and the one that met the stopping rule. Both of these are its findings about work merged earlier the same day.

maju116/rplatypus#329: the stale-index advice was for the wrong uv. This page gained “That is enough” in the morning, on six readings taken here - and every one of them was taken on a machine whose reticulate picks up a uv from PATH. Measured now: reticulate:::uv_binary() answers ~/miniconda3/bin/uv with this shell’s PATH and ~/.cache/R/reticulate/uv/bin/uv under env -i PATH=/usr/bin:/bin, which is the shape of a desktop-launched RStudio. The managed copy keeps its own cache, holding its own simple-v27/pypi/pyplatypus.rkyv, while uv cache dir in a terminal says ~/.cache/uv and simple-v15. So for the reader this page is written for - the one who “never chooses an interpreter” - the old command either failed outright or cleaned a cache the failing install had never read.

It names one file per cache now, in both places, with no uv needed on PATH; the glob was run here before it was written down. And the sentence the morning’s edit removed is back: a version missing from whichever index your machine reaches is the other cause, no command touches it, and waiting is the whole remedy.

maju116/rplatypus#330: the verdict step is asserted to RUN, not merely to exist. The morning’s #324 added a step that fails the job when R CMD check stops before its Status: line - the defect being a check that halted at step seven and reported success. Its test held the step’s presence and its grep, and measured, that was not enough: with continue-on-error: true added to that step, or with if: false, the whole file still passed - the state before #324, with a green test over it. Its own comment rules that out in so many words, which is why it is worth a line.

Three assertions now, scoped to the step’s own lines rather than to the rest of the job, because an if: belonging to a later step would otherwise read as this one’s - the same mistake the Status: grep made against the env: comment above it, one direction over. Two mutations caught, and a control that an if: on another job still passes.

The engine it needs: none beyond the pin, which stays at 0.8.0a80.

2026-11-09 (later still) - On macOS this needs Apple Silicon, and the sentence is held against the engine’s own page

maju116/rplatypus#327, which is maju116/pyplatypus#459’s other half. torch has published no macOS x86_64 wheel since 2.2.2 - March 2024 - and the engine asks for at least 2.7, so on an Intel Mac there is no PyTorch it will accept. Found by a probe coming back from a real machine rather than by anybody reading.

R’s half is a different half, which is why it is prose rather than a refusal. The engine’s install fails on that platform; this package’s succeeds, because install.packages() is R and a CRAN binary and nothing here needs Python until something asks for it. So what an Intel Mac meets is a package that installs, loads, and then cannot make its first call. The README now says so in both places it already keeps facts of this kind - beside the ~5 GB CUDA wheel and the Pascal cap, which are the other two things about PyTorch rather than about this package.

And it is a second copy of somebody else’s fact, so it is held rather than written. test-platform-note.R fetches the engine’s README from its default branch, reads the torch version out of the engine’s own claim, and compares it with both of the copies on this page - test-brand.R’s shape, including the kind of positive control that section needs, because a network answering no for its own reasons is otherwise indistinguishable from a page that changed. The version is read out of each document by its role rather than compared as a formatted string.

What is deliberately NOT here is a refusal. Unlike the engine, this package has code running on that machine before uv does - Sys.info() answers Darwin and x86_64 - so it could refuse by name, which would be better than whatever reticulate says today. Two things have to be measured before that is written: where it may fire, since CRAN runs an x86_64 macOS flavour and a package that refuses to load there is a package that gets archived, and what it replaces, since nobody here has read the message it would improve on. #327 carries both, and it waits on a machine rather than on a decision.

And one sentence on that page was false, found while editing the section it is in. About the engine told a reader that uv cache clean pyplatypus is not enough to clear a stale index response and that the whole index directory had to go. It is enough, and this project’s own record has it enough on six engine releases - most recently 0.8.0a83, where both caches refused the version minutes after it was published, that command removed 271 files and 2.9 MiB, and the very next install succeeded with nothing else done. The heavy lever is still named, as the fallback it is.

The engine it needs: none beyond the pin, which stays at 0.8.0a80. The fact is the engine’s 0.8.0a83 README, and reading that page is what the new test does.

2026-11-09 (later again) - The CRAN gate is --as-cran minus one section, and a check that dies before its verdict now fails

maju116/rplatypus#322, which is this morning’s own entry corrected - and then the repair corrected by a measurement.

#319 stopped the workflow overriding check-r-package’s default args, so --as-cran reached R CMD check. The flag is only half of that gate: before calling rcmdcheck the action sets _R_CHECK_CRAN_INCOMING_ to the string false whenever it is empty, and R consults the flag for the CRAN incoming feasibility section only when that variable is unset. So the section never ran - and that is where the URL and DOI checks live, the maintainer line, Days since last update, and bad_version, an insufficient package version, which is the one WARNING-level item in there.

Turning it on was the obvious repair, it was tried, and it made the gate worse. The section needs CRAN’s own package database, and from a GitHub runner the fetch of PACKAGES.in timed out at 60 seconds: read.dcf could not open the connection, R CMD check printed Execution halted, and rcmdcheck reported success. Measured on one tarball, three ways, and in CI:

the section on, IN CI           7 checking steps, no Status: line, job GREEN
the section on, locally        50 checking steps, Status: 2 NOTEs, 38 s of URL work
the section off (what ships)   48 checking steps, Status: 1 NOTE

Seven steps and a stop means the package’s tests, examples and everything after step seven were never checked, and nothing said so. The same configuration that works on this machine kills the gate on a runner, which is the usual lesson about a gate depending on the machine it ran on, with the sign reversed.

_R_CHECK_CRAN_INCOMING_REMOTE_: false avoids the network and buys nothing worth having: if (localOnly) return(out) sits before both the database fetch and the URL checks, so the version comparison and the URLs - the two reasons to want this - are on the far side of it. Measured, that arm adds one NOTE naming the maintainer.

So the section stays off and the prose says so. The gate is --as-cran minus the CRAN-incoming section: it still fails on a WARNING or an ERROR anywhere else, which is the codetools case that justified #319. What it does not check is the version and the URLs, and those are caught by the local --as-cran run before a submission and by CRAN’s own pretest, which refuses within minutes.

And the branch produced something better than what it was for. A check that dies before its verdict is indistinguishable from a clean one: rcmdcheck read a log with seven * checking lines and no Status: and reported success. The job asserts the verdict now, and fails naming the step count if there is none - which is the repair for any early death, not only this one. Proven in three directions on real logs: a finished log passes, a log truncated the way CI’s was fails after naming two steps, and no log at all fails.

Both halves of the gate are held by one receipt - the flag arriving (no args: override) and the verdict being asserted (the step, and what it greps for). Four mutations, four caught, identity first: the step deleted, the step left in place but grepping for the wrong string, an override restored, and the job renamed so the extraction finds nothing. The grep check is scoped to the step’s own lines because the first draft was satisfied by the word Status: appearing in a comment, which is a guard in name only.

Nothing about the package changes.

2026-11-09 (later) - Three functions in the shim had no caller, and a census says so next time

inst/python/platypus_shim.py is this package’s R-facing edge of the engine, and every call into it comes through one internal function, shim(). Three of its functions were never on the other end of one: anchor_report, target_survey and model_names. maju116/rplatypus#317. Measured over R/ and tests/ - zero references each, against one apiece for case_table, anchor_shapes and instance_predictions as the control that the search can find a reference at all.

They are deleted rather than wired up, which is the argument the engine’s own DetectionMetrics.as_rows was deleted on: a shim function with no caller is a second copy of an engine signature that nothing holds, so it can drift from the engine’s with nothing to notice - the class maju116/rplatypus#314 is, found by reading rather than by anything failing. Reinstating one costs ten lines; carrying three untested ones costs a reader every time they open that file.

No capability goes with them, which is what made this a deletion rather than a loss. The figure anchor_report forwarded is the engine’s own anchor_coverage, a method on its detection engine and a function in pyplatypus.detection; the survey is pyplatypus.data.TargetSurvey; and model_names was list(engine.runs). All three are on the engine’s public surface, so a Python user has had them throughout and an R user never did. If either report is wanted from R it is an R function, a help page and a receipt, which is work rather than a wrapper.

And the issue’s own reading of the engine was half wrong, checked rather than carried. It named AnchorReport and TargetSurvey as the two engine classes behind these; there is no AnchorReport anywhere in the engine - anchor_coverage returns a dictionary - and TargetSurvey is a class. A finding’s description of the other side is evidence too.

A census replaces the reading that found them. Every function the shipped shim exports is now asserted to be reached from this package’s code or from its suite, asked of the deparsed namespace rather than of R/: the sources are not in an installed package, so a grep over them would skip under R CMD check and in the engine job, which are the two runs where this question matters most. It carries no exemption list, measured rather than hoped for - with these three gone every remaining function is reached, four of them by the suite rather than by R/: volume_support, series_support and window_presets are the probes the helpers ask before skipping, and drawing_style says in its own docstring that it is there for the test that mirrors the engine’s colours.

The engine: nothing. No release, no pin move - this is one file R ships and three functions out of it, and 0.8.0a80 stays where it is.

Five mutations, five caught, identity case first. A callerless function reinstated is named by the census; the enumeration finding nothing, and a matcher answering TRUE to everything, are each caught by their own control; and the suite no longer counting as a caller is caught by the three skip probes. The fifth says the exclusion of the census’s own file is load-bearing, and it was caught one assertion earlier than expected - by zzz_not_a_shim_function, the name nothing uses, which is written in that file and so is found there the moment it reads itself.

2026-11-09 - CI checks the package the way CRAN will, because the reason it did not ended with the rename

R CMD check in CI dropped --as-cran, and the comment that justified it said the name clash with the archived Platypus “is being taken up with CRAN separately”. That ended: this package was renamed on 2026-10-14 and accepted on 2026-11-04. maju116/rplatypus#316.

The repair is a deletion rather than an addition, which is what reading the action’s own definition changed: r-lib/actions/check-r-package’s default args are already c("--no-manual", "--as-cran"), so the step had been overriding that default to drop the flag. The override is gone and nothing was added.

And its error-on default is "warning", which is what makes this a gate rather than noise: it fails on a WARNING or an ERROR and not on a NOTE. That matters because --as-cran emits NOTEs that are about the submission rather than about the package - Maintainer, and Days since last update - and a job failing on those would be deleted within a week.

The two NOTEs named above are NOT emitted by this job, and the entry below is the repair. maju116/rplatypus#322: the action switches off the section they come from, and it stays switched off deliberately - turning it on was measured and made the gate worse. So this gate is --as-cran minus its CRAN-incoming half, which is what the newest entry says out loud. The sentence is corrected here rather than deleted, because a changelog is a record.

What it buys is measured rather than argued. There were five days, 2026-10-29 to 2026-11-03, when --as-cran carried a WARNING and nothing said so: codetools used by :: in the tests and not declared. CRAN does not accept a WARNING, and the default branch is the submission candidate, so a submission on any of those days would have been sent back. No gate run before a push asked that question - the only thing that did was a run by hand before a submission, which is a person remembering.

This heading is not an ordinal, and that is deliberate. Every other entry today counts - fifth of the day, sixth, and so on - and that breaks the moment two branches are in flight, which is exactly what happened here: this one was written while the eighth was still an open pull request, so both would have claimed the same number and neither insert point would have existed on the other’s branch. It is the same flaw as last of the day, which this file corrected this morning: a heading that names a position in a growing sequence is falsified by whatever lands next. A heading that names its subject is not.

Nothing about the package changes. This is the gate reading what ships the way its reader will.

2026-11-08 (eighth of the day) - A misspelt argument is refused rather than swallowed, and evaluate_cases() can be asked for a decoding

Eight exported functions and methods took ... and no body read one, so predict(fit, splt = "test") predicted on the default split and said nothing. The wrong answer is a correct answer to a different question. maju116/rplatypus#315.

They check the dots now - rlang::check_dots_empty(), which costs no new dependency because rlang was already an import - and detection_crops() loses its ... altogether, because it is a plain export rather than an S3 method and R’s own unused argument is a better message than any check.

The check is the FIRST statement in each body, and that is a decision rather than a style: put it after a method’s own refusals and a typo on a classification fit reports the classification refusal instead, which sends the reader to the wrong sentence.

evaluate_cases() gains decode, which is the same class with a real argument rather than a typo. maju116/rplatypus#314: an instance model with two decodings gets a row per decoding from evaluate(), and its per-case table could only ever be the first one’s - with decode = "watershed" swallowed by the dots rather than refused. dsbowl-instances publishes one trained file whose three decodings are 0.0623 apart, which is exactly the comparison a per-case table exists to make readable. Refused by name off an instance fit, copying predict()’s own repair of the same shape.

And there is a limit to this, measured rather than assumed, because the help pages must not promise more than it does. R matches a supplied name to a formal it is a prefix of before anything reaches ..., so plot(fit, metric = "dice") sets metrics and the dots stay empty - no check anywhere can see it. splt is not a prefix of split, which is why that one is refused. So the promise is narrower than a misspelt argument is refused: a misspelling that is not a prefix of a formal is refused, and one that is becomes the argument it prefixes. Held by its own test.

Print methods and as.list() keep accepting and ignoring, on purpose: R itself calls them, so a check there could fire on a call the user did not make. The census that holds all of this exempts them by rule rather than by name, which it earned on its first run - the first version listed five print methods and this package has eleven.

2026-11-08 (seventh of the day) - The pin moves to 0.8.0a80: the sidecar said how the data was read and not which cases were scored

measured_with gained how the data was read at 0.8.0a79. This adds which cases were scored, and it is a companion question rather than more of the same one: the first says what the pictures looked like when they were measured, the second says which pictures there were. A reader who has one and not the other can reproduce the windowing exactly and still score a different set of images. maju116/pyplatypus#450.

split is recorded resolved, and the seed is the proof that this matters. fractions alone describes a partition; fractions with group_by and seed and the files that were found determines it. So the seed is in the record even when nobody wrote one - it defaults to 0 and arrives by resolution - and group_by is in it because that is what makes a split by patient rather than by file, which is the difference split_dataset() exists to let you keep.

Absent when a run names a second folder, which is a real second case: the two ways of getting a validation set are exclusive, so such a run partitioned nothing and the record says so by saying nothing.

split is not splits, and the help page now says so, because they sit in one block one letter apart. split is the data’s partition - which cases were held out. splits is the model’s tiling - a source image read at splits times input_shape and cut. One decides how many cases there are, the other which pictures a metric sees inside one case.

The engine’s own issue named three fields and shipped one, which is worth knowing here because the other two are decisions rather than omissions: shuffle is training-only, so it changes what the weights learnt rather than how they were scored, and validation gates whether a validation split exists rather than changing a figure’s value. Both are pinned as absent in the engine’s test with the reasoning.

Two receipts rather than one, because the first alone passes on a block that records a split unconditionally: a fit built with segmentation_data(split = ...) records fractions, seed and group_by, and a fit built with two folders records no split at all. Proven against an 0.8.0a79 engine tree rather than a mutated test - R forwards this and holds nothing, so no edit here could break it - with the running engine asked which version it loaded. Under a79 the three new assertions fail and the window ones still pass, which is a sharper reading than an older tree would have given.

The field-prose ledger answered nothing to re-read, predicted before it ran: 117 fields, one line moved, every digest holding. Fifth time.

And the pin move needed uv cache clean pyplatypus before the first gate would run. reticulate resolves the engine through the shared uv cache, which was still refusing 0.8.0a80 while a throwaway one had installed it half an hour earlier - so the gate failed as reticulate reported no Python, which reads like a broken bridge rather than a stale listing. 239 files, 4.6 MiB, and the engine answered 0.8.0a80 immediately after.

2026-11-08 (sixth of the day) - The pin moves to 0.8.0a79, and a sentence that was true said less than the engine does

A weights sidecar records how the data was read from this engine on. measured_with already carried the model’s half - how the figures were computed - and now carries the data’s beside it: how the data was read, which decides what they were measured on. window for every task, and channels_from and target_spacing for a semantic segmentation fit. maju116/pyplatypus#447.

What R owed was prose, which is a release this package needs something from rather than one it only passes through. The section of ?export_weights that tells a reader what the block holds, task by task, was true in every clause and incomplete as a whole the moment the engine shipped: every task also records how its data was read. That is the failure this project keeps paying for one layer down, which is what the field-prose ledger exists for - a page that is true and says less than the code does, with nothing holding it.

The asymmetry is held by a test, because a reader would guess the other way. The two halves of a specification inherit differently: an instance model extends the segmentation model and so has splits, while instance data extends the base data class and so has only window. So a semantic fit records channels_from and target_spacing and an instance fit records neither. I wrote the opposite into the help page first and the engine said so when asked.

Only what is set, which is the engine’s decision and the reason absence carries meaning here: a missing target_spacing says this run named none, not resampled to nothing. A record that wrote null could not be told from one written by an engine too old to answer - the distinction 0.8.0a74 drew in this package, where jsonlite returns NULL for a JSON null and for an absent key alike. window is the exception and is always there, because it always has a value: "auto" is a windowing rather than an absence, and it means whatever window the file itself recorded, so two datasets read with "auto" are not read alike.

The receipt runs in both directions, beside the one that has held trained_with since the a75 pin. A fit that names no window records "auto" - the value nobody wrote, which is the resolved-rather-than-as-written argument - and a fit built with segmentation_data(window = "lung") records "lung". The first on its own is satisfied by a block recording a constant. The absence of the two semantic fields is asserted as well, because that is what only what is set means on the side a test can see.

The field-prose ledger answered nothing to re-read, and the prediction was written down before it ran. 117 fields, one line moved - the version - and every digest held, because 0.8.0a79 added two methods and rewrote no description=. Fourth time that answer has come back, and it costs one command.

Two corrections to the fifth entry of today, both numbers in prose that a measurement contradicts. rows_to_frame() has nine callers and that entry said ten, twice: one in read_record()’s path and eight in the fitting surface, counted off the source rather than remembered. And its heading said last of the day, which is a pointer to the end of a growing list and was falsified by this entry - the same shape CLAUDE.md records about naming the journal’s last section. It says fifth of the day now.

2026-11-08 (fifth of the day) - Every table passes through one function, and it kept the first value of a cell that held several

rows_to_frame() is the seam every reported table crosses - nine callers, every scoring table, the run record’s history and all nine catalogue listings - and a cell holding more than one value came back as its first, with nothing said. It now refuses, naming the column and the row.

rows_to_frame(list(list(a = 1, b = c(2, 3)), list(a = 2, b = 4)))
#   a b
# 1 1 2        <- the 3 is gone
# 2 2 4

The defect is latent today and that is why it needed a guard rather than a fix. Measured rather than assumed: every row the engine builds carries scalars - summarise_cases, per_class, as_rows, answers and History.to_dict were all read, and four of those five are methods on an engine object rather than anything a reader calls, which is why they are written here without parentheses. The one family that genuinely has a multi-element field, the catalogue listings’ arguments and chooses, is flattened on the engine’s side of the bridge, where platypus_shim.py joins a tuple with ", ". So somebody met this class once and fixed it at the far end. With a refusal patched in, the whole suite reads FAIL 0 | SKIP 0 | PASS 2187 and the refusal never fires. What was wrong is the function being unable to say so if a row ever does arrive wide, and the flattening rule living somewhere other than in the thing that truncates.

A refusal rather than a warning, and the call graph is what decides it. All nine callers pass something the engine built, never anything a user wrote, so this can only ever fire on a defect in this package or in the engine - it is addressed to us, and a frame that is quietly wrong is the thing this project keeps measuring the cost of. The message says so and asks for a report.

And the flattening is unlist() rather than [[1]], which fixed a second, quieter half - measured, and it is worse than the truncation. A cell holding list(NULL) has length 1, so the old length test passed, [[1]] gave NULL, and assembling the column dropped it. The column is then one short, and as.data.frame() does not complain - it recycles:

#   key  iou
# 1   a 0.83
# 2   b 0.83        <- the engine sent nothing for b, and b reports a's number

That is a figure attributed to the wrong case, which is #223’s defect in another costume, and it is quieter than a truncated cell because every column is the right length and every value is a real number. Flattening before the length is taken makes the two shapes one case: unlist(list(NULL)) has length 0, so it becomes NA.

Proven able to fail, seven mutations and an identity case:

IDENTITY                                                  FAIL 0  PASS 28
the whole pre-#307 body is back: [[1]] and no guard        FAIL 6
the guard is gone, the flattening stays                    FAIL 3
the refusal is a warning that keeps the first value        FAIL 5
the message no longer names the column                     FAIL 3
the message no longer names the row                        FAIL 1
the flattening is [[1]] again, the guard stays             FAIL 1

The last one survived the first draft of its test and is the useful one: unwrapping a length-1 list by hand is indistinguishable from flattening at every depth the engine produces, and differs one layer further in, where one unwrap is not enough. What the test pins is the promise - one value per cell at any nesting - rather than the spelling.

The engine it needs: 0.8.0a78, unchanged. Nothing here crosses the bridge. #307.

2026-11-08 (later again) - One @param for several functions is a class, and two more of them were wrong

#304 repaired gamma, which documented two losses with one sentence that was false about the second. That was one instance. The fourteenth review pass enumerated every \item in every help page against formals() of the functions that page documents, and the class has two more members.

48 shared items (two or more exported functions take them)
 7 whose functions DISAGREE about the default
 2 of the seven wrong

?losses’ alpha documented four losses and described one of three meanings. It said “For Tversky, the weight on false negatives; raising it buys recall”, which is right for loss_tversky() and loss_focal_tversky() and wrong for the other two. In loss_combo() alpha is the cross-entropy half’s share of the total - measured on the running engine, and the decomposition is exact: alpha * cross_entropy + (1 - alpha) * dice. So a reader who writes loss_combo(alpha = 0.7) to buy recall moves weight onto cross-entropy and leaves the false-negative weighting alone, which in that loss is ce_ratio’s job. In loss_focal() it is a per-class weight with 1 - alpha on the background and NULL meaning off.

?optimizers’ weight_decay said “Off by default” and named optimizer_adamw() two sentences later without saying it is on there. Seven of the eight optimisers default to 0; AdamW defaults to 0.01, which is what that optimiser is for.

Both items now state each meaning with the function it belongs to.

And the census is kept as a guard rather than as a reading, test-shared-param-census.R. What it deliberately does NOT do is judge the prose, and the measurement is why: of the seven, two were wrong and five were right, and no mechanical signal separates them. A rule of name at least two of the functions catches both defects and condemns monitor, patience and eps, which are correct - there the argument means the same thing everywhere and only the default differs. So the guard does what the field-prose ledger does: it records the seven with a reason each and fails when an eighth appears, which guarantees somebody was asked. Both directions proven: a new disagreeing item fails it, and dropping a recorded one fails it too.

Neither defect was visible to R CMD check, which is satisfied when an argument is documented once.

maju116/rplatypus#306.

The engine it needs: none beyond the pin, which stays at 0.8.0a78.

2026-11-08 (later still) - The pin moves to 0.8.0a78, and one @param was wrong about one of its two functions

This is a release this package needs something FROM, which is rarer here than the usual shape. 0.8.0a78 gives the four losses nothing could catch a formula error in a value from outside, and among those it rewrote focal_tversky’s gamma description - so the field-prose ledger’s digest moved, this package regenerated its fixture, and re-reading the topic it named is where the finding is.

?losses documented gamma once for two constructors and was wrong about one. It said “0 is plain cross-entropy and 2 is the usual value”. Measured against the running engine:

loss_focal()          default 2   accepts gamma = 0   and at 0 it IS cross-entropy
loss_focal_tversky()  default 1   REFUSES gamma = 0   the field is declared gt=0

So the sentence is right for the first and wrong in both halves for the second, whose usual value is 1 - this package’s own constructor default, three lines below the prose that contradicted it. They are different parameters sharing a name: one is an exponent on a single prediction, the other on 1 - TI over a whole class.

And a third thing it never said. Abraham and Khan (arXiv:1810.07842) write the focal Tversky exponent as 1/gamma; this argument is the exponent, so their recommended 4/3 is this argument’s 0.75. The two agree only at 1.

?losses now separates the two, states each default in the same words, and gives the conversion. Held by test-shared-param-prose.R, which compares the default each half states with the default formals() reports, and by a second test asking the engine for the premise underneath - one accepts 0 and the other refuses it.

maju116/rplatypus#304, with the engine’s half maju116/pyplatypus#429.

The engine it needs: 0.8.0a78, for the description that set this off.

2026-11-08 (later) - A vignette printed a weights commit two pin moves out of date

vignette("data-science-bowl") prints the registry’s revisions as knitted output, and fives-unet’s cell had read c4492f7a since before 0.8.0a74. The registry has said 799fea5c since that release and d342f3ad since 0.8.0a77.

A revision is the worst column on that page to let rot, because it is the one value a reader copies: hf://maju116/platypus-weights/fives-unet.safetensors@c4492f7a... is a supported form and it still resolves. What it fetches is the sidecar as it was before maju116/pyplatypus#426 - no peel count beside its clDice figure, which is the file that defect was about. The stale cell does not fail; it hands back the superseded artefact, from the page that teaches borrowing published weights.

Nothing held it, and the two instruments that look like they do both answer a different question. test-weights.R asserts every revision in the registry is forty hex characters and never reads a vignette; test-precomputed-vignettes.R compares the prose lines of each .Rmd.orig against its .Rmd and drops knitted output on purpose. So this was an omission rather than a drift, and it is closed by a guard before it is closed by a re-knit: test-vignette-revisions.R reads every forty-character commit printed in any shipped vignette and asks available_weights() whether the registry still pins it.

The page is re-knitted, which moved three lines - the revision and two rows of a score table, because training on a card is not bit-reproducible. Nothing in the prose moved with them, and that is by the page’s own design: it says in so many words that it quotes no digits for those models because every re-knit trains them again.

And a second defect on the same four lines, which had nothing to do with the pin. The sentence under the table read “the description in that table is the part to read” and the table prints name and revision - there is no description column, and there never was. The prose now says what a revision is, a commit, which is why a published name means one set of numbers for ever, and sends a reader to available_weights() for the descriptions.

maju116/rplatypus#301.

The engine it needs: none beyond the pin, which stays at 0.8.0a77.

2026-11-08 - The pin moves to 0.8.0a77: a weights sidecar you can reproduce its clDice from

One move for two engine releases, which is what the pin is for: a release this package needs nothing from does not move it, and a release whose fix reaches an R user through it does. Both of these are the second kind.

0.8.0a76 records the tiling in a weights sidecar. A source image is read at splits * input_shape and cut, so the tiling decides which pictures a reported figure was measured on while the weights and the data stay the same - and nothing was writing it down. Measured on the published fives-unet with nothing else changed: four tiles by four give a Dice of 0.8788833 and two by two give 0.8054393, 0.0734 on one file, and the sidecar export_weights() wrote for those two runs was identical. This package forwards what the engine writes, so the key arrives rather than needing anything here.

A prediction was made before the pin moved and it held: this package’s suite does not go red. test-weights.R asserts the recorded engine, the metric list’s length, the metric’s name and that its resolved smooth is there - not the key set - unlike test-records.R, which pins a run record’s names exactly and went red at 0.8.0a74. Two releases that look identical from a distance, and the ratchet fires in only one.

0.8.0a77 moves the published fives-unet weights to a corrected sidecar. The file on the Hub recorded a clDice of 0.877906778305769 and said nothing about the peel count, so a reader holding it and metric_cldice() as it comes - iterations = 5 - measures 0.7970546 instead. 0.0809 apart, with nothing in the file to say why. It now carries cldice_iterations: 12 beside the engine that measured it, and a model written with weights = "fives-unet" fetches that file - platypus_fit() brings it down on first use and the sidecar comes with it.

available_weights() reads the registry from the engine, so the listing does not change: the description naming twelve peels and the default of five arrived with the previous pin move. What changes is the sidecar a reader gets.

And the first draft of this entry named a function this package does not have - load_weights, invented twice while describing how an R user reaches published weights, which is through weights = "fives-unet" in a model and platypus_fit(). That is maju116/rplatypus#229’s defect exactly, in the entry whose own test exists because of it, caught by reading NAMESPACE before the gate ran rather than by the gate. The same invented name reached the engine’s CHANGELOG.md, where nothing would have caught it - filed as maju116/pyplatypus#440.

maju116/rplatypus#300, with the engine’s halves maju116/pyplatypus#427 and maju116/pyplatypus#426.

The engine it needs: none beyond the pin.

2026-11-07 (later) - The retinal vignette’s clDice gap is the metric’s peel count, not the model’s training

vignette("retinal-vessels") told a reader that a better-trained model closes the gap between clDice and Dice, and it had never been measured at one setting. This page calls metric_cldice() with its default iterations = 5; the engine’s article measures the same metric at 12. Re-scored on their own test split at five peels, the published forty-epoch weights give a correlation of 0.856 and a mean difference of -0.082 - this page’s own 0.8581 and -0.0833, to within 0.002 - so about 0.085 of the distance between the two pages is the peel count and 0.0015 is the training.

The paragraph says that instead, both pages now name the peel count beside their figures, and the comparison the two pages make together is held at one setting rather than as an ordering of two. Nothing trained: the weights exist and a re-score takes four minutes. maju116/rplatypus#298, with the engine’s half maju116/pyplatypus#425.

The engine it needs: none beyond the pin, which stays at 0.8.0a75.

2026-11-07 - The pin moves to 0.8.0a75: a weights sidecar says what its figures were measured with

export_weights() now writes two things you do not pass it. trained_with is the engine version that produced the file, which the sidecar has always carried - and which four publishing scripts in the engine’s repository each wrote by hand until this release. measured_with is new: the engine that measured the figures beside the weights, and the configuration it measured them with. maju116/pyplatypus#419.

Resolved rather than as written, and that is the whole design. metric_cldice()’s iterations is the skeleton’s peel count. It moves the number by about five points on one published set of weights, and before this it appeared in no file at all - not because anybody removed it, but because nobody had written it: it was the metric’s own default. A sidecar recording only what a configuration stated would have recorded nothing about the one parameter that mattered. maju116/pyplatypus#413 is the measurement that found this, and it cost a day of re-scoring to answer a question one key now answers.

What is in the block depends on the task, because the question does. A semantic or classification fit records its metrics; an instance fit adds how objects were separated and decoded, since one trained model scores differently through each decoding and a number from it means nothing without the one it came from; a detection fit has no metric list and records the three thresholds its table moves with.

Passing either name through ... is refused rather than ignored. A caller’s copy of a fact only the engine is in a position to know is a copy that will drift - which is exactly what happened to trained_with, four times over.

Nothing an R user calls changes, and ?export_weights says where the two keys come from. By §3E’s distinction: an R user who exports weights meets both of them by the pin moving and by nothing else.

Not done, and deliberately: the four older published weights keep their sidecars as they are. A sidecar states what the engine of its day measured, so writing today’s reading into a record of another day would replace an honest silence with a certainty nobody measured.

2026-11-07 - The pin moves to 0.8.0a74: a run record says which epoch its weights came from

read_record() gains two fields, and they are the two a record could not answer with. weights_epoch is the epoch the model’s final weights came from, and stop_reason is why the run ended. Both existed in the engine, both were readable from a fit in the session that produced it, and neither reached run.json - so a directory six months later said which epoch was best by every metric the run logged and not which epoch’s weights it shipped. maju116/pyplatypus#417.

They are siblings of history rather than fields inside it, which is the part that matters here rather than in the engine: history is the key this package turns into a data frame, one row per epoch, so a scalar in there would have changed its type for the only reader of it that exists.

weights_epoch is NULL when the weights are not any one epoch’s - an average left by callback_ema() or callback_swa(), or a run that selected nothing and so ended on its last epoch, which nrow(record$history) already tells you. With callback_early_stopping(restore_best = TRUE) it is a number, and it is the same number a score table reports as best_epoch, because both read the one place the engine records it. A test holds those two to each other rather than to a literal.

One thing was measured rather than assumed, and it changed what print() shows. jsonlite returns NULL for a JSON null and for a key that is not there, so is.null(record$weights_epoch) reads this run selected no epoch exactly like this record was written by an engine older than 0.8.0a74. Those are different facts - one is an answer, the other is a record that cannot answer - and "weights_epoch" %in% names(record) is what tells them apart. So the print method tests the name and says no single epoch in the first case and nothing at all in the second, and ?read_record says so where a reader meets it.

The engine half also moved the published fives-unet weights onto the sidecar that can be reproduced, which available_weights() lists and nothing in this package names: the registry pinned a Hub commit whose recorded clDice no definition in the current engine produces. maju116/pyplatypus#413 and #420 have the reproduction; the weights file itself is byte-identical at both commits, so what changed is the figure beside them and not the model.

2026-11-07 - The pin moves to 0.8.0a73: clDice is the paper’s clDice now, and detection says which epoch its scores came from

One move for two releases, and both are things an R user meets through the pin and by nothing else - §3E’s distinction rather than its default.

0.8.0a72 makes metric_cldice() compute the quantity the paper defines. It is a metric and only a metric in both packages - neither offers clDice as an objective, so what moves is what you measure and never what you trained against. The soft skeleton cited Shit et al., CVPR 2021 and did not implement it: the reference code erodes with a cross and dilates with a square, and both of the engine’s were squares. monai.losses.cldice is a port of that same code, so the two operators are what every clDice anybody compares ours against was computed with.

Your numbers will move, and the direction is one-sided: the cross is stricter. Measured on 10 FIVES masks with the prediction thinned, the gap grows with how wrong the prediction is - about 0.004 at a clDice of 0.91 and 0.074 at 0.75. A three-pixel diagonal band is the sharpest single case: of its 190 pixels the old erosion kept 184 as “skeleton” where the new one keeps 64, which is one per row and is what a centreline of a diagonal is.

0.8.0a73 adds best_epoch to what evaluate() returns for a detection fit. Every other task reported it; detection carried epochs_run alone, so a detection run was the one place where callback_early_stopping(restore_best = TRUE) worked, the answer existed, and you could not see it. It is empty when a run selected no epoch - that run ended on its last, which epochs_run already names.

Both vignettes that print a figure these two change are re-knitted, which is the author’s decision rather than the cheap half: vignette("blood-cells") for the column, and vignette("retinal-vessels") for clDice and for the best_epoch it had been printing under the pre-0.8.0a68 meaning. One run of the retinal page pays both of its debts.

And re-knitting that page found the half a re-knit cannot fix. It transcribes three measurement tables from runs made between 2026-10-16 and 2026-10-22, and one of their columns is clDice - dice. Those arms’ checkpoints were not kept, so the column cannot be re-scored under the new definition without retraining fifteen models: it is left as it was measured, with the date and the reason beside it. Each table is internally sound, since every arm in it was scored the same way - what the page now warns against is reading one of those columns against the clDice evaluate_cases() prints today.

The field-prose ledger answered nothing to re-read across both releases - 117 fields, every digest holding, one line moved - which is the guard being cheap rather than quiet.

2026-11-06 - The pin moves to 0.8.0a71, one move for three releases - and the ledger caught a sentence here

One move rather than three, which is what the pin is for. Of the three engine releases behind it, exactly one is something an R user can feel, and they feel it only because the pin moved:

And moving the pin is what found a false sentence in this package’s own help page, which is the field-prose ledger doing the one job it exists for. The engine rewrote epochs’ description in 0.8.0a69 and the ledger named the four topics that document it; re-reading them turned up ?platypus_callbacks saying callback_cosine_annealing() “decays the learning rate from its initial value to min_lr”. It decays towards it: a scheduler steps after each epoch, so the value computed for the epoch after the last one is min_lr and no epoch ever trains at it.

That is the same loose sentence the engine corrected in itself, found here one release later by the mechanism built for it rather than by anybody reading the page.

The repair names a distinction rather than loosening a word, because min_lr is the floor for four callbacks and two of the four reach it. Measured through the engine, six epochs from a rate of 1.0 with min_lr = 0.1:

cosine_annealing      1.0000  0.9397  0.7750  0.5500  0.3250  0.1603   never 0.1
linear_annealing      1.0000  0.8500  0.7000  0.5500  0.4000  0.2500   never 0.1
reduce_lr_on_plateau  1.0000  1.0000  0.1000  0.1000  0.1000  0.1000   trains at it
step_decay            1.0000  0.1000  0.1000  0.1000  0.1000  0.1000   trains at it

So ?platypus_callbacks now says which two do which, and that setting epochs shorter than the run is the only way an annealed run trains at min_lr at all.

2026-11-06 - vignette("data-science-bowl") is re-knitted, and two numbers in it were never measured (engine 0.8.0a68, unchanged)

The pin does not move. 0.8.0a68 already carries everything here; what moves is a page that printed a column under its old meaning, and vignette("classification") was the only one checked when that column changed.

evaluate()’s best_epoch read 11 for both models. Under the pinned engine the first one reports nothing at all - it carries no callback, so it ended on its last epoch and epochs_run says which - and the second reports the best val_dice epoch rather than the lowest val_loss one. Re-knitted, so both rows are what this engine produces, with a paragraph saying why one is empty and the other is not: the difference is the two specifications rather than a defect.

And a sentence under that table quoted two losses that no run has ever produced. It read “a Focal-Tversky of 0.31 is not better or worse than a CCE-Dice of 0.22”, directly beneath a table printing 0.0715 and 0.0959 - the figures of the run it shipped with, in September. The point it makes is right and needs no digits, so it has none now: every re-knit trains both models again, and a number in the prose would be a claim about a run that no longer exists. Measured while fixing it, the same column moved between two re-knits taken an hour apart.

vignette("retinal-vessels") still prints best_epoch 14 and is not fixed here. That fit has no callback either, so the pinned engine reports nothing for it; the page costs 2 h 32 to re-knit, of which 2 h 15 is the fit, so the correction rides its next one. rplatypus#289.

2026-11-05 (night) - best_epoch names the epoch a row’s scores came from (engine 0.8.0a68)

The pin moves to 0.8.0a68, and what it carries is one column of evaluate() meaning what everybody already read it as. best_epoch was the epoch with the lowest validation loss, read off the run’s history whatever a callback was watching, while the scores beside it come from the weights callback_early_stopping(restore_best = TRUE) kept - the best monitored epoch. Those are the same epoch whenever monitor is left at its default of "val_loss", and different exactly when you set it to something else.

It is now the epoch the row’s own scores came from, and empty rather than wrong when they came from no single epoch: an average left by callback_ema() or callback_swa(), or a run that selected nothing and therefore ended on its last epoch, which epochs_run already names. The epoch that was best by some other quantity is in training_history(fit), which is where it always was.

vignette("classification") is re-knitted, because it described the defect. That page printed both numbers - 29 restored, 18 reported - and explained the gap, which is how anybody noticed; the paragraph now says what the column is and that this page is the reason. Nothing else on it changed in kind, and its figures are a fresh run’s.

2026-11-05 (evening) - a seventh vignette, for the task that had none

vignette("classification"), and it is the fourth task’s first worked example on either side. Nothing in the package changed and the pin did not move: the page is built out of what is already here, which is the point of it.

It continues the detection vignette. detection_crops() cuts the published bccd-yolo3 detector’s boxes out of the BCCD photographs, a dozen lines label each crop by what it overlaps and write them to one directory per class, and classification_data() reads that layout straight. Then classifier(), class_balance() before the fit rather than after it, and predict() for the probabilities a threshold is chosen from.

It is the only page here whose subject fails, and that is why it is worth reading. The classifier learns the three cell types nearly perfectly - 637 of 638 red cells - and cannot do the one thing it was added for, which is reject the boxes that match no annotation: 32 of 312. The page measures why rather than asserting it. Those crops are red cells that BCCD does not annotate, at a median detector score of 0.9864, so the label is a statement about the annotation and not about the picture; and the detector’s own operating_point, one number already in the specification, reaches the same precision for the same cost with nothing trained.

One thing it found reaches a page that already shipped: a precision measured against an incomplete annotation is a lower bound, so vignette("blood-cells")’s per-class precision understates the detector. It is said on the new page.

Two corrections to the README came with it. Multi-label classification is no longer listed as missing - labels_from has been there since the engine’s 0.8.0a50 - and the section that lists what is in the package now says so.

2026-11-05 - a palette DICOM read through a wide lookup table, and twenty-one fields now held (engine 0.8.0a67)

The pin moves to 0.8.0a67, the reviewer’s eleventh pass, and nothing in it changes what this package accepts. One defect reaches you through the pin and by nothing else: a PALETTE COLOR DICOM whose lookup table is declared 16-bit while its entries only reach 255 came back almost black - through read_images() and through a run’s own reading of your data alike - because the colour was scaled by what the table’s type can hold rather than by what the table holds. Such files exist - it is a writer quirk that pydicom documents - and 0.8.0a66, which fixed palette files being read as their raw indices, introduced this narrower case while doing it. It is scaled correctly now, and it says so rather than rescaling in silence. A window passed for a palette file also warns instead of being dropped without a word: an index carries no ordering, so there is nothing to window.

And u_net(..., batch_normalization = FALSE) is held by a test for the first time, along with twenty other model settings. Nothing changes in what any of them does. What changed is that nine of the twenty fields the constructors forward to the engine could have stopped being forwarded with the whole suite still green - the value was assembled correctly and nothing asserted it - and six of those have an engine-side guard that cannot see a field which never arrives. One test now reads every constructor’s own arguments against the schema, so a field added later is either covered or named with a reason.

The CRAN entry is live, and README.md and this file said otherwise until today: install.packages("rplatypus") gives 0.2.1, published 2026-11-04, while remotes::install_github("maju116/rplatypus") gives the default branch, which is ahead of it between submissions.

2026-11-05 - normalise = TRUE now needs an encoder, and three defects through the pin (engine 0.8.0a66)

The pin moves to 0.8.0a66, the reviewer’s tenth pass, and one thing in it changes what this package accepts: normalise = TRUE without encoder is refused rather than quietly ignored, by the engine’s validator and with a message naming the field. The statistics it applies belong to a backbone, and the built-in encoder has none - so setting it without one asked for something that could not happen. It applies to all five constructors that take one: u_net(), linknet(), res_u_net(), u_net_plus_plus() and classifier(). It arrived a day earlier, in 0.8.0a65, accepted and doing nothing; nothing can depend on the old behaviour. normalise = FALSE without an encoder is still fine: it asks for nothing.

Two defects reach you through the pin and by nothing else. Weights from hf://owner/repo/file@commit can be used offline again - the weights = argument of any model constructor, beside the names available_weights() lists - when the repository publishes no description beside them - 0.8.0a65 had started refusing that, which took away the one route that form exists for, because the hub raises the same error for a file it cannot reach and for one it has already recorded as absent. And a PALETTE COLOR DICOM is read through its lookup table instead of as its palette indices: such a file used to come back as the index numbers, windowed, so a red-and-blue picture was black and white and a model trained on it learned from an ordering the file never claimed.

Nothing else here is behaviour. Six of the pass’s nine findings were guards for lines that had none, which is why this entry is short against a release of nine issues: the largest of them is that a model setting could be read out of your specification and dropped on the way to the network with every test still passing. Two of those lines were this package’s own - normalise reaching the request from each constructor - and they are held now. maju116/pyplatypus#384 to #392, and this side’s half is maju116/rplatypus#278.

2026-11-04 (late) - normalise, and a threshold that now cuts what it says it cuts (engine 0.8.0a65)

normalise is new on every model that takes an encoder - u_net(), linknet(), res_u_net(), u_net_plus_plus() and classifier(). It defaults to pretrained, so nothing changes unless you set it, and it exists for one case: reloading weights you exported from a pretrained encoder on a machine with no network. A pretrained backbone normalises its input with that backbone’s ImageNet statistics, and until now those existed only when the weights were downloaded - so reloading your own weights meant fetching ImageNet weights to overwrite them, and offline it could not be done at all. Set normalise = TRUE with pretrained = FALSE and the statistics come from the backbone’s configuration, which needs no download, while the weights stay yours.

predict() on a detector now drops boxes scored below score_threshold, which is what that argument always said it did. The engine was cutting on the network’s objectness while reporting each box at objectness x class probability - a smaller number - so a box scored 0.06 survived a threshold of 0.5. This entry also said “and one scored 0.396 was dropped”, which was wrong and is corrected rather than deleted (maju116/pyplatypus#390): 0.396 is below 0.5 and is dropped after the fix as well, so it was the control in the report rather than a second symptom. Nothing above the threshold was ever lost - the reported score never exceeds the objectness - so if you went looking for boxes in that position among your own results, there are none to find. Measured on the published bccd-yolo3 weights over their own 72 held-out images: 2,771 predictions become 2,537, and map_50, map_50_95 and mean_matched_iou are identical to six decimals, so no published figure moves. If you filtered the returned data frame yourself, you can stop; plot_boxes(min_score =) was already filtering again and is unaffected.

Two more arrive through the pin alone, both in the weights path. A description beside a published file that could not be reached - offline, or on a dropped connection with nothing cached - was being read as a file published without one, which skipped the colormap and class checks; it raises now. And a multi-frame DICOM whose header does not admit how many frames it holds is refused rather than read as a picture whose rows are the frames.

2026-11-04 (evening) - a warning about loading somebody else’s checkpoint (engine 0.8.0a64)

CVE-2026-24747 is in PyTorch, not in this package, and the part that reaches you is a warning. Below torch 2.10 the weights_only=True unpickler does not validate what it is reading, so a crafted checkpoint can corrupt memory and may reach code execution. Passing a .pth file to weights - a torch archive rather than safetensors - now warns, in R, naming the advisory and the version it was fixed in. Every set of weights this project publishes is safetensors, which has no such surface, so this is about files from elsewhere.

It is a warning rather than a refusal, and this package cannot do better than that for you. platypus_use_torch("pascal") exists because a GTX 10-series card needs a CUDA 12 build, and the engine can ask for a torch version without being able to ask for a build: py_require() takes no index argument. The last version whose default PyPI wheel serves that card is 2.7.x, which is inside the vulnerable range. Measured on such a card: torch==2.14.1 from https://download.pytorch.org/whl/cu126 is patched and works there, so the way out is to install that build yourself before the first run.

Also through the pin: a detection report method the engine no longer offers, which nothing in this package ever called.

2026-11-04 - the warning for a foreign engine now runs on the route every call takes (engine 0.8.0a63)

RETICULATE_PYTHON is the one route the pin cannot hold, and the warning written for it reached almost nobody. The comparison between the engine that answered and the version this package pins was attached to the module import, which this package touches in exactly one place - platypus_status(). Every other call into the engine, all 44 of them, goes through the Python shim instead, and that route never triggered it. So a session pointed at somebody else’s pyplatypus ran it without a word unless the user happened to ask for the status. The comparison now runs when the shim is first imported, once per session, which is the route every call takes. rplatypus#272.

Six fixes arrive through the pin alone, and the first two are in the detector’s scoring. evaluate()’s map_50_95 was computed from a single ranking built at the lowest IoU threshold, which stopped describing the higher ones the moment a difficult truth could absorb a prediction - so the figure was wrong on exactly the Pascal VOC data last week’s fix was about. map_50 was never affected, and neither was any published number: BCCD marks none of its objects difficult. The rule the matcher follows is also named correctly now - it is COCO’s, which is what map_50_95 is defined against, rather than voc_eval’s.

The other four are a reader being protected from a silent answer. Weights now refuse to load into a model whose activation differs from the one they were trained with, or whose encoder no longer normalises its input the way a pretrained one does: both change what the network computes while every tensor keeps its shape, so a published file used to load without a word and predict confidently. A description that fails to download beside its weights is no longer treated as weights published without one, which had skipped the colormap and class checks. A multi-frame DICOM file - an enhanced CT or MR, or a cine loop - is refused by name instead of being read as a picture whose rows are the frames. And read_record() can open a run that went to NaN: the engine was writing a token R’s JSON parser rejects, so the record of the one run worth inspecting was the one this package could not read.

2026-11-03 (evening) - a difficult object no longer counts against a detector (engine 0.8.0a62)

A model that FOUND a hard object scored below one that MISSED it. Pascal VOC marks some objects difficult and leaves them out of its own evaluation; the engine deleted them from the truth before matching, so a prediction that found one had nothing to match and was counted as a false positive. On two truths of one class with the second marked difficult, both detected and the hard one ranked first, evaluate() reported an average precision of 0.500 with one false positive, against 1.000 for a model that predicted only the ordinary object.

evaluate_images() gains an ignored column, and that is the part worth reading before a script breaks on it. A prediction attributed to a difficult truth belongs in neither of the two columns that were there: calling it matched would claim the model found something the benchmark left out, and calling it spurious would charge it for the same thing. So the counts now read n_predicted == matched + ignored + spurious, and the column is zero for any dataset that marks nothing difficult - which is every dataset this package ships an example for, BCCD included.

Two more fixes arrive through the pin alone, both in the LabelMe reader detection_data(annotation_format = "labelme") forwards to. A shape drawn with the point tool was refused as a malformed box rather than by name, so the message naming the tool to use instead was unreachable for the shape LabelMe actually writes. And a circle drawn against an edge gave a box reaching past the frame, which an augmented run clipped and an unaugmented one did not - one annotation, two different targets.

The engine: 0.8.0a62. rplatypus#270.

2026-11-03 (evening) - codetools declared, so --as-cran is clean again

R CMD check --as-cran reported a WARNING, and had done since 2026-10-29. tests/testthat/test-runnable-examples.R calls codetools::findGlobals() to walk a function’s call graph, and codetools was not in Suggests - it is a recommended package rather than a base one, so the check wants it declared:

* checking for unstated dependencies in 'tests' ... WARNING
'::' or ':::' import not declared from: 'codetools'

It matters because the default branch is the CRAN submission candidate and CRAN does not accept a WARNING. With codetools declared, the same run gives 1 WARNING -> 0, leaving the three NOTEs this project already expects.

Nothing caught it because none of the gates run before a push use --as-cran: the suite does not look at DESCRIPTION, and R CMD check --no-manual does not imply it.

And the guard stopped swallowing an error. The scan’s own helper turned a failure of codetools::findGlobals into character(), which reads as “this function calls nothing” - so a function the scan could not walk would have passed the check rather than failing it. (Named without a call here on purpose: it lives in the test file, so it is not a function a reader can reach, and the guard over this file’s prose is right to say so.) It now stops and names the function. Measured: the whole scan is unchanged over every help page, so nothing in this package was relying on the swallow.

The engine: none. rplatypus#268.

2026-11-03 - the volume rule label_map does not follow, and the pin (engine 0.8.0a61)

read_images(label_map = TRUE) on a volume returned the file’s values divided by the largest one, and @return promised the values themselves. maju116/rplatypus#266. The engine documents label_map as ignored for volume formats - a NIfTI or a DICOM series never went through the 8-bit conversion that argument exists to widen - and on that route the flag meaning these numbers are classes is nearest, which volumes._rescale says in as many words. Measured through the shim on a NIfTI holding 0, 1, 2 and 7:

nearest = FALSE, label_map = TRUE    0, 0.1429, 0.2857, 1.0     the classes over 7
nearest = TRUE,  label_map = TRUE    0, 1, 2, 7                 the file's own values

The page says so now, and read_images() warns when it is asked for that combination, because it is always a mistake: the caller has asked for the file’s values. Not a refusal, since nothing is read at that point and the function cannot know whether any of the paths is a volume. read_masks() already passed both flags, which is why nothing here ever warned.

The pin moves to 0.8.0a61 and this package needs nothing else from it. Six engine fixes, all defects an R user meets through the pin moving and by nothing else:

2026-11-03 - the pin for three defects an R user meets through it (engine 0.8.0a60)

The pin moves to 0.8.0a60 and this package needs nothing else, which is the state §3E is written for - with the distinction 0.8.0a47 records, because all three of the release’s fixes are defects an R user meets through the pin moving and by nothing else rather than features this package forwards.

The one worth knowing about is the detector’s augmentation. platypus_fit(num_workers = "auto") resolves to min(4, cores - 1), so on any multi-core machine an augmented detector trained from R ran with workers - and every worker drew the same augmentation sequence, because the engine’s worker hook asks the augmenter for a reseed by name and the box augmenter had none. A four-worker run therefore saw a quarter of the transforms it believed it saw, the BCCD recipe’s flips and rotations among them. Silent: nothing in the shape of a run says so. maju116/pyplatypus#351.

Two more arrive with it. A detection epoch’s reported loss - including the val_loss that callback_early_stopping(), callback_model_checkpoint() and callback_reduce_lr_on_plateau() watch - depended on batch_size whenever a grid was empty in some batch, so two runs at different batch sizes were not comparable (maju116/pyplatypus#352). And a mask stored as a palettised or a one-bit PNG, read through read_masks(labels = ), came back as the palette’s luminance or as 0 and 255 rather than the values the file holds - the shape Pascal VOC’s masks arrive in, and the one 0.8.0a59’s widening could not reach (maju116/pyplatypus#353).

read_images() already forwards label_map, so the third needs nothing here either: the fix is in the reader the shim calls.

2026-11-02 - a label map read at its own width, and a pair for the rule that reads it (engine 0.8.0a59)

The pin moves to 0.8.0a59, and R needs something from the release rather than only through it. maju116/rplatypus#262: read_masks(labels = ) crosses to the engine’s reader with channels = 1, and that reader converted every ordinary picture through 8-bit greyscale - so a 16-bit PNG or a 32-bit TIFF label map arrived with every value above 255 flattened to 255. Measured on the same file:

read as an image    0, 255              the two values a byte can hold
read as a label map 0, 255, 300, 1000   the values the file actually stores

With labels = c(0, 255, 1000) that made two bands one class and label_classes() reported nothing unmatched, so there was no symptom at all. read_images() takes label_map now and read_masks() passes it on the labels branch; the colormap branch is a picture and stays 8-bit, which is why this is an argument rather than the new default - an ordinary image is divided by 255 later.

And writing that turned up a second copy of a rule nothing was holding, which is maju116/rplatypus#263. label_classes() and the engine’s labels_to_classes both compare a label map’s values to within half a unit and both send a value no label claims to the first class, and nothing compared them: label_classes() is called in one place and no test in either suite mentioned the engine’s function. test-label-classes-pair.R is that pair now, on the arrays where the two could disagree rather than the ones any implementation agrees on - the tolerance’s own boundary, a value no label claims, a value two labels can both claim, and values wider than a byte. Three mutations of R’s half are caught by it and by nothing else: the tolerance narrowed, the fallback removed, and the loop reversed so the first matching label wins instead of the last.

What else 0.8.0a59 carries reaches R through the pin and by nothing else. A repeated key in a config_file CSV used to pass through as two samples with one name, so evaluate_cases() returned three rows for four images and predict(each = TRUE) returned one entry for two; the engine refuses it now, naming the keys, and points at evaluate_cases(group_by = ) for the pooling somebody repeating a key was reaching for. R sends the specification and the engine does the discovery, so there is nothing for this package to add.

2026-11-02 - the class names R was deriving, and the pin (engine 0.8.0a58)

The pin moves to 0.8.0a58, and this time R needs something from the release rather than only through it. maju116/rplatypus#259: inspect_masks() named a class when the specification used regions and returned the bare index for the other three namings - while named_classes’ own documentation said it mirrored the engine’s _describe_classes, which names a region, a classes_from pattern, a labels value and a colormap colour. So the claim was false for three namings out of four, and a classes_from user asking which class was missing got 1 where the engine’s own refusal for the same data says “class 1 (files matching ‘_organ\.png$’)” - which ?inspect_masks calls the one thing a reader cannot work out for themselves.

What the fix turned on is a distinction the finding did not have, and a measurement is what found it. Forwarding _describe_classes - “name it the way the engine does” - turned five of this package’s own assertions from "one, both" into "region 'one' (of [1]), region 'both' (of [1, 2])". That sentence is written for a refusal; it reads well in a message and badly in a column, and save_class_masks() writes files from these names. Two jobs, conflated.

So 0.8.0a58 adds SegmentationData.class_names - one short label per class, a region’s name or a classes_from pattern, and the index for labels and colormap where the number is the class - and this package forwards to it instead of deriving anything. The engine’s refusal keeps its sentence. §3A decides where the rule lives: the engine’s name wins.

And four engine fixes reach an R user through the pin moving and by nothing else. maju116/pyplatypus#341 is the one worth reading: seed promised that a run’s augmentation was repeatable and nothing seeded the augmentation pipeline, so two runs at one seed differed whenever a model augmented - deterministic = TRUE included. ?platypus_spec carries the same promise, which is now true. The other three: loss_boundary(region = loss_lovasz()) was accepted beside regions or classes_from where loss_lovasz() alone is refused; a callback_model_checkpoint() condition on a val_ quantity was accepted under validation = FALSE and died with a bare KeyError after one epoch; and a refusal quoted an unmatched-voxel share that the engine itself calls meaningless for those namings.

The field-prose ledger was regenerated: 116 fields, and the recorded engine version is the whole diff - no description moved, so no @param needed re-reading.

2026-10-29 (night) - the pin, for four defects that reach an R user through it (engine 0.8.0a57)

The pin moves to 0.8.0a57 and this package gains nothing else. None of that release’s four findings is a surface an R user calls, and all four are defects an R user meets through the pin moving and by nothing else - which is §3E’s distinction rather than its default, the same shape as 0.8.0a47 and a48. maju116/pyplatypus#333 to #336.

Two of them reach R through the order a user writes their callbacks in, and this package sends that order unchanged. callback_early_stopping(restore_best = TRUE) beside callback_ema() ended a run on the raw best epoch or on the average depending on which came first - 0.578 apart in the largest weight on one run with nothing but the order changed - while the record reported the EMA steps either way. And the epoch loop asked any() over a generator, which short-circuits, so a callback_csv_logger() written after a stopper lost the run’s final row, and callback_stop_after() written before callback_early_stopping() restored the second-best epoch rather than the best. The averager wins now, stated rather than inherited, and every callback sees every epoch.

The other two are narrower. callback_swa() resolved its start fraction by a different rounding rule from every other callback’s start, so the same fraction began an epoch later whenever the arithmetic landed on a half - at 10 epochs, start = 0.75 folded two epochs where the shared rule folds three. And the detector’s letterbox pasted the image at a rounded offset while the boxes used the exact one, so every letterboxed frame and its targets were up to a pixel apart; predict() on a detector returns boxes in each image’s own pixels, so the inverse carried that offset back out.

And the field-prose ledger found something, which is the third time it has. Regenerating it for the new pin reported restore_best’s description rewritten in the engine’s callbacks schema, and re-reading R’s own @param for it - the only thing that step is for - turned up that the sentence was now incomplete rather than wrong: it said nothing about callback_ema() superseding it, which is the decision 0.8.0a57 took. So ?callback_early_stopping says it now. 116 fields, and that one digest plus the recorded engine version are the whole diff.

2026-10-29 (evening) - the seam, and a class that went missing (engine 0.8.0a56)

Five findings from the reviewer’s second pass. Four are the same shape - this package answered a question the engine already answers, and the two disagreed - and the fifth is this package’s own arithmetic. No engine release: the pin stays at 0.8.0a56 because every fix is on this side.

predict() with type = "class" was refused for regions and computed for classes_from. maju116/rplatypus#252. Both namings make a pixel’s classes independent - the engine has one switch for it and its own documentation says everything downstream reads that rather than either field - and this package read regions. So a classes_from fit returned the most likely channel over sigmoids with no background channel, which gives every background pixel a class. Nothing downstream could see it: save_class_masks() detects an overlap from a probability array and a class map has none.

Measured on a 4-sample 32x32 fixture with two binary mask files each: the call returned a 4 x 32 x 32 array holding only 1 and 2, with a pixel outside both masks given class 2 on probabilities of 0.497-0.500 and 0.499-0.527. The control is the same pictures written as one label map with regions, which was refused. The refusal also names the naming the caller actually wrote now, where it said regions whatever had been given.

print() of a specification said classes : 0 for three of the four namings. maju116/rplatypus#256. It counted the colormap’s length, which only colormap fills, so labels, regions and classes_from all printed zero on the one summary a user prints first - and it reads as the classes were not understood. The count comes from the engine now: labels = c(0, 1, 2) prints 3, with voc_colormap’s 21 as the control.

A misspelt key in split was accepted here and refused by name in a YAML file. maju116/rplatypus#253. The block was rebuilt from fractions, group_by and seed and everything else dropped, so sed = 7 reached the engine as a seed of 0 - an unseeded run whose author believes it is seeded. Worse, $ on an R list matches a name by prefix, so seeds = 7 was read as seed and simply worked. Both are refused by the engine now, because unknown keys are forwarded rather than discarded, and [[ replaces $.

The two halves are separate claims and the forwarding one hides the other, which the mutation batch is what showed: with every key forwarded, putting $ back failed nothing. So the prefix is asserted against what this package sends rather than against the engine’s answer - and the first draft of that test used $ and reported the field as set, which is the defect demonstrating itself.

A fractional whole number was truncated in silence. maju116/rplatypus#254. as.integer(1.5) is 1, so labels = c(0, 1.5) became c(0, 1) and a colormap entry of 255.9 became 255 - a different valid value rather than a message, where the engine refuses a number with a fractional part by name. The comment above that coercion had always said it was there so nobody “gets a puzzling message from a library they never invoked”, which described the intention and not the code. The test is trunc, which segmentation_data() has always used for regions.

And one finding is not about the seam at all: mask_coverage() hid a class with no pixels. maju116/rplatypus#255. tabulate() sizes its table by the largest value it sees, so a declared class above that got no row - and ?mask_coverage’s own example says a class with 0 pixels “is the thing to look for”. With labels = c("bg", "liver", "tumour") a mask holding only 1 and 2 returned two rows; a mask holding 1 and 3 returned three, with liver at 0, which is the control that says the last row was the only one affected. The highest-numbered class is the usual position of a rare foreground, so the row that disappeared was the one worth reading. The count now comes from n_class if given, then from labels, and only then from the mask.

No field’s description moved, so the field-prose ledger is unchanged and no @param needed re-reading.

2026-10-29 - the pin, and the metric that path was built for (engine 0.8.0a56)

The pin moves to 0.8.0a56, which is a defect reaching an R user and nothing else. maju116/rplatypus#247: evaluate() and evaluate_cases() with at = "source" raised a ValueError for cldice and surface_dice, naming neither the metric nor at - and ?evaluate calls that route “the answer to that warning”. The whole fix is the engine’s, so this is §3E’s None beyond the pin in the version that costs somebody something, the same shape as 0.8.0a47 and a48.

Measured on the engine’s side before the release: four 64x64 cases with [dice] returned a number on both grids while [dice, cldice] raised on at = "source" from both functions, with the dice-only row as the control. After: cldice 0.28 on the model grid and 0.4839 at source, which is two grids giving two numbers - what the route exists for.

It is a56 rather than a55 because a55 carried a regression this package found. 0.8.0a55 made the engine’s IoU matrix index the object numbers present rather than 1..max, and the engine’s own agreement overlay still mapped a row back with row + 1 - so an agreement figure over a label map with a gap coloured the wrong objects. What caught it is test-instance-drawing.R, which compares overlay_instance_agreement()’s output with this package’s own pixel for pixel and carries a fixture named gapped, whose comment says that taking seq_len(max(labels)) instead of the values present changes nothing - true here, and untrue there from that release until a56.

So this package held the correct copy of that rule the whole time: matched_objects() has always taken sort(unique(...)) of each side. That is the second time the engine was the wrong copy and the first time in code rather than prose, and the pair is what reported it.

The field-prose ledger was regenerated for the new pin and the diff is two lines, both the recorded engine version: no field’s description moved, so no @param needed re-reading.

2026-10-29 - the review’s R half: two guards that described more than they held

The review agent’s first audit produced nine findings and three of them are R’s. maju116/rplatypus#248 and #249 are here; #247 waits on the engine release that carries its fix. The whole audit is in the workbench’s REVIEWER_FINDINGS.md, and each finding was reproduced independently before it was filed.

Fixed

2026-10-28 - a split nobody labelled, and the one shape that can say so (engine 0.8.0a53)

The pin moves to 0.8.0a53, one move for two releases. 0.8.0a52 made an unlabelled classification test split reachable - a CSV of key, images, which is the ordinary shape of a prediction set - and this package needed a representation for it. a53 is a defect an R user meets through predict(fit, split = "train") and whose fix arrives by the pin moving and by nothing else, so the ratchet carries both rather than moving twice. maju116/rplatypus#245, maju116/pyplatypus#320 and #321.

Fixed

Changed

2026-10-28 - a label that is a set, from R (engine 0.8.0a51)

The pin moves to 0.8.0a51, one move for two releases. 0.8.0a50 shipped labels_from and this package needed it; a51 fixed two defects this package needed nothing from, so the ratchet carries it along rather than moving twice. maju116/rplatypus#241.

Added

Fixed

Documentation

2026-10-28 - nothing is inferred from silence (engine 0.8.0a49, the pin does not move)

No engine change and no pin move. platypus_spec() accepts a plain list of fields in data as well as one of the four constructors, and that route could describe one task out of four. maju116/rplatypus#239.

Fixed

Documentation

2026-10-28 - one label per image, from R (engine 0.8.0a49)

The pin moves to 0.8.0a49, and this is the first release this package could not take by moving the pin alone. platypus_spec() sends a YAML file or a bare data list straight to the engine’s validator, so a pin at 0.8.0a49 with no constructors would have accepted task: classification from a file and refused it from classification_data() - one configuration with two answers, which is the divergence recorded for regions in 0.8.0a28. So the pin and the surface arrive together, and a test holds the two paths to the same specification.

classification_data() and classifier() are the two new constructors. classification_data() is as much the fields it has not got as the two it adds: colormap, labels, regions, classes_from, channels_from and target_spacing all describe a mask or a grid, and a label is neither. classifier() is the one model constructor in this package not named after an architecture - a classifier has nothing after the pooling but a dense layer, so the contracting path is the architecture and encoder is the whole answer.

Four metrics of their own, and the default is measured rather than chosen. metric_auc(), metric_accuracy(), metric_balanced_accuracy() and metric_f1() share no name with the mask metrics. auc is the engine’s default because at a 3% prevalence a calibrated model never crosses the threshold: everything read off the hard prediction sees the same all-negative answer, so accuracy spans 0.0100 across models whose AUC spans 0.4631 and is not even monotone in their quality. class_balance() is the function that makes that prevalence visible before a run rather than after it, and it prints the engine’s own remark instead of a copy of its threshold.

predict() answers per image and is joinable. type = "class" gives a row per image with key, label, predicted and probability; type = "probability" gives a matrix of images by classes with the keys as row names. Named rather than ordered, which is the lesson predict(space = "source") cost on the semantic side: a prediction handed back as a bare sequence has to be paired with whatever else the caller holds by position.

What is refused, by name, is as much of this as what is returned. space and the cases/each pair describe a grid and a split of masks too large to hold at once, and neither means anything for one label per image. evaluate(at = ) is refused for the same reason. evaluate_cases() is refused for a different one: a case here is one image whose score is 0 or 1, so a split’s worst case is 0 for any model that gets a single image wrong - a number that carries nothing while reading like one that does.

Two defects found by doing this rather than by any test. The shim dropped the check_masks option for a detection specification by comparing the task against "detection", where the value is "object_detection" - so the branch never fired, the option reached an engine that has no such argument, and the TypeError was reported as this engine predates check_masks about an engine that is current. And predict()’s decode guard sat below the task branches, so a detection fit - the one branch that returns before reaching it - accepted decode and silently ignored it. Both are fixed here, because classification is the second task in the first position and would have inherited both.

?metrics also gains the prose it never had: it was A list of metrics, see [metrics]. against seven hundred characters in the engine, and the sentence it was missing is a mechanism - a callback watching val_<metric> is refused when nothing in the list will produce it.

2026-10-26 - every object’s shape, not just the whole mask’s (engine 0.8.0a46)

The pin moves to 0.8.0a46, and what it brings is a measurement the engine could not make per object. ellipse_measure() fits an ellipse to a whole mask, which is what a fetal head circumference needs and not what a field of nuclei needs. The engine now has instance_shapes(): one row per object out of one label map, beside the rows instance_sizes() already gives, with the semi-axes largest first and - for an image - eccentricity, which is the number shape analysis reports.

instance_shapes() is on this side too, and it forwards - the way ellipse_measure() does and unlike instance_sizes(), which stays R’s own arithmetic. That split is not a style choice: a count times a product needs no Python and a reader can verify it by eye, while an eigendecomposition per object cannot be reimplemented here without becoming a second implementation. The visible consequence is that this page’s example is wrapped in \dontrun{} and instance_sizes()’s is not, because R CMD check runs examples and CRAN has no interpreter.

It is per object in one pass, which is the whole reason it exists rather than being a line in the documentation: calling ellipse_measure() on labels == k once per object rescans the whole array every time, and on a 512 by 512 map of 1939 objects that is 1989 ms against 143.

One thing to know before calling it through the bridge, because it is the trap and not a detail. eccentricity is unitless and it is not spacing-free: it describes whichever space it was computed in, so on anisotropic voxels the grid and the millimetres disagree completely, in both directions.

a circle of 80 px at spacing (2, 1)       grid 0.0000    mm 0.8660
an 80 by 40 px ellipse at spacing (1, 2)  grid 0.8667    mm 0.0685

The second row is physically almost a circle and the grid calls it strongly elongated. The engine says which space a row is in through the key names - major_px and minor_px without a spacing, major_mm and minor_mm with one - so pass your voxel size and read the _mm keys unless you mean the grid. platypus_device() is unaffected and so is everything else: this release adds a function and changes none.

A volume gets three axes and no eccentricity, because an ellipsoid has no single one.

2026-10-26 - what an image reader is allowed to decode, and Python 3.11 (engine 0.8.0a44)

A file is now read as a picture only if its contents are PNG, JPEG, TIFF, BMP or GIF. Nothing about how you name your files changes, and that is the point: the engine’s reader identified a file by its contents and was handed nothing but a path, so a file carrying any other format - under any extension - went to whichever decoder its bytes asked for. One of those decoders hands the file to Ghostscript. A dataset of ordinary medical images is unaffected; a path holding something else is refused by name now, with a message saying that the extension was not what decided.

segmentation_data(), instance_data() and detection_data() all reach that reader, and so does anything that asks for a shape without reading pixels. Volumes, DICOM and DICOM series never went through it and are untouched.

This package now needs Python 3.11 or newer, and SystemRequirements says so. Python 3.10 reached end of life on 2026-10-01. For almost everybody this changes nothing: the package declares what it needs through reticulate and uv provisions the interpreter, so it simply provisions a newer one.

The exception is worth knowing before it happens. If you point RETICULATE_PYTHON at an interpreter of your own - the route this package warns about when the pyplatypus it finds there is not the pinned one, naming both versions - and that interpreter is 3.10, the engine can no longer be installed into it, and what you will see is a resolution failure from uv rather than anything this package wrote. The fix is a 3.11 interpreter, or letting py_require() choose one. platypus_device() is what reports the environment you ended up with.

Why the engine moved: its pillow floor was >= 10.0 while the decoder fixes it needs arrived in pillow 13, and pillow 13 requires Python 3.11 - so the floor could not be raised without dropping 3.10. Measured rather than assumed, nothing in the dependency tree moves forward with it: thirteen locked entries differ and every one is the removal of an old-Python branch.

A Python 3.10 user of the engine directly is served 0.8.0a43 by any installer rather than an error, which is the migration path and asks nothing of them. This package pins 0.8.0a44.

2026-10-26 - a detection epoch stops weighting batches alike (engine 0.8.0a42)

The numbers plot(fit) draws for a detection run change, and nothing you wrote changes with them. The engine averaged each epoch over batches, and for a detector that is not an average over examples: every component of the YOLO loss is divided inside the loss - three of them by a grid’s object count and the fourth by the cells it supervises. A frame holding thirty blood cells therefore counted the same as a frame holding two.

An epoch is now pooled by what the loss divided by. maju116/rplatypus#218.

What this means for a run you already have:

It was measured before it was changed, on 150 instrumented epochs of the engine’s own BCCD example: both poolings pick the same epoch, and that is reported as too close to call rather than as no effect - the gap between them varies across the best epochs by 1.05 times the margin separating the winner from the runner-up. The change is made because weighting a per-object quantity by batches is wrong, not because an epoch visibly moved.

2026-10-25 - a fifth set of published weights, measured in millimetres (engine 0.8.0a41)

available_weights() lists hc18-unet. A U-Net for the fetal skull in 2D ultrasound, trained on HC18 and published for measuring head circumference in millimetres rather than for an overlap score. Nothing in this package changed but the pin: the registry lives in the engine, so a new published name arrives in R through it.

available_weights()[["hc18-unet"]]
median absolute error     2.44 mm   against the sonographer's measurement, 199 images
mean                      3.84 mm   quoted beside the median because they disagree
worst                    48.07 mm
against the truth mask    3.41 mm   the model's own contribution, not the drawing
Dice                       0.9494   and the description says not to choose on it

Read that last line rather than the Dice. On this run the Dice and the millimetres order the same cases differently - Spearman 0.153 - and 85 of the 144 cases scoring 0.95 or better are still wrong by more than 2 mm. A ragged boundary barely moves an integral over a region and a mask that is systematically fat or thin moves it linearly, which are the two errors Dice is respectively most and least able to see, and they are the wrong way round for anybody measuring a length. These are the first published weights in this family where the score a reader reaches for first is the wrong one.

They are CC BY 4.0 rather than MIT, because HC18 is - the second set in the registry that is not MIT, after fives-unet, and attribution travels with them. The sidecar carries the citation and weights_licence.

Measuring with them from R is ellipse_measure(), which arrived in 0.8.0a39:

ellipse_measure(mask, spacing = c(px, px))

Predict on the source grid and measure there. The pixel size is a property of the file, so a prediction resized to the model’s input shape has pixels of a size nobody recorded.

2026-10-25 - batch_size leaves the reported score (engine 0.8.0a40)

evaluate()’s figure no longer depends on batch_size. The engine divided an epoch’s totals by the number of batches, so the examples of a short last batch counted more than the others and a split’s reported score moved with a training setting. Measured on 199 validation cases: 2.3e-04 at batch_size 4 and 3.5e-03 at 32, and the size of the gap is not even predictable from the setting, because it depends on which cases land in the short batch.

It is a repair rather than a change of definition, which is why the numbers move at the fourth decimal rather than being restated: the per-batch value was already the mean over the examples in that batch, so a mean of means was reaching for the mean over examples and mis-weighting one batch of it.

The larger half is for tiled runs. evaluate() now pools a case’s tiles by adding their overlap counts before scoring, the way summarise_cases() already did - so the table’s figure is the mean over images rather than the mean over tiles. On the retinal-vessel runs that is worth +0.033 to +0.049 of Dice, and it is why the table in vignette("retinal-vessels") needed no editing: every number in it was computed from the per-case route already, and this brings the one-row table into agreement with it.

The figure is now exactly the mean of evaluate_cases()’s own rows, which is what anybody comparing the two expected and did not get.

Nothing here needs editing in a script. One consequence worth knowing: best_model() ranks on this table, so where two models sat within the shift of each other the winner can change - on the eleven-loss comparison the winner is unchanged and its margin grows from +0.0006 to +0.0033.

And a defect this did not fix, filed rather than folded in: the detection trainer divides by batches too, but every component of the YOLO loss is normalised by the number of objects in the batch, so averaging over batches weights a frame of thirty cells like a frame of two platelets. That is a different repair and it has its own issue, #218. No published detection number comes through it - the AP table is scored per image.

#196.

2026-10-25 - a circumference in millimetres, and where the line between the halves falls (engine 0.8.0a39)

[ellipse_measure()] is new: the ellipse a segmented structure implies, as major, minor and circumference. It is what a sonographer writes down, and no overlap score stands in for it - two masks with the same Dice can be eight times apart in millimetres depending on whether they are wrong in a ring all the way round or wrong in a ragged edge.

class is an argument rather than an assumption, and it is the whole reason this is not a straight forward: the engine reads a foreground as anything above zero, which is right for its own argmax masks and silently wrong for this package’s, where which.max writes 1 for background. Read that way the measured ellipse becomes the whole frame - 459.8 mm against 158.8 on the example in ?ellipse_measure - so mask == class happens here, before anything crosses.

And a decision worth more than the function: [mask_volume()] and [instance_sizes()] stay in R. The engine’s 0.8.0a39 gained both, so forwarding them was the obvious move; it was written, measured and reverted.

It is faster - 0.035 s against 0.341 s on a 512 x 512 x 300 label map, because sum(mask == class) builds a logical vector of 78 million elements first - and what settled it was not speed. The engine’s first call downloads about 5 GB, so a user holding a mask and a spacing would pay a torch install for a count times a product. R CMD check also runs examples and CRAN has no Python, so forwarding would have put mask_volume()’s page into \dontrun{}, on arithmetic a reader can verify by eye.

So the rule drawn, and both halves of this entry are on opposite sides of it: a measurement that is arithmetic stays here; a measurement that needs a library crosses. The ellipse wants connected components, hole filling and an eigendecomposition - reimplementing those would be a second implementation rather than a second copy of two lines.

tests/testthat/test-measure-pair.R holds both: R’s answers against the engine’s own on the same arrays, with a positive control, and a check that the functions with runnable examples do not reach the bridge - asked of their bodies, because calling them on a machine that has the engine proves nothing, which is exactly how the reverted version passed its gates.

One new condition kind travels with the pin: platypus_measure_error, for a spacing that does not fit the array it was given. ?platypus_conditions lists it, and nothing here noticed it was needed - test-conditions.R failed the moment the engine defined the kind, and named it.

maju116/pyplatypus#270.

2026-10-25 - two things a session can settle once, instead of every time

The PyTorch build can be chosen once. options(rplatypus.torch = "pascal") in ~/.Rprofile, or PLATYPUS_TORCH=pascal in ~/.Renviron for runs that never load one - Rscript, a scheduler, CI. Both are read when the package loads, which is the last moment anything can change what the environment will be.

Until now the only way was to call [platypus_use_torch()] at the start of every session, because py_require() is session state. On a card older than Turing, forgetting it costs about ten times the training time without costing an error - the run completes, the numbers are right, and the card sits idle. That is the kind of bill that goes unnoticed.

The option wins over the variable, so a session can override what a machine was configured with; "default" turns the setting off for one session without editing the file it came from; and a value neither recognises is reported at load and ignored, because a bad line in a startup file should not make the package unloadable.

An engine that is not the pinned one now says so. The pin is what keeps an R release from breaking when the engine moves underneath it, and on one route it was never in force: a RETICULATE_PYTHON pointing at somebody’s own environment brings whatever pyplatypus is in it, and nothing compared the two. The failure arrived as a validator refusing a configuration field by name, which tells a reader nothing about their interpreter.

A warning rather than a refusal, naming both versions: the only way to reach this state is to have gone out of one’s way, and refusing would also break somebody deliberately testing an engine they built. PLATYPUS_ENGINE_PATH is exempt - that route asks for a source tree with no version at all.

maju116/rplatypus#211, #214. No engine release: both are about what an R session settles before Python starts.

2026-10-24 - the device report becomes the engine’s, and names a backend that is not CUDA (engine 0.8.0a38)

platypus_device() behaves as it did, and the answer now comes from the engine rather than from this package. The report used to be computed in inst/python/platypus_shim.py - a file of Python shipped by this package - so a Python user could not reach any of it, which is backwards: the engine owns the decisions and this package reads them.

What stays here is the advice. The line telling you to call platypus_use_torch("pascal") is about R’s session model and would mean nothing to somebody who installed the engine with pip; their version of that sentence belongs with the engine.

An accelerator that is not CUDA is now named. Until the engine’s 0.8.0a38 it asked about CUDA alone, so an Apple Silicon Mac - every Mac sold since 2020 - reported cpu while a working mps backend sat in the same installation. platypus_device() prints the backend only when it is not CUDA, because every published set of weights was trained on a CUDA card and a score from another backend is not directly comparable with the registry’s: the unremarkable case is not worth a line, and that one is.

maju116/pyplatypus#265 and #266.

2026-10-24 - a weight average fed per step (engine 0.8.0a37)

callback_ema() keeps an exponential moving average of the weights, updated after every optimiser step. That is what makes its decay mean what the literature means: 0.999 is 0.999 per step, so over 60 epochs of 200 batches it is 12,000 updates and a half-life of about 700 steps. A per-epoch version would have been the same name with values that do not transfer, which is why the engine gained a fourth callback hook rather than an approximation.

It is [callback_swa()]’s sibling and a run takes one of them: both replace the model when training ends, so the engine refuses the pair rather than applying them in whichever order the list happened to have. swa folds the tail uniformly from a declared start; ema weights recent steps more and needs no start at all.

Unlike swa it needs no pass over the training data afterwards. A uniform average of weights carries batch-normalisation statistics belonging to no epoch and has to have them recomputed; an exponential average of the buffers alongside the weights carries its own.

maju116/rplatypus#203, engine half maju116/pyplatypus#264.

2026-10-24 - the worst case becomes something a callback can watch (engine 0.8.0a36)

training_history() carries val_<metric>_worst: the lowest any single case scored, for the metrics whose counts pool into one score. monitor = "val_dice_worst" works, and so does a when condition on it.

This package has told every reader to look at that number since it had a summary() method - a model averaging 0.86 that scores 0.11 on some patient has a failure mode, and the mean is where it hides - and then every callback it offered watched a mean. Stopping, checkpointing and cutting the learning rate can all act on the distribution now.

Validation only. The training data is shuffled between epochs, so a case has no identity in that pass; "train_dice_worst" is refused rather than quietly reported as something else.

It costs 5.6% of an epoch, measured on the engine’s side: the counts are computed in the validation pass that already happened rather than in a second one.

maju116/rplatypus#205, engine half maju116/pyplatypus#263.

2026-10-24 - the quiet failure of segmentation becomes a number (engine 0.8.0a35)

Every epoch now reports foreground for both passes - the share of the prediction that is not background. Nothing is compared against the truth, so it is not a measure of quality: it answers whether the model is still producing a segmentation at all.

It arrives whether or not you ask for it, and that is the point. A model that collapses to predicting background everywhere is invisible in the numbers it already produced - with include_background = TRUE the Dice is a mean over class channels and the background channel is nearly perfect, so a collapsed model reports around 0.5 while its loss falls. A collapse you have to opt into noticing is one you will not notice.

callback_terminate_on_collapse() ends the run once that share has stayed at or below floor for patience epochs. A floor rather than a test for zero, because 0.01% foreground against a target that is 14% foreground has collapsed in every sense that matters; and a patience, because an early epoch legitimately predicts almost nothing. It can watch train_foreground instead, which answers a different question.

evaluate() carries the column, callback_model_checkpoint()’s when can take a condition on it, and any callback’s monitor can watch it.

maju116/rplatypus#206, engine half maju116/pyplatypus#262.

2026-10-24 - when a callback may act, and on what evidence (engine 0.8.0a34)

Three things that answer one question, which is why they arrive together rather than as three unrelated arguments.

callback_early_stopping() takes a warm-up: start as a fraction of the run or start_epoch as an epoch, one of the two and never both. patience could not express it, because it is in force for the whole run - a model needing fifteen epochs before its validation curve means anything forced patience > 15 permanently, paying that slack at the end where the point was to cut quickly. It delays the stopping and nothing else: an epoch inside the warm-up can still be the best one, and restore_best still returns to it.

callback_swa() takes start_epoch as the exclusive alternative to its fraction - the same concept, so the same two words.

callback_model_checkpoint() takes when, a named list of thresholds that must all hold before anything is written: list(val_dice = 0.9, val_loss = 0.2). The direction is derived, not configured - anything ending in loss has to be below its threshold and everything else above it, the rule monitor already uses - so there is no argument to get backwards. The names are checked against what the model reports when the specification is built. It is an extra gate beside save_best_only, not a replacement, and a run whose conditions never hold writes nothing and says so when it ends.

callback_stop_after(hours = ) is a wall-clock budget. An epoch cap cannot express it: how long an epoch takes is a property of the data and the shape. It is checked at the end of an epoch, so the real limit is the budget plus one epoch, and the run’s stop reason names the clock - a run that ran out of time does not read as one that converged.

maju116/rplatypus#199, #201 and #207; engine half maju116/pyplatypus#261.

2026-10-24 - two more learning-rate schedules, and one pairing that never worked (engine 0.8.0a33)

callback_linear_annealing() is callback_cosine_annealing() with a straight line. callback_step_decay() multiplies the rate by factor at fixed points, which is how published recipes are written - divide by ten at epochs 30 and 60.

callback_step_decay() takes every or milestones, never both and never neither, and says so from R before the call reaches the engine. Two arguments rather than one that accepts either, because 30 and c(30) are different schedules and one argument holding either would be read as whichever was typed.

And a pairing this package has implied for as long as it has had a cosine does not work. A schedule recomputes the rate from the optimiser’s initial value at the end of every epoch, so it erases whatever callback_reduce_lr_on_plateau() cut. Measured on six epochs of a loss that never improves, the rate with both configured follows the cosine exactly - 0.0976, 0.0905, 0.0794 from 0.1 - where the plateau alone reaches 1e-6. The engine refuses the pair now, by name, and so two schedules at once are refused as well.

A schedule beside callback_swa() is still fine, and no longer a matter of luck: the engine fixes the order so swa’s constant tail rate wins whatever position the callbacks were written in.

maju116/rplatypus#200, engine half maju116/pyplatypus#260.

2026-10-24 - a comparison across shapes stops being rigged (engine 0.8.0a32)

evaluate() and evaluate_cases() take at. Unset is what they have always done - each model scored on the grid it reads at. at = "source" puts every prediction back onto the grid of the file it came from and scores it against the truth as stored, so rows from models of different shapes answer one question and load_shape reads source.

This is the answer to a warning this package has printed since the engine’s 0.8.0a30 and to a paragraph in vignette("retinal-vessels") that said there was no option for it. There was not: the measurement behind that section rescored its arms with a script that lived outside both packages. Measured there, scoring each model on its own grid reported a resized model at +0.0187 where the honest figure was +0.0033 - 5.7 times - and the bias runs the other way on clDice, because upsampling smooths a prediction.

The grid is the files’ own and you cannot name another. A grid you name resamples the truth, so the thing both models are judged against would itself be an interpolation somebody chose. Only predictions move.

What it costs is what predict(space = "source") already documents: interpolation softens a boundary, and where reading cropped anatomy away the mask comes back padded with background - not examined rather than nothing there. For a comparison that is correct rather than unfair, but a 0 arriving that way is a result and not a fault. The loss is not reported on that grid, because a loss belongs to the objective a model trained with on the grid it trained on.

A detection fit refuses at by name rather than ignoring it: boxes are in the source image’s coordinates already, so there is no grid to choose. The refusal is R’s own and needs no engine, which is how its test runs on a machine with no Python.

maju116/rplatypus#187, engine half maju116/pyplatypus#259. The pooling question that came out of building it is maju116/rplatypus#196.

2026-10-22 - what CRAN’s review of 0.2.0 asked for, forward-ported

0.2.1 answered the review on its own branch, from the commit the submitted tarball was built from, because sending main would have replied to two documentation comments with ten new exported functions. The same two fixes belong here as well, or 0.3.0 collects the same review.

Description writes 'PyTorch' and 'Python' in single quotes, as the policy asks of software names in Title and Description. R stays unquoted: the cookbook the review links does not address it, and its own Title Case example is Web Application Framework for R.

colormaps.Rd gained the \value tag it never had. It documents four exported objects rather than functions, so it carried \format alone; the tag is written in the roxygen source and states structure and meaning, not just class.

Worth keeping because it is a property of the review rather than of this release, and the first version of this paragraph understated it. R CMD check --as-cran with the manual, on R 4.5.3, does not flag a missing \value - checking Rd contents says OK with the tag absent - and neither does tools:::checkRdContents(). That was written as a fact about this machine. It is a fact about both sides: CRAN’s own automated pretest did not flag it either. Measured on the two logs, which are byte-comparable because the only difference between the submissions is this tag and two quoted names:

rplatypus 0.2.0, the version sent back    Windows 1 NOTE, Debian 1 NOTE
rplatypus 0.2.1, with the tag             Windows 1 NOTE, Debian 1 NOTE
mentions of `value` or `colormaps`        0 in all four logs

So the two submissions are indistinguishable to every check that runs, and the difference between them is exactly what the reviewer asked for. A clean pretest says nothing about whether the review will pass - a human read the .Rd files. The instrument that does ask the question is a check of our own: a page with \usage must have \value.

2026-10-22 - platypus_fact(), and a platypus fact when the package is attached

The pin does not move and the engine needs no release: this is the first thing in the package that has nothing to do with segmentation. The family is named after the animal, and platypus_fact() says something true about it. One fact is also printed when the package is attached in an interactive session; options(rplatypus.facts = FALSE), or PLATYPUS_FACTS=0 in ~/.Renviron, stops that.

Three things about it are deliberate, and each one is here because of what it prevents.

Choosing a fact does not touch the random number stream. sample() advances .Random.seed, so a fact picked at attach time changes what the user’s own set.seed(1) then produces - measured, runif(1) goes from 0.2655087 to 0.3721239 - and in a package offering seeded splits and deterministic = TRUE that is the worst available place for it. suppressPackageStartupMessages() is not a defence: the arguments are evaluated before the condition is signalled, so suppressing the message keeps the side effect and removes the evidence. The seed is saved and put back instead, and the test for it carries the naive implementation as its positive control.

Interactive sessions only, so a fact never lands in an R CMD check log, a CI run, a knitr render or anybody’s reverse-dependency check. And .onAttach rather than .onLoad, which is where this package’s other startup work lives: .onLoad also runs for rplatypus::fun(), which would print a platypus fact into the middle of a script that never called library().

Every fact carries the source it was read from, and the numbering is stable. Four of the twenty-five exist to correct a popular claim rather than to state one - the platypus does have a stomach, it does not grind food with gravel, “platypi” is not a plural and there is no accepted word for a baby platypus - which is the only form this could take in a package whose argument is that numbers should be measured rather than felt. A test holds every entry to having a source. The numbers are published the moment somebody quotes one, so facts are appended and never reordered, and a ledger of each fact’s opening in tests/testthat/test-facts.R fails if one moves.

tools/reference-groups.yml gains a group, The animal, last in the order. The matching line went into pyplatypus/tests/test_reference_groups.py at the same time: the shared order is an agreement about where a group goes rather than a list both sides fill, so the engine carries the title with no contents, as this package does for Records.

2026-10-16 - classes_from: one binary mask file per class

Needs pyplatypus 0.8.0a28, and the pin moves with this. maju116/rplatypus#180, whose engine half is maju116/pyplatypus#91 - an issue open since 2022, and this is the half the author’s decision of 2026-10-12 deferred rather than dropped.

Added

Fixed

Documented

2026-10-16 - the pin moves again, and a sentence on ?metrics was true of no release

Needs pyplatypus 0.8.0a27, and the pin moves with this. Nothing in this package gains a function: the engine fixed a defect in the release it shipped metric_surface_dice() in, and what this package owes is one corrected sentence and the test that would have caught it.

Fixed

2026-10-16 - how far wrong the boundary is, and a loss that weights the rare class

Needs pyplatypus 0.8.0a26, and the pin moves with this.

Added

Changed

2026-10-15 - the instance surface has a document that runs it

Needs pyplatypus 0.8.0a25, which is already the pin.

Added

Fixed

2026-10-15 - a fourth set of published weights, and a score that needs its decoding

Needs pyplatypus 0.8.0a25, which is what the pin moves to here.

Added

Changed

2026-10-15 - the two instance axes can be asked about

Needs pyplatypus 0.8.0a24, which is already the pin.

Added

Changed

2026-10-15 - the wrap past the end of the palette is reported

Needs pyplatypus 0.8.0a24.

Changed

2026-10-15 - instances come back into R

Added

Fixed

Measured

2026-10-15 - drawing instances

Added

Measured

Fixed

2026-10-14 - regions from R: overlapping classes

Added

Fixed

[0.2.0] - submitted to CRAN on 2026-10-14

A different package from 0.1.1 under the same name, and the section below says how different.

Migrating from 0.1.1

This was in the README until 2026-10-07 - seventy lines between ## Installing and the first example, about a version the reader does not have, before the one they do. It is here because this file is where R keeps what changed between versions, and because the renames table below was a second copy of one the README already carried; the fuller of the two survived.

Code written against the 2020 release will not run on this one. That version built Keras models through the R keras package and handed them back to you; this one describes a whole run as a specification and computes it in PyTorch. Of the 37 functions and objects the old version exported, 30 are gone — including all of yolo3(), darknet53(), load_darknet_weights(), get_boxes(), non_max_suppression(), plot_boxes(), segmentation_generator() and the annotation readers.

Seven names survive, and three of them are the ones to watch, because they are still exported, still callable, and mean something different:

name 0.1.1 now
u_net() u_net(net_h, net_w, grayscale, ...) → a Keras model u_net(name, input_shape, ...) → one model in a specification
plot_masks() plot_masks(images_paths, masks, labels, colormap) plot_masks(images, prediction, truth, ...)
loss_dice() no arguments, returns a Keras loss loss_dice(smooth = 1), returns a loss to name in a specification

The other four — binary_colormap, voc_colormap, binary_labels, voc_labels — are the same data as before.

Object detection is back, and the names are different. The old package handed you a Keras YOLOv3; this one describes a detector in a specification like everything else. So those five names are gone rather than moved, and what replaces them is:

0.1.1 now
yolo3(), darknet53() yolo3() as one entry in platypus_spec() - same name, a specification rather than a model
load_darknet_weights() weights = "bccd-yolo3", or a path, or hf://owner/repo/file@commit
get_boxes(), non_max_suppression() done inside predict(), which returns boxes already suppressed and in each image’s own pixels
plot_boxes() plot_boxes() - same name, takes what predict() returns

The annotation readers are gone as functions and are now what detection_data() reads for you: Pascal VOC XML or LabelMe JSON, named rather than guessed.

The old release is still there and still installable, pinned:

remotes::install_github("maju116/rplatypus@0.1.1")

Its source is on the master branch, and the issues filed against it stay open. What has aged is its dependencies rather than its code: the pinned TensorFlow no longer installs on a current Python, which is the reason for a rewrite rather than a consequence of one.

2026-10-14 - the package is rplatypus

Changed

2026-10-14 - engine 0.8.0a22

This file has a gap and it is named rather than backfilled. The entry below is dated 2026-10-08 and the pin moved twice between then and here - to 0.8.0a12 for fives-unet (#151) and to 0.8.0a20 for the instance task (#155) - so the dates above this heading are not a complete record of what the pin did. Writing those entries now would be writing them from the commit log rather than from the work, which is a different thing from having written them; the heading says what this change is and the omission stays visible.

Changed

Fixed

2026-10-08 - engine 0.8.0a2

Added

Changed

2026-10-08 - engine 0.8.0a1

Added

Changed

2026-10-07 - engine 0.6.0a2, then 0.6.0a4, then 0.7.0a1

Changed, and it breaks code

Added

Fixed

2026-10-07 - the documentation site

2026-10-06 - engine 0.5.0a1 through 0.6.0a1

Added

Fixed

2026-10-05 - engine 0.3.0a15, then 0.4.0a1

2026-10-04 - engine 0.3.0a12, then 0.3.0a13

2026-10-03 - engine 0.3.0a10, then 0.3.0a11

2026-09-27 - engine 0.3.0a6, then 0.3.0a8

2026-09-26 - engine 0.2.0a2 through 0.3.0a5

The day the package stopped being a bridge and became a surface. Five engine releases, each followed here.

2026-09-25 - engine 0.2.0a1

The package exists.

[0.1.1] - 2020-10-17

The last release of the TensorFlow package, kept here for the record rather than as an ancestor of the entries above. It built Keras models through the R keras package: yolo3() and darknet53() with load_darknet_weights() for the original Darknet weights, a plain u_net(), segmentation_generator(), loss_dice(), the COCO and VOC colormaps, and readers for LabelMe JSON and Pascal VOC XML.

It is still installable and still pinned:

remotes::install_github("maju116/rplatypus@0.1.1")

Its source is on the master branch and the issues filed against it stay open. What has aged is its dependencies rather than its code - the pinned TensorFlow no longer installs on a current Python, which is the reason for a rewrite rather than a consequence of one.