v1.0.0-rc.175 #85

Merged
leonarski_f merged 541 commits from rc175 into main 2026-10-16 15:34:45 +02:00
Owner
  • Rugnux: Processes a collection live while jfjoch_broker writes it (rugnux <master.h5> <broker URL>).
  • Rugnux: Processing starts on the first images and confirms its choices once all are in, so analysis can begin during collection.
  • Rugnux: Faster processing, especially on servers with several GPUs and CPU sockets and on computers without a GPU (--no-gpu, macOS).
  • Rugnux: Uses much less GPU memory, so large data sets stay on the GPU of smaller cards, and a data set too large for the card is processed on the CPU instead.
  • Rugnux: On macOS, Bragg integration runs on the Apple GPU.
  • Rugnux: Better handling of twinning and pseudo-symmetry, with the twin hypothesis written as <prefix>_twin_<SG>.mtz, and a wrong --model no longer confirms the data or decides the hand.
  • Rugnux: --model accepts predicted (AlphaFold) models and falls back to molecular replacement when the model is not placed as a rigid body (experimental --mr forces it), settling space group, hand and twinning and writing a different answer as <prefix>_mr.mtz.
  • Rugnux: Molecular replacement is faster and settles the space group in more cases with several copies in the asymmetric unit.
  • Rugnux: Deposition-ready mmCIF, a richer report, and numbered steps on the terminal with the full log in <prefix>_log.txt; _P1.mtz, --estimate-beam-center and --beam-center-search are removed.
  • Rugnux: The beam-stop shadow is found correctly on tilted detectors.
  • Rugnux: Stills files that record a stationary goniometer axis get the default stills geometry refinement.
  • jfjoch_viewer: "Analyze dataset" uses the same defaults as a bare rugnux <file>, and shows the results of processed CBF, marCCD and SMV sweeps.
  • jfjoch_viewer: The Files panel refreshes on F5 and every few seconds, and the plot type and other selections stay put during live collection.
  • jfjoch_writer: Memory stays bounded and old data files are closed efficiently on long runs, any failure ends the run in an error state the broker is told about, and the master file appears only after its data files; the ZeroMQ pusher logs when a stalled writer makes it drop images.
  • Fixes: The TCP image pusher no longer blocks the broker's receiver, GPU out-of-memory failures stop with a clear message, and the XDS plugin masks module and chip edges.
* Rugnux: Processes a collection live while jfjoch_broker writes it (`rugnux <master.h5> <broker URL>`). * Rugnux: Processing starts on the first images and confirms its choices once all are in, so analysis can begin during collection. * Rugnux: Faster processing, especially on servers with several GPUs and CPU sockets and on computers without a GPU (`--no-gpu`, macOS). * Rugnux: Uses much less GPU memory, so large data sets stay on the GPU of smaller cards, and a data set too large for the card is processed on the CPU instead. * Rugnux: On macOS, Bragg integration runs on the Apple GPU. * Rugnux: Better handling of twinning and pseudo-symmetry, with the twin hypothesis written as `<prefix>_twin_<SG>.mtz`, and a wrong `--model` no longer confirms the data or decides the hand. * Rugnux: `--model` accepts predicted (AlphaFold) models and falls back to molecular replacement when the model is not placed as a rigid body (experimental `--mr` forces it), settling space group, hand and twinning and writing a different answer as `<prefix>_mr.mtz`. * Rugnux: Molecular replacement is faster and settles the space group in more cases with several copies in the asymmetric unit. * Rugnux: Deposition-ready mmCIF, a richer report, and numbered steps on the terminal with the full log in `<prefix>_log.txt`; `_P1.mtz`, `--estimate-beam-center` and `--beam-center-search` are removed. * Rugnux: The beam-stop shadow is found correctly on tilted detectors. * Rugnux: Stills files that record a stationary goniometer axis get the default stills geometry refinement. * jfjoch_viewer: "Analyze dataset" uses the same defaults as a bare `rugnux <file>`, and shows the results of processed CBF, marCCD and SMV sweeps. * jfjoch_viewer: The Files panel refreshes on F5 and every few seconds, and the plot type and other selections stay put during live collection. * jfjoch_writer: Memory stays bounded and old data files are closed efficiently on long runs, any failure ends the run in an error state the broker is told about, and the master file appears only after its data files; the ZeroMQ pusher logs when a stalled writer makes it drop images. * Fixes: The TCP image pusher no longer blocks the broker's receiver, GPU out-of-memory failures stop with a clear message, and the XDS plugin masks module and chip edges.
leonarski_f added 541 commits 2026-10-16 15:32:12 +02:00
A run alternates between a handful of centres (file, measured, post-refined,
probe, the beam-centre ladder's trial centres), and every miss rebuilt the
16.7 Mpx geometry table. The cache now keeps the six most recently used
(~170 MB each at 16 Mpx). Tables are bit-identical whether cached or built.

p.mtz md5 unchanged on myob/cytc/thau/kdp (GPU build). Timing: no measurable
change on myob/cytc/thau at 3 repeats; it removes table rebuilds where
geometries alternate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Every full-statistics rotation merge computed French-Wilson amplitudes - the
search candidates, the pinned low/high pair, the final, and the P1
cross-check - although only the written merge's amplitudes are used, and those
were recomputed anyway with the anisotropic prior. RotationScaleMerge gets
SetFrenchWilson(); Rugnux turns it off for its merges and makes the amplitudes
once after the anisotropy analysis (anisotropic prior when a tensor was
fitted, isotropic otherwise). --mode scale keeps the engine's own amplitudes.

p_P1.mtz is now intensities only (owner decision): the F/SIGF/F(+)/F(-)
columns are omitted; every intensity column is bit-identical to before.

French-Wilson was 29% of the CPU samples in the large-cell tail (8tyy P1
cross-check window, ~84 core-s). p.mtz md5 unchanged on myob, cytc, thau,
kdp, 8tyy (GPU build). Wall: 8tyy 173.9 -> 150.9 s; cytc/thau -0.2..-0.3 s,
myob unchanged (3 interleaved repeats).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The ladder fed a ring's four centres, indexed them together, then set every
centre up a second time (new mapping, new spot engines, re-detected
validation frames) to score it. Each centre is now evaluated completely
before the next, which removes the second set-up and the centres indexed
after an adoption. Adoption order and rule are unchanged.

p.mtz md5 and the adopted centre unchanged on kdp (no adoption, 48 rungs),
9qw8 (+2 px at ring 2) and cuhf2 (+10 px at ring 10). Ladder time kdp
68.8 -> 64.2 s, 9qw8 8.6 -> 7.4 s, cuhf2 21.3 -> 22.9 s (loaded box). The
cost of a centre is its spot re-detection (kdp: ~1150 frames per centre).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The same nearest-first axis walk, the same adoption bar, harmonic test and two-ring noise
floor. Each rung is first scored on the starting centre's spots reprojected at the trial
centre - no detection, ~0.2-0.4 s - and only a rung whose screen could be adopted is
evaluated honestly (its own mapping and re-found spots, try_beam_center) and adopted on that.
Re-finding the spots (the wedge scheme reads ~1150 of 1800 frames per rung on kdp, all of them
where the accumulation cap never fills) was nearly the whole cost of a rung. Over all 225 walk
rungs on seven sets the screen matched the honest count to a few frames, and exactly on the
rung a real adoption is made (43/60 both). Each rung also logs its cell volume and the centre
the rotation indexer refined it to.

Ladder span s / wall s / md5 (walk = perf5 0f9f3ac73, gpulock, RTX 5080):
  kdp            68.1 / 89.5 -> 8.0 / 28.8   1303b119 = 1303b119
  9qw8            7.3 / 60.1 -> 3.9 / 55.6   64ba0bfd = 64ba0bfd, same +2 px adoption 43/60
  cuhf2          23.2 / 34.3 -> 10.6 / 22.1  fd854737 -> 90244c59: the walk's +10 px adoption
                                             (31/60 honest, 23/60 screened, after a flat 22-29
                                             walk) is not made; equals --beam-center-search=off
  nothing_1      18.2 / 44.5 -> 3.4 / 23.0   no output either way
  lyso_half      15.5 / 26.4 -> 10.2 / 20.9  569b3019 = 569b3019
  cytidine       12.2 / 25.1 -> 2.4 / 14.0   b84fb61f vs 33244ee7, no adoption; this set's md5
                                             varies run to run (4 values over non-adopting runs)
  lcystine_20keV 32.1 / 51.1 -> 6.3 / 24.9   ec144e22 = ec144e22
  sum           176.6 -> 45.0 s ladder
Oracle (ladder does not run): myob 77ee24f2, cytc 9abf1490, thau a228a687 - unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The two-pass rotation pipeline's first image loop (the geometry pre-pass whose reflections
feed post-refinement) stops after the given fraction of the sweep; pass 2 still integrates
every frame and is the only one written. Default 1 (unchanged).

On the 25 sets ranked by the post-refinement held-out gain, --prepass-fraction 0.5 vs rc174
(REFMAC check): 0 verdict changes, d_min change median 0.000, R-free worse by >0.005 on 1/17;
image-loop seconds -17%, myob wall 11.9 -> 10.5 s. Without post-refinement at all
(--rotation-no-postrefine) the same sets had 5 verdict changes and 11/17 R-free worse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact (tier E): every per-group and per-pair sum keeps its index-order summation, so p.mtz and
every written file are bit-identical; nothing depends on the thread count.

- The fulls' group CSR (index order within a group) is kept per arm: on the GPU path it is the
  one RunArm already builds for SetFullsGroups, on the CPU path BuildFullsGroupCSR.
- The four "every thread walks every full, keeps its own groups" loops (error-model sums on the
  CPU path, Bijvoet sums, ISa asymptote, anomalous pair sums) become ParallelBlocks over groups /
  pairs walking the CSR - O(n_full) instead of O(threads x n_full) traffic.
- The serial walks that build per-group/per-pair data (outlier-median sets, band spread pairs,
  group d, anomalous-variance shells, per-hand R_meas rows, per-frame counts, the error-model
  sample list, the Wilson observation list) are ordered-parallel: fixed-block compaction
  (KeptIndices), CSR fills, per-block lists whose consumer is order-free (medians).
- CC1/2 and CCanom halves are assigned cell by cell from the CSR (the rank is a set property).
- Friedel pairs are numbered by first appearance through a parallel sort instead of an
  unordered_map of millions of keys (NumberByFirstAppearance).
- CPU path: the fulls scaling loop's group means sum over the CSR on all threads
  (ReduceFullGroupMeans replaces the serial ReduceGroupMeans), the rescale runs on all threads.
- The error-model sample download goes into uninitialised arrays (no serial zeroing).

Measured (perf-rsm-explore-wip prototypes, GPU RTX 5080, box load 6-14, Run-span medians):
MergeAndStats-side sections 8tyy -3.5 s, 8a1a -2.0 s, cytc -0.25 s. md5 of p.mtz identical
to production rc174 on myob/cytc/thau/8a1a/8tyy (GPU), myob/cytc (CPU), and at -N 4/8 (GPU
cytc, CPU myob).

Clean branch rebuilt from scratch (GPU and CPU) and re-verified: p.mtz md5 identical to production on myob/cytc/thau/8a1a/8tyy/8qaw/9gdj (GPU), cytc -N 4, myob/cytc and myob -N 4 (CPU).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact (tier E) for every written file.

- A search merge (for_search) is never written - the run always merges again in the chosen
  group - and nothing reads rfree_flag except the writers and --model validation of the written
  merge, so RotationScaleMerge no longer assigns a free set to it.
- AssignRfreeFlags takes a thread count: the per-reflection key (the lattice holohedry walk) runs
  on fixed chunks, and the distinct Laue-key count is taken off a parallel sort (equal keys are
  equal values, so every sort gives the same sequence) instead of an unordered_set. Rugnux passes
  its thread count at its two direct call sites; the default stays 1.

Measured (prototype, GPU, 8tyy F432 cubic): the R-free/chi2 section of the merges 4.5 -> 0.65 s;
8a1a 0.76 -> 0.37 s; cytc -0.13 s. md5 of p.mtz/p_P1.mtz/p.cif/p.hkl identical to production on
myob/cytc/thau/8a1a/8tyy.

Clean branch rebuilt from scratch (GPU and CPU) and re-verified: p.mtz md5 identical to production on myob/cytc/thau/8a1a/8tyy/8qaw/9gdj (GPU), cytc -N 4, myob/cytc and myob -N 4 (CPU).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact (tier E): the same observations in the same order, the same verdicts.

- The judged observations are compacted in fixed blocks (count, prefix, fill) instead of a
  serial push_back.
- Both sorts (by d, by reflection) sort (key, index) records, so a comparison reads the two
  elements it compares instead of two observations elsewhere in memory; same total order.
- Each reflection is decided on its own observations, so the reflections go side by side in
  claimed blocks; the rejection count is an integer sum.

Measured (prototype, GPU): Wilson test 8tyy 3.4 -> 3.0 s, 8a1a 2.8 -> 2.6 s per run (the
sort-on-records part is not in the measured binary yet); md5 identical.

Clean branch rebuilt from scratch (GPU and CPU) and re-verified: p.mtz md5 identical to production on myob/cytc/thau/8a1a/8tyy/8qaw/9gdj (GPU), cytc -N 4, myob/cytc and myob -N 4 (CPU).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact (tier E): the device gets the same group ids and the same group-ordered permutation
(ascending observation index within a group) the host's counting sort produced.

ComputeAsuGroups still finds the groups on the host (one ASU reduction per raw-hkl run, the
(key, run) sort, the dense ids, the representatives - now unpacked from the packed key instead of
a second ASU reduction). With the GPU resident it then hands the device only the runs in group
order (two arrays as long as the runs, not the observations): BuildGroups stamps every
observation's group from its run (the device evaluates the same finiteness test on the same
uploaded floats), counts each group's observations, and fills each group's segment and puts it in
index order. The host no longer builds or uploads the two observation-length arrays; the device
keeps its per-observation group array across Runs instead of reallocating it beside the old one.
SetGroups (host-built upload) is gone; the CPU path keeps the host histogram CSR.

Measured (prototype, GPU RTX 5080, Run-span sections): ComputeAsuGroups summed over a run's
merges 8tyy 9.0 -> 4.2 s, 8a1a 2.2 -> 0.85 s, cytc 0.51 -> 0.19 s. md5 of p.mtz identical to
production on myob/cytc/thau/8a1a/8tyy and cytc -N 4.

Clean branch rebuilt from scratch (GPU and CPU) and re-verified: p.mtz md5 identical to production on myob/cytc/thau/8a1a/8tyy/8qaw/9gdj (GPU), cytc -N 4, myob/cytc and myob -N 4 (CPU).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Floating-point summation order only (tier F): the seven global sums of RefineDecay (slope fits,
held-out disagreements), the per-batch sums of FitRelativeBCurve, and the per-shell and
per-(batch, shell) sums of MeasureRadiationDamageB were single-threaded walks over every full.
They are now FixedBlockSum: the split depends on the number of fulls alone (ReductionBlocks,
ParallelBlocks), the block partials are merged in block order, so the sums have the same bits at
every thread count and on every machine - but not those of the serial walk.

Checked: p.mtz, p_P1.mtz, p.cif, p.hkl and the report identical to production on myob/cytc/thau
(GPU) and myob (CPU), and identical between -N 4/8/default (GPU cytc, myob; CPU myob). On these
sets the decay correction is not adopted (negligible or not cross-validated) and the monitor's
printed values do not move, so the F-tier change is not visible in their outputs; a set where the
decay correction is adopted will move in the last bits of corr.
Clean branch: p.mtz md5 identical on myob/cytc/thau/8a1a (GPU), cytc -N 8, myob -N 4 (CPU).
Measured (prototype with spans, 8a1a, 2 interleaved pairs, load 8-10): decay sections 2.70 -> 1.08 s,
radiation-damage monitor 1.04 -> 0.86 s, RSM total 37.1 -> 35.6 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
DiffractionExperiment::GetImageTime() returns std::chrono::nanoseconds, but
the chart stored its count() as microseconds, so the sampling frequency came
out 1000x too small (a 6 ms image time gave a Nyquist of ~0.08 Hz instead of
~83 Hz). Keep the image time in seconds via duration<double>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The space-group search was almost entirely single-threaded. The per-reflection work now runs in
parallel over fixed chunks, while every floating-point accumulation stays serial and in the same
order as before, so the output is bit-identical.

- AnalyzeLTestUnderMerge takes nthreads (default 1): the usable test, the orbit means and the
  partner search run in parallel; the 20 resolution-shell passes become one walk.
- Stage B ClassifyReflection: one byte per reflection, candidates scored in batches of nthreads.
- FindRowModulation: cone test and cosines in parallel.
- PrepareMerge: parallel flatten; the sorts use inline {d, index} keys with the same comparison.

Measured: SearchSpaceGroup 14.7 -> 4.8 s on 8tyy (instrumented build). Wall clock, median of 3
interleaved runs vs rc175, timed inside the GPU lock (-march=x86-64-v3, CUDA): 8tyy 136.9 -> 127.0 s,
8a1a 81.8 -> 80.4 s, cytochrome C 18.8 -> 18.9 s (unchanged). 8qaw max RSS 21.7 -> 21.9 GB.

p.mtz md5-identical to rc175 on myoglobin, cytochrome C, thaumatin, 8a1a, 8tyy, 9gdj and 8qaw
(GPU build), and on myoglobin and cytochrome C in a CUDA-off build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
When a chain needs more fulls than the arrays hold, fulls and fulls_staging are released before the
resize. The gather overwrites every field, so growing in place only copied the previous chain's
records into the new allocation to be overwritten.

The expected saving (a few tenths of a second on 8tyy) is below the run-to-run noise of the wall
clock: 8tyy median of 3, 127.0 s without vs 129.6 s with, under different machine load. 8qaw max RSS
21.9 -> 21.7 GB.

p.mtz md5-identical to rc175 on myoglobin, cytochrome C (also at -N 4 and -N 8), thaumatin, 8a1a,
8tyy, 9gdj and 8qaw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Logger::RouteAll lets a program that owns its terminal send every Logger made after the call
to a single sink, at debug level, without the version line each one opened with. rugnux uses it
to write the whole log to a file and show only warnings and errors among its own step lines.

A Buffered logger now replays each line with the time it was logged at rather than the time of
the replay, so a timestamped log shows when the work happened (the P1 cross-check merge runs
beside the analyses and its lines used to carry the replay time).

HDF5DataFile's destructor reports a failed close through the logger instead of std::cerr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Standard mode prints only steps, one-line results with the time since the line above, a rule
closing each step with its total, warnings (!!) and errors (** ERROR:), and a summary at the
end. Every log line of every module goes to <prefix>_log.txt instead, at debug level and
timestamped, opened with version, build, GPU, threads, command line and the header geometry.
-v puts the whole log back on the terminal.

- RugnuxObserver gains OnStep / OnNote. RunPipeline posts them only on a pass that makes the
  result (the observer is held in steps_, nullptr on probe passes and on copies run beside the
  main pass); RunAllPasses opens steps for the checks between passes (rotation scale, metric
  symmetry, geometry walk) and notes its re-runs. OnPhase is written to the log as a marker.
- RugnuxConsole (CLI only): on a terminal a progress bar and a spinner status line drawn in
  place with \r, durations only; not a terminal, plain ASCII with the time of day on every line
  and quarter milestones for long passes. isatty/_isatty, no environment variables. A warning
  repeated by later merges is shown once more on one line and then counted per step.
- The CLI's former stdout blocks (space-group search, merging statistics, per-image cost,
  processing time, tNCS/twinning/anisotropy in --mode scale) go to the log; the calibration
  result block stays on the terminal as that mode's summary. The MX summary takes its rows
  from the report's own SUMMARY (ReportDocument::summary_rows); WriteResultReport returns the
  document it rendered. HDF5's automatic error-stack printing is switched off.
- The banner keeps the "Version X (git abcdef" form battery.py reads off a bare invocation;
  battery.py's failure note now reads p_log.txt, whose lines are not wrapped.

Output only: p.mtz is bit-identical on the three in-house reference sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The project has ended and is credited in the documentation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
With --prepass-fraction below 1 the geometry pre-pass stopped integrating at the fraction but still
handed the merge and the post-refinement an outcome vector the length of the whole sweep. The
fulls' per-frame log-scale smoother (Whittaker, lambda by cross-validation) then carried its
curve straight across the empty tail; on a sparse small-molecule sweep with 900 empty frames the
extrapolated scale fell further each round until it underflowed to zero, log G became -inf there,
the cross-validation error became 0*inf = NaN for every lambda, lambda fell back to 0, the
smoother's band matrix was singular on the frames without fulls, and every per-frame scale in the
sweep went NaN. The merge was then empty and the run aborted with "RotationScaleMerge: resolution
calculation failed". The outcome vector is now cut to the frames the pre-pass read, so its merge,
its rocking-events-per-frame prior, its smoothed mosaicity and its post-refinement all describe a
sweep of those frames. The pre-pass mosaicity that the second pass predicts with is carried to the
frames after the pre-pass at the median of what the pre-pass fitted (on a small-molecule sweep,
predicting the second half at each frame's own estimate alone lost a fifth of the predictions and
took R_meas from 5.2 % to 7.2 %).

The two-pass quality guard compares the refined pass with the pre-pass on their search merges.
Against a pre-pass of the first half, a merge of the whole sweep always holds more reflections,
more signal and more of the axial rows, so the guard could never fire (it fired on 6 battery sets
at fraction 1 and on none at 0.5). A refined pass judged against a partial pre-pass now fills the
guard's numbers from its own integration of the same frames, merged the way the pre-pass merged
them (P1 or the fixed group, search merge, no surfaces). At fraction 1 nothing changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
--prepass-fraction now defaults to 0.5; --prepass-fraction 1 restores the pre-pass over the whole
sweep. The first-pass indexing and the pre-scan still sample the whole sweep, and the second pass
integrates every frame and is the only one written. A full battery at 0.5 (with the ladder off) was
quality-neutral against the rc174 baseline except for one small-molecule set that crashed, fixed
in the previous commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The ladder stepped the beam centre a pixel at a time after a rotation first pass that indexed fewer
than half the validation frames. A battery with it switched off changed no output, so it goes: the
option, its usage text, ProcessConfig::beam_center_search_pxl, the once-per-run flag that kept
probes, walks and later passes from re-running it, and the docs. The background centre
measurement, the beam-centre check that indexes at the measured centre, the merge-judged arms and
the post-refinement of the centre are unchanged. rugnux now rejects --beam-center-search as an
unknown option.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
--estimate-beam-center was opt-in, off by default and used by no battery run. Removed with it: the
half-turn spot-symmetry estimator (image_analysis/geom_refinement/BeamCenterFromSpots and its tests,
used nowhere else), its pre-scan branch and paired-frame sample, the sparse-sweep extra reads, the
BEAM_CENTER_* constants used only there, beam_center_placed_, the spindle fit that only ran inside
it (--fit-spindle / --no-fit-spindle, ProcessConfig::fit_spindle; its estimate was only logged),
the viewer's "Measure beam centre before indexing" job option and the RugnuxCommandLine emission.

The background centre (FindBeamCenter on the pre-scan projection), the asynchronous beam-centre
capture, the beam-centre check that indexes at both centres and the measured centre's bound on the
post-refinement are unchanged, and so is the pre-scan sample for the shadow, the defective pixels
and the spot width. Warnings that pointed at --estimate-beam-center now point at --beam-x/--beam-y.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The online broker cancelled a collection with "Crystal lattice is coplanar and has no reciprocal
cell". The candidate filter in PostIndexingRefinement (used by FFT, FFTW and ffbidx) tests lengths,
angles and spot count only; three rows 120 deg apart in one plane pass all of them, and spots along
the plane normal index as (0,0,0). Such a candidate - from ffbidx, or one the least-squares
refinement walked flat - went on to LatticeSearch and XtalOptimizer, which refuses it and leaves it
in place, and then reached Astar() in AnalyzeIndexing/prediction, which throws; the receiver turns
that into a cancelled acquisition.

- Refine() rejects a candidate below MIN_BASIS_VOLUME_FRACTION, like any other bad candidate.
- XtalOptimizerInternal builds the refined lattice first and counts a coplanar result as a failed
  refinement, writing nothing back (the residual's volume clamp lets a solve end there and report
  success).

Test: PostIndexingRefinement_CoplanarCandidateIsRejected threw the exact message before the fix.
rugnux p.mtz unchanged on the three in-house reference sets at --prepass-fraction 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A pure move: HotPixels.{h,cpp} and HotPixelsGPU.{h,cu} are compiled into
JFJochImageAnalysis (like beam_stop/) instead of JFJochRugnux. Only include
paths and the CMake source lists change; the objects are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A pure move. ModelValidation, RigidBodyRefine, RigidBodyGPU, ModelFFT, ModelGrid,
ModelScaling, ModelMaskGPU, ModelScaleGPU and SigmaA - everything that works on an
atomic model - become the JFJochStructureRefinement library, linked by
JFJochImageAnalysis. WriteModel (the placed-model mmCIF/PDB writer) goes to writer/
as its own small JFJochModelWriter target, so JFJochWriter, which a writer-only build
compiles, does not gain a gemmi dependency. Only include paths and CMake lists change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A pure move of three method files out of rugnux/: SpotWidth (spot width and
bandwidth, which set the integration radius) joins JFJochBraggIntegration, which now
links JFJochImagePreprocessing for the ImagePreprocessorBuffer it reads;
SpindleCuspLoss and LeftoverLattices join JFJochIndexing. Only include paths and CMake
lists change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
1.0.0-rc.175
Build Packages / Create release (push) Successful in 15s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m23s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m36s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m16s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m19s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 11m45s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 12m36s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 17m9s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 19m2s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 19m19s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 23m59s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m22s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 21m34s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m44s
Build Packages / Generate python client (push) Successful in 35s
Build Packages / Build documentation (push) Successful in 1m14s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m18s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 13m52s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m48s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 19m12s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 19m49s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 16m19s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 12m44s
Build Packages / Unit tests (push) Successful in 1h17m24s
570b8ac31c
VERSION and the strings update_version.sh derives from it (API spec, generated broker
model and Python client docs, frontend, docs, FPGA and PCIe driver versions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
First step of splitting Rugnux.cpp into per-phase files, with no change in behaviour:
- the helpers more than one phase will call (IsFatalResourceError, BeamCenterNeed_pxl,
  select_equally_spaced_image_ordinals, ScaleRotation, RaiseFFTBoundForKnownCell) move from the
  anonymous namespace into rugnux_internal, still defined in Rugnux.cpp;
- ProcessFileWriter moves verbatim into the header;
- RunPipeline declares the four locals the later phases share with its tail (the image-loop byte
  count, the end message, the unmerged experiment and MTZ future) next to start_time, and builds a
  PipelineLocals of references to its locals there. Nothing reads it yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A verbatim move; the new file starts with Rugnux.cpp's include block so that the same declarations
are visible to the moved body.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
RebinAndRefit moves verbatim. The `if (calibration && !cancelled_)` block of RunPipeline becomes
the private member FitCalibration(PipelineLocals &, calibration_spot_list): its body is the block
body unchanged (de-indented one level) behind the lines that bind the RunPipeline locals it reads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A verbatim move, with the helpers only the pre-scan reads (AcrossSpindle_pxl and the pre-scan
sampling constants) moved into the new file's anonymous namespace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A verbatim move of RunAllPasses, RefinedPassIsWorse, ArmsHoldSameLattice and MeasuredCentreWins,
with the helpers only they read (MAX_CC_HALF_LOSS, SearchMergeCompletenessText,
PrimitiveCellVolume, SignalText, SameSetting) in the new file's anonymous namespace. The comment
describing RefinedPassIsWorse, which sat at the end of that anonymous namespace, now sits on the
function. TwinZoneVerdict stays behind for the scale/merge phase.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The stretch of RunPipeline from the per-image loop to the end message becomes the private member
ImagePass(PipelineLocals &), its body unchanged behind the lines that bind the locals it reads.
Two of the values it computed are also read by the scale/merge block after it, so they move up to
where PipelineLocals is built and travel in it: the bounds of the geometry pre-pass
(prepass_images, loop_end), computed from the same inputs as before. PipelineLocals also carries
`full` now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The body of RunPipeline's two-pass branch becomes the private member
FirstPassRotationIndexing(PipelineLocals &), unchanged (de-indented one level) behind the lines that
bind the locals it reads. Its one early exit, the indexing probe's `return result;`, becomes
`return true;`, and the call site returns the result there. The helpers only it reads (SameSpots,
IndexedMillerIndices, AddedClassOccupancy) move into the new file's anonymous namespace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The body of RunPipeline's `// Scaling and merging` block becomes the private member
ScaleMergeAndSymmetry(PipelineLocals &), unchanged (de-indented one level) behind the lines that
bind the locals it reads. Its two early exits (the post-refinement probe and the starved pass)
become `return true;`, and the call site returns the result there. The helpers only it reads
(MAX_BEAM_CENTER_SIGMA_PXL, TwinZoneVerdict, ClosestSymmetryAxisToSpindle, HolohedryRotations,
WarnIfMetricHostsMoreSymmetry) move with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Now that every helper used by a single phase has moved to that phase's file, the helpers left in
Rugnux.cpp's alternating anonymous and rugnux_internal blocks fit in one of each. Definitions are
reordered only; nothing else changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A TimingMark (common/Logger.h) logs "TIMING <section> <seconds>" at debug level when a section
ends, so the lines go to <prefix>_log.txt and stay off the terminal. Marked: each pass of
RunPipeline (pre-pass, probe, output) and its first-pass indexing, image pass, scale/merge/symmetry
phase, output files and census wait; inside the scale/merge phase every merge (labelled by its
role: P1, P1 space-group search, P1 all observations, the adopted group, P1 cross-check, quality
guard), the post-refinement, the space-group search and its two SearchSpaceGroup arms, adoption,
analyses, the P1 cross-check wait, model validation and writing; inside RotationScaleMerge the
ingest and, per arm, partial scaling, combine and fulls scaling, correction surfaces and the merge;
the console's steps; and the results report. tools/rugnux_timing.py tabulates the tail of a log.

Only the log changes: the outputs are byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A P21 crystal in the same form as 2xfw, but pseudo-merohedrally twinned
with a twin fraction of 0.33 at an obliquity of 0.29 deg. That is the
largest twin fraction at the highest obliquity in the arm. The PDB entry
was withdrawn, so the reference cell and resolution come from Table 1 of
Campeotto et al. (2018) Sci. Rep. 8, 14876, and the set runs without a
model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The P1 cross-check (<prefix>_P1.mtz) and --no-p1-crosscheck are gone. The
tail waited for that merge after the reflection files were written: 12-18 s
on a large rotation set (7200 frames), ~2 s on a small protein. A user who
wants a P1 merge re-runs with -S 1.

--finalist-ledger keeps working: it now starts a P1 merge of its own beside
the analyses (made only when the flag is given) and folds the ledger from it.
The twin-immune zone evidence, which was read on the cross-check merge, moves
with it and is reported only with --finalist-ledger.

The unmerged MTZ was built in the shadow of the cross-check merge; it is now
built beside the reflection files, from the point where the model validation
has settled the indexing.

WriteMtzReflections loses its amplitudes flag (only the cross-check wrote an
intensity-only MTZ). The merged outputs (p.mtz, .hkl, _unmerged.mtz) are
byte-identical to the base on the gate matrix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The time between the all-observation arm's search and the adopted group's
merge was unmarked (1.4-1.8 s on a large hexagonal set). It is now
"space-group decisions", with "metric lattice re-ask" (the sub-lattice
operator scoring, which is where that time goes) and the leftover-lattice
census wait it can contain marked inside it. Debug log only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The MXRDR zips for both sets hold gzip-compressed frames. Our staging had
expanded them to .cbf. rugnux reads .cbf.gz natively, so the manifest now
points at the files exactly as deposited.

6ZR0's zip, as the Keele repository serves it, has a readable central
directory with all 1060 members. Our earlier download was short, which
lost the last frame. Remove the statement that the repository's zip is
damaged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
XDS plugin: GPL linking permission, own tgz with its notices, macOS build; one portable CI build
Build Packages / Create release (push) Successful in 16s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m21s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m32s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m23s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 13m14s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 18m37s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 19m22s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 21m19s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m29s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m17s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 18m28s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m29s
Build Packages / Generate python client (push) Successful in 28s
Build Packages / Build documentation (push) Successful in 1m5s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 17m25s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 19m11s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 13m42s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 17m57s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m23s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 22m12s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 21m9s
Build Packages / Unit tests (push) Successful in 2h20m14s
07ae3c3a40
LICENSE: the section-7 addendum is rewritten as numbered additional permissions. CUDA libraries
are treated as System Libraries (Intel MKL dropped - never linked). New: the XDS plugin may be
linked with the closed-source XDS, valid only while it contains no third-party GPL code.

The plugin links HDF5, zlib-ng, Zstandard and Compression (LZ4, Bitshuffle, Bitshuffle h-perf)
and nothing else - no FFTW. In a portable (viewer-only) build it is its own CPack component,
shipped as jfjoch_xds_plugin-<version>-<os>-<arch>.tar.gz with LICENSE, a short
xds-plugin/THIRD_PARTY_NOTICES.md and only those six licence texts. On macOS it now exports only
plugin_* (--exclude-libs does the same on Linux). The RPM/DEB packaging is unchanged.

The viewer-only build already compiled everything rugnux needs, so it now packs one archive per
program: Linux three .tar.gz (viewer, rugnux, plugin); macOS the viewer .dmg plus rugnux and
plugin .tar.gz, each generator getting its components through CPACK_PROJECT_CONFIG_FILE. CI
merges build-viewer-tgz + build-rugnux-tgz into build-portable-tgz (cuda and nocuda, so Linux
rugnux gains a -cpu archive) and the two macOS jobs into build-macos. Both check that FFTW is
neither on the plugin's link line nor in its symbol table and that it exports only plugin_*.
The bare .so upload from the RPM job is dropped. RUGNUX_ONLY stays for aarch64 and Windows.

Verified locally (Linux, CPU, CI flags): three tarballs, the plugin link line is exactly the list
above, 0 FFTW symbols, four exports, system-only dynamic deps; XDS loads the packaged plugin.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Windows: viewer installer and rugnux zip from one build
Build Packages / Create release (push) Successful in 39s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m23s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 7m4s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 8m27s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 22m22s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 19m38s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 5m22s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m12s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 12m38s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 13m30s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 12m55s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 12m47s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 14m8s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 15m48s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 15m3s
Build Packages / Generate python client (push) Successful in 33s
Build Packages / Build documentation (push) Successful in 48s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 16m18s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 14m34s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 14m46s
Build Packages / Unit tests (push) Successful in 1h47m51s
36366295f2
The per-generator CPack config written for macOS is now generic and shared: the installer/.dmg
generator gets the viewer, the archive generator (TGZ/ZIP) every other component. A Windows
viewer-only build therefore packs NSIS;ZIP - the jfjoch-viewer installer and
rugnux-<version>-win64-<variant>.zip, name unchanged - and the cuFFT DLL is installed into both
components. CI folds build-rugnux-windows into build-windows (cuda and nocuda, so the Windows
rugnux gains a -cpu zip) and checks the zip holds rugnux.exe, no viewer, and in the CUDA variant
the cuFFT DLL. JFJOCH_RUGNUX_ONLY remains for the aarch64 cross build.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The viewer's live-broker client goes back to cpp-httplib, which the broker
already fetches; libcurl was only there for Kerberos, which is no longer
wanted. HTTPS comes from Mbed TLS 4.2.0, fetched and statically linked for
viewer builds only, so one TLS backend now serves Linux, Windows and macOS
(the macOS viewer previously had no TLS at all, and Linux needed host
OpenSSL >= 3.0). cpp-httplib verifies servers against the OS trust store:
the Windows certificate store, the macOS Keychain, the CA bundle on Linux.

CPPHTTPLIB_MBEDTLS_SUPPORT is set on jfjoch_viewer only, not on
httplib::httplib, so the broker stays plain HTTP. JFJochHttpReader.cpp is
the only viewer source that includes httplib.h, and the header keeps
httplib::Client opaque so <windows.h> stays out of the other viewer units.

Checked on Linux: a viewer-only build links Mbed TLS statically, with no
curl and no system libssl; a standalone client built against the same
httplib + Mbed TLS fetches from real HTTPS hosts and rejects expired,
self-signed and wrong-host certificates. Windows and macOS are untested.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs: regenerate THIRD_PARTY_NOTICES.md (Mbed TLS replaces curl, XDS plugin note)
Build Packages / Create release (push) Successful in 15s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m4s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 17m3s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 12m19s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 20m51s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 6m33s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 9m0s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 6m5s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 12m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 16m40s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 17m23s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 17m54s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 17m1s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 15m47s
Build Packages / Generate python client (push) Successful in 35s
Build Packages / Build documentation (push) Successful in 1m20s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 17m19s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m30s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 17m7s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 18m23s
Build Packages / Unit tests (push) Successful in 1h46m57s
1a970eef91
Generated from the root file by the update_version.sh step, which otherwise runs only on a version
bump; the published copy still listed libcurl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The guard's merge of the first prepass_images frames read nothing the post-refinement
writes and the post-refinement reads nothing the guard writes, but the two ran one after
the other at the start of the tail. The guard now merges the first frames of the outcomes
in place (RotationScaleMerge::SetFrameLimit) with the per-frame scale not written back,
instead of moving the reflections into a copy whose write-back was thrown away, and it
runs on its own thread from just before the post-refinement; its log lines are held and
replayed where the guard's result is read.

Same observations in the same order, same settings: p.mtz is byte-identical (myob, thau,
cytc, kdp, cytc -N 4 / -N 8, --prepass-fraction 1 on four sets, 8a1a). On 8a1a the 3.7 s
guard is now inside the post-refinement, which itself goes from 4.5 to 6.0 s under the
shared load: about 2 s off the tail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
OperatorCorrelation flattened, normalised (two serial sorts) and hash-indexed the whole merge for
every operator it was asked about. The metric sub-lattice ask scores each two-fold of the metric
against the same P1 merge, and the twin-law report each law against the same expanded merge, so
both now build an OperatorScorer once and ask it per operator; OperatorCorrelation itself is a
scorer used once. Same prepared merge, same pairing: the scores are the ones it gave.

On 8a1a (hexagonal metric over a 6-fold point group) the stretch between the all-observation
search and the adoption goes from 1.37 to 0.39 s. p.mtz and the twin-law lines byte-identical
(myob, thau, cytc, kdp, 8a1a). The two call sites in rugnux/RugnuxScaleMerge.cpp change by a line
each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
ChooseLogScaleSmoothing picks the smoothing of the fulls' per-frame log scale by leaving out
every fifth block of frames: 25 lambdas x 5 folds, each a banded solve over the whole sweep, run
one after the other on every iteration of every fulls scaling loop. The 125 solves are independent,
so they now run on the engine's threads; the held-out error is still added up per lambda over the
folds and frames in the order the serial loop added it, from the same curves, so the lambda chosen
is the same. nthreads defaults to 1 for other callers (the test).

8a1a: the six "combine and scale fulls" stages of the tail 4.7 -> 3.6 s. p.mtz byte-identical
(myob, thau, cytc, kdp, cytc -N 4 / -N 8, --prepass-fraction 1 on four sets, 8a1a); [RotationScale]
and the scale_merge test files pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Comment-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
RotationScaleMerge::Ingest is now IngestHost (read the outcomes, sort, build every host array,
smooth the geometry and recompute the partiality) followed by IngestDevice (allocate the GPU's
arrays and stage them). The host half reads the outcomes and nothing else, as the post-refinement
and the quality guard's merge do, so on the canonical pass it runs on its own thread beside both;
the device half runs where the ingest used to, after the guard has handed the GPU back, so the two
engines never hold the card at once. The device feed now stages the recomputed partiality in the
same slices as every other field, straight from the ingest array, instead of in a pass of its own
after the rest; the values on the device are the ones they were.

8a1a, no other load, interleaved runs: the first merge (P1) ends at 10.9 s after the image pass
against 11.3 with only the guard overlapped and 12.7 on the rc175 base. p.mtz byte-identical on the
gate sets (myob, thau, cytc, kdp, cytc -N 4 / -N 8, --prepass-fraction 1 on four sets, 8a1a,
8tyy, CPU build myob); --model, stills, calibration, --mode scale (GPU and CUDA_VISIBLE_DEVICES="")
outputs identical; --no-merge / --mode azint / --write-process-h5 _process.h5 differ only in the
datasets that differ base-vs-base (azint sums, bkgEstimate, profiling times, dates, git).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The unmerged scaled fulls the final merge hands back for the .hkl were gathered by one thread
pushing back over every full. Which fulls is now KeptIndices (the same list, in the same order),
and each record is made side by side.

The same records in the same order: p.mtz and the .hkl byte-identical (myob, thau, cytc, kdp,
cytc -N 4 / -N 8, --prepass-fraction 1 on four sets, 8a1a, 8tyy, CPU build myob). 8a1a, no other
load, interleaved: the P61 merge's statistics stage 3.94 -> 3.71 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Full open and in-house battery on 9ced4c444 (run 20261007-2139_9ced4c_rc175-p1-baseline) against
the rc.173 battery, without the REFMAC check: R-free figure and numbers are kept from the rc.174
battery (20261005-2102_3770f4_rc174-final-refmac). No verdict changed from rc.174; 2ygz is new and
fails. Two sets lost quality at the 50 % pre-pass default (outlier-laden outer shell on 5jk4,
runaway cut on the in-house split myoglobin set); fraction 1 restores them, but nearby fractions do
too and a fixed cut gives equal statistics, so the pre-pass only tips two existing weaknesses (an
unmasked dead edge row; a noisy half-set CC1/2 on a multi-domain crystal). Time, zoomed time,
in-house time and resolution figures regenerated with paper_plots.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
On a canonical pass the post-refinement only measures: nothing in ScaleMergeAndSymmetry reads
result.post_refine or prepass_detector_geometry_ - RunAllPasses does, after RunPipeline returns. So
the pass now gathers the observations where it did (beside the guard's merge and the engine's host
ingest - the gather reads the outcomes, which the merge chain later re-indexes in place) and runs the
fit on a thread of its own, beside the merges. RunPipeline joins it when ScaleMergeAndSymmetry
returns and replays its log lines and notes there. The speculative geometry probe the fit may start
is started from that thread, on a copy of the run taken on the main thread before the fit, because
the merge chain goes on to change the run's members. The probe-only pass keeps the sequential path.

8a1a, GPU build, two interleaved warm runs each: the window after the image pass loses the fit
(post-refinement 7.6 s -> 2.4 s gather on the main thread), but the quality guard's merge of the
pre-pass frames (7.8 s) is what that window waits for, so nothing moves: tail 30.57 -> 30.59 s,
whole run 55.78 -> 55.79 s; 8qaw 104.08 -> 104.06 s (guard 13.8-14.1 s); myob/cytc within noise.
Peak RSS on 8qaw 24.07 -> 24.35 GB. The gain needs the guard off the critical path as well.
p.mtz byte-identical on myob, cytc, thau, kdp, 8a1a and 8qaw, GPU and CPU builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
ApplyCellSurface copied and sorted every term's 1/d^2 to read 19 order statistics off it, for each
of the four surfaces of the final merge. The edges are now selected: one pass counts the values
under each top-16-bit pattern (a non-negative float orders as its bits), a second collects the few
bins the ranks fall in, and nth_element picks the value inside each. An order statistic is a value,
so the edges are the ones the sort gave.

8a1a, GPU build: the shell set-up of the four surfaces 0.55 -> 0.26 s (instrumented run), the
correction-surface window 6.32 -> 6.12 s and the final merge 11.03 -> 10.82 s (two interleaved warm
runs each); whole run 55.78 -> 55.45 s. 8qaw surfaces 2.95 -> 2.83 s.
Tried in the same window and not kept, as they measured nothing: the serial stable counting sorts
(surface group order, decay terms, fulls CSRs, delta-CC1/2 frame order) on all threads; delta-CC1/2
workers uncapped from 8; measuring two rounds of the edge search at once. The delta-CC1/2 window
(2.4 s) is bound by its longest chain of ranges, one serial accumulation, not by the worker count.
p.mtz byte-identical on myob, cytc, thau, kdp, 8a1a and 8qaw, GPU and CPU builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
SlurpGz cleared the reused read buffer and grew it back in 1 MB resizes, so every frame
zero-filled its whole decompressed size before gzread overwrote it. It now grows the buffer only
past what it already holds and trims it to the bytes read; the bytes are the same.

8qaw (Eiger 16M, .cbf.gz): memset 5.7% -> 0.5% of the run's user cycles (perf, GPU build). The GPU
build's image loops do not move (pre-pass / output loop 15.28 / 28.63 -> 15.16 / 28.62 s): they are
not bound by the decode. The CPU build's loops are; with the prediction change after this one, its
8qaw loops go 116.6 / 251.3 -> 94.4 / 206.8 s.
Tried and not kept: reading the next frame on a thread of its own while the worker analyses the
current one (two raw images per worker, two page-locked regions in ImagePreprocessorGPU). Exact,
but the 8qaw loops got slower (15.3 / 28.6 -> 16.1 / 29.8 s) and myob's too (2.10 -> 2.40 s): with
the decode overlapped the loop is still waiting on the uploads.
p.mtz byte-identical on 8qaw, GPU and CPU builds; the HDF5 sets do not reach this code (and are
identical with the commit after this one).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Rotation prediction enumerated the whole (2h+1)(2k+1)(2l+1) box every frame and kept the ~1% whose
rotation solution lands near the frame. A solution within phi_limit of the frame needs
|f| = ||p0|^2 + 2 S0.p0| <= 2 |S0_perp| |p0| phi_limit, and along an (h, k) column f is a quadratic
in l, so the l that can pass are at most two intervals. Those are walked - widened by 1% in f and by
a whole index at each end - in the same order and with the same arithmetic; the rest the phi test
rejected anyway. Without a phi limit (min_zeta 0) the whole column is walked as before. The GPU
kernel is unchanged.

A new test predicts 1000 random lattices, geometries, axes and rocking widths both ways and
requires the same reflections in the same order, every value bit for bit (30000 cases passed once
locally). CPU build, two interleaved warm runs: 8a1a output loop 241.5 -> 216.3 s, pre-pass loop
90.7 -> 73.8 s, whole run 427.0 -> 384.2 s; 8qaw (with the gzip change before this) loops
116.6 / 251.3 -> 94.4 / 206.8 s, user CPU 14700 -> 12718 s. GPU build within noise.
p.mtz byte-identical on myob, cytc, thau, kdp, 8a1a and 8qaw, GPU and CPU builds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A pixel that reads at most half of its own sector's level on average, by more
than the bound a hot pixel's excess has to clear, is now masked as dead - the
mirror of the hot rule. Nothing but a defect keeps a pixel dark frame after
frame, and every reflection whose disk covers it is integrated short.

The finder now also runs on CCD sweeps (no sensor depth): a dead CCD pixel
reads its offset, far below any ring with background, and the hot rule's
ten-fold ratio does not depend on Poisson counts either.

Found on 5jk4 (ADSC Q4, 2304x2304): the last row and the first two columns
read the offset (~20 ADU against ~60-100 around them) and were unmasked.
Reflections touching them merged to large negative intensities, and one
observation at y=2298 with sigma 10334 swamped the outer shell's half-set
CC1/2 (3.9 %) and the overall CC1/2 (0.786; 0.998 at rc.174). With the
6630 dead pixels masked the fall-off is clean: CC1/2 0.998, cut 0.96 A
(fit 1.01 A), at --prepass-fraction 0.5 and 1 alike. Counting-detector
sets with low background are untouched (myoglobin X06DA: 0 dead).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The automatic cut fitted its logistic to one Pearson CC1/2 per bin, which
has no bound on what one pair can do to it. In a weak shell a single
reflection with a huge intensity in both halves - an artefact the halves
reproduce, or one observation with a sigma fifty times the shell's - carries
nearly all of the covariance. Each bin's reflections are now dealt into up
to seven groups by their indices (groups of at least ten reflections), and
the bin reads the median of the groups' CC1/2. On a clean shell every group
estimates the same CC1/2, so the median is the Pearson value; an outlier
takes one group with it and the median does not follow. Only the cut
decision changes; the reported shell table is unchanged.

Found on a weak split myoglobin crystal: one reflection carried 50-99 % of
the covariance in most fit bins past 2.6 A (e.g. 43647 in a bin with median
I 1.3), reading them at CC1/2 0.93-0.98 where the median of groups reads
0.2. The cut swung between 1.21 and 2.03 A with --prepass-fraction; it is
now 1.85 A at 0.5 and 1.89 A at 1 (XDS 1.51 A, rc.174 1.96 A).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
WilsonOutliersGPU runs the test of WilsonOutliers.cpp step for step on the
device and returns the same result to the bit: the two orders the host sorts
into are total orders, so a stable radix sort of the indices reaches the same
sequence; every sum is taken by one thread over its shell or reflection in the
host's order; each product the host rounds on its own (-ffp-contract=off) is
rounded with __dmul_rn; medians are read off sorts; and the logarithms (t, z,
u, the tail scale) are taken on the host from downloaded counts and medians.
MergeAndStats calls it where the merge itself is on the device.

8a1a final P61 merge, "rsm merge and statistics" 4.35 -> 2.82 s (the test runs
twice there, once per error-model fit). p.mtz md5 unchanged on myob, cytc,
8a1a and 8qaw (GPU build). A Catch2 case checks the device result against the
host one on a population that reaches every branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
MergeEmSamples now forms each sample whole on the device - the counting
variance at the reflection's expected intensity included, every product rounded
on its own as the host build rounds it - and compacts them in fulls order with a
scan, so the host receives exactly the vector it used to build. That replaces
the download of three full-length double arrays and a flag array, and the host
pass that compacted them while reading every full's eighty-byte record.

p.mtz md5 and the error-model lines unchanged on myob, cytc, 8a1a and 8qaw
(GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The per-group sums the widening is measured from - each hand's inverse-variance
mean under the error model's sigmas, the group's d and the d range - are a walk
over every full's record on the host. In the GPU build a kernel takes them, one
thread per group in index order, with the host's model sigma to the bit
(ModelSigmaExact: every product rounded on its own); the host keeps the shells
and their medians. The host path is the same sums, now kept per group so both
paths hand the shell step one array.

p.mtz md5 unchanged on myob, cytc, 8a1a and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The multiplicative spread of the outlier band is the weighted median of
(|ln(I/median)|, weight) pairs, one per usable full, and its sort was the bulk of
that step. SortPairsGPU sorts them on the GPU (by the second key, then stably
by the first) and leaves the sequence std::sort leaves: two pairs that compare
equal are the same two numbers. The values themselves (a logarithm) and the
running sum stay on the host.

p.mtz md5 unchanged on myob, cytc, 8a1a and 8qaw (GPU build); a Catch2 case
checks the device order against std::sort.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The merges of the largest sets run with 12-13.6 GB of a 16 GB device already
taken by the resident observations, so the transient footprint of this pass is
what decides whether it fits. Release the sort keys as soon as the d order is
known and the noise ratios once their per-shell medians are read - on the
device, rather than downloading every sorted value. Peak about 96 bytes per
observation; the free memory is checked first and a device without it stops
with a message naming the CPU build.

p.mtz md5 unchanged on myob, cytc, 8a1a and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The strong-reflection asymptote is read from per-group sums of I*corr, its
square and the counting variance at the group mean - a walk over every full's
record on the host. In the GPU build a kernel takes them, one thread per group
in index order with the host's rounding (CountingVarianceExact); the host keeps
the strong-group selection and the median.

p.mtz md5, p.cif and the error-model lines (which print the asymptote)
unchanged on myob, cytc, 8a1a and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Every full's observation key and its half - the parity of the key's rank among
its group's usable fulls - are integer work, so the device gives the host's
halves exactly: one thread per full for the keys, one per group for the ranks.
The host receives both (the anomalous statistics rank the same keys again within
each Bijvoet cell) instead of walking every full's record for them.

p.mtz md5 and p.cif unchanged on myob, cytc, 8a1a and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
FitErrorModelGPU puts the samples in the host's rank order with four stable
radix passes, so the equal-count bins hold the same samples; computes every
normalised deviation with the host's rounding, so every bin median is the
host's to the bit; and hands the per-bin terms to the host's own update step,
now ErrorModelUpdate (a pure extraction from FitErrorModel). The one thing
that differs is the order each bin's two sums are added in: a fixed tree on the
device, the order nth_element happened to leave the bin in on the host. a and
b^2 can therefore differ from the host fit in their last bits - measured on two
synthetic pools: relative difference 2e-16 and 3e-13 in a, 5e-16 in b^2. The
device fit is deterministic (same pool, same bits).

p.mtz md5 nevertheless unchanged on myob, cytc, 8a1a and 8qaw (GPU build): the
difference does not survive the float rounding of the merged sigmas there. A
knife-edge decision elsewhere could still see it, which is why this is kept as
its own commit.

The host fit took 0.4-0.6 s per fit on the large merges, about half of it the
initial equal-count split, with only 16-way parallelism in the iterations.
Device peak about 72 bytes per sample (the rank sort; each stage's buffers are
freed when it is done); the free memory is checked first and a device without
it stops with a message naming the CPU build.

Kept last on the branch so that it can be dropped on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
XDS plugin 1.1.0: mask module/chip edges, pass saturation through; recommend Durin
Build Packages / Create release (push) Successful in 16s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m20s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m11s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 9m57s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 12m39s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 19m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 20m21s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m36s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m50s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 23m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m30s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m22s
Build Packages / Generate python client (push) Successful in 44s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 18m58s
Build Packages / Build documentation (push) Successful in 1m25s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 19m3s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m9s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m14s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 18m37s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 14m23s
Build Packages / Unit tests (push) Failing after 1h17m53s
2629f3821c
Plugin:
- Module-edge (bit 30) and chip-edge (bit 31) pixels are marked -2 (untrusted). Chip-edge pixels
  are split from a larger pixel with integer division and read low at low counts; feeding them to
  XDS as real pixels was what made the three plugins disagree in CI.
- A saturated pixel in a signed image (INTx_MAX) is passed through with its value, so XDS counts
  it as an overload; only the error marker (INTx_MIN / UINTx_MAX) becomes -1.
- Release version 1.1.0 set in CMake (library name and start message). VERSION_* in plugin.h stay
  0: XDS refuses a plugin whose info-array major version is not 0.
- Built and packaged on Linux only; the untested macOS archive is dropped.
- The XDS linking permission is removed from LICENSE; the plugin is GPL-3.0.

CI:
- XDS.INP beam centre corrected to 1091/1137 (ORGY was 7.7 px off, which put IDXREF on a knife
  edge where one plugin's run fell into a false minimum).
- Each XDS case must reach CORRECT, IDXREF spot SD must be < 2 px, and all file layouts read by
  the same plugin must give identical SPOT.XDS.

Docs: recommend Durin (PSI or Global Phasing build); mask-bit table rewritten from measurement.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
std::thread::hardware_concurrency() counts every CPU of the node, so a job
given a CPU set (taskset, a cpuset, a Slurm allocation) or a container CPU
quota started one thread per node CPU: the default -N, the shared worker
pool and every "0 = all threads" default. AvailableCpus() (ThreadAffinity)
counts the start affinity mask, capped by the cgroup CPU quota (v2 cpu.max,
v1 cfs_quota/period, the process's own cgroup and then the mounted root),
read once. Outside Linux it is hardware_concurrency(), so the viewer tree
stays portable. It replaces every hardware_concurrency() call outside the
tests and the vendored pocketfft.

Only how many threads run changes; the passes split their work by n alone,
so the results do not depend on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
With two GPUs or more the two-pass quality guard's merge of the pre-pass
frames gets a scaling engine of its own on device 1 (RotationScaleMerge::
SetGpuDevice), and the main engine's device ingest and first merge no
longer wait for it to hand the card back. The first merge waits only for
the guard's ingest, the last time the guard reads the outcomes: that merge
writes per-frame fields (mosaicity among them) back into them.

Neither merge reads what the other writes, and a merge gives the same bits
on any card of one model, so only when the guard runs changes. With one
GPU, and in the CPU build, the order is the one it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
With the fulls resident (the GPU combine ran), ApplyCellSurface no longer builds
its terms on the host and uploads them: SurfaceBuildTerms makes them from the
resident fulls, this surface's cell per full and the current corr (the only two
uploads) - which fulls give a term, the terms in fulls order, the group CSR over
them and each subset's per-(block, cell) segments - and SurfaceShellWeights the
resolution gauge's inputs: the shell edges (order statistics of 1/d^2 read off a
sort), each group's shell and each (cell, shell) bin's weight. Every order the
host made is a stable counting sort, which a stable radix sort of the indices
reproduces; the per-bin weights are summed one bin per thread in fulls order
with the host's rounding. The existing exact surface kernels read them as
before. Without resident fulls (the observation dump) the host path runs.

p.mtz md5 and the surfaces' log lines unchanged on myob, cytc, thau, kdp, 8a1a
and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The fused adaptive spot finder's azimuthal profile (and the standalone AzIntEngineGPU) summed the
corrected pixel values - pixel times a float correction - with float atomicAdd, in shared memory
and then across blocks. Float addition is not associative, so the per-ring sums depended on the
order the threads and blocks happened to arrive in, and on the grid size chosen for the GPU. The
profile, and the per-image background estimate read from it, moved in their last bits between two
runs of the same command: on a thaumatin JUNGFRAU 4M sweep one bkg value in _plot.txt differed in
the 4th digit between runs. The raw ring sums that drive detection were already exact integers;
only the reported profile was affected.

Each corrected value (and its square) is now quantised to 2^-12 on its own and summed as a signed
64-bit integer, the convention the raw ring sums and the Bragg-integration profile already use.
Integer addition is associative, so the sums are the same whatever the order or grid.

The repeat test of the fused finder now also requires the profile and its std to repeat bit for
bit (it failed on the old kernel at the first repeat); the AzIntEngineGPU test re-runs 20 times.
Fused finder benchmark: 0.313 -> 0.324 ms/frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Plugin 1.1.0 (2629f3821) passes a saturated pixel of a signed image (INTx_MAX) through with its
value so XDS flags it against OVERLOAD; only the error marker (INTx_MIN signed, UINTx_MAX unsigned)
becomes -1. The legacy test still expected INT16_MAX -> -1. It now checks the saturated value
explicitly and also that module-edge (bit 30) and chip-edge (bit 31) mask pixels read as -2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The rejected flags and the observation keys and halves (per full), the merged
intensities (per group) and the Wilson observation records were locals: on a
large cell every one of the run's merges allocated, page-faulted and zeroed
them again on one thread - hundreds of megabytes per merge on 8qaw. They are
now members, only resized, and reset with ParallelChunks where a pass needs
them reset. The error-model samples are no longer cleared before the device
download overwrites them (the host path still clears before it appends).

p.mtz md5 unchanged on myob, cytc, thau, kdp, 8a1a and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Which groups are written is decided first (KeptIndices, in group order), then
each written group's MergedReflection is formed on its own, on all threads,
into a result sized once - where the serial loop pushed back up to millions of
records, growing a vector that each merge allocated afresh. The same records in
the same order.

p.mtz md5 unchanged on myob, cytc, thau, kdp, 8a1a and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The R_meas accumulators per group and the device's per-group sums were eight
group-length locals plus a 56-byte record per group, value-initialised on one
thread for every merge that reports statistics. They are now members, reset in
parallel, and the device branch's pass that assigns each group its shell and
copies its sums runs on all threads; the per-shell observation counts are
integers, gathered per block. The shells' R_meas sums are still added in group
order, as before.

p.mtz md5 unchanged on myob, cytc, thau, kdp, 8a1a and 8qaw (GPU build).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The goniometer rotation-scale walk ran between the geometry pre-pass and the
refined (output) pass, on the critical path: two indexing probes, at the stored
angles and at the pass-1 fit, each with its own first-pass spot finding.

Adopting a scale other than the stored one is rare, so the refined passes now
start at once at the stored angles, on a copy of the run, while the walk runs on
the run itself. The walk starts once the output pass has indexed, so its probe at
the stored angles finds that first pass already done (spot store, rotation-indexer
memo), and its probe at the fit takes the frames' spots from the store as well:
the spot store's key no longer carries the goniometer's angles, and the spindle
angle each stored spot carries is stamped again for the angles in hand, exactly
as DiffractionSpot::Export stamps it (verify_first_pass_memo still recomputes and
compares). The output pass waits for the walk's answer only before it writes
(the superseded flag); where the walk adopts a scale, or leaves the spot-finding
settings changed behind it, the copy's passes are thrown away and the refined
passes run again on the run itself, which is then exactly where the sequential
walk left it. A refit integrates and only follows a move off the stored angles,
so it first calls the copy off (a cancel flag of its own) and waits for it to
end: two integrations never share the card.

WHEN, not WHAT: the adopted scale and the surviving output pass's inputs are
those of the sequential flow. The console shows the check as a step only where a
scale is adopted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The two-pass geometry pre-pass integrates the first --prepass-fraction
(0.5) of the sweep, but never less than 120 deg of rotation (header
oscillation), so a sweep up to 120 deg is pre-passed whole and one up to
240 deg gets 120 deg. Wider sweeps are unchanged. The range is computed
in one place, Rugnux::PrepassEnd.

Evidence (fraction sweep with the rc175-p1-baseline binary, 10 sweeps
of 360 deg and 10 of 180 deg; pre-pass distance vs the full-sweep fit):
median error 0.081 % at 90 deg, 0.042 % at 120, 0.042 % at 150 and
0.033 % at 180 on the 180-deg sweeps; 0.032 / 0.021 / 0.013 % at
120 / 150 / 180 deg on the 360-deg sweeps. 120 deg on a wide sweep
matches what the whole-sweep pre-pass gave a 180-deg sweep. Below
~60 deg the pre-pass misses the distance outright (6rym: held at the
header, 3.4 % off; 6vww and 6fwc: distance not freed). Merged quality
moves by under 1 % at any angle >= 90 deg.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The pre-scan sample (beam-stop shadow, defective pixels, spot width, the background beam centre)
and the first pass's frames (the spread and wedge schemes, the validation frames, the prefetch) now
come from [start, PrepassEnd) - the part of the sweep the geometry pre-pass integrates - instead of
the whole sweep. Rugnux::PrepassEnd is the one place that range is defined, for the pre-pass and for
these decisions alike, so any change to the pre-pass range (such as a minimum rotation) moves both.
Stills keep the whole range.

With rugnux following a collection while it is written, nothing before pass 2 then waits for the
second part of the sweep: the pre-scan, both first passes and the pre-pass run while it is
collected, and pass 2 starts reading as soon as they are done. An offline run takes the same
decisions from the same frames, so the streamed result still equals the offline one by md5 (in-house
JUNGFRAU 4M thaumatin replayed at one data file per second, also at -N 8 and with the replay paused
and resumed): 2.4 s from the last data file to the result, against 8.0 s for the offline run.

Result-changing. p.mtz md5 at af3ea9cca -> here (with the 120 deg minimum of the previous commit),
GPU: myob de2ab820 -> 9bdd6916, cytc 3e5d2f91 -> 62e744a1, thau bc5e3de3 -> 3ec88c34,
kdp cf83ee5d -> cdc2c068, 8a1a 50ab659e -> 309ea9a8, 8qaw d3287922 -> c2849d52; CPU: myob 3f9d12d8 ->
18c7ac2f, cytc d30c9969 -> 40e17d97, thau 4cdbdd94 -> f14e52da, kdp f1d801f3 -> eb930714. Sweeps of
120 deg or less are unchanged by this commit (their early range is the whole sweep). Needs the
battery before it is merged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The final master marks a finished collection for anyone watching the directory (rsync, autoprocessing,
a reader following the collection), so it must not appear before a data file it links to.

- FileWriter: at END every data file is closed (renamed into place) before the master is finalized
  and renamed. A data file that fails to close does not cost the master; the failure is reported
  after it. For NXmxIntegrated, file 0 is the master's own image dataset, so there a failure still
  stops the master.
- With several writers, the master was written by the writer of the first socket as soon as it got
  END, while the others could still be closing their data files. END now goes to every other writer
  first:
  - TCP: the connections are taken in reverse order, and each END waits for its acknowledgement,
    which a writer sends only after closing its files;
  - ZeroMQ, with a writer notification socket: END to every other socket, then their notifications
    (the same ones Finalize collected before, now collected here), then END to the first socket.
    Without a notification socket there is nothing to wait on and the order is all it changes.

Tests: TCPImageCommTest_MasterWriterGetsEndLast (three mock writers, the master's END after the
other two acknowledged), StreamWriterTest_ZMQ_MasterAfterAllDataFiles (two real writers, the second
started only after the collection was sent; the master's ctime is after every data file's). Both
fail without this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
So that rugnux can process a collection while it is written, the broker reports each data file once
a writer has closed it and renamed it into place:

- Writer: FileWriter takes a callback, called for every closed data file. StreamWriter uses it on TCP
  to send an acknowledgement with ack_for = FILE_CLOSED (new TCPFrameType 10): file number and the
  images the file holds. TCP protocol version 5. Files closed at END are reported before the END
  acknowledgement.
- LiveCollection (image_pusher): the start message the writer of the master file received, the
  closed files, the end. Fed by the TCP pusher (FILE_CLOSED acknowledgements; end once every writer
  acknowledged END) and by the in-process HDF5 pusher (the FileWriter callback). The ZeroMQ pusher
  has no back channel and offers none.
- Two routes, protected by the dataset's bearer tokens like the other dataset routes:
  - GET /live/start.cbor: that start message;
  - GET /live/events: server-sent events (collection, file, end, superseded) with the history first
    and a keepalive every 10 s. One follower at a time; a second gets 409.
  Documented in jfjoch_api.yaml; they add no schema, so the generated C++ model is unchanged.
- The HTTP server's thread pool is set explicitly (16, up to 64): an event stream holds a thread for
  the whole collection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
rugnux <path>/<prefix>_master.h5 http://<broker>:<port>/ - when the master file is not on disk yet -
processes the broker's current collection as it is written:

- the start message comes from GET /live/start.cbor; the given path has to end in the collection's
  master file name, and the data files are read next to it;
- JFJochStartMessageReader builds the dataset from the start message (detector, geometry,
  goniometer, mask) and reads the images straight from the data files; a read of an image whose file
  the broker has not reported waits for it;
- BrokerFeed reads GET /live/events in a thread of its own and hands the reader each closed file. On
  end, the run finishes with the images the collection delivered (a read past them returns no image).
  A broken stream, another collection, or a refused follower makes every waiting read throw
  LiveCollectionLost, which ends the run like a fatal resource error;
- the token is taken from JUNGFRAUJOCH_HTTP_TOKEN (the variable the viewer uses) and sent only in
  the Authorization header; without it, and with no master file on disk, rugnux stops at once;
- rotation data and --mode mx only; plain http only (TLS is not linked into rugnux).

With the master file on disk, the URL is ignored and the run is offline as before. The streamed run
does not read the master file and is not compared with it: its result can differ from an offline run
of the same files in the last bits of the metadata.

Tests ([Live]): LiveCollection, the server-sent-event parser, the FileWriter callback, the TCP
pusher's closed-file reports with two real writers, and JFJochStartMessageReader against the
master the writer produced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
For demonstrating and testing a collection followed while it is written:
- jfjoch_stream2_replay replays a stored dataset as a DECTRIS detector's stream2 (ZeroMQ PUSH on the
  stream2 port): start message, every image at a given rate, end message.
- fake_simplon.py stands in for the detector's SIMPLON control interface: answers the configuration
  and status requests the broker makes, accepts every command, reads back idle. It needs port 80.
Not installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The ladder's rung says which spots let the FFT find the lattice; until now it
also fixed how deep the whole run reads each frame (the spot-finding floor),
so which floor's FFT happened to succeed decided the run's depth. On a hard
pattern that is close to a coin toss: with the decisions taken from the first
part of the sweep, a sub-angstrom crystal (7atg) tied at 60/60 on a 1.13 A
and a 1.60 A floor, kept 1.60 A for the run and lost half its validation
spots. Now the winning lattice is itself scored at each deeper floor, deepest
first, and the run reads to the deepest at which it indexes as many
validation frames as at its own. A contaminant past a floor costs the lattice
frames there and keeps the floor.

Measured (GPU unless noted), against c240690a1:
  7atg              ISa 21.0 -> 23.1, R_meas 5.4 -> 5.1 %, R-free(model) 0.220 -> 0.160
                    (whole-sweep decisions, af3ea9: 22.9, 5.2 %, 0.178)
  myob_x06da_split  reads to 1.92 A instead of 3.00 A: ISa 2.2 -> 2.7, R_meas 87 -> 78 %,
                    CC1/2 81 -> 70 %, R-free 0.382 -> 0.402 (a contaminated split crystal)
  9qw8, 9zmu, 8v4o, myob_x06da_powder_1/2 (CPU): unchanged; 8v2t: p.mtz bit-identical.

Result-changing only where the ladder adopts a rung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The beam-centre check runs a second first pass at the centre measured from
the background, and reads the first pass's answer only once that pass is
over. With two GPUs or more, its spot finding (on the second card), scheme
feeding and FFT indexing now start beside the file centre's pass, on a
copy of the experiment at the measured centre with workers, a mapping and a
spot list of their own (SpotFindingWorkers). The check takes them only
where the experiment (rotation angles included), SpotFindingKey at the
measured centre and the spot budget are still what the run has there, and
scores the schemes on the run's own indexer as before; otherwise it runs
that pass itself as before. The spots at the two centres are not shared:
the spot list depends on the centre (ring background, resolution, budget).

With one GPU, and in the CPU build, nothing changes: on one card the two
passes share the card and the reader, and side by side they saved nothing.

FindFirstPassSpots is the first pass's per-frame spot finding, now shared;
ExperimentKey and SpotFindingKey take an experiment other than the run's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
With a DECTRIS detector the broker starts the writers - and so learned the collection's start
message - only when the detector's own start message arrived, after /start had returned. A reader
started right after /start found no collection (404) and had to wait for, and race with, the
detector.

- /start registers the collection in LiveCollection (Expect) as it is accepted, so GET /live/events
  is accepted from then on. The image pusher fills in the start message when it starts the writers
  (Begin) - at once on the FPGA path, when the detector's start message arrives on the DECTRIS path.
- The event stream's first event is now `start`, carrying that start message (CBOR, base64) with
  run number, file prefix, image count and images per file; it replaces `collection`. Until it is
  known the stream only keeps alive. file and end follow as before.
- However the measurement thread ends, it ends a collection the pusher did not end (EndIfOpen), so a
  cancel or a failure before the detector streamed reaches the reader as `end` with an error. The
  wait is event-driven: it ends with the start message or with the measurement.
- GET /live/start.cbor is removed: the stream carries the start message, one route less.
- rugnux takes the start message from the stream (BrokerFeed::WaitForStart), so it can be started
  right after /start; files reported before it attached the reader are replayed to it.

Tests: LiveCollection_ExpectedAtStart; BrokerFeed_StartFromEventStream (an event stream whose start
message comes late, then a file and the end).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Merge branch 'par-centre' into rc175
Build Packages / Create release (push) Successful in 15s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m35s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m12s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 10m35s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 15m11s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m28s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 20m36s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 21m55s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 22m43s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 24m29s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 18m36s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Failing after 28m11s
Build Packages / Generate python client (push) Successful in 46s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m29s
Build Packages / Build documentation (push) Successful in 1m28s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 18m30s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 17m38s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m10s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 19m53s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 15m29s
Build Packages / Unit tests (push) Successful in 1h21m1s
7c9a6c7d11
IDXREF writes the h,k,l it assigns back into SPOT.XDS, and its choice of basis is not deterministic
(sign flips between runs), so an md5 of the whole file could differ between layouts holding the same
pixels and finding the same spots. Compare x, y, frame and intensity only - what a read plugin
actually determines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The device correction-surface fit (gpu-tail2) keeps its per-term arrays - the
terms, their parity, the group CSR, the three subsets' orders and the per-term
weights, about 60 bytes a term - as engine members, and nothing freed them
after the last surface. They stayed on the card through the merge, where the
GPU Wilson outlier test (gpu-tail) then asks for ~96 bytes per full. On a
large rotation set (139.8 M partials, 13.35 M fulls, 16 GB card) the engine's
resident arrays left 1242 MiB free for the first Wilson test and 998 MiB for
the one after the automatic resolution cut, against 1222 MiB needed: the run
stopped with "Wilson outlier test ... run the CPU build" although nothing ran
beside it. The surfaces' buffers are released once the last surface is
fitted (the next pass's surfaces build their own), which gives back 905 MiB
there.

The up-front checks of the three dataset-sized tail steps (Wilson test, pair
sort, error-model fit) also read free memory as cudaMemGetInfo reports it,
which counts what the allocation pool holds with no buffer in it as taken.
They now hand that back and, only if it is still short, wait for GPU work
running beside the merge (GPUWorkBeside, the speculative probe) to end before
giving up - as the engine's own allocations already do. That changes when a
step runs, never what it computes; a set that does not fit still fails at
once with the same "run the CPU build" message.

p.mtz: the large set completes (and is identical with the Wilson test forced
onto the host); myob 9bdd6916 and 8a1a 309ea9a8 unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The first pass's last gate - is the lattice this crystal's at all - compared the validation spots
on the lattice with the median of five displaced-spindle-angle nulls, against the null's binomial
noise floored at one spot. On the two in-house no-crystal controls it is fed a handful of genuine
stray reflections from something rotating with the sample (the same detector positions, a pair
symmetric about the beam 198 frames apart, in both sweeps, offset by the 15-frame start difference),
plus one reflection near the spindle axis that stays in diffraction for ~46 frames. With the
decisions taken from the first part of the sweep the 60 validation frames are 3 deg apart instead
of 6, so that one reflection is counted on three validation frames, and the gate passed on
4 spots of 151 (null median 0) and 7 of 72, where the whole-sweep flow had seen 3 and 3.

Now the evidence unit is the distinct Miller index - a reflection on several validation frames
is one piece of evidence - and the test uses every displaced angle's count: with no crystal the
recorded angles are one of six exchangeable arrangements, so the gate is the binomial tail of the
recorded arrangement's share at p = 1/6, at the same 0.001 significance (SPOT_BUDGET_SIGNIFICANCE_Z).
No threshold is added; the median and the one-spot floor go from the gate. The comparisons between
lattices (ValidationEvidencePrefers) are unchanged.

  no-crystal control 1: 4 distinct vs 2 over 5 wrong angles, tail 0.009 -> no lattice (was P 1)
  no-crystal control 2: 5 distinct vs 2, tail 0.002 -> no lattice (was P 1)
p.mtz md5 identical to rc175 7c9a6c7d1 (GPU) on 5nw5, 5reo, 7atg, 8v4o, 9qw8, 9rci, 5ky6,
insu_I_x06da_low_isa, myob_x06da_powder_2; cytidine and myob_x06da_split pass. The weakest real
lattice in the open/in-house/private battery logs has 117 spots against a null of 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Two twin domains, or two crystals of one cell, each index every validation frame, so the frame
count the first pass picks its lattice by cannot tell them apart, and the FFT returns whichever
its sample favours. Since the decisions before pass 2 read only the first part of the sweep
(5b9144410), that sample is smaller and it can be the domain there is less of. On an I-centred
cubic crystal with a second domain at 60 deg about [111] pass 1 took the smaller domain; pass 2's
re-index then found the hexagonal P cell both domains share (3x the primitive volume), the
supercell guard handed it back to pass 1's lattice, and the run integrated the smaller domain
over the whole sweep. Every reflection the two share then carried the larger domain's intensity,
which broke the cubic 2-folds (operator CC 0.48 against 0.89 for the 3-fold about the twin axis),
the added-operator R gate refused 23 and the run merged in R3 on the cubic axes - the I centring
undeclared, primitive volume 2x.

Now the spots the chosen lattice leaves on the spread frames are indexed again, as the
leftover-lattice census does, and where they give the same lattice (Niggli-reduced primitive
edges within 2 %) in another orientation, the two are judged on the validation spots each puts
on itself over a wrong spindle angle (ValidationEvidencePrefers); the larger domain is kept. It
is decided on the same early frames, so part B's principle stands.

Measured (GPU, battery, against rc175 7c9a6c1):
  insu_I_x06da_low_isa  R 3:R, d 1.59, R_model 0.315 -> I 2 3, d 1.43, R_model 0.226 (= pre-B)
  5reo                  adopts the larger domain (86.5 % against 68.4 % of validation spots):
                        ISa 23.0 -> 34.3, R_meas 23.6 -> 23.3 %, R_model 0.205 -> 0.202,
                        d 1.64 -> 1.69 A, still C2
  5nw5, 8v4o, 9rci, 5ky6, 7atg, 9qw8, myob_x06da_powder_2, nothing_1, nothing_2: p.mtz
  md5-identical (the check ran on most of them and kept the lattice).

Result-changing only where a second domain is adopted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
When neither the file's nor the background-measured beam centre indexes a majority of the
validation frames, the first pass chose between them on the pooled spots each centre's lattice
puts on itself over chance, and only then asked the long-axis rescue - at the chosen centre alone.
The lattices compared there are, by construction, not the crystal's, and the comparison is close
to a coin toss. With the decisions before pass 2 taken from the first part of the sweep (part B,
5b9144410), 8v4o (P6122, c = 545 A) tipped from the file's centre to the measured one, 3.6 px out
across the spindle, on 16.7% against 15.5%; the rescue there could not see the 545 A axis, and the
run shipped a P1 sub-cell (primitive volume 0.35x). At the file's centre the same rescue on the
same early frames indexes 60/60 (checked with --beam-center-check=off on the rc175 binary).

The rescue is now a lambda, asked at both centres in that branch so each centre is judged on the
best lattice it gives, and asked as before after the beam-centre check where that branch did not
already ask it. The early-frame principle of part B is unchanged.

8v4o: P1 2.62 A, CC1/2 0.920, R_model 0.553 -> P6122 2.12 A (pass), the file's centre kept.
p.mtz md5-identical to rc175 7c9a6c7d1 (GPU) on 5nw5, 5ky6, 5reo, 7atg, 9qw8, 9rci, cuhf2,
cytidine, insu_I_x06da_low_isa, kdp_x10sa_20keV, lcystine_x10sa_20keV/25keV, lyso_x06da_half_image,
myob_x06da_powder_2, myob_x06da_sparse, nothing_1, nothing_2 and the two private sets that reach
the same branch. 9rci (doubled-c alternative under tNCS) is a different mechanism: rc175 with
--prepass-fraction 1 also returns the doubled cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The first pass can only choose among the cells the FFT builds, and two
lattices seen together - a twin domain, or a second crystal whose reciprocal
lattice shares an index-2 sub-lattice with this one's - build the cell that
contains both, twice the volume of either. That cell indexes both domains'
spots and wins every comparison of FFT candidates; the true cell is never
offered, so nothing downstream can pick it.

Each first pass now scores the standing cell's seven index-2 sub-lattices
(same orientation and geometry) on the validation frames. The frame count
only nominates (a sub-lattice must index a majority of the frames and no
fewer than the standing cell); the decision is the pooled-spot comparison
already used between a cell and its axis harmonic (ValidationEvidencePrefers,
each lattice against its own wrong-spindle-angle null). A real superstructure
keeps its cell there because its added class holds reflections; a two-domain
union cell loses because it must place each domain's spots twice as finely
with an orientation fitted to both.

9rci (P1, deposited ~36/39/101 A): from rc173 on rugnux had merged a c-doubled
cell (b+2c, ~199 A; R-free vs model 0.57). The deposited cell was reached only
between 0f9f3a and the part-B commit (5b9144410), because pass 1's orthorhombic-
constrained post-refinement walked the tilt to -0.46 deg and the whole-sweep
re-index happened to land on the short cell there. Part B did not cause the
wrong choice; it removed that accident (rc175 with --prepass-fraction 1 still
gives the doubled cell). Now, on the early range at the file's geometry, the
(0,1/2,1/2) sub-lattice indexes 57/60 frames against 41/60 and puts 1915/5659
pooled spots on itself against 1191 (30 and 35 by chance): adopted. Result:
cell 35.90 39.17 100.78 98.6 90.3 89.9, R-free (model) 0.270, d 1.69 A,
CC1/2 0.988 (was 199 A cell, 0.569, 1.81 A, 0.952).

Battery against rc175 7c9a6c (GPU): p.mtz md5-identical on 5nw5, 8v4o, 5ky6,
insu_I_x06da_low_isa, nothing_1, nothing_2, myob_x06da_powder_2, 7atg, 9qw8,
5reo, 6ze4, 9min, 8v2t, lyso_x06da_ref, insu_H_x06da_twin, myob_x06da_split;
only 9rci changes (now the deposited cell; the verdict read pass before only because P1 matched P1). Cost: tens of ms per first pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Since the decisions before pass 2 read only the first part of a rotation sweep
(5b9144410), the first pass samples [start, PrepassEnd) with its two schemes:
'spread' (100 frames over it) and 'wedge' (consecutive frames from the start up to
the accumulation cap). Which orientations a sweep passes through first is chance,
and a sparse pattern can open on frames that do not show the long axis: on 5nw5
(P212121, 92 x 170 x 390 A, 10-25 spots a frame, 0.1 deg frames) the spread over
the first 120 deg returned a C-centred 955 A cell at every density tried (60-200
frames: 0-10/60 validation frames) and the wedge a wrong one (0/60); the FFT never
offered the true cell. The run kept the 5/60 cell as a sparse lattice and merged
P2_1 at 2.43x the deposited primitive volume, ISa 2.7. The spread over 0-90, 30-150,
60-180 or 0-180 deg indexes 57-59/60, and consecutive frames from 60 deg on 60/60:
the second half of the first part carries the lattice.

Where the two schemes index no majority of the validation frames, pick_best now
also indexes 'end wedge' - the first part read back from its end up to the
accumulation cap - and judges it against them as any later scheme is (clearly more
frames, or the axis-harmonic arbiter). It stays inside [start, PrepassEnd), so
nothing before pass 2 waits for the rest of the sweep. It is not asked on a run the
schemes index: offered there unconditionally it only added a lattice for the
axis-harmonic arbiter, which took a 3x hexagonal supercell over an I-cubic crystal's
cell on a spot count. The measured-centre arm of the beam-centre check takes it the
same way, whether its schemes were indexed speculatively or not.

Battery subset vs rc175 7c9a6c (GPU): 5nw5 fail -> pass (P212121, vol 0.996, d 7.04,
ISa 8.42, R-free(model) 0.462; baseline 7.06 / 7.76 / 0.464); myob_x06da_powder_2
d 2.26 -> 1.47 A (baseline 1.47), ISa 2.11 -> 2.34; p.mtz md5-identical on 8v4o,
9rci, 5ky6, insu_I_x06da_low_isa, nothing_1, nothing_2, 7atg, 9qw8, 5reo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A rotation frame is refined on the sweep's one cell (orientation and beam per frame, never the
cell), and a crystal whose cell grows with the dose leaves that cell behind: the per-frame gate
(20 % of the in-resolution, non-ice spots on the lattice) then refuses the frames it misses as if
the crystal had gone. A frame that fails the gate is now tried once more on the sweep's cell scaled
isotropically - steps of 0.1 %, out to +-2 %, nearest first - to the scale that puts most of its
non-ice spots on the lattice, re-refined there, and integrated on it where it then clears the gate.
The scale must gain more spots than sqrt(count), so forty trial scales cannot turn a chance spot on a
sparse frame into a frame. Ice-ring spots are not counted, as the gate does not count them. A frame
the sweep's cell already admits is untouched, and only the per-image integration path does this
(not the first-pass validation scoring).

Found on 5ky6 (P 1 21 1, 564 x 0.5 deg, B rising by 46 A^2 over the sweep), which part B
(5b9144410, decisions before pass 2 from the first part of the sweep) turned from P 1 21 1 into
P 1 2 1. The mechanism:
- The first pass now indexes on [0, 141 deg). A per-frame probe of the isotropic scale that puts
  most spots on the canonical lattice reads -0.1 % on frame 0, +0.4 % at 50 deg, >= +0.6 % from
  115 deg on (at frame 230: 62 spots on the lattice at the sweep's cell, 112 at +0.6 %). Sampling
  the whole sweep (before part B) handed pass 2 a cell averaged over the dose, so frames to ~147 deg
  and part of the last wedge still cleared the gate; the early-range cell is the low-dose cell, and
  every frame from 113 deg on was refused (221 of 564 frames merged, completeness 83 %).
- The axial 0k0 row is recorded in those frames: the merge held 1 screw-absent axial reflection
  instead of 15, so the 2_1 screw could not be decided (P2 by default), and R-free 0.243 -> 0.263.
- Separately, on the early range the FFT candidate matched a false C-orthorhombic setting
  (gamma 92 deg) whose refutation fell straight to triclinic instead of P-monoclinic, so the cell
  was refined free (cell dev 0.64 -> 1.03 %). Not changed here (see the report).
Integrating every frame from the sweep's cell instead (the sparse-lattice switch forced on) also
recovers P 1 21 1, but at 1.63 A with ISa 5.0 and R_meas 45 %: the late frames are then integrated
on a cell they have left.

Result (GPU, battery verdicts against the rc175 full run 7c9a6c7d1):
  5ky6   fail P 1 2 1, d 1.50, R-free 0.263, compl 83.0 % -> pass P 1 21 1, d 1.62, R-free 0.234,
         compl 99.6 % (pre-part-B: P 1 21 1, 1.54, 0.244, 96.9 %)
  9rci   pass -> pass, compl 86.5 -> 94.7 %, d 1.81 -> 1.76
  9qw8   pass -> pass, R-free 0.329 -> 0.326, ISa 5.2 -> 5.5
  8v4o   fail -> fail (its part-B lattice loss is untouched), compl 22.3 -> 23.1 %
  p.mtz md5 unchanged: 5nw5, insu_I_x06da_low_isa, nothing_1, nothing_2, myob_x06da_powder_2,
  7atg, 5reo, 6fvz, 6hwj, 6pxc, 8tha, 9crw, 9jzo, 6zqr, 6rym, thau_x10sa_0p1deg, cytc_x10sa.
Catch2: Rugnux_Rotation, RotationIndexer* pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The arbiter between two first-pass schemes whose cells differ by an integer volume factor
adopted the larger cell when it accounted for more validation spots than the smaller one. Each
count is made on the cloud derotated through that scheme's OWN refined geometry, so the
comparison is also one of two geometries, and the scheme that refined on frames spread over the
sweep wins it whichever cell it found. With the decisions before pass 2 taken on the first part
of a rotation sweep (5b9144410), 'spread' samples 120 deg instead of 360 and on a crystal with a
very long axis it returned a 3x harmonic of the I-centred orthorhombic cell while 'wedge' found the
true one. The 3x cell accounted for more spots than the true cell - and none of its
spots lay off its (0,1,0) sub-lattice, so the class it adds was empty. It was adopted, and the
run merged in C2 at 3x the primitive volume (I/sigma and R_meas both far worse). On the full sweep
(before part B) the roles were reversed: 'spread' found the true cell and 'wedge' a 2x harmonic,
and the count, again won by 'spread', happened to point the right way.

Now the larger cell must also account for more spots than its own sub-lattice - the lattice with
the emptiest class's translation added - counted on the same cloud. On one geometry the two
cells differ only in the added class, which is the comparison the arbiter's design comment
describes. The added class is now measured on the primitive basis (a centred basis's own
absences read as an empty class), and both cells are counted on Niggli-reduced bases, since a
fractional-Miller tolerance on a skewed basis makes the sub-lattice miss spots it contains.
The log reports both counts. The ladder's harmonic check keeps its spot-count rule.

Measured (GPU, against rc175 7c9a6c1 + the OOM fix):
  a large-cell I-centred orthorhombic sweep: the 3x C2 harmonic is no longer adopted; it merges
                          in I222 again, with I/sigma and R_meas back to the night before part B
  6h2p_native             first pass no longer takes a 3x cell with an empty class (24436 vs
                          18613 spots); final unchanged: C2221, d 1.32, ISa 19.75, R_meas 13.6 %
  p.mtz md5-identical: 5nw5, 8v4o, 9rci, 5ky6, insu_I_x06da_low_isa, nothing_1, nothing_2,
  myob_x06da_powder_2, 7atg, 9qw8, 5reo, 6ze4, 9w3y, 6z9g, cuhf2, lcystine_x10sa_20keV,
  myob_x06da_sparse, lyso_x06da_half_image, 8a1a.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
"Error in detector operation" was raised whenever the wait-for-idle loop in
JFJochServices::Stop() ended in any state other than idle. For DECTRIS that
includes a single failed status request (connect/read timeout, non-200), which
GetState() silently turned into NOT_CONNECTED, and a cancel whose disarm
failed - none of them distinguishable in the log.

- DectrisDetectorWrapper::GetState() logs the failure reason before returning
  NOT_CONNECTED.
- Stop() logs the state that ended the loop, whether the stop command failed,
  and what the detector reports (DetectorWrapper::GetErrorDescription(); for
  DECTRIS the SIMPLON state and the detector status "error" list).
- Cancel() logs why the stop command failed.
- A non-200 GET now reports the HTTP status and body (it printed "Success").
- DectrisSimplonClient keeps one keep-alive httplib::Client, serialised by a
  mutex, instead of opening a new TCP connection per request.
- The wait-for-idle loop polls every 100 ms instead of every 5 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Commands and configuration writes (initialize, disarm, threshold change) can
take minutes. With one shared connection a status poll waited behind them;
now commands/config writes go through one kept-alive client and status/config
reads through another, each with its own mutex and fixed timeouts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A pin, loop or capillary on the goniometer shadows one place for part of the rotation only. The
beam-stop mask is one for every frame and is read off the mean over the pre-scan sample, so a pixel
such hardware covers on fewer than about half the sampled frames is kept and integrated attenuated
on the frames it is covered on. Since the pre-scan samples only the first part of the sweep (part B,
5b9144410), a sweep whose moving shadow is short in the first half and long in the second loses it:
on the in-house half-image lysozyme sweep the mask fell from 32.8% to 21.6% of the detector, the
second half of the sweep was integrated through the shadow, ISa dropped 6.9 -> 4.1, R_meas 116 ->
240%, the cell refined a != b, and the attenuated intensities erased the a-axis screw evidence
(merged rc175: P4_1 2 2 instead of P4_1 2_1 2).

The sample is now also read in blocks of 10 consecutive frames (rotation only); each block's shadow
is found on its own with the rings about the whole sample's centre, and the blocks' pieces that
reach beyond the 30 px band the whole sample's edge can grow over (PENUMBRA_MAX_PX) are added whole.
A pixel shadowed on most frames of any longer stretch is shadowed on most frames of one block, so
nothing finer is needed; pieces within the band are a block redrawing a static stop's edge and are
left out, so a sweep without moving hardware keeps its mask bit for bit. Part B's principle is kept:
everything is still decided on the first part of the sweep.

Measured (GPU, against merged rc175 eac0833c8, 19 sets): half-image lysozyme P4_1 2_1 2, ISa 4.1 ->
9.7, R_meas 240 -> 113%, CC1/2 0.955 -> 0.979, R-free(model) 0.331 -> 0.282 (last night 6.9 / 116%).
p.mtz md5-identical on 14 of 19 (8v4o, 9rci, 5ky6, 6yqf, 6ze4, 5reo, 9qw8, insu_I low-ISa,
insu_H notwin, myob powder 1/2, myob split, nothing_1/2); changed: 5nw5 (a dim crescent at the stop
edge now masked: ISa 8.4 -> 8.9, CC1/2 0.937 -> 0.980, R-free 0.462 -> 0.429), 7atg (0.85% added at
the detector edge, statistics unchanged), 9min (0.03%, fail -> fail unchanged), 5jk4 (<0.01%,
unchanged). Cost: pre-scan +0.1 s on HDF5 sweeps, +1-5 s on large file-per-image (CBF, marCCD)
sweeps, where the blocks are re-read and their masks computed on the host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
--model validation (battery-only for users) was 18% of the battery's time. Every
number it produces is unchanged to the bit (p.mtz, maps, placed model and every
model-validation line of the report md5/diff-identical on 11 open sets); only
when and where the work runs changes:

- The bulk-solvent grid fit (FitModelScale, most of the CPU time) fits each
  solvent pair on a copy of gemmi::Scaling's target that takes
  |Fcalc + k_sol exp(-b_sol s^2) Fmask| once per pair instead of at every
  solver evaluation; same expressions, same types (new test checks a grid
  point against gemmi's own Scaling fit with ==).
- Fcalc density and the solvent mask are made on two threads; the model's
  structure factors beside the GPU engine reservation.
- The indexing probe fits the relabellings concurrently.
- The null's replicates run beside the real model's placement (they start
  from a snapshot of the model as read); one GPU engine per replicate plus
  one for the real fit instead of a cap of 4 (engines are interchangeable
  and deterministic).
- The 2mFo-DFc, mFo-DFc and anomalous maps are made and written
  concurrently; the placed model is written beside the reflection files.
- A rigid-body zone whose solvent-mask grid needs gemmi's shrink is sent to
  the CPU when the engines are reserved (ModelMaskGPU::ShrinkIsNoOp), instead
  of failing on the GPU and validating everything again on the CPU - the
  same CPU result, without the wasted first attempt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This reverts commit f257d51ea.

The block-wise moving-shadow mask fixed its one target (a half-image
lysozyme set whose pin turns with the sample) but fired on 47 sets of
the full battery: on one small-molecule set the mask covered 51 % of
the detector and SHELXL R1 doubled (0.10 -> 0.20), on another the mask
grew from 8 to 21 %. The pre-scan cost rose by +87 s on the HDF5 sets
and +499 s on the CBF sets (the commit claimed +0.1 s / +1-5 s per set).
A narrowed version (regr2-mask) still costs +25-45 s battery-wide, about
+0.3 s per HDF5 set, for one difficult set - more than the owner's
budget allows. It is parked for rc176, where a moved shadow is to be
found by a far-end check of the early decisions instead.

The documentation paragraph the commit added goes with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This reverts commit c5a13d212.

The second-domain test fixed its target (a low-ISa insulin set) but was
evaluated 1132 times on 212 battery sets and adopted a "second domain"
on 50 of them; three sets that passed went to fail (5j23, 9qw2 and one
private set: F432 -> P432). It never asks whether the other domain is a
different orientation, so near-ties at 54:46 decide it. A narrowed
version (regr2-domain) still costs about 120-200 s battery-wide for one
difficult set. It is parked for rc176, where near-tie domains are to be
carried as two hypotheses and decided late.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This reverts commit a825fb6cb.

The per-frame cell-scale retry restored the space group of its one
target (a P2 set whose cell grows with dose) but not its data: R_meas
went 26 -> 37 % and d_min 1.54 -> 1.62 A against the pre-part-B run. It
patches the early, low-dose cell of pass 2 frame by frame with a
+-2 % range, 0.1 % step and sqrt(n) margin chosen on that set, and runs
inside AnalyzeIndexing on every set for one difficult one. A late cell
belongs to rc176's far-end check of the early decisions instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
79ec61c14 asked the 'end wedge' scheme inside pick_best, so every pass the
axis-sign, beam-centre and long-axis rescues run carried it, and its count went
into the pass those rescues are gated on. A consecutive wedge indexes part of
the validation frames at the WRONG rotation-axis sign as well: on 7kcn (CBF,
P4_122, the file's axis sign wrong for pass 2) the spread and the wedge gave
0/60, the end wedge 33/60, which lifted the pass over the sign rescue's
majority gate - so the flip, where the spread indexes 60/60, was never asked.
Pass 2 then integrated on the end wedge's lattice (33-49/60 frames): ISa 11.8
-> 4.7, R_meas 7.9 -> 15.3 %, wall 27 -> 43 s. The same mechanism let it
defend a file beam centre the measured one beats on 6p8j.

The end wedge is now asked once, after the rescues, at the geometry they
settled on, and only where nothing has indexed a majority; it is judged against
the standing pass as before. It can no longer displace a pass that indexes a
majority, and the passes the rescues run no longer pay for it.

Battery subset vs e99dd4949 (GPU): 7kcn back to 7c9a6c (p.mtz md5-identical
to 7c9a6c, ISa 11.8, R_meas 7.9 %); 5nw5 still pass (P212121, d 7.10, ISa
8.9); 6p8j first pass back to 7c9a6c's (measured centre, spread 60/60).
p.mtz md5-identical to e99dd4949 on 3mc4, 5ky6, 5reo, 7atg, 8v4o, 9qw8, 9rci,
cuhf2, cytidine, myob_x06da_powder_1/2, myob_x06da_split/sparse,
insu_I_x06da_low_isa, kdp/lcystine/yag/thau inhouse sets, nothing_1 (no
lattice) and six private sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Each candidate setting of the model was scored one after another on the
coarse shell (a density, an FFT and an isotropic scale fit, 0.2-0.5 s
each), and a run with a model in another setting scores them in both of
its validations. They share nothing but what they read, so they now run
on ParallelFor, each on its own copy of the model, and are read back in
their own order: the log lines, the ranking and the tie-break (first of
equal R wins) are those of the serial loop.

Measured (16-core workstation, loaded): 8sa8 3 candidates 0.64 s ->
0.22 s; 9ea5 3 candidates 1.47 s -> 0.50 s; 8xtg 1.45 s -> 0.40 s.
p.mtz, the maps, the map MTZ and the placed model md5-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where a model that fits is written on other axes than the data, the files
take its setting and the validation is made a second time on the
relabelled data. That second validation depends on the first only through
the setting and the indexing the first settles, and both are known long
before the first has finished: the change of basis right after the frame
scoring, the indexing the probe prefers before the null. So ValidateAgainstModel
now reports them (ModelFrameForecast, through ModelValidationSchedule::
on_forecast), and the second validation is started there, on the
relabelling AdoptModelFrame and relabel_output would make, applied to a
copy of the merge - beside the first one's null, real fit and maps.

It is kept only where the first decides exactly what was forecast (the
model fits, same change of basis, same indexing; a model asserting the
other enantiomorph is not forecast, as the label is decided last, on the
anomalous map). Until then its log is held (Logger::Buffered, replayed
where the serial run logged it) and its files wait on a gate
(ModelValidationSchedule::write_gate) placed before the first map is
written; otherwise it is released with false and returns unwritten, and
the serial validation runs as before.

GPU memory: two validations at once take twice the rigid-body engines.
The parallel start is decided up front from sizes, never from what is
free: allowed where twice what the first pool asked for (bytes per engine
times the engines wanted) fits a quarter of the card's TOTAL memory, the
share one validation may take. Threads: both validations submit to the
one ParallelFor pool from threads outside it (the second runs on a
std::async thread, as the null's replicates do), so no pass runs inline
on a pool worker and the pool's size bounds the workers.

Measured on the loaded 16-core workstation, TIMING model validation:
8sa8 30.6 -> 19.7 s, 8xtg 19.9 -> 13.9 s, 9ea5 22.1 -> 16.4 s (this and
the previous commit together). p.mtz, p.hkl, p.cif, the three maps,
p_maps.mtz and p_model.cif/pdb md5-identical to the base on 8sa8, 8xtg,
9ea5, 7qis and myob_x10sa; the validation's log lines identical as a set
(the null's replicate lines were already in completion order).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The rigid-body pool put all of its engines on the calling thread's current
card, so a validation used one GPU whatever the machine had. Now, by fixed
rules decided up front and never by momentary free memory:

- RigidBodyGPUPool::Create puts engine i on card (d + i) % count, d being
  the calling thread's card; the pool restores that card afterwards
  (an engine's constructor sets its own) and an engine is released on
  its own card.
- The threads that run the null's replicates are pinned with pin_gpu
  (which also binds them to the card's NUMA node where that is enabled),
  replicate thread t to card (d + 1 + t) % count, and Acquire() hands a
  thread an idle engine on its own card where there is one, any other
  otherwise. With one card this is exactly the previous back-of-the-list
  choice.
- The validation started on the forecast runs on card 1 % count, so with
  two cards or more it is on the other card from the first; the up-front
  memory rule becomes: twice the planned engine bytes within a quarter of
  the cards' total memory taken together.

The engines' kernels are deterministic (no floating-point atomics) and an
engine's result does not depend on which engine it is, so on cards of one
model the numbers are those of one card; what changes is only where the
work runs. On this one-card workstation the multi-card path cannot be
exercised: md5 of p.mtz and the validation outputs are identical to the
base, and the card-count arithmetic was checked by reading only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The validation in the model's setting writes its three maps and the map
MTZ over the ones the first validation wrote seconds before, and a
two-pass run writes over the first pass's. XFS (like ext4) flushes a file
that was truncated and rewritten when it is closed, so each overwrite
forced ~155 MB out to the disk on the spot. Measured on /data (XFS on a
hard disk): three 155 MB files rewritten 3.16 s, written fresh 0.62 s,
removed and written again 0.66 s. The contents are the same bytes either
way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The GPU bulk-solvent mask had no shrink step (SolventMasker::shrink(),
set_margin_around() with Refmac's r_shrink = 0.8 A), so every rigid-body
zone whose grid has an offset within 0.8 A was sent to the CPU. That is
not only fine grids: in an oblique setting (P2_1, beta ~141 deg) the
3.5 A zone's lattice-plane spacing is 0.69 A, and on such a set the real
fit and all nine null replicates ran their rigid bodies on the CPU.

The shrink is now two passes over the grid: mark the solvent points with
a macromolecule point among gemmi's near offsets, then turn into solvent
every macromolecule point with such an edge point at any stencil offset
(near or far, gemmi's split at the coarsest grid step). That is gemmi's
rule in both of its branches, and the stencil is built on the host in
double exactly as gemmi builds it. ModelMaskGPU::ShrinkIsNoOp() and
RigidBodyGPUEngine::MaskSupports() are gone; the rigid body no longer
refuses such zones.

Verified (ModelMaskGPU_ShrinkMatchesGemmi): the shrink alone on gemmi's
post-island mask is bit-identical, and the whole GPU mask equals gemmi's
put_mask_on_grid() bit for bit (0 differing points) on the five test
groups at 1.5 A and an oblique P2_1 cell at 3.5 A; repeats are identical.

Measured on the oblique-setting set of the open arm (P2_1, 14k atoms,
1.66 A): model validation 17.2 s -> 5.2 s with the GPU structure factors
of the next commit in place (the first validation's ten rigid bodies,
~12 s on the CPU, now take well under a second). p.mtz md5-identical;
the null moves from +73.1 to +86.1 sigma (the replicates are refined on
the GPU, which agrees with the CPU to rounding), verdict unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
ModelStructureFactorsGPU computes what compute_model_factors() and
map_from_coefficients() compute on the CPU - F_calc from the model's
density (IT92, Refmac-compatible blur, unblurred as prepare_asu_data()
does) and F_mask from the Refmac bulk-solvent mask, both on the
reflections prepare_asu_data(d_min) lists, in its order; and a map from
ASU coefficients on the grid get_size_for_hkl(coef, 0, 3.0) sizes - on a
device. Made once per cell, group, resolution and model, then evaluated
as often as the coordinates change, so refinement or MR can call it in a
loop. The device is an explicit parameter; every call leaves the calling
thread's current device as it found it.

Pieces:
- ModelDensityGPU: the rigid body's deterministic brick gather, moved
  out of RigidBodyGPU.cu into a component of its own (ModelMaskGPU's
  pattern); the rigid body uses it unchanged. MAX_BRICKS_PER_AXIS 8 ->
  16, so fine grids with high-B atoms (lysozyme at 1.2 A, a 0.9 A P1
  cell) are no longer refused; existing zones are gridded identically.
- One copy of the content is gridded and the symmetry composed in
  reciprocal space (SymmetryComposition), operators applied on the fly;
  the mask is ModelMaskGPU (every image of every atom, islands, shrink).
- Maps: gemmi's get_f_phi_on_grid() in ZYX order on the host (the
  coefficients written are the same), in-place cuFFT c2r, transposed back
  to XYZ on the device. One map at a time, in the engine's buffers.

Decided once, up front, per card, from its TOTAL memory: the engine's
bytes (16 N + cuFFT work + reflections, N the larger of the structure-
factor and map grids) must be at most half the card - the rigid body's
engines take at most a quarter beside it. Otherwise, or where the gather
cannot grid the cell, the CPU path runs, logged with needed vs total.
Anything to a resolution other than d_min (the null's 3.5 A fits) stays
on the CPU, so all replicates and the real model's side of the null are
computed the same way. A CUDA failure takes the existing path: the
validation restarts on the CPU.

Measured, model validation total per run (CPU path -> GPU), 16 GB card:
  F432 215 A cubic, 1.30 A, 500^3 grid: 47.7 -> 15.6 s (two validations;
     14.3 -> 2.4 and 33.4 -> 13.2, the rest of the second is writing the
     three 0.5 GB maps); F_calc + F_mask 5.7 s -> 0.05 s
  C2 1.11 A: 23.9 -> 13.6 s; P3_2 1.55 A: 18.2 -> 10.2 s;
  P2_1 1.25 A: 13.4 -> 6.5 s; F4_132 328 A: 13.0 -> 5.5 s;
  P6_5: 8.8 -> 4.2 s; P4_3 0.97 A: 4.6 -> 2.5 s; P1 0.92 A: 4.2 -> 2.2 s;
  small P1: 3.2 -> 1.3 s; P6_1: 8.8 -> 6.2 s; lysozyme: 1.8 -> 1.4 s.
p.mtz md5-identical on all 13 sets. Against the CPU path: FC within
1e-4 of mean |F|, phases of the strong half within 0.003 deg, maps within
1e-4 (2mFo-DFc) and 7e-4 (mFo-DFc) of their rms; every logged R, CC,
FOM, k_sol and anomalous site list identical at the printed precision,
except where a rigid-body commit sat on an exact R-free tie (0.2155 ->
0.2155) and fell the other way (R-work 0.2127 vs 0.2129). The GPU result
is bit-identical run to run and with -N 8 (maps, map MTZ, placed model).
Peak device memory of the engine: 2.5 GB at 500^3 (process total peaked
at 14.4 GB with what the merge still holds).

Tests: ModelStructureFactorsGPU_MatchesCPU (five groups, 3.5 and 1.5 A:
same reflections, F_calc <= 1e-5 of mean |F|, F_mask 2e-7 rms, repeat
bit-identical), ModelStructureFactorsGPU_MapMatchesCPU (<= 5e-6 of rms).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A CUDA error on the validation's GPU path (rigid body, structure factors,
maps) used to start the whole validation again on the CPU - a migration
part way through, which makes how long a run takes, and in principle what
its rigid bodies converge to, depend on a device fault. It now ends the
validation with ok = false and a logged reason ("the GPU failed during the
validation (...)"), which the run reports as MODEL_NOT_VALIDATED. A
validation that did not finish decides nothing, so the reflection files
are those of a run without a model. Where there is no GPU at all, or the
up-front rule sends the work to the CPU, nothing changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The rotation merge's resident device engine (RotationScaleMerge's GPU
half) lived to the end of the scale-merge step, so model validation
reserved its own engines on a card the merge still held: on the F432
1.30 A set 8.8 GB of the 16.6 GB card was taken when the validation
started. Nothing after the validation reads the engine except the opt-in
finalist ledger's P1 merge, which runs on it beside the validation; so,
unless that merge is running, the engine is released just before the
validation call. Device memory in use when the validation starts:
8.8 GB -> 2.2 GB; the validation window's device-wide peak is now 6.5 GB
(structure factors 2.2 GB, ten rigid-body engines 2.5 GB). p.mtz
md5-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The image-loop workers are kept on the CPUs of their card's NUMA node (pin_gpu), but the two
threads that drive the merging tail were not: the quality guard took its card with set_gpu(1),
and the main engine runs on the pipeline's own thread, which goes on to everything else. On a
multi-socket server their pageable copies to the card went through a remote socket.

RotationScaleMerge now holds a ScopedGpuPin (new, CUDAWrapper) for each IngestHost,
IngestDevice and Run: pin_gpu(gpu_device) for the call, and afterwards the thread's previous
device and CPU mask back (GetThreadCpus/SetThreadCpus, new in ThreadAffinity). So the binding
covers whichever thread drives an engine, and does not stay on the pipeline thread. The threads
the engine starts itself (ComputeAsuGroups, Combine, FitErrorModel, ApplyFrenchWilson use
std::async) call RestoreThreadAffinity first, as the pool's workers do, so they are not
squeezed onto one node. The guard thread, which does nothing but the guard's merge, uses
pin_gpu(1) instead of set_gpu(1). Pool workers are untouched: they restore the start mask
themselves; the new test checks both that and that a pinned caller keeps its own mask.

No flag, no environment variable. On one NUMA node (this workstation) only the device setting
happens, so nothing can move. Measured on the 5080 workstation (one card, one node), base =
rc175-next 41fab6156, --model dropped, under the GPU lock: p.mtz md5 identical on myob_x10sa,
cytc_x10sa, thau_x10sa_16keV, 8a1a, 11if, 7qis, 6h5t, kdp_x10sa_20keV at default -N (2 runs),
-N 8 and CUDA_VISIBLE_DEVICES=0. Tail ("scale, merge and symmetry") unchanged within noise,
e.g. 8a1a 30.06 -> 29.37 s (base runs 29.75/30.36), cytc 5.33 -> 5.35 s. The gain is for
multi-socket boxes (T4 server: guard copies 1.7-2.1 vs 3.5-4.3 GB/s) and is not measured here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The speculation that spot-finds and indexes at the measured beam centre beside the first pass
(only with two GPUs or more) put all its spot-finding workers on card 1. The file centre's spot
finding ends long before the speculation's, so the speculation is what the first pass waits on
(T4 server, kdp: main pass spot finding done at 1.3 s, speculation at 5.6 s). Its workers now
take card t % cards, as the first pass's own workers do; worker t builds and frees its engine on
the same card, since the destructor's OnWorkers maps t the same way.

Spots are a pure function of the frame, so which card finds them cannot change them. The
prototype measured kdp 25.9 -> 23.6 s on the 4-card T4 with md5 unchanged on 5 sets. On a
one-card machine the speculation does not start (get_gpu_count() < 2), so nothing changes there:
p.mtz md5 identical to rc175-next 41fab6156 on myob_x10sa, cytc_x10sa, thau_x10sa_16keV, 8a1a,
11if, 7qis, 6h5t, kdp_x10sa_20keV at default -N, -N 8 and CUDA_VISIBLE_DEVICES=0 (5080
workstation). The multi-card gain and the 2-card risk (speculation workers now share card 0 with
the main pass) are not measured here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The null - nine random placements of the model, and the real model's side
of the comparison - is scored to a coarser limit (3.5 A, or where the free
set allows), and those structure factors still ran on the CPU, one model
at a time on each replicate thread. Where the d_min structure factors are
on the GPU, a second ModelStructureFactorsGPU engine at the null's limit
is now made, on the same card, before any replicate starts; every
replicate and the real side go through it, so all of them are computed
the same way. It is decided like the first engine, from the card's total
memory: the two together must stay within half of it, else the null stays
on the CPU (logged). The replicates' threads may sit on other cards
(modelpar's 8d6e9d3a4); each call works on the engine's own device. Where
the null is scored to d_min it shares the d_min engine, as before.

Measured (validation time, before -> after; CPU path for reference):
  P3_2 1.55 A:  11.2 -> 9.0 s (CPU 17.7)
  P2_1 oblique: 5.1 -> 4.5 s (CPU 7.9)
  C2 1.11 A:   12.9 -> 13.2 s (CPU 23.3) - its null time is in the
               scale fits, which stay on the CPU
p.mtz md5-identical in all four variants per set (before, after, CPU,
-N 8); maps and map MTZ bit-identical after vs -N 8; the null's sigma
moves only in the second decimal (+113.39 -> +113.38, +86.13 -> +86.14).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
With two cards or more, the speculation's spot-finding workers took card t % cards, so some of them
shared card 0 with the first pass they run beside. They now take the other cards, worker t on card
1 + t % (cards - 1): on two cards all of them on card 1, as before the previous commit, and on more
cards spread over cards 1..n-1. Spots are a pure function of the frame, so the card cannot change
them; on one card the speculation does not start. Not measured on more than one card.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Both sides kept: modelpar's parallel basis scoring, the second validation
started on a forecast beside the first (write gate, schedule parameter),
the multi-GPU placement and delete-before-rewrite; gpusf's GPU structure
factors, maps and null engine, failure-instead-of-restart, and the merge
engine released before the validation (now just before modelpar's
ValidateAgainstModel call, after the forecast lambda is set up).

Placement: each validation's structure-factor engines (d_min and the
null's) are made on the card of the thread that runs it - the main
thread's for the first validation, card 1 % count for the speculative
second, which pins itself there - so two validations on two cards use
both, as the rigid-body pools do.

One memory rule for both, per card, from total memory, up front: a
validation plans at most half of its card - its structure-factor engines
a quarter together (was half for the d_min engine alone), its rigid-body
engines a quarter (RigidBodyGPUPool) - and the second validation runs
beside the first only where twice the first's plan (rigid-body planned
bytes + the d_min engine, twice it where a null is coming, the null's
engine being no larger) fits half of all cards' memory together (was:
twice the rigid-body plan within a quarter). The card's total is read
once when the engine is made; a CUDA error there fails the validation
like any other.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
`battery.py run` now ends with one line per set, each column rugnux / reference: the space group
with a tick or cross by the scorer's own verdict, d_min, ISa, CC1/2 overall and in the highest
shell, R_meas of the lowest shell, the model's R-free and rugnux's wall time. `battery.py table
RUN` prints it for any finished run (rows are rescored, so older runs get the new columns).

The highest-shell CC1/2 is new in results.json (cc_half_high, read off rugnux's own shell table)
with XDS's beside it (cc_half_high_ref, from CORRECT.LP's finest shell; the in-house manifest's
refs regenerated with `refs --write`, which added only that key).

A new "ci" tier: 25 sets that passed with the same space group and steady d_min / ISa in all four
rc174/rc175 full runs (and in the rc175-next verdict runs where they ran), with no warning that
questions the lattice or the group. About 10 minutes of rugnux (616 s measured) on the RTX 5080. A run that
includes ci-tier sets exits 1 when one of them fails (lattice, symmetry, crash, timeout, missing
input); nothing else fails a run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Runs on the machine that holds the battery data (runner label jfjoch_battery): builds rugnux with
the CI flags in the rocky8 image (its CUDA 12.9 suits the host driver; python3.12 is installed for
the scorer, the image's own python3 is 3.6), writes a site config naming only the open and in-house
arms, and runs `battery.py run --tier ci` on a push or the given subset (empty: the full battery)
by hand. The job fails when a ci-tier set fails; report.md and the log are kept as the artifact and
under /data/battery/ci/reports, the run directory is deleted.

Only what the job needs is mounted: the two data roots read-only, the PDB/COD caches, a ci scratch
directory and the host's full-run lock file - not /data/battery as a whole, which also holds the
private manifest and the private runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where the validation in the model's setting, started beside the first on
the forecast (b5980c9ba), is going to be kept, it writes the maps, the map
coefficients and the placed model on the written axes, and the first
validation's p_2fofc/p_fofc/p_anom.ccp4 and p_maps.mtz were overwritten
unread. ModelValidationSchedule::maps_superseded is asked once the
verdict, the setting and the indexing are decided, before any map is
made; the first validation then makes none (the map coefficients and the
MTZ are not built, the three map transforms not run, nothing written).
The answer is the same rule that keeps the forecast validation
afterwards (forecast_kept, now one lambda for both). The kept validation
also brings back the reflections it relabelled, free set included, so
relabel_output does not reindex and redraw them a second time. Where the
forecast is discarded or was never started, everything runs as before,
and modelpar's delete-before-rewrite stays for those cases. If the kept
validation then fails, a warning says no maps were written.

Log lines that disappear (first validation only, kept-forecast runs):
"mean 2mFo-DFc density at atom centres = ...", "anomalous difference map
from ... Bijvoet pairs; strongest density ...", "wrote p_2fofc.ccp4, ...";
in their place: "no maps made here - the validation in the model's
setting writes them". (The "anomalous density ... inverted" warning would
also go; it cannot be raised where a forecast is kept.)

Verified on 8sa8, 8xtg, 9ea5 (forecast kept), 8tyy, 7qis, myob_x10sa (no
forecast): p.mtz, p.hkl, p.cif, the three maps, p_maps.mtz and
p_model.cif/pdb md5-identical to the merge commit, repeated and at -N 8;
the discard path (8sa8 with a rotated model) identical files and log.
Time saved is small with the maps already on the GPU: 8sa8 7.6 -> 7.4 s,
8xtg 5.1 -> 5.0 s, 9ea5 within noise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Each rugnux / reference pair is now rugnux right-aligned to a fixed width, "/", the reference
left-aligned to the same width ("-" for a missing value), so every slash of a column sits on one
line; the tick or cross follows the space-group pair in its own column.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The multi-GPU placement (replicate threads pinned with pin_gpu) and the
second-validation memory rule read RigidBodyGPUPool and get_gpu_count(),
which a build without CUDA does not have; both are now inside
JFJOCH_USE_CUDA, where the pool can exist at all. Nothing changes in a
CUDA build. Non-CUDA rugnux and jfjoch_test build; [ModelValidation] 22
cases pass there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The workflow runs on the battery machine, not on the development workstation, so the data roots
(/data/open, /data/inhouse), the whole /data/battery mount, the scratch directory
(/data/battery/scratch/<run id>), the kept reports (/data/battery/ci_reports) and the runner's
valid_volumes are back as that machine has them; the remark tying the image to one host's driver
is gone. The private arm stays out because the site config names only open and inhouse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Structure factors, maps and the null on the GPU (with gemmi's mask shrink step), the second
validation started on a forecast beside the first, change-of-basis candidates scored at the same
time, validation work spread over the visible cards, the merge's GPU engine freed before the
validation, a CUDA error failing the validation instead of restarting it on the CPU, and no
first-validation maps where the forecast one is kept. Output files md5-identical to the CPU path's
reflection files and placed model on 8sa8, 8xtg, 9ea5, 8tyy, 7qis and myob; maps within float
tolerance of the CPU path and bit-identical run to run and at -N 8. Validation time 8sa8 24.0 ->
7.4 s, 8xtg 18.4 -> 5.0 s, 9ea5 19.1 -> 3.0 s, 8tyy 47.7 -> 3.0 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Each numeric pair is now rugnux and reference both right-aligned to a fixed width around the "/",
with fixed decimals per column (d_min 2, ISa / CC1/2 / R_meas / R-free 1, time 0), so the slashes
and the decimal points of a column sit on one line; "-" takes the same width.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Merge branch 'battery-ci' into rc175: a ci tier, a closing per-set table and the battery workflow
Build Packages / Create release (push) Successful in 9s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m25s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m16s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m37s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 13m59s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 19m14s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 19m43s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m3s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m3s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m3s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 18m57s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 18m15s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m27s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / Build documentation (push) Successful in 1m17s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 19m44s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 20m54s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 19m7s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 16m55s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 16m40s
Validation battery / Validation battery (push) Successful in 47m59s
Build Packages / Unit tests (push) Successful in 1h26m17s
0b2dde2147
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
sgequiv keyed space groups on their proper+improper rotation matrices, so groups with
identical merged intensities but different point groups (I41md vs I-42d, Pc vs P2/c,
Pna21 vs Pnma, P2 vs P2/m) were called a "REAL difference", contradicting its own
docstring, and score.py compared point groups literally on the XDS arms. A run that
wrote one member of such a pair and listed the other in SPACE_GROUP_ALTERNATIVES failed
as sym_other / sym_over.

sgequiv now keys each group, in every setting gemmi tabulates, on its Laue rotations
(operations plus their negatives), centring and absences; describe() separates the new
different-point-group pairs from the old same-point-group ones, and enantiomorph
handling is unchanged. score.py passes a row, on both arms, when some setting of the
reference has the same signature as our group in our own setting: verdict pass, cause
indistinguishable_alternative, the reason saying whether rugnux listed the reference.

Pass, not unscored: unlike an unmeasured screw axis, everything was measured and the
answer is as right as the data allow; only a model or a strong anomalous signal decides.
The separate cause keeps such rows countable. Being listed in SPACE_GROUP_ALTERNATIVES
alone is deliberately not enough: that list also carries the screw alternatives of an
undetermined axis, whose absences differ, and those stay under the screw rule. The
open arm's same-point-group UNDECIDABLE pass now also requires the setting check.

results.json gains sg_alternatives (rugnux's SPACE_GROUP_ALTERNATIVES). KDP joins the
ci tier. Re-scoring the open and in-house arms of the two latest full runs changes one
verdict in each: kdp_x10sa_20keV fail/sym_other -> pass/indistinguishable_alternative.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Full open and in-house battery on 0b2dde214 (run 20261009-1447_0b2dde_rc175-0b2dde2-full) against
the 10-07 battery on 9ced4c444, without the REFMAC check: the R-free figure and numbers are kept from
the rc.174 battery. Report regenerated from the run with the current battery tools (verdicts scored
by the current scorer, so KDP passes as an indistinguishable alternative), commentary written by
hand. 5ky6 and the low-ISa cubic insulin set go from pass to fail, accepted losses of taking the
pre-pass-2 decisions from the first part of the sweep; every other verdict is unchanged. The whole
battery took 4898 s against 5795 s, most of it model validation now on the GPU (278 s against
1024 s); without it the HDF5 sets are at the same speed. Time, zoomed time, in-house time and
resolution figures regenerated with paper_plots.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The pre-scan's beam-centre check and the post-refinement both measure where
the direct beam lands, but the report only carried the centre the run used.
Section 1 now sets the file's direct beam (FILE_DIRECT_BEAM) beside the
background's (BACKGROUND_DIRECT_BEAM, with its sigma), the shift of the
refined centre from the file's (BEAM_CENTRE_SHIFT) and the tolerance the
geometry asks for (BEAM_CENTRE_TOLERANCE: BeamCenterNeed_pxl, the number the
log's check prints, here on the run's own cell). A shift above the tolerance
is a BEAM_CENTRE_OFF warning naming the file's, refined and background
centres, the difference in px and mm and the value to correct the beamline
to; a run that refined no centre is warned on the background alone where
its fit resolves the difference (3 sigma). The summary gains a Beam centre
line.

The refined centre, not the background, is the trigger wherever one exists:
on a battery set where XDS's refined direct beam and rugnux's agree to 1 px,
the background fit is 12 px away from both. Report-only; no result changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The .cif now follows the PDBx/mmCIF dictionary's keys: _entry.id, the
entry_id of _cell/_symmetry/_reflns, pdbx_ordinal and pdbx_diffrn_id on
_reflns and _reflns_shell, diffrn_id on _diffrn_source/_diffrn_detector/
_diffrn_radiation, and _diffrn_radiation_wavelength.id. _software is a
loop naming Rugnux for 'data reduction' and for 'data scaling'.

The experiment as the input states it: _diffrn.ambient_temp from the
sample temperature, the source's general class (from NXmx
/entry/source/type, which the HDF5 reader now reads), facility and
beamline, the detector make/model in _diffrn_detector.type (where
.detector used to carry it) and its class in .detector where the name
settles it, and the collection date from /entry/start_time.

R_merge and R_pim are formed beside R_meas from the same per-reflection
sums (stills and rotation merges) and written with CC* overall and per
shell (_reflns.pdbx_Rmerge_I_obs/pdbx_Rpim_I_all/pdbx_CC_star and the
_reflns_shell counterparts), as report keys R_MERGE/R_PIM/CC_STAR and as
three new columns of the shell tables. R_pim following Weiss (2001),
CC* following Karplus & Diederichs (2012).

A second data block, data_sample_unmerged, carries the scaled unmerged
observations of a rotation merge as _diffrn_refln (measured index,
frame, scaled I and sigma, pdbx_scale_value) - the same records as the
.hkl, which the wwPDB accepts as scaled unmerged data. ScaledFull now
carries the scale and the frame. The unmerged MTZ stays unscaled.

Reflection data are unchanged: the merged _refln rows, the .mtz columns
and the .hkl are written as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The merged .mtz and the unmerged .mtz carry, after the existing "From
Rugnux <version>" record, HISTORY records with the git commit and date,
the command line, the input file (absolute path) and a cheap identity of
it - size and modification time, the master file's for HDF5, nothing
hashed - and the collection date. MTZ allows 30 records of 80
characters; a longer value runs on over records starting with '+', cut
so that no record ends in a blank a reader would strip.

The mmCIF names the commit in _software.description and the command
line, input file and identity under _jfjoch_provenance (the local prefix
the other private items use; no PDBx item holds them), the first two as
text fields so any character survives.

OutputProvenance is filled once in the CLI and travels in ProcessConfig
to every writer of a reflection file. Reflection data are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Overloads: the integrated reflections whose signal disk held a saturated
pixel are counted on the integration outcome before the merge, over the
whole range and over the reflections coarser than 4 A, where they
concentrate (log line "Overloads:", report OVERLOADED_REFLECTIONS*). An
OVERLOADS warning fires from 0.05% of all, or 0.25% of the low-resolution
reflections, and recommends attenuation / a shorter exposure. Measured: a
strongly diffracting in-house sweep reads 0.017% / 0.42% (warned), an
ordinary sweep with a few hundred overloaded partials 0.001% / 0.012%.

Overlaps: both integration engines count the signal disks a nearer
neighbour's prediction takes pixels from (the overlap ownership the default
--overlap exclude already builds), and those left under --overlap-minpk of
their own - BraggIntegrationCounts::overlapped / overlapped_major (log line
"Overlapping spots:", report OVERLAPPED_REFLECTIONS*). An OVERLAPS warning
fires from 1% in the second and recommends a longer distance, finer slicing
or a smaller beam; on 10 battery sets it fires on the two long-axis ones
(8.3% and 13.4%), the rest read 0.0-0.44%. Summary lines for both;
documented in RUGNUX_REPORT.md.

Counters only; nothing reads them. p.mtz md5-identical to the rc175
battery on 10 sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A rotation run now writes the geometry it integrated at as an XDS input
beside the report: ORGX/ORGY (the PONI, 1-based), DETECTOR_DISTANCE, the
detector axes carrying the tilt, wavelength, the rotation axis the run
integrated at (ProcessResult::used_rotation_axis, after any sign rescue),
oscillation (with an adopted rotation scale), starting angle/frame, data
range, NX/NY/QX/QY, OVERLOAD, sensor thickness, space group and cell (left
commented where the group is not in its number's reference setting),
FRIEDEL'S_LAW per -A and the merged resolution range. HDF5 inputs get the
master file as an x_??????.h5 template and a LIB= line to point at the
reader plugin; a CBF series gets its ???? template and its own frame
numbers. The report names the file (XDS_INPUT=). Conventions per Kabsch
(2010) Acta Cryst. D66, 125-132.

The rotation axis goes out negated: rugnux turns the crystal by -phi about
its axis (IndexAndRefine), XDS by +phi about ROTATION_AXIS. Checked with
XDS IDXREF on two exports (one CBF, one HDF5 via durin): the negated axis
indexes 99.5% / 14% of the spots, rugnux's own sign 37% / 0.04%. On the
CBF set XDS then ran DEFPIX/INTEGRATE/CORRECT from the file unchanged
(apart from the job list): 99.8% complete, ISa 23.3, direct beam within
0.15 px and cell within 0.07 A of rugnux's.

Output only; no result changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The REFMAC check is opt-in, so a battery run without --model-check has no
R-free pairs, and the axis range min()/max() over them crashed the script
before any figure was drawn. The R-free figure is now its own gnuplot block,
left out (with a line in summary.txt saying why) when there is nothing to
draw; d_min and the time figures are drawn as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The unmerged observations already leave a run as the unmerged MTZ (and
the scaled fulls as the .hkl), and deposition does not come straight
from rugnux, so the _diffrn_refln block made the .cif up to ten times
larger for no consumer. The .cif is one data block again; the keys,
software rows, metadata, R_merge/R_pim/CC* and provenance stay.
ScaledFull loses the scale and frame it carried only for that block.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
At one times BeamCenterNeed_pxl the warning fired on 107 of the 250 battery
sets - most headers are a few pixels off. It now fires beyond three times
the tolerance; between one and three times the summary's Beam centre line
states the offset and recommends correcting the beamline value, without a
warning. The background-only branch (runs that refined no centre) uses the
same bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The commit is already re-stamped on every build (JFJochGitInfo runs
GitInfoStamp.cmake with cmake -P; configure_file rewrites GitInfo.cpp
only when the value changed). What was missing is the case with no git:
a downloaded tarball left the SHA empty, and one unpacked inside some
other repository would have reported that repository's HEAD. The stamp
now asks git only when the source directory is itself the top of a work
tree, and otherwise records commit "unknown" beside the version from
VERSION. The reflection files' git description drops its trailing date
when there is none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The refined geometry is no longer written as a whole p_XDS.INP (and the
report's XDS_INPUT= key is gone). Instead the report carries one section,
XDS GEOMETRY KEYWORDS, with the XDS.INP keywords that carry the geometry the
result was integrated at: ORGX/ORGY (PONI, 1-based), DETECTOR_DISTANCE, the
detector axes where the detector is tilted, INCIDENT_BEAM_DIRECTION with
X-RAY_WAVELENGTH, and ROTATION_AXIS in XDS's sense (rugnux's axis negated,
as verified with XDS IDXREF). No cell or space group: XDS determines those.
The lines carry a "# XDS.INP> " prefix, so sed lifts them ready to paste.

The same keywords were logged by LogXDSGeometry at the start of every pass,
with the axis in rugnux's sense; that printing is removed, so the report
section is the one place they are stated. Docs updated (RUGNUX_REPORT,
RUGNUX_INTEGRATION, RUGNUX, RUGNUX_TUTORIAL).

Output only; p.mtz md5-identical on 3 battery sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Pure refactor, no behaviour change. The 2460-line function with ~25 lambdas
becomes Rugnux::FirstPassRun (RugnuxFirstPass.h): the state the steps share
(spot cache, validation frames, schemes, committed spot-list state, the
speculative passes) as members, and one member function per step taking
and returning the standing lattice (FirstPass). FirstPassRotationIndexing is
now the ordered list of decisions:

  PrefetchCertainFrames, IndexSchemes (+ centre/short-axis speculation),
  AxisSignRescue, BeamCentreCheck (IndexAtMeasuredCentre,
  CentreWhenFileFails, CentreByPooledSpots, CentreWhenBothIndex,
  CentresDisagree), LongAxisRescue, AddEndWedge, ReleaseSpotEngines,
  RequireLattice, ShortAxisHypothesis, SubLatticeHypothesis, Pass1Fallback,
  last-resort FirstPassLadder, MeasureVerdict, RejectChanceLattice,
  AdoptLattice, StartLeftoverCensus, ReportTiltWalk, MeasureSpotBudget,
  Pass1SupercellGuard.

Files: RugnuxFirstPass.cpp (order + shared machinery: spots, scoring,
schemes, ladder), RugnuxFirstPassRescues.cpp, RugnuxFirstPassAccept.cpp.
Bodies are moved verbatim (Rugnux members reached through rx.); every log
line is unchanged. Literal duplicates folded: the two MAX_SPOT_KEYS
constants, and the feed-every-scheme / run-every-indexer loops of the
long-axis rescue and the short-axis fallback (now FeedSchemes/RunIndexing,
the same calls in the same order). The leftover census, which outlives the
pass, captures what it needs explicitly instead of [&].

Verified against the rc175 0b2dde214 binary on 16 sets (7kcn, 8v4o, 5nw5,
9rci, 6z9g, 9min, nothing_1/2, 7atg, myob/cytc_x10sa, 8a1a, kdp, insu low
ISa, 6p8j, 9qw8) and -N 8 on 7kcn/8v4o/9rci: p.mtz md5-identical, logs
identical as multisets apart from timings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The two-pass supercell guards (in the first-pass indexing and after the second
pass) revert to pass 1's lattice whenever the second pass's primitive cell is
markedly larger. On a crystal with a doubled axis pass 1's schemes did not tie
on frames, so pass 1 never asked the axis-harmonic arbiter and kept the halved
cell; pass 2's schemes tied, its arbiter established the larger cell on the
spots (17048 against 16099 validation spots), and the guard then threw it away.

The scheme comparison now records when the lattice it keeps is the larger cell
of an axis-harmonic pair the arbiter settled, and both guards leave such a
lattice in place. Where the arbiter did not settle the larger cell - every other
set the guard fires on - nothing changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Whether a twin law can exist was read off a hard-coded list of Laue classes
(4/mmm, 6/mmm, m-3m and rhombohedral -3m "holohedral", everything else
eligible). That is a property of the lattice, not of the class: a C-centred
orthorhombic or a monoclinic lattice with no special metric admits no twin law
for mmm or 2/m either, and a pseudo-merohedral one admits a law the class says
nothing about. On five pseudo-merohedral sets the report said "Twinning
INDICATED - refinement needs a twin law" where the lattice admits none.

TwinLawPossible asks gemmi's find_twin_laws (Le Page two-folds within 3 deg,
as RfreeFlags and ReindexAmbiguity already do), and AnalyzeTwinning takes the
cell to decide it; in P1 or without a cell a law stays possible, so the
pre-promotion analysis and the space-group search are unchanged. Twin-like
statistics (<|L|> below 0.44) above the false-operator bar where no law is
possible are now reported as NO_TWIN_LAW: not a twin in this group, so the
symmetry may be too high or something else narrowed the distribution.
Report-only: no reflection file changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Near-perfect twins of a subgroup are undecidable from intensities, and a merge
in the higher group cannot be twin-refined. Where the adopted group may have
absorbed a twin law - a twin gate refused it and was overruled, or a group above
P1 was adopted on a merge that read twin-like before the search - the same
observations are also merged in a subgroup and written beside the answer, with
the twin law and its fraction in _pdbx_reflns_twin.

The subgroup is the one a decision named (the group a refused promotion was
adopted over, the indexed lattice's group a metric re-ask promoted from), else
the index-2 subgroup the search confirmed as a finalist whose lost two-fold
agrees worst with the intensities. It is merged from the same engine, to the
answer's resolution, without writing per-frame scales back; it carries the
answer's free set (flags read off the answer's merge by the adopted group's
asymmetric unit) and is written in the subgroup's reference and standard
setting (a C 1 1 2_1 description is reduced to P 1 2_1 1). The fraction is
Yeates' H on the pairs the law relates (1/2 - median H, a lower bound).
At most one file; other qualifying subgroups are named in the report
(TWIN_SUBGROUP_* keys). The answer's files are byte-identical.

IndexTwoTwinSubgroups and EstimateTwinLawFraction are new and tested; the
point-group ledger now carries each hypothesis's representative group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where a twin hypothesis (<prefix>_twin_<SG>) was written, the model is also
validated against that merge: as merged, and as twinned by its law -
|F_twin(h)|^2 = (1-a)|F_model(h)|^2 + a|F_model(Th)|^2 from the placed, scaled
model, one overall scale refitted per a, a scanned 0..0.5 on the working set
and R-free read at it (ValidateAgainstModel's new twin_law argument; GPU
structure factors as before). The maps and the placed model are then written
from whichever merge has the lower R-free; the report gives MAPS_FROM and the
subgroup's plain and twinned R-work/R-free and fraction. The subgroup file
follows its own validation's indexing, as the answer follows its own.

The model chooses the maps only: the space group, <prefix>.mtz and R_FREE stay
the answer's. Documented in RUGNUX_REPORT.md, RUGNUX_ADVANCED.md and
CPU_DATA_ANALYSIS_DECISIONS.md (14.9); Yeates (1997) credited.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
4MTK (P61, c 653 A, PILATUS 6M miniCBF, SBGrid 235), 4NWV (I222 virus-like
particle, 370-411 A axes, Rayonix MX-300 marCCD, SBGrid 9), 7D98 (P6122,
c 602 A, EIGER 4M miniCBF at 1.9 A, XRDa 13), 9V16 (P41212, c 451 A, EIGER1
16M HDF5, XRDa 338) and 4P8R (P6522, c 701 A, Rayonix MX-225 marCCD, IRRMC),
tagged large-cell (plus long-axis / virus). References are the deposited
space group, cell and resolution. 7D98 is pinned to its 250-frame miniCBF
sweep: the HDF5 masters beside it repeat frames 61-260 in 60-frame pieces.
4P8R's archive ships per-frame .bz2 files, decompressed after extraction.

EXTERNAL_TEST_DATA.md gains the five rows (DOIs resolved), the archive notes
and the updated collection counts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Each merge restored corr to the ingest state but kept g_partial from the merge
before it. A frame the new merge did not fit kept the G an earlier merge had
fitted it with, and the fulls' CC1/2 weight (frame_scale_fulls) read it, so a
merge depended on which merges ran before it on the same engine. RunArm now
resets g_partial to 1 at its start, as the first merge on an ingest already had
it. That is what lets two merges run side by side on two engines and give what
they give in series.

The fits themselves never warm-started (FitPerFrameG and the device kernel both
solve each frame afresh), so the change only reaches frames that a merge leaves
unfitted after an earlier one fitted them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Part B takes the decisions before pass 2 from the first part of a rotation sweep. Two diagnostics
now say, in the log only, where that may matter; nothing the run writes changes.

Far-end check (RugnuxFarEnd.cpp), after the image pass of the canonical pass: the sweep in 10 deg
blocks, six per-frame quantities pass 2 already holds - frames indexing on their own, share of
spots on the lattice, the cell scale the strong reflections ask for (d at the observed centroid
over d predicted), the residual rotation of each frame's refined orientation against the sweep's
lattice turned to that frame (about the spindle and off it), and the background - each late block
against the early blocks as a Student-t prediction from their own spread, family-wise at the run's
0.1 % significance over every late-block comparison. One line: "far-end check: early decisions
hold" or "far-end check: <decision> would be revisited because <evidence>".

Near ties (FirstPassRun::NearTie*): where a first-pass decision compares two candidates or a count
against a gate - scheme choice, metric-symmetry demotion, axis-harmonic arbiters, axis sign,
long-axis rescue, beam-centre gates and choices, end-wedge gate, short-axis and sub-lattice
hypotheses, ladder floor/adoption/rung choice, pass-1 fallback, no-crystal test - a margin within
two standard deviations of the comparison's own noise (binomial on validation frames, Poisson on
spot counts, the binomial standard error ValidationEvidencePrefers uses on pooled spots) is logged
as "near tie: <decision> <A> vs <B> margin x (noise y) - would carry both".

ValidationEvidencePrefers now reads its difference and standard error from
ValidationEvidenceDifference, which the near-tie lines share; same arithmetic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
ReadAttrStr and HDF5DataSet::ReadString read every string as fixed-length,
sizing the buffer by H5Tget_size. For a variable-length string (what h5py
writes for a str, UTF-8) that size is a pointer, so the read put a char*
into the buffer and returned garbage. transformation_type then never equalled
"rotation", and an EIGER master whose goniometer attributes were written that
way (depends_on, transformation_type, units on /entry/sample/transformations)
was processed as stills although it holds a valid rotation sweep.

All string reads now go through one helper, ReadHDF5String, which handles both
fixed- and variable-length strings in either character set; ReadElement<string>
already handled the variable-length case and now uses the same helper.

Test: JFJochReader_Goniometer_VariableLengthStrings reads the rotation axis
from a master with fixed-length ASCII and with variable-length UTF-8 string
attributes and depends_on dataset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The background ring's high-side clip, mean + 4*sqrt(mean), assumes counted photons. A CCD reports
ADU: on a Rigaku Saturn a background pixel scatters 4-5x its mean, so the clip sat at ~2 sigma, cut
the upper tail of every ring and added a positive offset to every reflection, compressing the
intensity statistics (a false 422 on one twinned I41 crystal).

No header carries a usable gain (SMV states none, every marCCD file the writer default of 1 ADU per
photon), so it is measured: background_gain::MeasureFrame takes the variance per unit mean of the
difference of pixels two columns apart, per level, clipped, on every fourth row of up to 16 of the
pre-scan's frames; Gain is the pair-weighted median over the levels. The clip becomes
mean + n*sqrt(max(g,1)*mean) (BraggIntegrationSettings::bkg_pixel_gain, CPU and GPU engines).
Only for SMV/marCCD input (ProcessConfig::counts_in_adu, set from the file format); HDF5 and CBF
keep g = 1, the identity. One log line reports the value. No new option.

Measured g: Saturn 944+ 4.7-5.2, Saturn 944 2.7, A200 0.68, ADSC ~0.3, marCCD ~0.3 - so the
ADSC/marCCD/A200 sets keep the photon clip exactly.

Checks (reflection data via gemmi and p.hkl, against the rc175 battery outputs / base build):
identical on 11if, 7qis, 8a1a, myob_x10sa, cytc_x10sa (photon counting), 4bwl, 5epe (ADSC,
marCCD) and 6cee (A200). Saturn sets, battery baseline -> this change:
  3r6o  I4122 R_meas 19.3% ISa 4.4 <|L|> .321 -> I41 (correct) 12.1% 8.1 .384
  3mc4  R_meas 10.2 -> 9.8 %, ISa 12.1 -> 12.7, d_min 1.79 -> 1.75 A, <|L|> .360 -> .391
  5uth  12.6 -> 11.8 %, ISa 10.4 -> 10.1, <|L|> .426 -> .483, offset vs |Fc|^2 0.21 -> 0.00
  5vml  10.2 -> 9.6 %, ISa 11.0 -> 11.6, <|L|> .426 -> .475, offset 0.15 -> 0.05
  5cc8  8.1 -> 7.9 %, ISa 10.8 -> 11.2, <|L|> .469 -> .494
  3meb  14.5 -> 14.6 %, ISa 13.2 -> 13.2, <|L|> .442 -> .457
Space groups unchanged except 3r6o. Pre-scan cost: +0.5 s on a 9 Mpx ADSC sweep, within noise on
a 15 Mpx marCCD sweep, nothing on HDF5/CBF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The space-group search made its two P1 merges one after the other on one
engine: the Lorentz-filtered one, its search, then the all-observation one and
its search. Since every merge now starts from the same state, the
all-observation merge is made on a copy of the engine (CloneIngest: the host
half of the ingest copied, the device half staged on the copy's card), started
right after the ingest and read where the search needs it, so it runs beside the
P1 merge, the re-scaled search merge and the first arm. Its search then runs
beside the first arm's.

Placement: from three cards up the copy gets card 2 to itself (card 0 has the
main engine, card 1 the quality guard's). With one or two cards it shares the
last card, and only where two engines of this size fit the card's total memory
(DEVICE_BYTES_PER_OBSERVATION = 256; two engines measured at ~150 B/observation
each), so the choice never depends on what is free at the moment. On a 16 GB
card that admits sets up to ~32M observations; larger ones keep the serial
order. Nothing of the copy reaches the run before the first arm says the arm is
wanted: its log, its result fields and its per-frame write-back onto the
outcomes are taken at the point where the serial merge would have been made. A
copy that failed is merged again in series.

Results are bit-identical to the serial order (p.hkl md5 and MTZ data), at
default -N and -N 8.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Stage A offered the b-unique two-fold (reference setting) on the lattice's holohedry alone, but held
its a- and c-unique siblings (pass-1 settings) to CellHostsRotations at 2e-3. On a cell refined free
~0.2 deg off orthogonal only the b-unique rung existed, so when the holohedral 222 was refused the
setting, not the data, chose the two-fold axis. Stage B already offers non-reference settings
without asking the cell, for the same reason; Stage A now does likewise where a holohedry is given.

With siblings of one order eligible together, ties were broken on the class CC alone, which a twin
law at fraction ~0.4 can win. A twin law cannot extinguish an axial row (every principal row maps to
itself or its Friedel mate), so ties now go first to the group whose pure two-fold axes carry the
more screw evidence on their own odd axial row - judged exactly as Stage B judges that zone - and
only then to the CC. Rows on 3-, 4- or 6-fold axes are not read.

Open arm: 6p8j (deposited P2_1, b = 152 A) was P 1 1 2 on the 88 A axis, now P 1 21 1 on the 152 A
axis (152 A odd row 158 nats, 46 A row 36, 88 A row refused; R-free 0.214). 9hnc and 5cc8 p.mtz
md5-identical (siblings offered but refused by added-operator R). On five passing P2_1 sets in an
orthorhombic metric the true axis carries all the evidence (offline replay), so no change there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The first part can be the odd one (a crystal that settles or drifts over the opening degrees), so
every early block is also tested against the late ones. The rotation scale is also checked as a
rate: the slope of the residual about the spindle over the late blocks against that over the early
ones. Every far-end line now ends with each quantity's smallest p times the family size, so the
population can be read off the logs. Still log only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The projected histogram is sampled every 1/(2*max_length) of 1/d, so a row
of length L between max_length and 2*max_length folds to 2*max_length - L and
peaks there as sharply as a real row. At the default 500 A bound a 602 A axis
came back as ~400 A and a 701 A axis as ~299 A; the folded cell still indexed
59/60 and 37/60 validation frames (only the reflections whose index lands on
an integer of the folded period), so the long-axis rescue, gated on a poor
pass, never ran. The same fold also turned real >500 A lattice combinations
of shorter-axis cells into spurious shortlist rows.

Each shortlisted row is now asked once, by a direct Fourier sum over the spot
positions (no sampling, so no fold), whether the spots carry its own length or
its partner 2*max_length - L more strongly, and keeps the stronger. Partners
within 0.1 of an integer multiple are left alone (a harmonic, settled
elsewhere). Refinement accepts rows up to 2*max_length when one was unfolded.

Measured: a hexagonal P6122 c=602 A EIGER set and a P6522 c=701 A CCD set,
both indexed de novo on the folded cell before, now index the deposited cell
60/60 and merge to R-free 0.31 / 0.22 against the models (the same numbers as
-C with the deposited cell). Of 13 long-axis battery sets, 7 are bit-identical
(p.hkl md5) and 2 never unfold a row, 8v4o and 9yl4 have the same intensities (CC 1.0) without needing
the long-axis rescue, 4mtk refines a cell closer to the deposit at half the
wall time, 4nwv merges better (R_meas 0.58 -> 0.47, ISa 6.7 -> 8.4).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Two candidates that both index fewer than a sixth of the validation frames (the bar the run keeps
a lattice on) are not two hypotheses about the crystal; on the first 34 battery sets most scheme
"ties" were 0/60 against 0/60. Log only, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Testing every block against the other part's blocks fired on single odd blocks: a block's mean
varies with the orientation, which a handful of reference blocks cannot represent (one block of 36
on a control, a 0.04 deg residual at t = 23 on another). The rotation rate, a straight-line fit
per part, failed the same way, its standard error blind to the smooth orientation-dependent
wiggle, and it did not see the target it was added for (the residual there runs at the same rate
through both parts). Now each quantity is one comparison: the block means of the rest of the sweep
against those of the first part, Welch's t, family-wise at 0.1 % over the quantities compared.
Still log only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The geometry walk re-integrates the whole sweep in every round, and on an
uncompressed SMV sweep that image loop is bound by memory traffic:
smv::ReadInto was the top function (33% of all user cycles on 2xfw), and
16 threads read 360 frames no faster than 1 thread. On Linux/macOS the
frame is now mapped and widened from the mapping, which drops the
read() copy of the whole file into a scratch buffer. Windows keeps the
ifstream read. Pixel values are unchanged; every pass benefits, not only
the walk.

Measured (RTX 5080 workstation, load 9-23 from other agents, 2 repeats,
base = rc175 0457cecaf, same flags):
  2xfw  WALL 48.15/48.33 -> 40.49/41.98 s, Geometry check 17.2/17.4 -> 13.6/13.8 s
  6zqy  WALL 26.10/..    -> 23.60/23.16 s, Geometry check 8.6 -> 7.1/7.2 s
  2ygz  WALL 32.05       -> 26.27 s
  -N 8: 2xfw 50.09 -> 42.82 s, 6zqy 26.78 -> 22.97 s
  peak RSS -0.5 GB (no scratch copy)
Microbenchmark, 360 2xfw frames, 16 threads: read 1.8-1.9 s, mmap 1.2-1.4 s.
p.hkl md5 identical to the base on every set run (2xfw, 6zqy, 2ygz, -N 8,
and the small SMV sets of the open arm).

Tried first and dropped: keeping the decoded frames in memory across the
walk's rounds. Exact, but filling 13.6 GB of fresh memory cost more than
the hits saved (2xfw round 1 +2.4 s, release +0.9 s, later rounds -0.8 s
each; CBF sets 5m17/7ph1 got slower), at +12.5 GB peak RSS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A crystal twinned by reticular merohedry of index n - obverse/reverse
rhombohedral domains, or a cubic crystal twinned by a 60 deg rotation about
[111] (the spinel law, index 3) - has its two domains' reflections on a
common lattice of n times the primitive volume of either domain's own cell.
That coincidence cell indexes more frames and more spots than the domain's
cell, so the first pass adopted it (a hexagonal/orthohexagonal or R:R cell
for an F- or I-cubic crystal), and nothing downstream could recover the
true lattice or Laue class.

A new step after the sub-lattice hypothesis (TwinCompositeHypothesis), for
n = 2 and 3, counts the validation spots on the standing cell in the four
classes of the pair of index-n sub-lattices whose union holds the most
spots: on both, on each alone, and on neither. Two domains leave the
(1 - 1/n)^2 of the nodes on neither empty but for chance coincidences; one
crystal, whatever sub-lattice a pseudo-translation favours, populates the
nodes off the stronger sub-lattice alike whether they lie on the second
sub-lattice or on neither. The step adopts the stronger domain's cell when
the two sub-lattice cells are congruent (2 % / 2 deg), the weaker domain's
own class holds at least 20 spots at more than four times the per-node density
of the class on neither, and the domain's cell indexes a majority of the
validation frames. The other domain is left to the leftover-lattice census.

Evaluated on the private, inhouse and a twin/tNCS/rhombohedral/cubic/
long-axis selection of the open arm: it fires on the two known index-3
twins (a spinel-twinned F-cubic crystal, now indexed F-cubic; the in-house
I-cubic insulin set with a 60 deg [111] second domain, R3:R -> I23) with a
weak/neither per-node density ratio of 60 and more (the class on neither
holds 0-8 spots against 250-2850 on the weaker domain), and nowhere else.
On every other set the ratio stays between 1.0 and 1.6, the top of that
range only on runs with a hundred or so validation spots, where the pair
picked as the emptiest of 7 or 13 forms carries the scatter; hence the
factor 4. Both twins now merge in their cubic group, and the sets
re-run against the release baseline otherwise did not change. Cost when it
does not fire: tens of milliseconds per first pass.

Merges two prototypes: the class census and n = 2 coverage of one, the
sub-cell congruence requirement of the other; no environment switch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
set_gpu_blocking_sync() called cudaSetDevice() on every card, and since
CUDA 12 that creates the card's primary context: ~0.18 s per card on the
workstation (measured, RTX 5080), one after another, before WALL_TIME
starts. On the 4-card server that is most of the 1.73 s per run spent
outside WALL_TIME (vs 0.37 s on one card).

- The blocking-sync flag is now set with the driver's
  cuDevicePrimaryCtxSetFlags, taken through cudaGetDriverEntryPointByVersion
  (no link against libcuda, so a CUDA build still starts without a
  driver). It creates no context; contexts created later carry the flag
  (cudaGetDeviceFlags 0xc, as before; user CPU time unchanged, so no
  spinning).
- Card 0's context is created by the existing cuFFT warm-up thread, beside
  the file open and pre-scan; cards 1..N-1 get theirs on a new background
  thread per card (create_gpu_contexts), as the pre-scan uses card 0 only.
- At exit the contexts are destroyed by one thread per card
  (release_gpu_contexts, cudaDeviceReset) after the run has destroyed
  everything that used them, instead of serially by the runtime's exit
  handler. Microbenchmark: an explicit reset costs the same as the
  implicit teardown on one card (175 ms), so no change on one card.

Which card does what is unchanged; only when contexts are made/destroyed.

Measured on the 1-card workstation (load 7-16, 2 repeats x 2 sets plus
2 earlier pairs on myob): process start -> first log line median 0.39 ->
0.18 s; last log line -> exit 0.18 -> 0.20 s (one context either way);
elapsed - WALL_TIME median 0.53 -> 0.37 s; WALL_TIME unchanged (context
creation now overlaps the input read). The multi-card gain (3 serial
context creations at start, 4 serial destroys at exit) cannot be measured
here. p.hkl md5 identical on myob_x10sa, cytc_x10sa, 2xfw, 6zqy -N 8;
all runs exit 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact: p.hkl md5 identical to the base on 2xfw, 6zqy, 2ygz, 8 small SMV sets, myob_x10sa, cytc_x10sa and -N 8.
SMV walkers 2xfw 48 -> 41 s, 6zqy 26 -> 23 s, 2ygz 32 -> 26 s; start-up to first log line 0.39 -> 0.18 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact: P4 p.hkl identical to the base on 39 noise-subset sets; concurrent arms p.hkl md5 and MTZ data identical
to P4 on 12 sets at default -N and -N 8 (incl. 5 sets where the arms disagree). Side arm on card 2 with 3+ cards,
else on the last card when 2 x observations x 256 B fits its total memory (decided up front).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where the free refinement splits a promoted equality beyond the integration
disc, RefinedPasses ran two probe passes (promoted, then demoted) and then
integrated the winner a third time as the canonical pass, although the
promoted probe and the canonical pass predict the same reflections. Now the
demoted arm runs first as a probe, and the canonical pass is the promoted arm
itself: it fits its post-refinement before the merge (lattice_arm_check_),
compares the held-out residual there with HeldOutResidualFell as before, and
stops before merging only if it loses, in which case the demoted class runs as
the canonical pass (same number of passes as before). A pass that does not
measure a post-refinement as canonical pass drops the one it fitted, and the
geometry the fits leave behind is the one the two-probe order left, so every
pass downstream starts where it did.

Exactness E: merged data and all output files identical to rc175 e9d4f2221 on
the 8 sets where the check runs (9sl0, 6yqf, 7atg, lysoI_micromax_pink,
myob_x06da_powder_2 promoted kept; 9t6s, 8v2t, lyso_micromax_pink demoted
wins), 2 runs each; the only difference is p_report.txt PASS= n-1 of n-1 where
the promoted class stands (one pass fewer is run). Decisions and residuals
identical.

Wall (time -v, base -> new, rep1 / rep2, shared machine):
  9sl0   48.4->40.2 / 48.9->39.6
  7atg   49.5->31.2 / 33.2->28.7
  6yqf   29.4->25.1 / 30.0->39.6 (noisy)
  lysoI_micromax_pink 23.2->13.0 / 14.5->13.2
  myob_x06da_powder_2 13.5->12.0 / 12.6->11.5
  losers 8v2t, lyso_micromax_pink, 9t6s: within +-2 s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Every round the geometry walk takes ends in a pass that writes the files
again, so the canonical pass before it spent its space-group search, merges,
analyses, model validation and files on an answer nobody reads. The walk's
first question needs only that pass's post-refinement and, for a move of less
than a step, two indexing probes. The speculative probe now runs both beside
the merge - at the fit's geometry as before, then at the geometry in hand,
which the walk otherwise indexed after the pass (a memo hit where the pass's
own first pass left one) - and GeometryWalkTakesRound reads the walk's answer
from the same first-pass memos the walk's probes then read. The canonical pass
asks it, without waiting, before its space-group search (stops there; not
with a _process.h5 open) and again at the superseded point before the
analyses. An answer not yet in leaves the pass to finish as before, so only
timing depends on when the probes finish; the walk itself is unchanged.

Exactness E (not F): merged data and all output files identical to rc175
e9d4f2221 on every walking set, 2 runs each; decisions identical. Fired:
before the search on 7ph1, 5m17, 8pqd; at the superseded point on 2xfw, 6zr0,
9t6s; on 6zqy the probes were not in by either point in these runs; 8xtf's
canonical pass is starved and never merges.

Wall (time -v, base -> new, rep1 / rep2, shared machine):
  5m17  65.9->57.0 / 68.5->56.7
  8pqd  49.8->43.0 / 50.0->43.1
  7ph1  51.1->48.9 / 51.1->45.3
  6zr0  31.1->22.7 / 24.4->21.1
  2xfw  101.2->66.7 / 48.2->48.2 (rep1 base noisy)
  6zqy  27.3->26.1 / 26.8->25.7
  controls myob_x10sa, cytc_x10sa, 11if, kdp_x10sa_20keV, 8a1a: no change
  beyond machine noise

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where pass 1's rotation-scale fit is a step or more (STEP_BOUND, 1 %) off the
stored angles, the walk runs on its own before the refined passes instead of
beside an output pass that a refit calls off and throws away. Scheduling only:
the run ends where the sequential walk leaves it either way (the overturned
path already re-ran the refined passes on the run itself).

Exactness E: all output files identical to rc175 e9d4f2221 on 9gdj, 9qw8,
6ttn, lalanine, 9t6s, 2 runs each.

Note: "the walk will then always refit" does not hold. Of the four battery
sets with the fit >= 1 % off (9gdj 0.980, 9qw8 0.988, 9t6s 1.012, lalanine
1.012), the stored angles stand on 9t6s and lalanine, which now walk first and
lose the overlap of the walk's two probes with the output pass (~0.5-0.8 s of
probe time); 6ttn (0.998) is below the step and unchanged.

Wall (time -v, base -> new, rep1 / rep2, shared machine):
  9gdj  99.6->109.7 / 107.4->101.4 (rep1 new run noisy)
  9qw8  43.1->39.2 / 34.4->33.3
  9t6s  18.1->18.2 / 18.9->18.5
  lalanine 20.5->19.7 / 7.9->8.3
Pre-rebase run on rc175 0b2dde214: 9gdj 99.6->93.8 and 99.6->93.4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A crystal whose spots avoid one coset of an index-2 sub-lattice thins the overlap and the class on
neither alike, so comparing only the weaker domain's own class with the class on neither nearly
fired on a single long-axis hexagonal crystal (stopped by the congruence check alone). The class on
neither must now be four times emptier per node than the emptiest of the weaker domain's own class
and the overlap. On the evaluated private, inhouse and open sets the non-twins score at most 1.44;
the two known index-3 twins score 61 and more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact: MTZ data (gemmi) and p.hkl md5 identical to the base on 31 sets, -N 8 on 3; only the PASS= count in
p_report.txt drops where a pass is no longer run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi

# Conflicts:
#	rugnux/RugnuxScaleMerge.cpp
Exact: reflection data identical to the base on 51/51 open+inhouse and 27/27 private sets. Diagnostics only;
the full battery collects their firing counts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi

# Conflicts:
#	rugnux/RugnuxFirstPass.cpp
#	rugnux/RugnuxFirstPass.h
A run that was killed by a signal, or whose log has "Processing failed", a GPU error (also one
rugnux handled and went on from), "terminate called" or a non-zero compute-sanitizer ERROR SUMMARY
is now verdict `crash` (score.crash_note), decided before anything else, so it fails even a
no-crystal control whose normal refusal exits 1. A crash counts as a failure in every pass rate,
gets a CRASHED section at the top of the report and a CRASHED line under the console table, and
makes `run` exit 1 whatever tier the set is in. Reports and compares re-read crashes from the log,
so an older run's crash shows as one: the GPU crash of 9ig7 in the last full run showed only as
"fail, exit 1: Step 8 FAILED" before - failure_note now prefers the [error] line it follows.

`battery.py stress` runs named sets --repeat times, --concurrent at once, optionally under
compute-sanitizer (memcheck/racecheck/synccheck/initcheck) or with CUDA_LAUNCH_BLOCKING=1, and
exits 1 on any crash; it reports distinct p.hkl per set and keeps out of the run list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A planned fallback that rugnux logs with a CUDA error text - the rigid body's zone over its
engine's reserve, revalidated on the CPU, or an indexer worker out of device memory while other
jobs held the card - would have turned sets into crashes in every run. A handled error now counts
only when it names a device fault (unspecified launch failure, illegal memory access/address,
misaligned address, launch timeout, device-side assert, "CUDA device unusable"), which takes the
context down for every thread and is never a property of the data. "Processing failed" still
counts whatever its cause.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The final no-crystal test (RejectChanceLattice) used the 0.001 binomial level of the comparisons
between candidate lattices. The lattice it tests is the winner of some fifty indexing attempts,
each fitted to an accumulated cloud that already holds the long-lived reflections it then re-finds
on the validation frames, so that null is optimistic by roughly the number of attempts. The two
in-house no-crystal controls passed it by one or two distinct reflections (tails 10^-2.06 and
10^-2.70 against 10^-3).

The test now asks 1e-9 (Z ~ 6); the rescue comparisons keep 0.001. With two reflections by chance
the controls would now need 15 distinct reflections instead of 4-5. Real lattices are tens of
decades past either level (weakest seen: 10^-83).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The de-novo FFT indexers implement the 1D-projection method of Steller,
Bolotovsky & Rossmann (1997), which was already credited; the 3D Fourier
transform indexing idea it descends from (Bricogne 1986, EEC PSD software
workshop, Phase III, LURE, p. 28 - no DOI) was not. Add it to
ACKNOWLEDGEMENT.md, the CPU_DATA_ANALYSIS references, the §5 text and the
in-source credit, and add the DOI of Powell (1999) to the MOSFLM paragraph.
3D-FFT papers (Campbell 1998, Gildea et al. 2014) are not cited: the code
does not compute a 3D transform.

Add a section acknowledging Global Phasing (Bricogne and colleagues) and
autoPROC (Vonrhein et al. 2011) as the expert-system model rugnux aims at.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Reframe the Global Phasing / autoPROC acknowledgement as inspiration and
name the ideas: header metadata (beam centre, geometry, axis) must not be
trusted blindly (autoPROC, Clemens Vonrhein in particular); the beam
centre is determined from the images (rugnux does it its own way); and
diffraction anisotropy must be analysed and reported (STARANISO, cited
with the reference the STARANISO server recommends; no DOI exists).
One-line pointers in data-analysis §1.4 and §13.5, and both works added
to the CPU_DATA_ANALYSIS references. The Sheriff & Hendrickson, Popov &
Bourenkov and Evans & Murshudov credits for the estimators are kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Merge branch 'credits' into rc175: autoPROC, Clemens Vonrhein and STARANISO credited as inspiration
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m34s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m48s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m29s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 14m6s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 16m32s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 19m9s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m5s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 20m12s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m3s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m33s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 17m49s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 18m52s
Build Packages / Generate python client (push) Successful in 36s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 21m43s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 22m52s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 16m1s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 20m44s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 17m40s
Validation Battery / Validation Battery (push) Successful in 48m22s
Build Packages / Unit tests (push) Successful in 1h26m40s
8d8f336165
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
The line put the distinct-reflection count against a normal-approximation bar, which at a
handful of reflections reads the wrong way: a control the exact test rejected (5 vs 2 by
chance, tail 10^-2.70 against 10^-3) was printed 0.59 above the bar. It now prints the exact
binomial tail and the significance, both as log10, with the margin in decades and the noise as
the tail's change over one binomial standard deviation of the count. The tail is summed in logs
(ValidationEvidenceLog10Tail), so it stays finite for a real lattice; the decisions compare the
same tail in log10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
subprocess.run's timeout killed only the direct child. Under --gpulock that is flock, under
`stress --sanitizer` compute-sanitizer, and in both cases rugnux went on running on the card,
orphaned, beside whatever took the GPU lock next - found when a cut-short memcheck stress run left
its rugnux behind. Each run now gets a process group of its own, killed whole on a timeout, Ctrl-C
or SIGTERM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
UseAtomBoxesByPlane handed its functor one grid point at a time, so the density
(gemmi's ExpSum/ExpAnisoSum with unsafe_expapprox) ran scalar - on the CPU path of
model validation the rigid body's density copies were 48% of all validation
CPU time (8sa8, perf). The walk over an atom's box on a plane is unchanged (same
order, same delta.x stepping) but now only collects the points within the radius
(index, d2, delta); the density functor then evaluates them a Gaussian at a time
over all points - each point still sums its Gaussians in gemmi's order from 0 -
in loops GCC vectorises (AVX2, no intrinsics), and adds them to the grid in walk
order. The mask functor zeroes the gathered points. Same grid, bit for bit, at
any thread count (ModelValidation_ParallelGriddingMatchesGemmi: iso + aniso,
five groups, 1 and 4 threads).

Measured (CPU build, --mode scale --model, shared machine, indicative): 8xtg
validation 28.1 -> 13.6 s wall, run total 665 -> 366 user-s (with the FitModelScale
commit); outputs identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
All exact to the bit (bench against the previous FitModelScale on P1, P2_1,
P2_12_12_1, P4_32_12, P6_122, P2_13, I23 and the dependent n<=5 path; the
ModelScaling cases; validation outputs identical on real data):
- the start of a grid point (fit_isotropic_b_approximately) is computed from the
  same |Fcalc| the fit takes, once instead of twice, and without copying the
  Scaling's points per chunk;
- gemmi's Levenberg-Marquardt is followed in a copy (LevMarFit) whose
  compute_lm_matrices is templated on the parameter count (2..7), so alpha/beta
  stay in registers; a zero derivative row adds +0 instead of being skipped
  (alpha/beta are never -0), and the full square is summed (the lower half is
  gemmi's, the upper is its mirror) - branch-free and vectorised;
- grid points are scheduled one per task (ParallelFor) instead of fixed chunks;
- a fine-pass pair bit-identical to a coarse-pass pair reuses that fit.

Bench (single thread, 48k reflections, P2_1): 1.58 -> 1.33 s; ~15-25% on the
other groups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
compute_model_factors' CPU path (no GPU, or the grid does not fit the card)
gridded the density with gemmi's serial put_model_density_on_grid and the mask
with SolventMasker::put_mask_on_grid. It now uses ModelGrid's
PutModelDensityOnGrid / PutMaskOnGrid with -N threads, sharing one set of orbit
leaders (the two grids are the same size) - gemmi's grids bit for bit at any
thread count, as the rigid body already uses them. The GPU path is untouched.

Exactness: p.hkl md5, p.mtz/p_maps.mtz reflection data, the three maps and
p_model.pdb identical to the base and the validation log lines identical (up to
the order of concurrent replicate lines) - CPU build, --mode scale --model on 5reo,
myob_x10sa, 8sa8, 8xtg, 5ebi at -N 1, default and -N 8; full CPU runs of 9ea5,
7dkp, 6iu5, 8tyy; the GPU build with no visible device on the five --mode scale
sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Supercell probe, log-only part: the class it reads over 20-3 A is followed out to the run's own
limit (a second prediction of the same supercell on the same 60 frames, so the 20-3 A band and the
SUPERCELL_* keys are unchanged). Half-integer reflections are counted only where no lattice
reflection predicted on the same frame lies within r3, and each resolution shell is read against the
median of the six other parity classes - which a single doubling leaves empty - as an excess in units
of the lattice's intensity and as a z in the class's own standard errors, the strongest sibling's z
beside it ("Supercell probe to the limit" in the log).

Doubled-cell hypothesis: where that class stands clear of the six empty ones (measured above its
noise and at more than twice the strongest of them, in at least three shells), the run integrates and
merges the doubled cell too, forcing the last pass's indexing result with its lattice doubled along
the class (orientation, geometry and axis kept). One of the two is written as the answer, the other's
merged .mtz/.cif/.hkl beside it as <prefix>_doubled or <prefix>_halfcell; the report names both
(DOUBLED_CELL_*). Which one is the answer is a policy behind one constant: ADDED_CLASS_SIGNIFICANT
(the doubled cell wherever the class stands clear) or HALF_CELL_BREAKS_DOWN (default: only where the
class reaches a quarter of the lattice's intensity in the finest shell both are measured in).

The doubled lattice is classed with a near-zero tolerance: the default lattice search promoted a
triclinic doubled cell with an 88.2 deg angle to orthorhombic C, and no frame indexed.

Measured on forced doubled-cell merges (rc175 b530c2d base): the open-arm crystal deposited in the
doubled cell merges at R-free 0.275 against its deposited model, against 0.574 in the half cell
(odd/even 0.02 at >8 A rising to 0.65 at 1.95 A, odd half-set CC1/2 0.93 at 8-5 A). Five crystals
deposited in the half cell read classes the doubled merge holds as well (odd CC1/2 0.73-0.98 at
8-4 A): two rise with resolution like the first (to 0.4-0.5 at 2.5 A), one is strong from low
resolution (0.3-0.5), two stay flat at 0.1-0.2. Nothing measured on intensities - the trend, the
neighbour correlation, the separation from the nearest lattice reflection after the r3 filter -
separated the doubled deposition from the rising half-cell ones, so the policy is the owner's call.

Also: the SUPERCELL_POSSIBLE prose no longer says correct cells "read the same", and the warning no
longer advises -C with the doubled cell, which the indexer reduces back to the half cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
A pooled CudaDevicePtr frees on the thread's allocation stream, not on the engine's stream, and the
pool may hand the memory to another engine - or, past its release threshold, unmap it - as soon as
that free is reached, which on an idle allocation stream is at once. An engine destroyed with work
still queued (FFTIndexerGPU after SearchCap's last DirectionsChanged upload, a spot finder between
DetectAt and Extract, a shadow accumulator after a pending fold, any engine on an exception path)
thus had kernels or copies writing memory that was someone else's or no longer mapped. Now:
- CudaStream synchronises before cudaStreamDestroy (destructor and move-assignment), which covers
  engines whose own stream is declared after their buffers (FFTIndexerGPU, the gather buffer);
- every engine holding pooled buffers and a stream (shared or own, declared before the buffers)
  synchronises it in its destructor; BraggIntegrationEngineGPU also before EnsureCapacity
  reallocates, where a Run that threw leaves work queued.

A GPU failure that is handled no longer hides a lost context: ShadowFinder, BeamCenterFFT, the
rigid-body pool and model validation call cuda_throw_if_context_lost() before cuda_clear_error(),
as the device-decode fallbacks already did; RotationScaleMergeGPU's Alloc does so before waiting up
to ten minutes for GPU work beside it and then reporting a lost device as out of memory; and a
failed cudaMalloc says why. BeamCenterFFT logs the failure it used to drop silently, the
speculative geometry probe logs the exception it swallowed (its GPU fault was otherwise reported by
the merge beside it, under the merge's name), and RotationScaleMergeGPU's DeviceGuard no longer
throws from its destructor.

Only synchronisation and error paths change: p.hkl md5 and the MTZ data (gemmi) are identical to
the b530c2d full battery on myob_x10sa, cytc_x10sa, 8a1a, 9gdj, 11if, kdp_x10sa_20keV and 6z9g.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
LevMarFit takes compute_wssr() in a copy (ComputeWSSR) that keeps each
reflection's exp(-0.25 * b_star.r_u_r(h)); when the step is accepted,
ComputeLMMatrices is evaluated at exactly those parameters and takes the same
values from there instead of calling exp again. Same expressions, same
parameters, same numbers.

Bench against the base FitModelScale (single thread, 48k reflections, P2_1):
1.65 -> 1.10 s for this and the previous commit together; P1, P2_12_12_1,
P6_122, I23 and the dependent n<=5 path identical to the bit and 30-40% faster.
Validation outputs (p.hkl md5, p.mtz / p_maps.mtz data, maps, p_model.pdb, log)
identical to the base: CPU build --mode scale on 5reo, myob_x10sa, 8sa8, 8xtg,
5ebi at -N 1, default and -N 8; full CPU runs of 9ea5, 6iu5, 7dkp; the GPU build
with no visible device on the five --mode scale sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Where the first pass recognises two twin domains on their coincidence lattice (reticular
merohedry, index n = 2 or 3) and adopts the stronger domain's cell, the 1/n of that domain's
reflections that lie on the other domain's lattice too carry both domains' intensity. The step
now records the overlap as a lattice vector t of the standing cell (h . t divisible by n), the
rotation result carries it, and the per-image integration drops those reflections. t is a
real-space vector, so it holds through every later change of basis; it is read in sixths so a
centred conventional cell (F, I, C, R) is exact. Each unique reflection keeps its equivalents
that the other domain does not overlap; only those whose every equivalent is overlapped are lost.

The two-pass quality guard compared signal counts between a header pass that integrated
everything and a refined pass that left the overlap out, and sent the run back to the
composite lattice. A pass that left an overlap of index n out now has its counts taken up by
n/(n-1), and its axial rows are not compared with a pass that did not.

Nothing changes where the step does not fire: the field stays empty, the factor is 1.

Measured (merging the existing outputs with and without the class H.(111) = 0 mod 3, against
removing a random third): spinel-twinned F-cubic private set R_meas 25.5 -> 14.3% (random
26.7%), insu_I_x06da_low_isa 24.1 -> 16.6% (random 24.2%). With this change, end to end:
- insu_I_x06da_low_isa: I23, R_meas 24.0 -> 12.7%, ISa 5.2 -> 28.8, CC1/2 0.997 -> 1.000,
  completeness 99.8 -> 95.7%, d 1.45 -> 1.47 A, model R-free 0.260 -> 0.246.
- private F-cubic twin: F432, R_meas 25.5 -> 6.3%, ISa 3.9 -> 20.4, completeness 100 -> 93%,
  d 3.23 -> 3.77 A (XDS reference 4.0). 432 now passes the added-operator gate on the
  all-observation merge (contrast 0.75, bound 0.72) instead of being refused there (0.62) and
  rescued by the twin-immune zone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact: p.hkl md5 and MTZ data identical to the b530c2d battery on myob_x10sa, cytc_x10sa, 8a1a, 9gdj, 11if,
kdp_x10sa_20keV, 6z9g; 9ig7 identical to its clean reruns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Exact: p.hkl, MTZ and map data, p_model.pdb identical at -N 1/default/-N 8 on 9 sets (CPU build and GPU build on
the CPU path); GPU path unchanged. Indicative: 8xtg CPU validation 32-41 -> 23-25 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Doubled-cell hypothesis: the run now always merges the doubled cell under <prefix>_doubled first and
decides with both merges in hand. Adopted only where (1) the probe's class grows significantly with
resolution (three standard errors between the 20-8 A shell and the finest measured one) and (2) in
the doubled merge one pseudo-translation - half a cell plus eps, fitted over the class - modulates at
least half of the added class in every shell (FitDoubledClassTranslation: A sin^2(pi h.eps) + B per
shell, share 1 - B/mean). Where adopted, every <prefix>_doubled file takes the plain name and the
probed cell's file of that name moves to <prefix>_halfcell. Replaces the HALF_CELL_BREAKS_DOWN bound.

Measured on the forced doubled merges: modulated share per shell 0.80-1.0 out to 1.9 A on the crystal
deposited in the doubled cell; on the ones deposited in the half cell it falls to 0.01-0.34 by
2.1-2.8 A (the rising class with twinning), or stays at -0.3-0.48 at every resolution (three others);
the one class it fully explains does not rise (eps 0.1 of a cell, flat at 0.1 of the lattice).

Space-group search: the trim of a screw zone's largest absence exempted a violation "at its row's
mean" on the de-modulated intensity, so under a deep pseudo-translation a reflection at 0.015 of its
row (de-modulated by 1/0.002) could never be trimmed and alone decided the row against the screw.
The exemption now reads the measured intensity, at both sites.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The first-pass spot engines (FirstPassRun::SpotEngine, SpotFindingWorkers::Engine)
were given back by move-assigning a fresh struct over them. A move-assignment goes
in declaration order, so it freed raw_image - whose bytes the analysis engine had
page-locked with cudaHostRegister (PinInputRegion, every uncompressed/CBF frame) -
and only then destroyed the engine, which unregistered the already freed range.
In between, the address may be handed to another thread's buffer. Measured in a
standalone test on this card and driver: a copy into a new buffer at a stale
registered address goes to the old pages (the data never arrives, no error), and
cudaHostRegister / cudaMallocHost on it fail with "already mapped"; eight threads
freeing in this order made unrelated threads' ordinary pageable copies return
wrong data, the correct order none. The engines now release themselves engine
first, buffer last.

The image pass had the same hazard where the writer still holds an image: the
worker read the next frame into a new one and dropped the old one, which the
writer then freed while the worker's engine still had it registered. The old
image is now held until the engine has pinned the new one.

This is the most likely cause of a sticky copy-engine fault (Xid 31, CE write to
an unmapped address) that ended a rotation run's scaling with "unspecified
launch failure": the speculative geometry probe released its spot engines on
many threads at once while the merges beside it were copying. That failure was
not reproduced in 9 reruns before the fix, so the link is inferred, not shown.

Merged data unchanged (p.hkl md5 and MTZ data identical to rc175 on 10 sets;
p.hkl identical with --write-process-h5 on a CBF set).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Most likely cause of the intermittent 'unspecified launch failure' (Xid 31, copy-engine write to an unmapped
address) seen once on 9ig7; not reproduced. Exact: p.hkl md5 and MTZ data identical on 10 sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
- ModelValidation reads the model and, where it carries non-given NCS operators
  (_struct_ncs_oper 'generate' / MTRIX without the given flag), expands them
  (gemmi expand_ncs, vendored assembly.cpp from GEMMI 0.7.5, MPL-2.0 as the rest
  of gemmi_gph). Same in the battery's model_check.py and depdata_check.py.
  An icosahedral-virus entry deposited as 3 of the 45 chains of its ASU was
  scored against a fifteenth of its atoms: R-free 0.545 -> 0.297.
- The indexing the model chooses (AdoptModelFrame) now moves the cell with the
  indices (reseat_integrated in the main path; CellInBasis in the forecast,
  twin-file and stills/scale paths). A near-symmetry law (b within 2% of c, beta
  a degree off 90, a~b in P1) relabelled the reflections only, so the files were
  written with two axes swapped or beta flipped relative to the indices. Where
  the law moved the cell, the validation is remade on the moved cell.
  Measured on 18 open/in-house sets with models: p.hkl and MTZ reflection data
  bit-identical on all; cell and model R unchanged where the law is exact; the
  cell now matches the deposited one on the 6 pseudo-merohedral cases (orthorhombic
  a/c swap, three monoclinic beta flips, a P1 alpha/beta swap), R-free within
  +-0.001 there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Exact: p.hkl md5 and MTZ reflection data identical on 18 sets. 4nwv R-free 0.545 -> 0.297 and cell in the depositor's
frame; written cells corrected on 9rcs, 9b22, 9qw8, myob_x06da_powder_1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A class that does not rise keeps the half cell whatever the doubled merge shows, so the doubled pass
is no longer run for it - on a ci-tier crystal (R-centred, class at 1-2% of the lattice) that pass ran
the card out of memory and failed the run. The doubled pass also runs without --model: placing the
model in a doubled cubic crystal's 776 A R-centred cell took twenty minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
nothing_1 and nothing_2 now pass the no-crystal test by about seven decades (tails 10^-2.06 and
10^-2.70 against 1e-9) instead of 0.3-0.9, so a change that moves one stray reflection no longer
flips them, and a regression that lets a chance lattice through fails CI. The ci tier is 28 sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Exact: p.hkl md5 and MTZ data identical on every real set (34 open/inhouse incl. the ci tier, all 27 private);
both no-crystal controls now ~7 decades inside the bar instead of 0.3-0.9.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
On strong data a class at one or two percent of the lattice rises significantly (0.009 to 0.059 at 4
standard errors on a ci-tier triclinic crystal, which was then adopted doubled and failed), and a half
cell missing a few percent of its intensity still describes the crystal. The doubled deposition ends
at 0.35-0.40, those classes at 0.06.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A centred half cell (I-centred orthorhombic, its centring class the added one) was compared to the
doubled cell's sub-lattices as if primitive, so its class was never read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A superstructure cannot be more symmetric than its own average, which the half cell's merge is. On a
private-arm crystal whose added class passed the rise and the one-translation tests, the doubled merge
came out centrosymmetric over a chiral half-cell group (point group order 8 against 4) and the run
failed against its reference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A shell is read only where at least half of the class's reflections are clear of the lattice's by r3.
On a 701 A axis 26-43 of ~36000 per shell were clear; they read at the lattice's own strength, the
doubled merge (93% of predictions overlapping a neighbour) passed the one-translation test, and the
run was adopted doubled and failed. With the rule, of ~134 sets with probe logs only 9min and one
I-centred crystal reach the doubled pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The doubled pass now runs only on resolved, rising classes (two sets of ~134 with probe logs), so the
twenty-minute model placement in a doubled cubic cell can no longer arise, and the adopted answer is
validated again (9min: R-free 0.275 against the deposited model, 0.574 in the half cell).

Final rules, measured on gate + ci tier + all sets with a de-modulated axial row + 49 sets with a
baseline supercell occupancy >= 8% + the private arm: 9min fail -> pass (P212121, doubled cell);
every other set run keeps its verdict and a bit-identical p.hkl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Merge branch 'doubled-cell' into rc175: a doubled-cell hypothesis for a half-translation pseudo-symmetry that breaks down with resolution
Build Packages / Create release (push) Successful in 7s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m58s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m43s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m50s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 15m10s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 16m20s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 19m2s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 19m20s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 20m9s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m0s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 16m10s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 17m46s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 18m52s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m5s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 20m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 22m24s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 16m8s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 19m46s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 17m29s
Validation Battery / Validation Battery (push) Successful in 48m17s
Build Packages / Unit tests (push) Failing after 1h24m43s
a897b08a7d
Narrow, guarded: the doubled pass runs only where the added class grows with resolution and is resolved; the doubled
cell is adopted only where one pseudo-translation explains the added class in every shell and the point group does not
rise. Fixes the one remaining lattice failure (half cell -> deposited cell, R-free 0.574 -> 0.275); p.hkl identical on
the ci tier, the supercell gate set, 78 further supercell-candidate sets and all private sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A CUDA out-of-memory in an indexer worker was turned into an IndexerResult carrying only the
message string. The rotation indexer then returned no lattice, the first pass treated that as a
genuine negative and the rescues acted on it: in a stress run (two rugnux on one card) a run had
both first-pass schemes fail with out-of-memory, the axis-sign rescue compared 0 frames at the
flipped sign with the -1 "no lattice" sentinel, adopted the flipped sign, and the beam-centre rescue
then moved the centre - a failure of the machine used as evidence about the crystal. The CUDA
category was lost on the way, so the fail-fast check (IsFatalResourceError) never saw it.

- IndexerResult::error is now the std::exception_ptr the worker caught. RotationIndexer::RunIndexing
  rethrows it before touching any state, so nothing is memoised for a failed run and the exception
  keeps its category: rugnux stops with the CUDA message at once (verified with a forced indexer
  failure: exit 1 after 1.9 s, "CUDA (GPU) error (...)"). FirstPass::indexer_error,
  RotationIndexer::GetIndexerError and RequireLattice's machine-failure branch are gone.
- The beam-centre arm in RunAllPasses rethrows fatal resource errors like its sibling catches; it
  had caught the out-of-memory and carried on at the file's centre.
- IndexAndRefine keeps per-frame indexer failures apart from frames that did not index (count +
  first exception). The frame stays "not attempted" as before; the receiver logs the count and the
  first error at the end of a collection (no cancel), rugnux's image pass and stills geometry
  refinement rethrow it and stop. A rotation-indexer failure inside IndexAndRefine (online rotation
  indexing, FinalizeRotationIndexing) is recorded the same way, so a live collection is not
  cancelled by it.
- A failed device allocation or cuFFT plan now says how much was asked, how much of the card is
  free, and - when the card is merely full - that another program is using the GPU or the data set
  needs more than the card has, with the CPU remedy; previously "Failed to allocate device memory
  (out of memory)" / "Failed to create CUFFT plan with cufftPlanMany".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A pass that finds no lattice carries score -1. That sentinel was printed as "-1/60 validation
frames" and compared as a count: the axis-sign rescue adopted the opposite sign when it scored 0
frames against the file's "no lattice" (0 > -1), and the long-axis rescue could return a re-index
that indexed nothing over a standing pass that found nothing.

Both rescues now count a missing lattice as 0 frames, so an alternative needs at least one indexed
frame to win. The log says "no lattice" (Frames()) in the axis-sign, beam-centre, long-axis and
rung lines and in the near-tie lines, whose margins also count it as 0 (log only). On the base
binary's full open+inhouse battery no set adopted a sign or a long-axis re-index against a
lattice-less pass, so no result moves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the first pass is run again at the measured beam centre and the file's centre is kept, the run
restored the experiment, spot finding, mosaicity, geometry and prepass result, but not the rest of
what the arm's pass writes: its reflection footprint replaced bragg_adaptive_ (the integration
settings of the canonical pass), and last_rotation_result_, bkg_starved_fraction_,
bragg_before_adaptive_ and supercell_settled_ stayed the arm's. A losing arm therefore changed the
result, and an arm that completed gave a different answer from one that failed: on an open-arm
tetragonal set 66132699 against 66121594 integrated reflections and a different p.hkl.

These are now restored with the rest. Verified on that set with a forced arm failure (temporary
local patch, not committed): the arm completing and the arm failing now give the same p.hkl
(99faeb99), which is also what the arm failing gave before (stress runs #0/#1); the base binary
gave 94c3afc9 when the arm completed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In a stress run (two rugnux on one 16 GB card) the indexer's cufftPlanMany failed three times with
CUFFT_INTERNAL_ERROR (5), not CUFFT_ALLOC_FAILED, so the message carried neither the free memory
nor the remedy. Every plan failure is now reported like a failed allocation: free memory on the
card, the two usual causes and the CPU remedy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The model scale searched b_sol only over 10-80 A^2. That is the range
Fokine & Urzhumtsev report for models at ordinary B, but on low-resolution
entries whose deposited B is 200-400 A^2 the fit sat on the 80 A^2 ceiling
(and k_sol on its 0.1 floor): a solvent edge far sharper than the atoms it
lies against, reaching too deep in resolution. R-free came out 0.05-0.10
above REFMAC (0 cycles) and phenix.model_vs_data on the same MTZ, model
and flags.

The ceiling is now max(80, 2 x the model's mean atomic B) (SolventBMax),
for the reported fit and for the rigid-body placement's per-zone solvent
(CPU, GPU and the host fallback alike). The grid keeps eight b_sol values
across a wider box, so it costs no more points; the default box, and so
every model with mean B <= 40 A^2, is bit-identical to before.

Measured (10 open-arm sets with --model, GPU build, vs the same base):
  high-B set, 7.0 A:  R-free 0.462 -> 0.364, b_sol 80 -> 379 (REFMAC 0.366,
                      REFMAC's own b_sol ~371; model_vs_data 0.291);
                      the rigid-body step now commits
  high-B set, 3.9 A:  0.410 -> 0.331, b_sol 80 -> 388, k_sol 0.10 -> 0.225
                      (REFMAC 0.328, model_vs_data 0.314); null 10.1 -> 14.2 sigma
  B~200 set, 3.2 A:   0.408 -> 0.402, b_sol 80 -> 120; enantiomorph label
                      decision unchanged (27.2 -> 25.8 sigma)
  7 other sets (mean B 16-57): bit-identical R, k_sol, b_sol, placement.
p.hkl md5 and the MTZ reflection data are identical to the base on all 10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
TCPStreamPusher::SendImage is called from the receiver's frame-transformation
threads. It used a 128-deep per-connection queue and waited when it was full.
With images routed per file ((n / images_per_file) % writers), every writer
receives a whole file as a burst at the full frame rate (1000 images/file at
2 kHz fills 128 in ~64 ms), so the queue filled on every burst and all
transformation threads ended up waiting on one writer. They then stopped
returning FPGA frame buffers, and the FPGA dropped packets: partial images,
images without a background estimate, data loss on every writer. The ZeroMQ
pusher has an unbounded queue and never blocks, which is why it coped.

- The per-connection queue is unbounded: every element owns a distinct
  image-buffer slot, so the bound is the image buffer's slot count. When slots
  run out the receiver drops only the write (GetImageSlot() == nullptr), after
  analysis. Absorbing one file burst therefore needs an image buffer of about
  images_per_file slots (~20 GB for a 9M at 1000 images/file); below that,
  images are still dropped, now as writer-queue-full instead of packet loss.
- SendImage never blocks. A dead or wedged writer is still caught in SendAll
  (15 s silence / 60 s without progress), after which WriterThread returns the
  queued slots unsent.
- The 200 ms fallback in StopDataCollectionThreads is gone: at END it closed
  the connection of any writer whose queue stayed full for 200 ms - a normal
  state for a slow but healthy writer - discarding up to 128 queued images and
  never sending END. END now follows the whole backlog.
- A per-connection enqueue mutex makes "check active, enqueue" atomic against
  "clear active, enqueue the end sentinel": an image could land behind the
  sentinel when a connection was torn down mid-run, leaking its slot
  (CheckIfBufferReturned then threw) or being sent as stale data in the next
  run. New stress test SendImageRacingEnd_NoSlotLeak (verified to fail with
  the race widened and the lock removed).
- TearDownConnection marks the connection broken first, so the destructor
  returns a backlog instead of sending it.

Writer side:
- jfjoch_writer -b/--rcv_buffer sets the TCP receive socket buffer (was
  hardcoded to the kernel default).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
RugnuxFarEnd.cpp used M_PI, which MSVC does not define without
_USE_MATH_DEFINES, and broke the Windows viewer build (C2065). The
ResultReport_SupercellCheck test still looked for the supercell warning's
wording from before the doubled-cell change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The rotation-scale walk ran its probe passes on the run itself and put back a list of members
afterwards (experiment, geometry, pre-pass mosaicity, scale). The list missed what an integrating
refit probe leaves - bkg_starved_fraction_, last_rotation_result_ - and the spot-finding settings a
first-pass ladder rung adopts were carried on purpose, with a check that threw the refined passes
away when the walk had changed them. Each probe now runs on a copy of the run as the walk found it,
and only its first-pass memos (an exact cache) come back; the run ends the walk as it began it, the
restore lines and the spot-finding check go, and the refined passes are overturned only where a
scale is adopted. The geometry walk's index_at_geometry probe runs on a copy the same way, so a probe
the speculative one already answered (memo hit) and one run there (miss) leave the run identical.

Measured before the change with a temporary state dump at the start of the refined passes, walk run
against a run that skips the walk and applies the walk's k directly (8r5r, 9gdj, 9qw8, 2ygz adopt
k != 1; 9t6s, lalanine walk first and keep k = 1): p.hkl identical on all six; the run state differed
only in last_rotation_result_, bkg_starved_fraction_ and the memo count - leaks the canonical pass
overwrote before reading, so latent.

After (GPU, gpulock, shared machine): p.hkl and MTZ reflection data identical to the base binary on
26 ci-tier sets with a merge (+ both no-crystal controls, same verdict), on the 11 open sets whose
walk adopts k != 1 or runs first, on 7 sets whose geometry walk takes a sub-step round, and on the
one private set that adopts a scale. Catch2 [RotationScale] and Rugnux_FirstPassMemoMatchesRecomputation
(verify_first_pass_memo through the walk) pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The pre-scan's spot measurement (spot width, bandwidth, powder rings) built
all of its GPU engines on card 0 from unpinned threads. Worker t now runs on
card t % gpus with pin_gpu(), as the image-loop workers do, which also binds
it to that card's NUMA node where binding is enabled. Every tier runs on
fresh threads, so each tier re-pins worker t to the card its engine was built
on, and the engines are freed on threads pinned the same way (as the first
pass's ReleaseSpotEngines does).

Worker count, width tiers (8/15/30/60 on a 60-frame sample) and the frames
read are unchanged. A frame's spots do not depend on the card that found them
(same model), and the per-frame results are stitched in sample order as
before, so the output does not depend on the number of cards.

The projection and defective-pixel passes are left on card 0: both fold
every frame into a single device accumulator (ShadowAccumulatorGPU,
HotPixelFinderGPU) that lives there.

Verified on one card: CI-tier battery p.hkl md5 and MTZ reflection data
identical to the base binary on every set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The demoted arm is a probe pass, run on the run itself before the canonical pass (the promoted arm).
Its first pass measures a spot budget and writes it onto the experiment, and nothing put it back, so
the canonical pass started from the probe's budget instead of the full spot list - measured, 446
spots per image instead of 1000 on one in-house crystal (validation spots 22055 instead of 43582) and
58 instead of 1000 on 6yqf. The same held where the arm lost: the demoted canonical pass started from
the budget the stopped promoted pass measured. The demoted arm now runs on a copy of the run and gives
back only the geometry its post-refinement asks for (which the promoted arm reads, as before) and its
first-pass memo; where it wins, the demoted canonical pass starts from the experiment the promoted one
started from. The indexing-settings and flag juggling around the probe goes.

GPU, gpulock, shared machine, on the 22 sets whose baseline runs the check: p.hkl and MTZ reflection
data identical to the base binary on 20; lcystine_x10sa_20keV fails with a cuFFT out-of-memory on the
shared card with both binaries (four attempts). Changed: 6yqf only (its canonical first pass now reads
the full spot list): merge 3.23 -> 3.15 A, R_meas 0.654 -> 0.568, ISa 4.97 -> 5.36; rugnux model
R_free 0.459 -> 0.504 at its own d_min, phenix.model_vs_data R_free at the common 3.23 A 0.443 -> 0.435
(0.443 -> 0.447 at full resolution). The check's decision is unchanged on all 22.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The doubled-cell probe (eight parity classes over 20-3 A plus the to-the-limit
trend) was armed on every full rotation image pass: the geometry pre-pass, the
beam-centre arm, the metric-symmetry demoted arm, the geometry-walk and
rotation-scale probe passes, the speculative geometry probes and the output
passes. Its only consumer is ProcessResult::supercell of the answer
(DoubledCellHypothesis and the report), and every answer RunAllPasses returns
comes from a write_output, non-pre-pass RunPipeline call (single pass, pass 2,
the walk's rewrite, the quality-guard redo, the lost-arm rerun, the doubled
pass). The indexer is built fresh per pass, so an unarmed pass reads nothing.

Output-identical: ci tier + 7 doubled-cell/supercell sets (35 sets), base
e34a5fb88 vs this: every .hkl byte-identical, 69/69 MTZ files identical in
reflection data/cell/SG (gemmi), p_report.txt identical apart from DATE,
WALL_TIME and RUGNUX_GIT; battery compare: 0 sets changed. The log loses only
the non-output passes' "Supercell probe" lines (e.g. 4 -> 2 on a two-pass set).

Indicative only (shared GPU): summed "image pass" time over the 35 sets
328.4 -> 320.4 s, geometry pre-pass integration 99.5 -> 93.7 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The verdict gated only deltaB_linear, the Debye-Waller part of the tensor, against the data set's own
systematic floor. A deficit that does not follow s^2 (a FLAT signature) can stop the signal far earlier
in one direction and still read NOT_DETECTED: one open-arm set reported directional limits of
3.18/3.18/2.14 A beside NOT_DETECTED. The cone limits (<I/sigma> = 2 in 20 deg cones) were computed but
never consulted.

Now each statement is decided on its own evidence and either establishes anisotropy:
- tensor: unchanged gate;
- directional limits: at the best direction's limit the weakest cone's <I/sigma> is below the best
  cone's by more than 3.5 of their joint standard error (both read linearly in s^2 between shell
  centres, as the limits are), the weakest limit is measured (not censored at the data edge), and the
  limits are at least 0.25 A apart - the spread the report already calls "noticeable".

Why the magnitude term: with the standard errors alone (the counting statistics of the cones) the
weakest cone is "significantly" below the best on 172 of 216 battery sets, down to limits 0.5% apart
(measured with a replica of ConeLimits on the a897b08 battery's merged data). The cones differ
systematically (multiplicity and coverage depend on direction), so significance alone made DETECTED
near-universal. A refusal (CANNOT_DETERMINE: dose ramp, short sweep, triclinic, no unmerged data) is not
overruled - its reasons bias the cones too - but now reports the measured directional limits.

Report: ANISOTROPY_DETECTED_BY (TENSOR/DIRECTIONAL/BOTH/NONE); developer keys for the two cones'
<I/sigma> and errors; the summary row names the test that carried the verdict, and CANNOT_DETERMINE
rows give the directional limits; the mmCIF verdict comment says so. Report only: no intensity, flag or
decision downstream reads the verdict (the French-Wilson prior uses the tensor regardless).

Measured (battery replica, 225 sets with limits): directional statement established on 95; 23
NOT_DETECTED -> DETECTED (directional ratio 1.14-1.59); of the 17 sets with ratio > 1.2 and an
uncensored weakest limit, 11 flip, 5 stay CANNOT_DETERMINE with limits reported, 1 (sparse, noisy
cones) stays NOT_DETECTED. ANISOTROPY warnings (spread > 0.5 A) appear on 6 more sets. Confirmed with
rugnux runs on the ci tier, those 17 and the private arm: p.hkl and MTZ data identical to the base
(e34a5fb88) on every set compared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CalcGlobalWilsonB fits ln<I> against 1/d^2 from 4 A to d_min. With d_min just under 4 A that window is a
sliver, and after the shells past the signal limit are dropped it held 0-2 shells, so the estimate was
NaN (or a fit over almost no range); and any B above 200 A^2 was discarded, which is every crystal
diffracting to 4-7 A.

Now the window is never narrower in 1/d^2 than the 4-3 A window a 3 A data set gets: a narrower one is
widened towards low resolution (d_min 3.15 A -> from 4.39 A, 3.61 A -> 5.96 A, 3.87 A -> 7.40 A; data
coarser than ~4.4 A use everything, as before). The 200 A^2 cap is dropped for the dataset-wide
estimate (kept for the per-image one). The window is logged, printed with the statistics and reported
(WILSON_B_RANGE_A, developer key); the correlation is reported as before. Data with d_min <= 3 A are
unchanged.

Report only: the value feeds the statistics text, the log, the report and the mmCIF
_reflns.B_iso_Wilson_estimate; nothing in scaling, merging or the MTZ reads it.

Measured on the open arm (replica on the a897b08 merged data, confirmed by rugnux runs): 12 sets
change, 7 previously NaN now finite - 5nw5 315, 9z44 493, 6r72 302 A^2 (were above the cap; model mean
B 328/310/114, deposited Wilson 388/322/179), 9yzk 210 (model 383, ctruncate 243, xtriage 104), 7qij 104
(model 194, ctruncate 150), 9yl4 78 (model 146), 8qq7 35 at 4 shells (model 197; its signal ends well
inside the written 3.15 A). 6yqf 132 (corr 0.51) -> 96 (corr 0.69; model 57, ctruncate 145,
xtriage 114); 9rcs 69 -> 148 (model 74, ctruncate 131). At 3-4 A the estimators themselves disagree by
1.5-3x, so these are better-defined numbers, not exact ones. p.hkl and MTZ data identical to the base
(e34a5fb88) on every set compared (ci tier, private arm, the Wilson sets).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
When a first-pass decision is a near tie (the margin within 2 sigma of its own
noise, the existing "near tie" lines), the same comparison can be made again on
the 60 validation frames plus 60 frames equally spaced over the rest of the
sweep (late_validation). A gate set at a share of the validation frames is
applied at the same share of all 120; a lattice that indexes none of the later
frames (a crystal that left the beam) is judged on the validation frames alone.
Decisions that are not near ties never read those frames; sweeps the pre-pass
already covers have none.

Carried: scheme choice (also in the ladder's and rescues' scheme comparisons),
the axis-harmonic arbiter, the metric-symmetry demotion gate, the sub-lattice
majority gate (only where it decides: on 74 firings the sub-lattice already
indexed fewer frames than the standing cell) and its pooled comparison, the
beam centre's pooled-spot comparison, the pass-1 fallback, the long-axis
rescue (different lattices only) and the ladder's absolute bar. Each
"near tie decided late" line says whether the later frames overturn the early
decision.

Validated deciding every near tie late, against rc175 a897b08a7 (all 60
open+inhouse sets that log a near tie plus the 28 ci sets; the 14 private
sets that log one): no verdict changes; p.hkl identical on 76/78 and 13/14.
Changed: one R32 set (R_meas 86.6 -> 82.2%, same space group, cell, d_min), one
unscored small molecule (merging statistics unchanged), one unscored private
small molecule (ISa 2.2 -> 17.8, R_meas 37 -> 4.8%, through the metric-symmetry
demotion; with the demotion not carried it is identical to the base).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A first pass now acts on its validation frames at once (as before part B). Where
it decided a near tie that later frames can re-check, the copy of the run taken
as that pass began is kept; after the run - when every frame is in, live too, and
nothing else holds the card - the pass is run again on it as a probe deciding
late, and its outcome (lattice, path, centre, spot settings, carried centres) is
compared with the early one. Only where a re-check ends elsewhere is the run made
again, every near tie decided late: the same answer as deciding late throughout.
A run that found no lattice after carrying a near tie is made again likewise.

Live, no near tie holds the run up. In a simulated collection (frames readable
at 10 ms each, 3 repeats) the pass-1 first pass ends when the base's does (9.8
vs 10.1 s, 13.9 vs 14.0 s, 15.1 vs 15.6 s) where deciding late waited for the
end of the collection (17.9, 21.7, 23.5 s); the result follows the last frame by
+1.0/+4.6/+14.8 s against the base's +0.6/+4.1/+13.3 s (deciding late: +2.3/
+10.3/+23.1 s) - the difference is the re-checks. A set whose near tie overturns
pays a second run (+25 s on one).

Validation (78 open+inhouse sets incl. the ci tier, 14 private): p.hkl identical
to deciding late throughout on all 92 (one private set compared at this base,
the rest at e39e40f95, from which this base is output-identical on them);
identical to the base except the 3 sets where deciding late changes it (one R32
set, R_meas 86.6 -> 82.2%; two unscored small molecules). Re-checks ran on 50
sets (135 first passes; replayed deciding early, none reports a difference); the
run was made again on 6 (4 overturns, 2 no-crystal controls). Re-check time on
the other 46: median 0.9 s (7.6% of the run), at most 18 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Measured on an RTX 5080, one worker (nsys), 16M EIGER rotation set, per frame:
spot-finding kernels 757 -> ~383 us. Output is bit-identical: integer sums
and a deterministic candidate/spot order throughout.

- flag_strong reads the image only where a strong pixel can be: the plain
  ring pass notes each 32-pixel word's largest value, a geometry table gives
  the rings each word touches, and a word below the lowest of their
  thresholds is skipped. One thread per word writes the whole word, so the
  ring buffer needs no clearing or atomics. 128 -> 12 us.
- analyze_pixel with candidates uses 16-row tiles (only tiles near a
  candidate are worked, and each warp's serial chain of rows is short) and
  issues the entering/leaving row loads before the window scans; the window
  count is summed in 32 bits. 254 -> 72 us. The dense path keeps its tiling.
- analyze_candidates reads its column window with unconditional, unrolled
  loads; through value_at each load waited for its previous-pass bit and
  the row before. 74 -> 34 us.
- scatter_bits emits a block's range with the whole warp (prefix sum over
  the words' bit counts, same order). 32 -> 6 us.
- finish_components split into label (one block), sum (all blocks, integer
  atomics) and filter/compact (one block); a component's sums no longer walk
  the list from its first member to its last. 26 -> 12 us.

Tried and not kept (slower on sm_120): warp-aggregated ring sums and a
low-value histogram fast path in reduce_rings_shared, a precomputed
tile-needed map, unrolling analyze_pixel's row loop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The demoted arm of the metric-symmetry check integrated and post-refined the
whole sweep even where, released from the equality, its first pass ended on
exactly the lattice the promoted class indexes. It then integrates the same
reflections the canonical pass does, realises the same held-out residual, and
the promoted class stands on the tie - 4nwv spent 14 s to report "I-centred
orthorhombic does not beat I-centred orthorhombic".

The promoted class's first pass now runs first as an indexing probe (memo
cleared, so it always records its outcome), and the demoted arm stops right
after its own first pass when that ends on the same first-pass outcome string
(lattice vectors to the last bit, path, class, geometry, spot-finding state).
No comparison is made and the canonical pass runs as before. The decision is
unchanged by construction; the geometry the arm used to hand on is the
promoted pass's own fit in that case either way.

Output-identical: p.hkl md5 and MTZ arrays equal on all 34 metric-check and
scale-walk sets of the battery (31 open/in-house + 3 private) against
56bad5fdb. The skip fired on 5 sets; metric-check step time 4nwv 14.0 -> 2.9 s,
7kcn 6.9 -> 1.6 s, in-house lysozyme sets -1.3..-1.7 s; the promoted probe
costs ~0.1-0.8 s on the sets where the lattices differ. Battery metric-check
step total 129 -> 107 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The demoted arm of the metric-symmetry check and the rotation-scale walk's
refits integrated and post-refined every frame of the sweep, only to read a
held-out residual or a scale fitted on top. They now integrate only the frames
the geometry pre-pass read (prepass_frames_only_, PrepassEnd), as the pre-pass
asked its own fit: the run decides early on the evidence it has.

Metric-symmetry check: the canonical pass, the promoted arm, fits its
comparison post-refinement on the same frames (GatherPostRefineObservations
takes a frame limit). That fit only measures and leaves nothing on the run;
the canonical pass then measures its own fit over every frame where it
measures one anyway, as any canonical pass does. A decision whose margin from
the HeldOutResidualFell line lies within NEAR_TIE_SIGMA of its noise is a near
tie in the part-B sense: the canonical pass also fits over every frame while
its reflections are in hand, and after the run the demoted arm runs again on
every frame (deciding late) and is judged against that fit. Where the check
ends elsewhere the run is made again deciding late, which asks both arms on
all the frames as before. No new threshold.

Rotation-scale walk: a refit proposes the next scale; the validation frames of
the whole sweep still decide whether it is taken, so it needs no re-check.

Battery, 31 open/in-house metric-check and scale-walk sets + 3 private,
against 56bad5fdb:
- p.hkl and MTZ arrays identical on 32 of 34. Every metric-symmetry decision
  ends as before. 6yqf decided a near tie the other way on 1200 of 2000
  frames, the re-check on all frames overturned it and the run made again
  gives base's output (24 -> 55 s). 6cdl and 9vyb were near ties the re-check
  confirmed.
- 9gdj: the scale walk takes one more refit and adopts 0.97020 instead of
  0.97045; R_meas 11.41 -> 11.36 %, R_free 0.1709 -> 0.1708, d_min, SG same.
- One private scale-walk set: the pre-pass refit pointed up where the
  all-frame one pointed down; the whole-sweep validation frames prefer the
  higher scale, and the merge improves on every statistic.
- ci tier: 0 sets changed beyond noise (only 9gdj's data differ).
- rugnux wall over the 31 sets 1059 -> 993 s (with 6yqf's re-run), metric
  check step 129 -> 85 s; ci tier 640 -> 626 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The gate for merging the space-group search's all-observation arm on a copy of the engine, beside
the first arm, judged an engine at a fixed 256 B per observation. It now counts what the ingest
knows - observations, rocking events (at most one full each) and distinct hkl (at most one ASU
group each) - at 80 / 260 / 200 B, the envelope of the measured per-engine peaks (helpers
included: error-model fit, Wilson outlier test, pair sort), plus a fifth, and keeps 3 GB of the
card for what else stays resident (context, the run's indexers: measured up to 2.3 GB at the gate
with no speculative pass running).

Measured per-engine peaks (scratch build tracking each engine's device allocations): 4nwv 7.98 GB,
8a1a 8.15, 8tyy 9.36, 8qaw 13.3 - two engines do not fit a 16 GB card on any of these, so they stay
serial. The old constant was not an over-estimate there (fulls cost ~260 B each at the surface
fit). What changes: 9 battery sets of 33-53 M observations at high multiplicity (8s38, 8sqt,
7ph1, 9min, 9i80, 9sl0, 9h0q, 8v4o, 6ze4) now merge the arm beside; none goes the other way.
Their merge-step VRAM peak is 11.0-14.1 GiB of 15.9, no out-of-memory, no failed speculative probe.
Every engine peak measured on 11 sets is at most 81% of the estimate.

Output identical (p.hkl md5 and MTZ data, 40 sets incl. ci tier). Gain is small: the two merges
share one GPU, so the arm beside slows the main P1 merge by ~0.5 s while saving ~0.7-1 s of wait
at the search; alternating A/B on 5 of the sets: -1.1 s output pass in total.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
With -N left to choose, rugnux now confines the process (main thread, the CPUs
RestoreThreadAffinity hands back, and what AvailableCpus counts) to the CPUs of
the packages its cards hang off, and sizes its threads to them. Nothing changes
where the allowed CPUs sit in one package, there is no card or a card's NUMA
node is unknown, or the cards' packages already hold every allowed CPU. A mask
set from outside (taskset, numactl, a cpuset) is kept: the decision works
within it. An explicit -N keeps the old behaviour.

On a 48-CPU server with four cards on two of its four NUMA nodes, a rotation
run took 24.5-24.7 s confined to those nodes' 24 CPUs against 27.7-27.9 s on
all 48 (scaling and merging 8.1 -> 6.2 s), and two larger sets moved
95-99 -> 84-87 s and 131-135 -> 113-115 s. On a single-socket machine this is
a no-op.

The decision itself (CpusOfGpuPackages) is a pure function, tested on
synthetic 4-package, 2-package hyper-threaded, cards-on-both, no-card and
restricted-mask topologies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The ingest read every ~92-byte Reflection seven times per engine: the background/h-range sweep, the
h histogram, the key scatter, the observation build, the delta_phi re-solve against the smoothed
geometry, the partiality recompute, and the device staging. On a box with several sockets these are
remote, latency-bound loads (the owner's 4-socket profile puts RotationScaleMerge's ingest lambdas
at the top of the CPU time on most large sets). Now:

- the histogram of h is counted in the background sweep, per chunk of frames over the whole int16
  range the sort key holds, so the h range needs no sweep of its own;
- delta_phi is re-solved against the smoothed per-frame lattice while each kept observation is built
  (SmoothedFrameGeometry computes the per-frame bases first, SmoothedDeltaPhi solves one);
- the build keeps zeta and d beside delta_phi, so the partiality recompute reads compact arrays, and
  it brings corr_ingested to the recomputed partiality in the same loop, which retires the
  observation-length copy of the predicted partiality and its separate pass.

Output identical (p.hkl md5 on 9gdj and cytc; cytc also with --dump-observations, dump md5 equal,
and CPU-only). Transient host memory: +4 bytes per kept observation inside IngestHost (zeta and d
in, the predicted-partiality copy out) and nthreads x 256 KiB of histograms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The GPU engines are built per worker per step, and each took its page-locked host buffers with
cudaHostAlloc / cudaHostRegister and gave them back with cudaFreeHost / cudaHostUnregister. Those
calls map and lock pages under the process's memory-map lock, so all workers queue behind each
other: on the 5080 workstation a myoglobin run made ~3400 of them (6-22 thread-s, depending on the
load), 9gdj ~10400 (124 thread-s); on a four-socket host they cost 20-30 ms each.

- CudaHostPtr takes its memory from a pool: a freed buffer is kept in a power-of-two size class for
  the next engine, up to 512 MiB in all; a buffer above 64 MiB is never kept. Rugnux::Run hands the
  pool back at its end.
- PinnedVector (std::vector with the pool's allocator) replaces the registered vectors of
  AdaptiveSpotFinderGPU and ImagePreprocessorGPU; the Bragg predictors download into a pooled
  pinned buffer and copy into their output, instead of registering it.
- BSLZ4DecoderGPU copies the compressed chunk into a pooled pinned buffer before the upload, so it
  is one DMA instead of the driver's pageable staging (9gdj: 163 GB pageable H2D, 52 s of copy
  time, -> 147 GB pinned, 13 s).

Measured with nsys on the workstation: myob 3398 -> 1396 calls (674 of them the release at the
end), cytc 2986 -> 1270, 9gdj 10428 -> 1748 (124 -> 3.7 thread-s). Output identical (p.hkl md5 on
myob, cytc, 9gdj; CI tier battery).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The all-observation arm of the space-group search merges on a copy of the engine (CloneIngest), and
its IngestDevice staged every observation from the ~92-byte source reflections a second time -
concurrently with the main engine's own staging of the same reflections. Now the main engine
stages, and the copy takes its card arrays with cudaMemcpyPeer (a device copy when both engines
share the card, as on a one-card machine). CloneIngest no longer copies the predicted partiality,
which only that staging read.

Output identical (cytc p.hkl md5, and with --dump-observations the dump md5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Several pipeline copies of one run build FFT indexer pools on the card at once - the
rotation-scale walk's indexing probes start as the output pass begins its image loop,
and the speculative geometry probe runs beside the merge - and each indexer is a cuFFT
plan plus 0.6-0.9 GB of buffers. Construction had no admission control: a cufftPlanMany
or device allocation that found the card full failed straight through, and since
IndexerResult carries the exception that ends the run. With 7.2 GB of the card held by
another process, a small-molecule rotation set failed 4 of 4 runs this way although
each pass fits on its own.

Construction now does what the merge's allocations do: hand back the pool's unused
memory, wait for the work beside it to end, and build again. Who waits for whom is
fixed so that nothing waits in a circle:
- the main line waits for the probes (GPUWorkBeside, as the merge already does);
- a probe waits for the main line's image pass (new GPUImagePass), never for a probe,
  itself included. The image pass waits for no probe, so it ends.
The walk's indexing probes now hold a GPUWorkBeside too (they are exactly that work),
and a run carries gpu_work_beside_ so its pools know which side they are on. Only when
an indexer is built changes, never what it computes; with nothing to wait for it still
fails at once.

Same run with the 7.2 GB hog: 5/5 complete (also at 7.6 and 8.0 GB), p.hkl identical
to the unhogged run; a card that cannot hold the run still fails in 0.5 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A ci-tier run (or any run given --nullmodel) now also runs five ci-tier sets of the open arm
(8u0i, 6iu5, 9khr, 9b22, 6pxc) once without --model and once with each of five WRONG models,
the deposited coordinates of other open-arm entries (already in the pdb_cache, so no new data):
similar size, sequence identity under 25%, gesamt Q-score at most 0.20, one hard-ish model of
the same secondary-structure class per set, one in the same space group or a similar cell, and
for the two enantiomorphic sets one in the other enantiomorph. nullmodel.json records every
model's size, identity, Q-score and reason.

Each wrong-model run is scored against the no-model run (nullmodel.py): (a) p.hkl, the p.mtz
reflections and p.cif identical, (b) no KEY= line outside MODEL VALIDATION moved, (c) MODEL_FIT
not ACCEPTED, no decision taken, CC_MODEL_CONFIRMED_TO_D_MIN NONE and R_free >= 0.50 (Wilson's
random-structure R is 0.586 acentric). The pairs go to nullmodel_results.json and a report
section of their own; the sets' own rows are untouched. A failed pair fails the run, and the
Gitea battery workflow prints the section into the job log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The partiality recompute reads d only to broaden the rocking curve by the energy bandwidth. On a
large set the final ingest sits at the process's peak resident memory (8a1a: max RSS rose by the
array's 4 bytes x 68 M observations), so the array is kept only where a bandwidth is set; with
zeta in and the predicted-partiality copy out, the ingest's peak is then where it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Where the measured spot width widened the integration radius, the canonical pass
integrated the whole sweep at the widened radius, and only then did the starvation
guard read how many background rings the neighbouring predictions crowd; over the
bound (1.13%) the pass was thrown away and run again at the fixed radius.

The geometry pre-pass now counts that same quantity at the widened radii without
integrating there (BraggIntegrationEngineCPU::CountNeighbourCrowding: the neighbour
mask and the ring pixels the run's mask leaves readable, no image) on 20 frames
spread over the pre-pass, with the standard error of the ratio over those frames.
Over the bound, the canonical pass integrates at the fixed radius from the outset.
A count within NEAR_TIE_SIGMA of the bound is a near tie in the part-B sense: the
canonical pass counts the widened radii again on 20 frames over the whole sweep,
and where that puts them under the bound the run is made again deciding late. A
run deciding late counts nothing early and decides as before. Under the bound the
canonical pass integrates at the widened radius and the measured guard stays as it
was, so a pre-pass count that is too low (its prediction holds fewer tails than
the canonical pass's, or it stood on another lattice class) costs only the pass
it cost before. No new threshold.

Battery against 46be3f647 (the 14 open sets that re-ran at the fixed radius, the
9 open/in-house sets closest under the bound, the ci tier, 2 private sets):
- p.hkl and MTZ data identical on all 51 open/in-house sets and on one private set.
- The 14 re-running sets: rugnux wall 534 -> 447 s (7qij -27, 4nwv -12, 7yzx -10,
  9fhc -9, 5src -6 s, on a shared machine). 8sqq and 8xtf count under the bound on
  the pre-pass and re-run as before. 4nwv (1.69 +- 0.28%) and one other set were
  near ties, re-checked on the whole sweep (1.72%, 1.61%), ending where they did.
- One private, densely crowded set: same space group and d_min, R_meas 28.6 ->
  39.3%. Its old re-run re-indexed starting from the spot budget the thrown-away
  pass had measured (152 of 1000). With that budget given (--max-spots 152), the
  new run gives 30.6%. The difference comes from that path, not from the radius.
- The count costs ~13 CPU-s on the heaviest set (65k predictions a frame). Over
  the 22 ci sets that count, pre-pass integration took 45.2 -> 45.8 s.
- A build forcing the near tie and its overturn on 4nwv re-checks on the whole
  sweep (1.80% against the engine's 1.81%) and makes the run again deciding late.
  It ends with the same p.hkl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A predicted model (AlphaFold mmCIF, pLDDT in the B column) is trimmed at pLDDT 70, given B-factors
from the pLDDT (Oeffner et al. 2022) and placed by molecular replacement, one copy in the ASU:

- rotation function on a uniform 6 deg sampling of the point group's rotation ASU, each orientation
  scored by the correlation of E^2 with the symmetry-summed |F|^2 read off the model's molecular
  transform, computed once by FFT and interpolated (Navaza 1994);
- full-symmetry Crowther & Blow translation function (one FFT per orientation and group, through
  MapFromFPhi/FFTW) for the 50 best orientations in every group with the merge's rotations;
- the best three placements per group through RefineRigidBody (GPU where there is one), bulk solvent
  and R-free to 3 A, a C-alpha packing check against symmetry mates, Matthews, and the
  random-orientation null for a placed candidate.

Decision: PLACED = TF Z >= 8, clash <= 5%, Vm in [1.5, 4), null >= 3 sd. Within the answer's merge
the placed group with the lowest R-free is chosen where it leads every other packing group by 0.03;
the twin hypothesis' merge is searched, and chosen, only where no group of the answer's merge can
hold the model at all (Matthews, packing). An enantiomorph relabels the answer as --model does;
another group goes to <prefix>_mr.mtz/.cif plus <prefix>_mr_model.pdb; the MODEL PRIOR report
section carries the table. The search runs on the CPU in both builds; without --af-model nothing
changes.

Measured (RTX 5080, MR step): 3r6o twin I41 chosen over I4122 (Matthews + clash) in 1.8 s, solution
0.09 A CA rmsd from Phaser's; 11if P41 -> P43 1.2 s; 7ris P3121 -> P3221 1.7 s; 9zmu P6122 -> P6522
4.7 s; 11if's model against 3r6o's data NOT_PLACED.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The compiler's register choice held the fused decode/un-transpose/preprocess kernel to four
resident blocks per SM on the RTX 5080, where its shared buffer allows five. Asking for five
(48 registers, a small spill) takes it from ~391 to ~313 us per 18 Mpx frame (myob, -N 1, nsys,
repeated). sm_75 cannot hold five 256-thread blocks, so its code is unchanged (55 registers).
Output identical: ci tier base vs new, compare 0 changed, p.hkl identical on 26 sets, MTZ
reflection arrays identical on 49 files; [ImagePreprocessorGPU] passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The pools' indexers return their memory to the allocation pool when destroyed; without a
trim the next GPU test in the same process (AdaptiveSpotFinderGPU_RunToRunReproducible)
found the card taken and failed with out of memory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge branch 'wrongmodel' into rc175: wrong-model null step with every ci-tier battery run (fails until the false CC significance, the fragile fit gate and the rigid-body reserve are fixed)
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m27s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m45s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m22s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 14m23s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m13s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 21m8s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 22m17s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m5s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m21s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m7s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 17m49s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m26s
Build Packages / Generate python client (push) Successful in 34s
Build Packages / Build documentation (push) Successful in 1m18s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 16m41s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 21m17s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m21s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 21m15s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m35s
Validation Battery / Validation Battery (push) Failing after 57m2s
Build Packages / Unit tests (push) Failing after 1h23m44s
54993911f1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
IndexerThreadPool_FFTIndexerWaitsForGPUWorkBeside filled only the test thread's
card, but an indexer worker takes its card round-robin (pin_gpu: a process-wide
counter modulo the device count). On the CI runner, a container given all of the
host's GPUs, the probe's worker could land on a card nobody had filled, build its
indexer and fail "CHECK(probe.Run(x, recip).error != nullptr)"; the two checks that
expect a wait passed there for the same wrong reason. On a one-card workstation the
worker is always on device 0, so it never showed.

Every card is now filled, each under its own device, and released the same way, and
the fill is topped up right before each Run so memory given back in between by
anything else does not let the indexer fit. The pools are trimmed on every card at
the end. What the test checks is unchanged.

Run 20x alone and with the [CUDAMemHelpers],[AdaptiveSpotFinderGPU] group, also with
another process holding 4 GB of the card: all pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- An MR ENANTIOMORPH decision is taken into the answer's label only as --model takes a model's
  enantiomorph: ValidateAgainstModel runs on the placed model (in a scratch directory, removed after)
  and its adopted_model_enantiomorph - fit over its null, anomalous map at the atoms not inverted -
  decides; otherwise ENANTIOMORPH_VETOED. The hand-check numbers go to the MODEL PRIOR section.
- Rotation and translation functions on the GPU (MolecularReplacementGPU.cu): one block per
  orientation for the rotation function; per orientation the copies, the bin weights, a gather of the
  Crowther-Blow coefficients (CSR lists, fixed order, no atomics) and a cuFFT C2R, block-reduced
  statistics finished on the host in a fixed order. The CPU path (FFTW) is unchanged in what it
  computes; the orientation-independent part of the translation function (TranslationSetup) is now
  built once per group on either path.
- Rescoring: a placement clashing with its mates over twice the limit is not refined, one rigid-body
  engine per group, and the null is built only for the candidate the choice falls on.

Measured MR step on the RTX 5080 (before -> after, seconds; hand check extra where it runs):
3r6o 1.77 -> 0.67, neg 2.14 -> 0.37, 11if 1.20 -> 0.51 (+0.6 hand check), 7ris 1.46 -> 0.56 (+0.6),
9zmu 4.71 -> 1.07 (+0.9). Rotation 0.50 -> 0.02 s and translation 1.17 -> 0.13 s on 9zmu.
Mirrored-detector runs of two anomalous lysozyme sets: the hand check vetoes (map inverted at the
model's atoms); the unmirrored runs adopt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ENANTIOMORPH hand check ran a whole ValidateAgainstModel on the placed model: model read and frame
probe, a rigid body, a 9-replicate null (~0.5 s of the ~0.6-0.9 s), sigma_A, three maps written. The
decision needs only --model's rule: the model fits its null (the MR choice has already required its
own null of the same construction) and the anomalous map at the model's atoms is not inverted.

ReadModelAnomalousMap (ModelValidation) computes F_calc and F_mask to the data's resolution (GPU engine
where there is one), carries them through the bulk solvent MR scored the placement with (the overall
scale and anisotropic B do not move phases), and reads the anomalous map with the same code as the
validation: its coefficient construction and its reading at the atoms are factored out of Validate
(anomalous_coefficients, read_anomalous_map) and used by both, so plain --model is unchanged.

Hand check 0.6/0.6/0.9 s -> 0.027/0.028/0.040 s on 11if/7ris/9zmu; same ADOPTED/VETOED on all seven
cases tried (the three demos, two anomalous lysozyme sets and their mirrored-detector runs), with the
scatterer means and deepest troughs within 0.1 sigma of the full validation's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
gemmi's table lists some groups a second time on another origin ("P 42 21 2a", "P 21212(a)",
"I 2 2 2a", "C 1 21 1"). SameRotationGroups kept them as separate candidates, so the true group tied
with itself in R-free and the choice came out UNDECIDED: five single-copy sets of the --af-model
survey (and one partial-model set) ended there. A group equal to one already listed up to an origin
shift on the twelfths is now left out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A developer utility, not installed: the search, the decision and the hand
check run on the answer's p.mtz (and the twin hypothesis' p_twin_<group>.mtz)
that a rugnux run wrote, so a survey of the search over every dataset with a
predicted model takes minutes instead of a reprocessing per set. The hand
check of an ENANTIOMORPH decision moves from the rugnux driver into
CheckEnantiomorphHand, which both call; unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Three defects the battery's wrong-model null found (5 sets x 5 deposited models of unrelated proteins):

CC(model, data) significance. The per-shell sigma was Fisher's transform on independent reflections,
but the data and a scaled model share the falloff across a shell, the anisotropic B fitted to these
very data, the bulk solvent and the symmetry enhancement epsilon - whatever the model is. 21 of 24
wrong models read CC_MODEL_CONFIRMED_TO_D_MIN to the data's d_min (10-15 sigma in most shells).
The correlation is now formed on I/epsilon and |F_model|^2/epsilon, and its null is empirical: the
data at h against the model at h + d for the 26 nearest lattice offsets, which keeps everything smooth
in reciprocal space and loses what the model knows of h. SIGMA = (z - mean) / max(sd, 1/sqrt(N-3)) in
Fisher z over those 26, a new CC_NULL column shows the null, and a shell confirms only beside a coarser
shell that also clears 3 sigma (one shell alone did so once in 216 wrong-model shells). Prototyped on
the p_maps.mtz of a full open-arm run and the null runs: wrong models confirm 0/24 (was 21/24), the
deposited models confirm 197/200 (was 198/200 in the prototype's reading; the one lost has CC_MODEL
0.05-0.13 in every shell). The report no longer says a model cannot agree by accident.

MODEL_FIT gate. Accepted at 3.0 sample sds of a nine-draw null, as if the sd were known; an unrelated
P1 helical model on a P3(1) helical crystal was ACCEPTED at +3.05. Against 48 draws it reads +2.09 and
the best random placement fits as well as it does, and drawing the null about the cell axes instead
changes nothing (+2.15): the cause is the nine-draw sd, not the orientation. The gate is now 3 sigma
allowing for that: t(8) * sqrt(1 + 1/9) = 4.51. The deposited models of the open arm read +7.0 and up.
The indexing lead stays at 3 sigma (INDEXING_MARGIN_SIGMA): it is read only behind the fit gate, and
a deposited model settles one at +4.15.

Rigid-body GPU reserve. The finest zone's observations were counted by the data's d, but the zones
count them by the d of the index in the model's ASU, and a model's group need not keep the data's
metric (an I2(1)3 model on I222 data: 2320 in the zone against 1712 reserved), so the validation died
with "zone over the engine's reserve". Counted the zones' way, under every indexing probed.

Tests: ModelValidation_CCModelUnrelatedModelConfirmsNothing (Fisher alone reads +4.7 on an unrelated
model with an anisotropic falloff; the new sigma stays under 1), ModelValidation_WrongModelInAGroupOffTheDataMetric
(fails with the old count), ResultReport_ModelValidationSection (CC_NULL column, lone-shell rule).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The battery job ran the ci tier and the wrong-model null in one step with one exit status, so a red
job did not say which of the two failed. battery.py nullmodel runs the null on its own, in a run
directory whose report is just that section, and exits 1 when a pair failed; run --no-nullmodel keeps
it out of a --tier ci run. The workflow runs `run $SUBSET --no-nullmodel`, then (for a ci-tier run)
`nullmodel` as a separate step that runs whatever the first did, and keeps both reports. The ci tier's
own rows are unchanged; a plain `run --tier ci` still includes the null as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Run directories are scratch, and the paper tooling needs the runs it draws from to outlive them.
battery.py keep RUN copies a run's report.md, results.json and manifest.json as written into
tools/battery/results/<host>/<run>/ (outside docs/, which Sphinx publishes), the host and run name
read from the report. Open and in-house runs only: a run its manifest marks private, whose report is
report_PRIVATE.md or says PRIVATE ARM, or with an arm or row outside those two, is refused. A run of
which only report.md exists is kept with what there is.

The CI job now keeps results.json and manifest.json beside report.md, in
/data/battery/ci_reports/<run>/ and in the artifact, so a run on either host leaves what keep needs.
paper_plots.py resolves a RUN by name among the kept runs after runs_root.

Kept: the rc175 full battery on mpc2898 (20261011-1440_57fef1_rc175-57fef1c-full, all three files)
and on mx-aare-test (20261011-1236_549939_1.0.0-rc.175, report.md only; the other two are to follow
from that host). Both checked for the private arm's set ids and input directories.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The kept-results directories are named by the machine's role, not its host name:
tools/battery/results/workstation/<run>/ (1 socket, RTX 5080) and results/server/<run>/ (4 sockets,
4x T4). battery.py keep takes the role as --as; the kept files, untouched, still record the real
host and hardware. The server's rc175 full battery now has all three files (open + inhouse, private
false); checked for the private arm's set ids and input directories.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A run can make the same first pass several times - the canonical pass and the probes at the
geometry it stands at - and each of them that carried a near tie left its own re-check. The first
pass is deterministic in its inputs (FirstPassInputKey, the key the first-pass memo already trusts),
deciding late as deciding early, so a re-check whose pass read the same inputs (and the same
pre-pass flag) as an earlier one takes that one's outcome instead of running again.

Measured on 4p8r (8 re-checks, 5 on inputs of their own): re-checks 18.7 -> 13.5 s, p.hkl
identical. A diagnostic build that runs the skipped re-checks anyway and compares is used to
check the outcomes agree.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An optional `fixed` model: other atoms of the crystal held where they are -
the other copies of a molecular-replacement search - whose Fcalc is added to
the body's at every evaluation and whose atoms the bulk-solvent mask covers
together with the body's (computed once per zone, with the body where the
zone starts, and held). With them the origin is no longer free, so the gauge
projection is skipped. On the CPU the fixed part is gridded once per zone; on
the GPU the fixed atoms must be copies of the body, and each one's placement
is read off by Kabsch superposition and composed on the device with the
engine's own density, transform and mask (SetFixedCopies), so a fit beside
three copies of a 90 kDa model costs what a fit alone does.

Without `fixed` nothing changes (the composition kernel's extra term is null).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Copy number. The hard "Vm >= 4: MULTI_COPY, no decision" rule is gone. Copies
are searched one after another up to the largest copy number with a Matthews
probability of 1% (a Gumbel distribution with Kantardjieff & Rupp's 2003 mode
and mean, no resolution dependence); COPIES_LIKELY reports the most probable.

Sequential search. Copy n's translation function carries copies 1..n-1 as a
known partial structure: the cross term 2 Re(conj(F_known) F(t)) goes on the
same FFT grid (CPU FFTW and GPU cuFFT; KnownStructureFactors, AssemblyCorrelation).
Its peaks, past those that clash, are ranked by the correlation of the whole
assembly with E^2 (the TF itself, linear in |F|^2, rewards copying a placed
copy onto an origin shift), the first that stands out (TF Z >= 8), packs and
lowers R-free by 0.01 is kept; each copy is refined beside the others held
fixed (RefineRigidBody's fixed copies), and the first copy again beside the
second, the earlier ones once more at the end.

Packing: ClashFraction counts C-alpha clashes with other copies and all their
mates, binned (7mzt: 51 s of clash checks -> 1.9 s).

Search quality: rotation-function peaks hill-climbed off the 6 deg sampling
(6ze4: RF Z 10.4 -> 16.0, 3.3 deg off the deposited orientation -> 0.2); the
first copy goes down the TF list until three placements that pack are
refined, so a group is CLASH only where no offered orientation packs; groups
are searched side by side, and every group the best one leads is searched
again with the best one's copy orientations tried first, the better search kept.

tNCS: the native Patterson of E^2 (5-10 A) off the lattice; placed copies'
orientations join every further copy's search; and where the tNCS vector turns
one group's screw axes into another's (TncsRelatesGroups), a group whose search
stopped at fewer copies does not lose to the other - UNDECIDED. Measured on an
in-house tNCS crystal (vector c/3 along the 3-fold): both hands fit with three
copies to within 0.01 in R-free, and where one hand's search failed the other
was taken as ENANTIOMORPH; now UNDECIDED.

Null: the replicates are placed by the translation function in their own
orientation, as the search placed the real one (a null at the real centroid
reads high), and the gate is 4.51 = t(8) sqrt(1 + 1/9), 3 sd of a nine-draw
null, the gate --model's MODEL_FIT is now read at.

Speed: placements scored on the GPU structure-factor engines; further copies'
trials score with the first copy's bulk solvent held, the assembly refitted at
the end. Survey of 193 sets (jfjoch_mr_replay on stored merges, RTX 5080
shared with other jobs): MR median 1.15 s, max 24 s; demos 0.8-1.1 s.

Report: COPIES and COPIES_LIKELY columns, MODEL_PRIOR_COPIES.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge branch 'af-mr3' into rc175: --af-model searches several copies per asymmetric unit (Matthews probability, sequential translation function, fixed-copy rigid body, tNCS)
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m23s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m24s
Build Packages / build:portable:linux-x86_64:cuda (push) Failing after 12m20s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 16m11s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 19m38s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 22m18s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 23m27s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 17m12s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m57s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m3s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 23m18s
Build Packages / Generate python client (push) Successful in 39s
Build Packages / Build documentation (push) Successful in 1m18s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m7s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m24s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m53s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m44s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m58s
Validation Battery / Validation Battery (push) Successful in 59m36s
Build Packages / Unit tests (push) Successful in 1h30m17s
fcbe92042b
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The beam-stop projection (ShadowAccumulatorGPU) and the defective-pixel sums (HotPixelFinderGPU) are
now kept one per card, fed by workers pinned t % gpus like the other pre-scan workers, and folded
into the first card when they are read (cudaMemcpyPeer + an integer add / max-with-count kernel).
All of them are integer sums or a maximum with the first-value rule, so the fold is order-free and
the result is identical to one card's. With one card nothing is folded.

The pre-scan sample is read once and kept (compressed as stored; up to 2 GiB, a frame past that is
read again where it is wanted) for the spot measurement and the defective pixels, which read every
frame again each before - a CBF/marCCD/SMV frame was decoded on the host three times.

A beam-stop detection that finds no shadow leaves the mask as it was, so every probe copy of the run
repeated it on the same inputs. Its answer (shadow, ring centre, mean projection) is now kept in a
memo shared with the copies, keyed on the sampled frames, the geometry the rings are drawn in, the
polarization and the binary mask; up to 4 entries.

Debug TIMING lines for the pre-scan's parts (read and projection, beam stop, defective pixels, spot
measurement). New tests: two accumulators/finders folded match one fed every frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
battery: 2ygz leaves the open arm; 6oww accepts P 41 21 2 as an open question
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m30s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m4s
Build Packages / build:portable:linux-x86_64:cuda (push) Failing after 12m11s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 12m41s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 15m50s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 19m16s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 22m2s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 22m19s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m44s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m44s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m19s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m38s
Build Packages / Generate python client (push) Successful in 25s
Build Packages / Build documentation (push) Successful in 1m22s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m25s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m57s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m42s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m51s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m25s
Validation Battery / Validation Battery (push) Successful in 58m10s
Build Packages / Unit tests (push) Failing after 1h25m42s
230c5e3917
2ygz is a withdrawn PDB entry of the same protein as 2wnq, so it scores nothing that 2wnq does not.
6oww's deposited 8-chain P 1 21 1 model obeys the 4-fold and 2-fold the higher group adds, and its
|Fc|^2 correlate across the added 422 operators at 0.97-1.00, so P 41 21 2 is accepted beside the
deposited group - marked as an open question, as no refinement has compared the two.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The budget is checked before the read, so a frame that will not be kept is read into the worker's
reused buffer instead of a fresh one paged in and let go (a 16 Mpx CBF sample kept 29 of 60 frames
and paged in all 60), and a kept frame borrows the worker's reader scratch for the read instead of
allocating one of its own. The budget can now be passed by at most a frame per worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The group reduction reaches every observation through the group permutation, and so fetched one
32-byte sector per field for I, sigma, partiality and corr. I, sigma, partiality and prescaling_corr
now sit together in one aligned record, so it fetches two sectors per observation; the grouping
kernels' finiteness test and the combine read the same record. The per-observation fit coefficient,
its flag and 1/sigma are no longer stored: the per-frame fit forms them as it reads each observation
(the same expressions, so the same bits), which removes PrepScaleObsKernel and 13 bytes per
observation of device memory. DeviceBytes' per-observation figure drops by those 13 bytes.

Every sum keeps its order: p.hkl md5 identical to the base on cytc, 8a1a and 8qaw.
RTX 5080, nsys, merge-engine kernels: 8a1a 2459 -> 1924 ms, 8qaw 3624 -> 3025 ms, cytc 501 -> 476 ms
(ReduceGroupMeans 1.5-1.6x faster, PrepScaleObs gone, FitPerFrameG absorbs it).

Measured and not taken: storing all per-observation arrays in group order makes the reduction 3-4x
faster on its own, but the per-frame fit then has to gather, and moving 15 arrays at every grouping
costs as much as the reduction saves (net 0 on the 5080); a record for the combine-only fields
did not speed the combine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A developer tool, not built or installed upstream. It also failed to link in both portable builds
(static cuFFT without the device-link step on Linux with CUDA, ws2_32 on Windows).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Both run on the same runner machine and its one GPU. In run 380387 the unit-test job reached
Rugnux_FirstPassMemoMatchesRecomputation while the battery job (same runner, same minute) was
processing one of its larger sets; the FFT indexer's cuFFT plan found 0.1 of 15.6 GB free and the
test failed, as a GPU out-of-memory is meant to. The unit-test job joins the battery's concurrency
group, so the two no longer overlap (which also keeps the battery's timings clean).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
- Likelihood (Read 2001; Z-score over random positions as McCoy et al. 2005): log-likelihood gain of
  Rice/Woolfson E distributions, sigma_A from the pLDDT coordinate error with its scale refined, to
  2.5 A (or the 20000 lowest-resolution reflections). Further-copy TF peaks ranked by the gain on the
  search's reflections (peaks that lower it dropped); a copy whose TF Z is below 8 also stands out where
  its refined likelihood Z-score reaches 8 - only where the copies placed are as many as Matthews makes
  most likely. The likelihood is computed only where a translation function does not stand out.
  Copy structure factors exact from one FFT of the cell's grid, or read off the molecular transform
  where it reaches the likelihood's resolution.
- PAE domains: the AlphaFold DB's predicted_aligned_error JSON beside the model (same name pattern) is
  read automatically; average linkage on the symmetrised PAE (10 A, domains >= 40 residues). Where the
  whole model is placed nowhere, each domain is searched by itself side by side; placed domains must
  agree, and a domain alone may only confirm the answer's group.
- Twin comparison: where the answer's merge chose and the twin hypothesis' merge exists, the chosen
  placement read as a crystal in the subgroup (its symmetry images as copies, the subgroup's setting
  found from the two cells) against the model searched for afresh in the subgroup, both against the
  data twinned by the law (fraction refined on the working set, Yeates 1997); the subgroup is taken by
  a 0.03 R-free lead plus its own null/margin -> TWIN_SUBGROUP, written to p_mr with law and fraction.
  With --af-model rugnux makes the subgroup merge wherever a metric re-ask promoted the indexed group.
- tNCS rule: a group with fewer copies beside tNCS (screws bridged by the vector, or the enantiomorph)
  no longer leaves the choice undecided where the chosen copies, carried into it and refined, fit
  worse by 0.03 in R-free.
- Speed: the molecular transform's FFT on the GPU (cuFFT, serialised), no rigid body for trials that
  lower the likelihood, re-search only with tNCS, PAE domains searched in parallel.

Survey (193 sets, MR replay on stored merges): single GREEN 58 -> 68, multi 48 -> 58, partial 8 -> 9;
4bwl (and, in full runs, 2wnq and a withdrawn entry of the same protein) TWIN_SUBGROUP P21 instead of
a wrong AGREES; a P21212 set with tNCS now SPACE_GROUP P21212 instead of UNDECIDED; no wrong call.
CPU and GPU builds make the same decisions on 18 demo/multi-copy/twin sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
For every open-arm set where rugnux and the deposition differ in symmetry (sym_over, sym_screw,
screw_undetermined, accepted alternatives) and the sets the twin review flagged, an independent
assessment by established programs is now kept beside the manifest in
tools/battery/symmetry/<id>/: a summary.md and the trimmed POINTLESS and ZANUDA logs. Each row
names its directory in "symmetry_evidence". Nothing reads this for scoring, and no accepted or
rejected answer changes.

symmetry_evidence.py makes the evidence for one set. It re-runs the battery run's binary with the
unmerged export (p.hkl must equal the run's) and runs POINTLESS on the observations. It merges
them with AIMLESS in the deposited group and the model's setting, then runs ZANUDA with the
deposited model on three free sets, each chosen per lattice-holohedry orbit. It runs POINTLESS and
ZANUDA again on the depositor's structure factors, and tabulates the axial reflections of both
data sets for screw questions. ZANUDA's preference can move with the free set (6toc), which is why
there are three.

What the programs found, reported neutrally: an exact pseudo-symmetry, a twin or another effect
can produce any of these results, and the aim is only which answers the battery accepts.
6oww: ZANUDA prefers P 43 21 2 to P 21 on all four data sets, with lower R-free. 6toc and 8xtg:
ZANUDA prefers the higher group, but R-free is mixed (6toc) or favours the deposited group
(8xtg). 6pxb and 8xte: ZANUDA keeps the deposited P 32, and the deposited model in the higher
group refines far worse. 3r6o, 4bwl, 2wnq, 8c3e: the deposited model gives the higher group no
support. 5ebi: ZANUDA cannot reproduce the deposited fit. Screw sets: see each summary.

Run on rc175 fcbe920 battery run 20261012-0139 with CCP4 9.0.014 (POINTLESS 1.14.2, AIMLESS
0.8.2, ZANUDA 1.097 / REFMAC 5.8.0431).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The spot-width stage of the pre-scan copied every preprocessed frame back to the host (72 MB a
frame on a 16M detector, page-locked host mirrors per worker) for measurements that read a few
pixels about each spot. Now:

- MeasureSpotsOnTiles: the flux curves and the footprint read (2*24+1)^2 squares about each spot,
  gathered on the card (ImagePreprocessorBufferGPU::GatherTiles) - no copy_image_to_host, no host
  mirror. The flux curves are bounded at R_BKG_OUT = 20 px. The footprint keeps its arithmetic; a
  per-iteration check that its window is inside the square, and a spot whose window walks off it is
  measured again from scratch on an 80 px square, then on the whole frame. Where the squares would
  be more pixels than the frame, the frame is read. Same pixels in the same order, so the same bits.
  Harness over 34 rotation sets (~1500 frames): 0 differences; misses at 24 on a few spots of most
  frames, at 54 on up to 80% of frames of a few sets, at 80 on none.
- The azimuthal mapping and the per-worker spot engines are built while the frames are read.
- The tiers no longer wait for each other: workers run on through the visit order, each tier's
  answer is drawn from exactly its own frames, and frames measured past the tier the answer stops
  at are thrown away - a frame is measured the same whichever tier it is read for.
- PreScanMemo keeps the spot measurement, the defective pixels and the background gain under the
  pre-scan key plus the spot-finding and azimuthal settings, so a copy of the run that pre-scans
  again on the same inputs reads no frames.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The search was CPU-bound: on a 54-chain partial complex ~80% of all CPU samples were the score's
FitModelScale grid (and libm's exp under it), with the GPU busy 13 of 37 s.

- score_placement fits its scale on the GPU where its structure factors are made there
  (ModelScaleGPU's FitSolvent / Fit, as the rigid body already does); equal to rounding, the CPU
  build unchanged.
- A model whose expected log-likelihood gain (sum sigma_A^4 / 2, McCoy et al. 2017; sigma_A^2 the
  part of the scattering one copy is by Matthews, times Luzzati's D^2) is below 10 is not searched
  for. Over the 193-set survey the lowest eLLG of a placed model is 26; every PAE domain below 24
  placed nowhere, so no decision changes.
- The likelihood setups made where first needed, one serving both where the reflections are the same.
- After a candidate fails its null, the other placed candidates' nulls side by side.
- The first copy's placements refined side by side on an engine each; the groups' translation
  functions side by side; CopyFactors and KnownStructureFactors over threads; the further copies'
  CopyFactors and gains for the peaks walked all at once; the known-structure TF setup made while the
  first copy is refined. All exact.

Survey replay (193 sets): no decision changes; two R values differ in the 3rd decimal (GPU scale).
Alternating runs, MODEL_PRIOR_SECONDS base -> new: 54-chain complex 38.3 -> 14.7, 4mtk 18.3 -> 9.7,
9v16 13.7 -> 8.3 (shared machine, indicative).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The null a placement must beat turned the whole assembly about its common
centroid, so with several copies it also moved them off the arrangement the
search found. Under tNCS, two parallel copies a tNCS vector apart fit the data
whatever the model is, and the null threw that fit away: wrong deposited models
reached TF Z 14-47 and 8-34 sd over the null on tNCS crystals, and two of them
decided the space group (SPACE_GROUP, a reflection file in the wrong group).

Each copy is now turned about its own centroid, all by the same rotation, so
only the model's own orientation is random. On a control of 38 open-arm sets
(deposited chain randomly re-oriented as search model, 3 unrelated deposited
models and a fold-scrambled decoy per set) the wrong models' null falls to
1.4-4.2 sd on the tNCS sets (one at 7.2, left UNDECIDED), both wrong decisions
become NOT_PLACED, and no right-model or AlphaFold-model decision changes
(nulls stay 15-107 sd). The 193-set AF survey replay loses no GREEN (two
UNDECIDED become NOT_PLACED); the four twin/tNCS targets stay correct.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Completes c6912e2a0 (whose message was a placeholder). What the two commits do:

- tNCS by half a lattice vector v: copies v apart weaken the reflections with h.v half-integral, and a
  placement moved by (v + o)/2 (o an origin shift of the group) fits the strong ones exactly as well.
  The candidate the choice falls on is compared with every such move of itself (rigid body refined,
  packing) by R over the weakened reflections only; a move better by 0.01 is taken instead, and where
  the two best are within 0.01 the merge is UNDECIDED.
- With tNCS, the group the merge was made in, searched to fewer copies than the chosen group, keeps
  the merge UNDECIDED (not for its enantiomorph, which the hand check decides): its first copy alone
  can sit at half the vector, and no placement of the chosen group, which may sit there itself, rules
  it out.
- P1: the first copy sits at the origin, which no function of its position can single out (the
  translation function is flat), so one copy stands out where its rotation-function Z reaches 8; the
  null then judges it as before.

Checked against the deposited coordinates (placement comparison, symmetry/origin/setting matched):
two half-vector-shifted placements that had been decided (a SPACE_GROUP in the wrong setting, an
AGREES off by a/4) are UNDECIDED; two UNDECIDED tNCS sets are now AGREES with confirmed placements;
the nine confirmed tNCS placements and the ENANTIOMORPH stay; a P1 single copy with a perfect-match
prediction is placed (confirmed), a wrong PAE-domain placement in P1 (RF Z 6.4) is not.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Drops the rule of af796d0a6 that the merge's own group searched to fewer copies always leaves the
choice UNDECIDED: it also caught a correct SPACE_GROUP decision (a target set; the half-vector move
check already catches the wrong one it was meant for). Instead, the chosen copies carried into a
tNCS-related group rule that group out only where the chosen placement fits the data itself
(R-free below 0.50): a perfect deposited model placed in the wrong group at R-free 0.520 had ruled
out the deposited group by 0.036; it is UNDECIDED now. The carries that decide in the survey all
start from R-free 0.36-0.47 and lead by 0.04-0.10.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The mapping is now built while the frames are read, and with every core it took them from the
readers - on a 16M EIGER the read went from 0.10 to 0.19 s. Built on AvailableCpus() minus the
readers it is back at 0.11 s. The mapping is per pixel, so the same whatever thread count builds it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Files panel never showed files written after a directory was first
listed when they came from another host (a detector writer on NFS/GPFS/
Lustre), because QFileSystemModel only learns about changes through its
inotify watching, and there was no way to refresh it by hand.

F5 (window-wide, only while the panel is visible) and a refresh button
next to the root's browse button re-list the root and every expanded
directory. A timer does the same every 3 s while the panel is visible
and no mouse button is held, but re-lists only directories whose mtime
changed since the last listing, so a steady tick costs one stat per
expanded directory.

QFileSystemModel has no rescan call; index() of a path it does not know
adds that row in place, so only the names missing from the model are
passed (index() is ~10 us a call; a CBF sweep is thousands of files).
Selection and expansion are untouched, new rows are sorted into place,
and the first visible row is kept where it was when rows land above it.
Entries deleted by another host still stay listed; local deletes are
seen by the model's own watching as before.

Measured offscreen on a 5000-file directory: unchanged tick ~1 ms,
re-listing the changed 5000-file directory ~5 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
From the nsys/perf profiles of the copy chain (GPU busy 39% between copies, the thread mostly in host
work and syncs):
- The known-structure translation function's lists, device arrays and cuFFT plan are made once per
  group (MolecularReplacementGPU::Prepare) instead of on every copy (~260 MB of uploads and a plan
  per call before).
- The known copies' CopyFactors and likelihood, which rank the peaks, are taken while the GPU runs
  the translation function.
- The structure-factor engines are made per entry, without one lock held across all groups' creation.
- The transform's index wrap divides only for an index outside the grid (CPU and GPU).
All exact: same arrays, same kernels, same order. 4mtk MR, alternating 3 pairs: 9.5 -> 9.0 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
- The option is --mr <file>. What kind of model it is is read from the file: a prediction where it
  is ModelCIF (_ma_qa_metric) or has no cell and one B value per residue in 0-100 (AlphaFold PDB/mmCIF);
  an experimental structure otherwise. A prediction is handled as before (pLDDT >= 70 trim, B from
  pLDDT, PAE domains where the AlphaFold DB's PAE file sits beside it). An experimental structure is
  taken as its polymer - no waters, ligands, hydrogens or alternative conformers, occupancy 1, isotropic
  B as refined - with a coordinate error of 0.67 A (a prediction's at pLDDT 90) for sigma_A; a B column
  above 100 no longer stops the search.
- The report section is MOLECULAR REPLACEMENT (EXPERIMENTAL), its keys MR_* (MR, MR_MODEL_FILE,
  MR_MODEL_KIND, MR_DECISION, MR_SPACE_GROUP, MR_COPIES, MR_HAND_CHECK, MR_SECONDS, ...); the <prefix>_mr
  outputs are unchanged.
- The twin hypothesis' merge that is made only for the model to test (a metric re-ask promoted the
  indexed lattice's group) is written and reported only where the model chooses the subgroup.

Raw deposited coordinates of nine control sets, unconverted: every one placed and confirmed against the
deposition (all chains as one body), the groups the same as with the prediction or the prepared model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
During a collection every dataset tick ran JFJochViewerDatasetInfo::UpdateLabels(), which
clear()ed the plot-type combo and re-added every item, so an open dropdown lost its hovered
row and scroll position on each tick and picking a plot type was a race against the next
update. The same clear-and-refill ran per tick or per image in the calibration window's
calibration combo, the ROI list combo and the powder calibrant combo; the last two also
threw away the user's choice (the calibrant was reset to the first entry on every image).

New LiveComboBox::SetItems() takes the full item list and leaves the combo untouched when the
list is unchanged. When it does change while the popup is open, the change is held back and
applied (queued) once the popup window hides, so a click that closes the popup lands first.
A replacement keeps the selection by item data, blocks signals, and a held-back one emits
itemsReplaced() so the owner can re-plot.

The calibration window now re-shows the selected calibration on a dataset tick instead of
always reloading the pixel mask over it.

Tested offscreen: a timer fires datasetLoaded() every 25 ms while the dropdown is open; the
popup and hovered row survive, newly available indexing metrics are held back until the popup
closes, and the picked type sticks and is what the chart plots. The rc175 code fails the
hover checks of the same harness.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CHANGELOG: rc.175 names --mr, which takes predicted and experimental models
Validation Battery / Validation Battery (push) Canceled after 0s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m32s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m59s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m0s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 18m30s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m33s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 20m38s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 23m30s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 24m37s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 24m55s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m41s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m44s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m10s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m2s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m50s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m42s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m3s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m16s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 20m0s
Build Packages / Unit tests (push) Failing after 1h25m11s
f567771d56
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The scale fit of every model-validation fit (fit_model: the real model, the indexing probe's relabellings
and every null replicate) was FitModelScale on the host - a long-double Levenberg-Marquardt per solvent grid
point, ~80% of the validation's CPU on a 400 A cell. It now runs on the GPU through ModelScaleGPU::FitSolvent,
the engine the rigid body and molecular replacement already use, from a small pool (ModelScaleGPUPool: up to
four engines with their own streams, sized on the card's total memory, at most an eighth of it). The grid,
the fits and the winner rule are FitModelScale's; the sums are taken in double on the device, so R-factors
agree with the host's to rounding. The CPU build, and a GPU build without a card, keep FitModelScale.

The change-of-basis probe gridded each candidate frame with gemmi's serial put_model_density_on_grid, once
for the data as merged and once more for each alternative indexing. It now grids each frame once, with
ModelGrid's parallel PutModelDensityOnGrid (the same grid, bit for bit), from threads outside the pool so the
gridding spreads over it, and scores every labelling on that F_calc. Exact.

4nwv (122k atoms, I222, 400 A cell): model validation 33.2 s -> 14.0 s; every logged R, sigma and decision
the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
One --model now does the right thing by itself:
- A prediction (AlphaFold/ModelCIF, read from the file) goes straight to molecular replacement.
- An experimental structure is placed by the rigid body as before. The null now runs on every
  --model run (no NOT_TESTED): "placed" = the fit beats its null (4.51 sd) in the answer's merge
  or in the twin hypothesis' merge. Placed: behaviour unchanged (hand and reindex may go into
  p.mtz); a subgroup the model fits and prefers is written as <prefix>_mr.
- Not placed (or no crystal cell): molecular replacement with the experimental-model path. It may
  relabel the hand into p.mtz past the anomalous veto, never reindexes; another group or the twin
  subgroup goes only to <prefix>_mr. Maps, R-free and CC come from validating the MR placement with
  every decision switched off; MODEL_FIT stays the rigid body's verdict.
- The twin hypothesis' merge is made in memory with a model where a metric re-ask promoted the
  group or the model's group has fewer rotations than the adopted one; written and reported only
  where the model chooses it.
- --mr is kept as "--model, searched for by MR straight away".
- Report: MODEL_PLACEMENT= RIGID_BODY|MR|NOT_PLACED, MODEL_DECISIONS_TAKEN gains MR_ENANTIOMORPH,
  MODEL_CHOICE_FILE_PREFIX/SPACE_GROUP; the MR keys move into 5. MODEL VALIDATION; MODEL_FIT absent
  where no rigid body ran. MR NOT_PLACED is never a warning.
- Battery wrong-model step: check (c) also fails a wrong model placed by MR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The rotation predictor used to download every predicted reflection into pinned memory, copy it into
the host vector, build sort keys, std::sort them, gather the 88-byte structs into scratch and copy
them back (OrderOutput), and - on a frame over output_limit - partial_sort the structs themselves
(TruncateToOutput). On a large cell that is a quarter to a third of the image-loop worker CPU
(profiles of 4nwv and 7qij: TruncateToOutput 12-16 %, OrderOutput 9-13 %, plus the memmoves).

Now the same order is built on the device with stable cub radix sorts of an index: delta_phi, then
the packed hkl (OrderOutput's h, k, l, delta_phi), then - over the cap only - partiality descending
and excitation error ascending (TruncateToOutput, whose hkl tie-break the stable sort keeps). Only
the kept rows are gathered and downloaded. Floats are keyed by an order-preserving integer map with
-0 made +0, so the key order is the comparators' order; the only possible difference from the host
is between two solutions of the SAME hkl with bit-identical partiality and excitation error, whose
relative order partial_sort leaves to its heap and the stable sort to delta_phi.

The device buffers grow with half again of slack, so a frame predicting a few more than the last
record (the supercell probe's frames above all) no longer reallocates and predicts again; the host
side is sized for what is returned, at most output_limit.

Test: BraggPredictionRotGPU_DeviceOrderMatchesHost holds the device output against OrderOutput's
order and against TruncateToOutput applied on the host, at four caps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The CBF reader decoded every frame's x-CBF_BYTE_OFFSET stream on the worker thread - 19-30 % of the
image-loop worker CPU on CBF sets (7qij, 4mtk) - and the 4-byte image was then uploaded. ReadRawImage
now hands over the stream as the file stores it, in the image's own buffer, as a new read-only
CompressionAlgorithm::CBF_BYTE_OFFSET (mode Int32, what it decodes to), and ImagePreprocessorGPU
decodes it on the card into the same int32 image and runs the same preprocessing kernel on it.

The device decode (CBFDecoderGPU) cuts the stream into 256-byte tiles. A token is 1, 3, 7 or 15 bytes,
so a tile can be entered at 15 offsets; for each one thread works out where the decode leaves the
tile, how many pixels it emitted and their sum modulo 2^32 (the walks from different entries meet
within a few bytes, after which they are one walk). An exclusive scan composing these maps gives each
tile its true entry, first pixel and running value, and a second walk writes the pixels. A stream
that ends before the image is refused as on the host.

The host decoder moves to compression/ (DecodeCBFByteOffset) and is reached through
JFJochDecompressPtr, so every host consumer - the CPU build, the azimuthal-integration worker, the
pre-scan's shadow and gain passes - decodes exactly as before, now from the stream. It is the CPU
path, not a fallback: a frame the device refuses is not retried on the host. A process file written
from CBF input stores the decoded int32 image, as it always did. .cbf.gz is still inflated on the host.

Tests: CBFDecoderGPU_MatchesHost (every token size, 0x80 inside payloads, escape runs across tiles,
trailing CIF text), CBFDecoderGPU_ShortStreamIsRefused, ImagePreprocessorGPU_StoredEncodingsMatchCPU.
Checked locally against the host decoder on 36 real frames of 9 open-arm CBF sets (signed 32-bit,
signed and unsigned 16-bit, plain and gzip): identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The marCCD reader read each frame's 16-bit scanlines into scratch and widened them to int32 on the
worker thread (marccd::ReadInto, ~20 % of the image-loop worker CPU on 4nwv), and the 4-byte image was
uploaded. ReadRawImage now reads the scanlines straight into the image's buffer and hands them over as
a new read-only CompressionAlgorithm::UINT16_WIDENED (mode Int32): two bytes a pixel cross PCIe, from
page-locked memory, and the preprocessing kernel widens them itself.

It is not CompressedImageMode::Uint16, which calls 65535 an error pixel; a marCCD's 65535 is its
saturation marker. So the kernel compares each pixel with int32's error marker and the int32 saturation
cap - the comparisons are made on the pixel's value, in int64, rather than in the storage type - and
decides every pixel exactly as for the int32 image the host would have widened it to. The host widening
(JFJochDecompressPtr) is the CPU path.

Test: ImagePreprocessorGPU_StoredEncodingsMatchCPU, with a frame carrying 65535 at a saturation of
65535, against the CPU preprocessor on the host-widened image.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The two passes over every integrated reflection that open the scaling step ran on one
thread: 1.2-1.3 s on a 78 M-reflection sweep, on the critical path before the scaling
engine starts. One image per task now; the counts are integers and each reflection's
ice flag depends on itself alone, so the result is the same at any thread count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
With the GPU resident, the host ingest swept the source reflections four times (background
and h histogram, sort keys, the in-range build, the device staging), bucket-sorted the keys
and cut the raw-hkl runs: 5-6 s of host time per engine on a 78 M-reflection sweep, run
twice beside the quality guard. Now the frames' reflections are copied once, through two
pinned buffers, to the card, where the per-frame background is summed, the reflections
are ordered by two stable radix passes ((image, index), then raw hkl - the one order the
host's total comparator gives), cut into runs by run-length encoding, filtered by the
resolution range and compacted. The observations, the runs and their order are the host
ingest's to the bit; a new GPU test checks the layout against the host's sort and cut.
The geometry smoothing still runs on the host in this step, on arrays read back.

The all-observation arm's engine is cloned after the main engine's device ingest (its
DeviceBytes are known only then) and copies the runs on the card as well. DeviceBytes now
also covers the ingest's own peak (90 bytes per reflection, 64 per run before the cut).

Measured: p.hkl and MTZ data identical on cytc and 7qij.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Two waits on the critical path of a canonical rotation pass go:
- the post-refinement gather (2-3.5 s on a 78 M-reflection sweep) now runs in the task that
  fits it, beside the chain; it reads only the reflections, and the chain waits for it before
  the space-group search, the first step that re-indexes them;
- with one GPU the quality guard's merge used to have the card to itself while the main
  engine waited. It now runs beside the main engine wherever both fit the card's total
  memory (the main engine's DeviceBytes plus its reflection share for the guard, plus the
  3 GB reserve the all-observation arm uses); otherwise it runs first as before and the
  main engine is ingested again after it. The main engine's first merge holds its
  per-frame write-back until the guard has read the outcomes (as the arm on a copy does),
  and the guard's numbers are recorded where the pass first needs them.

Same merges, same numbers; only when the guard and the gather run changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The rest of the resident ingest moves to the card: the delta_phi re-solve against each
frame's smoothed lattice, the one exact-Bragg angle per rocking event, the partiality
recomputed from the smoothed mosaicity (with corr brought to it) and the histogram of the
frames an event spans. The host keeps the per-frame work (the smoothed lattices, the
mosaicity window) and reads back only corr; nothing per observation crosses back, and the
host arrays the smoothing worked on are no longer built on this path.

The integer work is the host's to the bit (the event-span histogram). The floating-point
work differs in the last bits: every rounding on the card is explicit (no contraction, so
the same bits on any GPU), the host's compiler fuses some of the same products into sums,
and erf/atan2 come from the CUDA library. This is the one place in the lever where results
change; see the hand-back report for the sets and metrics it moves.

Measured on 7qij (main engine, 78 M reflections): the whole ingest 6.5 s host + 1.3 s device
before this lever -> 2.9 s, of which 2.4 s is the upload (memory-bound, beside the
post-refinement gather) and 0.25 s this smoothing. Same space group, error model,
resolution and reflection count; IMEAN differs at the 1e-7 level except on near-zero
intensities.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The gather and the engine's ingest both read every reflection, and side by side they split
the memory bandwidth and the worker threads; the engine's ingest is what the chain waits for.
The gather now waits for it (or for the pass to end) and runs beside the first merges
instead, which leave the host mostly idle. 7qij main ingest: 2.9 -> 1.1 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
On a set too large for the quality guard's engine to sit beside the main one (8qaw: 123 M
observations; 8a1a), the main engine was ingested, found not to leave room, dropped and
ingested again after the guard - several seconds of ingest for nothing. The pre-pass's
engine merged the guard's very frames earlier in the run, so its card memory per reflection
is now kept (prepass_engine_bytes_ / prepass_engine_reflections_) and both engines are
reckoned from it before either ingests: the guard at its frames' reflections, the main
engine at all of them (observations add in proportion; rocking events and distinct hkl at
most in proportion). Beside where the two fit the card less the 3 GB reserve, otherwise
the guard first. The re-ingest path is gone.

7qij, 9v16, cytc: beside; 8qaw, 8a1a: guard first. Same merges, same numbers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The two passes read every integrated reflection and are memory-bound once on all threads;
the ice flags are now set in the overload sweep. 7qij, 78 M reflections: 0.4 s for both
(1.2-1.3 s serial before this lever). Same counts, same flags.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The CC(model, data) table's rows ('#      4.13 ...') were read as section titles, so every key after
the table in 5. MODEL VALIDATION counted as a decision outside it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every merge downloaded the fulls' frame and group keys and built both CSRs by serial counting
sorts on the host - four passes over the fulls, two of them scattering into an array as long
as the distinct hkl (15 M on 4nwv) - and uploaded them again. They are now stable radix sorts
of the resident fulls on the card, with the counts summed there; the host gets the counts per
frame and the group CSR it walks itself. The same permutations (stable sorts list each frame's
and each group's fulls in index order, as the counting sorts did): p.hkl identical on cytc
and 4nwv.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first device decoder walked 256-byte tiles, one thread each, straight from global memory: on a
PILATUS 6M frame (~6 MB of stream) that is ~24k threads walking alone, and the two walks took
~0.5 ms of the card per frame - with the CBF decode gone from the workers the card is what bounds
the 7qij loops (kernels 67-78 % busy). Now each thread walks a 68-byte part that its block has read
into shared memory together (68, not 64, so the threads of a warp do not read the same banks), the
scan runs over tiles of 8 parts whose maps are composed first, and the write kernel stages a block's
pixels in shared memory and writes them out coalesced.

Measured on 36 real frames of 9 CBF sets (nsys, average per frame): 0.41 ms against ~0.6 ms before.
Output unchanged: the same tests, and the same 36 frames identical to the host decoder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A UINT16_WIDENED frame (marCCD) was page-locked where it lay with PinInputRegion, which keeps the
registration until the engine moves on to another frame. The pre-scan hands its kept frames to the
spot measurement and to the defective-pixel search at once, each with its own engines, so one
engine could unregister a frame while another engine's copy from it was still in flight. That
takes the context down ("unspecified launch failure"), seen as a rare crash at the end of the
pre-scan on a 3072x3072 marCCD sweep (1 run in 30 reproduced it).

The stored bytes now go through the engine's own page-locked staging buffer, as the CBF and BSLZ4
decoders already do. The pixels decoded are unchanged.

Test: ImagePreprocessorGPU_SharedStoredFramesManyEngines - four engines upload four shared marCCD
frames concurrently; it aborted with the launch failure before the change and passes after it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
ModelValidation_ReindexesTheDataIntoTheModelsIndexing still expected no null for data already in
the model's indexing, as when the null ran only where a decision was pending. Since the unified
model validation it runs on every validation whose schedule asks for it (fit_test, the default),
and is skipped only where the verdict is another validation's; the test now checks that the null
ran and that the model fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The three cytochrome c rows take P 32 2 1 (154) from their reference models, whose 2-fold holds to
measurement noise; XDS's label carried an arbitrary hand. The why-texts of 6oww, 6toc, 6pxb, 8xte
and 8xtg now quote the tools/battery/symmetry evidence; no accepted answer changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nvcc contracts a*b+c into an FMA by default, and decides it separately for every architecture it
compiles for. The same source therefore ran to different bits on a T4 (sm_75) and an RTX 5080
(sm_120), and a run that crossed a knife edge came out differently on the two machines: a rotation
set made again after a near-tie overturn merged with multiplicity 19.0 / R_meas 75.1% on the T4
server and 20.1 / 82.2% here, the delta-CC1/2 frame rejection removing 33 frames on one and 14 on
the other. A build here with only compute_75 code reproduced the server's numbers to the digit.

With --fmad=false for all CUDA sources the sm_75 and sm_120 builds give the same p.hkl on that set.
The host side has been built with -ffp-contract=off for the same reason; this extends it to the
device. Kernel time on a large HDF5 set: 48.3 -> 48.6 s (+0.7%), no measurable wall change.

This changes the bits of every GPU run, so every battery row moves; it is not an exact change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The --mr line now says what the unified --model flow does for a user: a predicted model is
accepted, and a model the rigid body does not place goes to molecular replacement, with --mr
forcing it. One viewer line for the Files panel refresh (F5 and the poll) and the combo boxes
that no longer reset during live collection. Owner-requested edit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The host decoder wrote each pixel with _mm_stream_si32 and ended with _mm_sfence, to keep the
decoded frame out of the cache on its way to the GPU by DMA. The GPU now decodes CBF itself, so
the host decoder only serves the CPU engines and the tests, and the project rule is no SIMD
intrinsics. Plain stores; the values written are the same.

CBFDecoderGPU_MatchesHost, CBFDecoderGPU_ShortStreamIsRefused,
ImagePreprocessorGPU_StoredEncodingsMatchCPU and the CBF reader tests pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
None of the eight GPU test cases in the file (BraggPredictionGPU*, the CPU/GPU consistency
cases and BraggPredictionRotGPU_DeviceOrderMatchesHost) checked for a device, so a CUDA build on
a machine without one failed them instead of skipping. Same guard as the other GPU tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
RUGNUX_ADVANCED's --model page described the rigid body alone. It now says how the unified flow
places a model: an experimental model by the rigid body, checked against its null in the answer's
or the twin hypothesis' merge, and by molecular replacement where it is not placed; a prediction
straight to molecular replacement; --mr skipping the rigid body; MODEL_PLACEMENT. It says what a
placed model may change in <prefix>.mtz (the hand, and from the rigid body an indexing-ambiguity
reindex) and that another group or the twin subgroup the model chooses goes to <prefix>_mr.mtz/.cif
with <prefix>_mr_model.pdb/.cif. --mode scale places by the rigid body only (no molecular
replacement, --mr refused); a full run does molecular replacement on serial stills too (checked:
a stills run with a predicted model reports MODEL_PLACEMENT= MR). The options table gains --mr
and the --model row the fallback.

RUGNUX_INTEGRATION no longer says rugnux does no molecular replacement, and ACKNOWLEDGEMENT names
--model beside --mr for predicted models.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The ingest term was 90 bytes per reflection and 64 per run. Measured with cudaMemGetInfo at each
step of IngestUpload/IngestSort (relative to the free memory before the observation arrays):

- the second radix sort is the peak where runs are few: the 66 B of observation arrays, by_image,
  perm, key, key_sorted and cub's alternate key/value buffers of a plain SortPairs - 102.3-103.0 B
  per reflection on 7qij (51.1 M, 52.2 M, 78.1 M reflections) and 4nwv (32.7 M);
- the cut is the peak where runs are many: 98 B per reflection plus 56 per run (4nwv, 15.4 M runs:
  125.8 B per reflection, 4106 MB against 3923 MB from the old formula);
- the upload holds the 66 B beside the two 64 MB staging buffers (~150 MB measured).

Now max(103 B per reflection + 58 B per run, 66 B per reflection + 160 MB): 2-4% above every
measured peak, where the old formula was 10% under on 7qij (7170 MB against 7986 MB for the main
engine) and 4.5% under on 4nwv. Its only consumer is whether the all-observation arm goes beside
the main engine, which changes when that merge runs, not what it gives.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Owner decision: about a second slower is preferred over 2.5-4 GB more VRAM. With one GPU the
quality guard's merge is launched ahead again, beside the post-refinement and the main engine's
host ingest, and joined before the main engine's device ingest, as before 4158c48df/095814bae;
the per-reflection placement estimate (prepass_engine_bytes_/_reflections_) and its share in the
all-observation arm's memory check go. Kept from those commits, being independent of where the
guard sits on one card: the post-refinement gather in the fit's own task, the first merge holding
its per-frame write-back (rather than waiting) for a guard on a second card, and the guard's
numbers recorded where the pass first needs them.

Measured (RTX 5080 16 GB, base = f853596, alternated, 2 runs each; machine load ~13 on 32 cores):
  VRAM peak  7qij 14.7/15.2 -> 13.3/12.2 GB, 9v16 15.1/14.6 -> 12.3/12.8 GB
  wall       7qij 47.1/39.0 -> 44.1/42.5 s,  9v16 40.3/37.4 -> 36.8/38.2 s (within the noise)
  guard merge alone 4.4-4.7 -> 1.8-1.9 s (7qij), 3.7-3.8 -> 1.6 s (9v16)
p.hkl identical on both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A stored sweep is all there when the run starts, so a first pass now decides a near tie on its
validation frames and the later frames together at once, instead of deciding on the validation frames,
re-checking the pass after the run and making the run again where the re-check ends elsewhere. Live
(following a collection from the broker) keeps that early path unchanged: ProcessConfig::live.

The pre-pass decisions - the rotation-scale walk's refits, the demoted metric-symmetry arm, the
integration radius - stay on the pre-pass frames offline too, with their near-tie re-checks after the
run; only the run made again decides them on all the frames (decide_all_late_, split from decide_late_).

--simulate-live <ms> reads a stored sweep as the collection it was while being written - image i
readable (i+1)*ms after the run starts, 0 = all at once - and takes the live path, so that path can be
tested and run in the battery without a broker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
- CPU_DATA_ANALYSIS_IMAGE §0: CBF byte-offset and marCCD widening are decoded on the card; the host
  decoders are the CPU build's path.
- CPU_DATA_ANALYSIS_DECISIONS §14: the null runs for every model the rigid body is tried on; the
  device choice is made once on the card's total (structure factors and rigid body a quarter, scale
  fits an eighth) and a CUDA failure ends the validation, deciding nothing; a subgroup the model fits
  is written as <prefix>_mr; new §14.10 on molecular replacement (model kind, rotation and
  translation functions, likelihood, Matthews, tNCS and P1 rules, null, what it may change).
- CPU_DATA_ANALYSIS: scope and references gain molecular replacement.
- CPU_DATA_ANALYSIS_INDEXING: the near tie decided early and re-checked once every frame is in.
- RUGNUX: merging is the default and _process.h5 optional; live following of a collection with a
  broker URL; --model's MR fallback.
- RUGNUX_INSTALL, RUGNUX_OVERVIEW, RUGNUX_TUTORIAL, RUGNUX_REPORT: GPU work list, <prefix>_mr outputs,
  MR in --mode mx for rotation and stills, rigid body only in --mode scale.
- RUGNUX_ADVANCED: -N default as the usage states it.
- battery README: the ci tier has 28 sets.
- ModelValidation.cpp: a comment still said a GPU failure reran the validation on the CPU (comment only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
One page listing the CUDA engines by pipeline stage - decode and preprocessing, per-image analysis
(azimuthal and ROI integration, spot finders, extractor), pre-scan accumulators, indexing,
prediction and integration, the scaling engine, model validation and molecular replacement - each
with what it computes, how closely it agrees with the CPU, and a link to the section describing the
method. Added to the toctree after CPU_DATA_ANALYSIS_DECISIONS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
- All 84 DOIs checked (82 on Crossref, 2 DataCite): title, journal, volume, pages and first author
  match; Matthews 1968 and Yeates 1997 links percent-encode their parentheses; the MOSFLM link
  points at a page that answers.
- autoPROC: an explicit acknowledgement of Clemens Vonrhein (Global Phasing) for the principle that a
  pipeline should verify the metadata it is given - beam centre, geometry, goniometer - and correct
  it where it is wrong; Vonrhein et al. (2011) Acta Cryst. D67, 293-302 (DOI checked).
- Zhang, Sauter et al. (2006) DISTL, already credited in SpotFindingSettings.h, gets its entry.
- Molecular replacement: tNCS looked for in the native Patterson as Read, Adams & McCoy do; the same
  credit at DetectTranslationalNcs (comment only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A new page, RUGNUX_MODEL_AND_REFERENCE.md, beside the other rugnux pages in the toctree and
linked from RUGNUX.md and the --model section of RUGNUX_ADVANCED.md. It lists every option that
brings in a model or reference data (--model, --mr, the PAE file beside a prediction, -z and
--reference-column, -S, -C, --force-rotation-lattice, --report-resolution), sorts every decision
into data-only, model-informed label in <prefix>.mtz (hand past the anomalous veto, indexing
ambiguity, setting), model-driven separate files (<prefix>_mr) and report-only statistics, walks a
--model run step by step, says exactly where a model can touch the space group and what guards it
(the null, the anomalous veto, the indexing margin, the MR rules, the wrong-model battery step),
and describes the free set and model bias (a deposited model's R-free on rugnux's own test set is
not cross-validated; -z with the PDB's structure factors inherits the depositor's set). Includes the
one pre-merge use of a model: serial stills with -C/-S, where it breaks the per-image indexing
ambiguity. Documentation only; behaviour as merged in rc175 (7ed970503).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ProcessDataImage, ProcessEndMessage and NotifyReceiverOnFinalizedWrite caught only
JFJochException, while ProcessStartMessage and FinalizeDataCollection already caught
std::exception. Anything else - std::bad_alloc from a per-image array, filesystem_error from
a path check, the JSON library refusing a string that is not UTF-8 while publishing a
file-done notification - left CollectImages, and Run() logged "Exception not properly handled"
and returned with the state never reaching Finalized: no END acknowledgement, no writer
notification, the master's .tmp left behind until the next START. On the TCP image stream the
broker then waited for the END acknowledgement indefinitely, because the writer's heartbeat
thread keeps reporting it alive whatever state it is in.

Now the same catch as at the other sites: error state, fatal ACK, END processed, Finalized.
The new StreamWriter test provokes the JSON case through a sample name that is not UTF-8 with
the file-done socket enabled, and checks the run ends Finalized with no master written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every stage between the socket and the HDF5 write was bounded by a message count and never by
bytes: ZeroMQ receive watermark 100, cbor_fifo 200, outside_fifo 1000, repub_fifo 200 - up to
some 1500 images in flight per writer process, each a whole compressed image, so 6-12 GB for a
JUNGFRAU 4M and 30-60 GB for a 9M, and the writer's memory was a quantity nobody had chosen.

ReceiveRingBuffer is a fixed ring of bytes owned by the ImagePuller base class, so the ZeroMQ
and TCP pullers share it. A message is received straight into it (TCP reads the payload into
the ring; ZeroMQ copies the message in) and travels as a shared_ptr region in ImagePullerOutput;
the deserialised image points into that region, republish sends it as is, and the bytes come
back when the last holder lets go. Space is reclaimed in arrival order: a region released out
of turn - republish finishes with messages in its own order - is marked and waits for the ones
in front of it. Because of that, what republish may hold is capped at a quarter of the ring,
and an image over the cap is not republished, exactly as one that finds the republish queue
full; otherwise a stalled republish consumer would pin the ring and slow the writer to its
rate. A message larger than the whole ring (a START or END of a very long run) goes on the heap,
so it cannot wedge the receiver.

A full ring stalls the receive thread, and what that does depends on the stream. On TCP the
pusher waits behind the writer's BUSY heartbeats and gives up only after a minute in which no
image at all was written. On ZeroMQ the pusher's send blocks for one second, then drops the
image and sends without waiting for the rest of the run - so on ZeroMQ a ring that is smaller
than the writer's stalls costs images, where the old 1500-message queues absorbed them. That
is the trade: the budget is now a number, and it has to be chosen. jfjoch_writer sets it with
-m/--rcv_ring_mb, default 2048 MB per writer process; the ZeroMQ receive watermark default goes
from 100 to 10 messages, since it now only counts what sits beyond the ring. The ring's peak
use is in the end-of-run log line, and docs/JFJOCH_WRITER.md has a Memory section with the
sizing rule.

The puller's own default is no ring at all, which keeps every other user as it was: the
broker's DECTRIS stream2 puller (JFJochServices, JFJochReceiverService) stays zero-copy with
the ZeroMQ message as the buffer, and the broker's direct HDF5 writer (HDF5FilePusher ->
FileWriter) never goes through a puller. The puller threads now also catch std::exception, as
the writer does since the previous commit: deserialising the END message of a million-image
run allocates a vector per per-image array, and a bad_alloc leaving the thread ended the process.

Tests: ReceiveRingBufferTest (reuse, wrap-around, full ring, out-of-order release, heap
fallback, zero size, stop, a region outliving its ring); ZMQImageCommTest_SmallRingBuffer (64
one-megabyte images through a three-image ring, nothing lost, peak within the bound); one of
the two writers in TCPImageCommTest_2Writers_WithAck runs on a three-image ring.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The slots of closed files stay in `files` (empty) for the whole run, and CloseOldFiles visited
all of them on every image: a thousand slots per image for a million-image run. Keep the open
file numbers in an ordered set and walk that; being ordered, the walk ends at the first file
that is not old enough to close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A send that blocked for over a second drops that image and turns the socket non-blocking for
the rest of the run, so every later image that finds the queue full is dropped too - without a
word in the log. Log the moment it happens, once per run, with the image number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ZeroMQ puller: the message region without a ring takes the message's read-only bytes
Validation Battery / Validation Battery (push) Canceled after 0s
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m37s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m31s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m16s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 19m12s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m31s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m54s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 23m44s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 24m54s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 26m5s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m42s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m29s
Build Packages / Generate python client (push) Successful in 25s
Build Packages / Build documentation (push) Successful in 1m8s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m43s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m56s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m45s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m46s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 20m11s
Build Packages / Unit tests (push) Failing after 2h15m46s
9da3344639
ZMQMessage::data() returns const uint8_t *, and GCC refuses the implicit conversion to the
region's uint8_t *. In this mode the region is only read (deserialised and republished), never
written, so the cast is safe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bounded memory and efficient closing of old data files on long runs, a failure ending the run in
an error state the broker hears about, and the pusher's log line when a stalled writer makes it
drop images; the master-file ordering moves here from the Fixes line. Owner-requested edit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
determine_cutoff_radius() starts at the IT92 approximation of the radius. When the density there is
exactly the float cutoff (1e-5f) - which happens for a few B per element, e.g. a nitrogen at
B + blur = 277.64563 - neither loop runs, x1 == x2, and the interpolation returns 0/0 = NaN. Upstream
gemmi master has the same code.

A NaN radius cast to int gave MakeAtomBox() INT_MIN half-widths, so UseAtomBoxesByPlane() sized its
point buffer from a wrapped product (one point) and then wrote past it: the heap overflow (SIGSEGV, or
a crash later in FFTW) that killed molecular replacement after the rigid body of a large deposited
model, turned to imitate another crystal form, had failed (2 of 73476 atoms, in the CPU rigid body of
an MR placement). The GPU density path took the same NaN radius into its box sizes.

The outward search now steps at least once, so that case returns the point at the cutoff. Every other
B gives the same radius bit for bit (checked over ten elements, every 8th float B in 2-600 A^2: 3 NaN
before, none after, no finite radius changed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Where the model's group, carried onto the data's axes (CellMappingOperators), is an index-2
subgroup of the adopted group, the merge the model is asked about the twin hypothesis on is the
one in that subgroup: a merge in another subgroup averages reflections the model's symmetry does
not relate. Where the data's own twin hypothesis (p_twin_*) is another subgroup, the model's merge
is made beside it, for the model only, and written - as <prefix>_mr - only where the model is
placed there and fits it better (the existing rule); p_twin_* and every data file are untouched.
A p_mr made from a subgroup merge carries the model's own group where it has the subgroup's
rotations (its screws and hand, unless the anomalous map at its atoms is inverted), without the
reflections that group extinguishes.

Measured: a hexagonal set whose deposited model is P3121 adopted P622 on twin-like data; its
data hypothesis is P6, and the model was asked about P6 and chose it (p_mr P6). Now it is asked
about P321 (twin R-free 0.296 at a fraction 0.45, answer's merge 0.398) and p_mr is P3121, the
deposited group. ci tier 28/28 and 14 named twin/screw sets: p.hkl, p.mtz data and p_twin data
identical; no other report key changed. AF models via --model (8 sets): decisions identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
A screw candidate whose absent class no reflection contradicts, refused only because its
evidence could not reach min_screw_absence_evidence even with every predicted-absent axial
reflection at the floor (one such reflection reaches 6.9 nats; a row without a control class
reaches nothing), left the symmorphic group adopted and the report saying the group follows from
the absences. Such a row is now SPACE_GROUP_SCREW_UNDETERMINED, with the screw group in
SPACE_GROUP_ALTERNATIVES. Report only: the result is kept apart (unreached_screws) and folded in
after every decision, so the written group and files do not change.

Measured on a monoclinic set whose merge holds five 0k0 reflections at noise level, one of them
predicted absent: P2 was reported as decided by the absences against a deposited P21; it is now
P2 with the b screw open and P21 offered. Over the full battery's candidate tables the condition
holds on that set and one private set (the same P2/P21 case) only. ci tier: no change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Merge branch 'fix-mr-segfault' into rc175: an atom whose density at the starting radius is exactly the cutoff no longer gets a NaN radius (MR model-density overflow)
Validation Battery / Validation Battery (push) Canceled after 0s
Build Packages / Create release (push) Successful in 6s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m26s
Build Packages / Unit tests (push) Canceled after 0s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m16s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 12m20s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m29s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 17m53s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m25s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 22m3s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 22m35s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m30s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m0s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m23s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / Build documentation (push) Successful in 1m8s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m2s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m40s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 21m39s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 26m20s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m4s
da87160b07
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
battery: workstation reference is the rc175 full battery at f853596
Validation Battery / Validation Battery (push) Canceled after 0s
Build Packages / Unit tests (push) Canceled after 0s
Build Packages / Create release (push) Successful in 21s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m26s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 12m18s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m1s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m20s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m33s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 15m9s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 28m8s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 16m39s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 16m6s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 19m20s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / Build documentation (push) Successful in 1m24s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m41s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 26m9s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 27m16s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 24m33s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m29s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m40s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 27m4s
563ad27af6
Replaces the kept 57fef1c run with 20261013-0557_f85359_rc175-f853596-full (open + inhouse; the
private arm is never kept).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge branch 'review-fixes-rc175' into rc175: review fixes (quality guard serial on one card, DeviceBytes as measured, CBF plain stores, GPU test guards), docs brought up to the code, GPU_DATA_ANALYSIS, acknowledgements, CHANGELOG
Validation Battery / Validation Battery (push) Canceled after 0s
Build Packages / Unit tests (push) Canceled after 0s
Build Packages / Create release (push) Successful in 21s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m13s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m28s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 12m56s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 7m35s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 17m6s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m21s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m33s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 18m25s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 18m4s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 22m34s
Build Packages / Generate python client (push) Successful in 33s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 22m51s
Build Packages / Build documentation (push) Successful in 1m55s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m22s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m8s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 30m11s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 22m54s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m51s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 24m50s
e80735a261
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A new last column, "disposition", gives each frame's merge disposition as
a number to plot directly: 1 merged, 0.5 downgraded, 0 rejected; nan for
a frame that never got a scale (never reached the merge) and on a run
that merged nothing, as the file writes every value nothing measured.
Existing columns keep their indices.

0.5 is a marker, not a weight. "Downgraded" is a label applied after the
merge (RotationScaleMerge::CountSweepDisposition marks the merged frames
inside a flagged sweep-quality range); the merge applies no factor of its
own to them - they are carried at whatever weight their own scale and
sigmas give - so there is no fixed or per-frame reduction factor that
could honestly be written instead.

Writer-only change: on three open-arm sets with downgraded and rejected
frames p.hkl is identical to the same commit without it, the first ten
columns are unchanged, and the column's 1/0.5/0 counts equal the report's
merged/downgraded/rejected counts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge branch 'plots-disposition' into rc175: <prefix>_plot.txt gains a per-frame disposition column (1 merged, 0.5 downgraded, 0 rejected)
Validation Battery / Validation Battery (push) Canceled after 0s
Build Packages / Create release (push) Successful in 21s
Build Packages / Unit tests (push) Canceled after 0s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m16s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m11s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 9m16s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 12m55s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m2s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m52s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 17m44s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 21m18s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m30s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 23m31s
Build Packages / Generate python client (push) Successful in 31s
Build Packages / Build documentation (push) Successful in 1m8s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 23m42s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 26m45s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 29m4s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m4s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 27m39s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 22m45s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 28m13s
7d64a62872
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The intermittent "out of GPU memory" on the 3600-frame cbf.gz set (about one run in three on a 16 GB
card, base included) was caught with allocation backtraces: FitErrorModelGPU's 42.8 MB in
MergeAndStats failed while the speculative geometry probe (GPUWorkBeside) held its FFT indexers on
the same card. The engine's own arrays (RotationScaleMergeGPU Impl::Alloc) wait for such work to end
and ask again; the merge's helpers - ErrorModelGPU, WilsonOutliersGPU, PairSortGPU and the cub
scratch of the engine itself - allocated directly and failed at once.

CudaAlloc::SynchronousAfterWorkBeside: Synchronous, and where the card is full after the pool is
trimmed, it waits for the GPU work beside the main line (wait_for_gpu_work_beside, 10 min as for
the engine) and asks once more. The four merge files use it. Only the probe passes register
GPUWorkBeside and they index only, so none of them merges and nothing waits for itself. When a
buffer is allocated is all that changes; what is computed is not.

Catch2 CudaDevicePtr_AfterWorkBesideWaitsForItsMemory: with the card filled by work beside, a
Synchronous buffer fails and a SynchronousAfterWorkBeside one gets its memory once the work ends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Molecular replacement's structure-factor score engines were made one per group, copy count and
resolution, each admitted against a quarter of the card's total, and all kept to the end of the
search. On a large cell they summed to well over the card: traced with nsys on the rotated 4nwv
model (which failed on every run, base included), seven engines of 1.36 GB were held beside the
two rigid-body pools (5.9 GB) when a 288 MB buffer did not fit.

Each engine is still admitted, or the search scored on the CPU, exactly as before, on the card's
total memory. Now the engines kept on a card also stay within that quarter together: an engine
that does not fit beside them takes the place of the least recently used one no score is using,
and waits for a score to finish where all are in use; one taken out is made again when next asked
for. A score holds its engine for as long as it uses it (shared_ptr lease). Engines compute the
same numbers whenever they are made, so only when the card holds them changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The rotation merge engine is released before model validation decides whether its GPU path fits,
but a predicted model (or --mr) skips that validation and went into molecular replacement with the
engine still on the card - about 10 GB on the AF 9v16 run, which then failed with 0.0 GB free on
some runs and on the others got fewer rigid-body engines. It is now released before the search
too, on the same condition (no finalist-ledger merge still running on it). Card peak of that run
15.75 -> 14.05 GB; MR keys identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Last-bit change, not bit-exact: every partial whose frame does not contain its exact diffracting
condition gets a partiality - and so a slice-centroid walk along its Debye ring - that differs in
the last bits.

The partiality was (erf(b) - erf(a)) / 2 in float. Where the frame's slice lies in one tail of the
rocking curve both erf are near +-1, and their difference keeps only a few ulps of 1: glibc erf and
CUDA erff, one ulp apart, gave partialities ~1e-3 apart (relative) at p ~ 3e-5. The slice centroid
(RockingSliceCentroid_rad) divides by that partiality, and at zeta ~ 0.05 the walk it scales is
hundreds of pixels, so CPU and GPU placed such a reflection up to 0.1 px apart -
BraggPrediction_CPU_GPU_consistency_large_cell failed on one of them once --fmad=false (7ed970503)
moved the ulps. A slice in one tail now takes the same difference from erfcf, which keeps its relative
precision there; one helper (RockingSlicePartiality) serves both predictors.

Reflections placed more than 0.01 px apart by CPU and GPU in that test: 65 -> 0. BraggPrediction*,
BraggPredictionRot*, BraggPredictionRotGPU*, RockingSlice* pass. 7qij image passes 5.68/7.90 s ->
5.26/7.50 s (one lock hold; no cost).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Merge branch 'erfc-partiality' into rc175: rotation partiality in the rocking-curve tail from erfc (CPU and GPU agree; last-bit change)
Validation Battery / Validation Battery (push) Canceled after 0s
Build Packages / Create release (push) Successful in 19s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m29s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m13s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m35s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m41s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m35s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 26m56s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 18m2s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m8s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 17m16s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 19m28s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m21s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m43s
Build Packages / Generate python client (push) Successful in 26s
Build Packages / Build documentation (push) Successful in 1m10s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 22m56s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 21m23s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 19m10s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 23m1s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 21m24s
Build Packages / Unit tests (push) Successful in 2h1m27s
fca28f2728
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CI: the validation battery is a job of Build Packages, after the unit tests
Build Packages / Create release (push) Successful in 24s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m18s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m41s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 13m17s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 23m15s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m30s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 5m43s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 14m27s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 17m7s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m27s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 19m18s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m38s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m7s
Build Packages / Generate python client (push) Successful in 23s
Build Packages / Build documentation (push) Successful in 1m0s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m4s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 15m25s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 20m43s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 21m25s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 17m1s
Build Packages / Unit tests (push) Successful in 1h56m34s
Build Packages / Validation Battery (push) Successful in 31m53s
62e64235df
The unit-test job had joined the battery workflow's concurrency group to keep the two off the GPU at
the same time. A group keeps one pending run or job and cancels it for a newer one, cancel-in-progress
or not, so the battery waiting for its runner was cancelled by the same push's unit-test job, on
every push since 140739a69.

The battery is now a job in build_and_test.yml that needs the unit tests and runs whatever their
outcome, so within one run the two take turns; no concurrency group, so a merge to main always gets
both, and a manual release (create_release) runs neither. A manual subset or the full battery is a
Run workflow with battery_subset (empty = full). Two pushes close together can still overlap on the
GPU; a runner with capacity 1 for the GPU jobs is the later fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each refit reads a share of the error left, so the walk's steps contract by about the ratio of the
last two. Where the step that ratio predicts moves the end of the sweep by less than half a frame,
the refit (an integration of the pre-pass frames and a post-refinement) and the indexing probe after
it are not made; the scale the validation frames last preferred is adopted, as before.

Measured on the walk sets of the battery against rc175 edde4439c: p.hkl and MTZ data identical; it
fired on one open set (2 refits instead of 3, same adopted scale), about -7 s there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The walk's indexing probes start as the canonical pass's post-refinement fit ends and run beside its
merge and space-group search. The pass asked whether the walk takes a round only before the search
and before its analyses; it now also asks after the search's two arms, before the space-group
decisions and the merge in the adopted group, without waiting for anything. A pass the walk is going
to replace stops there instead of finishing the search. (Waiting for the probes before the search was
measured and dropped: it cost 0.2-3.6 s on sets where the walk took no round.)

Exact by construction (only a pass that is thrown away is cut short); p.hkl and MTZ identical on the
ci tier and 49 extra sets. Fired on one geometry-walk set, alternated runs 33.8 -> 31.6 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
The measured-centre arm is a whole geometry pre-pass. It now integrates first a third of the pre-pass
frames, in 3k + 1 blocks of about 10 deg over the whole pre-pass arc with every third kept, and the
file's centre merges the same frames (its outcomes for the other frames are moved out while that
engine ingests). Where the measured centre loses on that subset on every line MeasuredCentreWins draws
by more than NEAR_TIE_SIGMA times the line's noise (Poisson for the strong counts, the Fisher standard
error for a CC1/2), it has lost and no full arm is run. Anything else, a near tie or a win, puts the
run back as the file's centre left it and runs the arm over all the pre-pass frames, deciding as before.
The run made again (decide_all_late_) runs the arm on all frames.

The same subset was tried for the metric-symmetry demoted arm and the walk refits and is not used
there: the held-out residual's standard error does not grow on a third of the frames, so a subset
margin overstates the evidence (one set's metric call flipped at 2.07 sigma), and a subset refit moved
one walk to a worse fixed point.

Against rc175 edde4439c, ci + 49 extra + 9 private sets: p.hkl and MTZ data identical on all 86. The
arm lost on the subset on 22 sets (no full arm), was a tie/win on 9 (full arm as before, +0.3-1.7 s).
Alternated runs: one set 62.1 -> 55.2 s, another 46.2 -> 42.4 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
After the END message the receiver waits for every image buffer the pusher still holds (for
ZeroMQ: zero-copy messages freed once libzmq has transmitted them) before calling Finalize();
on expiry the collection fails with "Send commands not finalized". The limit was a 10 s literal
repeated in the FPGA and Lite receivers; it is now one constant, SendFinalizeTimeout, in the
JFJochReceiver base class.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replaces the kept f853596 run with 20261013-1944_a45135_rc175-a451359-full (open + inhouse; the
private arm is never kept). Against f853596: no verdict changes beyond 5ky6 fail -> unscored (screw
undeterminable), sum 4145 -> 4088 s; the other moves are the last bits of device code without FMA
contraction (8xtf ISa 7.7 -> 7.9) and anomalous sigma.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MR score-engine cache and merge buffers: no hang, no doubled wait; near-tie doc follows offline decide-late
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m24s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m14s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m43s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 19m8s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m44s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 22m47s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 24m6s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m47s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m47s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m48s
Build Packages / Generate python client (push) Successful in 30s
Build Packages / Build documentation (push) Successful in 1m9s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m10s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m34s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m52s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m52s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 23m41s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 19m32s
Build Packages / Unit tests (push) Successful in 1h25m53s
Build Packages / Validation Battery (push) Successful in 31m9s
6e9b891eb4
- MolecularReplacement: an engine whose making throws anything (std::bad_alloc, a gemmi error), not
  only a JFJochException, is refused and its reservation given back, so a thread waiting for the
  same engine is woken instead of waiting forever.
- RotationScaleMergeGPU::Alloc waits for the GPU work beside itself (GPU_BUSY_TIMEOUT), so its
  buffers are allocated plainly Synchronous; SynchronousAfterWorkBeside added a second 10-minute wait
  in front of it.
- CPU_DATA_ANALYSIS_INDEXING: an offline run decides its near ties on the whole sweep from the start;
  only a live run (broker URL or --simulate-live) decides early and re-checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stills per-image loop spent 99% of its time indexing (28.4 of 28.7 ms per frame on a 16 Mpx
serial set; read, decode, spot finding and integration together 0.33 ms), and only four threads
were ever busy:

- The 16 image workers each block on IndexerThreadPool, which had the default 4 threads, while the
  FFT candidate post-processing is CPU work. Stills now size the pool to min(-N, 8); 8 gets 94% of
  what 16 does (83 / 125 / 133 frames/s at 4 / 8 / 16 with the changes below) at half the card
  memory - each GPU FFT indexer holds ~0.5 GB, 16 of them took the run to 12.7 GB.
- FFTIndexer::ReduceResults (widened path, taken on ~2/3 of calls) deduplicated ~2900 candidate
  cells per call by a linear is_close scan: 46% of all CPU cycles. The accepted cells are now kept
  ordered by a and only the 2%-window of a is asked; the answer (any close cell) is the same.
- FilterFFTResults built a 16k-entry multimap per call (11% of cycles); now a partial sort of
  indices in the multimap's reverse order (magnitude descending, later direction first on ties).
- calculate_fft_result reads magnitudes from a bin-major transposed copy (in the histogram buffer,
  no new memory): neighbouring lanes no longer read a spectrum apart. Bit-identical results
  (checked against the old kernel on 4000+ calls, 0 mismatches); not faster on an idle card, but
  67 -> 98 frames/s with the pool's streams sharing it.
- The stills min-pix choice indexes a frame at 3/2/1 and then the winner again; the seed of
  strongest spots is often the same list each time (31% of indexer calls repeated an earlier call
  of the same frame). IndexAndRefine answers a repeated list from a per-frame FrameIndexerCalls
  memo kept by MXAnalysisWithoutFPGA for the duration of one frame.

Measured (RTX 5080, 32 threads, data in page cache), first 2000 frames, ABAB: 35/35 -> 127/127
frames/s, p.hkl md5 identical. Full 24960-frame run: loop 1081 s -> 277 s, wall 1132 s -> 328 s,
p.hkl md5 identical (P41212, 33234 unique); with a pool of 16 the loop is 222 s (the full run has
more spots per frame than its first 2000 frames, so the CPU side weighs more there). With a known cell (ffbidx path) 715 -> 920 frames/s on
2000 frames, p.hkl identical. Tests: jfjoch_test [Indexing] pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Open 196/203 (97%; the 20261011 server run had 195/205), inhouse 47/47, no crash. Kept as
results/server/20261014-0141_6e9b89_1.0.0-rc.175 (report.md, results.json, manifest.json as
written); no private arm on this site.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
battery: 4nwv joins the ci tier (29 sets)
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m28s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m29s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 15m47s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m46s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m11s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m37s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m6s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 24m57s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m51s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m10s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m32s
Build Packages / Generate python client (push) Successful in 33s
Build Packages / Build documentation (push) Successful in 1m6s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m44s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 24m8s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 25m30s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m48s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 25m14s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m39s
Build Packages / Unit tests (push) Successful in 1h28m18s
Build Packages / Validation Battery (push) Successful in 33m41s
8430d69731
The virus capsid (I222, ~400 A cell, marCCD) passed with the deposited group in every rc175 full
run: 71 s on the workstation, 159 s and again in 20261014-0141 on the server. It adds a second
large set to the regression check, about a minute of rugnux on the RTX 5080.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stills scale/merge after the image loop was almost entirely serial:
- every scaling-reference merge (MergeAll: the per-image scaling reference and the two
  partiality post-refinement references) ran French-Wilson single-threaded on its export,
  about 3 s each, and assigned free-R flags - neither is read by a reference, which uses I
  only. MergeAll now exports intensities alone (MergeOnTheFly::ExportIntensities).
- the merge accumulated into a std::map; it is now an unordered_map exported in key order,
  which is the std::map order.
- AddImage, the error-model collection and the R_meas pass of MergeStats did the
  per-observation work (systematic-absence test, ASU key, filters, error-model sigma,
  outlier test, merged-I lookup) in one serial loop. They now reduce a block of images in
  parallel and consume it in image order (ReduceImagesInOrder), so every floating-point
  sum, half-set choice and hash-map insertion is made in the serial order.

Measured on a 25k-frame XFEL stills run (box loaded, load 9-14): merge P1 19.0 -> 7.1 s,
final-group merge 27.7 -> 13.2 s; on a second run 23.4 -> 7.7 s and 29.7 -> 13.6 s.
mtz/unmerged mtz data, hkl, cif and report identical on both runs, mx and --mode scale.
New test MergeOnTheFly_AddImagesIsTheSerialMerge checks the parallel merge against the
serial loop across a block boundary.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
hide_gpus() in CUDAWrapper makes get_gpu_count() answer 0 without calling
cudaGetDeviceCount, so every GPU decision in the process (indexer Auto ->
FFTW, preprocessing/spot finding/decode, pre-scan, scaling engine, model
validation, MR) takes the path it takes on a machine without a GPU, and no
CUDA context is created. rugnux reads --no-gpu in main, ahead of the parser,
because set_gpu_blocking_sync() already asks CUDA for the devices. The
banner says 'CPU only (--no-gpu)'. The 'run it on the CPU' advice in the
out-of-GPU-memory messages and in the install docs now names --no-gpu
instead of CUDA_VISIBLE_DEVICES=.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In rugnux people choose the algorithm, not where it runs. -X fft (and the
old -F) now resolves at parse time to the GPU FFT indexer where a GPU is
visible and --no-gpu is not given, and to FFTW otherwise. -X cufft is the
GPU implementation alone, as -X fft was. -X cufft and -X ffbidx without a
GPU now stop at once with a clear message instead of failing later in the
indexer pool. Auto is unchanged and not redundant: on stills with a known
cell and a GPU it takes FFBIDX.

Only rugnux's CLI spelling changes. The internal IndexingAlgorithmEnum, the
broker and its OpenAPI enum keep FFT = the GPU implementation; the CLI
maps onto it, and the command line the viewer writes for a run (RugnuxCommandLine)
gives the enum's FFT back as -X cufft, which re-parses to the same thing.
A run without -X, and -X fft on a machine with a GPU, process exactly as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
calculate_fft_result (the per-direction prominence peak search) was 61-65% of the GPU kernel time of
a de-novo stills run, and the card is what limits that loop. It is one dependent chain per direction,
latency-bound. Two exact changes, taken where the spectrum holds a whole background window (any
FFT max cell above ~30 A; the general loop stays for the rest):

- the sliding window written out as its three phases (clamped at the start, centred, clamped at the
  end): the same additions and subtractions in the same order, so the running double sum is bit for
  bit the same, but the loads no longer sit behind data-dependent while loops and are issued ahead;
- the window mean divides by a constant width, so the double division (a long FP64 iteration on a
  consumer card) is replaced by the reciprocal multiply plus one correction by the exact remainder,
  which returns the correctly rounded quotient (Markstein). Checked on the CPU against the division
  for 4e8 random dividends over all odd widths 3-401: 0 differences.

Measured (RTX 5080, box loaded by other builds, load 12-25): kernel minimum 1.50 -> 0.83 ms, mean
under the 8-stream pool 4.7 -> 2.8 ms; de-novo stills 2000 frames ABAB 113/120 -> 127/138 frames/s.
p.hkl byte-identical (2000 and 3000 frames, base and new min-pix schemes).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Changes results - not for merging without the owner's approval.

The per-image min-pix scheme indexed every frame at min-pix 3, 2 and 1, each with the 30/80/all seed
escalation, then indexed the winner again: 3.9 indexer calls per frame. Measured per frame on a
serial lysozyme run (2000 frames, de novo and against the known cell):
- where several lists index, the searches return the same lattice (97-98% of pairs);
- the gain over a fixed min-pix 2 has two sources: (A) frames min-pix 2 indexes but whose min-pix
  1/3 list indexes ~26 more spots (a better per-frame refinement), and (B) frames with almost no
  spot of two pixels (min-pix 3/2 lists of 1-3 spots, ~50 single pixels at min-pix 1) on which the
  min-pix 1 search fits 8-9 spots. De novo, 189 of 194 such B lattices had cells unrelated to the
  crystal's; against the known cell they index, but leaving them out raises I/sigma and lowers R_pim.

Now: the lattice is searched for on the min-pix 2 list (min-pix 3 where that does not index); the
first list that indexes hands its indexer answer to the others, which are only refined and scored
(FrameIndexerCalls::lattice_found). The min-pix 1 list is searched on its own only where the min-pix
2 list holds at least ViableCellMinSpots spots. Ties keep the larger min-pix, as before.

Measured on two full 24960-frame serial runs (RTX 5080, box loaded by other agents, load 9-20; loop
rate is frames/s of the image loop; I/sig, R_pim, observations of the P41212 merge):
  known cell (ffbidx, -C):   base 785 / 750 -> 1130 / 1017 frames/s (warm 6000-frame ABAB 860 -> 1453)
    run A: obs 5.37M -> 5.30M, I/sig 13.09 -> 13.02, R_pim 6.48 -> 6.46%, CC1/2 .970 -> .973
    run B: obs 4.91M -> 4.89M, I/sig 12.00 -> 11.95, R_pim 6.50 -> 6.47%, CC1/2 .971 -> .970
  de novo (FFT):             base 89 / 91 -> 174 / 195 frames/s
    run A: obs 3.96M -> 3.91M, I/sig 10.18 -> 10.12, R_pim 6.94 -> 6.98%, CC1/2 .960 -> .967
    run B: obs 3.91M -> 3.89M, I/sig 10.53 -> 10.52, R_pim 7.01 -> 7.03%, CC1/2 .965 -> .961
  fixed min-pix 2, for scale: ffbidx 1496 / 1316 frames/s, obs 4.94M / 4.54M, I/sig 12.62 / 11.77.
The indexing rate drops (ffbidx 59.5 -> 56.9%, 55.2 -> 52.4%; FFT 60.5 -> 52.1%, 60.2 -> 47.9%):
the frames that go are the spurious B lattices; observations stay within 1.3%. Indexer calls per
frame 3.89 -> 1.67 (de novo, 2000 frames); the min-pix 1 search is skipped on 58% of frames.
De novo, 23 of 691 frames whose lattice was the crystal's now take a superlattice (13, same spots
indexed) or a cell fitted to 6-12 spots of a weak frame (10) from the min-pix 2 search.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Changes results - not for merging without the owner's approval.

The 30/80/all seed escalation exists for the de-novo search, which a flooded frame's full spot list
derails. ffbidx fits a known cell with a trimmed score that already discounts off-lattice spots, so
for it the escalation only multiplied calls: up to three per searched list, ~2.2 per indexed frame
with a fixed min-pix 2. The ffbidx path is GPU-bound (nsys: card 98.5% busy, ffbidx kernels 86%), so
calls are the loop rate. Stills only: rotation never resolves to ffbidx.

Measured on top of the one-search min-pix scheme (parent), two full 24960-frame serial runs, P41212
merge (box loaded by other agents, load 13-17):
  run A: 1130 -> 1532 frames/s; obs 5.30M -> 5.47M; I/sig 13.02 -> 13.12; R_pim 6.46 -> 6.43%;
         CC1/2 .973 -> .976; indexing rate 56.9 -> 57.3%  (original scheme: 785 frames/s, 5.37M,
         13.09, 6.48%, .970)
  run B: 1017 -> 1488 frames/s; obs 4.89M -> 4.99M; I/sig 11.95 -> 11.94; R_pim 6.47 -> 6.42%;
         CC1/2 .970 -> .972; indexing rate 52.4 -> 52.9%  (original: 750, 4.91M, 12.00, 6.50%, .971)
Warm 6000-frame ABAB in one lock hold: 1453/1419 -> 1734/1810 frames/s, same indexing rate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On a 16 GB card a probe pass (GPUWorkBeside) runs its image work beside the main line's image pass
(GPUImagePass). Measured on a 16 Mpx detector with nsys, the two together held 8.6 GB of device
buffers (two sets of 24 per-worker 67 MB image buffers plus five 0.6 GB FFT indexers), so on an
8 GB card the refined-geometry pass ran out of memory ("Allocating GPU memory (53.0 MB) failed").

Below 12 GB of card memory each of the two now waits, before it starts, for the other kind to end.
Only when each runs depends on the card, never what it computes; neither waits once running, so
nothing waits in a circle. On a 16 GB card nothing changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The final stills merge drops every crystal whose cell is not close to the consensus cell
(MergeOnTheFly::Mask), but the scaling reference (MergeAll) and the partiality post-refinement
were made over all of them. On two 25k-frame XFEL runs of a tetragonal crystal those were 26-30%
of the indexed frames and 60% of the integrated reflections: weak frames (median 8 indexed
spots) indexed in a 2x/3x/4x supercell or an unrelated lattice, whose supercell-only reflections
are pure noise and whose indices do not name the true reflections. They went into the
reference every merged crystal was scaled against, and through the tilt refinement.

MergeAll and StillsPartialityRefine::Run now take the reference cell and use only the crystals
it accepts - the same test, with the same tolerances, as the merge.

Measured (both runs, mx path): CC1/2 0.9599 -> 0.9598 and 0.9645 -> 0.9644, I/sigma +0.05 and
+0.02, R_pim unchanged, space group and resolution unchanged; --mode scale against a deposited
tetragonal lysozyme model CC 0.9452 -> 0.9477 and 0.9461 -> 0.9473. Scale+merge 2.1x faster on
--mode scale (14.6 -> 6.9 s, loaded box).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each crystal's (psi_x, psi_y) tilt was refined by a Ceres problem rebuilt for every inner
iteration: one auto-differentiated residual block and one Cauchy loss per reflection. With the
scaling reference cleaned up it was 70% of the stills scale/merge. The same objective - Cauchy
on the sigma-weighted intensity residuals plus the Gaussian prior on the tilt, the tilt bounded -
is now minimised by a small Levenberg-Marquardt on the rho'-reweighted 2x2 normal equations,
the tilt applied to all reflections through one rotation matrix and the Jacobian taken by
central differences.

It reaches the same answer to optimizer precision: on the new synthetic test
(StillsPartialityRefine_RecoversTheTilt) partiality error 0.0559 -> 0.03019 with Ceres and
0.03016 with this, mean tilt 0.02065 vs 0.02067 deg; on two 25k-frame XFEL runs (--mode scale)
CC1/2, I/sigma, R_pim and the CC against a deposited lysozyme model agree to 0.0005, mean
|dpsi| 0.023 deg on both. Scale+merge 6.9 -> 3.0 s (--mode scale, loaded box).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The device scaling's peak, measured with nsys on four large sets (33-123 M observations, 4-16 M
distinct hkl), is 81 bytes an observation plus 291 bytes a distinct hkl to within 5%. Once the
ingest has counted both, an engine whose estimate is over 80% of the card's total memory scales on
the CPU instead, with one warning line saying why; a resident ingest is made again on the host,
which the CPU path needs. Decided once, before anything is scaled, on the card's total memory: the
answer depends on the card, not on the moment.

On a simulated 8 GB card (total clamped to 8.2e9, 7.75 GB free) a 33 M-observation, 15 M-hkl set
whose final merge alone needs ~7.5 GB on the GPU ran out of memory there; it now completes, and its
merged MTZ matches the 16 GB GPU run to float rounding (max relative difference 9e-7 on any
intensity column, same reflections, cell, space group and resolution cut). The largest estimate on
the battery is 12.45 GB, under 80% of a 16 GB card, so nothing changes there.

The all-observations arm is copied from the engine's card arrays only when that engine is on the
GPU.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A worker of the full-analysis image loop holds about 24 bytes a detector pixel on its card (sixteen
held 6.3 GB on a 16.8 Mpx detector, measured with nsys). The workers now keep to half of the card's
total memory: on a 16 GB card that is still the sixteen of before for detectors up to ~21 Mpx; on
an 8 GB card with a 16.8 Mpx detector it is ten, where sixteen ran out of memory with ~7 GB free.
How many workers there are changes only the loop's speed. An explicit -N is obeyed as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The defective-pixel finder ran only on scanning rotation data. On stills, a pixel that holds the
detector's error value on every frame but is missing from the file's static mask is read by the
integrator as a per-frame unreadable pixel, so every reflection whose signal disk touches it is
flagged overloaded and the merge drops it. On a SwissFEL JUNGFRAU stills run, 14k such pixels made
3.9% of all integrated reflections "overloaded" (mostly finer than 1.6 A, where they are scarcest)
and raised a false OVERLOADS warning; with them masked, 0.05% remain flagged and the profile fit
keeps the reflections whose disk stays at least MINPK readable.

On stills only the error-value and dead rules apply: the hot-pixel rule counts a reflection's stay on
a pixel in rotation, so spacing is passed as 0 there, which HotPixelFinder already treats as "no
hot-pixel test". Rotation is unchanged (same gate, same oscillation and spacing).

Result-changing for stills; merged-statistics A/B on full runs and the battery ci check still to do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the main image pass already waited for, a simulated 8 GB card with 7.0 GB free (the display's
share taken) still ran out of memory: the rotation-scale walk's index-only probe and the speculative
geometry probe ran beside each other during the merge, and the walk's cuFFT plan failed. On a small
card a probe pass now also waits, before it starts, for every other probe pass to end. Scheduling
only; nothing changes on a 16 GB card.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ExportReflections ran French-Wilson single-threaded: about 1 s on the P1 search merge of a 25k-frame
stills run (180k reflections). It now uses the merge's thread count, as AssignRfreeFlags does; both
give the same result at any worker count by construction. mtz/unmerged-mtz data, hkl, cif and report
identical (--mode scale, 25k-frame XFEL run).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The broker cancelled the whole data collection on the first indexed image when its algorithm was FFT and no
GPU was visible (the indexer pool refuses FFT there, and the receiver cancels on the exception). FFTW is too
slow for live stills, so it is not switched to; with None the pool reports the frame as not indexed and the
collection runs on. rugnux resolves -X FFT itself (cuFFT or FFTW) and never reaches this case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The unmerged MTZ took every integrated still. A still whose cell is not close to the run's - the same
test, with the same tolerances, the merge applies - was indexed in another lattice (on few spots,
typically an axis multiple), so its H K L do not name the reflections a reading program takes them
for: aimless or careless would scale them in as wrong reflections. On two 25k-frame XFEL runs such
frames carried 60% of the integrated reflections, and the 824 MB file was the largest single cost
after the merge (about 7 s in --mode scale). Rotation runs are unchanged (one lattice).

The orientation matrix in the batch headers is likewise taken from the first crystal on the run's cell.
Test: Unmerged MTZ: on stills, only the crystals on the run's cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A still indexed on few spots often gets a lattice that explains them as well as the true one: an axis
multiple, whose reciprocal lattice holds every true node plus the nodes between, or the true lattice in
another setting. Its cell is then not the run's, the merge leaves it out, and its reflections at the true
nodes - as good as any - are lost. On two 25k-frame XFEL runs of a tetragonal crystal these were 26-30%
of the indexed frames; for those holding the true cell as a sublattice, the reflections at true nodes had
the I/sigma of accepted frames and those between them none.

RelabelOnSublattice (new, scale_merge/SublatticeRelabel) compares lattices, not cell parameters: it
looks for integer combinations of the frame's reciprocal axes whose full metric is the run's cell within
the merge's own tolerances, volume ratio 1-4 (another setting, or a 2x-4x supercell). Where several such
bases exist - a nearly symmetric supercell fits along more than one axis - the one with the strongest
mean I/sigma at its nodes is taken, since only the true lattice has signal there. The frame's indices
are re-derived on that basis (h = h' M^-1), reflections that are not nodes are dropped, as are those
finer than any frame already on the run's cell reaches (the wrong lattice was also predicted to a wrong
resolution), and the frame gets the run's basis in its own orientation. It runs before the first stills
merge, and in --mode scale before the resolutions are recomputed from the cell.

--mode scale, both runs (indicative; the mx pairs are queued): 1060 and 920 crystals put back,
observations +8%, I/sigma 10.3 -> 10.6 and 10.7 -> 11.0, CC1/2 0.966 -> 0.969 and 0.970 -> 0.966.
Tests: SublatticeRelabel_DoubledAxisGoesBackToTheTrueCell, _OtherSettingAndUnrelatedLattice,
_SignalPicksTheAxisThatWasDoubled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge origin/rc175 (server battery results, 4nwv in the ci tier) into rc175
Build Packages / Create release (push) Successful in 8s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m13s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m55s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 17m38s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m39s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 20m1s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m21s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 23m14s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 24m8s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m59s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m21s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m15s
Build Packages / Generate python client (push) Successful in 34s
Build Packages / Build documentation (push) Successful in 1m20s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m20s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 22m47s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m5s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m18s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m31s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m42s
Build Packages / Unit tests (push) Failing after 1h38m41s
Build Packages / Validation Battery (push) Successful in 41m45s
aca4dd3cca
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The peak search gave each of the 16384 directions to one thread, a chain of dependent
FP64 additions over 1572 bins - latency-bound on a card that holds far more threads.
Each direction's spectrum is now split into four consecutive runs of bins, one thread
each, and the runs' first-best peaks are combined in order with the same strict
comparison, so the first bin of highest prominence is still the one returned.

A run after the first sums its starting window afresh rather than carrying the running
sum in. The double sums of float magnitudes are exact unless a magnitude is below
~1e-9 of its window sum, so the background is the same double: 0 of 491520
directions differ in a synthetic still/rotation harness, and the hkl of 3000 stills
frames (-X fft) is byte-identical.

Kernel (isolated, RTX 5080): 0.87 -> 0.65 ms per call. The stills de-novo loop gains
~4% (128 -> 133 frames/s, ABAB, load ~10): the FFT chain as a whole is bound by GPU
memory bandwidth under the indexer pool's concurrency, so even a background in float
(6x faster kernel, not adopted) gives only ~9%. Rotation first pass: FFT kernels are
~3% of the indexing step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The carried indexer answer now brings its spindle severity along, so with a goniometer (--force-still)
no severity-only pass runs per carried list. The min-pix 1 search gate counted ice-ring spots the
indexer leaves out; it now counts the spots indexing would use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With a known cell the min-pix 1 list is searched again whatever the min-pix 2 list holds. On sparse
serial data the frames with almost no spot of two pixels are real hits: a weakly diffracting
hexagonal run (20000 frames, -C -S) lost half its indexed frames and observations to the gate
(6.5 -> 3.4%, 273k -> 120k observations, CC1/2 .83 -> .74); with the gate off for a known cell it
matches the original scheme (6.5%, 274k, .83). De novo the gate stays: there the cells a free search
fits to single pixels were unrelated to the crystal's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This reverts commit 64a8d8234. Measured on non-lysozyme sets (one search per frame scheme, 5000
frames each, with -C -S): on a body-centred orthorhombic crystal with a 235 A axis the single call
indexed 14.4% of frames against 15.9% with the 30/80/all escalation (79.7k -> 63.1k observations);
the escalation restored the frame count exactly (15.9%) on that set, on a P212121 set (73.6%) and on
lysozyme (55.0%). The lean seed matters for ffbidx on a long cell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
macOS: sign jfjoch_viewer and rugnux with a Developer ID and notarize them
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Failing after 3m56s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m51s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m6s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 17m41s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m52s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m19s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 21m26s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 12m53s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m2s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m48s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 14m55s
Build Packages / Generate python client (push) Successful in 28s
Build Packages / Build documentation (push) Successful in 1m4s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 26m8s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 22m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 29m3s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 27m12s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m36s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 28m42s
Build Packages / Unit tests (push) Failing after 1h23m15s
Build Packages / Validation Battery (push) Successful in 33m26s
82e5de1261
JFJOCH_MACOS_SIGNING_IDENTITY (the certificate's SHA-1) makes the install step sign the shipped
program with hardened runtime and secure timestamp: macdeployqt's -sign-for-notarization for the
viewer bundle, a codesign INSTALL(CODE) for rugnux. tools/notarize_macos.sh then submits the cpack
artefact to Apple, waits for the verdict and staples the ticket to the .dmg; the rugnux binary
inside the .tgz is submitted as a zip, as a bare executable cannot carry a ticket. Both macOS CI
jobs resolve the identity from the runner's keychain, check for the hardened runtime and notarize
on every push, so a rejection shows up with the change that caused it.

The identity is passed as the hash, not the name: Qt's generated deploy script writes
DEPLOY_TOOL_OPTIONS unquoted, so a name with spaces splits into several macdeployqt arguments. The
notarytool profile has to live in the login keychain file (store-credentials --keychain): the
default data-protection keychain becomes unreadable once the screen locks, which an unattended
runner's does.

Verified end to end on the runner: both artefacts accepted by Apple, the stapled image and a
quarantined copy of the app and of rugnux pass Gatekeeper as Notarized Developer ID, signing and
the credential lookup work from a launchd-started process with the console locked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A transform launched on a non-blocking stream straight after cufftPlanMany could read the
plan before its setup, still queued on the NULL stream, had finished: Xid 31, a read at a
fixed near-null address, "an illegal memory access" in the indexer threads. It showed at
transform length 3072, in 3 of 4 runs of one rotation set where a pool built its FFT
indexers at the start of integration (6 of 28 ci-tier sets, 9 of 25 null-model runs).
It went away under CUDA_LAUNCH_BLOCKING=1 and with a cudaStreamSynchronize(NULL) after
the plan (0 of 11 runs). The 3142 length the indexer used so far never hit it, but
nothing ordered it either.

Plans are built once per indexer, so the wait costs nothing measurable. The other cuFFT
users (beam-centre FFT, model structure factors, rigid body) create raw plans and are not
covered by this wrapper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Merge origin/rc175 (macOS signing and notarization) into rc175
Build Packages / Create release (push) Successful in 8s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m53s
Build Packages / build:portable:macos-arm64:nocuda (push) Failing after 10m37s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m38s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 19m15s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m49s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 23m5s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 23m47s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m48s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m39s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m52s
Build Packages / Generate python client (push) Successful in 26s
Build Packages / Build documentation (push) Successful in 1m0s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m25s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m44s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 21m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m32s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m48s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m46s
Build Packages / Unit tests (push) Successful in 1h37m57s
Build Packages / Validation Battery (push) Successful in 37m52s
76adf3f0fa
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CI macOS: hardened-runtime checks read codesign's output from a file
Build Packages / Create release (push) Successful in 6s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m21s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m16s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m0s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 17m35s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m32s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m40s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 16m39s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m12s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m33s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 13m50s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 17m0s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m28s
Build Packages / Generate python client (push) Successful in 20s
Build Packages / Build documentation (push) Successful in 1m2s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m57s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 23m9s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m19s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 24m24s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 20m59s
Build Packages / Unit tests (push) Failing after 2h0m28s
Build Packages / Validation Battery (push) Successful in 52m44s
212fa457d6
The viewer-bundle and rugnux-tarball checks piped `codesign -d` into `grep -q`. grep exits at the
first match, codesign then dies of SIGPIPE (141), and under `set -o pipefail` that is the pipeline's
status, so a correctly signed bundle was reported as "no hardened runtime". Both checks now write to a
file and grep it, as the other checks in this workflow do, and print the signature when they fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-ring value histogram of the plain ring pass was laid out [ring][value], so the counters of
neighbouring pixels - neighbouring rings, low values - lay 4 KB apart and fell in the same few L1
sets, evicting each other on every pixel. Indexed [value][ring] they share a few cache lines. The
sigma-clip passes read it value-major; integer sums, so the same totals.

Measured on a 16M-pixel frame (microbenchmark of AccumulateRingsBlock, Apple M6): 0.97 -> 0.80
ns/pixel on a P core, 5.3 -> 3.7 on an E core. The function was 9-38% of CPU on the battery sets run
CPU-only; one small-molecule set ran 4% faster end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-pass sigma clip of the background ring walked the reflection's whole box a second time,
recomputing every pixel's stencil distances and neighbour mask. Pass A now keeps the ring pixels the
clip reads (readable, not a neighbour's flux), with their radial offset, in pixel order, and the clip
sums those - the same values in the same order.

Measured on cytochrome C (CPU-only, Apple M6): the clip loop was 8.4% of CPU; the run is 4-5% faster
with this and the ring-histogram change together. Merged reflections and maps byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reseat_integrated changed the basis of every integrated reflection on the main thread. One image per
task now; each touches only its own outcome. Measured on 4nwv (CPU-only): 2.1 s of serial time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 20 shuffled copies are drawn in order from the one generator as before, then each greedy search
runs on a worker of its own and reads only its copy, so the nulls are the serial loop's bit for bit.
Measured on 4nwv (CPU-only): the analysis was ~3.9 s on the main thread, most of it these searches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RESULT-CHANGING (owner approved, ulp level): CPU profile fit in the GPU engine's arithmetic
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m55s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m12s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 12m20s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 22m9s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m11s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 10m37s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 13m30s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 15m22s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 14m5s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 18m9s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m7s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m10s
Build Packages / Generate python client (push) Successful in 21s
Build Packages / Build documentation (push) Successful in 1m2s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m31s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m6s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 17m42s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m34s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 19m24s
Build Packages / Unit tests (push) Failing after 1h54m39s
Build Packages / Validation Battery (push) Failing after 41m38s
ba3f754752
Pass B of BraggIntegrationEngineCPU now does what BraggIntegrationEngineGPU's fit does: profiles in
float, the Gaussian from expf with the widths' reciprocals taken once and normalised by a reciprocal,
and the reweighted fit in float with one reciprocal of the variance for its three sums (the GPU's
__frcp_rn). Per-reflection geometry and the final sigma stay in double. The logic was already the
same on both; the CPU now differs from the GPU only in summation order.

Measured (CPU-only, Apple M6): Pass B loops 1.2x faster on P cores; cytochrome C 1.9%, myoglobin 1.5%
faster end to end, 4nwv integration steps 3%. Intensities move by ~1e-6 relative; merged statistics
in the fourth digit (error model b 1.3259e-3 -> 1.3258e-3, R_work 0.2440 -> 0.2439).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ba3f75475 (CPU profile fit in single precision) left out: it made CPU-vs-GPU
agreement 3-5x worse (2-4e-7 -> 0.6-1.9e-6 relative) for 1.5-1.9% on CPU-only runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Scaling: the 80%-of-card check runs right after the device ingest's sort and cut, where the
  observation and distinct-hkl counts are first known, instead of after the geometry smoothing;
  same counts, same decision, but a run that goes to the CPU no longer does the device smoothing
  (6.9 s on an 8 GB card for a 33 M observation set) or holds its memory meanwhile.
- Rigid body: engines budgeted on a quarter of the card's total (the validation's plan), not
  min(total/4, free - 1 GB). A busy card no longer sends the rigid body to the CPU (measured on an
  8 GB card: ~80 s and a different fit); engine allocations wait for GPU work beside the run, and
  an engine that cannot be set up fails the validation (RigidBodyGPUFailure) instead of falling
  back to the CPU.
- Beam-centre FFT: GPU when its footprint is at most half of the card's total, otherwise CPU,
  logged; no catch-and-rescore on the CPU after a GPU failure.
- FitPerFrameG reserves its per-frame vector (CPU scaling waited on the heap lock on Windows).
- get_gpu_free_memory(): cudaMemGetInfo, except on an integrated GPU under Linux, where free also
  counts MemAvailable less a tenth of the total. Used only where free memory is waited on
  (cuda_memory_available) or quoted in out-of-memory messages; it decides nothing.
  The two card-filling [CUDAMemHelpers] tests skip on integrated GPUs.
- Out-of-memory messages point at rugnux --no-gpu instead of "the CPU build".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every TimingMark ("TIMING <section>") and every rugnux step ("TIMING step ...") now
opens an NVTX v3 range of the same name, so Nsight Systems traces from different
machines can be lined up by pipeline step. Start/end ranges, not push/pop, so a
range may end on another thread and parallel arms may overlap. Header-only NVTX
(CUDA::nvtx3), a no-op without a profiler attached and compiled out without CUDA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gpu_direct_host_read() (CUDAWrapper) is true where the device accesses pageable
memory through the host's page tables (cudaDevAttrPageableMemoryAccess and
...UsesHostPageTables: GB10, GH200, GB200), asked once per device;
JFJOCH_FORCE_DIRECT_HOST_READ forces it on for testing with page-locked inputs.
Where it holds:
- BSLZ4DecoderGPU: the LZ4/fused kernels and the tail copy read the reader's
  compressed chunk directly, no pinned staging memcpy and no H2D copy;
- ImagePreprocessorGPU: Analyze and the UINT16_WIDENED path hand the host
  buffer to preprocess_kernel (if aligned to the pixel type), and nothing is
  page-locked;
- RotationScaleMergeGPU::IngestUpload: IngestExtractKernel reads the pinned
  stage in place, without device staging buffers; the stage is reused only
  after the kernel has run.
Discrete cards take the unchanged path. Every caller synchronises its stream
before the host buffer goes away. DirectHostReadGPUTest checks the three
sites against the CPU on page-locked inputs, in either mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every cuModuleLoadData/Unload in a run is cuFFT making or destroying a plan, and each plan loads
its own kernel modules under a process-wide lock: over a second a plan on a busy GB10, and a PTX
compile per load on Windows. The FFT indexers, rigid-body engines, structure-factor engines,
molecular replacement and beam-centre FFT each made their plans afresh and destroyed them again,
and the sizing functions (rigid-body and structure-factor FFTWorkBytes, beam-centre
DeviceMemoryNeeded via cufftEstimate*) made throwaway plans only to read a work size.

CudaFFTPlan now borrows a plan from a per-process cache keyed by device and full plan shape
(CudaFFTShape: n, embeds, strides, distances, type, batch). A plan is lent to one user at a time
(cuFFT is thread safe only across distinct plans), goes back when its owner is done - after a wait
for the owner's stream - and is never destroyed. Kept plans hold no work area: the user passes its
own, or CudaFFTPlan allocates one from the pool while it is borrowed. The sizing functions read the
work size from the kept plan (CudaFFTPlan::WorkBytes), which the engines then reuse. Plan creation
keeps the NULL-stream wait of 90ba15871, which now also covers the rigid-body, structure-factor
and molecular-replacement plans that were raw cufftHandles without it. A rigid-body engine hands
its previous zone's plans back on SetZone. FitsInDeviceMemory answers false where cuFFT cannot
plan the transform, as the ignored cufftEstimate failure did.

Measured with nsys on an RTX 5080: cuModuleLoadData/Unload 450/450 -> 303/51 on cytc_x10sa,
458/458 -> 161/51 on 4nwv (the remaining unloads are cuFFT's own inside plan creation; the
remaining loads are plans that are alive at the same time, mostly the concurrent rigid-body
engines). Wall unchanged on this workstation (ABAB), where a load costs ~1 ms; the gain is for
GB10 and Windows. Outputs bit-identical (p.hkl and the refined model on 4nwv, cytc_x10sa,
lyso_x06da_ref); cufftPlan3d vs cufftMakePlanMany with a caller work area checked bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan cache of 310222156 never destroyed its plans: a long-lived process would carry every
shape it ever planned, and rugnux's cudaDeviceReset at exit (release_gpu_contexts) left the static
cache holding handles of a reset context. Plans are now kept only while a CudaFFTPlanScope is alive
(reference counted); when the last one ends every kept plan is destroyed on its own device, and
outside any scope a plan is destroyed as soon as its user is done - what every cuFFT user did
before the cache. rugnux opens one scope for the whole run (FFTPlanReuse, a CUDA-free wrapper, in
RunRugnux), so all its modes keep the reuse and the plans go before the device reset.
jfjoch_viewer and jfjoch_broker open none: their plans live exactly as long as the indexer that
holds them, as before, and nothing carries over to the next processing or collection.

Measured with nsys on an RTX 5080: cuModuleLoadData unchanged from 310222156 (cytc_x10sa 303,
4nwv 161, down from 450/458 before the cache); every load is now unloaded at the end of the run
(303/303, 161/161). An idle plan of the indexer shape (3142 x 16384 R2C) holds 1.6 MB of device
and 1.3 MB of host memory, a 3D grid plan 0.26 MB device. Outputs bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	image_analysis/geom_refinement/BeamCenterFFTGPU.cu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	common/CUDAWrapper.cu
SpotTiles_MatchWholeFrame_GPU: wait for the upload before the squares are cut
Build Packages / Create release (push) Successful in 7s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Failing after 1m18s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 8m39s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 12m27s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 17m23s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m5s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m8s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 20m52s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 23m35s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m45s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 16m28s
Build Packages / Generate python client (push) Successful in 28s
Build Packages / Build documentation (push) Successful in 1m6s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m46s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 21m12s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 24m56s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m25s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 24m47s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 27m16s
Build Packages / Unit tests (push) Successful in 1h52m27s
Build Packages / Validation Battery (push) Failing after 36m29s
228f0fc00f
cudaMemcpy from pageable memory may return before the copy lands on the card, and
ImagePreprocessorBufferGPU gathers its squares on a non-blocking stream that does not wait for the
NULL stream, so on a loaded GPU the squares could be cut from the previous frame. Test-only race;
the pre-scan's engines synchronise their own streams before the buffer is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Metal Bragg integration engine on macOS, output unchanged (bit-identical to the CPU engine)
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m16s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m41s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m24s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 14m56s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 22m55s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m7s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 13m56s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 19m6s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 19m0s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m33s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 17m43s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 20m21s
Build Packages / Generate python client (push) Successful in 31s
Build Packages / Build documentation (push) Successful in 1m25s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 19m20s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 24m18s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 23m34s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 24m21s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 20m7s
Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / Validation Battery (push) Successful in 35m52s
f05e67b2b4
BraggIntegrationEngineMetal is BraggIntegrationEngineCPU with its integer pixel work run as Metal
kernels, one thread per reflection: the neighbour mask, the owner map, pass A's walk over every box
and the background clip. Everything in double, the profile learning and pass B stay on the CPU, in
the CPU engine's own code - Apple GPUs have no double, and no GPU exp matches macOS expf bit for bit.
The sums are integers, so the GPU's order does not matter; the stencil arithmetic is the same
BraggStencil.h, compiled at run time (MSL 3.0, no offline Metal toolchain needed) with fast math off
(sqrt and division correctly rounded, measured) and fp contraction off, as the host already builds
(-ffp-contract=off, now extended to Objective-C++). The image is read in place (unified memory).

The CPU engine is split so both engines share the finishing code: pass A counts into BraggBoxSums
(integer; the double ring sums were exact integers already), the clip threshold is its integer floor,
FinishPassA and ProfilePasses are shared. Verified byte-identical p.hkl and MTZ data before/after the
split and with the Metal engine on lysozyme, cytochrome C, myoglobin, 4nwv and a small-molecule set.

Measured on an Apple M6 (wall, CPU engine -> Metal): 4nwv 180.5 -> 151.2 s, cytochrome C 72.7 ->
59.8 s, lysozyme 29.2 -> 22.5 s, myoglobin 43.3 -> 41.2 s; the sparse small-molecule set +1.5 s
(few reflections per frame leave the GPU idle).

CI: a macOS step runs tests/BraggIntegrationEngineMetalTest.cpp, which requires every float of every
reflection and every count to match the CPU engine over 19 setting combinations on random crowded
frames (negative pixels, exact-pixel ties, hot, masked and overloaded pixels), three frames per
engine; it fails without a Metal device and if the GPU path fell back to the CPU. Ten planted kernel
bugs were each caught. rugnux --no-gpu hides the Metal device too (gpus_are_hidden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loop's corr now lives in the group CSR's order next to the references (the partials in no group
after them), so the group means read both sequentially instead of gathering corr through the
permutation, and the corr update walks the same arrays. FitPerFrameG's filters, which do not change
over the loop, are applied once into a per-frame ScalingFit list with the reference-independent
factors precomputed (FitScalingFrames). ReferenceWeight is inline, and the group means are no longer
pre-filled with NaN serially.

Same values, same operations in the same order: p.hkl and the merged and unmerged MTZ data are
byte-identical on lysozyme, cytochrome C, myoglobin, a small-molecule set and 4nwv. On 4nwv (Apple
M6, 12 threads) the partial scaling iterations take 3-4x less time, ~18 s -> ~6 s over 168
iterations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Model validation: one GPU budget for all its parts, 80% of the card's total
Build Packages / Create release (push) Successful in 22s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Failing after 1m31s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 9m12s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m39s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 12m39s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 23m36s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m15s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 15m15s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 14m8s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m36s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 17m13s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 17m33s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m58s
Build Packages / Generate python client (push) Successful in 28s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 24m10s
Build Packages / Build documentation (push) Successful in 1m9s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m10s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m23s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m59s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 21m53s
Build Packages / Unit tests (push) Successful in 2h5m52s
Build Packages / Validation Battery (push) Successful in 36m32s
0d25d765cf
What a validation puts on its GPU was decided part by part on fixed fractions of the card: the
structure factors to d_min on a quarter, the null's structure factors on whatever of that quarter
the first left, the rigid-body engines on another quarter, the scale fits on an eighth. On an 8 GB
card that sent the null's structure factors of a 4.4 GB validation to the CPU (and held the rigid
body to one engine) although everything the validation holds fits the card with room to spare.

Now PlanModelValidationGPU decides all of it at once, before anything is reserved, from the card's
total memory: every engine is reserved up front and kept to the end, so their sum is held against
one budget, 80% of the card less what a merge engine still at work holds there (the finalist
ledger's P1 merge, RotationScaleMerge::DeviceBytes). Each part takes one engine where it still fits,
in the order structure factors to d_min, rigid body, the null's structure factors, scale fits; a part
that does not fit runs on the CPU. What is left goes to more rigid-body engines (up to one per null
replicate plus the real fit) and then more scale-fit engines, which only change how many fits run at
once. A second validation may run beside where twice this one's whole GPU plan fits the cards'
budgets; it gets this one's bytes as gpu_beside_bytes, which limit its engine counts and never its
placement, so its numbers are those of the validation it stands in for. One log line states the
placement, the bytes held and the budget.

- RigidBodyGPUPool::Plan sizes the pool without engines (EngineBytes), Reserve(n) makes them; Create
  (Plan + engines on a quarter of the card) stays for molecular replacement, whose own rules, and the
  --mr hand check's structure factors (ReadModelAnomalousMap, a quarter), are unchanged.
- The null's resolution is worked out before the plan, so its engine is decided and reserved with
  the others instead of after the indexing probe.

Placement changes only on smaller cards: with the logged footprints of the open-arm sets, 4nwv's
null structure factors move to the GPU below ~9 GB (and its rigid body at 6 GB), 8tyy's structure
factors to d_min below ~8.7 GB; on 16 GB and larger nothing moves. On the RTX 5080 (16 GB), 4nwv,
8tyy, 6oel and 3inp give byte-identical reflection files, maps and models to rc175 HEAD, with more
rigid-body engines where they fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RESULT-CHANGING (owner approved, ulp level): Metal profile fit, CPU Gaussian from a shared exp
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m54s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m49s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m58s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 18m10s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m35s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m13s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m25s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 25m6s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m48s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m15s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m34s
Build Packages / Generate python client (push) Successful in 26s
Build Packages / Build documentation (push) Successful in 1m1s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m5s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m36s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 24m54s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 22m7s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m53s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 28m8s
Build Packages / Unit tests (push) Failing after 3h11m38s
Build Packages / Validation Battery (push) Successful in 33m42s
229a432a66
Pass B of BraggIntegrationEngineCPU is split where a device can take over: per reflection, what the
fit needs is worked out in double (BraggFitSetup), the profile, its readable mass and the reweighted
fit run in single precision (FitProfile -> BraggFitOut), and the error, the summation guard and the
result are finished in double. BraggIntegrationEngineMetal runs the middle as a Metal kernel, one
thread per reflection, summing in the CPU's order (FitOnDevice); the CUDA engine is untouched.

The Gaussian's exp is now BraggExp (BraggExp.h), on the CPU and on the GPU: no GPU exp reproduces the
host's expf, and BraggExp uses only +, -, * and exact power-of-two scaling, so both compile it to the
same bits. Checked on the Apple M6 over every float in [-110, 89] (2.2e9 arguments), with subnormal
products and sums matching too. It is closer to correctly rounded than macOS expf (max 0.53 ulp;
0.2% of arguments below -1 not correctly rounded vs 0.62%). This is the one result change: per
observation |dI|/sigma p99.9 <= 1.7e-6, max 1e-4, 10-30x below what ba3f75475 moved. Merged
statistics (resolution, completeness, CC1/2, R_meas, I/sigma, ISa, space group) are identical on
lysozyme, cytochrome C, myoglobin, a small-molecule set and 4nwv; only tNCS fields that flip on any
last-digit change move (cytochrome C's pseudo-translation flag, whose modulation ratio sits at the
2.5 threshold, turns off).

The Metal engine stays bit-identical to the CPU engine: BraggIntegrationEngineMetalTest passes
unchanged, and catches planted kernel bugs (another exp, 3 iterations, a dropped lost-peak test, <=
for the disk, inverted exclude logic).

Measured wall time, Apple M6 (CPU pass B -> GPU pass B): lysozyme 24.7 -> 18.6 s, myoglobin 39.8 ->
35.7 s, cytochrome C 59.3 -> 49.0 s, 4nwv 150.2 -> 131.3 s; the small-molecule set, sparse frames,
92.5 -> 95.6 s (noise of +-3 s). Metal's fast::exp in the kernel instead measured the same on every
set (18.6, 35.8, 49.1, 131.2 s), so it would buy nothing for the bitwise check it would cost. On the
CPU alone BraggExp costs nothing measurable (cytochrome C --no-gpu 72.8 -> 73.1 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`battery.py timing --rugnux BIN` runs myob_x10sa and cytc_x10sa from their AlphaFold DB
predictions (AF-P68082-F1 and AF-P62894-F1, v6, committed verbatim with their predicted
aligned error under tools/battery/models/, CC BY 4.0) and 4nwv from its deposited model with
strict NCS expanded and moved by a fixed rigid-body transform (30 deg about (0.36, 0.48, 0.80)
through the centroid, shift (12, -18, 25) A), made at run time from the site's pdb_cache. Each set
runs the battery's own command for its arm, in an ordinary run directory, so the site config,
results.json, report, table and compare all apply.

A set passes when the battery's own verdict passes, MODEL_PLACEMENT is MR, MR_SPACE_GROUP is the
expected group with its hand, R_FREE is under its limit, CC_MODEL_CONFIRMED_TO_D_MIN is not NONE,
and the placed model's intensities correlate with the reference model's (|F|^2, shell-normalised,
10-4 A, best over the lattices' changes of basis) at 0.30 or more. That last check is independent
of rugnux's scores: correct placements give 0.55 / 0.90 / 0.96; the myoglobin placement turned by
30 deg with rugnux's report unchanged gives 0.035, the unplaced 4nwv start model 0.011, a
placement relabelled to the other hand 0.18. Rows carry the checks and the per-step times from the
TIMING step lines; the report gets a Timing benchmark section; the run exits 1 on any failure.

On the workstation (RTX 5080, rc175 8eacce): myoglobin passes in 10 s (R-free 0.44), 4nwv in 106 s
(MR 34 s, R-free 0.315 against 0.297 for the deposited placement). Cytochrome c fails: MR places
three copies of the monomer in both P 32 2 1 and P 31 2 1 within 0.03 in R-free and so places
neither (MR_DECISION UNDECIDED), where the three-copy reference model decides the hand at once.

battery.py now imports fcntl only for the full-run lock, falls back to Popen.kill where there are
no process groups, and keeps the binary's extension in the run directory, so a timing run can work
on Windows (untried there).

Licence and credit: licenses/alphafold-db.txt (from models/LICENSE.txt via COLLECT.sh), a new
"Data files in the repository" table in THIRD_PARTY_NOTICES.md, and the AlphaFold DB with Jumper
et al. 2021 and Varadi et al. 2022 and 2024 in docs/ACKNOWLEDGEMENT.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CUDA engine is untouched: on the RTX 5080 p.hkl is byte-identical to rc175 on 4nwv, lysozyme
and cytochrome C. Under --no-gpu (lysozyme, cytochrome C, myoglobin) intensities move at the ulp
level from the single-precision fit and nothing changes beyond noise in battery compare; wall
44/117/71 s -> 41/105/65 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RotationScaleMerge: CPU fulls scaling loop on a working copy, combine prefetches, output unchanged
Build Packages / Create release (push) Successful in 18s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m53s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m37s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 12m1s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 14m35s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 22m23s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m33s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 15m13s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 11m10s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m58s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 15m55s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 15m21s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m20s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m14s
Build Packages / Generate python client (push) Successful in 28s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 20m30s
Build Packages / Build documentation (push) Successful in 51s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 14m3s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 20m46s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 14m33s
Build Packages / Unit tests (push) Successful in 1h26m4s
Build Packages / Validation Battery (push) Failing after 36m1s
bd27d802b7
The fulls' scaling loop now works as the partials' loop does since 4e177bdb2: a compact copy of the
fulls in the group CSR's order (ScalingRef, corr beside it), so the group means read sequentially
instead of gathering eighty-byte Obs through fulls_group_perm, and the per-frame fit over a
ScalingFit list built once with the Unity model's filters (FitScalingFrames, which takes the minimum
count as a parameter). FitPerFrameG, SolveScale and ReduceFullGroupMeans had no callers left and are
gone; their rationale comments moved to where the filters and the fit now are.

Combine prefetches the partials of the raw hkl 8 runs ahead: a run's partials lie a frame apart in
memory and the work per rocking event kept the CPU from reaching the next runs' loads, so it waited
on a cache miss for nearly every partial.

Same values, same operations in the same order: p.hkl and the merged MTZ data are byte-identical on
lysozyme, cytochrome C, myoglobin, a small-molecule set and 4nwv. Measured on 4nwv (Apple M6, two
interleaved runs each): the combine-and-scale-fulls stages 12.6 / 12.7 s -> 5.6 / 8.9 s; the scaling
steps (5 + 9) 54.6 / 53.0 s -> 52.0 / 51.0 s; total wall time is within run-to-run noise (127-131 s).
Cytochrome C scaling and merging 3.35 / 3.43 s -> 3.19 / 3.19 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scaling engine held far more on the card than any step needed, and the
image passes' pooled memory was still reserved when it started.

- The image passes' allocation pool is trimmed before the scaling's arrays
  are allocated (they are not pooled): 4.3-8.8 GB of the 16 GB card was held
  by others when the scaling started, now 1.5-2.3 GB (context, cuFFT and the
  run's FFT indexers, which live for the whole run).
- A new grouping or combine frees the previous one's fulls, CSRs and merge
  results before allocating its own; the final merge of a large cell used to
  hold the P1 search's per-group arrays beside its own.
- The partials' group CSR goes at the combine; the arrays only the fulls'
  scaling and download read (records, zeta, frame CSR, group means, image
  number, px/py, clipped) go once the fulls are on the host.
- Merge buffers live only in the call that needs them: the error-model sums
  and sample places, the outlier medians, half-sets and CC factors, the R_meas
  sums; MergeAccum's per-group results go once downloaded (MergeAccumRelease).
- The combine's per-run scratch is local to the combine.
- Ingest: both radix sorts run on cub::DoubleBuffer (no cub copies of keys and
  values), the sort outputs are allocated after the raw-hkl key is gathered and
  freed, the uncut runs and old order go before the observations are cut, and
  the delta_phi re-solve takes the hkl from its run, so the per-observation
  64-bit key no longer outlives the sort.

Measured with per-array device accounting on an RTX 5080, engine peak after
the GPU/CPU decision (MB, before -> after; largest engine of each set):
  4nwv  32.7 M obs, 15.4 M hkl:  7536 -> 4265
  8qaw 122.7 M obs,  8.6 M hkl: 11716 -> 9327 (ingest 12533 -> 10756)
  9v16  54.7 M obs,  5.7 M hkl:  5831 -> 4283
  7qij  78.1 M obs,  2.2 M hkl:  6108 -> 5242 (ingest  7978 ->  6768)
  8sa8  50.0 M obs,  5.0 M hkl:  5490 -> 4205
The ingest's own peak went from ~104 to 82-100 bytes per reflection.

GPUScalingPeakBytes refitted on fourteen engines of these five sets to the
peak after the decision it makes (the ingest has passed by then): 81 B/obs +
291 B/hkl -> 63 + 115, within 12%. DeviceBytes refitted as an upper bound:
67/260/200 -> 66/94/52 per obs/event/hkl (up to 18% over), ingest
103/58 -> 89/56 per reflection/run. With it the all-observation arm now runs
beside the first merge on a 16 GB card for 4nwv, 9v16 and 8sa8.

p.hkl byte-identical to the rc175 baseline on 15 battery sets (the five above,
11if, 5reo, 6oel, 9jzo, 9min, metformin, lyso_x06da_ref, thau_bl1a_3p8keV,
insu_I_x06da_5keV_2, myob_x06da), p.mtz identical apart from the history;
battery compare shows no change. [RotationScale] and
ErrorModel_DeviceFitIsTheHostFit pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On an 8.55 GB card (WDDM) the validation planned 6.81 GB of a 6.84 GB budget, but the card already
held more than the 20% headroom allowed, and the driver paged GPU memory to system RAM: every
rigid-body fit ran ~5x slower. Measured on 4nwv on a 16 GB card, at the start of model validation
the device held 1.1 GB beyond a fresh context, and on 6oel 2.7 GB:
  - the stream-ordered allocation pool's free-but-reserved memory, left by the image passes and the
    scaling's pooled buffers (1.07 GB on 4nwv, 2.1 GB on 6oel); the validation's engines are allocated
    outside the pool and cannot reuse it;
  - the image pass's FFT indexers (IndexerThreadPool), alive for the whole run (0.8 GB on 4nwv,
    0.5 GB on 6oel), although nothing indexes after the merge.
Both are now released: the validation trims the pool (cuda_trim_allocation_pool) before it plans,
and rugnux drops the indexer pool once the leftover-lattice census, its last user, has ended, before
the model is validated or searched for. What is left beside the validation is 0.06 GB over a fresh
context on both sets (streams, idle cuFFT plans).

Extra engines are now added only within half of the budget. They change no number and bought little
where measured (4nwv, 16 GB card: 13.6 s with one rigid-body engine, 13.1 s with two, 12.6 s with
five), so the card keeps headroom instead. On 16 GB 4nwv now plans 2 rigid-body and 2 scale-fit
engines, 6.56 GB (measured peak 7.3 GB with the context); on an 8.55 GB card one of each, 4.42 GB.

Reflection files, maps and models byte-identical to rc175 HEAD on 4nwv, 8tyy, 6oel and 3inp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
p.hkl byte-identical on the RTX 5080 for 4nwv, lysozyme, cytochrome C, myoglobin, 8qaw and 9v16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the RTX 5080 nothing moves between GPU and CPU and the output is byte-identical; on cards below
about 9-10 GB parts that used to fall back to the CPU now stay on the GPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	image_analysis/scale_merge/RotationScaleMerge.cpp
- The resident ingest reads the reflections twice: first only what the sort
  and the cut need (frame position, resolution, background, raw-hkl key),
  then - once the GPU/CPU decision has been made on the exact counts - the
  observations the cut kept, written straight to their place. The
  observation arrays and the sort buffers are never on the card together,
  and no observation field is cut (held twice) any more.
- A set too large even for that sort scales on the CPU before anything is
  allocated on the card (ScaleOnCPUWhereSortTooLarge, same 80%-of-total rule).
- The predicted detector position never goes to the card: it is taken on the
  host, and the combine emits the index of each full's peak partial instead
  of its px/py (8 B less per observation and 4 per full).
- A full carries its raw-hkl run index instead of h, k, l (8 B less per
  full); the constant-1 partiality array of the fulls is gone.
- The fulls' CSR sorts and the error-model rank sort run on cub::DoubleBuffer.

Measured with per-array device accounting on an RTX 5080, largest engine of
each set, before (c130c7923) -> after, MB:
  set   obs/hkl          ingest peak (B/refl)        post-decision peak
  4nwv  32.7 M/15.4 M     3277 (100) ->  2336 (72)    4265 -> 3625
  8qaw 122.7 M/ 8.6 M    10756 (88)  ->  7421 (61)    9327 -> 8156
  9v16  54.7 M/ 5.7 M     4838 (89)  ->  3419 (63)    4283 -> 3774
  7qij  78.1 M/ 2.2 M     6768 (87)  ->  4707 (60)    5242 -> 4707
  8sa8  50.0 M/ 5.0 M     4107 (82)  ->  3134 (63)    4205 -> 3603
The sort before the decision now holds 32 B per reflection (46 on a P1-like
set with many runs); the engine's overall peak is after the decision on all
of them.

GPUScalingPeakBytes refitted on the same fourteen engines: 63 + 115 ->
56 B/obs + 99 B/hkl, within 9%. DeviceBytes as an upper bound: 66/94/52 ->
58/30/86 per obs/event/hkl, ingest 89/56 -> 60/42 per reflection/run.

p.hkl byte-identical to the rc175 baseline on 15 battery sets (4nwv, 8qaw,
9v16, 7qij, 8sa8, 11if, 5reo, 6oel, 9jzo, 9min, metformin, lyso_x06da_ref,
thau_bl1a_3p8keV, insu_I_x06da_5keV_2, myob_x06da); compare shows no change.
[RotationScale] (RotationIngestGPU now also checks the host positions) and
ErrorModel_DeviceFitIsTheHostFit pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8qaw's scaling engine peak 10.76 -> 8.16 GB; p.hkl byte-identical on 15 battery sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- IndexerThreadPool::ReleaseIndexers(): each worker drops the indexers it has
  built (once its current frame is done) and the allocation pool is trimmed, so
  their memory goes back to the card, not only to the pool. A later frame builds
  them again, as on first use. A rotation pass calls it once its first pass is
  done and again when the leftover-lattice census - the last of its indexing -
  ends. Measured on the RTX 5080: the 1.24 GB (pre-pass) / 0.62 GB (later
  passes) the pass's pool held through the image pass and the scaling is gone;
  4nwv's pre-pass image pass peaks 5.97 instead of 7.18 GB of device memory.
- GPUMerge: the main line holds one through ScaleMergeAndSymmetry. The
  speculative geometry probe (GPUWorkBeside(after_merges=true)) waits for it on
  a card below the take-turns size (12 GB), where the probe's FFT indexers and
  image workers beside the merge's arrays would overfill the card - on a
  Windows (WDDM) card that does not fail an allocation but pages it and crawls.
  The merge waits for nothing, so nothing the merge waits on (the rotation-scale
  walk's probes) can wait in a circle with it. No change on a 16 GB card.

Output unchanged: p.hkl byte-identical on cytc_x10sa, myob_x10sa, 4nwv and
8qaw on this card; also identical with the card's total read as 8.55 GB
(10 / 9 image workers instead of 16) and with 4 image workers, so the worker
count does not change the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two space groups of a merge whose placements are within R_FREE_MARGIN (0.03) of each other in R-free
left the choice UNDECIDED. Where both placed the same number of copies they have the same parameters,
so the working reflections compare them as fairly as the free ones, and there are about twenty times as
many: the other group is now ruled out where the chosen placement's |F_obs - F_calc| is lower by 3
standard errors of the paired per-reflection difference over all reflections to the scoring limit.
Groups with different copy counts, and the tNCS carry rule, are unchanged.

Found on the timing benchmark's cytochrome C set started from the bovine AlphaFold model (the
sequence-exact one): three copies placed in both P 32 2 1 (R-free 0.427) and P 31 2 1 (0.437), so
MR_DECISION was UNDECIDED. Not a regression: the 2026-10-11 tNCS work ran this set with the horse
model, whose first copy did not place in P 31 2 1 at all, so P 32 2 1 was chosen with no tie (still
so at rc175 HEAD). With the 380 free reflections at 3 A the two hands differ by 0.010 +- 0.012 in
R-free; over all 7285 reflections by 0.029 at 11 standard errors. Now ENANTIOMORPH P 32 2 1, 3 copies,
placement CC 0.744.

MR replay survey of 193 sets (AlphaFold models): multi-copy UNDECIDED 7 -> 4, all three now correct
(7q6j P 21 21 21; both cytochrome C x06da sets P 32 2 1); one PAE-domain case (8tha) breaks its whole-
model hand tie towards the deposited group but stays UNDECIDED by the domain rule; no other set changes,
no wrong decision. Every tie the rule decided did so at 8.5-12.4 standard errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cytochrome C from its sequence-exact AlphaFold model now places in P 32 2 1; MR replay survey of 193
sets: UNDECIDED 7 -> 4, all three newly decided correctly, no wrong decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The partials lie in frame order, so a partial's frame is the last frame
  starting at or before it in the frame CSR (FrameOf, a binary search) and is
  no longer stored: UpdateCorr, SmoothCorr (which takes the fulls' stored
  frames through the same FrameOf), FilterFrame, the combine, and the
  ingest's flux, delta_phi and partiality kernels. 4 B less per observation.
- on_ice and clipped/overloaded share one flags byte (on ice at bit 0, the
  host's OBS_CLIPPED / OBS_OVERLOADED one bit up); a full's on_ice byte is
  0/1, so the kernels shared by the two read either alike. 1 B less.

Measured on an RTX 5080, largest engine of each set, round 2 -> round 3, MB:
  set   obs/hkl          ingest peak (B/refl)      post-decision peak
  4nwv  32.7 M/15.4 M    2336 (72) -> 2172 (67)    3625 -> 3461
  8qaw 122.7 M/ 8.6 M    7421 (61) -> 6808 (56)    8156 -> 7543
  9v16  54.7 M/ 5.7 M    3419 (63) -> 3146 (58)    3774 -> 3500
  7qij  78.1 M/ 2.2 M    4707 (60) -> 4317 (55)    4707 -> 4317
  8sa8  50.0 M/ 5.0 M    3134 (63) -> 2884 (58)    3603 -> 3353
Kernel time on 8qaw (nsys, all calls): UpdateCorr 364 -> 365 ms, SmoothCorr
143 -> 112 ms, combine 477 -> 476 ms, ingest partiality/delta_phi/flux
113 -> 109 ms - the search costs nothing measurable, the saved reads pay for it.

GPUScalingPeakBytes refitted: 56 + 99 -> 51 B/obs + 98 B/hkl, within 10%.
DeviceBytes: 58 -> 53 B per observation, ingest 60 -> 55 B per reflection.

p.hkl byte-identical to the rc175 baseline on 15 battery sets (4nwv, 8qaw,
9v16, 7qij, 8sa8, 11if, 5reo, 6oel, 9jzo, 9min, metformin, lyso_x06da_ref,
thau_bl1a_3p8keV, insu_I_x06da_5keV_2, myob_x06da); compare shows no change.
[RotationScale] and ErrorModel_DeviceFitIsTheHostFit pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8qaw's scaling engine peak 8.16 -> 7.54 GB (12.5 GB before round 1); p.hkl byte-identical on 15 battery sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The speculative geometry probe and the rotation-scale walk's indexing probes
gave their indexers' and image workers' buffers back to the allocation pool
only, which kept them reserved under the main line's merge and analyses: 5.5 GB
on 8qaw and 5.3 GB on 4nwv on the RTX 5080, until the end of the run. The pool
is trimmed (every card: cuda_trim_allocation_pools) when such a probe ends, as
IndexerThreadPool::ReleaseIndexers now does through the same helper.

Measured (RTX 5080): device memory held at the end of the final merge 8.7 ->
3.4 GB (4nwv), 14.5 -> 7.7 GB (8qaw). Output unchanged: p.hkl byte-identical
on cytc_x10sa, myob_x10sa, 4nwv and 8qaw, on this card and with its total read
as 8.55 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What molecular replacement put on its GPU was decided part by part on a quarter of the card each: every
group's rigid-body pool (up to three engines), every null's (up to nine), the score engines' cache, the
--mr hand check's structure factors. The groups and the nulls run side by side, so the quarters added up:
on 4nwv on a 16 GB card the search held 4 rigid-body engines of 1.88 GB, two score engines of 1.64 GB,
the 1.82 GB molecular transform, two translation functions' 1.2 GB and the 2.4 GB the allocation pool
kept from the validation before - 13.8 GB at its peak, 83% of the card. On an 8.55 GB card that pages.

Now each merge's search is planned once, before it starts, on the budget model validation uses
(ModelGPUBudget: 80% of the card's total, less what a merge engine still at work holds): the transform,
one rigid-body engine and the largest score engine take their place in that order where they still fit,
more rigid-body engines only within half of the budget, and what is left is a room the score engines
(least recently used given up first, as before) and the rotation and translation functions' working
buffers share, each waiting for the other to finish rather than spilling past it
(PlanMolecularReplacementGPU). A part that does not fit runs on the CPU, decided on its size alone.

- One rigid-body pool per merge serves every fit of the merge - the groups' first and further copies, the
  nulls, the tNCS shifts, the carried copies - sized for the largest (the most copies Matthews allows, in
  any group) and reserved once, instead of a pool made and freed for each. RigidBodyGPUPool::Cover widens
  a planned pool to another model or group; its zones are kept by group, atom count and resolution.
  RigidBodyGPUPool::Create (the quarter rule) is gone.
- The sizes are worked out on the CPU while the transform is computed.
- The molecular transform keeps only the indices it is read at (|k|, l <= box / d_min + 1): 0.84 GB
  instead of 1.82 on 4nwv, host and device. Its rows keep their full length: cut short too, the rotation
  function read them 15% slower.
- The translation function's prepared setup is kept on the host and uploaded by each call, so the device
  holds it only while a function runs; the cross-term offsets are uploaded only where there are cross terms.
- The allocation pool is trimmed before the search, after the transform's GPU FFT, before a score
  engine is made and before the hand check, whose structure factors are now judged against the budget.
- rugnux passes the merge engine's bytes, where the finalist ledger keeps it, to the search, the hand
  check and the validation of the placed model.

Measured on the RTX 5080 (16.6 GB), timing benchmark: peak device memory during MR 13.8 -> 8.7 GB on 4nwv
(start 3.0 -> 0.66 GB after the trim), 1.67 -> 1.14 GB on cytc, 6.3 -> 0.76 GB on myob. On 4nwv the plan
holds the transform 0.84 GB, one rigid-body engine of 2.10 GB and leaves 10.35 GB of a 13.29 GB budget
to the score engines and the functions; a second engine would pass half of the budget (one engine
against two: +1.2 s on the rescoring and the null). MR 31.7 -> 31.6 s on 4nwv. Reflection files, maps,
placed models, reports and the MR candidate lines byte-identical to rc175-metal-merge on myob, cytc and
4nwv; [MolecularReplacement], [ModelValidation], RigidBody*, ModelScale*, [CUDAMemHelpers] pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The molecular transform's FFT went to the GPU only where its buffers took at most a quarter of the card
(MapToFPhiGPUFits), the last quarter rule left in molecular replacement. On a large cell that is never:
4nwv's 768^3 box needs 5.45 GB, so on a 16 GB card it ran on the CPU in FFTW, single-threaded - 12.5 s
of the search's 31 s.

Now it is judged, as every other part of the search, against the search's budget (80% of the card less
what is resident; the search holds nothing else yet when it runs), and the device writes only the
cropped transform the search keeps (CropHalfL, moved to ModelFFT), so it needs 4.46 GB instead of 5.45.
cuFFT and FFTW agree to float rounding, not bit for bit, so wherever the transform moves to the GPU the
search's functions can differ in their last bits: on 4nwv on 16 GB and 8.55 GB cards it moves (it now
does on any card above about 5.6 GB, before above 21.8 GB); the small boxes were on the GPU before and
give the same values (the same kernel, cropped).

Measured on the RTX 5080, timing benchmark: 4nwv's transform 14.1 -> 2.2 s, MR 31.6 -> 20.1 s, run
93.0 -> 89.2 s; MR's peak device memory unchanged at 8.7 GB (the FFT's own 5.2 GB comes before anything
else is held). On this card the reflection files, maps, placed models, reports and the MR candidate lines
of myob, cytc and 4nwv still came out byte-identical to rc175-metal-merge. [MolecularReplacement] passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4nwv MR peak 13.77 -> 8.74 GB on the RTX 5080; myob, cytc and 4nwv outputs identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4nwv molecular replacement 31.7 -> 20.1 s; outputs identical on myob, cytochrome C and 4nwv on the RTX 5080.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CUDA::nvtx3 is only an alias for CUDAToolkit_INCLUDE_DIRS. In the aarch64 SBSA cross build that is
targets/sbsa-linux/include, and the cross-sbsa packages the image installs (cudart, crt, culibos,
cufft) do not put nvtx3 there, so Logger.cpp failed with "nvtx3/nvToolsExt.h: No such file or
directory". NVTX v3 is header-only and architecture-neutral, so the headers are now looked for in the
toolkit include dirs and then in the include dir beside nvcc (the host tree, which ships them).
One CMake decision sets JFJOCH_USE_NVTX on JFJochLogger; where no headers are found the ranges
compile out, as they already do without CUDA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prediction lists a frame's reflections by Miller index, which scatters consecutive ones over the
whole detector, so pass A's box walk, the reflection-mask and owner-map builds and the CPU profile fit
each waited on memory for every box's image rows and mask tiles (the pixel load alone was 18% of pass
A's samples). These loops now visit the reflections sorted by 16-row band, then column. Each of them
gives the same result in any order - integer sums, min/max, per-reflection outputs indexed by the
reflection - so the output is unchanged; the profile learning, whose double sums do depend on the
order, still walks the original one.

cytochrome C --no-gpu: user cycles 10.71e12 -> 10.20e12 (-4.8%); p.hkl and MTZ data identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Building each reflection's Gaussian profile was a third of the CPU pass B's samples (cytochrome C),
one scalar BraggExp per cell. BraggExp branched to its two ends before the arithmetic, and the
branches - with GCC's -ftrapping-math, which forbids running float arithmetic on a path that does not
need it - kept every loop over it scalar. It now tells the ends apart on the bits of x, an integer
comparison, runs the same arithmetic on x (or on 0 out of range) and masks the result: the same bits for
every float but NaN, checked over all 2^32 inputs against the old function, scalar and vectorised.
The cells are filled a row at a time and summed afterwards in the same order, so the loop vectorises
(8 lanes, AVX2) and the profile is unchanged. BraggScale2 chooses its operands instead of a branch;
scaling by 1 is exact. The Metal kernel shares the header and gets the same arithmetic.

cytochrome C --no-gpu: p.hkl and MTZ data identical; user cycles -7% against the previous commit on
the same (loaded, load 16-32) machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- FFTIndexerGPU makes and transforms the projection histograms
  FFT_BATCH_DIRECTIONS (4096) directions at a time, into one set of batch
  buffers, and keeps only the transposed magnitudes of all directions for the
  peak search, which still runs once over all of them. cuFFT's R2C gives the
  same bits at any batch count (checked at histogram lengths 1024-12568 and
  batches 1-16384), and every other step is per direction, so the result does
  not change. Measured: the two indexers of a first pass hold 0.83 instead of
  2.48 GB, plus a quarter of the plan work area (0.2 instead of 0.8 GB each at
  a doubled cell bound).
- Rugnux::GPUWorkersPerCard: the per-image loop's existing rule (24 bytes a
  detector pixel, half of the card's total memory) as one function, now also
  capping the first pass's spot-finding workers and the pre-scan's workers
  (both at most 8 a card as before). No change on a 16 GB or an 8.55 GB card
  with a 16-18 Mpx detector; a 4-6 GB card gets 4-6 of them.

Output unchanged: p.hkl byte-identical on cytc_x10sa, myob_x10sa, 4nwv and
8qaw, on the RTX 5080 and with its total read as 8.55 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the CPU every orientation's translation function scattered its terms into a complex<double> grid,
converted that to an FPhiGrid, which MapFromFPhi copied into FFTW's input, transformed, and copied out
again into a map the statistics then read: 24 bytes a grid point, 1.6 GB an orientation on 4nwv's cell,
with the 2 x 32 orientations of the groups' functions in flight at once.

Now, once per setup and for all its orientations (a new TranslationFunction over a list of rotations, which
the search calls), each half-grid point's entries are listed in the order the terms and pairs come, cross
terms after; an orientation sums each point over its own list - the same additions in the same order as
the scatter - writes it conjugated into FFTW's input as MapFromFPhi would (MapFromFPhiExecute, the same
plan) and reads the statistics off FFTW's output scaled as MapFromFPhi scales it: 8 bytes a point.

CPU-only (--no-gpu), timing benchmark on 4nwv: the translation stage 61.5 -> 17.7 s, provisional (load
average 24-31 during both runs, and every other stage moved by as much again with no change). Reports,
MR candidate lines, reflection files, maps and placed models byte-identical on myob, cytc and 4nwv,
CPU-only and on the GPU. [MolecularReplacement] passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The viewer's processing job had drifted from `rugnux <file>`: 60 instead of 100 first-pass rotation
images, no adaptive integration radius, a fixed spot budget instead of a measured one, the per-image
loop never capped (nthreads_auto), stills radii 4/6/13 instead of 6/8/14, ice-ring flagging taken as
off for HDF5 files that do not state it (rugnux: on for rotation), the background radial correction
off (rugnux: auto), counts_in_adu never set for marCCD/SMV, 8192 instead of 16384 FFT directions on a
CPU-only host, and a stationary goniometer axis processed as rotation.

The defaults both front ends share now live in RugnuxDefaults (spot budget, beam-stop frames,
rotation first-pass images, stills geometry frames, bare-run spot finding, stills radii, ice-ring
default, radial correction auto), and the CLI and the viewer both read them. The CLI's own behaviour
is unchanged. The viewer's only intended difference stays: it writes _process.h5.

The copied command line now leaves out -N, --max-spots and --integration-radius when the run left
those to rugnux, so it reproduces the automatic choices instead of pinning them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As a bare rugnux forgets the crystal an input file stores (ClearStoredCrystal), the settings panel
now shows the file's cell and space group but does not use them until the user ticks "Known unit
cell". Before, a file carrying a cell was indexed and merged with it, like rugnux -C/-S.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings panel's azimuthal-integration settings now apply to azimuthal-integration jobs only. An
MX job takes the defaults, which is what rugnux --mode mx runs with (no reader takes the settings
from the file), instead of whatever the panel held. In MX the profile feeds the ice-ring score and
the background estimate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Where the molecular transform's FFT runs on the CPU - a CPU-only build, --no-gpu, a card too small for it -
it was one single-threaded FFTW 3D r2c: 12.5 s of 13 s of the transform on 4nwv's 768^3 box, one core busy
while fifteen waited. Now MapToFPhiInParallel does it as two passes over the threads: the r2c along w of
every (u, v) column, then the 2D transform of each plane l the search keeps (l <= reach), cropping as
CropHalfL does. Each column and each plane is done by one plan, so the result does not depend on the thread
count; it agrees with FFTW's 3D plan to float rounding, not bit for bit. The GPU path is unchanged.

CPU-only (--no-gpu), timing benchmark: 4nwv's transform 13.1 -> 2.4 s (provisional, load average 13-25).
What the rounding moved: 4nwv and myob came out byte-identical; on cytc (three copies, tNCS) the null's
replicates, placed by the translation function, read the fit at +22.8 sd instead of +18.7 (MR_HAND_FIT_SIGMA),
and the placement - same R-work, R-free and every MR candidate line - came out at an origin-equivalent
position 43 A from the previous one, so its model file and maps differ while the answer (ENANTIOMORPH,
P 32 2 1, placement CC 0.744) does not. GPU runs byte-identical on all three. [MolecularReplacement] passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ImagePreprocessorCPU::AnalyzeBlock tested each pixel with a chain of branches and kept its extremes in
64-bit compares, so it ran scalar (11-14% of the samples on myoglobin and YAG --no-gpu). Each pixel is
now classified without a branch; the extremes are taken over keys that order as the values do (a
signed type's sign bit flipped), with a pixel that does not count masked to the key no maximum or
minimum moves on; and runs of whole mask words with no pixel masked - nearly all of a detector - take
a loop of their own that GCC vectorises (AVX2, all six pixel types). The counts are integers and the
extremes exact, so the statistics and the buffer are those of the pixel-by-pixel tests in any order:
checked against the old function on random blocks of every pixel type, mask density and block split.

YAG --no-gpu: p.hkl and MTZ data identical; user instructions -9%, cycles about even at load 14-33
(the loop is bound by writing the frame-sized buffer when 32 threads share the memory bus).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run of an HDF5 file is shown as a snapshot - its metadata read back over the file's own images.
A raw sweep has no HDF5 to hang one on, so registering a run was refused and nothing of a
finished job reached the image: no beam-stop shadow, spots, reflections or per-image results.

The run's _process.h5 carries the images too (Rugnux writes them in when the input is not HDF5),
so a raw sweep's run is now read as a file of its own: activating it opens the _process.h5 with the
HDF5 reader, activating "Original" goes back to the sweep's reader. Runs can be removed, renamed and
switched between as for an HDF5 file, and the run list carries both, so the plots overlay them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4nwv CPU-only translation stage ~61 -> ~18 s (provisional, loaded machine); its memory per orientation 24 -> 8 B per grid point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rugnux decided rotation vs stills with "an axis that turns" (a stationary axis is stills), but the
--refine-geometry default asked only whether the file has an axis. A stills file that records a
stationary axis was therefore processed as stills yet never got the default geometry bundle
adjustment that stills with a known cell (-C or a reference MTZ) get. Both now use one test,
RugnuxRotationData.

Changes results only for stills files with a stationary axis and a known cell. On a weak serial
stills set of that kind (-C, 5000 frames): indexing rate 16.2% -> 28.2%, P1 -> I222, written
4.74 -> 4.04 A (CC1/2 fall-off fitted at 4.24 A, none fitted before), I/sigma 0.9 -> 2.8,
CC1/2 0.52 -> 0.69, R_meas 164% -> 209%, ISa 1.2 -> 0.9. A stills set without an axis is
byte-identical (.hkl).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- BeamCenterFFTGPU's point surfaces: each spectrum product goes in the place
  of a spectrum that is finished with (a.a where m's spectrum goes before it
  is there, a.m and m.m and a^2.m in a's), so the third spectrum buffer goes,
  and the forward and inverse plans share one work area - they never run at
  once. The same transforms and products in the same order, so the surfaces
  are unchanged. 2.6 -> 2.05 GB on a 16 Mpx detector, the largest single
  holder at the first pass's peak; DeviceMemoryNeeded counts it so.
- Rugnux::FirstPassIndexerThreads: a probe pass beside the main line on a card
  below the take-turns size (gpu_takes_turns(), 12 GB on the first card's
  total) builds one FFT indexer for its pools instead of two, and indexes its
  two schemes one after the other. Which indexer indexes a frame does not
  change what it finds.

Measured with the RTX 5080's total read as 8.55 GB: first-pass peak 6.50 ->
5.92 GB (8qaw), 5.76 -> 4.84 GB (4nwv) together with the batched FFT indexer;
the speculative probe after the output 6.30 -> 4.35 GB (8qaw). Output
unchanged: p.hkl byte-identical on cytc_x10sa, myob_x10sa, 4nwv and 8qaw on
this card and on the 8.55 GB simulation; run times unchanged within noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
When a finished job's _process.h5 becomes the active run (or the user switches runs), the settings
panel shows the run's cell and space group but no longer ticks "Known unit cell", so the next run
determines the crystal from the data as a bare rugnux does, unless the user ticks it. Before, the
run's crystal - P1 for a run that ended there - was used by every later run until cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
8qaw's first-pass peak on a simulated 8.55 GB card 6.50 -> 5.92 GB; p.hkl byte-identical on cytc, myob, 4nwv, 8qaw at 16 and 8.55 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
SlideVertical took a missing row as a null pointer and read `in ? in[col] : INT32_MIN` inside the
loop. A load under a condition cannot be if-converted, so GCC 13 (-march=x86-64-v3) vectorised only
the rarely taken variant and ran the one every interior row takes - both rows present - as a scalar
loop with branches. A missing row is now a row of INT32_MIN, which the loop already counts as
nothing, so both rows are always read and the loop vectorises (AVX2, alias-versioned). Same integer
sums in the same columns, so the same strong pixels.

--no-gpu, perf (load 23-35, provisional): SlideVertical cycles -47% / instructions -71% on
myoglobin (7.20e11 -> 3.84e11 cycles), -43% / -71% on YAG, -53% / -71% on cytochrome C; the
detection pass as a whole (FlagRow + SlideVertical + DetectPass) -17%, -16%, -26%. The pass is now
bound by the first read of each image row and its ring bins (FlagRow, IPC ~0.4), so total cycles
move by only 1-3%. p.hkl and MTZ data identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
ShadowFinder compared each pixel with the median of a 1-pixel circle about the
geometry's beam centre. On a tilted detector that centre is the point of normal
incidence, not where the beam lands, and the rings of constant 2theta are not
circles about it: on a 2theta arm swung by 5-10 deg at 50 mm the circles cut across
the background's steep fall-off and the far side of the detector read as shadow.
Every tilted home-source SMV set in the open arm masked 14-41 % of its detector.

A pixel's ring is now distance * tan(2theta) from the detector matrix, its azimuth
for the arm search the lab azimuth, and the mean is divided by the obliquity
(distance / l_z)^3 - the part of the solid angle that varies along a 2theta ring -
alongside the polarization factor. Host and device share one helper. Untilted, all
three are exact identities, so untilted sets keep their mask bit for bit.

Named-set before/after on the same commit (16 sets): 5reo, 5uth, 6h2p_1p89A, 4bwl,
lyso_x06da_ref unchanged. Shadow 26.5->5.9 % (3mc4), 40.6->10.4 (3r6o),
39.4->6.4 (5vml), 37.1->9.2 (3meb), 33.1->6.8 (5cc8), 13.8->5.8 (3p85);
completeness 77->91, 43->98, 80->84, 43->78, 45->80, 86->87 %. The 0.2 deg-tilted
in-house PILATUS4 sets move by a few hundred pixels; the 20/30 deg tilted
small-molecule sets mask 0.4->0.8 and 0.1->0.6 %, SHELXL R1 unchanged or better.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
On the tilted Rigaku Saturn sets of the open arm the masked fraction drops from 14-41% to 6-10% and completeness roughly doubles; no space group or verdict changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Profiled with --no-gpu on a large-cell set, the scaling-and-merging step ran under four cores for
about a third of its wall time. The serial pieces, and what they are now:

- The combine's tail sorted the eighty-byte fulls by frame on one thread. It now sorts each full's
  (frame, position) under the same frame-only comparison - std::sort's moves depend only on the
  outcomes of its comparisons, so the positions come out in exactly the order the records did, ties
  included - and gathers the records once on all threads, straight from the per-thread parts (no
  concatenation copy).
- The fulls' group CSR (a serial counting sort) is a parallel sort on (group, index), a total order.
- The host merge accumulate, the R_meas walk and the error-model sample pool were serial walks over
  every full scattering into per-group sums; they walk each group over the group CSR on all threads,
  so every group is still summed in index order, and integer counts are added up per block.
- The per-pair centric test of the anomalous statistics runs on all threads; the anomalous export
  map is reserved once.
- partials, fulls and the scaling loops' working copies (ScalingRef, ScalingFit, corr) use an
  allocator that default-initialises (DefaultInitAllocator / FillVector), and Obs has no member
  initialisers, so these arrays are first written - and their pages faulted in - by the parallel
  passes that fill them rather than zeroed on one thread first. On a large sweep that serial
  first touch was several seconds of kernel time per merge. The ingest now sets a partial's
  var_per_I and capture to 0 explicitly; every other producer already wrote every field.

p.hkl and the MTZ data are byte-identical on three large sets (--no-gpu). Provisional, shared
machine (load 20-33 on 32 threads), scaling-and-merging step: 115 -> 91 s on a 3600-frame set,
74 -> 61 s on a 500-frame P1-heavy set, 55 -> 51 s on an 1800-frame set; time under four cores
28 -> 6 s, 17 -> 3 s and 5 -> 1 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
With the serial walks gone the CPU merge is bound by memory traffic over the partials - running the
final merge of a large set at -N 7 takes 20 s against 18 s at -N 32 on a dual-channel machine - so
these changes take passes and random accesses out of the loops rather than add threads:

- The partials' corr update of one scaling iteration (UpdateCorr, then the rescale) is no longer a
  pass of its own over the working copy: each grouped partial is brought up to date by the next
  iteration's group-mean walk, which reads it anyway, and what is owed when the loop stops is
  applied after it (CorrUpdate). The partials in no group, which the means never read, are updated
  right away. Every corr goes through the same updates in the same order.
- A partial's ASU group is kept in a flat per-partial array (partial_group) that the CPU passes
  read, instead of being stamped into the eighty-byte records at random once per grouping.
- RunArm no longer restores corr onto the partials when the CPU partial scaling is about to
  overwrite every one of them; that loop starts its copy from corr_ingested directly. Its result
  goes back to the partials' order through a compact array (also the memo's) and then onto the
  partials in one sequential pass, instead of a random scatter into the records.
- The per-frame fit prefetches the group mean it gathers, 16 observations ahead; the correction
  surfaces' fit reads one reference array (swI / sw, NaN where a group has no weight) instead of
  two, with the same prefetch.
- The loops' index arrays (partials, fulls) are built on all threads.

p.hkl and the MTZ data are byte-identical on three large sets with --no-gpu, and the final merge
re-run (--mode scale) gives the same bytes at -N 7 and -N 32. Provisional, shared machine (load
12-27): scaling-and-merging step, against the start of this branch, 115 -> 77 s on a 3600-frame
set, 74 -> 58 s on a 500-frame P1-heavy set, 55 -> 42 s on an 1800-frame set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The owner's generator and its SVG, under docs/images/, shown on RUGNUX_OVERVIEW.md.
Corrections against the code, with the owner's header and style kept:

- Input: marCCD added; "stream" is not a ZeroMQ stream but the broker's live
  collection (closed data files read as they are reported, rotation only).
- Pre-scan: also masks defective pixels; it MEASURES the beam centre and commits
  none - the first pass indexes at both centres - so the centre now goes to Index
  as its own arrow ("measured beam centre"), and the arrow into the per-image
  panel carries "masks, spot widths".
- Index: a first pass on a frame sample (validation frames, rotated into one
  crystal frame), not a per-image indexer.
- Refine: the per-image fit is the in-tree LM solver adapted from Ceres
  (LMSolver.h), not Ceres Solver itself.
- Input arrow: pass 1 integrates only the first half of the sweep (>= 120 deg).
- Post-refine: beam centre added (distance, beam, cell, axis; tilt is held);
  the hypotheses run between the passes (beam-centre arm, metric-symmetry arm,
  geometry walk, rotation-scale walk) noted; pass 2 is repeated while a round
  realises a gain.
- Whole dataset: the P1 merge is where competing passes are judged (quality
  guard, beam-centre arms), so it carries "tested"; Symmetry adds the doubled-cell
  hypothesis; the in-group merge names corrections and error model; structure
  is drawn dashed with a bypass to the report (no model); report lists unmerged
  MTZ and the report file.
- Legend: a CUDA step takes its CPU path without a GPU.
- Taller boxes where a second text line needed room; the SVG is written next
  to the script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Owner's review of the rotation figure:
- The in-group merge names the correction surfaces it fits (decay, absorption,
  modulation; RotationScaleMerge) instead of the word "corrections".
- New "autodiff" badge, on the fits whose derivatives are automatic: Refine and
  the rotation indexer's lattice refinement (XtalOptimizer/XtalRefine, forward-
  mode Dual numbers), Post-refine (ceres::AutoDiffCostFunction in PostRefine.cpp)
  and the stills geometry bundle adjustment (GeometryRefiner). Not on the rigid
  body (analytic plus forward differences), the stills partiality refinement
  (central differences), the beam-centre fit or the stills indexer's candidate
  refinement. The Ceres text on Refine is gone.
- Text back to about the owner's budget and box sizes: the meaning moved to
  arrows ("pass 2, or pass 1 if it merges better" into the P1 merge, "beam
  centre" from the pre-scan to Index) and badges; "rival ..." phrases removed.

New: the stills figure (rugnux_pipeline_stills.svg) from the same script, same
style and legend. Stills = no goniometer axis or a stationary one
(RugnuxRotationData). Geometry bundle adjustment first, only with a known cell
(dashed); pre-scan masks the beam stop and bad pixels; per image: decode,
profile, spots, index (1D FFT, or ffbidx with a cell; min-pix chosen per image
by what indexes: tested), refine beam/orientation/cell, excitation-error
prediction, profile fit; whole dataset: consensus cell and per-image scale (CPU),
symmetry, merge with partiality post-refinement and error model, structure, report.
Both figures are on RUGNUX_OVERVIEW.md, the stills one under "Stills instead".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The merge's per-group and per-full working arrays (accumulators, R_meas sums and staging, ISa scatter,
error-model pools, the per-full fields) are engine members kept between merges to save faulting them
in again. Each is resized and refilled before it is read, so handing them back leaves the next merge
exactly where an engine's first merge starts.

- ReleaseMergeScratch(): the main engine's, before the space-group search. Its next merge is in the
  group the search picks, with far fewer groups than P1.
- ReleaseAllButPerFrameScale(): the CloneIngest engine of the all-observation arm merges once; right
  after its Run() everything on its host but the per-frame state WriteBackPerFrameScale reads goes
  (fulls, staging, raw-hkl runs, ingest copies, merge scratch).

Measured (heap profile + RSS sampled at 4 Hz, GPU path): 4nwv peak RSS 19.10 -> 18.17 GiB; the
space-group search phase 19.1 -> 16.9 GiB, so the peak is now the two concurrent P1 merges. Outputs
byte-identical on myob_x10sa, cytc_x10sa, 4nwv (timing benchmark, MR) and 8qaw, 4nwv (open arm):
p.hkl, MTZ column data, p_maps.mtz, maps, placed model, every report field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The joint geometry fit reads at most MAX_OBS (20000) spot positions, chosen by I/sigma, through
pointers into the gathered partial array (28 B a partial). That array and a full-length pointer array
(8 B a partial) stayed alive until the fit returned. Now the ranking is built straight from the
gathered partials (no pointer array), the kept observations are copied out, and the gathered array is
freed before the fit. Same comparisons in the same order, so the same selection.

8qaw (122.7 M partials, GPU path): peak RSS 23.33 -> 21.92 GiB with the previous commit; the
post-refinement, which runs beside the P1 merge, holds 3.3 GB less for most of its fit. Outputs
byte-identical on myob_x10sa, cytc_x10sa, 4nwv, 8qaw. [PostRefine] tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Three more cuts in the bytes the CPU scaling moves per iteration:

- The partials' working copy is ordered with the partials a group mean can take in first - in a
  group and not below the partiality floor, ReferenceWeight's fixed tests - group by group in the
  CSR's order, and all the others after them. The group means walk only the first part: a partial
  they would skip anyway adds nothing to a sum, so no sum changes, and on a fine-sliced sweep about
  half of the partials are rocking-curve tails below the floor. The others are read by nothing
  during the loop, so their per-iteration corr updates are kept (a few per-frame arrays an
  iteration) and applied to them once, in order, when the loop ends.
- ScalingFit holds the four floats the per-frame fit reads (20 bytes, was 32) and the fit works
  out 1/sigma^2 and partiality/prescaling_corr from them where it uses them: the same expressions,
  so the same doubles.
- The correction surfaces keep their group-ordered reference terms in one copy per frame parity,
  so a cross-validation reference pass reads only its own half; a pass over all the terms walks a
  group's two halves together in term order, which is the order the single copy had.

Samples in the partial loop on a 3600-frame set (perf, cycles, whole scaling-and-merging step):
group means 29.7k -> 15.1k, per-frame fit 22.4k -> 13.7k, the separate corr update pass 11.9k ->
gone. Wall time on the shared machine (load 26-33) is within its noise of the previous commit.

Validated with the battery against the start of the branch (873880bba), --no-gpu: p.hkl and MTZ
data byte-identical on 4nwv, 8qaw, 9v16, lyso_x06da_ref, cytc_x10sa, myob_x10sa and
yag_x10sa_20keV; the same bytes again at -N 11 on all seven; and on the GPU path the same bytes
on lyso_x06da_ref and 9v16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The capture scored three surfaces: the 2D point inversion and a 1D line mirror per detector axis
(the Friedel-mate part along the spindle). Only the point surface is used - its strongest candidate
seeds the background walk that measures the beam centre. The line mirrors and their margins were
wired report-only when the capture was added and a consumer for them never landed; their one reader
was the pre-scan's "Beam centre capture:" log line. Removed from the scorer, both engines (fftw3f and
cuFFT: the 1D plans, the gather/accumulate/pack kernels and the line phase of the device-memory
estimate), the tests and the docs; the log line now gives the point centre and its margin.

Output unchanged: p.hkl byte-identical on lysozyme, cytochrome C, myoglobin and 5reo; [BeamCenter]
and [BeamCenterFFTGPU] pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
--no-gpu Scaling and merging (provisional, loaded machine): 8qaw 120 -> 77 s, 9v16 71 -> 41 s, 4nwv 74 -> 57 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The two other points between merges where the engine's MergeAndStats arrays (~330 B per ASU group and
~55 B per full; ~3 GiB per P1 merge on a 400 A cell) sat beside other work:
- the all-observation arm merged in series (the CPU path, or a card too small for the arm beside),
  whose arrays were held through that arm's SearchSpaceGroup;
- after the answer's merge, through the analyses (a twin-law or ledger merge refills them).

-N 10 --no-gpu (the macOS path's thread count): myob_x10sa 7.73 -> 7.28, cytc_x10sa 7.96 -> 7.56,
4nwv (timing benchmark) 20.34 -> 20.07 GiB peak RSS. Outputs byte-identical (p.hkl, MTZ column data,
maps, placed model, report fields) at -N 10 on the CPU path and on the GPU path for myob_x10sa,
cytc_x10sa, 4nwv, 8qaw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Peak live heap: 4nwv (GPU) 17.1 -> 16.2 GiB, 8qaw 21.1 -> 20.0 GiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
- GPU_DATA_ANALYSIS: rotation scaling's GPU/CPU decision before the card is touched (sort and
  scaling at 80% of the card's total) and the two-pass ingest; FFT indexer batches of 4096
  directions and indexers released after a pass's last indexing; beam-centre FFT on the CPU above
  half of the card; model validation and MR on one 80% budget (the scale fit's "eighth of the card"
  rule removed); CUDA profile fit in the CPU engine's single-precision arithmetic; Metal engine not
  used under --background-trim.
- RUGNUX_INSTALL: the scaling of a set too large for the card now goes to the CPU by itself (it no
  longer stops the run), the up-front decisions listed, the rigid-body CPU fallback removed; Bragg
  integration on the Apple GPU on macOS.
- CPU_DATA_ANALYSIS_IMAGE: beam-stop rings of constant 2theta and the tilted detector's solid angle
  divided out (solid angle no longer described as constant along a ring); beam-centre FFT half-card rule.
- CPU_DATA_ANALYSIS_DECISIONS / RUGNUX_REPORT: MR's paired-residual tie rule; MR CPU transform FFT
  threaded independently of the thread count; GPU_COUNT on macOS.
- JFJOCH_VIEWER: processing starts from a bare rugnux's defaults, Known unit cell unticked on open
  and when a run is shown, MX jobs with rugnux's azint defaults, raw sweeps' runs shown from
  _process.h5, Copy command omits automatic -N/--max-spots/--integration-radius; Metal on macOS.
- RELEASE_CONTENTS: macOS artefacts have no CUDA but integrate on the Apple GPU.
- RUGNUX, RUGNUX_OVERVIEW, RUGNUX_TUTORIAL, RUGNUX_ADVANCED: a stationary goniometer axis is stills
  (and gets the stills --refine-geometry default); -N worker ceilings on a small card; --no-gpu also
  hides the Apple GPU.
- tools/battery/README: the timing benchmark's cytochrome c set is no longer UNDECIDED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Merge changelog-rc175-r2 into rc175: CHANGELOG entries for this round
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m59s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m32s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 15m3s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 19m7s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 19m54s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m25s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 23m33s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 23m44s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m51s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m3s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m31s
Build Packages / Generate python client (push) Successful in 28s
Build Packages / Build documentation (push) Successful in 1m7s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 21m36s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m20s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m13s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 23m31s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 23m10s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 19m44s
Build Packages / Unit tests (push) Successful in 1h27m35s
Build Packages / Validation Battery (push) Successful in 33m5s
3be5dcaac8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
One page (docs/RUGNUX_BENCHMARKING.md, in the rugnux toctree) for anyone measuring rugnux on another
machine: the battery protocol (arms, one command per arm, verdicts, the --report-resolution
like-for-like table, run directory, compare, the rules), exactly what model_check.py runs (first
REFMAC rigid-body cycle of the deposited model at the common d_min, the depositor's data as
baseline, the refined shared-free-set protocol) and its limits, the three-set timing benchmark and
its checks, the practical lessons on timing (CI flags, first run after a build, quiet machine,
ABAB, page cache, GPU lock, Windows WDDM paging, macOS unified memory, memory peaks, TIMING lines,
NVTX), and the layout of a pack to send back. Linked from RUGNUX.md, TESTS.md (whose battery
description still spoke of variants) and tools/battery/README.md, which also notes the two
site-config snags met on Windows and macOS (relative paths, a copied pdb_cache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
From the Windows laptop's timing runs (2026-10-16), where every set failed until the site config's
paths were made absolute and the copied pdb_cache's <entry>.json files deleted:
- a relative runs root, lock file, cache or data root is taken relative to the site config's own
  directory (the manifest stays relative to tools/battery/), so rugnux finds it from its work dir;
- model_check.deposition uses the coordinates where the cache now is, not the path its json
  recorded when the entry was first fetched;
- hardware info for the manifest on Windows (PowerShell CIM: CPU, cores, threads, memory);
- report.run_state does not probe the run's pid with os.kill on Windows, where that terminates it;
- a crash with a Windows NTSTATUS exit code is named as an exception.
The docs no longer tell people to work around the first two by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Replaces the kept a45135 run with 20261016-0057_e25876_rc175-e258760-full-refmac (open + inhouse,
--model-check; the private arm is never kept), plus SUMMARY.md: against a45135 no verdict changes
(243 pass / 7 fail / 4 unscored, the same sets), rugnux time 1.14 -> 1.16 h; the 15 sets changed beyond
noise all have a tilted detector and a changed beam-stop mask (the 2-theta rings of c207012a1): large
gains on the tilted Saturn sets, swings on the hard in-house variants. First kept run with REFMAC:
201 open-arm sets, refined R-free ratio rugnux / depositor's data median 1.004.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
168 open-arm sets started from their AlphaFold DB model with the battery's own command (--model <AF model>),
checked against the deposited model with the timing benchmark's checks: 102 GREEN, 0 wrong space group or
hand, 46 not placed, 8 undecided, 3 crashes on the GPU path (model-scale fit after MR) - see AFMR_SCAN.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
battery: cross-platform timing report for rc175 3be5dcaac - the timing benchmark on six platforms
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m46s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m42s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 16m50s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 20m38s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m14s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m9s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m53s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m45s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 25m45s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 30m53s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m24s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m7s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m28s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 24m39s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 19m38s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 24m14s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 24m36s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 20m4s
Build Packages / Unit tests (push) Successful in 1h29m33s
Build Packages / Validation Battery (push) Successful in 34m9s
57a3e02f59
tools/battery/results/timing_platforms/: the timing benchmark (myob and cytochrome C from AlphaFold DB
models, 4nwv from a moved deposited model, all placed by molecular replacement) on the workstation
(RTX 5080, and CPU only), the 4x T4 server, GB10, an 8 GB Windows laptop and a Mac mini M6: Table 1 wall
time for the three sets, Tables 2-4 time per pipeline stage (the workflow figure's boxes) with the sum of
steps against the wall time, and per platform the commit, build, command, hardware and conditions as
recorded. All checks pass everywhere with the same MR decisions. Generated by timing_table.py from
platforms.json (the packs it reads are kept outside the repository).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
battery timing report: Mac step times, MR decisions and reproducibility from the run directory
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m48s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m17s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 15m42s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 19m44s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m14s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 24m28s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m45s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m44s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m37s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m28s
Build Packages / Generate python client (push) Successful in 30s
Build Packages / Build documentation (push) Successful in 1m12s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 22m6s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 19m12s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 23m59s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 25m7s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 23m29s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 21m16s
Build Packages / Unit tests (push) Failing after 3h9m14s
Build Packages / Validation Battery (push) Successful in 40m8s
b5aac74c2f
The Mac entry was typed in from a markdown summary and left most step times, the MR decisions and the
run details unrecorded. They are now taken from the Mac's own run directory
(20261016-0727_3be5dc_timing-3be5dc: results.json, manifest.json, p_log.txt) with timing_table.py's
loader, kept as a 'manual' entry so the report still regenerates where that directory is not reachable.
The Mac rows of the generated report were spliced in from a Mac-only render (the other platforms'
sources are not on the Mac); the three "partial steps" notes are dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
leonarski_f merged commit 19b135186d into main 2026-10-16 15:34:45 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mx/Jungfraujoch#85