Skip to content

refactor: separate C++23 module interface from implementation - #545

Merged
Sunrisepeak merged 19 commits into
mainfrom
refactor/module-impl-separation
Aug 13, 2026
Merged

refactor: separate C++23 module interface from implementation#545
Sunrisepeak merged 19 commits into
mainfrom
refactor/module-impl-separation

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

What

Split every module that carried its own implementation into the standard pair:

  • X.cppm — module interface unit (export module M;): types, declarations, templates, constexpr. Produces the BMI.
  • X.cpp — module implementation unit (module M;): the bodies. Produces no BMI.

No directory moves, no behaviour changes, no new product code.

Why

Today every function body lives in the interface, so any body edit changes the BMI and everything that imports the module recompiles.

module direct importers transitive downstream
xlings.platform 45 63
xlings.core.palette 8 53
xlings.libs.json 31 52
xlings.core.log 39 47
xlings.core.config 34 35

Average transitive downstream: 10.7 modules.

Phase 1 (this checkpoint)

81 files, 779 entities, 26,606 body lines moved. The interface surface — the part every importer reads — drops from 46,253 → 19,633 lines (42%).

Transformed by one tool (.agents/tools/module-split/split.py) so the rules are uniform and the diff is reviewable, with gcc as the verifier.

Verification

  • mcpp build clean
  • mcpp test38/38 binaries passed, 0 failed

Not covered locally: 1,228 lines of the moved code sit behind _WIN32 / __APPLE__ guards that a Linux build never compiles. The macOS and Windows CI jobs are the gate for those.

Still to come on this branch

  • phase 2 — out-of-line class members (241 members / 3,596 lines identified; config.cppm is 85 members with 34 importers)
  • build-speed measurement vs main (cold + incremental) and the binary-size side of dropping implicit inline

Trade-off being measured, not assumed

Moving a body out of the interface drops its implicit inline. Without LTO the release build loses those cross-TU inlining opportunities, so this branch reports binary size alongside build time rather than claiming the change is free.

…ism measured first

110 .cppm files carry both interface and implementation, so every body edit
changes the BMI and every importer recompiles. Average transitive downstream
is 10.7 modules; platform.cppm's is 63.

Before planning the edit, a throwaway probe established that mcpp orders
.cpp implementation units via P1689 dyndep and emits no BMI for them, and
that the three shapes this codebase actually needs all hold: a non-exported
helper declared in the interface and defined in the implementation unit is
callable from an exported template instantiated downstream; an extern
module-linkage global works the same way; export/default-args/constexpr have
to stay put.

Records the classification rules, the globals-ordering invariant, the
84.9%/15.1% free-function vs class-body split, the inline/LTO trade-off to
measure rather than assume, and main's cold-build baseline (68.85/55.14/64.12s).
… sizing analysis behind it

split.py transforms one .cppm that carries its own implementation into a
standard (.cppm interface, .cpp implementation) pair. It scrubs comments and
literals to a length-preserving mask, scans one brace level into items, and
classifies each: types/templates/constexpr/inline/const stay whole in the
interface; non-template function definitions and dynamically-initialised
namespace-scope variables split into a declaration plus a definition; #if
conditionals are emitted to both units so a definition never loses its guard.

Doing it with one tool rather than 110 hand edits is what makes the diff
reviewable -- every file is transformed by the same rules, and gcc verifies
the result.

Three facts established by probe before the rules were written:
- an implementation unit implicitly sees everything its interface declares,
  including non-exported namespace aliases, type aliases and types, so
  nothing has to be duplicated
- `export` must not appear in the implementation unit, and a default argument
  must appear only in the declaration
- namespace-scope `static` has to lose the keyword: internal linkage cannot
  span two units, and module linkage already keeps the name module-private

analyze.py sizes the job: 84.9% of the 46,253 lines sit outside class bodies.
…ules (phase 1)

Every .cppm carried its own function bodies, so an implementation edit changed
the BMI and recompiled every importer -- 10.7 modules on average, 64 TUs for
platform.cppm.

Phase 1 moves namespace-scope implementations into module implementation units
(`module M;` in a .cpp), which produce no BMI. 81 files, 779 entities, 26,606
body lines. The interface surface drops from 46,253 lines to 19,633 (42% of
what it was); that is the part every importer has to read.

