Skip to content

Commit 4f359eb

Browse files
committed
Stop writing down numbers only the merging pull request knows
Two of the round-13 findings are the same defect. Section 4 fixed the readable format set at {1} while section 9 said stage 4 bumps it, which leaves the final reader and writer disagreeing whichever way it is implemented: perpetual rebuilds, or no bump and the tempfile filename bug surviving into 1.0. Section 9 then illustrated the downstream version boundary with 0.9.0.9003, which from 0.9.0.9002 names stage 1 rather than stage 4, so a brms guard written against it would switch to compile_stan_file() three stages before that function exists. Both now name the thing instead of the value: a release reads exactly the format it writes, and a guard names the stage whose dev version it needs. The release order moves to #1258 entirely. The document kept saying the order lived here and nowhere else while also saying staging belongs to the issue, and the issue printed the order anyway. Constraints stay here, order lives there, and each links once. Stage 5 went last for a reason that was false: its inputs exist at stage 3, which writes the record and captures reported_features. It goes last because it publishes answers stage 4 settles. Air keeps its position and loses its justification. Branch conflicts end when the NEWS reconciliation merges, which is before the tag, so that argument never reached "after the candidate." What puts it there is that it is optional, and an optional cosmetic change cannot gate a tag. Its one real risk is a reflowed roxygen line regenerating .Rd, which R CMD check would not catch, so re-run roxygen after it. Corrections to specific claims. Both ".." and "garbage" error inside compareVersion(); garbage warns first. The earlier probe used tryCatch(warning=), which unwinds before the stop() runs. "Syntactically valid" now names the grammar already at R/path.R:298, and says compareVersion() cannot be the validator, since "2.36" and "2.36.0.1" pass it silently. The two $format() refresh lines are not the same age: the variables_ clear is #1235's and unreleased, the stan_code_ reassignment shipped in 0.7.0, so deleting NEWS.md:94 would drop a released behaviour change. And R/model.R:709 writes the resolved user header back into cpp_options, which is what currently makes it a cpp_option at all.
1 parent 0e895b8 commit 4f359eb

1 file changed

Lines changed: 116 additions & 29 deletions

File tree

dev-notes/compilation-state.md

Lines changed: 116 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,19 @@ Today the only way to read the header back is `$cpp_options()[["USER_HEADER"]]`,
394394
is why §1 can have `$cpp_options()` report `cpp_options_supplied` without losing
395395
anything: the header was never really a `cpp_option`, and now it is not one at all.
396396

397+
**`R/model.R:709` is what makes it one, and it goes.** After resolution, `compile()`
398+
writes the header back into `cpp_options` under whichever spelling was used, and
399+
`:941` then stores that list, so a caller who passed `user_header = "inc/mine.hpp"` as
400+
an argument and never touched `cpp_options` gets `USER_HEADER = "/abs/path/inc/mine.hpp"`
401+
back from `$cpp_options()`. Wired to `cpp_options_supplied` with that line still in
402+
place, the accessor would report a field the caller never passed, holding a path they
403+
never wrote. What survives is only the Make flag: CmdStan reads `-include $(USER_HEADER)`
404+
(`make/program:41`), so `USER_HEADER=` still has to reach `make`, and it reaches it as an
405+
injected option merged at flag construction (§4) rather than through the list the record
406+
is taken from. `resolved_header$spelling` goes with the line`:709` is its only
407+
consumer, and one channel has one spellingalongside the `previous` parameter §8
408+
removes.
409+
397410
### One canonical spelling, established on entry
398411

399412
`cpp_options_to_compile_flags()` (`R/cpp_opts.R:129`) uppercases every named entry, so
@@ -939,9 +952,16 @@ A single "sort and last-wins-deduplicate" rule is wrong. The correct rules diffe
939952

