|
| 1 | +# flex_pca: sampling-distance control, reconstruction backends, and the envelope mask as solve support |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +**Planning only (2026-09-10, revised after review). No code is proposed for immediate implementation.** |
| 6 | + |
| 7 | +Scope, as decided: |
| 8 | + |
| 9 | +- `flex_pca` contains no masking logic. The mask is an input: a real-space |
| 10 | + `[0,1]` volume with a smooth falloff, supplied through `pcg_mskfile`. The |
| 11 | + program resamples and validates it, nothing more. |
| 12 | +- Before anything else, the fixed `box_crop=64` working box is replaced by a |
| 13 | + sampling-distance parameter that controls down-sampling, in the way |
| 14 | + `refine3D` and `abinitio3D` do it. |
| 15 | +- Then `rec_backend=gridding` (the current implementation) and |
| 16 | + `rec_backend=pcg` (new) are supported in the same pattern as those |
| 17 | + workflows, reusing `reconstructor_pcg` wherever it applies, first with the |
| 18 | + spherical support that is used today. |
| 19 | +- Only then does `pcg_mskfile` give the envelope-constrained analysis, and it |
| 20 | + is available on the PCG backend only, as it is everywhere else |
| 21 | + (`parameters_phases.f90:897`). |
| 22 | + |
| 23 | +Code references are to `0eee68726`. |
| 24 | + |
| 25 | +## 1. Order of work |
| 26 | + |
| 27 | +```text |
| 28 | +step 0 smpd_target-driven working box (replaces box_crop=64) |
| 29 | +step 1 rec_backend=gridding|pcg in flex_pca, spherical support, reconstructor_pcg reused |
| 30 | +step 2 pcg_mskfile -> soft envelope as the solve support (PCG only) |
| 31 | +``` |
| 32 | + |
| 33 | +Each step is gated on its own and none needs the next; step 0 is a |
| 34 | +prerequisite for both others because the box decides the memory and the |
| 35 | +resolution of everything below it. |
| 36 | + |
| 37 | +## 2. Step 0: a sampling distance instead of a fixed box |
| 38 | + |
| 39 | +### 2.1 Today |
| 40 | + |
| 41 | +`exec_flex_pca` sets `box_crop=64` unless given |
| 42 | +(`simple_commanders_flex_pca.f90:43`), then `derive_flex_pca_band` derives |
| 43 | +`lp = 2.5 * smpd_crop` (`COV_LP_OVER_NYQUIST`) and `box_rec = box` |
| 44 | +(lines 133-170); `parameters` derives |
| 45 | +`smpd_crop = box/box_crop * smpd` (`parameters_phases.f90:326`). The |
| 46 | +particle cache (`ptcl_cache_ensure`), the projected-model image prep, the |
| 47 | +basis reconstructors, the `vol1` mean and the probe-basis volumes |
| 48 | +(`read_and_crop` at `box_crop`/`smpd_crop`) all follow `box_crop`. The |
| 49 | +covariance is therefore fitted at whatever sampling `box/64` happens to be: |
| 50 | +~3.5 A on a 200 A particle in a 256 box at 0.9 A, ~6 A in a 400 box -- the |
| 51 | +resolution of the analysis is an accident of the box. |
| 52 | + |
| 53 | +### 2.2 Proposed |
| 54 | + |
| 55 | +Follow `refine3D` (`simple_commanders_refine3D.f90:187-215`): a target |
| 56 | +sampling distance, `autoscale(box, smpd, smpd_target, box_crop, smpd_crop, |
| 57 | +scale, minbox)` from `simple_magic_boxes.f90:104` (magic box sizes, never |
| 58 | +finer than the data, floor at 64), and `box_crop`/`smpd_crop` set on the |
| 59 | +command line for the workers. Points specific to `flex_pca`: |
| 60 | + |
| 61 | +- **Default.** Helix-level detail needs the working Nyquist at or below |
| 62 | + ~4.5 A (alpha-helical pitch 5.4 A, strand separation 4.8 A), i.e. |
| 63 | + `smpd_target <= 2.25 A`. A default of **2.2 A** is proposed; the exact |
| 64 | + value is an open question (§6), and `refine3D`'s 1.3 A is too fine for |
| 65 | + the covariance stage at present accumulator sizes. |
| 66 | +- **`lp` and `smpd_target` are one decision.** Today `lp` is derived from |
| 67 | + the box; with a sampling distance both directions are possible and only |
| 68 | + one should be primary. Proposed: `smpd_target` is primary and |
| 69 | + `lp = COV_LP_OVER_NYQUIST * smpd_crop` stays derived; an explicit `lp` |
| 70 | + instead sets `smpd_target = lp / COV_LP_OVER_NYQUIST` before |
| 71 | + `autoscale`, so a user asking for a 6 A band gets a 2.4 A lattice rather |
| 72 | + than a 6 A band on a 3.5 A lattice. An explicit `box_crop` remains an |
| 73 | + override for tests. |
| 74 | +- **`box_rec` stays the native box.** The delivered state maps are not |
| 75 | + capped at the covariance Nyquist; unchanged. |
| 76 | +- **Memory scales as `box_crop^3`.** The coupled M-step accumulators |
| 77 | + (`rho_cross_exp`, `npairs x` expanded grid) and, on the PCG backend, the |
| 78 | + pair kernels (§3.3) grow with the cube of the box. Going from 64 to 96 |
| 79 | + or 128 is x3.4 or x8. The existing `COV_ATHR_BUDGET` / |
| 80 | + `cov_dim_budget` machinery budgets the reduced solve's dimension; the |
| 81 | + same budgeting has to cover the accumulators, and the log should print |
| 82 | + the footprint at start-up. |
| 83 | +- **Provenance.** The embedding cache (`infile` resume) is tied to the |
| 84 | + particle selection; it must also be tied to `box_crop`/`smpd_crop`, or a |
| 85 | + cache built at one sampling will be resumed at another. Same for the |
| 86 | + probe-basis files and `flex_pca_probe.txt`. |
| 87 | + |
| 88 | +## 3. Step 1: `rec_backend` in flex_pca |
| 89 | + |
| 90 | +### 3.1 Which reconstructions |
| 91 | + |
| 92 | +`flex_pca` reconstructs in two places (the mean is an input, `vol1`; |
| 93 | +`COV_MEAN_FROM_DATA=.false.`): |
| 94 | + |
| 95 | +1. **State maps** (`simple_flex_pca_rec3D.f90`): kernel-weighted |
| 96 | + reconstruction per state and half at `box_rec`, currently gridding with |
| 97 | + a shell-relative density floor (`floor_rho`), `zero_background` and |
| 98 | + `mask3D_soft` at the broadest sphere in the box (lines 495-502). This is |
| 99 | + the direct analogue of `reconstruct3D`. |
| 100 | +2. **The M-step basis solve** (`simple_flex_pca_em_iter.f90:2394-2432`): |
| 101 | + `solve_coupled_basis_exp` (`simple_flex_projected_latent_model.f90:446`) |
| 102 | + divides the `k` right-hand sides per Fourier voxel by the `k x k` |
| 103 | + gridding density `rho_cross_exp`, then inverse-KB envelope, half-set |
| 104 | + FSC Wiener `2F/(1+F)`, band-limit, `mask3D_soft(msk_crop)`. |
| 105 | + |
| 106 | +Both get the backend switch; the gridding paths stay as they are. |
| 107 | + |
| 108 | +### 3.2 Pattern |
| 109 | + |
| 110 | +`rec_backend` is already a validated global parameter |
| 111 | +(`parameters_phases.f90:779-782`), `pcg_mskfile` already requires it to be |
| 112 | +`pcg` (line 897), and the PCG strategy's preconditions apply unchanged |
| 113 | +(`pcgop=kernel`, `mskdiam` set, `projrec=no`; |
| 114 | +`simple_rec3D_pcg_strategy.f90:613-632`). `flex_pca` has its own |
| 115 | +master/worker stage protocol (`simple_flex_pca_distr.f90`, |
| 116 | +`PCA_STAGE_STATES`), so the selector lives in the flex layer rather than in |
| 117 | +`create_rec3D_strategy`: one switch on `params%rec_backend` at the two |
| 118 | +sites above, and the `>>> ... REC3D EXECUTION (gridding|kernel PCG)` style |
| 119 | +log line so a run says which backend it used. |
| 120 | + |
| 121 | +### 3.3 States on `reconstructor_pcg` |
| 122 | + |
| 123 | +Reused wholesale; the kernel weights enter through the noise model, not |
| 124 | +through a new weight array. |
| 125 | + |
| 126 | +**Mechanism.** In `prepare_fused_planes` |
| 127 | +(`simple_reconstructor_pcg.f90:2158-2222`) each particle contributes the |
| 128 | +right-hand-side plane `weighted(h,k) = conj(CTF.shift) y(h,k) / sig2(shell,i)` |
| 129 | +and the density plane `absT2(h,k) = CTF^2 / sig2(shell,i)`; `absT2_plane` |
| 130 | +(2087-2145) builds the same density for the kernel path. Everything else in |
| 131 | +the solve -- the preconditioner `1/(rho+floor)`, the Gram kernel `Khat`, the |
| 132 | +data scale behind a relative `lambda` and the `P_tau` scale, and the raw |
| 133 | +accumulator artifacts the workers write -- derives from those two |
| 134 | +accumulators (`finalize_density_accum`, `finalize_khat`, |
| 135 | +`update_lambda_from_density`). The weighted least-squares objective |
| 136 | +`sum_i w_i ||Sigma_i^{-1/2}(A_i x - y_i)||^2` is therefore obtained exactly |
| 137 | +by passing `prep_particles` the array |
| 138 | +`sig2(0:R,i) = sigma2_noise(:,pinds(i)) / w_i(s)`: the weight multiplies the |
| 139 | +RHS term and `|T_i|^2` identically, as the normal equations require, and |
| 140 | +propagates to the preconditioner, the kernel, the ridge and the raw |
| 141 | +artifacts without touching any of them. The rec3D PCG strategy already |
| 142 | +builds that array per particle from the sigma store |
| 143 | +(`simple_rec3D_pcg_strategy.f90:712-718`; under `cc` it is a unit array and |
| 144 | +the division applies just the same), so the change is confined to the flex |
| 145 | +caller: one division per particle and shell before `prep_particles`, per |
| 146 | +state and per half, the even/odd split coming from the disjoint halves of |
| 147 | +the weight table as `mask_state_weights_by_half` does today. |
| 148 | + |
| 149 | +**Details.** |
| 150 | + |
| 151 | +- Particles with `w_i(s) = 0` are removed from `selection`/`pinds` for |
| 152 | + that state rather than given an infinite sigma; they contribute nothing |
| 153 | + and dropping them shrinks the particle pass, which matters for a state |
| 154 | + holding a small fraction of the data. A floor on `w` (of order 1e-3) |
| 155 | + keeps `sig2` finite in single precision; particles below it are dropped |
| 156 | + too. |
| 157 | +- The ridge is set in relative mode (`set_lambda_relative`), not the |
| 158 | + absolute `PCG_LAMBDA` the refinement strategy passes to `new`: with |
| 159 | + weights in `[0,1]` the effective particle count of a sparse state can be |
| 160 | + a small fraction of `N`, and an absolute 1e-3 would be a materially |
| 161 | + stronger prior on low-occupancy states than on populated ones, whereas |
| 162 | + the relative form scales with the weighted `D` and treats every state |
| 163 | + alike. |
| 164 | +- The shell-relative preconditioner floor `RHO_FLOOR_FRAC` scales with the |
| 165 | + weights automatically; it takes over the role of flex's `floor_rho` |
| 166 | + without biasing the estimate. |
| 167 | +- Rejected alternative: an explicit per-particle weight array inside the |
| 168 | + reconstructor, multiplied in at the two plane routines. Same numbers, |
| 169 | + more code, a second scaling path beside sigma; `add_raw_accum_weighted` |
| 170 | + is no help since it weights whole artifacts (trailing-reconstruction |
| 171 | + continuation), not particles. It would only be worth it if the solver |
| 172 | + sidecars had to report unweighted sigma statistics, and the data scale |
| 173 | + of the weighted problem is the one that describes the solve. |
| 174 | +- The sigma route works for the states because the weight is a scalar per |
| 175 | + particle. It does not carry over to the M-step, where the per-particle |
| 176 | + weight is the `k x k` matrix `E[z_i z_i']` and the pair-weighted kernels |
| 177 | + need the explicit generalization of `accumulate_absT2` (§3.4). |
| 178 | + |
| 179 | +**Flow.** Per state and half: accumulate at `box_rec`, workers |
| 180 | +`write_raw_accum` (keyed by state and half), master `add_raw_accum`, base |
| 181 | +solve cold with at least `FINAL_PCG_MAXITS_FLOOR` iterations, spherical |
| 182 | +support via `set_mask` at the `msk` radius, shipped map `window * u`, |
| 183 | +provenance sidecar. This replaces `floor_rho`, `zero_background` and the |
| 184 | +broadest-sphere mask. The merge gate |
| 185 | +(`simple_flex_pca_merge.f90:392-417`) then compares support-constrained |
| 186 | +states; its 0.98 similarity threshold was calibrated on sphere-masked |
| 187 | +gridding maps and is re-calibrated on the synthetic fixture. |
| 188 | + |
| 189 | +### 3.4 The M-step on the PCG operator |
| 190 | + |
| 191 | +The coupled normal system has `(q,r)` blocks |
| 192 | +`H_qr = sum_i M_i(q,r) a_i^2 P_i^H C_i^2 P_i / sigma^2`, with |
| 193 | +`M_i = E[z_i z_i']`: the single-volume operator with a scalar weight per |
| 194 | +particle and pair. The present per-voxel `k x k` solve on the gridding |
| 195 | +density is the block-Jacobi preconditioner of that system, so the PCG |
| 196 | +version keeps `solve_coupled_basis_exp` in exactly that role and adds: |
| 197 | + |
| 198 | +- pair-weighted Gram kernels `Khat_qr`, `k(k+1)/2` of them, from the same |
| 199 | + particle pass that accumulates the right-hand sides (the weighted |
| 200 | + generalization of `accumulate_absT2`); real and symmetric, packed |
| 201 | + half-grid, ~4 MB each at box 64, x3.4 at 96, x8 at 128 -- budgeted with |
| 202 | + the pair count (§2.2); |
| 203 | +- the support `P (x) I` on the hard domain `window > 0`, deapodization |
| 204 | + inside the operator (replacing the post-solve `mstep_gridcorr` |
| 205 | + multiply), shipped basis `window * U`; |
| 206 | +- CG to a relative-residual tolerance, not a fixed count: at these box |
| 207 | + sizes an iteration is `2 x npairs` small FFTs and costs nothing next to |
| 208 | + the accumulation, and the EM fixed point should be the least-squares |
| 209 | + fixed point rather than an iteration-count artefact. |
| 210 | + |
| 211 | +Even and odd bases are solved separately on the same support, as now, and |
| 212 | +the per-component half-set FSC Wiener filter and band-limit stay |
| 213 | +post-solve in the first version, followed by a re-window (the filter leaks |
| 214 | +a little across the support edge). Moving the per-component FSC precision |
| 215 | +inside the operator as `P_tau` is a possible second version, not part of |
| 216 | +step 1. The marginal-likelihood diagnostic `-2logL/N` logged per probe |
| 217 | +iteration (`em_iter.f90:2367-2393`) is the arbiter between the backends, |
| 218 | +since a half-set FSC under a common window is optimistic on both. |
| 219 | + |
| 220 | +### 3.5 Unchanged |
| 221 | + |
| 222 | +E-step, embedding, kernel weights, targets and the merge logic. The |
| 223 | +initial basis (`simple_flex_pca_em_basis.f90:88`) and the probe-basis load |
| 224 | +(`simple_flex_pca_em_fit.f90:505`) multiply by the same window the solve |
| 225 | +uses -- with the spherical support that is the sphere they use today, so |
| 226 | +step 1 changes nothing there. |
| 227 | + |
| 228 | +## 4. Step 2: `pcg_mskfile` as the support |
| 229 | + |
| 230 | +### 4.1 Input contract |
| 231 | + |
| 232 | +A real-space `[0,1]` volume with a smooth falloff at the project's native |
| 233 | +box and sampling (`automask3D_stateNN.mrc` from a `refine3D_auto` |
| 234 | +`automsk=yes` run, or any user mask). `flex_pca` does not threshold, |
| 235 | +dilate, soften or otherwise edit it. It validates: cubic, box equal to the |
| 236 | +project box, values in `[0,1]` after resampling, non-empty. Whether the |
| 237 | +envelope is generous enough to contain the moving parts is the user's |
| 238 | +responsibility when preparing the mask; the dilation and edge controls |
| 239 | +live in `automask3D` and the refinement policies, not here. |
| 240 | + |
| 241 | +### 4.2 Resampling without Fourier-crop artefacts |
| 242 | + |
| 243 | +`read_and_crop` (`simple_image_geom.f90:673`) is an `fft`, |
| 244 | +`clip_inplace`, `ifft`: a brick-wall low-pass. On a mask that is |
| 245 | +harmless when the falloff is wide compared with the target voxel, and |
| 246 | +ringing when it is not: overshoot above 1, undershoot below 0, and -- |
| 247 | +the case that matters for a hard support -- positive ripples *outside* the |
| 248 | +mask that would extend the domain `window > 0` into solvent. The mask is |
| 249 | +needed on two lattices, `box_crop`/`smpd_crop` for the basis and |
| 250 | +`box_rec`/`smpd_rec` for the states, and the same routine serves both: |
| 251 | + |
| 252 | +1. Fourier crop to the target box (`read_and_crop` as is). |
| 253 | +2. Record `min` and `max` before clamping and the fraction of voxels |
| 254 | + outside `[0,1]`; these are the ringing diagnostic and go to the log. |
| 255 | +3. Clamp to `[0,1]`. |
| 256 | +4. Floor: values below a small threshold are set to exactly 0 so the |
| 257 | + hard domain is the intended envelope and not its ripple; the threshold |
| 258 | + is a constant of the mask contract (the PCG solver already treats |
| 259 | + `window < PCG_SUPPORT_DIV_MIN = 0.1` as outside for its warm-start |
| 260 | + division, `simple_reconstructor_pcg.f90:597-606`; the floor here should |
| 261 | + be no larger than that and is a decision to make on the fixture). |
| 262 | +5. Report occupancy (fraction of the box, and of the `msk` sphere) on the |
| 263 | + native and the target lattice; a large change is the sign that the |
| 264 | + falloff was too narrow for the target sampling. |
| 265 | + |
| 266 | +The falloff width that keeps the Fourier route clean is a few target |
| 267 | +voxels; at 2.2 A that is ~7-10 A, which the 7.5 A `ENVMSKWIDTH_A_MIN` |
| 268 | +dilation with its cosine skirt already satisfies for masks that come from |
| 269 | +`automask3D`. If the diagnostics of step 2 show ringing on user masks in |
| 270 | +practice, the fallback is real-space (trilinear) down-sampling of the mask, |
| 271 | +which cannot ring and only blurs the edge slightly; it should not be the |
| 272 | +default because the Fourier route is what every other volume in the run |
| 273 | +goes through. |
| 274 | + |
| 275 | +### 4.3 Where the support enters |
| 276 | + |
| 277 | +Exactly where the sphere enters in step 1, with `set_mask_volume` in place |
| 278 | +of `set_mask`: the state solves at `box_rec`, the coupled M-step at |
| 279 | +`box_crop`, the initial-basis and probe-basis windows, and the merge gate |
| 280 | +(which then compares windowed states and needs no mask of its own). The |
| 281 | +`vol1` mean from a PCG `automsk=yes` refinement is already windowed by the |
| 282 | +density envelope; passing that same envelope file as `pcg_mskfile` makes |
| 283 | +mean and basis share one support, which is the recommended use. On the |
| 284 | +gridding backend `pcg_mskfile` is rejected by the existing validation, so |
| 285 | +the envelope-constrained analysis is a PCG-only mode, as in `refine3D`. |
| 286 | + |
| 287 | +## 5. Validation |
| 288 | + |
| 289 | +- **Step 0:** the same run at `smpd_target` 3.5 / 2.6 / 2.2 A on the |
| 290 | + synthetic fixture (`production/tests/simple_test_flex_pca.f90`) and on |
| 291 | + one real dataset; ground-truth basis capture, `-2logL/N`, wall time and |
| 292 | + peak RSS per setting. The default is the coarsest setting on the capture |
| 293 | + plateau. |
| 294 | +- **Step 1:** gridding vs PCG at the same sampling and spherical support: |
| 295 | + per-state unmasked FSC, basis capture, `-2logL/N`, reproducible |
| 296 | + dimension count and the principal-angle history (the 0.97 / 0.02 |
| 297 | + stopping constants were tuned on gridding bases and may move), the |
| 298 | + merge-gate threshold, and a `refine3D_states` run seeded from each set of |
| 299 | + states. |
| 300 | +- **Step 2:** the resampling diagnostics of §4.2 on `automask3D` masks and |
| 301 | + on deliberately sharp masks; sphere vs envelope support on the fixture |
| 302 | + and on a membrane protein (PfCRT), watching the leading-axis background |
| 303 | + ratio that the `DEFLATE_BG` comment records at 27x on real data and the |
| 304 | + micelle's share of the leading components. |
| 305 | + |
| 306 | +## 6. Open questions |
| 307 | + |
| 308 | +- Default `smpd_target`: 2.2 A is proposed on the helix argument; the |
| 309 | + fixture sweep may argue for a coarser default with the fine value as an |
| 310 | + option. |
| 311 | +- Is `smpd_target` primary and `lp` derived (proposed), or the reverse? |
| 312 | +- The floor below which a resampled mask value counts as outside the |
| 313 | + hard support (§4.2 item 4). |
| 314 | +- Hard support (`window > 0`) at 2.2 A sampling, or the soft `P = window` |
| 315 | + alternative that `install_support` rejects for refinement? The |
| 316 | + refinement argument (a solver-state-dependent mixture of `P*u` and `u`) |
| 317 | + applies here too, so hard is the default proposal. |
| 318 | +- Whether the per-component Wiener step ever moves inside the operator |
| 319 | + as `P_tau` (a second version of §3.4, not part of the plan). |
0 commit comments