summaryrefslogtreecommitdiff
path: root/src/docs/UNIFY_SEARCH.md
blob: f38970b2b07116afdd909cbddc1f4503692b18e9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# Unifying the per-move search kernel in `search.c` and `split.c`

Status: **plan only, not started.** Do as the only change in a commit;
test with `precommit_check.sh` plus the `head_reference` node-count gate
below at each step. Line numbers below are current as of this revision
(EFP's explicit per-move pruning block has been removed from `Search()`;
only stale names remain — see "EFP is gone" below).

## Motivation

`Search()`'s main move loop (`search.c:909..1780`) and `HelpSearch()`
(`split.c:1058..1283`) independently implement what is supposed to be the
same per-move logic: make the move, compute its extension, decide whether
to prune or reduce it, search it with a PVS window, re-search if a reduced
move fails high, unmake it. They have drifted apart because every
improvement to this logic has been written once, in `search.c`, and not
reliably ported to `split.c`. Concretely, today:

- `Search()` has LMP (late move pruning, via `ShouldLatePrune`/the joint
  FH% table). `HelpSearch()` does not.
- `Search()` gates reduction/pruning eligibility through the single
  `CanWeReduceOrPruneMove()` (searchsup.c:783), which the routine's own
  comment documents as 9 base checks plus 2 move-identity exemptions
  (killer-adjacency, countermove match). `HelpSearch()` (split.c:1187-1193)
  has a hand-rolled 7-condition gate that is missing: the SEE-negative-
  capture carve-out, the mate-magnitude-alpha check, the `fThreat` check,
  and both exemptions. **This is a bigger behavioral gap than "HelpSearch
  lacks LMP"** — `HelpSearch()` is currently reducing/pruning-eligible on
  moves `Search()` would exempt outright.
- `Search()` passes real `bbMinorAttacksSnapshot`/`bbRookAttacksSnapshot`
  attack-bitboard snapshots into `EstimateHangingMaterialCp`. `HelpSearch()`
  passes zeroed snapshots (`split.c:1215-1216`), degrading its hang
  estimate (and, by extension, `ShouldLatePrune`'s joint-table lookup once
  step 4 below gives it one).
- `split.c:1245` and `split.c:1268` write `ctx->sPlyInfo[ctx->uPly]
  .iExtensionAmount` — but both writes happen **after** `MakeMove`, i.e.
  after `ctx->uPly` has already advanced (confirmed: `search.c:465` binds
  `pi = &ctx->sPlyInfo[ctx->uPly]` *before* the move loop's `MakeMove`
  calls; `move.c:928` is where `MakeMove` increments `ctx->uPly`).
  `split.c:1156`'s own `ComputeMoveExtension` call already compensates
  with `uBegin[ctx->uPly - 1]` one line away, so the "-1 after MakeMove"
  pattern is known and applied inconsistently, not unknown. **This is two
  separate bugs, not one:**
  1. Wrong slot: writes the child's `iExtensionAmount`, not the mover's —
     invisible to the null-move history-reduced-bailout check one ply
     down (searchsup.c).
  2. Stale-on-skip: `search.c:1386`'s own comment documents that it used
     to only write `pi->iExtensionAmount` when `iExtend < 0`, and that was
     itself a bug (a later sibling move could read a stale reduction left
     by an earlier one) — fixed in `Search()` by writing it
     unconditionally every iteration. `split.c:1245`/`1268` still only
     write inside `if (iLMR < 0)` / the re-search branch, i.e. still have
     the stale-on-skip bug `Search()` already fixed once.
  Both must be fixed together: unconditional write, to
  `ctx->sPlyInfo[ctx->uPly - 1].iExtensionAmount`.

## EFP is gone — update, don't carry forward