940953
A record whose `format_version` this cmdstanr does not read **rebuilds, and says
941954
so**, exactly like an executable that predates records (§7). It is not refused and
942-
does not require `force_recompile`. At 1.0 the readable set is exactly `{1}`, so in
943-
practice any mismatch rebuilds; a later release may widen the set, which changes what
944-
is readable without changing this rule.
955+
does not require `force_recompile`. **A release reads exactly the format it writes and
956+
nothing else**, so in practice any mismatch rebuilds; a later release may widen the set,
957+
which changes what is readable without changing this rule.
958+
959+
The number is deliberately not written down here. Stage 3 starts writing records and
960+
Stage 4 bumps the format (below), so a literal in this section would have to be kept
961+
in step with a value only the merging pull request knowsthe two-owner failure that
962+
§9 exists to avoid. It costs nothing to leave open: the pre-bump format is never
963+
released, so the only records written under it are on development branches, and they
964+
rebuild once.
945965

946966
**`format_version` versions the build interpretation contract, not the JSON shape.**
947967
If a later cmdstanr changes how `cpp_options` are canonicalised, identical bytes in a
@@ -1183,9 +1203,17 @@ samples pays it regardless, since `$sample()` calls `$variables()` to validate d
11831203
**`$format(overwrite_file = TRUE)` must stop refreshing the cache**
11841204
(`R/model.R:1308-1312`). It rewrites the Stan file and then reassigns `stan_code_`
11851205
and clears `variables_`, which makes both accessors describe a source the executable
1186-
was *never* built from#1228's failure in the opposite direction. #1235 added that
1187-
refresh deliberately, under the older contract where `$code()` meant "the file as it
1188-
is now"; the accessors have since been redefined and this has not caught up.
1206+
was *never* built from#1228's failure in the opposite direction. Both lines were
1207+
written under the older contract where `$code()` meant "the file as it is now"; the
1208+
accessors have since been redefined and this has not caught up.
1209+
1210+
**The two lines are not the same age, and the difference decides a NEWS entry.** The
1211+
`variables_` clear is #1235's, written and reverted inside the unreleased window, so
1212+
`NEWS.md:94` goes with it and no user upgrading from 0.9 ever saw it. The `stan_code_`
1213+
reassignment is commit `1719851e` from April 2022 and shipped in 0.7.0 through 0.9.0,
1214+
so removing it changes *released* behaviour and owes an entry of its own. `NEWS.md:94`
1215+
describes both in one sentence, which is how deleting it as a stale unreleased entry
1216+
would silently take the released half down with it.
11891217