Done by one tool (.agents/tools/module-split/split.py) rather than 81 hand
edits, so every file is transformed by the same rules and the diff is
reviewable. Five rules came out of compiler feedback rather than from reading
the standard first, and each names a real hazard:

- a deduced return type (`auto f() {...}` with no trailing `-> T`) cannot move:
  callers in other units would have nothing to deduce from
- an out-of-line member definition cannot be split, because a member function
  cannot be *declared* at namespace scope -- so the out-of-line `= default`
  assignment operators in xvm/types and libxpkg/types/type stay put
- an anonymous namespace moves WHOLE: internal linkage cannot span two units,
  and mirror/forms' constants proved it by vanishing from their callers
- `operator=` is a NAME, not an initialiser; reading the '=' as one turned
  four defaulted operators into `extern ... ::operator;`
- namespace-scope `static` loses the keyword: internal linkage cannot span two
  units either, and module linkage already keeps the name module-private

Verification: mcpp build clean, and mcpp test 38/38 binaries passed, 0 failed.

Not verified here, and CI has to carry it: 1,228 lines of the moved code sit
behind _WIN32/__APPLE__ guards that a Linux build never compiles.
…ions out of the interface (phase 2)

Two changes, both aimed at what is left in the BMI after phase 1.

**Class members.** A body defined inside a class is implicitly inline, so it
stayed in the interface and an edit to it still recompiled every importer.
286 members / 3,845 body lines move to out-of-line definitions in the
implementation unit. `config.cppm` is the case that pays: 93% class body and 34
direct importers.

Out-of-lining is not a text move, and each rule here is one gcc refused:
- the declarator-id becomes `C::name` and everything after it resolves in class
  scope, but the RETURN TYPE precedes it and does not -- so a nested return type
  gets qualified, and the definitions are emitted INSIDE their namespace rather
  than fully qualified at file scope (`Segment` does not resolve there)
- `override`/`final` are declaration-only; a virt-specifier outside a class is
  an error
- `: registry_ { registry }` -- a brace-initialised member-init list was being
  read as the function body, producing `Ctor(args); {}`
- a class inside `#if defined(_WIN32)` must have its outlined members re-wrapped
  in the same condition, or its Windows bodies compile on Linux

**Module-private declarations.** A non-exported helper's declaration only needs
to be in the interface if something still THERE names it -- an exported
template, an inline or constexpr body, a class member body. Otherwise it is
module-private and belongs in the implementation unit: in the interface it sits
in the BMI, so changing its signature would recompile every importer for
nothing. Deciding this needs care in two places gcc found:
- a qualified call `detail_::binding_error_(...)` IS a reference; a lookbehind
  rejecting ':' missed the common form
- inside `export namespace`, an entity is exported even though its own
  declaration does not say so
- when the declaration is dropped, the definition becomes the only declaration
  and must carry the default arguments the declaration used to hold

Interface surface: 46,253 -> 14,819 lines, 32% of the original.

Two members keep their bodies because moving them makes gcc@16.1.0 segfault,
both recorded with the evidence in ICE_SKIP. The second was found by bisecting
config.cppm's 85 movable members (bisect-member.sh): #34 is
`static Config& instance_() { static Config inst; return inst; }`, whose
function-local static is where the module-attached `Config` is first completed.
Moving it crashes cc1plus in an unrelated translation unit, on a different type,
in a different module -- so the message names nothing that changed.
…d dedenting

Two defects in the phase-2 tool, both found by reading its output rather than
by a compiler.

**Re-running stacked copies.** outline.py appends its section to the .cpp. That
was fine while the generated files were untracked -- `git clean` removed them
between runs -- and silently wrong once they were committed: clean leaves a
tracked file, and split.py does not rewrite the ones outline.py creates itself.
Three regenerations left three definitions of `TaskManager::TaskManager` in
task.cpp and two of `UsageSkill::name`. It now truncates at its own banner
before writing, and regen.sh removes generated files by diffing the tree against
the base ref instead of trusting `git clean`.

**Indentation.** A member body carries the class's indentation, so 3,845 lines
of out-of-line definitions came out one level too deep with their closing brace
hanging in mid-air, and `.strip()` had eaten the space before the opening brace.
Bodies are now dedented by the member's own indent.

