feat(63): Pooling all-or-nothing calculation - #64
Open
Aukevanoost wants to merge 23 commits into
Open
Conversation
Adds the repro spec for #63 asserting the CURRENT (broken) behaviour: a pooled monorepo family can be served from two builds at two versions, both when a strict pin drags a member down and when host precedence pins one. Plus the design (research.md, spec = §15 family-instance model) and the execution plan.
Pooling will re-point a member's winner onto a pooled family instance, which also moves its remotes[0] serving basis - the thing applyEntrypointCoveragePolicy and findTears key off (PR #62). Both callers must therefore run the same election tail instead of pooling growing a second coverage rule or re-running determine. Moves the verdict loop, the coverage policy, findTears and scopeTornRemotes into 2.app/steps/apply-winner.ts as createApplyWinner(config), plus versionAcceptance() for the shared demands map. The optional acceptance argument lets pooling call it with only determine's memoized isCompatible while determine reuses its own map. No behaviour change: the suite passes unedited.
Pure, unit-tested building blocks for the family-instance model (research.md §15), not yet wired into the step: buildInstances, consumedMembers, buildAcceptanceTable, singleProviderMembers, canTakeAllFrom and hostPinnedTags. buildInstances drops an islanded remote's whole instance rather than just its scope versions - in the production capture the islanded Angular-21 remote is the sole provider of animations, so skipping versions alone leaves animations@21.2.18 shared next to core@22.0.8. research.md §15.3 now states this explicitly. The acceptance table and the direct assignment of single-provider members are mandatory precomputations, not optimisations: 4.4 ms vs 262 ms at R=50/M=80.
determine only scopes a rejecting version when some remote objected strictly, so a remote declaring strictVersion:false is deduped onto an incompatible tag today by its own choice. Testing requiredVersion alone would have made pooling stricter than the resolver it defers to and islanded those remotes, costing more downloads than the status quo. No pooling spec covers a loose remote, so the suite would not have caught it. research.md §15.1 rule 4 qualified accordingly. Also trims production comments to the necessary minimum.
poolFamily rebuilt and wrote back every member on every init, even when the pool had nothing to island and the rebuild reproduced what storage already held. On a coherent 6-remote portfolio from benchmark/ that is 16 redundant writes and ~55% of the step (0.336 ms -> 0.13-0.165 ms); where a remote is islanded nothing changes. Islanding now warns once per (pool, remote) naming the member and tag that made it impossible - it was silent at default log level, since determine sets action='scope' without logging in non-strict mode. The derived scoped-only warning is suppressed when an island in the same pass took the member's last provider (research.md §11.4), so an operator gets the cause, not the effect. No resolution changes. Side effect of skipping the rebuild: no-op pools no longer reorder versions, which keeps store-remote-entry's newest-first order intact for profile.latestSharedExternal.
Threads a shared isCompatible memo through determine and pooling (init.factory), adds versionCheck to the step's ports, and elects the instance that serves the most remotes entirely - coverage plus acceptance, since scoring 'is some mixed draw acceptable' re-elects the very split families this fixes. Host pins and single-provider members are seeded first and never scored. Re-pointing goes through the shared election tail with strictExternalCompatibility forced off (§11.5: an acceptance-driven scope is not a hard error), and splits a version at remote granularity first: verdicts are per version but acceptance is per remote, so one strict co-tenant would otherwise island every remote sharing its tag (5 islands / 70 downloads vs 2 / 43 on captured 7 + eleven). Case 1 of the repro now resolves the whole family from one build. NOT yet ticked in plan.md: measured on benchmark/, election is neutral on 3 portfolios and worse on 2 (+7 downloads, +1 island), and on 7+eleven it leaves core@22.0.6 beside core/primitives@22.0.8. See the BLOCKER note in plan.md iteration 4.
An instance may only serve a member if it ships every live member of that member's package, at tags agreeing with anything already pinned. Entrypoints of one package must come from one build - without this the extension pass fills the siblings an elected instance does not ship from another instance, which on benchmark/ produced @angular/core@22.0.6 beside @angular/core/primitives@22.0.8 and islanded the strictest consumer. Measured on the five benchmark portfolios, election with the guard is identical to current behaviour everywhere (it never fires) while still fixing repro Case 1; without it, two portfolios lose 7 downloads and gain an island. Package boundaries are not in DenseSharedInfo, so packageOf() follows the naming convention - it can only restrict election, never widen it. 13 tests in elect-instance.spec.ts.
A remote that cannot take every member it consumes from the pooled builds now scopes its whole family instead of mixing; a member whose share version lost all its non-islanded copies counts as unserved. Scoping is monotone, so the pass repeats after any round that scoped someone. Multi-build draws log at debug, never warn (§15.1 rule 6), and the island warning names whether determine refused the version or §15 acceptance did. The healthy path builds no acceptance table: with nobody islanded and every member shared, applyWinner's own skip rule already guarantees the test passes. Perf work included: shared poolContext so instances/consumed/acceptance are built once per pool, takeable groups precomputed per instance, and two gates before scoring (objective already maxed; coverage-only upper bound on the objective). NOT ticked in plan.md: the step is still 3-4x slower than iteration 3 (captured 7 cold 1.43-1.91 ms vs 0.34-0.42) and election accounts for most of it while changing no real-portfolio outcome. See the BLOCKER note in plan.md iteration 5.
Reverts the election machinery (efc5e70, fbfe4c9, 4a4a4b7) and fixes the split family with tag distance instead, per Auke's call: a remote may draw on several builds - patch drift inside a family is normal - but not on builds that disagree, meaning a member they both ship sits on a different minor line. Such a remote serves its whole family from its own build. The gate iterates to a fixed point, since islanding removes a serving build and can leave a member unserved. Both repro cases are now fixed, including Case 2, which §15 had knowingly left broken - and host precedence never bends: the host keeps its pin and the remote that would mix builds gives way. Measured on benchmark/: all five portfolios byte-identical to pre-fix behaviour (the 22.0.6/22.0.8 shared sets are one minor line, the cross-major remotes were already islanded), and the step costs +0.05-0.12 ms rather than election's 3-4x. Pooling needs no versionCheck, no acceptance table and no scoring.
A remote loaded at runtime may dedup a pooled family only if the committed builds serving its members agree - every member two of them both ship on the same minor line. Otherwise it serves the whole family from its own build. Strictly additive: the step still only rewrites its own actions, never committed versions. Init already stops any remote from drawing disagreeing builds, but the committed shared set can hold two that disagree when nothing so far consumed both (the capture's forms@22.0.8 beside forms/signals@21.2.18). A remote loaded later is exactly the consumer that would bridge them. pool-dynamic-externals now takes sharedExternalsRepo; DI updated.
The probes are local testing tools, not part of the package. benchmark/ is now git-ignored wholesale (fixtures were already untracked); plan.md and research.md point at benchmark/probes/ so a later loop pass knows where they live.
Re-ran both probes against the five benchmark portfolios. Three findings the docs had wrong or missing: - Pooling never reduces downloads on a real portfolio (36/45/64/36/36 after, 36/42/43/36/36 before). It buys coherence and pays downloads. \S13.3's 'strictly better than today, everywhere' was a prototype claim that did not reproduce; \S13.3 and \S14.1 are now marked as such and \S16.1 holds the real table. - The agreement gate fires on no real portfolio: every island is range incompatibility. Small blast radius, thin real-world validation. - No remote dedups onto a tag its own requiredVersion rejects, which is why dropping the acceptance table lost nothing - determine already applies that test per external. Also records follow-up F-A: one extra previous-major remote takes 7+eight from 36 to 64 downloads and islands 5 of 8. Neither election nor the gate fixes it, but the gate is structurally subtractive and cannot, so it needs winner re-pointing. Pre-existing #56 behaviour, does not block #63. Corrected the stale spec framing throughout: status is implemented-through-6 not designed-not-implemented, \S15.6's Case 2 limitation is retired, and plan.md's hard-constraint preamble no longer describes the reverted election model. Iterations 8-10 rewritten off the shipped behaviour.
determine clears `dirty` before pooling runs, so pooling had no way to tell what changed and recomputed every pool on every init - 45% of a warm init spent reproducing its own output. determine now resolves to the externals it re-elected per shareScope, which init.flow already threads into pooling. Pooling skips a scope with nothing touched before reading it, and any pool no member of which was touched; a touched member still processes its whole pool, since islanding one remote scopes its untouched siblings. Sound because pooling reads the `scope` verdicts it wrote itself: re-running it over unchanged storage can only reproduce them. Warm pooling 0.218 -> 0.002 ms on the captured seven; cold unchanged.
Renames the #63 repro to a permanent regression spec and adds the three fixture shapes the design argued about: patch drift inside one minor line is tolerated rather than unified, a previous-major member leaves the shared set when its only provider is islanded, and a clean subset consumer of an asymmetric family is never islanded. F-A gets its own characterisation file, and writing it corrected the mechanism recorded in research.md 16.2. The cascade is not "sole-provided members lose their provider, so consumers island": all five islands in 7+eight are range incompatibilities, and the three healthy Angular-22 remotes island on router@22.0.8 after determine moved that winner to the 21 line. Its extra-download objective counts versions, not remotes, so one 22 version shared by three remotes loses 2:1 to two patch-drifted 21 versions. Pooling only amplifies it, so any fix belongs in the resolver's cost model - still out of scope for #63. Re-ran the outcome probe: every 16.1 row holds with W2 in place.
The pooling section still described the pre-#56 anchor model and claimed coherence is a property of versions alone, "guaranteed by islanding". The repro falsifies that: a split family contains no incompatibility, so islanding never fires on it. Rewritten around what ships - determine's winners stand, gate 1 islands strict incompatibilities, gate 2 stops a remote drawing on builds that disagree at minor granularity, iterating to a fixed point. Adds the download trade (coherence, never fewer downloads), why the test is tag distance and not requiredVersion, the authoring rule and where it actually routes, the react/react-dom recipe for unscoped lockstep families, a log-line table that says which lines need no action, and the dynamic-init mirror. Two claims corrected against the code: the serving basis is the first non-islanded remote of the share version, and the dynamic gate compares only committed builds. Also fixes the same overclaim in config.md and the stale integration-spec docblock.
Records the islanding cascade with the mechanism as measured, not as first assumed: determine's extra-download objective counts versions rather than remotes, so two patch-drifted legacy remotes outvote three aligned modern ones, and pooling's all-or-nothing islanding turns that one mis-election into a whole family. Includes the measured tables, why a pooling-side fix is impossible, three candidate avenues and acceptance criteria. Also notes the limitation in version-resolver.md next to the cost model itself, which claims to minimize download time and in this shape does not.
plan.md and research.md were the working documents for this fix; they stay readable in this branch's history, and F-A-islanding-cascade.md points at them for whoever picks that follow-up up.
A scoped version is served to every one of its remotes from that remote's own build, so it costs one download per uncached copy — not one per version, which is exact only when every version has a single remote. The selection objective now weights each scoped version by that count, precomputed outside the O(versions²) loop. Since the shared version costs one download whichever candidate wins, minimising that sum is exactly minimising total downloads for the external: the same objective the docs already claimed, in the right unit. This is follow-up F-A's measured cause. On the production capture, adding one previous-major remote made @angular/router's single 22.0.8 version, shared by three agreeing remotes, cost 2 against each of two patch-drifted 21-line versions costing 1; the winner moved to the 21 line and pooling's all-or-nothing islanding amplified it into all 24 members. That portfolio goes from 64 downloads and 5 of 8 remotes islanded to 45 and 2, both genuinely cross-major. Every other measured portfolio is byte-identical, and determine's timings and isCompatible counts are unchanged. The non-basis-pin fixture gives 2.2.2 a second copy so both candidates cost 2 and the newest wins the tie; with one copy 2.1.1 is simply the cheaper winner and nothing is redirected onto a tag a non-basis remote pinned, which is what that describe exists to test. islanding-cascade.characterisation.spec.ts becomes islanding-cascade.regression.spec.ts: two specs lock the fixed behaviour, and one labelled characterisation keeps the residual — the election is still per external, so members of one pool whose remote-count majorities sit on different version lines still elect opposite winners.
version-resolver.md's Optimal Version Strategy now counts the cost per remote copy, states that minimising it is exactly minimising downloads, and carries the visible consequence: a larger group of remotes on an older tag can win over a smaller group on a newer one. Ties still break toward the newest tag, and host precedence and latestSharedExternal short-circuit before the objective. Its known-limitation note narrows to the residual. F-A-islanding-cascade.md flips to fixed-cause-with-residual and records two measured dead ends so neither is retried blind: the family-instance election reverted earlier on this branch, and letting the agreement gate subsume the incompatibility gate. Measured, the latter removes #63's guarantee outright — 0 islands on every portfolio, majors={21,22}, the split packages back, and 14 pooling tests red including "no foreign framework runtime" — because a fully cross-major remote self-serves everything, so its draw set is {itself} and the gate passes it vacuously while its sole-provided winners stay shared to the modern side. All-or- nothing islanding is load-bearing for coherence, not merely an amplifier.
The e2e suite asserted on the import map the library *would* hand to the browser: jsdom, a mocked fetch, and setImportMapFn replaced by a collector. It could not observe the artefact actually being applied, which build a remote's code ends up holding, or what the page downloads. Playwright runs the published initFederation in Chromium instead. Nothing is substituted: the map is installed by the library's own replaceInDOM, modules load through its own loadModuleFn, storage is the browser's sessionStorage, and only the logger is injected. Each remote is served over HTTP from its own origin by one server dispatching on the Host header, with Chromium pointed at it via --host-resolver-rules, so http://mfe-a/ is a real cross-origin remote at the same URL the fixtures already used. Four claims move from derived to measured. The map is parsed out of the <script type="importmap"> elements in the document. Every remote's exposed module statically imports every entrypoint its remoteEntry declares and reports what each one resolved to, so a map that fails to cover a declaration fails the test, and a dedup is confirmed by the consumer landing on the provider's file. Download cost is what the server served and what the page evaluated - "pooling buys coherence for one extra download" is now 2 requests against 3. A warm init is a page load repeated against the same sessionStorage. Restructured around the shape of a remoteEntry rather than the rule being proved, one file per dimension: membership (npm scope, pool tag, shareScope, singleton, entrypoints), ranges (the version fields), islands (verdict shape, host precedence, the flag boundary), cost, chunks, lifecycle (warm, incremental, dynamic, and how the browser treats a second import map) and capture. Every rule the jsdom suite locked is carried over; the new cases are mostly ones only a browser can answer. Three of those corrected an assumption while being written: - Chromium honours the additive second import map and merges it, so the dynamic path is exercised natively. A merge cannot replace an existing mapping, which is the browser-level reason the delta has to be additive; both halves are pinned, alongside the es-module-shims configuration. - The unpooled capture is coherent per remote. Both tags are shared, but each remote gets the one its own range elected and the 21 half only reaches the islanded remote's scope, so the defect is in the shared set, not yet in the running page. The reachable crash is pinned in islands. - shell splits @angular/platform-browser across two builds: the root from the elected 22.0.8 build, the /animations entrypoints self-filled from its own 22.0.6 one. Intended - entrypoint-coverage documents sibling self-fill - and tolerable for the same reason patch drift between members is. npm test stays vitest and unit-only; coverage thresholds never depended on the e2e specs. npm run test-e2e is Playwright.
Every write goes through commit(), which sorts a shared external's versions newest-first, and determine relies on that: it reads versions[0] as the latest and keeps the first candidate of equal cost. Six pooling fixtures seeded the opposite order, so their expectations encoded winners production cannot elect and any tie-break change read as a regression. The pooling specs' seed() helpers now sort through newestFirst(), and the expectations are re-derived from sorted input. Two scenarios collapse under sorted input and are re-authored on coverage asymmetry instead of tag order, mirroring their e2e counterparts: patch-drift tolerance (mfe-a sole provider of forms, so it really draws from two agreeing builds) and the asymmetric subset consumer (the newer patch alternates per member, so the winners still split across both builds). The tagged design-system fixture swaps its tags so the incompatible consumer is the older remote, keeping the scenario reachable. store-remote-entry.version-order.spec.ts asserts the invariant itself over every scope, on both commit paths, so the assumption is checked rather than implied. Dropping the sort from commit() fails 3 of its 5 tests.
The suite was one file per dimension of a remoteEntry, which put the feature flag in five of them and left the input format almost uncovered. The before/ after argument for useAutoExternalPooling was spread across islands, capture, entrypoints and lifecycle, while every generated portfolio emitted the same shape: dense externals with a chunks property. Both flat forms are still in the field - two of the eleven recorded fixtures use them - and convertFlatSharedInfo, which decides how a flat entry is grouped, had no end-to-end coverage at all. Files are now organized by what the portfolio looks like, since that is what decides the verdict. flag is the only file that switches the flag: one portfolio held fixed, on and off, for the split family it prevents, the sibling it stops bridging, the two-major shared set it repairs, the dynamic path, and what it costs (one download on the minimal case, nothing on the capture). symmetric holds families whose remotes declare the same members, so only version lines differ; asymmetric holds those whose member sets differ - containment, ragged coverage, disjointness, sole-provided members and entrypoints leaving the shared set with their island. ranges, islands, cost and chunks are gone and every assertion in them moved; the two characterisations move verbatim. vendor-shapes is new and covers the four shapes a build emits: externals dense (entries) or flat (one element per entrypoint), crossed with chunking dense (a chunks property) or flat (@nf-internal/chunk-* pseudo-externals). shape() in the harness renders one declared portfolio into any of them, so a split family and a ragged family are each driven through all four and must produce the same island, the same map and the same builds per remote. A guard asserts the four served documents really differ, so the loop cannot silently collapse onto one shape. Three things only that matrix shows: - convertFlatSharedInfo is load-bearing on a flat build. Off, @angular/material and @angular/material/sort are unrelated externals that elect separate providers, so the page runs two builds of one package and no gate reacts - issue #61's shape, unprotected. On, they regroup and the widest-remote basis serves the package whole. Both arms are pinned. - Flat chunking maps a chunk into every declaring remote's scope, dense chunking only into the serving remote's. Same runtime result, different map; the flat extra mapping resolves and costs no download. - A chunk pseudo-external never joins a pool, though @nf-internal looks exactly like an npm scope: non-singleton externals never reach the shared-externals repo, so buildPools cannot see them. Origins and fixtures are anonymous and numbered: SCOPE is mfe1-mfe5 in the order a portfolio declares its remotes, and the eleven recorded entries are mfe1-mfe11 (1-7 the production capture, 8-11 synthetic). What a name used to carry - the cross-major outlier, the strict pin, the superset build - is now stated in the spec docblocks and the two fixture READMEs, where it can be accurate. New fixtures/pooling holds three entries in the one shape no capture has: flat externals, flat chunking and pool tags together. Auto-pooling is off for them, so the declared tag is the only thing forming the family; the incompatible remote islands across the family while rxjs, in no pool, still dedups. Also folds the entrypoint-coverage integration spec into entrypoints.e2e.spec.ts, where the policy can be checked against a running page rather than a seeded repository, and adds harness/coherence.ts for the whole-portfolio measures that capture and flag both need. 97 tests, up from 76.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #63.
The defect
With
useAutoExternalPooling: true, a monorepo family whose members are each individually compatiblecould still be served from two different builds at two different versions. A remote consuming both then
ran a mismatched framework family and crashed at runtime.
Two reproductions, both now regression-tested:
mfe-bpinscoreto~22.0.5, socoreresolves down to22.0.5;routerhas no such pin and onlymfe-aprovides it, so it resolves to22.1.0.mfe-a'sown
core@22.1.0is deduped away ⇒mfe-arunsrouter@22.1.0againstcore@22.0.5.core@22.0.5and norouter.corepins to the host's tag whilerouterresolves freely frommfe-a— same mixed family, and the most common real-world trigger.This is a regression from #56, not a pre-existing hole: #50's anchor model kept every remote on a
single build. #56 bought back the downloads that cost, by dropping the lockstep guarantee.
The fix — an agreement gate over determine's winners
Pooling still never re-runs the compatibility search, and it now explicitly never moves a winner:
host precedence and
requiredVersionacceptance stay settled bydeterminebefore pooling runs, andpooling grants no dedup the resolver did not already grant. It only decides, per remote, whether that
remote may take the dedups it was granted. Two gates:
scopeon any memberserves its whole family from its own build.
a family is normal — but not on builds that disagree. Two builds agree when every member both ship
sits on the same minor line:
22.0.6beside22.0.8is fine,22.0.5beside22.1.0is not.Disjoint builds agree vacuously, which keeps ragged coverage cheap. Islanding is monotone, so the gate
iterates to a fixed point, re-entered only after a round that scoped someone.
Both cases are fixed with host precedence fully intact — in case 2 the host keeps its pin and
mfe-agives way. The same gate is mirrored on the dynamic path (additive only: the committed import map is
immutable, so the loaded remote is the only thing that can move).
Why tag distance rather than
requiredVersion: the resolver has already applied every declared range perexternal, and the range cannot discriminate anyway — in the crash case and in the safe ragged case it is
the same
^22.0.0, accepting both tags. The minor line is the only signal in the data that separates them.Also in this branch — the download objective, counted per copy
Measuring the gate surfaced a second, independent defect in the resolver, filed as follow-up F-A. Its
measured cause is fixed here; a narrower residual stays open and is described further down.
A
scopeverdict serves every one of a version's remotes from that remote's own build, so it costs onedownload per uncached copy. The resolver's extra-download objective counted scoped versions — exact
only when every version has a single remote.
Under pooling that miscount was severe. Adding one previous-major remote to the capture (
eight, Angular21.2.15, honestly pinned, in conflict with nobody) made
@angular/router's single22.0.8version —shared by three agreeing remotes — cost 2 against each of two patch-drifted 21-line versions costing 1.
The winner moved to the 21 line, the three modern remotes'
router@22.0.8became strictly incompatible,and all-or-nothing islanding amplified one member's mis-election into all 24 members: 64 downloads, 5 of
8 remotes islanded.
The objective now weights each scoped version by its uncached copy count. Since the shared version costs
one download whichever candidate wins, minimising that sum is exactly minimising total downloads for the
external — the same objective the docs always claimed, in the right unit. That portfolio now resolves to
45 downloads and 2 islands, both genuinely cross-major remotes; every other portfolio below is
byte-identical.
Behaviour change: a larger group of remotes on an older tag can now win over a smaller group on a
newer one. That is what "fewest extra downloads" asks for, and under pooling it is the coherent direction,
but it is user-visible. Ties still break toward fewest torn entrypoints and then the newest tag — they are
simply rarer than when the cost was counted per version.
Cost: none.
determinemedians 0.262 / 0.479 / 0.279 ms against 0.259–0.315 / 0.469–0.516 /0.298–0.311 ms before — noise — and
isCompatiblecalls are identical (11 / 28 / 14), so no new calls tothe version-check port. One
O(versions)map per multi-version external, built outside theO(versions²)selection loop; the single-version short-circuit (12 of 18 externals on the capture) isuntouched. Host precedence and
profile.latestSharedExternalshort-circuit before the objective and areunaffected.
Measured, on a 7-remote production capture and four variants
The
7 + Ng21 siblingrow is the only one the copy-weighted objective moves: it read43 → 64,24 → 14and 5 islands with the gate alone.
Three things worth knowing, all in the release notes below:
unchanged or increased it — never reduced it. What it removes is the incoherence: shared sets spanning
majors
{21, 22}collapse to{22}, and packages split across two tags(
@angular/formsshared at both21.2.18and22.0.8) disappear. That is the crash being fixed.a range incompatibility, i.e. the pre-existing gate. So real-world exposure is small — and so is
real-world validation. The minor-line mechanism's measured effect lives in the two synthetic repro arms.
requiredVersionrejects (0 violations, all portfolios).Performance
Two wins, both measured with the same probe toggled in place:
A pool that islands nobody now writes nothing at all, and
determinehands pooling the set of externalsit re-elected so a warm init skips pooling entirely. The gate compares tags only — no
versionCheckdependency, no acceptance table, no scoring — and short-circuits before touching builds when a single
build already serves every member.
The copy-weighted objective adds nothing measurable to
determine(medians within noise, identicalisCompatiblecounts) — see the section above.F-A, the islanding cascade: cause fixed, narrower residual left open
The cascade was pre-existing #56 behaviour surfaced by measurement, not introduced here. Its measured
cause — the objective's unit — is fixed above, which is what takes
7 + Ng21 siblingfrom 64 downloads and5 islands to 45 and 2.
What remains is narrower: the objective is exact per external, but it is still evaluated per external.
Two members of one pool whose remote-count majorities sit on different version lines still elect opposite
winners, and pooling still amplifies the split.
islanding-cascade.regression.spec.tsholds two specs forthe fixed behaviour and one labelled characterisation for this residual, so a future fix reads as a
deliberate change.
F-A-islanding-cascade.mdcarries the two remaining avenues (a pool-aware objective, orletting pooling veto rather than re-point) and, importantly, two measured dead ends so neither is
retried blind:
the members it rejects and islands only if its own build disagrees with the builds it still draws on.
Measured, this removes bug: pooling should be on exact versions #63's guarantee outright: 0 islands on every portfolio,
majors={21,22}, the splitpackages back (
@angular/forms{21.2.18,22.0.8}), and 14 pooling tests red includingpooling.integration.spec.ts's "no foreign framework runtime". A fully cross-major remote self-serveseverything, so its draw set is
{itself}and the gate passes it vacuously, while its sole-providedwinners stay shared to the modern side. All-or-nothing islanding is load-bearing for coherence, not
merely an amplifier.
Release notes (patch)
useAutoExternalPooling(or apooltag), a coupled family can no longer be served toone remote from two builds that disagree. Such a remote now serves its whole family from its own build.
version, which is what a scoped version actually costs. Portfolios where one version is shared by many
remotes and its rivals by one each now resolve to the cheaper version — with pooling enabled this
removes cascading islands (one measured portfolio: 64 → 45 downloads, 5 → 2 islanded remotes).
downloads and buys coherence — pooling did not reduce downloads on any measured portfolio.
older tag can win the shared version over a smaller group on a newer one. Ties still break toward the
newest tag.
profile.latestSharedExternalopts out of the cost model entirely.writes. The cost-model change is free (no extra compatibility checks).
F-A-islanding-cascade.md— per-external election can still split a pooled familywhen its members' majorities sit on different version lines.
Version left at
4.5.3— preparing4.5.4is the usual separatechore:commit.Notes for review
applyWinnerwas extracted fromdetermine-shared-externals.tsin the first commit as a pure,spec-untouched refactor (778/778 green with zero spec edits), so the entrypoint-coverage policy from
PR fix(61): Issue where asymmetric entries were silently dropped instead #62 has one implementation.
efc5e70,fbfe4c9and4a4a4b7build a family-instance election, which4a4a4b7'ssuccessor reverts. It was built, measured and dropped: it changed the outcome of exactly one synthetic
fixture, was worse on two portfolios until a coherence guard held it back, and cost 3–4× the pooling
step on every init. The history is kept deliberately so the measurement is not repeated blind.
determine-shared-externals.spec.ts→when only a non-basis remote pinned the version:2.2.2gains asecond copy so both candidates cost 2 and the newest wins the tie. With a single copy,
2.1.1is simplythe cheaper winner and nothing is redirected onto a tag a non-basis remote pinned — which is what that
describe exists to test. The scenario is preserved; only the incidental tie is restored.
islanding-cascade.characterisation.spec.tsis renamed toislanding-cascade.regression.spec.ts, sincetwo of its three tests now lock fixed behaviour rather than a known deficiency.
benchmark/(the production capture and the three probes) is git-ignored — local measurement only.research.md,plan.md) are removed in the final commit; theyremain readable in this branch's history.