11901218
Removing those lines is the whole fix. **`$format()` is kept, overwriting included**
11911219
a reviewer proposed removing the method, but rewriting the file is the useful part
@@ -1719,6 +1747,19 @@ time. So this is the only place the invariant §10 relies on can be established.
17191747
record must carry a parseable `builder` version, and on the fallback `<exe> info` must
17201748
report complete version fields.
17211749
1750+
**"Syntactically valid" means the grammar cmdstanr already uses**: three numeric
1751+
components with an optional release-candidate suffix, `[0-9]+\.[0-9]+\.[0-9]+(-rc[0-9]+)?`.
1752+
That is not a new contract invented here — it is the pattern at `R/path.R:298` and `:337`,
1753+
where cmdstanr already decides what counts as a CmdStan version when matching installation
1754+
directories. Pointing this check at the same grammar keeps one definition doing both jobs.
1755+
Note for whoever implements it that `cmdstan_version_for_comparison()` strips `-rc[0-9]+$`
1756+
(`R/path.R:156`), so the check runs on the reported string before that stripping.
1757+
1758+
**`utils::compareVersion()` must not be the validator.** "It did not complain" is a
1759+
weaker property than "this is a CmdStan version": `compareVersion("2.36", "2.35.0")` and
1760+
`compareVersion("2.36.0.1", "2.35.0")` both return `1` with no warning and no error, and
1761+
neither input is a version CmdStan reports. Only the grammar rejects them.
1762+
17221763
It is a syntactic check and nothing more. **Rejecting a version for being old would
17231764
defeat this whole section**, whose purpose is that a binary built by an older CmdStan
17241765
keeps working. Without the check, `model_compile_info()` synthesises `".."` from three
@@ -2057,12 +2098,11 @@ ship as one stage** — separating those leaves a window where the new promise i
20572098
whichever way the cut is made (Stage 4), though the engine's pure, unwired half
20582099
separates cleanly and is Stage 3b.
20592100
2060-
**The release order is stated here and nowhere else.** Everything below refers to this
2061-
line rather than restating it:
2062-
2063-
```
2064-
Stage 4 -> Stage 5 -> NEWS reconciliation -> release candidate -> Air's format -> 1.0
2065-
```
2101+
**#1258 states the release order; this section states the constraints behind it.** The
2102+
split follows the boundary already set under Purpose and scope, and it has a test: if
2103+
the order changes, the work list is what you edit; if the *reason* changes, this is.
2104+
Both printing it is how four different statements of it came to exist in an earlier
2105+
draft.
20662106
20672107
The candidate ships when everything is ready rather than at the earliest defensible
20682108
moment, and that is a structural choice rather than a preference. The moment an item can
@@ -2073,12 +2113,29 @@ One definition of ready removes the adjudication instead of getting it right eac
20732113
20742114
What downstream gives up by waiting is a tag, not a start. We open their pull requests
20752115
ourselves (below), and the dev version supplies the boundary in the meantime: bumped **in
2076-
the same pull request as each break**, it lets brms write
2077-
`packageVersion("cmdstanr") >= "0.9.0.9003"` against master the day the break lands,
2078-
which is finer-grained than a tag rather than a substitute for one. Bumping in a
2079-
follow-up commit is worse than not bumping at all, since a guard written against the new
2080-
number then takes the old branch and calls a method that has already gone. Stages 1, 4
2081-
and 5 each owe one; Stages 2, 3 and 3b change nothing observable and do not.
2116+
the same pull request as each break**, it lets brms guard on `packageVersion("cmdstanr")`
2117+
against master the day the break lands, which is finer-grained than a tag rather than a
2118+
substitute for one. Bumping in a follow-up commit is worse than not bumping at all, since
2119+
a guard written against the new number then takes the old branch and calls a method that
2120+
has already gone.
2121+
2122+
**Each guard names the stage it needs, not a number chosen now.** brms moves onto the
2123+
standalone family, which arrives in Stage 4, so its boundary is *the Stage 4 dev
2124+
version*, whatever that pull request assigns. Writing a literal here picks the wrong
2125+
stage as soon as the arithmetic moves: from `0.9.0.9002`, Stage 1's bump is `.9003`, so
2126+
a guard written against `.9003` today would switch brms to `compile_stan_file()` three
2127+
stages before it existsthe partial migration this paragraph exists to prevent. The
2128+
downstream pull requests we open carry the real numbers.
2129+
2130+
**A bump is owed for a public contract downstream has to branch on, not for anything
2131+
observable.** Stages 1, 4 and 5 owe one; Stages 2, 3 and 3b do not. Stage 3 is the case
2132+
that makes the distinction necessary rather than pedantic: it creates a sidecar beside
2133+
every executable and can print the untracked-dependency note, both plainly observable,
2134+
and neither is something brms or instantiate could write an `if` against. A version
2135+
number exists so someone can branch on it, and there is nothing there to branch on. What
2136+
the sidecar does raise is whether instantiate carries the record into the package library
2137+
alongside the staged executable, which is install mechanics and belongs to #1238; no
2138+
version guard would answer it.
20822139

20832140
### Stages 0 and 1 — landing in #1235, then Make-option correctness
20842141

@@ -2204,10 +2261,16 @@ enough, and is one fewer moving part in CI.
22042261
22052262
### Stage 5 — public build-record inspection
22062263
2207-
`stan_build_info()` last, because its inputs do not exist until Stage 4: it reports
2208-
`reported_features` and the record, and Stage 4 is what makes both live. That is an
2209-
ordering fact rather than a scheduling preference, so it cannot drift. It ships before
2210-
the candidate, per the order above.
2264+
`stan_build_info()` last, because **it publishes answers Stage 4 settles**. Its inputs
2265+
exist a stage earlier — Stage 3 writes the record and captures `reported_features` — so
2266+
the ordering is not about availability. It is about meaning. Until Stage 4 deletes the
2267+
merge, `$cpp_options()` still answers "what is this binary" by mixing the report into
2268+
the request, so a function published before then would arrive into a world where its
2269+
own purpose is not yet true, and Stage 4 would change what it reports. It must also
2270+
answer for an unprovenanced executable (§7), and record-aware adoption is Stage 4, so
2271+
before that the case it most needs to cover does not exist. That is an ordering fact
2272+
rather than a scheduling preference, so it cannot drift. It ships before the candidate,
2273+
per #1258.
22112274
22122275
**It must ship in 1.0**, despite looking purely additive. Because §1 keeps the
22132276
request separate from what the binary reports, this is the only way to ask what an
@@ -2252,9 +2315,16 @@ upgrading from 0.9 to 1.0 never saw the intermediate behaviour and does not need
22522315
know it existed. This is a pass before the candidate, once the stages have settled,
22532316
not something to do incrementally while the picture is still moving.
22542317
2318+
**The pass covers inherited gaps, not only this design's.** Release notes belong to the
2319+
release rather than to whoever caused each line, and the unreleased window already
2320+
carries user-facing changes that were never written up: measured against merge subjects
2321+
since `v0.9.0`, roughly a quarter of the merged pull requests that touch behaviour have
2322+
no entry, among them a new exported function, two new public methods and a changed
2323+
default on `loo()`. #1258 owns the method and the list.
2324+
22552325
### The release candidate
22562326