Verified on the regenerated tree: no duplicate banners, and the only repeated
declarator-ids are genuine overloads (`Config::display_path` on path and string,
`Installer::Installer` on IndexManager and PackageCatalog). Build clean.
`test_cli_spec_parity.py`'s reverse pass reads the hand-written argv loops
looking for flag literals the spec does not publish. It named
`src/core/subos.cppm`, `src/core/xself.cppm` and `src/cli.cppm` -- and those
loops now live in the implementation units, so the pass matched **0** literals
instead of 64.

The guard failed loudly rather than reporting green on an empty scan, which is
the reason this was caught at all. It now names the modules by stem and reads
both sides, since an interface unit and its implementation unit are one module
and an argv loop can sit in either.

Verified: 240 option spellings executed, 64 parser literals checked.

Two other layout dependencies on `.cppm` were audited and still hold, because
`VERSION` is `static constexpr` and stays in the interface:
tools/linux_release.sh's version sed, and test_version_consistency.py.
test_windows_header_hygiene.py already globbed both extensions and reports
10 modules including <windows.h> with NOMINMAX first -- the generated
implementation units inherit the global module fragment intact.
Moving a module-private declaration out of the interface deleted the comment
above it -- 230 comment lines across the tree, explaining helpers whose bodies
are still there. Counting comment lines before and after is what found it; the
compiler had nothing to say.

The lead comment now follows whichever side keeps the declaration: the interface
when it is still referenced there, the implementation unit when it is not.

Phase 1 now conserves comments (10,290 -> 10,543). The surplus is the comment
above an #if, which is emitted to both units along with the directive -- both
units need to know why the guard is there, and an unexplained `#if defined(_WIN32)`
in the implementation unit would be worse than a duplicated sentence.

Also adds the report skeleton: method, fan-out, the two ICEs, and what this run
does not verify (1,228 lines behind _WIN32/__APPLE__ guards, the musl toolchain,
e2e). Measurements are appended once both sides have run.
Nothing about moving implementations may add or remove an export. This compares
the exported-name set of every interface unit against the pre-split tree:
961 identifiers before, 961 after, 0 lost, 0 gained.

The check has to be depth-aware, and the first version was not. A line-anchored
regex over an `export namespace` body also matches the LOCAL VARIABLES inside
the function bodies that live there, so it reported 914 "lost exports" whose
names were `1`, `a`, `acc`, `activeBin` -- an artefact of the bodies moving, not
a lost export. It now scans with the splitter's own item scanner and counts only
top-level declarators.
…mments preserved

CI found what a local dev build cannot: `x86_64-linux-musl` (gcc@15.1.0-musl)
rejected `catalog.cpp`, where a `std::views::transform` pipeline moved into a
module implementation unit --

    use of 'constexpr auto std::ranges::views::__adaptor::operator|(...)'
    before deduction of 'auto'

-- while gcc@16.1.0 accepts the identical body. Reproduced locally with
`mcpp build --target x86_64-linux-musl`: exactly one error, one file.

The reverse has bitten this project before: `views::split | ranges::to` compiled
under 15.1.0-musl and made a whole module fail with "Bad file data" under 16.1.0,
blaming an unmodified TU (.agents/docs/2026-08-06-subos-architecture-proposal.md
§590). Range adaptors in modules are fragile in BOTH directions across these two
versions, so the two targets now share one compiler major rather than trading one
breakage for the other. `x86_64-linux-musl` + gcc 16.1.0 is already an installed,
supported combination, and aarch64-linux-musl was on 16.1.0 already.

Verified: `mcpp build --target x86_64-linux-musl` clean in 32.29s, and the
static binary runs (`xlings 2026.8.11.2`, ELF statically linked).

This needed no source exclusion, so the toolchain-skip hook added while
diagnosing it is removed rather than left behind empty.

Also regenerates the tree so dropped declarations keep their doc comments:
comment lines 10,316 -> 10,612. The surplus is the comment above an `#if`, which
goes to both units with the directive.

My own local loop was the gap, not CI: several rounds cleared only
`target/*/gcm.cache` and kept the object files, so a stale catalog.o hid this on
the dev toolchain too. A cold `rm -rf target` and the second target both have to
be built before believing a green build.
… too

macOS CI failed on the phase-2 push having passed on the phase-1 one. Every TU
instantiating a zero-argument `log::` template died inside libc++'s <print>:

    formatter<basic_format_string<char, basic_string<char>>, char>
      -- call to deleted constructor

which is `std::print` no longer seeing its `FILE*` overload and treating
`stdout` as the format string. Traced to log.cppm:66 via
`log::info<>` at migrate.cpp:19 and `log::println<>` at uninstall.cpp:134.
The build stops on first failure, so two objects is a floor, not the scope.