A prior session removed EFP's explicit per-move futility-pruning block
from `Search()`'s move loop entirely. Confirmed by search: `ShouldFutilityPrune`/
`FutilityPrun*`/`StaticNullMove`/`ShouldStaticNull` have zero hits anywhere
in `search.c`/`searchsup.c`/`split.c`. Only inert names remain:
`EFP_FH_MIN_SAMPLES` (search.c:84, unused #define), `_PostReduceDepthBucketEfpAligned`
(a depth-bucketing helper, name is now just vestigial), and
`fAnyMoveEFPPruned` (search.c:1851, read in an `else` branch comment on
the store-upper-bound path — confirm whether this flag can still ever be
set anywhere before relying on it in the extraction; if nothing sets it
any more it's dead and should be dropped in step 0 below, not carried into
the shared kernel).

This also **resolves the doc's previous closing question** ("unify EFP and
LMP before this project") — there's no EFP left to unify; `ShouldLatePrune`'s
joint FH% table is the sole pruning mechanism now. Drop that requirement.

It also means the previously-planned "related gap" about `pi->uPVDistance`
never written in `HelpSearch()` is **moot**: `uPVDistance` has zero hits
anywhere in the codebase now (it was EFP-era instrumentation, removed
along with EFP). Nothing to port.

## What should NOT be extracted

The two loops differ along three axes that are not just stylistic — they
reflect genuinely different control/data flow, and trying to force them
into one shape will make the result worse, not better.

1. **Move acquisition.** `Search()` pulls from a staged iterator (hash
   move, killers, generated moves, ..., tracked via index `x`).
   `HelpSearch()` pulls from a shared move pool via `_GetNextParallelMove`,
   which also hands back the *current* shared `iAlpha`/`iBestScore`
   (another helper thread may have raised them since this thread's last
   iteration). This is loop-header logic. **Stays in each caller.**
2. **Result publication.** `Search()` keeps `iAlpha`/`mvBest` as plain
   thread-local variables, calls `UpdatePV()`, and uses `goto end`/`goto
   no_more_moves`. `HelpSearch()` publishes through the locked
   `_UpdateSplitInfo(ctx, mv, iScore, u)` and checks `g_SplitInfo[u]
   .fTerminate` after every move. Different synchronization models.
   **Stays in each caller.**
3. **The split-launch decision itself** (`search.c:1082-1173`,
   `StartParallelSearch` and the `g_fCanSplit`/`g_uNumHelpersAvailable`
   gating). Must stay outside the extracted kernel, or a helper thread
   executing the kernel could attempt to launch a nested split, which the
   current design does not expect. **Stays in `search.c` only.**

## What SHOULD be extracted

The inner per-move kernel, called once per move after it's been pulled off
whichever move source the caller uses — current location
`search.c:1175-1681` (SEE computation through `UnmakeMove` and the
`iCumulativeExtend` restore), immediately before the publication tail that
starts at `search.c:1684`:

```
SEE (pre-move, on pos)
  -> MakeMove
  -> ComputeMoveExtension
  -> CanWeReduceOrPruneMove gate
       -> ShouldLatePrune (LMP)
       -> GetLMRReduction (LMR)
  -> PVS search (null-window, then full-window re-search if it raises alpha)
  -> re-search to full depth if a reduced move fails high
  -> UnmakeMove
```

## The instrumentation problem (the design question the old draft missed)

The span to extract is NOT a clean make/decide/search/unmake block with no
side channels. The publication tail at `search.c:1684-1777` reads several
locals the kernel's interior sets, and `JOINT_EXTRA_VERIFY`'s shadow-search
block sits *between* the PVS re-search and `PRUNE_VERIFY`'s fail-high
check, inside the make/unmake span — the instrumentation can't be
boundary-excluded without duplicating the real move-handling logic around
it. Locals that cross the proposed kernel/caller boundary in both
directions:

- Caller → kernel (already loop-invariant, computed once outside the
  per-move loop; pass as params, don't recompute): `uRawDepthB`
  (`search.c:993`), `uRawImproveB`, `fNodeSplitEligible`.
- Kernel → caller (read by the publication tail at 1684+, must survive the
  call): `fIsLeftoverMove`, `fMoveIsPruneReduceEligible`,
  `uThisMoveHangBucket`/`GapBucket`/`NumBucket`/`DepthBucket` (all
  `PERF_COUNTERS`/`FH_TABLE_COLLECTION`-gated), `fThisMoveIsCountermoveMatch`/
  `uCMEvidenceBucket` (`MOVE_ORDERING_EVIDENCE_COLLECTION`-gated),
  `fThisMoveJointExtraEligible`/`uThisMoveJointExtraTier`
  (`PERF_COUNTERS`-gated), `fVerifyFlagged`/`uVerifyBand`/`uVerifyDepthB`/
  `uVerifyAttB`/`uVerifyRateB` (`PRUNE_VERIFY`-gated), and the `TEST_NULL`
  node-cost pair.

**Decision needed before writing code, not discovered mid-extraction**:
bundle all of this into one `OUT` struct (e.g. `MOVE_KERNEL_DIAGNOSTICS`,
`#ifdef`-sectioned to match each field's existing gate) that the kernel
fills in and the caller's publication tail reads — rather than a long
flat parameter list. The user's note below ("put what you can in
`PLY_INFO`") is the other half of this: some of these (the bucket/tier
values) are genuinely per-move-at-this-ply state and belong as fields on
`ctx->sPlyInfo[ctx->uPly - 1]` (indexed the same way `iExtensionAmount`
already is) rather than threaded through call/return at all — this
removes most of the diagnostics-struct bulk and gives `HelpSearch()`'s
own ply slot the same fields for free. Resolve this split (struct vs.
`PLY_INFO` fields vs. a mix) as part of scoping the extraction, before
step 3.

## Two sequencing traps that will break the "bit-identical node counts" gate

- `pf->iCumulativeExtend += iExtend` (search.c:1387) happens *after* the
  LMP-prune `continue` (search.c:1313-1315) — the prune path `UnmakeMove`s
  but never increments/decrements `iCumulativeExtend` for that move at
  all. A `MOVE_KERNEL_PRUNED` return must preserve that asymmetry exactly;
  don't "clean up" by making prune and search paths symmetric around
  `iCumulativeExtend`.
- `WE_SHOULD_STOP_SEARCHING` (search.c:1684) is checked *after*
  `UnmakeMove` and *after* the `iCumulativeExtend -=` restore. A
  `MOVE_KERNEL_ABORTED` return must not skip either of those — the kernel
  itself must still fully unwind (unmake + restore) before reporting
  abort, not bail out of the middle of its own cleanup.

## Proposed signature

```c
typedef enum _MOVE_KERNEL_RESULT
{
    MOVE_KERNEL_SEARCHED,   // move was made, searched, and unmade; iScore valid
    MOVE_KERNEL_PRUNED,     // move was late-move-pruned; not made; iScore invalid
    MOVE_KERNEL_ABORTED     // search was stopped mid-move; iScore is a partial result
} MOVE_KERNEL_RESULT;

FORCEINLINE MOVE_KERNEL_RESULT
SearchOneMove(SEARCHER_THREAD_CONTEXT *ctx,
              MOVE mv,
              ULONG uDepth,
              SCORE iAlpha,
              SCORE iBeta,
              SCORE iEval,
              ULONG uLegalMoves,
              FLAG fIsLeftoverMove,
              FLAG fFullWindow,
              FLAG fThreat,
              ULONG uRawDepthB,
              ULONG uRawImproveB,
              BITBOARD bbMinorAttacksSnapshot[2],
              BITBOARD bbRookAttacksSnapshot[2],
              OUT SCORE *piScore,
              OUT INT *piExtend);
    // Diagnostics (hang/gap/num/depth buckets, countermove-evidence
    // bucket, joint-extra tier, prune-verify band) are NOT parameters --
    // see "the instrumentation problem" above. They land in
    // ctx->sPlyInfo[ctx->uPly - 1] (written by the kernel before it
    // unmakes) wherever that's a clean fit, or an OUT diagnostics struct
    // otherwise. Finalize the split while scoping step 3, not here.
```

Notes on why each non-obvious parameter is there:

- **`fFullWindow` is explicit, not inferred.** `Search()`'s current code
  infers "is this the first move at this node" from the sentinel
  `iBestScore == -INFINITY_SCORE` (search.c:1396). That sentinel is only
  true for `Search()`'s actual first move. `HelpSearch()` must never take
  that branch — a helper thread's first move off the shared pool is *not*
  the node's first move — and today this is only safe because
  `_GetNextParallelMove` happens to never hand back `-INFINITY_SCORE`.
  Once this logic is shared, that implicit contract becomes load-bearing
  in a way that won't assert if violated. Making it an explicit
  caller-supplied flag removes the trap.
- **`uLegalMoves` is a parameter, not computed inside.** It feeds
  `MoveNumBucket`/`ShouldLatePrune`'s floor and `CanWeReduceOrPruneMove`'s
  own `uLegalMoves > 1` check. `search.c` has it as a running counter;
  `split.c` must pass `g_SplitInfo[u].uAlreadyDone + uMoveNum + 1`
  (matching what it already computes locally as `uSplitLegalMoves` at
  split.c:1217 — confirm this matches the `> 5` floor `split.c:1189`
  hardcodes today, which should become a call into the shared
  `CanWeReduceOrPruneMove` instead of staying hand-rolled). Getting this
  wrong silently changes LMP/LMR aggressiveness with no assert to catch
  it.
- **`fThreat` must be plumbed through `g_SplitInfo`.** `CanWeReduceOrPruneMove`
  takes `fThreat` as a real gating input (searchsup.c:783) but
  `chess.h:1612-1662`'s `SPLIT_INFO` struct has no such field today — add
  one, populated by the originating thread at split-launch time (same
  pattern as `iEval`/`iImprovement`), or `HelpSearch()` can't call the
  real gate at all.
- **Attack snapshots are real parameters, not internally zeroed.** This is
  the fix for the degraded-hang-estimate gap above: `HelpSearch()` must
  start passing real snapshots instead of the zeroed
  `bbNoMinorAttacksSnapshot`/`bbNoRookAttacksSnapshot` it uses today
  (split.c:1215-1216). Requires extending `g_SplitInfo[]` to carry
  `bbMinorAttacksSnapshot`/`bbRookAttacksSnapshot` from the originating
  thread (already computed in `Search()` before `StartParallelSearch`).
  **Open risk, worth raising with the user explicitly (not just noting):**
  once the kernel is shared, `FH_TABLE_COLLECTION`'s sampling
  (search.c:1323-1365) fires from helper threads too. If step 4 (unify)
  lands before step 5 (real snapshots), those samples get logged with
  zeroed snapshots — training the joint FH% table on degraded inputs, not
  just giving `HelpSearch()` a degraded *decision*. **Recommendation: land
  step 5 together with step 4, not as a follow-up**, or gate
  `FH_TABLE_COLLECTION`'s increments to the originating thread only
  (`ctx->uPly` vs. some "is this a split helper" check) until step 5 lands.
  Ask the user which they'd rather do before step 4.
- **Return value is an enum, not control flow via `goto`.** The kernel
  can't `goto end`/`goto no_more_moves`/`continue` — those targets don't
  exist in the extracted function. Each caller's own loop dispatches on
  the enum: `Search()` on `PRUNED` does `continue` (and must NOT touch
  `iCumulativeExtend`, see the sequencing trap above); on `ABORTED` it
  does its `goto end` with `iBestScore = iScore`; `HelpSearch()` on
  `ABORTED` does `break`. The abort *condition* itself also differs
  (`WE_SHOULD_STOP_SEARCHING` vs. `g_SplitInfo[u].fTerminate`) — stays a
  caller-side check made around the kernel call, not a predicate pushed
  into it.

## Suggested sequencing

0. **Housekeeping**: confirm whether `fAnyMoveEFPPruned` (search.c:1851)
   can still be set anywhere post-EFP-removal; if dead, delete it rather
   than carry it into the shared kernel's scope. Small, standalone.
1. **Standalone fix**: `iExtensionAmount`'s two bugs in `split.c` (wrong
   slot + stale-on-skip, see Motivation above) — write
   `ctx->sPlyInfo[ctx->uPly - 1].iExtensionAmount = iExtend` unconditionally,
   every iteration, matching `search.c:1386`'s already-fixed convention.
   Commit on its own, verified via `precommit_check.sh`.
2. **Extraction**: pull the kernel out of `search.c` into a new shared
   header (`search_inline.h`, `FORCEINLINE` across translation units
   requires a header), make `Search()` call it, with `HelpSearch()`
   untouched (still running its own inline copy, including its own
   gap-riddled gate). This isolates "did the extraction change
   `Search()`'s behavior" from "did unifying the two callers change
   `HelpSearch()`'s behavior." Resolve the instrumentation-placement
   question (PLY_INFO fields vs. diagnostics struct) as part of this
   step, before writing the header.
3. **`g_SplitInfo` plumbing**: add `fThreat` and the two attack-snapshot
   bitboards to `SPLIT_INFO` (chess.h), populated at split-launch time.
   Do this *before* step 4, not after — see the `FH_TABLE_COLLECTION`
   risk above.
4. **Unification**: make `HelpSearch()` call the same kernel via the real
   `CanWeReduceOrPruneMove()` gate (replacing its hand-rolled 7-condition
   version) and `ShouldLatePrune`, removing its inline copy entirely. This
   is the commit that actually changes `HelpSearch()`'s behavior — it
   gains LMP, the full 9-check+2-exemption gate, and real attack
   snapshots, all at once, since step 3 already landed the plumbing they
   need.

## Verification plan

Standard gates at every step (`precommit_check.sh` — `TEST=1` self-test
suite + `debug_smoke_test.sh`), plus:

1. **Isolate the single-threaded path first (after step 2).** Force
   `g_uNumHelpersAvailable = 0` (no split ever fires) and confirm **node
   counts are bit-identical to HEAD** on the three curated suites
   (`ecm_ringers`, `ecm_confident_quick`, `ecm_hard_quick`) at a fixed `sd`
   depth, per the `head_reference` protocol in CLAUDE.md. This should be a
   pure refactor at this stage — "no change whatsoever," not "no
   significant change." Any node-count delta here is a real extraction
   bug, not noise. Watch specifically for the two sequencing traps above
   (`iCumulativeExtend` on the prune path, `WE_SHOULD_STOP_SEARCHING`
   ordering) — those are exactly the kind of thing that produces a subtle
   node-count drift instead of a crash.
2. **After step 4, re-enable helpers and compare separately**, expecting a
   real difference this time (`HelpSearch()` now has LMP, the full gate,
   and real snapshots it didn't have before). Compare solve counts and
   fail-high rates across the three curated suites per CLAUDE.md's
   existing methodology (measure the pick, not just the game). Rebuild
   `head_reference/` as the new baseline once this change is committed.
3. Run the full `precommit_check.sh` gate before every commit in the
   sequence above, not just at the end — this is a search/move-generation
   change throughout.

## Performance: `Search()` is the current profiler hotspot

Confirmed with the user: `Search()` is the single hottest function in the
program right now. That changes the risk profile of this refactor from
"pure correctness/maintainability" to "must not cost NPS" — a few concrete
implications for how to do each step:

- **`FORCEINLINE` is load-bearing, not decorative, here.** The whole
  point of extracting into a header function instead of a normal
  `.c`-file one is that the compiler can fully inline `SearchOneMove` at
  both call sites and optimize it in the context of each caller's
  surrounding code (register allocation across the call, dead-branch
  elimination of whichever `#ifdef` blocks aren't compiled in) exactly as
  if the code were still written inline. If it isn't actually inlined in
  the shipping `GENETIC=1 PERF_COUNTERS=1 MP=1 SIXTYFOUR=1` build, this
  refactor becomes a real regression (a non-inlined call in the hottest
  function in the program), not a neutral one. Don't just assert
  `FORCEINLINE` and move on — check post-step-2 that `clang` actually
  inlines it (`-Rpass=inline`/`-Rpass-missed=inline`, or inspect
  `nm`/disassembly for a surviving `SearchOneMove` symbol) before trusting
  the bit-identical-node-count gate alone; a node-count match doesn't
  prove NPS didn't drop.
- **Don't let the diagnostics-boundary decision regress the hot path.**
  The "instrumentation problem" section above already flags that several
  locals need to cross the kernel/caller boundary. For the fields that
  move into `ctx->sPlyInfo[ctx->uPly - 1]`: writing them is already in
  the hot path today (`iExtensionAmount` already lives there), so this is
  likely free. For anything kept as an `OUT` diagnostics struct instead:
  every field in that struct gets written on every move in the
  `PERF_COUNTERS`/`FH_TABLE_COLLECTION`-eligible path even when the
  calling code only reads a couple of them, and the struct itself is a
  stack object passed by pointer — prefer the `PLY_INFO`-field approach
  wherever a field is genuinely per-ply state (most of them), and reserve
  an `OUT` struct only for values with no natural per-ply home. This is
  exactly the tradeoff the "instrumentation problem" section says to
  resolve before writing code — performance is now part of *why* that
  decision matters, not just design cleanliness.
- **Don't widen the non-`PERF_COUNTERS` build's hot path.** The
  `SearchOneMove` signature above is sized for the worst case (every
  diagnostic input). In a release build with none of those macros
  defined, make sure the corresponding parameters either compile away
  entirely (same `#ifdef`-sectioning convention the rest of this file
  already uses) or are proven by the compiler to be dead weight — don't
  let `uRawDepthB`/`uRawImproveB`/the snapshots turn into always-live
  arguments on every call when the only consumers are instrumentation
  that's compiled out. Verify by comparing generated code size/a quick
  NPS run of a `PERF_COUNTERS`-undefined build before and after step 2,
  not just the instrumented build used for the suite gates.
- **Opportunistic, in-scope wins to look for while the code is open**
  (don't go hunting outside this refactor's diff, but don't ignore an
  obvious one either, since the whole per-move span is being rewritten
  once regardless): any snapshot/bucket value currently recomputed
  per-move that's actually loop-invariant per node (the doc's
  `uRawDepthB`/`uRawImproveB` hoist is already one example carried over
  from the current code — check for others while rewriting this block,
  e.g. whether `GetMoveFailHighPctThousandths(mv, &uFHAttempts)` or the
  joint-table lookup have any sub-computation that's the same across
  moves at a node and is currently being redone); and whether the
  diagnostics-struct-vs-PLY_INFO choice above can also drop a redundant
  write (e.g. if a value is written to both a local and `pi->` today,
  keeping only the `pi->` copy and reading it back removes a stack
  write). Treat these as reviewed, measured changes (node-count +
  NPS-on-`head_reference`-suite comparison), not assumed-safe
  micro-optimizations — a performance claim needs the same before/after
  evidence a correctness claim does here.
- **Verification gate, added to the existing plan**: alongside the
  bit-identical node-count check in step 2's isolation gate, also record
  wall-clock NPS for the same `sd`-depth suite runs, before and after
  each step, on this box (which the project's own CLAUDE.md already
  warns is noisy under shared load — do a back-to-back A/B, not a single
  run, per the Environment notes). A refactor that's node-count-identical
  but measurably slower per node is a regression this plan must not ship
  silently.

## Open question for the user

Whether to gate `FH_TABLE_COLLECTION`'s sampling to the originating
thread only until step 3's snapshot plumbing lands, or accept a short
window (within the same commit sequence, steps 3+4 landing together) where
no helper-thread sample is ever collected with zeroed snapshots because
the plumbing and the unification land atomically. Recommendation above is
the latter (land 3 and 4 together) — flagging here so it's a decision, not
an assumption.