2257-
The candidate ships after the NEWS reconciliation, per the order above, so downstream
2327+
The candidate ships after the NEWS reconciliation, per #1258, so downstream
22582328
packages have something to migrate against rather than a release note. That is what
22592329
makes §8's breaking change affordable.
22602330
@@ -2486,10 +2556,25 @@ outright.
24862556
The formatting and linting work is scheduled around this, and the formatter and the
24872557
linter go to different places.
24882558
2489-
Air's one-time whole-repo format (#1153) is the **last** change before 1.0. It is
2490-
whitespace-only and deterministic, so shipping it after the candidate is cheap, and
2491-
by then there is no branch left for it to conflict with — which there would be
2492-
today, with #1235 and #1254 both open. Its PR-review action is a separate thing:
2559+
Air's one-time whole-repo format (#1153) is the **last** change before 1.0, **and it
2560+
is optional**. That is the reason it goes last rather than just before the candidate.
2561+
An optional cosmetic change cannot gate a tag: put it earlier and the candidate waits
2562+
on a decision nobody has made about something that does not alter what the release
2563+
does. Last, it is skippable, and skipping it changes nothing else.
2564+
2565+
Two arguments that look like they belong here do not. Branch conflictsreal today,
2566+
with #1235 and #1254 both open — stop once the NEWS reconciliation merges, which is
2567+
*before* the tag, so that constraint says "not yet" and never says "after the tag."
2568+
And whitespace-only determinism makes the change cheap in either slot, so it does not
2569+
choose between them either. What decides it is that the item is a maybe.
2570+
2571+
**One check when it runs.** Air reformats `#'` lines like any others, so a reflow that
2572+
moves a roxygen tag regenerates `.Rd` and `NAMESPACE` differently and R CMD check will
2573+
not notice. Re-run roxygen afterwards and confirm the generated files are unchanged.
2574+
That is the whole of the residual risk in shipping it after the candidate, and it is
2575+
cheaper to check than to reschedule.
2576+
2577+
Its PR-review action is a separate thing:
24932578
additive, conflicting with nothing, and most useful *during* the stages, since
24942579
Stages 2 to 4 write a good deal of new code that would otherwise be formatted after
24952580
the fact. Check first whether it comments on changed lines or on whole files; if the
@@ -2612,8 +2697,10 @@ every comparison. It is wrong for a model.
26122697

26132698
The guard is also narrower than "unusable", which matters because it removes the
26142699
temptation to lean on it: a malformed non-empty string never reaches the `-1` at all.
2615-
`".."` errors inside `utils::compareVersion()` and `"garbage"` warns, so the function
2616-
has two distinct bad-input behaviours and neither is a safe fallback. Filed as #1260,
2700+
Both `".."` and `"garbage"` error inside `utils::compareVersion()`, with `missing value
2701+
where TRUE/FALSE needed`; `"garbage"` emits `NAs introduced by coercion` first. So the
2702+
bad-input behaviour is an error, sometimes with a warning in front of it, and never the
2703+
`-1`. Filed as #1260,
26172704
separately from this design: the comparison should not answer a question it was not
26182705
asked, but fixing it is defence in depth rather than what closes this. That is why §7
26192706
makes "an adopted executable always yields a valid version" a *checked* invariant rather

0 commit comments

Comments
 (0)