The only delta to log.cppm in phase 2 was the module-private declaration rule
dropping three declarations -- `gFile_`, `gColor_`, `color_on_()`. A template
left in the interface is instantiated in the IMPORTER, and what that
instantiation can see depends on the interface's reachability set, which
dropping declarations changes in ways the standard leaves to the implementation.

So the rule now skips any module whose interface still holds a template. Six
files, none of them a build-time bottleneck: interface 14,819 -> 14,832 lines.

Honest about the evidence: I could not reproduce this locally. A minimal probe
with the same shape -- GMF <cstdio>, a template calling stream-less std::print,
zero-arg instantiation from another module's implementation unit -- compiles
clean under clang 20.1.7 on Linux, and the error trace runs through Xcode's
_stdio.h, so it needs macOS libc. CI is the only loop, and CI is what verifies
this.

Locally verified: cold gcc@16.1.0 build clean (24.21s), musl gcc@16.1.0 clean
(24.22s).
…iation anchor

The previous fix was wrong. Restoring log.cppm's dropped declarations changed
nothing: macOS failed again on c94e199 with the identical error in the identical
two objects, so that hypothesis is disproved and its guard is reverted rather
than left in the tree unjustified.

The real anchor is `Config::print_paths()`, whose body carries seven
`std::println("...")` calls -- the overload that takes no stream. libc++
implements it as `print(stdout, fmt, args...)`, so the FILE* overload has to be
visible; when it is not, `stdout` is deduced AS the format string and you get a
deleted `formatter<basic_format_string<...>>`.

An in-class body is implicitly inline, so it IS in the BMI, and it can be the
only thing instantiating `std::print`/`std::println` for every importer of the
module. Outlining it moved that instantiation point out of the interface, and
clang 20 on macOS then stopped finding the FILE* overload in `migrate.cpp` and
`uninstall.cpp` -- both of which import `xlings.core.config`, both otherwise
untouched since the push macOS passed.

This is the same shape as the two gcc ICEs already recorded: moving a body moves
where a template is first instantiated, and the failure surfaces somewhere else
entirely, naming nothing that changed.

Phase 1 correctly does NOT carry this rule, and the asymmetry is the reason it
passed macOS while phase 2 did not: a non-inline namespace-scope body need not be
in the BMI at all, so it was never anchoring anything.

Cost: 3 members stay inline, interface 14,832 -> 14,853 lines.

Verified locally: cold gcc@16.1.0 clean (26.11s), musl gcc@16.1.0 clean, export
surface still 961 identifiers, 0 lost 0 gained. macOS remains CI-only.
…units <cstdio>

Root cause, found with a local clang loop instead of 13-minute CI cycles: the
fragile construct is the STREAM-LESS `std::print` / `std::println` overload inside
a template that stays in the interface.

libc++ implements `print(fmt, args...)` as `print(stdout, fmt, args...)`, so the
FILE* overload has to win overload resolution at the point of instantiation --
and for a template in an interface that point is in the IMPORTER. Once enough
bodies leave the interface, clang 20 stops picking it: `stdout` is deduced AS the
format string and the error is a deleted `formatter<basic_format_string<...>>`,
reported against files that did not change (migrate.cpp, uninstall.cpp).

Two mechanical rules replace the two guesses that came before, both of which are
reverted:
- 23 call sites across 6 interfaces name their stream. [print.fun] defines the
  two-argument form AS `print(stdout, ...)`, so this changes no behaviour and
  removes the deduction entirely. Bodies that move to an implementation unit are
  left alone -- nothing instantiates those from another TU.
- an implementation unit whose text ends up naming `stdout`/`stderr`/`FILE` gets
  `<cstdio>` in its global module fragment. `Config::print_paths()` lands in
  config.cpp, whose interface has no fragment at all.

With those, no member needs an exclusion for this: the print-anchor rule and the
template-reachability guard are both gone, and the 3 members the anchor rule had
pinned are outlined again (interface back to 14,819 lines).

**The local loop is the real lesson.** clang 20.1.7 on Linux reproduces the macOS
failure exactly -- main builds clean, the split does not -- so this was
diagnosable in 40 seconds all along. A minimal probe of the same shape does NOT
reproduce it; it needs the whole project. `build/bench/clang_variant.sh` builds
any variant with clang in an isolated copy, and `clang_bisect.sh` binary-searches
a file's members.

One of my own bugs is worth recording: `open(f,'w').write(ensure_cstdio(open(f).read()))`
truncates the file before the read runs, so every unit taking that path came out
empty -- surfacing as undefined symbols at link time, never as an error in the
file that was emptied.

Verified: gcc@16.1.0 clean, musl gcc@16.1.0 clean, clang@20.1.7 clean, export
surface 961 identifiers with 0 lost and 0 gained.
`classify()` took an `in_class` argument it never read, and its docstring listed
five return values when there are six. Removed and spelled out; regenerating
afterwards reproduces the committed tree byte for byte, which is the check that
the cleanup changed no behaviour -- and incidentally that regen.sh is
deterministic.

The report carries method before numbers: how each measurement is taken and why
(a plain `touch` of a .cppm does not work -- mcpp preserves a BMI's timestamp
when a recompile is byte-identical, so it would skip every downstream unit and
flatter the before-picture), the probe set spanning both fan-out and the two
kinds of body, and `compare_segment` as a control that must NOT improve.

It also corrects a number from the previous commit message: the stream naming is
11 sites across 4 interfaces, not 23 across 6. The 23 was the count of
stream-less prints in the pre-split .cppm set, and 12 of those belong to bodies
that moved to a .cpp and were never normalised.

Section 8 records what each of the four toolchains rejected, including the two
wrong fixes and why they are reverted rather than left in, and section 9 is the
gate this work should have started with: a cold `rm -rf target`, the musl target,
and clang -- all runnable locally in under a minute each.
…ool defects review found

Self-review of the PR, and it found real things.

**The split copied imports instead of moving them.** split.py duplicates the
interface's import list into the implementation unit -- correct for the
implementation, wrong for the interface, which was left importing modules only
the moved bodies used. 153 of 554 interface import edges (28%) named nothing in
the interface, and each one makes the interface's BMI depend on a module it does
not use, so an edit there recompiles this interface for nothing. That caps the
benefit the split exists to deliver. trim-imports.py is phase 3 and runs last,
because deciding this needs every interface in its final form. Interface
14,819 -> 14,666 lines.

It took one compiler-driven iteration. An aggregator module declares nothing and
only `export import`s -- `xlings.runtime` re-exports event/event_stream/
capability -- so its export set read as empty and its importers were over-trimmed:
`interface.cppm` lost `Event`, `EventStream` and `CancellationToken`. The export
sets are now closed over re-exports, partitions included.

**cold.sh has never run from where it was committed.** Its root was
`dirname/../..`, which was right in build/bench and points at `.agents/` from
`.agents/tools/module-split/`. Broken since the first commit of this branch, and
the report cites it.

**The report cited two scripts that are not in the repo.** `clang_variant.sh` and
`measure_side.sh` lived in build/, which is gitignored, so the local gate the
report documents could not be run. Both are now committed as
`clang-variant.sh` / `measure-side.sh` with their paths corrected; logs still go
to build/. The tools are 755 like the rest of .agents/tools.

**The unverified surface was overstated by 69%.** "1,228 lines behind
_WIN32/__APPLE__ guards" is really 728: the old counter flagged any block whose
condition mentioned those macros, including `#if !defined(_WIN32)`, which is true
on Linux -- 208 of the miscount was platform/unix.cpp's POSIX code. Recomputed by
evaluating the conditions.

**And a correction to the report's own reasoning.** It claimed the control probe
"must show no improvement". It improves 2.5x, because the downstream interfaces it
still rebuilds are smaller. The control separates rebuild-cost from rebuild-set;
the moved probes get both and land at 4.6-12.1x. The gap is what the move bought.

Verified after the trim: gcc@16.1.0 clean, musl gcc@16.1.0 clean, llvm@20.1.7
clean, export surface still 961 identifiers with 0 lost and 0 gained, and 1,262
function definitions conserved exactly. Unit suite re-running.
`main`'s baseline is in, so the report carries numbers instead of intentions.

Cold build 56.40s -> 27.28s, a 2.07x median-to-median win, and the direction was
not a given: translation units roughly doubled. One implementation edit goes
4.6-12.1x depending on fan-out, and on main such an edit cost about as much as
building the whole project from scratch -- 54-63s against a 56.40s cold build,
because before the split every body edit WAS an interface edit.

**`mcpp test` is 1.16x SLOWER**, 953.81s -> 1104.83s, and that is the honest
headline the rest of the report has to be read against. The mechanism is pinned,
not guessed: +4.72s per test binary, 38 x 4.72 = the entire 179s gap, and the
overhead is near-constant regardless of the test file's own size (min +3.10,
median +3.74, max +4.37). A compile-side cost would scale with the file; a
link-side cost would not. Each test binary now links ~202 objects instead of ~112.

So the split trades link time for compile time. Link once and you win 2x; link 39
large static binaries and you lose 16%. The follow-up belongs to the test harness
-- 38 binaries each statically linking the whole project -- not to the split.

Also records what the self-review found (§12), all of which built green first:
the copied imports, cold.sh never running from its committed path, two cited
scripts that were not in the repo, the 69% overstatement of the unverified
surface, the backwards control-probe claim, and a closure bug in the new tool.
Plus the five invariants it checked and found clean, as numbers.

Binary size +0.10% at -O0, which is the measured answer to the inline trade-off
rather than an assumption about it.
…tion for it

Re-measured on the final tree. `mcpp test` was the one number going the wrong
way -- 1.16x slower than main -- and phase 3 turns it into 1.03x FASTER:
953.81s on main, 1104.83s before the trim, 921.73s after.

Per test binary: main median 21.18s, branch 24.92s before the trim (+3.74s), and
21.07s after (-0.11s). The penalty did not shrink, it disappeared.

**And that falsifies what I wrote about the cause.** I called it link cost: the
overhead was near-constant per binary regardless of the test file's size, and each
binary now links ~202 objects instead of ~112. That argument does not separate the
two candidates, because BMI loading is ALSO near-constant per TU -- it depends on
the import closure, not on the file being compiled.

Phase 3 is the controlled experiment. It changes only interface import edges; the
object count each binary links is identical before and after. Had linking been the
cause the penalty would have survived. So the cause was every test TU loading BMIs
for modules nothing in the interfaces named -- 28% more edges than needed. The
split does not meaningfully trade link time for compile time; it was carrying a
defect that looked exactly like that trade.

Everything else improved with it too: cold build median 27.28s -> 26.09s (2.16x
against main), `parse_sudo_env` 4.46/4.60 -> 4.39/4.35 (12.4x), and the control
17.24/17.54 -> 15.31/15.24, which is the same effect one level down -- the
downstream interfaces it still rebuilds now carry smaller closures.

All six CI workflows green on the trim commit.
Both version sites, since the release workflow reads only config.cppm while
mcpp.toml drifts -- `tests/scripts/test_version_consistency.py` fails the PR
when they disagree.

`YYYY.M.D.N` with N starting at 1; `.0` is reserved for a milestone release.

Verified against the binary the bump actually produces, not the one at the path
I already had: a version change moves the mcpp fingerprint, so
target/67501c87.../bin/xlings kept reporting 2026.8.11.2 while the new build sat
at target/466b5e30.../bin/xlings. Contract scripts re-run against the new one --
cli parity 240 spellings / 64 literals, generated command reference, docs
examples, windows header hygiene.
Follows the house shape: 一句话 first, then the premises this round overturned,
recorded rather than quietly corrected. Three of them were mine:

- "TU count doubles so the cold build gets slower" -- it got 2.16x FASTER
- "the test-suite regression is link cost" -- falsified by phase 3, which
  changes only import edges and left the object count per binary identical
- "the control probe must not improve" -- it improves 2.9%, and that is exactly
  what makes it useful: it separates rebuild-set from rebuild-cost

Also the four toolchains and what each rejected, the invariants as numbers, and
the one thing deliberately left alone (1,953 lines of `inline` bodies, which
main's own tree carries identically).
The control probe reads 2.5x on the phase-1-2 tree (43.58 -> 17.24) and 2.9x on
the final one (43.58 -> 15.24). Both were in the docs with neither labelled, so
the two looked like a contradiction. The ratio that carries the argument -- the
gap between the control and the moved probes -- is unchanged either way.
@Sunrisepeak
Sunrisepeak marked this pull request as ready for review August 13, 2026 04:44
@Sunrisepeak
Sunrisepeak merged commit f072075 into main Aug 13, 2026
6 checks passed
@Sunrisepeak
Sunrisepeak deleted the refactor/module-impl-separation branch August 13, 2026 04:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant