v1.0.0-rc.181 #92

Merged
leonarski_f merged 330 commits from rc181 into main 2026-11-02 12:05:21 +01:00
Owner
  • Rugnux: Several rotation sweeps of one crystal are merged as one dataset (rugnux a_master.h5 b_master.h5), on a common indexing and cell, with a non-isomorphous sweep left out and reported; --sweeps separate processes each sweep on its own. Bruker .sfrm frames and imgCIF files with a full goniometer axis chain are read.
  • Rugnux: More robust rotation processing - a retry with a more sensitive spot finder when no lattice is found, a self-consistent detector distance, long axes and doubled cells tested as hypotheses, a -C supercell multiple stepped down, and each ice ring merged, down-weighted or rejected on its own.
  • Rugnux: Serial stills are merged with a partiality-aware error model, reported as ERROR_MODEL_A/B/C with no ISa or R_merge/R_meas/R_pim; runs without -S have an overall intensity scale about 0.86-0.88 times that of earlier versions, with unchanged statistics. A second crystal form is left out of the merge and reported, the indexing ambiguity is resolved more reliably, and --model no longer influences processing.
  • Rugnux: The reported CC1/2 is the standard unweighted one, which can be lower than before on sweeps with weak frames; ISa comes with a 95% interval (ISA_LOW/ISA_HIGH).
  • Rugnux: Model refinement adds anisotropic/TLS, local NCS and twin refinement where R-free supports them; building waters and relabelling ions is now off by default (--refine-solvent turns it on).
  • NXmx: A detector of several modules is written as module_0 ... module_N only, so DIALS reads each module as its own panel.
  • Other: JPEG previews use a bundled encoder (libjpeg-turbo no longer needed), Rugnux prints how to cite it, and the release archives contain the HTML documentation.
* Rugnux: Several rotation sweeps of one crystal are merged as one dataset (`rugnux a_master.h5 b_master.h5`), on a common indexing and cell, with a non-isomorphous sweep left out and reported; `--sweeps separate` processes each sweep on its own. Bruker `.sfrm` frames and imgCIF files with a full goniometer axis chain are read. * Rugnux: More robust rotation processing - a retry with a more sensitive spot finder when no lattice is found, a self-consistent detector distance, long axes and doubled cells tested as hypotheses, a `-C` supercell multiple stepped down, and each ice ring merged, down-weighted or rejected on its own. * Rugnux: Serial stills are merged with a partiality-aware error model, reported as `ERROR_MODEL_A/B/C` with no ISa or R_merge/R_meas/R_pim; runs without `-S` have an overall intensity scale about 0.86-0.88 times that of earlier versions, with unchanged statistics. A second crystal form is left out of the merge and reported, the indexing ambiguity is resolved more reliably, and `--model` no longer influences processing. * Rugnux: The reported CC1/2 is the standard unweighted one, which can be lower than before on sweeps with weak frames; ISa comes with a 95% interval (`ISA_LOW`/`ISA_HIGH`). * Rugnux: Model refinement adds anisotropic/TLS, local NCS and twin refinement where R-free supports them; building waters and relabelling ions is now off by default (`--refine-solvent` turns it on). * NXmx: A detector of several modules is written as `module_0` ... `module_N` only, so DIALS reads each module as its own panel. * Other: JPEG previews use a bundled encoder (libjpeg-turbo no longer needed), Rugnux prints how to cite it, and the release archives contain the HTML documentation.
leonarski_f added 330 commits 2026-11-02 12:04:49 +01:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Two changes to the stills scale model; rotation is untouched (p.hkl md5 identical on two rotation sets).

The per-crystal scale fits (ScaleOnTheFly and the partiality post-refinement's G and tilt) measured
residuals in counting sigmas. The strong reflections of a still scatter about the model by tens of
percent (robust sd of ln(I/<I>) 0.45-0.8 by shell on one run), so with a Cauchy loss at 3 counting
sigma every strong reflection was an outlier and the scale was set by the weak ones: re-fitting each
frame's scale against the final merge moved it by a factor 0.45-1.5 (5-95%). The fit's variance is now
the expected one at the model, background + counting + (0.3 * model)^2. The coefficient is not
delicate: 0.1-1.0 fit alike in an offline replay; what matters is that the term is there.

The run's rocking width came from the intensity-weighted moment of the excitation error over all
frames pooled. Frames predict through windows of their own that differ by an order of magnitude, and
the widest windows dominated both moments: on a run whose windows are mostly narrower than the curve
at high resolution the measurement read the window at every resolution, 2.8e-4 1/A with no angular
term, several times too wide at low resolution (low-partiality observations there merged at 0.1-0.3
of their reflection's mean). The moment is now taken per group of frames of similar window (4 per
shell), each against its own window, and the shell takes the median of the groups whose curve is
narrower than their window. On that run the width becomes 6.7e-5 1/A + 0.018 deg angular; where the
pooled measurement was already right (a SASE-bandwidth run), it stays where it was.

0-cycle REFMAC R-free against the deposited models, 9 still battery sets (6 used while developing, 3
held out), change from rc179: -0.0100, -0.0095, -0.0057, -0.0055, -0.0051, -0.0045, -0.0030,
-0.0024, +0.0009 (held out: -0.0055, -0.0051, +0.0009). The width change alone accounts for most of
the largest gain; the scale-fit variance for 0.000-0.003 per set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit ccc0e01f56827bbe0648ed7c5b8049912d80650f)
/rugnux/status gains `model` (rugnux_live_model) - placement (RIGID_BODY/MR/NOT_PLACED), R-work and
R-free of the placed, unrefined model, CC(model, data), R-work over its random-orientation null, mean
2mFo-DFc at the atoms, the rigid body's move, molecular replacement's decision, group, copies,
TFZ/RFZ/LLG-Z and search resolution, and the strongest anomalous site - taken from
ProcessResult::model_validation and ::model_prior (LiveModel). `anomalous_signal` says the summary's
"Anomalous" row reports usable signal (CC_anom > CC_ANOM_SIGNAL, now a named constant shared with the
report), so the viewer can show the anomalous map by default only then.

The step that placed the model (Molecular replacement, or Model validation) now carries notes: what
MR placed and its TFZ/RFZ, R-work/R-free (unrefined) and CC(model, data), the rigid-body move.

Generated code regenerated from jfjoch_api.yaml: broker/gen (cpp-pistache-server), the TypeScript
client (npm run openapi) and broker/redoc-static.html. Python client not regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit f602e931dc6afcccb3767b8c6df87650d9496033)
- Fo-Fc (and the anomalous map) are carved within the same 4.5 A of the atoms as 2Fo-Fc, not only
  cropped to the model's box + 6 A, and Fo-Fc starts at +-3.5 sigma: uncarved at 3 sigma it filled
  the frame with noise peaks. A departure from AareDB's viewer, which carves 2Fo-Fc only.
- The anomalous map is on by default only where the status says the data carry anomalous signal.
- The model section (molecular-replacement scores + viewer) sits directly under the timeline and
  slides in when the run ends with a model; the stage stays dark until model and maps are in, then
  fades up while the camera eases in from further out and turned ~35 deg (1.6 s, ends exactly at the
  reset view). prefers-reduced-motion turns all of it off.
- The page uses up to 2400 px instead of the 1300 px column; Processing and Result are 1:2, the
  summary tiles flow into as many columns as fit, the viewer is clamp(520px, 64vh, 820px) tall.
- Score tiles: placement (copies, group, MR time), TFZ (RFZ, LLG-Z, search resolution), R-free
  (R-work, unrefined), CC(model, data), fit over random orientations, 2Fo-Fc at atoms, and the
  strongest anomalous peak only where there is anomalous signal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 3bb891c60acd2c5618bbc461fabcb7513f5df947)
Three changes to the stills post-refinement and merge, decided on 0-cycle REFMAC R-free against the
deposited models (diff = ours - deposited data, noise ~0.002):

- Stills merge only observations whose model partiality is at least 0.5 (RugnuxDefaultScalingSettings;
  rotation keeps 0.02, and --min-partiality still overrides). Diagnosis on per-observation dumps: the
  empirical rocking curve has a floor of 5-15 % of its peak out on the tail (mis-oriented crystals), and
  dividing such observations by a small model p makes them biased, not noisy. Variance terms in (1-p) or
  (1-p)/p recovered a third of what the cut does; a narrower (correct-looking angular) width or a
  Lorentzian form was worse. On one set in scale mode the optimum is flat over 0.3-0.6.
- The CC adoption gate in RefineOne is gone: it left 25-33 % of crystals at p = 1 next to the refined ones,
  and collapsed to 3 % on a run whose curve is off-centre.
- The run's radial scale of the reciprocal lattice (a wavelength or cell a little off, invisible to spot
  positions since they fix only lambda d*) is measured from where the curve is centred against d*^2
  (MeasureRadialScale, iterated three times) and applied to q in the width measurement and the
  post-refinement. One run had its curve centred at +2-3e-3 d*^2 (scale -1.6e-3 measured); the others
  measure below 7e-4.

--mode scale on stills now starts where the full pipeline starts: p = 1, the excitation error recomputed
from the stored lattice (the window was read as zero before, so no run width was ever measured), and
collapsed scales rejected after the refinement.

Still battery, the 12 non-float sets, change of diff vs the previous build: -0.0035 mean, 9 better, three
within +-0.0006 except one +0.0053 (an edge win becoming a draw); the off-centre run -0.005 here and -0.017
in an earlier run of the same algorithm (that set varies by ~0.01 between runs). Rotation p.hkl
byte-identical on three sets. Test StillsPartialityRefine_MeasureRadialScale added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 0d85e5b975ba863828bd17ebc3b5308e9aa294ce)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit df5f2ae22f979a5569f4d569fe2a8cdd5999d242)
The profile fit keeps a signal disk that lost pixels to the mask and fits the pixels that remain. That
holds for a gap or a dead pixel, but not for the beam-stop shadow: the mask is drawn where the background
is dimmed, and the Bragg half-shadow does not end there, so the rest of such a disk is attenuated too.
Measured on summed rocking events against their symmetry mates (stratified by resolution and zeta, the
detector edge excluded), observations whose disk touches the shadow read low by 12-60 % on 5uth, 7qij,
5nw5, 3r6o, 3meb (CCD and PILATUS alike) and level on the in-house photon-counting sweeps; observations
with a clean disk are unbiased except on 5nw5, whose half-shadow reaches ~20 px past the mask. The test
sits in MXAnalysisWithoutFPGA::IntegrateOnImage, so rotation, stills, the _process.h5 and --mode scale see
the same reflections; with no shadow in the mask (the broker) it does nothing.

The shadow finder also reports pixels holding a detector's no-data mark (PILATUS CBF module gaps at -1):
they are the darkest pixels there are. The pre-scan now masks those as dead (bit 10) instead of beam-stop
shadow (bit 9), so that the shadow test does not drop every reflection clipped by a module gap and the
reported shadow share is the shadow's. A mean of zero is not taken as dead: a deep shadow on a still
counts nothing in the projected frames.

CPU subset battery vs rc180 (REFMAC 0-cycle R-free): 3meb -0.0004, 5uth -0.0004, 6h2p -0.0003,
6oel -0.0002, 3r6o +0.0001, 7qij/7rji 0, 5nw5 +0.002 (its lowest-shell CC1/2 0.78 -> 0.89, ISa 8.4 -> 9.3);
in-house refres ISa up 0.1-0.3 on lyso/thau/insu/cytc/myob, half_image refres R_meas 75 -> 64 %, no set
lost quality. Stills (lcls242_s/t, lcls222, euxfel83, sacla236): R-free within +-0.002, the noise of a
perturbed run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit a7610d847694b5540dacc9ef609344ea7c7ae8fd)
Reflections coarser than --scaling-low-resolution (default 50 A) are now merged and written - what
maps and refinement want of the lowest resolutions - but stay out of every fit of the scale model, by the
same path an ice-ring observation takes (excluded from scaling, kept for merging), under a name of its
own: RotationScaleMerge's per-observation flag is now `unscaled` (UNSCALED_ICE | UNSCALED_LOW_RES; on the
device OBS_UNSCALED = OBS_ON_ICE_FLAG | OBS_LOW_RES_FLAG), set from the reflection on the host and in the
device ingest, and only its ice bit marks a merged reflection as on an ice ring. The stills merge drops
its low-resolution cut; ScaleOnTheFly, StillsPartialityRefine and the reindexing correlation keep it.
The merge statistics run over the whole sphere unless --report-resolution declares a range, so the
reflections the beam stop hides count as missing. Which coarse reflections exist at all is the beam-stop
shadow test's decision (a7610d847).

Why not simply lift the limit: on 5nw5, 35 reflections beyond 50 A bent the scale model enough to move
the merged 20-50 A intensities by +61 % and 10-20 A by +19 % (Wilson B 319 -> 382, REFMAC R-free +0.006).
Kept out of the fit, the merged intensities at 10-50 A are unchanged to 0.2 % and Wilson B is 307.

CPU subset battery against the previous commit (lp-cpu-v2 / lp-cpu-v1): REFMAC 0-cycle R-free 5nw5
+0.0004, 6oel +0.0005, others within 0.0001; rugnux's own R-free/R_model rise where the new >50 A terms
enter it (lyso R_model 0.2714 -> 0.2739; the model is poorest there). Kept beyond 50 A: 7qij 61, 5nw5 35,
6oel 14 unique. In-house photon-counting sets unchanged, except insu_I_x06da_ref, where the delta-CC1/2
frame exclusion now removes 449 instead of 78 frames: refres R_meas 0.246 -> 0.167, ISa 33.5 -> 35.4,
R_free 0.2443 -> 0.2427, anomalous signal 3.31 -> 3.38 (XDS: R_meas 0.349, ISa 25.1). Stills (lcls242_s/t,
lcls222, euxfel83, sacla236) within 0.0002 of the previous commit. GPU (device ingest) and CPU give the
same 5nw5 merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 32a9fbcb3ca67879ceb051c13f58d61fe85530f4)
Two defects let a stage turning 1.9% slower than its stored angles cost
9qw8 half its signal (R-free on the depositor's flags 0.346 against
their 0.251, ISa 5.2, c 100.24 against the deposited 100.92):

- Pass 1's lattice, integrated wherever the refined-geometry re-index
  finds less (Pass1Fallback), kept the cell and orientation pass 1 fitted
  at the stored angles. The rate error sits partly in that cell, so the
  adopted scale was integrated with a cell that undoes it. The lattice
  is now refit (cell + orientation, detector and axis held, no
  re-index) on the validation frames of the whole sweep at the angles
  the pass runs at, and kept where it indexes no fewer of them.
- The walk took a refit step only where the validation frames
  significantly preferred it. A first pass indexes through a degree of
  drift late in the sweep, so the frames score 0.98772 and 0.98198
  alike while integration at the first loses half the signal. Off the
  stored angles a refit step is now refused only where the frames score
  it significantly worse; leaving the stored angles still needs
  significant evidence.

9qw8 (CPU, battery protocol): walk 0.98772 -> 0.98087, c 100.94, ISa
5.2 -> 11.2, R_meas 0.270 -> 0.157, CC1/2 0.975 -> 0.993, R-free on the
depositor's flags 0.346 -> 0.267 (ratio 1.381 -> 1.066; forcing
--rotation-scale 0.9816 gives 0.255). The other eight sets that adopt a
scale (2wnq, 2wnn, 6ttn, 8r5r, 8pqd, 8xte, 9i80, 9gdj) move by at most
0.0006 in R-free; the photon-counting controls lyso_x06da_ref,
thau_x10sa_16keV, cytc_x10sa, 5reo and 3mc4/3r6o are unchanged
(lyso p.hkl byte-identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 5daa4ed635b22e29b25e3e02f8ad8c058f9176a2)
The pre-scan's plane test read per-pixel sector medians of the mean projection. On a large
photon-counting detector with a background of a fraction of a count per pixel over the 60
frames read, those medians were zero and the test returned no harmonic at all: on 21 of 255
battery sweeps (EIGER2 16M and JUNGFRAU 4M sweeps, the laboratory EIGER sweeps, the
small-molecule sweeps at 20-25 keV) the correlation was NaN or within 0.01 of zero, so the
plane could not be decided there. The background is now pooled over 9 x 9 pixels first, as the
mask itself already is; 216 of 244 synchrotron sweeps then show their (horizontal) plane past
both gates, against 159 of 229 before.

Pooling also lifted one long-distance sweep (500 mm, 2theta under 21 deg, polarization can
move its background by at most 4 %) over the correlation gate: its background carries a 10 %
cos 2phi harmonic along image y, 2.9 times what a fully polarized source would give, on a
horizontally polarized beamline. No polarized source exceeds a scale of 1, so a harmonic of
twice that or more now decides nothing (PLANE_MODULATION_MAX).

The pre-scan logs the correlation and the scale on every run that detects the beam stop
(GetPlaneEvidence). A turned plane is kept on the run and re-applied where a pass starts again
from an experiment taken before the decision while the shadow is already in the mask (the
rotation measured-centre arm, the no-lattice pass), which previously went on with the stated
plane.

No battery sweep turns; where the plane is not turned the applied coefficient is unchanged.
The degree is still never fitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 50a145a4f1ddf6d58332a8ab745627dc36a2dc3d)
RefineStillsGeometry fitted distance, beam and cell on the strongest sampled frames whatever their
cell, so a sample holding two crystal forms put the cell, and with it the distance, between them -
while the merge later keeps only the given cell's population (GivenCellPopulation). The sampled
frames are now filtered by the same population before the bundle and the module placement are
formed, the population told from the strong frames (>= 20 indexed spots), whose cells show the
valley between two forms; a weak frame's cell at the start geometry spreads across it. A run of
one form has no valley and keeps every frame.

GivenCellPopulation: a stray frame or two far out in a population's tail is a local maximum of the
histogram with no valley of its own, so its range was everything, both forms included, and its
mean - the mean of all frames - could sit nearer the given cell than the given form's own and win.
A population is now represented only by its highest peak. Test
CellPopulation_StrayFramesBeyondTheTail (fails before, passes after).

Still battery, against the same code without the change: the two runs of a two-form sample
-0.0019 and -0.0015 R-free (0-cycle REFMAC; the second repeated three times, -0.0013..-0.0021,
run noise ~0.001), R_split 10.6 -> 9.8 % on the first; single-form controls fit the identical
geometry (R-free within 0.0003). Rotation does not call either path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit ada1b08003fa9748175cce381fc8f28569f28296)
The tail after the last image had unmarked gaps: the device ingest's second upload, geometry smoothing and
partiality recompute were one "smoothing" mark; the analyses step was one mark although the anisotropy
future, not tNCS, is what it waits on; and the time between "Processed N images" and the end of the run
(the pass's locals going, the pinned pool handed back) had none. Logging only: outputs unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 4ce9e5d6d489324a759f155ad9718271c52ba86b)
Rugnux::Run ended by giving the pinned buffers its engines kept for each other back to the driver
(cudaFreeHost of up to 512 MB, 0.07-0.12 s on a quiet workstation, 0.13-0.37 s loaded) - after the last
result was made and before the caller could show it. Now Rugnux::ReleasePinnedPool() is called by the
broker once the live status holds the result (and the run itself is destroyed after that, not before),
and by the viewer once it has emitted its result; the CLI, which exits after writing its files, leaves
it to process exit. Results unchanged; the broker's last image -> Done is shorter by that time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit b80f6bc3280be9e55a86cd8036f7db83a5742c15)
Under the header of the Rugnux live page, four big-number cards in the manner of jfjoch_viewer's merge
statistics window: Resolution (d_min of the merge), ISa, Space group (short symbol, screw axes
subscripted) and the model. The model card shows the fit over random orientations - R-work of the placed
model below its random-orientation null, in sd - which is the test rugnux places a model by, both for
molecular replacement (after the TFZ/LLG-Z gate) and for a model that fits where it is. The card is green
where rugnux placed the model (placement MR or RIGID_BODY), red where it did not (with MR's reason), grey
where no model was uploaded; no threshold of its own. "—" while the run is going, "searching…" during
the MR step.

/rugnux/status gains `space_group`, `d_min` and `isa` (set with the result; absent until then, isa also
where the error model did not determine it), filled from ProcessResult in RugnuxThread with the values
the summary's Space group, Resolution and Signal rows print.

Generated code regenerated from jfjoch_api.yaml: broker/gen (cpp-pistache-server 7.20.0), the TypeScript
client (npm run openapi) and broker/redoc-static.html. Python client not regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit fcc019707751aab9d8c9f349309eeadcf364b9c2)
The home page introduces Rugnux, the viewer and the acquisition system in three cards, with the
licence stated once, and the sidebar is regrouped by program: what a user of Rugnux or the viewer
needs comes first, and the algorithms, formats, interfaces, hardware and FPGA pages sit under one
collapsed "Technical reference" hub (TECHNICAL.md). No page moves, so every existing URL stays.

Download pages (RUGNUX_DOWNLOAD.md, VIEWER_DOWNLOAD.md) are rendered by a small Sphinx extension
(docs/_ext/jfjoch_downloads.py) from the `release` in conf.py, which update_version.sh rewrites,
so no version string on them is maintained by hand; a script then asks the Gitea API for the
newest published release and repoints the buttons at its assets, naming any archive that release
does not carry. Without network access the build-time links stand. JUNGFRAUJOCH.md is the
landing page of the acquisition side with its install pointers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 050442592504ce879dc51d78b6d3d1c58268a800)
docs/landing/ is a self-contained page (HTML, CSS, a little JS, no framework): a hero, a card per
program, the download section with the archives of the newest release and the reader's own
platform marked, the licence in one paragraph, light and dark. The jfjoch_downloads extension
renders it into the HTML output root as index.html at the end of every Sphinx build, so it is
served wherever the documentation is - Read the Docs, the gitea-pages branch and the broker's
frontend bundle - and its download buttons are the same ones as on the Sphinx download pages,
built from the configured release and repointed at the newest published one by the shared script.

The Sphinx root page moves to documentation.html (root_doc), and the frontend's documentation
frame opens that page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 64cde61f0b941b3e8c99eaa9b74afeaa75aefc78)
A restraint dictionary carried in the binary, so refinement reads nothing from disk and needs no
CLIBD_MON: the 20 amino acids and MSE, the DNA/RNA nucleotides, ATP/ADP/AMP/GTP/GDP/GMP, DMSO, PEG
fragments (PEG, PGE, PG4, 1PE), GOL, EDO, SO4, PO4, ACT, MPD, water and common ions - copied verbatim
from the CCP4 Monomer Library (LGPL-3.0) - plus links_and_mods.cif cut down to the links and
modifications those compounds can take (peptide, cis-peptide, proline, N-methyl, disulfide,
nucleic-acid backbone, termini, metal-Cys/His) and ener_lib.cif cut to its atom-type table.
monomers/update_monomers.py holds the compound list and regenerates the subset; CMake embeds every
.cif of the directory as string pieces under MSVC's literal limit; EmbeddedMonomerLibrary() parses
them from memory into a gemmi::MonLib once per process. About 0.7 MB of text in the binary.

gemmi v0.7.5's monlib.cpp, topo.cpp and riding_h.cpp vendored verbatim (prepare_topology). Licence
text in licenses/, rows and a note in THIRD_PARTY_NOTICES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 38034718052c4a300691393b5b817f476c83dfc2)
RefineModel (Refinement.h) refines a placed model against the merge, reading and writing nothing:
- placement stage (R1): every chain of 50+ atoms as a rigid body at once (L-BFGS on six parameters per
  chain, their gradient from the per-atom one through Ceres jets), then one B shift per residue;
- restrained stage (R2): positions and isotropic B of every atom of a residue the embedded dictionary
  describes, the rest held fixed but kept in Fcalc and the mask; riding hydrogens added by gemmi's
  topology, moved with their parents, re-placed every macro-cycle. Macro-cycles refit the mask, the
  scale and bulk solvent (FitModelScale / ModelScaleGPUPool) and the likelihood's D and beta (ML on
  the free set in resolution bins), rebuild the repulsion pairs (symmetry mates included), then L-BFGS
  on positions and, where chosen, on B.
- weights: a ladder of full refinements at multiples of the gradient-balanced X-ray weight, chosen by
  R-free within bond/angle bounds (ties to the tighter geometry), then the B refinement tried at its own
  weights and kept only where it beats B held by more than the tie.

The target and its gradient (RefinementXray, RefinementGPU): the Rice/centric amplitude likelihood
(RefinementMath.h, shared host/device code), the gradient by Agarwal's FFT method - the adjoint of the
reciprocal-space symmetry composition into a half-l (CPU) or half-u (cuFFT) spectrum, one inverse real
transform, then each atom's density derivatives (xyz and B) summed over its box. On the GPU everything
per evaluation stays on the device: ModelDensityGPU's gather, r2c, a fused compose + likelihood kernel,
a CSR gather for the adjoint (no atomics), c2r, one warp per atom for the gradient; the restraints are
one kernel per restraint plus a per-atom gather of gradient slots; cub reductions. Deterministic.

Restraints (RefinementRestraints): bonds, angles, torsions (with esd, no phi/psi, no alternative sugar
puckers), chiral volumes, least-squares planes from gemmi's Topo; repulsions beyond 1-4 (vdW radii from
the dictionary's energy library, 2.6 A for H-bond pairs, 1.9 A metal-N/O/S); B similarity of bonded and
angle-related atoms.

DensityBlobs (D1): gemmi's flood-fill blobs of mFo-DFc above 3 sigma peaking above 4 sigma, placed beside
the model and labelled WATER / ION / LIGAND / MODEL_ERROR by distance, contacts, volume and the anomalous
map.

tools/rugnux_refine_test refines a model against a finished run's p.mtz (developer utility, not
installed). Tests: likelihood derivative, the embedded library, restraint values on ideal residues and
every restraint kind's gradient against finite differences (CPU, and the GPU against the CPU), the X-ray
gradient against finite differences in P1, P212121, C2 and P6122 (CPU and GPU), and a synthetic
refinement that recovers a shaken model. Credits in docs/ACKNOWLEDGEMENT.md and at the algorithms.

Measured on the open arm's battery outputs against REFMAC 5.8, 10 cycles, same p.mtz, free set and start
model (R-free): 5reo 0.2228 vs 0.2236, 8dyz 0.1733 vs 0.1651, 9chw 0.1995 vs 0.1969 (CPU engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 8efe6f2322b3bfdae580bb34f256e67730dfc3c2)
Opt-in. After the model's placement and everything it decides (setting, indexing, enantiomorph, MR, twin
choice), the placed model is refined against the merge it was placed on (RefineModel) and the blobs of the
refined mFo-DFc map are listed (FindDensityBlobs). Nothing reads the outcome: the reflection files, the
space group and R_WORK/R_FREE of the model as given are those of a run without --refine.

Writes <prefix>_refined_model.cif/.pdb, <prefix>_refined_maps.mtz (FP FC PHIC FWT PHWT DELFWT PHDELWT FOM
FREE) and <prefix>_blobs.json; the report's model section gains REFINE_* keys (start, placement and
restrained R-factors, rmsd bonds/angles, clashes, fixed residues, weights, blob counts by kind) and the five
strongest blobs. Documented in RUGNUX_MODEL_AND_REFERENCE.md and CPU_DATA_ANALYSIS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit aaee92711a205bf6a575a47c4db4555d34e7e8fe)
Measured by stripping the deposited ligands and ions of 12 open-arm models (placement-refined, then the
blobs of mFo-DFc): ligands with a blob 34/39, called LIGAND 26/39, LIGAND blobs on a removed ligand or ion
24/33; ions with a blob 23/40. The ligand-size threshold was four resolution spheres, which called no glycan
at 3.3 A and every ion at 1.1 A; it is now one and a half, and never under 8 A^3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 8e66d58ba87c35119df3ba8779ca193053f5cf00)
On deposited models the per-residue B shift raised R-free at every resolution tried (2.56 A: 0.320 ->
0.327) and the rigid bodies rarely helped; each is now kept only where it lowers R-free by more than the
0.001 tie, otherwise the model goes on as it was. The positions' weight ladder runs from 1/1024 to 1/32 of
the gradient-balanced weight: the R-free optimum sat near 1/500 at 2.6 A and near 1/50 at 1.1-1.7 A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit d444fb03f610db70903de04f53f0489a71540b37)
The scale, the bulk solvent and the likelihood's parameters are held within a macro-cycle; refitting them
twice as often, with 10 L-BFGS iterations on the positions and 5 on B per cycle (was 25 and 15), lowered
R-free on a 1.65 A deposited model from 0.2218 to 0.2189 with B held, and halved what B refinement cost
there, at the same number of evaluations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 8021d0cfc0bd56b1cc149a40df72ed78f35367c8)
RefinementTest: the GPU restraint check on an engine made for a cell its gather takes
Build Packages / build:portable:macos-arm64:nocuda (push) Failing after 3m15s
Build Packages / build:jfjoch:rocky9:nocuda (push) Failing after 12m50s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Failing after 13m47s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Failing after 11m26s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Failing after 14m20s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Failing after 14m11s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Failing after 14m0s
Build Packages / Unit tests (push) Successful in 1h14m36s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:linux-x86_64:nocuda (push) Failing after 11m24s
Build Packages / build:jfjoch:rocky8:nocuda (push) Failing after 12m32s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 18m50s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 21m55s
Build Packages / build:jfjoch:rocky8:cuda (push) Failing after 13m56s
Build Packages / Build documentation (push) Successful in 59s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Failing after 10m11s
Build Packages / Validation Battery (push) Successful in 34m41s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m17s
Build Packages / build:portable:linux-x86_64:cuda (push) Failing after 13m19s
Build Packages / Generate python client (push) Successful in 26s
Build Packages / build:jfjoch:rocky9:cuda (push) Failing after 13m38s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m4s
d3673d17fc
The restraints do not read the cell; the 14 A cell that makes the symmetry contacts is too narrow for the
gather's boxes, so the engine was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit a6e00e10ce1798a97d1f054c7553cc9eb6b5cf4b)
Refinement restraints: n_slots, not slots - Qt defines slots as a macro, which broke the viewer's build through Rugnux.h
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:linux-x86_64:cuda (push) Failing after 14m59s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 20m37s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m21s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m21s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m50s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m33s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 22m30s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 28m12s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m20s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m21s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m42s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 23m43s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m30s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m6s
Build Packages / Unit tests (push) Successful in 1h36m6s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 32m0s
Build Packages / Build documentation (push) Successful in 1m8s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m31s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 28m26s
Build Packages / Validation Battery (push) Successful in 34m44s
f077320efa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
rugnux_refine_test: the CUDA device-link step a portable build needs for the static cuFFT, as rugnux and the portable test have
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m14s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 19m52s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m41s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 29m30s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m23s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m31s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m53s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m36s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m10s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 24m4s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m27s
Build Packages / Generate python client (push) Successful in 36s
Build Packages / Build documentation (push) Successful in 1m30s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m42s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 24m37s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 28m40s
Build Packages / Unit tests (push) Successful in 1h34m4s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m2s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 23m23s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 29m19s
Build Packages / Validation Battery (push) Successful in 34m43s
47960ccaf7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The stills merge had no working outlier rejection. --reject-outliers was 0 for stills, and when set its
band took the systematic part as a factor exp(+-n s), s the spread of ln(I / median) - on stills a
reflection's observations are partials and per-crystal scales, many near zero, so s is wide and the band
let through observations hundreds of sigma out. On a high-multiplicity broad-bandpass run with a spotty
powder contaminant, single grains in a weak reflection's box were that reflection's whole merged
intensity (half-set means -2 vs 2284 on 141 observations); the shells holding the powder rings and the
lowest shell merged at CC1/2 0.6-0.75 beside 0.99 elsewhere, at multiplicity ~400-1000.

Stills now test the plain linear band, n sqrt(a sigma^2 + (b <I>)^2) about the median - the error
model's b is the spread as it is - at n = 10 by default (rotation keeps 6 and its multiplicative band).
The stills merge statistics now report the rejected count (OBSERVATIONS_REJECTED was always 0 there).

Measured (REFMAC 0-cycle R-free of an isomorphous room-temperature model, depositor's free set, scale
mode on stored integrations, vs the same code with --reject-outliers 0):
- broad-bandpass in-house stills, 50k frames: 0.318 -> 0.265; 20k: 0.274 -> 0.244; R_split 0.29 -> 0.06,
  CC1/2 0.79 -> 0.98. 0.1% of the observations are rejected.
- monochromatic in-house stills, 30k frames: 0.216 -> 0.212; R_split 0.126 -> 0.087.
- n = 6 gains ~0.003 more there but costs the public XFEL sets 0.003-0.006 on three of four; 20 keeps
  most of the gain.
Still battery, 12 non-float sets, against rc180: R_split better on all 12; R-free change mean -0.0005
(range -0.006..+0.003, plus pal86 +0.009 that is within that set's run-to-run spread of ~0.007 - a
rerun of rc180's behaviour gave 0.192 against the baseline's 0.185); one draw becomes a win (lcls242_s),
nothing else changes class. Rotation is untouched (the stills band and default are gated on the
experiment not being a rotation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 3a7142801056f21c720956255f1207e0bf1a8f43)
The GPU known-cell indexer behind FFBIDX is now built from an in-tree copy of upstream commit
a94fd4f5c55289e70860feea358182d2072e02b5 (the archive the FetchContent block pinned), so it can carry
local fixes. No behaviour change.

Copied: indexer/src/{indexer.cpp, indexer_gpu.cu, log.cpp} and the ffbidx/ headers the build uses
(indexer.h, indexer_gpu.h, refine.h, log.h, exception.h, envvar.h), each with a header line naming the
upstream URL, commit and licence; LICENSE.md beside them; and the library's test spot lists
(data/simple/files/image{0..8}_{local,peakfinder8,radial}.txt) that FFBIDXIndexerCPU_MatchesGPU reads,
now found in-tree. ffbidx/version.h is written once ("1.1.3-a94fd4f") instead of generated - the
generator ran git in the parent build, so it used to stamp Jungfraujoch's own hash.

The in-tree target keeps the name fast_indexer_static and what upstream's indexer/src/CMakeLists.txt
gives the static library: the source list, CUDA_RUNTIME_LIBRARY Static, cxx_std_17 (PUBLIC), the source
directory as PUBLIC include, CUDA::cudart_static and Threads::Threads (PRIVATE), Eigen3::Eigen (PUBLIC,
the project's), the CUDA-architecture default only where none is set, and -Xcompiler=/Zc:preprocessor for
the CUDA sources under MSVC (CUB needs the conforming preprocessor). Left out: the shared library, the C
API (c_api.cpp, unused), install/pkg-config rules, the version_txt generator target, the top-level
project's data/tests/python/examples subdirectories, and -DVECTOR_CANDIDATE_REFINEMENT=1 (no source
reads it).

Licences: COLLECT.sh takes the text from the vendored LICENSE.md; the THIRD_PARTY_NOTICES row moves from
the fetched to the vendored table. CLAUDE.md's list of FetchContent downloads no longer names it.

Verified: clean configure and build of rugnux and jfjoch_test; FFBIDXIndexerCPU_MatchesGPU and the
Portable_FFBIDXIndexerCPU tests pass; a 3000-image stills run gives the md5-identical merged .hkl with
the fetched and the vendored library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 0e4fcb87de0fbb7c0022750ff4d62d8c5a5c3c18)
FFBIDX on the GPU: the same spots index to the same cells, whatever order the thread blocks finish in
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 19m23s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 23m47s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m33s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 29m33s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 29m21s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / Build documentation (push) Successful in 1m19s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m42s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 28m43s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 35m41s
Build Packages / Validation Battery (push) Successful in 38m34s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m27s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m19s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m8s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 23m43s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m49s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 30m37s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m24s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 30m3s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 32m54s
Build Packages / Unit tests (push) Successful in 2h7m57s
c982ba4f65
Each block of the candidate-vector and the cell search sorts its own candidates and merges them into
one shared top list under a lock, in the order the blocks happen to get there. The merge compared values
only, so where two candidates scored the same - common on the 30-spot seed the stills path tries first,
since the score is a sum of trimmed terms - the place went to whichever block merged first. The same
frame then indexed to a slightly different lattice, or not at all, from one run to the next.

Ties are now broken by the candidate's identity - the sample index for a vector; the cell vector (with
its flip), candidate and rotation sample for a cell - which makes the order total, so the merged lists are
the top-N of everything whatever the merge order. The blocks' own lists are already in that order: the
block radix sort is stable and its samples ascend with the thread index.

Measured on a 100k-frame serial set: 4-21 of ~33k indexed frames came out with a different lattice in
every pair of runs, which the stills scaling and partiality chain turned into 0.97-0.99 CC1/2 and
thousands of observations in or out of the merge. With the fix, three runs at the default thread count
and one at -N 8 give the md5-identical merged .hkl; a per-frame dump (spot list, indexer answer,
refined lattice) of 20k frames is identical across three runs where it differed in 4-6 frames before.
FFBIDXIndexerCPU_MatchesGPU and Portable_FFBIDXIndexerCPU_SyntheticStill pass. No regression test: a
repeat-and-compare test on the library's spot lists, threaded, passed before the fix too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 9cff12d652088851b81f6a353ac52aa8318799f2)
docs: --model on serial stills - breaking the indexing ambiguity is the one thing taken from it before the merge, and why
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m36s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 16m53s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m43s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m5s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 27m10s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 29m8s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 20m41s
Build Packages / Unit tests (push) Successful in 1h45m17s
Build Packages / Create release (push) Successful in 27s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m8s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m31s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 22m51s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m12s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 23m7s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m45s
Build Packages / Build documentation (push) Successful in 1m12s
Build Packages / Validation Battery (push) Successful in 37m27s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m22s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m21s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 18m42s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 29m32s
2daa173859
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Where the space group is given (-S) and its lattice admits alternative indexings, a stills run without
-z or --model used to merge the crystals' random indexings together (and warned). The crystals are now
made consistent with each other before anything is scaled (ReferenceFreeReindex): each takes the
indexing in which it agrees best with all the others (Brehm & Diederichs 2014), agreement being the
sum over shared reflections of the products of shell-normalised, per-crystal standardised intensities,
weighted by 1/(1+(sigma/sd)^2). The sum over all pairs is (|M|^2 - const)/2 for M the running sum, so a
pass over the crystals is one pass over the reflections rather than over all pairs: coordinate ascent,
four seeded starts, stop when <= 1 crystal in 1000 moves, the overall relabel that moves the fewest
crystals. Deterministic (fixed summation order, sequential passes).

A twin law of a pseudo-merohedral cell whose axes differ by a few per cent is decided by the crystals'
own refined cells, which the indexer has already used: where >= 90% of the crystals' cells fit better as
indexed, those crystals are anchors and only the others are decided by the intensities. Without this,
two public sets got worse (pal90 R-free 0.183 -> 0.207, lcls242_t 0.296 -> 0.350).

With a reference or model the per-image choice stands; the same resolution runs as a check, reported as
AMBIGUITY_DATA_DISAGREES. New report keys (section 2): INDEXING_AMBIGUITY_RESOLVED, AMBIGUITY_CRYSTALS,
AMBIGUITY_REINDEXED, AMBIGUITY_SEPARATION_SIGMA, AMBIGUITY_DECIDED_FRACTION (+ AMBIGUITY_TIME_S, dev).
--mode scale is not changed.

Still battery, no --model (R-free, REFMAC 0-cycle against the deposited model; before -> after; with
--model for comparison): euxfel83 0.321 -> 0.207 (0.204), euxfel152 0.346 -> 0.257 (0.245), alvra213
0.209 -> 0.201 (0.207), alvra213_int16 0.210 -> 0.202 (0.208), pal90 0.183 -> 0.183 (0.188), lcls242_t
0.296 -> 0.294 (0.285); controls sacla237 / lcls222 unchanged (no ambiguity, no-op). The step takes
0.2-1.6 s on 2.7k-54k crystals; synthetic 64k crystals x 200 reflections 0.5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 80d378a1fe92364fd5b99a17f5c9ee8498412d36)
RefinementStage sums the time of each stage over a refinement (Refit and its parts, the evaluations and
their X-ray, restraint, host and upload shares, the repulsion build, the riding hydrogens, the rungs) and
logs it at debug level at the end; rugnux_refine_test -v shows it. No change to any result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 9491f54e0beccf36c65ebcec9afff90d08485f86)
(cherry picked from commit 0ce206a88df960754476ce2f75ffaed90fbf3123)
EstimateLikelihoodParameters fits each resolution bin on its own and writes only that bin's values, so
the bins run under ParallelFor; the result is the same, bit for bit. It was the largest single stage of
a refinement (107 calls, one core): 5reo 2.42 -> 0.56 s, 8dyz 2.46 -> 0.57 s, 9yl4 3.54 -> 0.88 s;
whole refinement 5reo 6.3 -> 4.8 s, 8dyz 6.9 -> 5.3 s (RTX 5080, -N 8, loaded box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 85aa34d3031c8e36be4462fca544bc801af45d5a)
(cherry picked from commit d84ba55252adf455d5b4ba62052dfbafa6290a64)
Every evaluation uploaded both copies of the atoms' densities (the gather's and the gradient's) and their
boxes, although in a positions-only minimisation no B changes between two Refits. The engine now keeps one
copy (the gradient kernel reads ModelDensityGPU's atoms and boxes), uploads it only where some atom's
density was made again, and sends the positions from a pinned buffer without waiting. The densities that
are made again (every atom in a B minimisation) and the grid positions are worked out on the refinement's
threads. Same bits.

9yl4: upload 3.29 -> 0.56 s, host side 1.29 -> 0.61 s, refinement 22.3 -> 17.7 s; 5reo 4.8 -> 4.4 s
(RTX 5080, -N 8, loaded box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 46c3b67c91aee0aec3473b0fb1a4945c00bda070)
(cherry picked from commit 0aebc98110c8155932af647d6204891aec662294)
BuildRepulsions walked the neighbours of every atom and only then dropped the pairs of a riding hydrogen;
those atoms are now skipped before the walk. The atoms are searched in 64 blocks under ParallelFor and the
blocks' pairs joined in atom order, so the restraint list is the same as before for any thread count. The
model is no longer copied for the NeighborSearch. Same bits.

9yl4: repulsions 3.37 -> 1.09 s (91 calls), refinement 17.7 -> 14.2 s; 5reo 0.38 -> 0.12 s
(RTX 5080, -N 8, loaded box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit ded11388a18f5a4138a73150c4bb073570152727)
(cherry picked from commit 9c96836a90c9f170008d343af2cd885a4e59b321)
RefinementEngine::EvaluateWithRestraints evaluates both targets at the model's atoms. On the GPU the X-ray
chain is queued first, the restraints' coordinates are written into a pinned buffer while the device works
on it, the restraint kernels follow on the same stream, and the host waits once; the sums and gradients
come back through pinned buffers. Same kernels on the same inputs, same bits. The CPU engine keeps calling
the two in turn.

9yl4: evaluation 5.1 -> 4.7 ms, refinement 14.2 -> 13.4 s; 5reo 4.0 -> 3.9 s (RTX 5080, -N 8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 51434b98b62a9e6322ffe811bf272a45ff6ce003)
(cherry picked from commit 0bcfa3f8041cec4bb4bc376b8559f30c36d3ac80)
Every rung of the weight ladder starts from the same model and depends on no other, so the six position
trials, and then the three B trials at the chosen weight, run at the same time: on the main model and engine
and on up to five slots beside it, each a copy of the model before the topology was made (the topology is
built again on it, hydrogens included) with its own engine. The slots go on the GPUs in turn from the main
engine's, as many as each card's budget takes, so a machine with several cards spreads them; a slot whose
engine cannot be made is left out, and with none the trials run one after another on the main engine as
before. The threads are shared out among the slots. The log lines and the choice are made afterwards in
ladder order, so the log, the trial table and the refined model are the same, bit for bit (GPU and CPU
engines checked).

5reo 3.9 -> 2.5 s, 9yl4 13.4 -> 7.8 s, 6o2h 3.1 -> 1.9 s, 8dyz 4.6 -> 2.7 s, 9rcs 2.9 -> 2.2 s (one RTX 5080, -N 8).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 0ea6a6db8aba640afc5249fe748922ddfaa2537a)
(cherry picked from commit 0c0176c8bf571ed26f8a68a9de40222487c6200e)
Three terms of the score of a model - the same F_model at every stage, the as-given start and the
refined end alike - and a breakdown of it:

- Riding hydrogens from the start: the dictionary's hydrogens are added before the model is first
  scored (REFMAC's 0-cycle restores them; they move R by 0.010-0.013 at 1.1 A). The engine is made
  once, with them.
- The bulk-solvent mask on a grid of its own, min(d_min/3, 0.4 A), at most 2^26 points. On the
  density grid (1.1 A at 3.3 A) gemmi's 0.8 A shrink found no grid offset and was skipped, which put
  R 0.015-0.03 high at >= 2.5 A. 0.3 and 0.4 A agree to 0.001.
- The overall scale and anisotropic B by robust least squares (pseudo-Huber, iteratively
  reweighted, c = median |residual|) in the fine pass of the k_sol/b_sol grid (ModelScaleBox::robust,
  refinement only; MR and validation keep the plain fit). Plain least squares on |F| is the exact LS
  optimum but lets a few badly-fitted strong reflections set the scale (1.1 A set: R 0.1706 against
  REFMAC's 0.1600). The scale fit of refinement now runs on the CPU (no ModelScaleGPUPool).

RefinementSettings::score_only scores the model as given and stops; RefinementResult::score_start
carries every term (k, B, anisotropic B, k_sol, b_sol, mask spacing, R in 20 shells, -lnL work/free,
the likelihood FOM, hydrogens; per-reflection terms with score_only). GeometryStatistics gains rmsZ.
rugnux_refine_test: --score-only, --dump.

Measured against REFMAC 5.8.0431 ncyc 0 (refi bref iso) on the same model, data and flags, 14 open-arm
sets 1.1-3.6 A: R-work/R-free within 0.002 on 12 (was +0.003..+0.03); 7n2s and 9yl4 score 0.003/0.005
below REFMAC, which a 0.6-0.8 A mask grid reproduces. REFMAC with ANISOU stripped scores identically,
so isotropic B is not a parity term. Open: the likelihood's D/FOM (ours 0.01-0.11 higher at high
resolution); geometry differs only by REFMAC's metal LINK restraints and residues outside the
embedded dictionary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 4f94b21611874ad9dcf89e6771fe311a0e10bb06)
(cherry picked from commit 00db6a6f539ac2da515c0a649474335f4c4b07b8)
rugnux --mode refine --model model.cif -o out data.mtz

The input is an MTZ or a structure-factor mmCIF, gzipped or not, by content
(writer/RugnuxReflectionFile: parsing in the file layer, the library reads no file). Columns are
found, not named: F/SIGF-like amplitudes (calculated amplitudes and map coefficients passed over),
else intensities made into amplitudes by French-Wilson, else the mean of the anomalous pairs; the
free set from a FreeR_flag-like column (0 the test set, or the minority value), else rugnux's own
5 % set. RunRefineOnly (rugnux/RugnuxRefine) runs RefineModel; the outputs are --refine's
(_refined_model.cif, _refined_maps.mtz, _blobs.json) and a report with the REFINE_ keys, the input's
REFINE_FRENCH_WILSON / REFINE_FREE_FLAGS, and every term of the start score under REFINE_SCORE_
(also in --refine's section). --refine-score-only scores the model as given; --refine-d-min cuts
the resolution.

tools/battery/refine_parity.py runs REFMAC 5.8 at 0 cycles and rugnux --refine-score-only on the
same files and prints the terms side by side, with R by resolution from both programs' model
amplitudes on the same reflections.

Tests: [refine_only] - column choice (observed over map coefficients, intensities, 1-marks-free
flags, no flags, no usable column), an MTZ read from its bytes, and the mode end to end on a
synthetic cell (French-Wilson, generated flags, score-only, the report keys).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 63ee830c39c988588cffb8c9d4638a678815ec2c)
(cherry picked from commit d7397311e6ce0d3bb37ac91e8973b6052e38589f)
The score's robust fine pass (pseudo-Huber IRLS of ModelScaleBox::robust) on the device, so that refinement on
a GPU no longer fits its scale on the host every cycle - with the trials side by side that was a robust grid
search on one thread per slot (13-133 s per refinement on the seven timing sets, against 1.7-8 s before the
score commit).

- ModelScaleGPU: the weights multiply the WSSR and J^T J / J^T r in scale_sums (a weight row per fit,
  reserved at the first robust fit); robust_residuals / robust_weights kernels; the median of each fit's
  first residuals taken on the host as FitModelScale takes it (nth_element at n/2, on the fits' threads);
  LevMarBatch split out of FitBatch so that the rounds restart from where a fit stands. FitSolvent(...,
  robust) refits the coarse winner in the fine pass, as FitModelScale's robust fit does; the plain fit is
  unchanged.
- ModelScaleGPUPool::Fit(..., robust); its streams at the highest priority: a fit is a chain of small
  launches the host waits on, and on a card shared with the trials' long kernels each wait queued behind
  them (refit.scale 58-87 ms -> 14-45 ms per cycle, the result the same to the bit).
- Refinement: the main context and every trial slot fit on their pool (robust); the CPU engine keeps
  FitModelScale's robust fit.

The card's fit is FitModelScale's to rounding (float residuals and weights, the steps' anisotropic factor in
float), deterministic run to run and whichever engine a fit takes: R-work/R-free of the 14 parity sets on
the GPU equal the CPU engine's to 4 digits (largest difference 1.4e-6 on the 3.6 A set).
Test: ModelScaleGPUPool_RobustMatchesFitModelScale (one reflection in fifty a 4x outlier, five cells).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit d15f048cbd7ed2d6b430f9f9cb5018ca284da8f0)
The score draws the mask on a grid of its own (min(d_min/3, 0.4 A), BulkSolventMask) on the host; on a GPU
engine that was a CPU mask and transform every cycle, per trial slot on one thread. Now the engine masks
on that grid itself:

- RefinementGPUSetup::mask_grid (BulkSolventMask::MaskGrid, the grid the host mask uses); where it is not
  the structure factors' grid the engine reserves a grid, a spectrum and an R2C plan for it (counted in
  DeviceBytes and the FFT work area), and Fmask masks (ModelMaskGPU), transforms and reads the rows there.
  Refit takes the engine's Fmask on a GPU, BulkSolventMask on the CPU.
- ModelMaskGPU's shrink: each edge point marks its stencil as solvent instead of every macromolecule point
  searching its stencil for an edge - the same idempotent stores to the same points, so the same mask to
  the bit (ModelMaskGPU_* tests unchanged), and a thin shell of points visited instead of half the grid.
- BulkSolventMask finds its symmetry orbits at its first mask: a GPU refinement never draws one on the host
  (1.1 s of trial-slot setup on the 3.6 A set).

The card's mask is gemmi's but at points exactly on an atom's radius (ModelMaskGPU.h): R-work/R-free of the
14 parity sets on the GPU are the CPU engine's to 4 digits. Refinement deterministic run to run on the GPU
(7 timing sets, refined model and report identical over 5 runs of successive builds that did not change
results).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 89b5d00907c9ad99e8b389f6272a3724f981cd10)
- RefinementSettings::max_trials_at_once (rugnux_refine_test --trials-at-once): 0 as many as the ladders and
  the cards take, 1 one after another - the check that side by side changes nothing. Verified: refined
  model and report identical, side by side vs one after another, GPU (5reo, 9yl4, 6o2h, 9rcs) and CPU
  engine (6o2h, 9rcs, 5reo), and run to run.
- RefinementSettings::cancel, an atomic flag for the broker's live deadline: checked at every iteration of
  the L-BFGS (positions, B, rigid bodies, group B: the minimiser stops where it stands), at every
  macro-cycle and before every weight trial. The trials that ran are chosen among as usual; cut before the
  first, the model is the placement's. RefinementResult::cancelled says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit f708d3d227bb42215ffe3d9a15c766dbd73b783c)
Metal sites were held only by the 1.9 A metal contact of the repulsions; REFMAC restrains the metal LINKs of
the coordinate file (its documentation), which was the one geometry term the 0-cycle comparison did not
share (one 2.8 A set: bond rmsd 0.018 against REFMAC's 0.042).

- monomers/metals.cif: the Monomer Library's own statistics of metal sites (metals.json, LGPL data as the
  rest of monomers/), cut by update_monomers.py to the metal ions of its compound list and O, N, S donors:
  mean distance, spread and number of sites per metal, donor element and coordination number.
- AddMetalLinks (before prepare_topology): every LINK / struct_conn record between a metal ion and an O, N
  or S that no link of the dictionary describes (its ZN-CYS, ZN-HIS, FE-CYS, FE-HIS are taken as they are)
  gets a link of one bond at EmbeddedMetalDistance for the two elements and the ion's coordination - the
  number of its donor records, symmetry mates included - from the rows resting on at least 20 sites (the
  coordination's own row, else the neighbours interpolated), sigma their spread (>= 0.02 A); a His or Cys
  donor loses the proton the bond replaces (DEL-HD1 / DEL-HE2 / CYSmod1, as the library's links do).
  Records only, no distance search; records to a symmetry mate count for the coordination but are not
  restrained (bonds to symmetry mates are not, anywhere).
- The metal bonds are a restraint set of their own (RefinementTopology::metal_bonds), evaluated with the
  geometry and reported apart (GeometryStatistics::rmsd_metal, REFINE_RMSD_METAL_BONDS,
  REFINE_SCORE_RMSD_METAL_BONDS / _BONDS_ALL): their sigma is the spread of real sites, so they sit
  further from ideal than covalent bonds, and the weight ladder's 0.02 A bound stays a bound on the
  covalent ones. refine_parity.py compares REFMAC's bond rmsd (links included) with the combined one, and
  takes --gpu.

Score-only geometry against REFMAC 5.8 0-cycle, all bonds (count): 2.8 A Zn/Fe set 0.0402 (4934) vs 0.042
(4935), was 0.0178; a Zn-Cys set 0.0186 (7102) vs 0.019 (7102); a Ca set 0.0297 vs 0.026 (REFMAC also
refines the sugars and uses one Ca-O distance); a Mg set 0.0114 vs 0.017 (REFMAC restrains 8 symmetry
links and puts Mg-O at 1.99 A, the library's statistics at 2.07). R of the scored model unchanged (+0.0001
on the first from the removed His/Cys protons). Refined R-free with the links vs without: +0.0016, -0.0002,
+0.0005, +0.0004.
Test: RefinementRestraints_metal_links (Zn-Cys4 by the dictionary's link, Mg(H2O)6 by the new ones, both
back at their distances).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 0fcffc5c71e03e184cff18f07524473462a57b68)
The likelihood's variance per bin (beta) is fitted by maximum likelihood on the free reflections as the whole
spread of Fo about D Fc, and D is then fitted on the working reflections with the variance eps beta + 2 sigma^2
(acentric; + sigma^2 centric), which is also the target's variance. Murshudov et al. (2011) Acta D67 355:
D from the working set, the variance from the free set. The measurement error is thereby counted twice, so D
and the figure of merit fall where sigma is a large part of the spread - at high resolution.

Measured on REFMAC 5.8.0431 0-cycle output (black box) of the 14 parity sets: its FOM inverted per reflection
gives var = eps a + 2 sigma^2 (acentric, fitted coefficient 2.00) and eps a + sigma^2 (centric); its D
(FC_ALL/FC_ALL_LS = the log's SigmaA_Fc1) and a are reproduced by this estimate (on REFMAC's own Fc, one set
to rms 0.009 in D over all 20 bins). The previous per-bin free-set ML put D at 0.39-0.48 where REFMAC has
0.06-0.13 in the outermost bins.

rugnux score vs REFMAC 0-cycle, 14 sets: mean |FOM difference| 0.056 -> 0.012 (max 0.112 -> 0.042), per-bin
D rms 0.107 -> 0.068; R-work/R-free unchanged (D does not enter them). The score's FOM is now the mean over
the working reflections, as REFMAC reports it, and the refined model's maps are weighted by the refinement's
own m and D rather than a separate sigma_A estimate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit d00d635e2874bff6cd216b7f24367362d0cc2e56)
(cherry picked from commit b93e0ea2fc1ffcc6bb40dae9b77816a321f4d6ec)
EstimateLikelihoodParameters' golden-section searches over D at a fixed beta - four per bin on the free
reflections, and the one on the working reflections the D/FOM change added, 50 evaluations over 95 % of
the data - evaluated -ln L reading every row through the index and taking log(var) at every evaluation.
The rows are now gathered per search with their variance and its logarithm; the sum is the same
expressions in the same order, so D, beta and everything after are the same to the bit (refined model and
report identical on the seven timing sets). refit.ml 200 -> 140 ms per cycle on the 1.1 A set, one thread per
trial slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 23ad3bfc254d10af310f17dbb8f85616f666a783)
Each macro-cycle of a weight trial used to start a fresh Ceres solve: the L-BFGS memory was dropped every
10 (positions) or 5 (B) iterations and every cycle began with a steepest-descent step. RefinementMinimiser
keeps one memory for the positions and one for the B factors across a trial's macro-cycles (the target's
scale and likelihood parameters are refitted in between, which the memory tolerates), with a backtracking
Armijo line search and Oren-Shanno scaling of a diagonal initial inverse Hessian.

The diagonal: for B, the X-ray curvature per atom - the likelihood's curvature in |F_model| per resolution
shell against the atom's (s^2/4)^2 f^2 exp(-B s^2/2), calibrated once per trial by a central-difference
probe along a random direction - plus the B restraints'. For the positions none: tried with the restraints'
Gauss-Newton diagonal (bonds and angles, ~1e4 per axis against the X-ray's ~1e2) it slowed the minimiser,
since that stiffness is in directions that move bonded atoms together.

Same budget (10 cycles x 10 + 5 iterations, same ladder), median R-free (REFMAC 0-cycle scorer) from the
refinement battery's MD starts, base -> this: 5f6m (8 starts) 0.250 -> 0.210, 8dqb (8) 0.387 -> 0.345,
7arr 0.383 -> 0.356, 11if 0.325 -> 0.295, 5reo 0.316 -> 0.326 (CA RMSD 0.48 -> 0.43), 7d98 0.416 -> 0.393;
REFMAC converged: 0.168 / 0.284 / 0.311 / 0.251 / 0.261 / 0.361. Deposited models (14 parity sets): R-work
lower, R-free +0.0035 on average (their free sets were fitted to; the minimiser now moves the bottom rung
further). Time unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit dfba4c1bd3c9345f31b64fd1cadc805810960825)
Every rung of the old ladders (6 positional, then 3 B) was a full 10-cycle refinement from the placed model, so
~90% of the evaluations were thrown away and the kept trajectory had 100 iterations. The run is now:

1. a ladder from the placed model, 2 macro-cycles per trial: three positional weights (w0/1024, /128, /16), each
   with the B factors held and refined (adp = 4 w), chosen by the old rule (tighter first, a later trial wins only
   within the geometry bounds and by more than the R-free tie);
2. a trajectory of steps from where the last one ended, each two trials side by side - the weights as they are and
   the X-ray term raised, in turn the positions' (x2, at most w0/8) and the B factors' (x4) - until a step lowers
   neither R-work nor R-free by more than the tie (at most 8 steps);
3. a final ladder from there at x1/2, x1, x2.

A macro-cycle is 30 positional and 20 B iterations (the minimiser's memory kept within a trial). A trial starts
from the scale, solvent and likelihood fit its predecessor ended with (the same computation, so result-neutral),
and the slots that run share the threads.

On 5 battery MD starts (start 1 of 5f6m 1.09 A, 8dqb 2.04, 7arr 0.92, 11if 1.37, 7d98 3.32; R-free by rugnux's
scorer, REFMAC 0-cycle within 0.002), previous commit -> this, against REFMAC run to convergence (40-100 cycles):
5f6m 0.215 -> 0.167 (REFMAC 0.168), 8dqb 0.341 -> 0.290 (0.284), 7arr 0.356 -> 0.289 (0.288), 11if 0.295 ->
0.236 (0.244), 7d98 0.393 -> 0.337 (0.360). Time 5.4 / 8.6 / 7.3 / 3.9 / 10.0 s on the RTX 5080 (base 6-7 s).
Halving a weight during the trajectory never won in the logs, so steps only try raising it; the final ladder
can lower it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 2bfb84bc4f3c72e8da37e54b728839b65a636519)
- B similarity: E = (B_i - B_j)^2 / (kappa^2 B_i B_j), the symmetric Kullback-Leibler divergence of two
  isotropic Gaussians (Murshudov et al. 2011), so sigma = kappa sqrt(B_i B_j) instead of a fixed 1.5-4.5 A^2.
  kappa 0.1 / 0.133 / 0.2 / 0.3 for main-chain bonded / angle-related and side-chain pairs (REFMAC's logs list
  sigma ~0.1 B for main-chain bonded pairs at B 11-140; the ratios are those of the old sigmas, which these
  equal at B = 15). New class: atoms within 4 A that no bond or angle relates, waters and symmetry mates
  included, kappa 0.5 (BuildBContacts, sharing the neighbour search with BuildRepulsions). CPU and GPU.
- The B factors' X-ray weight is balanced on its own (BalancedADPWeight: B-restraint over X-ray B-gradient on
  the model with B shaken by 20%) instead of being a multiple of the positional weight; the ladder tries B held
  and refined at that weight, the trajectory's B steps divide and multiply it by four, positional steps and the
  final ladder leave it.
- Before each B-refining macro-cycle the overall isotropic B of the scale moves into the atoms and the
  solvent's B (F_model unchanged), so the two do not drift against each other.
Tried and not kept: xyz and B as one L-BFGS vector (the B block scaled by its curvature, the positions by the
median X-ray curvature): no better than alternation with a memory per block on 12 starts (mean R-free 0.265
vs 0.262), worse on 7arr/7d98; a refit before each B step: no gain for +20-40% time.

Refinement battery MD starts, median R-free by the REFMAC 0-cycle scorer, previous commit -> this (REFMAC to
convergence): 5f6m x8 0.167 -> 0.167 (0.168), 8dqb x8 0.291 -> 0.294 (0.284), 7arr x4 0.270 -> 0.278 (0.311),
11if x4 0.245 -> 0.244 (0.251), 5reo x4 0.312 -> 0.258 (0.261: the ladder no longer keeps B held where B is the
model's largest error), 7d98 x4 0.342 -> 0.335 (0.361). Deposited models: unchanged (B stays held there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit ed292f35b482b696d81f71f1d97e0016ca05b144)
Three changes to how the trajectory of commit 2bfb84bc4 moves and ends:
- A step is taken while its trial lowers R-free or R-work by more than the tie and its R-free stays within the
  tie of the best so far; the trajectory then ends at its best step. On a deposited model, whose free set it
  was fitted to, R-work falls while R-free rises, and the trajectory now ends where it began instead of walking
  on while R-work falls.
- Positional steps try the weight halved as well as doubled: at a fixed weight the geometry loosens as the model
  improves (the X-ray gradient grows with D) until the trials leave the bounds, which stopped 8dqb's
  trajectories at 2 A (angles > 2.0 deg at w0/128 by step 6).
- The final ladder's choice replaces the trajectory's end only where it displaces it (geometry within bounds,
  R-free lower by more than the tie); before, its first rung was taken by default, two more cycles of movement
  that on deposited models cost R-free.

Deposited models (14 parity sets), R-free after refinement vs refine-combined: mean +0.0058 -> +0.0016
(e.g. 7n2s +0.012 -> +0.003, 9rcs +0.013 -> -0.008, 7d98 +0.015 -> +0.003, 9yl4 +0.010 -> +0.003), the time
halved against refine-combined. MD starts, median R-free (REFMAC 0-cycle scorer), previous commit -> this:
5f6m 0.167 -> 0.168, 8dqb 0.294 -> 0.297, 7arr 0.278 -> 0.290 (REFMAC 0.311), 11if 0.244 -> 0.245, 5reo 0.258 ->
0.259, 7d98 0.335 -> 0.335. Determinism: run A == run B == --trials-at-once 1 on 5reo, 9rcs and a 5f6m MD start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit c2b55aab1aaff637d9484dec0d25003b5c4a3857)
Refinement (Refinement.h) now runs whenever --model or --mr places the model (rigid body fits, or
molecular replacement placed it), on the GPU or the CPU: a run that should not spend the time is
given no model. --no-refine skips it (for debugging); --refine is still accepted and ignored. A
model that was not placed (rejected by the rigid body, not placed by MR - e.g. the battery's
wrong-model null) is no longer refined.

Processing-inert as before: it runs after every decision; R_WORK/R_FREE and every key outside
REFINE_ (section 5), and <prefix>.mtz / _model.cif / _maps.mtz, are unchanged. The block moved
after the placement notes so they stay on the step that placed the model. The viewer's processing
jobs refine too. The equivalent command line gives --no-refine back where refinement was off.

Battery: score.py reads keys exactly, so REFINE_R_FREE cannot alias R_FREE; new report-only
fields rfree_refined, rwork_refined, refine_s (part of rugnux_wall_s), shown in the per-set table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit b0bb741831f517b553e59152b3b482a3f75d953a)
The live run refines a placed model as `rugnux` does (ProcessConfig::refine), after every decision
and before the end message; a cut before it starts leaves it out, as the model. The writer still
gets the placed model; the refined one stays with the broker.

API (broker/jfjoch_api.yaml):
- rugnux_live_model.refinement (rugnux_live_refinement): performed, reason, on_gpu, seconds, r_work,
  r_free, rmsd_bonds_A, rmsd_angles_deg, blob_count, blobs (at most 50, rugnux_live_blob: kind, x, y,
  z, peak_sigma, volume_A3, nearest_atom, nearest_atom_distance_A)
- rugnux_live_status.refined_model
- /rugnux/model.cif and /rugnux/map.ccp4 take stage=placed|refined, by default the refined model
  where there is one; the anomalous map stays on the placed model's phases (the refined
  coefficients carry none).
Regenerated broker/gen, frontend/src/client, redoc, and the python client docs (which also brings
the hero fields and rugnux_live_model it was missing).

Frontend: the model card keeps its green/red and gets "R-free 0.xx (refined)" as its context line,
"refining..." while the step runs; the model section adds refined R-free and blob tiles; Mol*
shows the refined model and maps, with the blobs as small spheres coloured by reading (switch, off
at first). The Refinement step is in the timeline like any other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 3b47fb9a53b7dfad1c1ed289e169094c8d3d9d3e)
The refinement of a placed model gets the run's cancel flag (RefinementSettings::cancel, through
CancelFlag::RunFlag), so the broker's deadline or a cancel - and Ctrl+C in rugnux - cuts a running
refinement short with what its finished trials found. RefinementResult::cancelled goes to the live
status as rugnux_live_refinement.cancelled (API, regenerated clients) and to the step's note.

Hero: once refined, the model card's headline is "R-work / R-free, refined" (e.g. 0.32 / 0.35), its
green/red still the placement verdict, the placement and fit in the context line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 395016a30c2bc2aa26fdf2cec2ced9cebe6fad80)
`rugnux <images> <model> <reflections>` in any order, without --model or -z. Each positional file
is classified by its contents (RugnuxInputKind.h, in JFJochRugnuxOutput - the library still reads
no files): an MTZ (magic) or an mmCIF with a _refln / _diffrn_refln loop is reference data (-z; the
input itself in --mode refine), a PDB file (a PDB record first, then ATOM/HETATM) or an mmCIF with
an _atom_site loop is a model (--model), anything else is images for the readers. CBF (###CBF or
_array_data) counts as images; a file whose first line is neither data_ nor a PDB record is decided
there, so image files are not read through. An mmCIF holding both atoms and reflections is refused
as ambiguous. At most one model and one reference, --model/--mr and -z included. Gzip read through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 8f844b0bf86f338d20e8aed426a387722dd8d7cc)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The per-image reference/model choice of indexing ignored the crystals' refined cells. On a
pseudo-merohedral cell, where at least 90% of the crystals' cells fit the run's cell better as
indexed than after a twin law, the cells decide that law - the rule the data-only resolution
already uses (AnchorOnCells, factored out of ResolveReindexReferenceFree and shared). The resolver
now keeps each image's CC per indexing (IntegrationOutcome::reference_cc); before the stills
merge, AnchorReferenceReindex puts every image back as indexed, applies the cell rule, and lets
the reference choose only among the indexings left open. Exactly merohedral lattices (no metric
signal) are unaffected.

Still battery (REFMAC 0-cycle R-free, model before -> anchored; data-only in brackets):
pal90 0.1883 -> 0.1834 (0.1832), alvra213 0.2073 -> 0.2011 (0.2013), alvra213_int16
0.2081 -> 0.2021 (0.2024); euxfel83 0.2037, euxfel152 0.2452, sacla237, lcls222 unchanged;
lcls242_t 0.2847 -> 0.2952 (0.2942): there the model had moved about half the crystals against
cells that decide all three laws (96/96/100%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 85c804676ffd15ffd66e2ba05928000d6f4e765f)
rugnux: no --refine flag - a placed model is refined by default, --no-refine skips it
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m49s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m15s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m34s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 23m41s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m2s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 28m20s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m53s
Build Packages / Unit tests (push) Successful in 1h35m43s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m51s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 22m42s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 23m21s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m29s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m18s
Build Packages / Build documentation (push) Successful in 1m12s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 27m41s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m22s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m12s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 31m25s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 23m32s
Build Packages / Generate python client (push) Successful in 39s
Build Packages / Validation Battery (push) Successful in 39m44s
6da51dc5d7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
BulkSolventMask::MaskGrid, documented as "sizes and spacing set, values not allocated", allocated and zeroed the
whole fine mask grid (up to 2^26 floats) - in BulkSolventMask's constructor and in every GPU engine's, i.e. once
more for each weight-trial slot, only for the grid's size. It now takes good_grid_size() as set_size_from_spacing()
does, and the CPU engine's Fmask() allocates its copy. rugnux_refine_test, RTX 5080, -N 8: 9yl4 8.6 -> 7.8 s,
7d98 4.85 -> 4.0 s (engine 0.66 -> 0.16 s, slots 0.60 -> 0.23 s on 9yl4); the high-resolution sets, whose mask
grid is small, are unchanged.

RefinementMinimiser keeps s.y of every pair it stores and Direction() takes rho and gamma from it instead of two
more dot products per pair per call (the same Dot() of the same vectors): Direction's CPU time -22% on 9yl4; no
wall change with the trials side by side, where other slots fill the card meanwhile.

Bitwise: refined CIF identical on 5reo, 5f6m, 9yl4, 7d98, a 5f6m MD start (GPU) and 6o2h (CPU engine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 6f5caf5e1b1183a752acc5cca78bfb7ef002b12a)
Deterministic, rounding changes. The per-bin ML fit took 4 rounds x (40 + 40) golden-section steps on the
free set and a 50-step golden section for D on the working set - each step a Bessel I0/I1 per row, ~4.6M per
Refit at 92k reflections, 27-37% of a trial's wall on the high-resolution sets. Each one-dimensional search is
now a safeguarded Newton iteration on the analytic first and second derivatives (bracketed on the sign of the
derivative, bisection where a step leaves the bracket or the curvature is not positive), within the same
brackets; the free-set alternation keeps its 4 rounds from the moments, so beta is the one it was (a converged
joint fit was tried: the 4 rounds are not converged in the low-resolution bins, and converging them moves
6iu9's FOM by -0.003, so it was not kept). D on the working set starts from the bin's D of the context's
previous Refit (XrayRowModel::bin_d), restored with a trial's fit and cleared at a trial's first Refit, so a
trial depends on nothing its slot ran before (side by side = one at a time, md5).

14 parity sets, score only: R-work/R-free identical to 4 digits, mean |FOM - REFMAC| 0.01186 -> 0.01186,
D within 1e-5 and var within 1e-4 (relative) per reflection. refit.ml (TIMING sum, -N 8): 5reo 1.47 -> 0.11 s,
5f6m 3.88 -> 0.26, 9yl4 1.37 -> 0.11, 5f6m MD start 4.97 -> 0.46. Wall: 5reo 2.03 -> 1.75, 5f6m 3.45 -> 2.60,
9yl4 7.99 -> 7.36, 7d98 4.04 -> 3.78, 5f6m MD start 6.10 -> 4.82 s. 5f6m MD starts 01-04 median R-free
0.1693 -> 0.1692.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit a91511945e0ccd7145a93877c0c231e87f86fb44)
Bitwise (the bins are fitted independently: the same bits at any thread count). The weight-trial slots Refit
in lockstep while the GPU idles, each with its 1/slots share of -N for the ML bins; the bins now go to the
shared worker pool with -N tasks per slot, so the threads of slots that are not fitting help the ones that are.
After the Newton searches (previous commit) the ML fit is 0.1-0.5 s summed over a run, so the wall gain is
within noise: 5reo 1.63-1.75 -> 1.72, 5f6m 2.60 -> 2.57, 9yl4 7.34 -> 7.28, 7d98 3.78 -> 3.78, 5f6m MD start
4.82 -> 4.76 s. Refined CIF md5 unchanged on 5reo, 5f6m, 9yl4, 7d98, 5f6m MD start (GPU) and 6o2h (CPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 56815f5390cc01e8da5d420fff73cdf4394c385c)
Bitwise. The first robust round copied every fine-pass fit's residuals to the host (up to 25 x n floats,
9 MB at 92k reflections) and took std::nth_element of each there. The median is now a radix selection on the
card (one block per fit, four byte-wide passes of integer counts over the float bits - the residuals are not
negative, so the bits order as the values): the element nth_element puts at n / 2, the same value; only the
fits' c come back. FitSolvent and RobustBatch lose the host threads they took for it.

Refined CIF md5 unchanged on 5reo, 5f6m, 9yl4, 7d98, 5f6m MD start. Wall (-N 8, quiet): 5reo 1.72 -> 1.68,
5f6m 2.57 -> 2.43, 9yl4 7.28 -> 7.19, 7d98 3.78 -> 3.70, 5f6m MD start 4.76 -> 4.57 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit dc53896c335fdabdf4d417323038e05b8b63535d)
Logic change (deterministic), kept on evidence. Every Refit searched the whole k_sol/b_sol box - an 88-point
coarse pass of plain least squares, then the robust fine pass around its winner. ModelScaleBox (and
ModelScaleGPUPool::Fit / ModelScaleGPU::FitSolvent) take an optional start; with one, the coarse pass is
skipped and the fine pass is laid around it. Refinement passes the grid winner of the context's previous
Refit (before MoveOverallB moves the overall B into b_sol); it travels with a trial's fit, and a trial's first
Refit without one (Context::ForgetStarts, which also clears the likelihood's D start) searches the whole box,
so a trial still depends on nothing its slot ran before (side by side = one at a time, md5).

Evidence (GPU, -N 8): full refinement of the 14 parity sets from their placed models - 12 refined CIFs
identical (md5), 9ig7 and 6iu9 differ, final R-work/R-free equal to 4 digits but 6iu9 R-work 0.2911 -> 0.2910;
of the 5 timing cases 4 identical, 7d98 (cif start) R-free 0.3096 -> 0.3095; 5f6m MD starts 01-04 median R-free
0.1692 -> 0.1692; 6o2h on the CPU engine identical (md5). The score-only parity table is not touched (one Refit,
no start). refit.scale (TIMING sum): 5f6m 1.52 -> 1.10 s. Wall: 5reo 1.68 -> 1.53, 5f6m 2.43 -> 2.24, 9yl4 7.19 ->
7.02, 7d98 3.70 -> 3.65, 5f6m MD start 4.57 -> 4.22 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit ff285fb525ecf4b480698d4aafe212a34301a8fe)
Bitwise. MakeOwnSlot copies the model as it was before the topology and reads only what the placement does not
change (cell, reflections, the engine's kind, the B ceiling), so the slots are now built on their threads right
after the start score, beside the rigid-body and group-B placement, and joined where they were built before
(also where the placement throws). The "slots" stage goes from 0.23 s to 0 on 9yl4; the placement is not
slowed (0.59 -> 0.60 s). Refined CIF md5 unchanged on 5reo, 5f6m, 9yl4, 7d98, 5f6m MD start (GPU) and 6o2h
(CPU); side by side = one at a time. Wall: 5reo 1.46-1.53 -> 1.54, 5f6m 2.26 -> 2.21, 9yl4 7.03 -> 6.83, 7d98
3.65 -> 3.56, 5f6m MD start 4.20 -> 4.15 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 0752a921a4293d4213894e3242c4ef36d8ce2a05)
Bitwise. cudaStreamSynchronize spins a core for the whole wait; the weight trials' engines wait on a shared card
for most of a run, so about half of the user cycles of a refinement were the spin (PROFILE.md: 38-50%). A
StreamWaiter (CUDAMemHelpers.h: an event made with cudaEventBlockingSync, recorded and waited for) now takes the
refinement engine's waits - evaluation, Fcalc, Fmask, the uploads of positions, row model, blur and restraints.
The short waits of the host-driven loops stay spinning: the scale fit's Levenberg-Marquardt (one launch of
66-120 us per step) and the gather's pair count; making those block too cost 2-3% of wall (wake-up latency),
here the wall is unchanged. No process-wide cudaDeviceScheduleBlockingSync: that would change every GPU wait
of the broker.

CPU use (-N 8, time -v): 5reo 301-315% -> 190%, 5f6m 326% -> 181%, 9yl4 333% -> 222-228%, 7d98 307% -> 181%,
5f6m MD start 309-320% -> 151%. Wall, two interleaved runs each: 5reo 1.43-1.52 -> 1.43, 5f6m 2.19-2.20 ->
2.19-2.20, 9yl4 6.83-6.84 -> 6.88-6.89, 7d98 3.50-3.51 -> 3.58, 5f6m MD start 4.16-4.34 -> 4.16 s. Refined
CIF md5 unchanged on the 5 GPU cases; side by side = one at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit d23d2cf7bb99b30617a3f57c531b03db8bc03e05)
Refinement GPU: the atom gradient kernel walks its box without integer division
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m32s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m38s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 31m39s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m10s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m4s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 28m32s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m0s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m17s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 23m45s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 23m41s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m36s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 23m51s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 29m44s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m39s
Build Packages / Validation Battery (push) Successful in 41m34s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m28s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m17s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / Build documentation (push) Successful in 1m7s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 23m45s
Build Packages / Unit tests (push) Successful in 1h35m16s
13da79b788
Bitwise. One warp per atom, each lane taking the box's points t = lane, lane + 32, ...: every point cost three
integer divisions/modulos to turn t into (u, v, w) and three more (imod) to wrap them into the grid. The lane
now steps (iu, iv, iw) on by 32 points with adds and carries, and wraps from the box's first point, wrapped once
per atom (a box is narrower than the cell, so one conditional subtraction per axis). The same points in the same
order per lane, the same arithmetic.

atom_gradient_kernel (nsys, side by side): 5reo 426 -> 237 us, 5f6m 887 -> 523, 9yl4 1436 -> 802, 7d98 692 ->
388 us per launch. Refined CIF md5 unchanged on the 5 GPU cases; side by side = one at a time. Wall: 5reo
1.43 -> 1.35, 5f6m 2.20 -> 2.00, 9yl4 6.88 -> 6.82, 7d98 3.58 -> 3.38, 5f6m MD start 4.16 -> 3.71 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 6554e9dcbe4b448eda582eb76d6a24c59fe6ab0c)
Each still predicted through |dist_ewald| <= 2x its own profile radius (the RMS excitation error of its
indexed spots): one width at every resolution, differing eightfold between frames. Against the run's
measured rocking width that was 5-8 sigma at low resolution on one SACLA run (59 % of the 40-8 A predictions
empty) and below one sigma at high resolution on a crowded LCLS run (half of the curve never integrated).

Now the stills image pass first integrates a sample (a tenth of the run, at least 2000 frames) the old way,
scales it against its own merge and measures the run's rocking curve on it - centre (MeasureRadialScale) and
width (MeasureRunWidth), as the merge measures them - then predicts every frame through 2 sigma(d) of that
curve, centred on the reciprocal lattice scaled by the radial scale (BraggPredictionSettings::window_*, CPU
and CUDA). The post-refinement uses the same curve instead of measuring it again: re-measured through
windows that follow it, the constant term falls to ~0 and the result swung by up to +-0.015 per run.

Still battery, 12 non-float sets, against the st-partial baseline (0-cycle REFMAC R-free vs deposited):
9 better, 3 worse, sum of changes -0.013; the off-centre run -0.012, worst +0.008 on one JUNGFRAU run (its
sample measures a different width form than the full run). Integrated reflections 0.8-1.7x, battery wall
+13 % on a loaded box. Rotation untouched (window fields zero unless set); p.hkl byte-identical on three
rotation sets.

Tests: BraggPrediction_WindowFollowsResolution, BraggPrediction_CPU_GPU_consistency_window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 1b28d671d780bc88525ceac5120017963d937bbc)
MeasureRunWidth measured the width in eight shells of equal count in d*^2 and fitted the three-term
formula gamma0^2 + (gamma_e d*)^2 + (bw |q_z|)^2 to them. On runs predicting ~1000 reflections a frame
the equal-count shells all sat at 3.8-1.8 A, so the low-resolution width was the formula's
extrapolation, and the fit's form flipped with the number of frames (to a pure bandwidth at 50k frames).

The same window-group moment is now also measured in eight shells of equal width in d*, made
non-decreasing (pool adjacent violators, weighted by each shell's summed signal) and kept in RunWidth as
a table that the partiality interpolates linearly in d*. The formula is still fitted on the equal-count
shells and still sets the prediction window. With fewer than three measurable shells the formula is
used as before.

Still battery, 12 non-float sets, together with the sigma-following prediction window (the previous
commit; run 20261026-1021_1b28d6_still-part-comb against rc180 20261026-0857_3882b0_still-part-base1,
0-cycle REFMAC R-free vs deposited, both --no-refine): 6 better, 3 unchanged, 3 worse (by 0.002), sum of
changes -0.027 (the window alone: -0.007); largest gains pal90 -0.014, alvra103_int16 -0.011, lcls222
-0.005; largest losses euxfel152 +0.005, alvra213_int16 +0.005 (+0.010 with the window alone), euxfel83
+0.003. Win/draw/loss against the depositions unchanged at 9/2/1. Rotation: p.hkl byte-identical on
5reo, lyso_x06da_ref and thau_x10sa_16keV.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit de779bb10b1813eeae625e3994b1ca73d6079d7a)
The generated spot-and-ring drawing is replaced by one 0.2 deg rotation frame of the in-house
lysozyme standard (EIGER2 16M, SLS X10SA): Bragg spots on their lunes, the beam stop and its arm,
the module gaps. docs/landing/hero_frame.py makes the 920 px WebP (67 KB) from the master
file - crop about the beam centre, log tone map from the frame's own background, block mean+max
downsampling - and is run by hand; the WebP is committed and copied by the landing-page step.
The dark theme shows it inverted through a CSS filter.

The hero's command line drops its explanatory sentence and gains the second form,
rugnux <diffraction image file> <CIF/PDB model file>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit ff26000bfe7446cb4fda739c3b72596f8a8b7047)
- New docs/CPU_DATA_ANALYSIS_REFINEMENT.md (§15): the ML amplitude target and its FFT gradient,
  scale/robust fit/fine solvent mask, D and the variance (work/free), the embedded Monomer Library
  subset and topology, geometry/metal/KL-B restraints, the minimiser, the weight trials, refined maps,
  blobs, GPU/CPU, and a "Not yet" list; with its own References. Linked from TECHNICAL.md and
  CPU_DATA_ANALYSIS.md; §14 no longer says the structure is never refined.
- RUGNUX_REPORT.md: a Refinement (REFINE_*) section - every REFINE_ and REFINE_SCORE_ key, the
  --mode refine input keys and the _blobs.json fields.
- RUGNUX_MODEL_AND_REFERENCE.md: "What it does not do yet"; RUGNUX_ADVANCED.md: --model mentions the
  refinement, --no-refine as in the usage, a --mode refine options table; GPU_DATA_ANALYSIS.md: the
  refinement's CUDA engines and the missing Metal path.
- ACKNOWLEDGEMENT.md: Brunger, Karplus & Petsko 1989 (gradient-balanced starting weight), Nocedal 1980
  (two-loop recursion), Charbonnier et al. 1997 (pseudo-Huber), Jiang & Brunger 1994 (flat solvent
  mask), Kullback & Leibler 1951; all DOIs checked against Crossref.
- In-source credit lines: the weight balancing (replacing a "Brunger (1992)-style" line that named the
  R-free paper), Nocedal 1980, Charbonnier 1997, Jiang & Brunger 1994, Emsley et al. 2010 at
  FindDensityBlobs, Read 1986 at the refined maps. Comments only.
- docs/THIRD_PARTY_NOTICES.md regenerated from the root file with update_version.sh's own step (it
  lacked the CCP4 Monomer Library row).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 09375dba351bae0cc73e9a6f0f79455dc92b3a10)
GPU_DATA_ANALYSIS.md: the refinement section at the level of the other engines - density gather,
cuFFT r2c/c2r, compose + likelihood target, CSR adjoint, warp-per-atom gradient, restraints and
slot gather, fine mask and robust scale fit (on-device median), reductions and determinism,
stream priority and blocking-sync waits, trial slots over cards, DeviceBytes budget, CPU fallback,
no Metal; indicative RTX 5080 timings.

Pipeline figures: a dashed Refinement box after Structure & map on both, the model branch labelled
as deciding nothing; the stills row gains the indexing-ambiguity step before the first merge.
RUGNUX_OVERVIEW.md: captions, a refinement paragraph, refined outputs under Write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 8645fb2aca1abbbddec29cdfa99056471f8f38a5)
Two mechanisms, as the code has them: with -S and no reference or model, the crystals made
consistent from the data (Brehm & Diederichs objective, cell-metric anchors); with -z or --model
(-C/-S), per image against the reference, crystals their own cell decides keeping that indexing,
the data-only result as a check. Removes the statements that the ambiguity needs a reference or
model (tutorial, quick start, overview, §10.9), adds the anchoring to the reference/model rows,
the "no -S" case (mixed merge, can pass for higher symmetry, warning), and the report keys in the
reference case (AMBIGUITY_DATA_DISAGREES in place of AMBIGUITY_REINDEXED, AMBIGUITY_TIME_S,
none in --mode scale).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 8db1d3ecb97dba90471c4db238aff632d11bc3e8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
CI: the documentation also pushed to the gitea-pages branch of rugnux/gitea-pages, on every push for now (token test)
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m20s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m28s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 22m55s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 24m56s
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 17m28s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 21m52s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m37s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m1s
Build Packages / Validation Battery (push) Successful in 38m39s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 26m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 28m19s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m33s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 28m7s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 30m2s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m18s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 28m29s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m41s
Build Packages / Generate python client (push) Successful in 31s
Build Packages / Build documentation (push) Successful in 45s
Build Packages / Unit tests (push) Successful in 1h31m22s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m57s
d90bc68085
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The embedded CCP4 Monomer Library subset grows from 62 to 84 compounds:
- the modified tRNA/rRNA nucleotides PSU, 5MC, 1MA, OMG, OMC, OMU, H2U, M2G, 7MG, 2MG, 5MU (RNA group,
  joined by the library's backbone link "p");
- the cofactors NAD, NAI, NAP, NDP, FAD, FMN, HEM, HEC, SAM, SAH, and dioxygen (OXY). CMO is left out:
  its dictionary has C-O at 1.25 A, where bound CO is near 1.15 A.
links_and_mods.cif gains the links these compounds use: HEC-CYS1/HEC-CYS2 (heme C thioethers),
CYS-HEMB/CYS-HEMC, CYS-FAD and HIS-FAD1/2, with their modifications.

AddMetalLinks now takes a metal atom of any residue, not only a one-atom ion, so a heme iron's LINK records
to His NE2 / Met SD / water become bonds at the library's metal-site distances; the site's coordination counts
the metal's bonds in its own dictionary (a heme's four pyrrole N) as well as the records. A metal within a
ligand is also a metal for the repulsions (1.9 A to N, O, S) and its dictionary bonds are kept with the metal
bonds. Note HEC.cif's own Fe-N(pyrrole) ideal is 1.90 A (HEM: 2.04 A), so a cytochrome c reports a metal-bond
rmsd near 0.1 A.

Test RefinementRestraints_deposited_ligands builds the topology of eleven deposited models cut down to the
ligand and its surroundings (tests/test_data/ligands, 66 KB, from PDB 1EHZ, 8BKH, 6K9I, 2W3V, 5VML, 6F3P,
8CON, 3TK0, 7TCD, 6W4H, 5EPE): every ligand restrained, bond rmsd < 0.03 A, the RNA backbone bonded to each
modified nucleotide, the heme Fe-His bonds, and the HEC thioethers. rugnux --mode refine on the full entries
against their deposited structure factors: R-free within +-0.003 of the build where these were held fixed,
ligand bond rmsd vs the dictionary from 0.004-0.058 A down to 0.002-0.042 A. rugnux grows by 425 KiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit f4d407c8de6cb462320edb21a70665350e436d28)
--mode scale re-measured the rocking width on the stored reflections, so on any file whose frames were
predicted through the run's curve it merged with a different curve than the mx run did. The curve the
post-refinement used (formula, table, radial scale) is now carried in the EndMessage (StillsRunWidth) and
written to the master: /entry/MX/stillsRockingWidthFormula [gamma0 1/A, gamma_e rad, bandwidth sigma],
/entry/MX/stillsRadialScale, /entry/MX/stillsRockingWidthTableDstar and /entry/MX/stillsRockingWidthTable
(1/A). The reader returns it, and --mode scale passes it to the post-refinement. A file without it is
re-measured as before.

MeasureRunWidth grouped each frame's reflections by the frame's window, taken as the largest excitation
error it predicted. With a window that follows sigma(d*) that is the high-resolution window, applied to the
frame's low-resolution reflections too. Given the curve the frames were predicted through, the window is now
that curve at the reflection's own resolution; without one, the frame's largest excitation error as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 992343d0206489991880bb927efc79131134a1d9)
StillsPartialityRefine_MeasureRunWidth_WindowAtReflectionResolution, JFJochReader_StillsRunWidth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit dafb8d1bf996d1000741eecf019aee780dd91ebe)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 1195b9be6e0ca66774774f6de4867180b7bd0a47)
Rotation runs now estimate the standard uncertainties of the cell. Every strong integrated
observation (I > 3 sigma, unclipped, unsaturated, off ice rings) gives |q| = 1/d at its observed
centroid, which depends on the detector geometry only; q^2 = h^T G* h is linear in the reciprocal
metric, so the components of G* the point group leaves free (projected from the group's rotation
parts, any setting) are fitted by weighted linear least squares with 4-sigma rejection, and the
GoF-scaled covariance is carried analytically onto a, b, c, alpha, beta, gamma (ties give equal
e.s.d.s, fixed angles zero). Least-squares cell e.s.d.s are known to be too small (Herbstein 2000),
so the same fit is made on 8 blocks of consecutive frames and their scatter / sqrt(8) is the second
estimate; the written e.s.d. is the larger of the two per parameter. Stills get none.

Outputs: <prefix>.ins next to every .hkl (TITL, CELL at the wavelength, ZERR with Z = order of the
group, LATT/SYMM from the space group - positive LATT only with -1 at the origin - a placeholder
organic SFAC/UNIT at 18 A^3 per non-H atom, HKLF 4, END); the mmCIF gains _cell.*_esd ("." where
fixed) and _cell_measurement.reflns_used/theta_min/theta_max, and writes the cell to 4/3 decimals;
report keys CELL_ESD, CELL_ESD_LSQ, CELL_ESD_BLOCKS, CELL_ESD_BLOCK_COUNT, CELL_ESD_REFLECTIONS,
CELL_ESD_THETA_RANGE_DEG (+ dev CELL_ESD_OBSERVATIONS, CELL_DSPACING_FIT).

On 13 small-molecule sets the block scatter is 10-40x the least-squares e.s.d. and is what gets
written; it lands within ~0.3-5x of XDS's CORRECT.LP e.s.d.s. SHELXT solves from the generated
.ins and SHELXL carries ZERR into its CIF su. Costs 12 ms on a small molecule, 0.16 s on 690k
lysozyme observations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit e74a3be76261fe1b6c0ff7b80c21afc4768c46cd)
RUGNUX_ADVANCED: --simulate-live/--fast-live rows, -e exclusive, stills
behaviour of --beam-center-check, -S, --min-partiality (0.5),
--capture-uncertainty, --adaptive-integration-radius (on for stills too),
--detector-distance/--rot1/--rot2/--no-refine-tilt; --background-radial
auto as it decides now; --refine-modules syntax; _cell.*_esd.
MODEL_AND_REFERENCE: the default refinement of a placed model in the
summary, the decision table and the step list; --mode scale does not refine.
INTEGRATION: the .ins written beside every .hkl (ZERR on rotation).
VIEWER: rugnux writes _process.h5 only with --no-merge/--write-process-h5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 51d2564356ba46bda8730c855ff8d3ee7c23d605)
RUGNUX: a model run also refines the model it placed; the model can be
given as an input; only the unmerged MTZ is written by default; the
_process.h5 is not a default output; a short "when a run fails" note.
TUTORIAL: the .ins, the refined model/maps/blobs, the log and _modules.h5
in the output list; the report is not written with -o ""; stills: -X Auto
picks ffbidx with a cell, a run over several files.
FORMATS: the XFEL stills formats and model/reference inputs told by content.
CALIBRATION: -C and -S as the calibrant. INSTALL: the CPU path runs every
indexer and the refinement. MODEL_AND_REFERENCE: the refined model is
written as .pdb too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 0062d72b95e996438ddc65dc343b526c84daa7fb)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit f8d07397288137dd5e3a0e5c101f2036d51228bd)
RUGNUX_REPORT: a table of the headline keys (checked against
ResultReport.cpp); section 5 appears for --model, --mr or a model among
the inputs and holds the MR and REFINE_ keys; a --mode refine report has
one section; stills ambiguity resolved by the crystals' own cells first;
the developer-only CELL_ESD_OBSERVATIONS / CELL_DSPACING_FIT; the
"nothing here is refined" paragraph points at REFINE_*.
BENCHMARKING: the still battery, the refined R-free beside the placement
one, the Refinement and Doubled-cell steps in the timing table.
EXTERNAL_TEST_DATA_STILLS: the gain is measured, not passed; how the
battery runs these sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit e9f5e3b63fb6208d6bd5984c1ecd0eb0da7c29ab)
INTEGRATION: the stills prediction window and partiality from the run's
measured rocking curve (a d* table, kept in _process.h5 for --mode scale),
the model-error term in the per-crystal fits, --min-partiality defaults,
the adaptive radius on stills (background-ring reach).
IMAGE: reflections reaching into the beam-stop shadow are not integrated;
no-data pixels masked as dead, not as shadow.
INDEXING: the rotation-scale walk's refusal and refit; the stills geometry
refinement on the given cell's crystal form only.
DECISIONS: the model is refined after validation by default.
CPU_DATA_ANALYSIS: titles in the references that lacked them.
GPU: the still prediction kernel follows the rocking-curve window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 5ae198b566b17de259fd0f0d6c8a0f8ea18eaa05)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 795c98c9d6b5db0cde4d6e0b8c7b0dad9c99775f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Live refinement also runs on a broker without a GPU, on the CPU and slower, and the run's deadline
or cancel can cut it. Description only; regenerated broker/gen (Rugnux_live_refinement.h), the
frontend client (types.gen.ts, zod.gen.ts), broker/redoc-static.html and the python client doc
page - each differs from before only in this description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 53d06c6e221fa1cf776822965560fb990cc69c73)
Landing page (title, hero, lede, Rugnux card), the documentation home's Rugnux card and the opening
of RUGNUX.md now say what the program does: from diffraction images to merged data and, with a
model, on to a refined structure and maps.

RUGNUX.md Quick start is rebuilt around how a run is started: rotation (master file, or the first
frame of a CBF/marCCD/SMV sweep), serial stills (several masters or all of a dataset's files by
glob), serial stills with -C/-S and --crystfel-geometry (wavelength/distance flags only where the
files state neither), and with a model (positional or --model): placement by rigid body or MR,
refinement by default (--no-refine), maps and blobs, and that the model changes no processing
decision except the indexing choice on serial stills with -C/-S. It says plainly that the defaults
are meant to be the answer, and lists the output files with and without a model. The tutorial's
stills section drops -X ffbidx, -z and the resolution limit from its first example and shows the
same three stills commands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit bfca846332037688364b9d2d1c4250315f09410d)
M_PI is not defined by MSVC without _USE_MATH_DEFINES, and CellEsd.cpp is reachable from the
viewer build. The citation, so far only in CellEsd.h, now also sits at the algorithm it credits.
The other files new in rc180 under common/, image_analysis/, reader/, writer/, rugnux/ and
viewer/ use no M_* constants or POSIX-only calls (grep of the rc180 diff).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit a019bc5fd2084907af9379068af3265ffe6c9ef5)
The monoclinic cell of the .ins/mmCIF and fit tests is now 10/7/12, beta 100, and the
orthorhombic, hexagonal and tetragonal cells of the sampling tests are rounded synthetic ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 6f964bc0de0ca842f374e6aed83c8848e410173b)
A slot builder thread that throws (gemmi's topology or hydrogen placement, bad_alloc) would
call std::terminate - rugnux or the broker gone. The slot is now left out as one the card
cannot take is, with a warning, and MakeOwnSlot's GPU engine catches std::exception, not only
JFJochException. Both callers of RefineModel (after the merge, and --mode refine) catch
std::exception: the refinement is reported NOT_PERFORMED with "refinement failed: <what>" and
the run writes everything else (cell e.s.d.s, unmerged MTZ, report), as the refinement is
processing-inert.

Test: --mode refine on a cell too large for the bulk-solvent grid comes back with
REFINE= NOT_PERFORMED and the reason, not an exception.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit dc4e99fe4b3da2a9a3fa4290b5f70bfcc8c7bdb8)
tf_z, rf_z, mr_d_min, the placed model's R-factors, CC, map and anomalous sigmas, the rigid
body's move, the refinement's R-factors and rmsds, and the headline d_min are set only where
finite (llg_z and null_sigma already were). nlohmann writes NaN/inf as null, which the page's
"!== undefined" checks let through to .toFixed(), and which the zod schema rejects for the
required blob distance - a blob with no atom within 12 A has an infinite one. That distance
is now optional (jfjoch_api.yaml; C++ model and frontend client regenerated), absent then.
The model cards test for != null, so a null that arrives anyway does not take the page down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 62ff026aa507e5bda1daa5aa079f32fb5c21f16e)
GPU: a slot was packed onto a card by its engine's bytes alone. The per-slot estimate now adds
the slot's ModelScaleGPUPool with the robust fit's buffers (ModelScaleGPUPool::DeviceBytes(n, 1,
true)), and RefinementGPUEngine::DeviceBytes counts the two restraint sets SetRestraints() holds,
not one. The 80%-of-card budget is unchanged, so where these do not fit fewer slots are packed
instead of an allocation failing inside a running trial.

CPU: OwnSlotDevices made a slot per wanted trial whatever the thread count, so -N 1 ran six trial
threads with six models, topologies and engines in memory. The main slot and those beside it are
now at most -N. Side by side equals sequential, so no result changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 3c04c01158266c267f8e5ffaaaa98d6d8fd2a747)
The environment variable overrode the current device that ScopedGpuPin sets, so a stray value
moved an indexing thread to another card. Recorded in the file header as a local modification,
together with the deterministic tie-break of c982ba4f6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 89acdeef939ee82cf15ee9c5fb52a016fb1441f8)
Contacts() dropped every pair found in image 0 with a nonzero lattice shift ("the identity term
covers the contact"), but that pair is a contact with a translated copy of j, not with j itself:
in P1 no intermolecular repulsion or B-contact restraint was made at all, and in every group the
contacts along a, b and c were missing (MeasureGeometry's clash count with them). Such a pair is
now kept like a symmetry mate's, from the lower atom of the two.

An atom against its own image x -> T x is also met through T^-1 at the same distance (another
operator, or the same one at another lattice shift for a screw), and both were kept: atoms near
a 3-, 4- or 6-fold or a screw axis were repelled twice. For i == j the image first in fractional
coordinates of T x and T^-1 x is kept; an involution is kept once as before.

Test: in P1 two waters 2.4 A apart across a cell face get one repulsion and one B contact (none
before); a water 1.4 A from a 3-fold axis gets one repulsion with its images (two before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 0d6e4d0491e655a49d59b709e1398843506d162a)
The held test read the X-ray gradient alone. At the floor, an atom whose X-ray term pushed
outward but whose restraints pulled it back in more strongly was held fixed against the total
target, and one whose restraints pushed it out was handed a gradient along the direction Set()
clamps away - a flat direction for L-BFGS, failed line searches and s.y <= 0 pairs.

On the 14 parity sets (--mode refine, GPU) the refined models are md5-identical; on the four
5f6m MD starts R-free 0.1707/0.1676/0.1720/0.1669 -> 0.1707/0.1690/0.1719/0.1668 (median
0.1692 -> 0.1699).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 2f4b0b5ea54fdac54de7ee6c5a1476d2fc019fd8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 339c6cbaa4177964e5eb06d0878efb83d1da2dbc)
broker: redoc regenerated from the merged API (blob distance optional, live refinement on the CPU too)
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m34s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m44s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 29m21s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m8s
Build Packages / Validation Battery (push) Successful in 43m17s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m6s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m40s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 25m17s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 23m13s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m53s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 23m56s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m42s
Build Packages / Unit tests (push) Successful in 2h17m52s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m28s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m28s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m9s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 30m44s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 27m13s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 29m40s
9830fe9dd0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Rugnux measured the cell's e.s.d.s (CellEsd) but they only reached the mmCIF's _cell.*_esd, the .ins's
ZERR and the report. They now travel in the END message (EndMessage::unit_cell_esd, CBOR key
unit_cell_esd, same layout as unit_cell), are written beside the cell in NXsample as unit_cell_esd
(float[6]: a b c alpha beta gamma, A and degrees) and are read back into
JFJochReaderDataset::unit_cell_esd. rugnux's _process.h5 and, through AddToEndMessage, the broker's
live master file get them. Rotation only; absent on stills and where nothing was measured.
Optional CBOR field, no version bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 6902b5637aa92c27565f22bcf3fe11b5d2536038)
In broker live mode the refined model, its map coefficients and the difference-map blobs stayed in
the broker for the viewer routes and were lost at the next run. They now go to the writer in the END
message as the placed model and maps already do - EndMessage::refined_model (PlacedModelMessage),
refined_map_coefficients (MtzTable) and blobs (DensityBlobsMessage, one vector per field) - and the
writer writes <prefix>_refined_model.cif (+ .pdb), <prefix>_refined_maps.mtz and <prefix>_blobs.json,
the names the CLI gives them. The blobs' JSON is made by one function (DensityBlobsJson) that the CLI
now writes through as well, so the two files are byte-identical. Optional CBOR fields, no version bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit c65220d96a96d2b1897f3c6632c94f9379b670a3)
Docs landing page: the hero is a live Rugnux run - a frame, the placed model in its density, the timeline
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m15s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m25s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m16s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 25m12s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m45s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 17m22s
Build Packages / Build documentation (push) Successful in 50s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 30m21s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m32s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 33m1s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m34s
Build Packages / Validation Battery (push) Successful in 46m42s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m52s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 25m48s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 16m48s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 28m12s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 29m5s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 25m45s
Build Packages / Unit tests (push) Successful in 2h17m1s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m31s
2736f4c85e
The front page's hero image is now the cytochrome C collection at SLS X10SA as the broker's Rugnux
live page ran it: a detector frame on the page's indigo beside the AlphaFold model placed in its
electron density, over the two-lane timeline of the live analysis (the 18 s collection, the image
processing alongside it, scaling, merging and molecular replacement 4.9 s after the last image).
One WebP per theme (hero_live_light.webp, hero_live_dark.webp), shown by the same rules that pick
the theme's colour tokens, so the toggle and the system preference both switch it; the hero's art
column widens to 560 px on desktop (360 px on a phone). The lysozyme frame and its generator go;
the Sphinx extension copies the two new files into the site. Made by docs/landing/hero_live/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 5f2592390d9ec39428198df8cd8e18ad60167a79)
docs landing: Rugnux card names synchrotron rotation and XFEL serial data and links the public test-data lists; footer motto removed
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m52s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 22m24s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m30s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 15m11s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 19m48s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m0s
Build Packages / Generate python client (push) Successful in 30s
Build Packages / Build documentation (push) Successful in 1m32s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 29m40s
Build Packages / Validation Battery (push) Successful in 45m14s
Build Packages / Create release (push) Successful in 10s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m7s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 14m10s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m46s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 30m20s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 23m52s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m50s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m15s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 18m58s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 21m59s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 23m11s
Build Packages / Unit tests (push) Successful in 1h31m24s
067f3a7d7b
The Rugnux card says in passing that it takes a synchrotron rotation sweep or XFEL serial
stills, and ends with one sentence pointing at the two pages that list the public datasets it
is validated on (External test data: over 200 rotation sets, 207 in the open battery manifest;
External test data: serial crystallography: eleven CXIDB entries). The "two mountains, two
railways" line at the foot of the page is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
(cherry picked from commit 03e4431836a2f4ba816e6d3cdd57cc6ada87b95d)
The cell e.s.d.s and the unmerged MTZ read only the integrated observations and the group, both final once
the model has been read, and the refinement reads neither: both now start before the refinement instead of
after it. In MakeMaps the difference map is made on its own thread while the anomalous map is made.

Outputs identical (MTZ data, CIF/HKL/INS, refined model, maps, blobs, report minus provenance) on the
thaumatin and cytochrome C live runs. Live path without the unmerged MTZ (as the broker runs it), step
Refinement: thaumatin 2.76 -> 2.40 s, cytochrome C 1.99 -> 1.93 s. With the unmerged MTZ (CLI), Refinement +
Writing files: 3.21 -> 2.67 s and 3.33 -> 2.80 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
AnalyzeCentreOfSymmetry ran on one thread between the adopted group's merge and the analyses:
its orbit fold and L-test partner lookup were std::map (now hashed - the orbits are sorted by a
total order right after, and the lookups only test membership), and its 20 simulated
populations took their log/cos per reflection serially. The generator's draws are now taken
in the same order on one thread and turned into intensities on all of them (the Box-Muller
operands in the order GCC evaluated them; bit-exact against the old function on five groups at
1/4/32 threads).

AnalyzeAnisotropy's three BuildCells and the observation cells of the systematic floor find each
copy's nearest of 60 directions on all threads and fill the cells in the old order; the
observation sort is a ParallelSort (index-unique pairs, a total order). AnisotropyRunInfo
carries the worker count.

Live path, workstation, 3 A/B pairs, outputs identical (mtz/hkl/cif/model/maps/report):
thau centre stats 0.18 -> 0.06 s, analyses 0.57 -> 0.43 s; cytc 0.04 -> 0.016 s,
0.37 -> 0.32 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
AnisotropyPriorHKL is the same tensor, bit for bit, as AnisotropyTensorHKL(AnalyzeAnisotropy(...)),
so the amplitudes no longer wait for the unmerged-observation part of the analysis (ScaledObservations):
the model work starts at once and the analysis, its text and the merge statistics are taken before
the rigid-body validation or after molecular replacement. The analysis takes its own copy of the
space group, which the enantiomorph relabelling changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The trajectory ends where a step gains nothing, and that step ran from the best state at the weights the final
ladder then tries from the same state: on a positional step every rung, on a B step the middle one. A trial of
the same weights from the same model is the same computation (RunTrials: the outcome is the same on any slot),
so the final takes those outcomes from the step and runs only the rungs it has not got. The maps' Refit() then
starts from the fit the main slot would hold had it run its rung (Context::Restore of that rung's fit) - the
solvent grid and the likelihood's D it starts from otherwise depend on which trial the main slot ran last, and
that showed as last-bit differences in the map coefficients.

RunTrials gives the trials that refine B, the longest, to the slots first: on a machine whose cards hold a slot
each the ladder's two B rungs no longer share one card while a positional rung has one of its own.

Byte-identical (p.mtz, p.hkl, p.cif, p_model, p_refined_model, p_refined_maps.mtz, p_blobs.json, report minus
timing) on three rotation sets run live-path, and on two of them in --mode refine. Refinement wall on one
RTX 5080: 1.86 -> 1.71 s, 2.42 -> 2.17 s, 50.0 -> 40.8 s (one stage of the trajectory each).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The device ingest staged every reflection to the card twice - once for what the sort and the
cut read, once again for the observations the cut kept - and the second staging was a full
copy of the reflections into pinned memory and over the bus. Where the whole set of reflections
and the ingest's own peak beside it fit a quarter of the card (decided on the card's total
memory, so the answer depends on the card and not on the moment), the first upload now goes
into one resident copy, and the observations are taken from it in a single extract; the second
staging stays as the fallback where the copy does not fit, and where the card reads host
memory in place. The extract kernel also hands over the predicted positions, which the host
had gathered itself from a downloaded index array, and the two pinned staging buffers are kept
across the ingest instead of being allocated per staging. Same observations, same order, same
bits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The first arm's space-group search now runs beside the wait for the all-observation merge and
the search started on it, instead of after both. Classifying a reflection for a candidate group
asks gemmi twice which operators fix it (once for the absence, once for the epsilon factor);
one pass over the operators answers both, with the absence read off a fixing operator's own
translation, which is what gemmi's test reduces to once the centring has been checked. The
delta-CC1/2 frame disposition builds its per-reflection totals on all threads, each taking a
range of reflections and walking every observation so the sums keep their order, and measures
up to sixteen candidate chains side by side with a worker's scratch made on first use. Same
numbers, same bits; only the waiting changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Before the merge, the integrated reflections were walked twice - once per frame for what the
blank sweep ends are decided on, once more for the overload counts and the ice flags - and
each walk reads the whole reflection set from memory. The frame's sweep-end tally is now a
function of its own (TallySweepEnd), and the merge step makes it in the same per-frame walk
that counts the overloads and flags the ice, then adds the counts up over the frames still in,
so the trimmed and left-out frames count nothing, as before. Per frame the sums are the same
sums in the same order; the totals are integers. TrimBlankSweepEnds keeps its one-argument
form for the callers that walk the frames for nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
exp(2 pi i h.t) depends only on the integer h.t (in units of 1/den), so the engine tabulates it once over the
range the rows and operators make, with the same op_phase() at the same double argument - the same bits. The
compose kernel reads the table at h.t; the scatter terms carry their table entry, and their row directly, so
neither hot kernel divides: the rotations go to the device in whole units (gemmi's are den times an integer
matrix), the term's row no longer has to be split from its operator. The table is a few thousand entries, the
scatter terms one int wider.

Outputs are byte-identical; the X-ray evaluation counter drops 7-10% on the demo-sized sets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
A Levenberg-Marquardt step is a few short launches the host then reads the sums of, a hundred or so steps per
fit, and the process has every synchronisation sleep until the device raises the wake-up
(set_gpu_blocking_sync): for these launches the wake-up costs more than the work. The fit's waits poll the
stream instead. Outputs are byte-identical; the refinement's scale refit counter drops 20-27% and the
molecular replacement's scale fits gain a little on the demo-sized sets.

Summing the block partials on the host instead of in a kernel was tried alongside and dropped: the coarse
solvent grid of 88 fits made it a megabyte of read-back per step, slower than the kernel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The staged record of an atom is now its isotropic Gaussians, occupancy and radius (an anisotropic atom's
matrices are read from global memory on that rarer path) and its box as bit masks over the brick's eight
points along each axis, with the base and wrap that turn a point into gemmi's unwrapped box index. The
inner loop tests a point with three shifts instead of three box offsets and six shared reads, and the shared
record is a quarter of its size, so a fourth block fits on a multiprocessor. The density arithmetic, the order
over the atoms and the summation are unchanged, so the grid is bit for bit the CPU walk's as before.

Outputs are byte-identical; refinement and molecular replacement each gain a few percent on the demo-sized sets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The rugnux thread put its result in place only after destroying the run and releasing the pinned host
memory the engines kept between them, so the end message and the writer waited for that teardown.
The result is now moved out first and a promise tells the measurement thread it can send the end
message; the run is destroyed and the pinned pool released on the rugnux thread (the same thread, so
the same CUDA device as before) beside it, and the measurement thread joins the rugnux thread at the
end of the collection, before it reports itself finished. The end message reads only rugnux_result,
a value moved out of the run (it already outlived the run), and nothing in the teardown touches it.
A run that fails still releases the waiting measurement thread.

Measured on the workstation (CLI harness doing the broker's teardown after Run): the run's destruction
0.03-0.04 s and the pinned pool release (440-540 MB) 0.08-0.10 s, so ~0.12 s off last image -> end
message on a cytochrome-C-size rotation set. Processing and outputs are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The search (or the carry of the early search) reads of the merge only its French-Wilson amplitudes, which the
analyses make last from a tensor fitted to the merge alone, so - as the first validation of an experimental model
already does - it starts before the analyses on a copy given the same amplitudes, where no twin hypothesis' merge
is searched beside it and no reference supplies the free set. The pass's integrated observations
(ReflectionArena mappings, gigabytes) are freed on a thread of their own as the pass returns, joined when the run
goes, instead of before the outputs are written and before the broker publishes the result. Output unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The GPU engine's waits on its stream (every evaluation, the refits' transforms) and the model density's wait for
the (brick, atom) pair count were blocking event and stream synchronisations; where a weight trial has a card to
itself, as on a box with a card per trial, the wake-up of the sleeping thread is on the critical path of every
evaluation. Both now poll, and the pair count and overflow flag come back into pinned memory instead of a
pageable int (a pageable copy back is staged and blocks the host for the whole stream).

OwnSlotDevices places the slots beyond one per card on the cards the ladder's long trials (those refining B)
leave free, so that on four cards the three B rungs have a card each and the three B-held rungs share the fourth,
instead of two cards each carrying a B rung beside a B-held one. The outcome does not depend on the placement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Two byte-identical cuts to the per-evaluation host chain, which on a large model is twice the GPU's
work per evaluation (34 ms against 16 ms of kernels per weight-trial slot, measured in nsys) and so bounds
the refinement on one card with three slots, and the whole of it on a card with one slot.

- The gather's per-atom density records (38 MB on a 242k-atom model, sent on every B-factor evaluation)
  live in a PinnedVector and go up by DMA; the boxes go through a pinned staging buffer of the density
  object. ModelDensityGPU::SetAtoms and RefinementGPUEngine::SetDensities take a pointer and a count.
- RefinementMinimiser::Direction takes each pair's dot product in the same sweep that applies the previous
  pair's update to q: the dot products are serial chains of n additions, and the update's memory traffic
  now hides under them (20 -> 13 ms per call at 365k parameters). The same operations per element in the
  same order; the loop-2 coefficient stays written as (a[i] - b) with b on its own, because a[i] - rho * dot
  contracts into a fused multiply-add and changes the bits (checked bitwise in a standalone harness).
- A debug line with the GPU engine's bytes and the budget.

4nwv (open arm, 242k atoms), live flags, one lock hold, loaded box: refinement 42.35 -> 38.40 s;
refine.gpu.upload 8.14 -> 3.94 s. All outputs identical but provenance lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Byte-identical: every pass is element by element in the serial loop's order, or a copy that is no longer made.

- The engine's gradients are read in place from its pinned buffers and widened to double on the threads
  (RefinementGPUEngine::HostGradient/HostRestraintGradient); the positions go up from a pinned vector of the
  engine's host side; the model's atom pointers are kept between calls (the model is the context's own).
- Context: the riders moved and folded on the threads - a parent takes its riders in index order through a
  CSR list (SetRiderParents), as the serial walk added them.
- AtomTarget: the parameters set and the gradient assembled on the threads; the per-atom gradient vectors kept
  between evaluations (7 MB each on a large model: made anew, their pages fault in every time).
- RefinementMinimiser: the sweeps over the parameters (x + alpha d, s, y) on the threads it is given, the dot
  products the serial chains they were; Direction()'s working vector and a let-go pair's vectors reused.

A/B against the frozen combined2 binary, one lock hold each, outputs identical but provenance lines:
cytc refinement 1.600 -> 1.548 s (live), 1.724 -> 1.668 (CLI); thau 1.906 -> 1.885, 2.031 -> 2.027;
4nwv (with the commit before) 39.9 -> 35.5 s, the ladder 14.6 -> 12.7, each step 8.3 -> 7.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Error model: ISa is reported only where b is the solution of a fit - not beside a clamped a, not when b was held at zero, and on stills under the same rule
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m0s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 23m56s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 24m2s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 28m17s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 30m42s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 27m8s
Build Packages / Unit tests (push) Successful in 1h34m39s
Build Packages / Validation Battery (push) Successful in 36m53s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m22s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m14s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m22s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 22m53s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 29m22s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 25m23s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m8s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 19m21s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 28m14s
Build Packages / Build documentation (push) Successful in 1m4s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m50s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m13s
037c32d9fb
The reported ISa is 1/b of the merge error model var = a*s2 + b^2*<I>^2. Three outputs
printed a number that was not the result of any fit:

* A bin whose median normalised deviation lies far below the others' - observations that
  repeat their symmetry mates to the bit, as an input read twice gives - takes the whole fit
  through its 1/bd2^2 relative weight and pulls both terms towards zero. a stops on its 0.25
  bound, b^2 does not, and the remainder's 1/b reads as an I/sigma in the hundreds to
  millions; the standard-error test let it through on about half of the synthetic pools.
  Beside a clamped a the b^2 is not the solution of any fit, so it is now left unresolved.
* When b had no leverage and was held at zero, b_resolved kept its default and the report
  printed ISA 0.00 where the log said ISa was not reported. Unresolved now.
* The stills fit (MergeOnTheFly::RefineErrorModel) had no resolution test at all, so any
  tiny positive b^2 went out as ISa; on pools with no systematic error one seed in twelve
  printed ISa ~1000. It now carries the same test and rugnux reports it the same way.
* The --report-resolution refit's ISa (REFRES_ISA) was written without the gate.

No a or b changes: the merge weights, outlier band and every value except the resolved flag
are untouched, so a merge whose a sits inside its bounds reports exactly what it did. The
flag also feeds the per-frame-scale arm choice (PartialScalingWins), which is the one
decision that can move - only on a merge whose a is on a bound.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The final merge in the adopted group was the last long pole of the scaling tail after the
space-group decision - on a four-card server about two of the step's four and a half seconds
on a 16M protein set - and it waited for a decision that, on nearly every run, lands on a group
the run could have named much earlier. The canonical pass now clones its engine after the
ingest (as the all-observation arm does) and merges ahead, beside the P1 merges, the search and
the decisions, in the group a search of the geometry pre-pass's merge predicts; and where the
first arm's search names another group, in that group too, on a second card. The prediction is
searched on a P1 merge of ALL the pre-pass's observations, made on a copy of its engine on the
spare card beside the second pass: the pre-pass's own merge is Lorentz-filtered, and a screw
axis is read on the weak reflections the filter discards - searched on that merge, a 4(1)
crystal was predicted as its symmorphic group every time. Each merge ahead is taken only where the decision lands on exactly its
group (Hall symbol) on the same ingest - every change of basis and re-ingest of the reflections
clears that - and otherwise dropped, left to end on its own off the critical path, so the outputs
are those of the run without it: no merge depends on the merges before it on an engine (RunArm restores the ingest's corr
and G = 1), the per-frame scales are written back from the copy and its partial-scaling choice
handed to the engine. The centre-of-symmetry statistics are made in the same thread, and the
pre-promotion tNCS/twinning analysis, which the final merge used to hide, starts on a copy of
the all-observation merge beside the searches when a merge ahead may be made, taken on the same
cell and centring.

Spare cards only, decided from the card count before anything starts: card 3 from four cards
up, then card 2 (the all-observation arm's, whose merge is done when the second is asked for);
on two or three cards the last card, beside the engine there, where one more engine fits its
total memory; and only where one more merge fits the host budget. On one card nothing is made
ahead, the pre-pass's search is not run, and the run is what it was, byte for byte and in time.
Same code live and in the CLI. The search's resolution cut on a P1 merge moves into SearchCut()
(a pure move) so the pre-pass's search is set up as the canonical one is.

Workstation (one card): outputs and timings identical to the base. Server (4x T4): see the
report of the A/B.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
Refinement: the gather's pairs built in fixed-shape buffers, and each evaluation replayed as a CUDA graph
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 19m8s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m31s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m47s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 23m42s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m45s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 27m49s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m1s
Build Packages / Validation Battery (push) Successful in 36m7s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m12s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m26s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m31s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 29m50s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / Build documentation (push) Successful in 1m0s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 29m59s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 24m14s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 23m7s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m40s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m32s
Build Packages / Unit tests (push) Successful in 1h33m58s
546566cff4
ModelDensityGPU::Compute() no longer reads the pair count back to the host between its launches: the atoms
are counted per brick with integer atomics, the counts scanned into the bricks' segments, the segments
filled through atomics and then each brick put into model order by rank (one block per brick), which leaves
the pairs exactly as the stable radix sort of the atom-major fill left them - the gather sums in the same
order, bit for bit. The pair bound of the atoms set is checked on the host at SetAtoms() instead of on the
device at every evaluation, and the buffers shrink from four to three ints per pair.

With nothing coming back mid-evaluation, RefinementGPUEngine records the X-ray evaluation and the restraint
evaluation each as a CUDA graph on its stream (cudaStreamBeginCapture, thread-local mode) and replays it,
re-recording only where the shape changes (the atom count, a gradient asked or not, a restraint set
re-uploaded). Two graphs, not one, so that the host still fills the restraints' positions while the X-ray
half runs, as before. Where a stream cannot be captured the launches are queued as they were.

Outputs byte-identical to 037c32d9f on the in-house cytochrome C live run (MTZ arrays, HKL, models,
blobs, report). Workstation (RTX 5080): refinement 1.64 -> 1.50 s on that run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
The logo is the viewer's and frontend's psi_01.png, linked to www.psi.ch in the header corner and
inverted for the dark theme as the viewer does it; the Sphinx extension copies it beside the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
RESULT-CHANGING: Refinement: atoms gridded out to a density of 1e-4 e/A^3, not 1e-5
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m53s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m6s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m23s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m26s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m23s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 23m49s
Build Packages / Build documentation (push) Successful in 1m3s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 25m44s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m5s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m54s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 27m45s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m50s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 31m52s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 24m1s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 29m14s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 23m18s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 28m48s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 29m34s
Build Packages / Validation Battery (push) Successful in 35m40s
Build Packages / Create release (push) Successful in 8s
Build Packages / Unit tests (push) Successful in 1h36m9s
7782e304ed
The refinement's density calculators (the CPU engine's, the GPU engine's and the map gradient's) cut every
atom's density at an absolute 1e-4 e/A^3 of its blurred density instead of gemmi's default 1e-5. The radius,
the gather's box and the gradient's box all follow from it, so the structure factors, the gradients and with
them the refined model change in the last digits. Molecular replacement's and the validation's own
calculators keep gemmi's default.

Where this sits against other programs' defaults, which are relative to the atom's own peak density: cctbx
resolves phenix.refine's wing_cutoff to 1e-3 of the peak below 5 A (1e-4 above), X-PLOR's ELIM 7 is e^-7 of
the peak and SFALL's VDWR 2.5 about 2e-3 of it. For carbon at B + blur of 20-120 A^2 an absolute 1e-4 is
6e-5 to 6e-4 of the peak - still stricter than any of them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu
A point query on a populated gemmi NeighborSearch that returns each neighbour's copy nearest the
point with its distance and the Cartesian image transform, nearest first. The nearest-image shift and
the Cartesian form of a symmetry image, which Contacts() computed inline, are now two small helpers
that both use, so the restraint path and the new query share one image computation. The solvent audit
needs it to measure the surroundings of a water or an ion outside the restraint machinery.

Contacts() is unchanged in what it computes: a refinement of 1HR2 with --mode refine gives the
same refined model file, byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The first part of the solvent project (P1): an audit only, no model change. For every water and
single-atom ion it measures the N and O neighbours (symmetry mates included) in the Cat_Wiz bands
1.9-2.3 / 2.3-2.6 / 2.6-3.2 A and the clash shells to 3.6 A, types each neighbour as donor, acceptor
or both from the dictionary's hb_type and as positive or negative from its residue's chemistry (Lys,
Arg, N-terminus; Asp, Glu, C-terminus, nucleotide phosphate O; His and Asn/Gln amide ends marked
flip-ambiguous), takes the bond-valence sum and normalised vector sum for Mg, Na, K and Ca (Brese &
O'Keeffe R0, b = 0.37), the nearest like and unlike charges, the ion pairs within 6 A, and the
2mFo-DFc, mFo-DFc and anomalous maps at the site.

Each species then gets a plain sum of simple terms: the first shell against its coordination number,
the neighbours' distances against its own, contacts it does not make (too close, Mg's 2.3-3.4 A
exclusion zone, donors at a cation, acceptors at a halide, like charges), the bond valence against
the charge, and where the maps are there the residual difference density and the anomalous peak.
Every neighbour's coordinate error (from B and d_min) widens its bands and its distance term, so
poor coordinates make the species' shells overlap instead of separating them wrongly. A site bound to
another cation leaves out that cation's other ligands, which are no hydrogen bonds of its own (the
cis waters of a Mg(H2O)6). The best of water, Mg, Na, K, Ca and Cl is reported, AMBIGUOUS within 2;
NH4+ is scored but only reported.

Unit tests on synthetic geometry: an octahedral Mg(H2O)6, a K+ in eight carbonyl O at 2.8 A, a Cl-
with four N-H donors at 3.2 A, a tetrahedral water, the typing, the symmetry-mate and lattice
neighbours of AtomsNear, and the bands blurring with coordinate error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
RefineModel runs AnalyseSolventSites on the refined model (and with --refine-score-only on the model
as given) after the blob search, in the pipeline after a merge and in --mode refine alike. The maps
step now also makes the 2mFo-DFc grid, which the audit reads at each site and which is released
straight after. Nothing reads the audit: the refined model, maps, blobs and every R are unchanged
(1HR2 --mode refine: refined model file identical to the build without it).

The report gets REFINE_SOLVENT_SITES, _LABELS, _BEST, _DISAGREE, _AMBIGUOUS, _IONS and
_IONS_DISAGREE and a paragraph listing the ions (label -> best-supported species, bands,
neighbours, the first 40); rugnux writes every site to <prefix>_solvent.json beside _blobs.json.
The broker's live path does not carry it to the writer yet.

Docs: CPU_DATA_ANALYSIS_REFINEMENT.md 15.10 describes what is measured and every term of the score;
RUGNUX_REPORT.md the keys and the JSON; ACKNOWLEDGEMENT.md credits the Cat_Wiz bands and the
Auffinger-group surveys, Echols 2014, Morshed 2015, Carugo 2014, Brown & Altermatt 1985, Brese &
O'Keeffe 1991 (R0 as tabulated by Zheng et al. 2017), Mueller et al. 2003 and CheckMyMetal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Refinement questions need no raw images, so the battery gets the smallest extension that runs them
the battery's way: an arm whose manifest (refine.json) lists PDB entries, whose inputs - the
deposited coordinates and structure factors - are fetched into the site's pdb_cache as the open
arm's models are, and whose one command is rugnux --mode refine --model <xyz> <sf.cif>. A set
passes when the refinement ran; R-free before and after and the solvent audit are reported, never
scored. Every site has the arm, as it needs no data root. The first set is 1HR2 (the Tetrahymena
P4-P6 domain), whose Mg2+ sites the Cat_Wiz paper re-examines.

score.judge also records the REFINE_SOLVENT_ keys on every row whose model was refined, so the open
arm carries the audit too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The same P4-P6 domain as 1HR2, bound to a Fab, at 1.95 A with ammonium acetate in the drop: the
structure in which, per Leonarski et al. 2017, a water takes the place of 1HR2's Mg2+ at the
stacked-purine N7/O6 site. Cheap (seconds) and a direct counterpart for 1HR2's audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The Gaussian distance terms of water, Na, K, Ca, Cl and NH4+ used a spread of a quarter of their
band, which the papers do not give. Water H-bonds at 3.0-3.2 A then cost more as water than as Cl
(ideal 3.2), and ordinary waters with three such contacts were called Cl: 22 of them in one
1.1 A structure of the open arm. The published bands are broad because the distances are spread
over them, so for those species the band is all there is; Mg keeps its 2.07 +- 0.04 A (O) and
2.19 +- 0.08 A (N), which is what separates it from Na. Such waters now come out water or
ambiguous with Cl, as the halide literature says they should look.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The coordination bands are Cat_Wiz's (Naleem, Henning-Knechtel, Kirmizialtin, Auffinger 2026);
the distances and the exclusion zone come from the separate surveys of Leonarski et al. 2017, 2019
and 2025. The comment now credits each to its own authors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Deposited models carry many metalc records at outer-sphere distances (a Mg
"bonded" to a phosphate O at 2.9 A, Na to waters at 2.7-3.1 A, Ca at 2.65-2.8 A).
AddMetalLinks turned every one into a bond restraint at the library's
inner-sphere distance (Mg-O 2.07 A, sigma 0.04), which dragged the ion onto that
atom and out of its own density: in 1HR2, Mg A62 went from 2.94 A of an OP2 to
2.06 A, A60 from 2.98 A of an O6 to 2.17 A, both ending in negative mFo-DFc.
The X-ray term is not what pulls: raising the metal contact repulsion target to
2.6 A changed nothing (those pairs were bonded, not contacts), and with the
records gated the 1.9 A repulsion alone keeps the ions where they were.

A record is now taken only where the model's distance is within the library
distance (at the coordination the records claim) + 0.33 A - Mg-O 2.4 A, the
inner-sphere bound of Leonarski et al. (2017), one margin for every metal;
the coordination counts the records taken; dictionary links are unchanged.

Two related defects fixed on the way:
- the link names made here ("metal-0", ...) were left on the records and written
  out with the refined model, so refining rugnux's own output found 1 metal
  bond instead of 163; the records now get back the names they were read with,
  and a name the library does not have describes nothing;
- a refused record was still matched by gemmi to the link made for another
  site of the same residues and atoms; it carries a name that matches nothing
  during prepare_topology.

Battery, --only (refine arm 1hr2, 2r8s; open 36gk 6f3p 7l6j 7os3 8agq 8sa8)
vs rc180 7782e304e: refined R-free within +-0.001 everywhere (36gk -0.0010).
Ion-N/O contacts outer-sphere in the deposition and inner after refinement:
49 -> 15 (1hr2 8 -> 3, 8sa8 18 -> 5, 36gk 17 -> 5), the remaining ones drift
of <= 0.2 A at the window edge, none pulled to a bond distance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
193L (tetragonal lysozyme 1.33 A, Na+ and Cl-), 1EXR (Ca2+-calmodulin 1.0 A,
four EF-hand Ca2+), 1K4C (KcsA in high K+, 2.0 A, the selectivity-filter K+
sites) and 4YBB (E. coli 70S ribosome 2.1 A, tag "large", to show that a
ribosome refines at all). All X-ray with deposited structure factors.

4YBB on an RTX 5080 (16 GB): completes on the GPU, 489,290 atoms with riding
hydrogens, 3.06 M reflections; wall 2:09, peak GPU memory 7.4 GB, peak host
RSS 6.9 GB; R-free 0.2876 as given, 0.2782 after placement, 0.2808 final
(deposited 0.2338).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
RefineModel now runs the refinement (RefineOnce, the former body), then up to
three rounds of a water build (SolventBuild.{h,cpp}): the refined model's
mFo-DFc peaks from 3 sigma (each symmetry-equivalent set once, moved to the
copy nearest the model) become waters where they have a hydrogen-bond partner
(an N/O donor or acceptor at 2.2-3.4 A, an ion within 3.2 A, a halide at
2.9-3.4 A) and nothing closer than 2.2 A to N/O or 3.0 A to anything else; the
changed model is refined again from the start and the round is kept only where
R-free is lower. Peaks that cannot be one solvent atom are reported, never
filled: 1.2-1.9 A from a model atom, beside a model atom in -3 sigma density,
where a truncated residue's missing atoms would be, a tetrahedral cluster or
bonded peaks, elongated density, denser than every S and P. Removal: any water
in mFo-DFc <= -3 sigma; built waters also when partnerless or at the B ceiling.
On by default (RefinementSettings::solvent) to 2.8 A; not with
--refine-score-only. REFINE_SOLVENT_ADDED/REMOVED/NOT_SOLVENT/BUILD_ROUNDS/
BUILD_SECONDS; per-site reasons in _solvent.json "build". The model as given
and the placement stage keep the first refinement's numbers.

Evidence for the removal rules: phenix.refine's 2mFo-DFc < 1 sigma filter raised
R-free on the deposited waters of all five sets tried, and on our own waters in
five of six; removing 1K4C's 38 partnerless deposited waters raised it too.
Candidates are not gated on 2mFo-DFc (an unmodelled atom has only part of its
density there): with that gate ~2/3 of the missed deposited waters were refused.

Battery --only vs the Mg-fix binary (refined R-free, refinement time with the
build): 36gk -0.0022, 7l6j -0.0006, 7os3 0, 8agq 0, 8sa8 -0.0016, 6f3p -0.0008,
1hr2 -0.0107, 2r8s -0.0081, 193l -0.0049, 1exr -0.0019, 1k4c 0 (its round
rejected); no set worse. Time 2-4x the single refinement (1hr2 2.4 -> 7.2 s,
8sa8 11 -> 32 s); a rejected round costs a full refinement.
AlphaFold starts placed by MR (CLI, open-arm data): 9zlo 0.2940 -> 0.2564,
9gjx 0.3051 -> 0.2896, 9s02 0.2548 -> 0.2059.
Waters stripped from the refine-arm depositions and rebuilt: R-free recovers
75-100% of what the deposited waters are worth; recall/precision within 0.5 A
of a deposited water 0.25/0.25 (1hr2, 2.25 A RNA), 0.79/0.68 (2r8s), 0.63/0.57
(193l), 0.70/0.78 (1exr), 0.56/0.76 (1k4c).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
An atom closer than 0.5 A to one of its own symmetry images is on a special position: it is placed exactly on
the site (the mean of its position and its images under the site symmetry), its xyz gradient is projected onto
the subspace the site symmetry leaves unchanged, its occupancy is kept as given, and it is left out of the rigid
bodies. Contacts(): no repulsion of such an atom with its own coinciding copies (the self-repulsion that pushed
1K4C's filter K+ off the 4-fold), and each of its contacts listed once, not once per coinciding copy; AtomsNear()
lists a special atom's coinciding copies once (the solvent audit counted a K+ on a 4-fold four times).

The water build puts a peak on a special position exactly on it at occupancy 1/multiplicity, and refuses one
0.5-2.2 A from its own copy. Strip-and-rebuild on the five refine/open sets with special waters: placing beats
or ties refusing in R-free (7l6j -0.0016, 36gk -0.0002, 193l -0.0007, 8agq -0.0005, 1k4c 0); 8 of 11 placed
special waters coincide with deposited special waters.

1K4C (--mode refine, deposition): K3001-K3007 and HOH 3014 now on the axis (were 0.45-3.9 A off), R-free
0.2110 -> 0.2107, audited K+ disagreeing with the label 6 -> 1. Battery --only (11 sets, 6 open + 5 refine) vs
the P2 run: refined R-free within +-0.0005 except 193l +0.0015 (a different build trajectory; refinement
before the build 0.1738 -> 0.1737).

Test Refinement_special_position (K+ on the 4-fold of P4, occupancy 0.25).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The refinement of a placed model (and --mode refine) builds no waters and relabels no ion; the solvent audit is
still made. ProcessConfig::refine_solvent carries it to RefinementSettings::solvent, and RugnuxCommandLine gives
it back. The broker's live mode exposes no refinement switch at all (--no-refine has none either), so none is
added there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
gemmi's IT92::get() takes the neutral atom whatever the charge (its global ignore_charge defaults to true), and
the refinement never set one, so a Mg2+ scattered as 12 electrons, a Cl- as 17. PrepareModel now gives each
single-atom ion (a metal or a halide the embedded dictionary describes) its dictionary charge and every other atom
charge 0 - a deposition's formal charges on carboxylate O, phosphate O and Lys NZ (thousands per file in the PDB
cache) are not taken up - and the refinement's form-factor lookups (CPU density and gradient, GPU density atoms,
the B curvature) go through IonFormFactor(), which honours the charge without touching gemmi's global. Model
validation and molecular replacement are unchanged: they keep gemmi's neutral lookup.

Evidence (--mode refine --no-refine-solvent on the depositions, charged vs neutral): R-free 1hr2 (42 Mg)
0.2570 -> 0.2559, 1k4c 0.2107 -> 0.2106, 193l 0.1737 -> 0.1739, 1exr 0.2083 -> 0.2084, 36gk 0.2590 ->
0.2591, 2r8s/7l6j unchanged, 6hwj/8sa8/6fid -0.0001; ion B over environment B unchanged within 0.01 but K
(1k4c 0.91 -> 0.95). The electron-count evidence of the relabelling needs the ions' true electron counts.

Test Refinement_ion_form_factors (10/12/18 electrons; charges as set; CPU and GPU score alike).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Polymer chains whose sequences align with more than 15 residues at 80% identity (REFMAC5's criterion) are copies;
atoms of the same name in aligned residues of the same name correspond. Each copy is paired with the next of its
molecule and restrained on its interatomic distances below 4.2 A (bonds and angles excepted) against the other
copy's, under a Geman-McClure loss that saturates past 0.5 A, so regions that really differ release themselves
and rigid domain motion costs nothing. No superposition, no B-factor NCS, waters and ligands free.

Use and strength are decided late, by the weight trials: every rung of the first ladder runs without NCS and then
with it at sigma 0.1 A, and every trajectory step also tries it switched on / halved / doubled (off beyond 0.4 A);
listed after its twin, NCS is kept only where it lowers R-free by more than the tie. A model without copies runs
exactly the trials it ran before (bit-identical refined model on a single-copy control).

New kind RestraintKind::NcsDistance evaluated by the CPU and the GPU restraint kernels; REFINE_NCS_ report keys
where copies exist. On MD-perturbed starts of the refine-battery sets (refine mode, depositor's free set):
3.1-3.5 A sets R-free -0.012 to -0.073 (one start +0.001), 1.95 A -0.001 to -0.004.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Over the shorter chain a short RNA aligned with gaps into a long one finds most of its four letters matched: on
the 70S ribosome (4ybb) the 5S, 16S and 23S rRNAs fell into one group, and their restraints (1073 residues
released) were noise. The test adds a fragment of the molecule that must not be taken for a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
S1 - the model's displacements kept: an atom's ANISOU is no longer made isotropic; where the model has TLS
groups whose atoms bring no U, their U is made from the groups and the B column read as the total or as the
residual to them, whichever (or the isotropic B) scores the lowest R-free as given. Refined models are written
with ANISOU and TLS groups (mmCIF _pdbx_refine_tls*, and PDB REMARK 3, which gemmi's writer leaves out).

S2/S3 - both engines grid an atom with its full U (U = dev + B/(8 pi^2) I, so the B machinery works on it with
its shape held) and give the target's derivatives in U by the FFT method, CPU and GPU (one warp per atom, no
atomics). After the weights are chosen, the refinement tries, as trials side by side: the B factors refined on;
TLS groups with a residual B (the model's own, one per chain, and chains cut into 2/4/8/16 TLSMD segments by
dynamic programming over a quadratic B(r) fit); and a U per atom with rigid-bond (RIGU form), similarity and
isotropy restraints at three weights. A trial displaces another only within the geometry bounds and with both
R-free (beyond the tie) and the free -ln L lower. The water-build rounds hold the chosen U shapes.

An anisotropic atom's density is now cut at the radius of its largest eigenvalue rather than its largest
diagonal element (also in the validation's density and the GPU gather), which left skewed atoms truncated.

Refine arm + open-arm subset (11 sets, vs 51cbe0ef7), refined R-free: 1exr 0.2064 -> 0.1377 (SHELXL U kept),
193l 0.1689 -> 0.1322 (aniso), 2r8s 0.2262 -> 0.2054 (TLS 47), 1hr2 0.2463 -> 0.2316 (TLS 30), 1k4c 0.2110 ->
0.1881 (TLS 42), 36gk 0.2445 -> 0.2221, 7l6j 0.1766 -> 0.1553, 7os3 0.2040 -> 0.2014, 8agq 0.1948 -> 0.1595,
8sa8 0.1830 -> 0.1548, 6f3p 0.1821 -> 0.1453. Every output U positive definite. The first refinement takes
about 2-3x as long (the hypothesis trials; measured on a shared GPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The solvent audit gains the evidence the species need beyond the shell, and the build acts on it:

- Electrons: the site's own density fitted to 2mFo-DFc with the neighbours' held (one amplitude at the
  environment's B, the other atoms' IT92 densities to the data's resolution subtracted out to 4.5 A), on the
  scale of the macromolecule's C/N/O nearby (not the waters', which read about half their model's height).
  Depositions read Mg 11, Na 10, Cl 19, K 21-23, Ca 22-26, Zn 33, waters 4-6. Term: log-normal against the
  species' expectation (an ion at full occupancy, a water at the model's waters' median) in the waters' spread.
  An ion is read where this fit puts it (within 0.6 A), not where its label's restraints hold it; a plain
  2mFo-DFc maximum was tried first and pulled ions into their merged hydration density (1HR2 A5 1.7 A from a
  water).
- Anomalous: f'' per species at the wavelength (gemmi fprime, vendored fprime.cpp, Larch-derived, notice
  added), on the scale of the model's S and P, Gaussian in the map's rms - replacing the fixed 4 sigma.
- N7: a divalent ion on a purine N7 more than 1.0 A out of the base plane, or not 3.5-3.9 A from its O6/N6
  (NAR 2017), costs Mg and Ca 2 per such ligand.
- Halides: a missing donor costs Cl 0.5 (as a missing ligand does Mg/Na/Ca) and the positive-group requirement
  is gone - halides bind at hydrophobic surfaces too; the density decides.
- The build relabels a water or single-atom ion (no altloc, once, with density of its own) whose best species is
  another, not ambiguously: residue, element, atom name, scattering charge; its metal records replaced by
  inner-sphere ones (rugnux_metalcN). REFINE_SOLVENT_RELABELLED, per-site reasons in the report and the JSON.
- Metal LINK records left as contacts are decided once, on the model as given, and kept through the build's
  refinements (MetalRecordKey, BuildRefinementTopology outer_records) - the P2 ratchet.

1HR2 deposited-model audit vs Cat_Wiz / NAR 2017: the eight hexahydrated Mg A14-A27 MG; none of the ten
speculative A51-A64 supported as Mg (6 water, 4 Cl?/water?); A5 ambiguous Na/Mg (NAR 2017: probably monovalent).
Build with relabelling vs without (same binary, deposited SF): R-free 1hr2 -0.0019, 2r8s -0.0021, 7l6j -0.0016,
8agq -0.0006, 36gk 0, 8sa8 +0.0001, 193l +0.0003, 6hwj +0.0008. Relabelled: 1hr2 18 (14 speculative Mg ->
water), 8agq 4 Na -> water, 8sa8 Mg -> Na at Glu-bidentate 2.3-2.5 A shells; waters -> Cl at donor sites.

Tests: SolventSites_electrons_decide_halide_from_water, SolventSites_N7_out_of_plane, SolventBuild_relabel,
the decided-once records in RefinementRestraints_metal_links_outer_sphere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Where the lattice admits a twin law for the group (Le Page two-folds within 3 deg, coset
representatives whose square is in the group), the placed model is refined a second time against
the data as twinned by the law: the Rice amplitude likelihood with the model amplitude
k sqrt((1-a)|F(h)+Fsol(h)|^2 + a|F(Th)+Fsol(Th)|^2), D and Sigma fitted against it, exact at a = 0.
Twin mates not observed are composed as structure-factor-only rows, so no extra FFT; the GPU engine
adds a twinned-term kernel and a mate-derivative gather (deterministic, no atomics), the CPU engine
the same sum. Per macro-cycle: scale/solvent, the fraction, the scale refitted on the model-detwinned
Fo, the fraction again. The fraction is the regression of shell-normalised Fo^2 on the model's
intensities at h and Th (intercept included), which gives 0 for unrelated partners; least squares on
the twinned amplitudes gave 0.16 for unrelated partners and wrong laws on a model at R-free 0.31.

The twinned refinement is kept where a > 0, its R-free is below the untwinned one by more than 0.001,
and the law beats its null - the same model at the same fraction with every mate replaced by an
unrelated reflection of the same resolution - on the free set by 3 standard errors (paired). A law
whose twin pairs the free set splits is reported, not tested. The water build continues in the arm
kept. Maps of a twinned refinement use Fo detwinned in proportion to the model. Keys REFINE_TWIN,
REFINE_TWIN_LAW, REFINE_TWIN_FRACTION, REFINE_TWIN_R_WORK/_R_FREE, REFINE_TWIN_R_FREE_NULL,
REFINE_TWIN_NULL_Z, REFINE_UNTWINNED_R_WORK/_R_FREE.

Measured (--mode refine on the rc180 battery's placed models and merges, 32 sets): adopted on every
entry deposited as twinned (6iu6 0.231->0.200, 6iu8 0.285->0.199, 6iu9 0.307->0.212, 9i80
0.237->0.212, 2wnn 0.292->0.246, 2xfw 0.210->0.202, 5j23 0.250->0.192), on the twinned frames of
3mc4 (0.265->0.157) and 3r6o (0.264->0.169), and on 6p8j, 6vww, 7k1l, 8xte, 8xtg, 9qw2, 2wnz, 9hnc;
not adopted on 15 sets with a law (fractions 0-0.11, z <= 2.5), whose refined models and REFINE_
keys are byte-identical to the build without this change. Holohedral groups: NO_TWIN_LAW, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Each round of the build was a refinement of its own - the whole weight search again, and a rejected round cost a
full refinement - so the build took 2-4 times the refinement. Now the atom list carries placeholder waters from the
start (occupancy 0: no density, no mask, no contact, not refined; a fifteenth of the heavy atoms, at least 64), and
the build runs where the weight trajectory ends: a round reads the maps and the audit of that model, puts waters into
placeholders, takes waters out (occupancy 0) and relabels sites in place (RetypeAtom: element, flags, metal bonds,
exclusions; the GPU engine refreshes an atom's density on an occupancy or element change), in every trial slot; then
the solvent alone is refined at the trajectory's weights (B refined, 10 iterations a cycle) and the round is kept
where that lowers R-free, else undone. At most two rounds; the final ladder then refines what they kept with the
rest. The output model is the view with the placeholders moved into their chains (SolventView) and the relabelled
metals' inner-sphere records. One topology for the whole refinement, so the metal records are decided once by
construction (the outer_records plumbing of the previous commit is gone again). Contacts() leaves out atoms at
occupancy 0; FindSpecialPositions() too (placeholders at the origin sat on the 2-folds of I222/P43212/C2 and were
projected there - R-free 0.44 on 36gk until fixed); waters put on a special position get their site constraint.
The audit runs on the threads (its density tables made up front), and MakeMaps makes its three maps at once.

Timing (--mode refine on the depositions, GPU, each set's three runs under one lock, load 15-24 from other agents),
refinement seconds without the build / with the old build / with this one, R-free old / new:
1hr2 3.5/11.3/4.6 0.2462/0.2490, 2r8s 2.8/11.2/4.6 0.2238/0.2244, 193l 1.0/3.1/1.8 0.1713/0.1713,
1exr 1.6/5.2/1.9 0.2076/0.2084, 1k4c 2.5/4.9/3.2 0.2106/0.2106, 36gk 3.7/12.4/6.7 0.2559/0.2543,
7l6j 3.6/16.3/6.5 0.1530/0.1546, 8agq 2.1/6.7/2.7 0.1753/0.1761, 6hwj 1.9/8.1/3.6 0.2222/0.2232,
8sa8 8.5/36.2/14.1 0.1714/0.1710, 6f3p 4.8/22.3/8.3 0.1629/0.1630, 7os3 1.4/4.9/2.6 0.2122/0.2115.
Build overhead 0.2-0.9x the refinement (median 0.7), was 1.0-3.7x (median 2.3); R-free within 0.001 on 8/12.
Tried and dropped (P3.md): the trajectory again after the rounds (never gained), a weight ladder after the build
(never displaced), building after the ladder instead (same), accepting rounds within the R-free tie (worse on 4),
a full step as the round's judge (noisier), one macro-cycle for the solvent (6f3p's rounds refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The solvent build now runs inside RefineOnce's trajectory (solvent-p1), so the NCS trials were carried into the
trajectory lambda: every step adds its "NCS changed" trial, and the solvent-only judge of a build round runs at the
NCS sigma the trajectory ended at. The copies are found once per refinement on the polymer atoms; the solvent
placeholders (occupancy 0, own chain) and relabelled waters/ions are never part of them, and atom indices do not
move when the build edits the slots in place, so the restraint records stay valid through the rounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On tls-aniso the displacement model was decided in the first refinement and the water build's later
refinements held it (adp_hypotheses off). With the solvent build inside RefineOnce (solvent-p1) the order
is now: ladder -> trajectory -> solvent build rounds -> final ladder -> displacement hypotheses, so the
isotropic / TLS / per-atom U decision is taken late, on the model with its solvent. The solvent-only judge
of a build round runs with the anisotropy the trajectory carries (the model's own ANISOU / TLS reading).
TLS groups leave waters out, so neither the occupancy-0 placeholders nor the waters a round adds enter a
body; the per-atom U hypothesis gives a U to every refinable atom, the waters a round activated and the
relabelled ions included, and none to a placeholder still unused. The U and TLS groups are written onto
the model before SolventView moves the built waters into their chains.

Trials carry NCS sigma and the ADP hypothesis together (RunTrial/RunTrials take both); the GPU density
cache is re-made on a change of occupancy/element/charge or of the anisotropy. An anisotropic atom now
scatters with its ion form factor too (AnisoDensityAtom, AnisoGradient), as the isotropic path does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On twin-refine the untwinned refinement ran without the water build, DecideTwin refined the placed model
again against the twinned data, and the build's rounds (each a RefineOnce) continued in the kept arm. With
the solvent build inside RefineOnce (solvent-p1) each arm is now a whole refinement: untwinned and twinned
each run the weight search, their own solvent build (placeholders, relabelling, the solvent-only judge
against their own target - the twinned arm's maps detwinned) and their own displacement-model and NCS
trials, and DecideTwin compares where the two ended (R-free, the fraction, the null at the twinned arm's
final model). The decision is taken late, on the finished models, and costs one more full refinement only
where the lattice admits a usable law; the alternative - deciding on solvent-less arms and refining the
kept one again with the build - would cost a third refinement. DecideTwin no longer returns the law (no
later round needs it). REFINE_TWIN_R_* / REFINE_UNTWINNED_R_* are the two arms as they ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The mask grid is capped at 2^26 points; a cell larger than that got the spacing that fills it. For
4YBB (5.7e7 A^3) that was 0.95 A, coarser than the 0.8 A shrink radius, so the shrink reached no grid
neighbour and was silently skipped: the macromolecule kept the whole 1.0 A probe, the solvent fraction
fell from 0.68 to 0.61 and F_mask correlated only 0.81 / 0.59 with phenix's at 8-5 / 5-3 A. Where the
cap would coarsen the mask past d_min / 3 the mask now uses the density grid itself (0.70 A here),
whose points are on the card anyway, so the engine grows only by the mask's labels (8.22 -> 8.36 GB).

The R-factor against grid spacing has a cliff at the shrink radius (gemmi Refmac masks, rugnux Fc,
python scaling): 0.95 and 0.85 A give R-free 0.286, 0.78 / 0.70 / 0.60 / 0.40 A all 0.271.

4YBB, refine arm (deposited model + SF, depositor's free set):
  as given      R-work 0.2771 -> 0.2573, R-free 0.2876 -> 0.2697   (CPU engine identical)
  placement     R-free 0.2782 -> 0.2627
  restrained    R-free 0.2808 -> 0.2621
  water build   R-work 0.2510 -> 0.2345, R-free 0.2697 -> 0.2547
phenix.model_vs_data on the same files: 0.2349 / 0.2461.
Only a set whose cap exceeds d_min / 3 changes; of the batteries' sets that is 4YBB alone (1hr2,
1k4c score-only bit-identical). Cells capped past 0.8 A at lower resolution (d_min / 3 above the
cap) keep the coarse grid and still lose their shrink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
No conflicts. The mask choice is per engine and independent of the solvent build, TLS, NCS and twin arms;
it changes only sets whose capped mask spacing exceeds d_min / 3 (4YBB in the manifests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The displacement hypotheses were compared with the first of them (the B factors refined on), and the one
chosen was taken whatever the state the weights ended at; only that first one had to displace it. Where the
trajectory held B and refining B is worse (a 3.6 A set with twelve copies: B held R-free 0.2797, B refined
0.3103), a per-atom U at 0.2939 was then adopted although it is worse than holding B. Now the chosen
hypothesis must displace the state as any step must, else the B factors stay as the weights left them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The mask's shrink (0.8 A, Refmac radii) reaches no grid point on a grid whose step is not below it and is
skipped, so the macromolecule keeps the whole 1.0 A probe. Model validation masked on the density's grid
(d_min / 3), so every run merged to d_min above ~2.25 A lost its shrink; the refinement's capped grid
did the same in cells above 2^26 points at 0.4 A once the cap passed 0.8 A.

SolventMaskGrid (ModelGrid.h) now sizes the mask's grid for both: the step asked for, but never coarser
than MASK_SPACING_SHRINK = 0.75 A nor than the density's grid (which it then is, as before). Validation
asks for d_min / 3, so it keeps the density's grid up to 2.25 A and gets one of its own beyond; the
refinement asks for d_min / 3 or 0.4 A or the cap's step as before. ModelStructureFactorsGPU masks on
that grid with its own cuFFT plan where it is not the density's; GPU and CPU masks stay gemmi's to the
radius-edge points, as before. Below the shrink radius the R-factors do not depend on the step
(R-free the same at 0.78 / 0.70 / 0.60 / 0.40 A on a 2.1 A cell, 0.95 / 0.85 A 0.015 worse).

Open arm, battery --only, before (f149f0a02) -> after, validation R-free (shell-scaled R_model):
  4nwv (merged 2.94 A)  0.297 -> 0.281  (0.294 -> 0.278)
  5lzl (2.88 A)         0.232 -> 0.210  (0.223 -> 0.200)
  6oel (2.84 A)         0.259 -> 0.232  (0.249 -> 0.223)
  8r5r (2.80 A)         0.294 -> 0.269  (0.265 -> 0.245)
  6i3j (2.33 A)         R_model 0.1924 -> 0.1916
  5jvn (2.24 A), 7l6j, 5reo, 6cee: identical; space groups unchanged everywhere.
Refinement in those runs: 4nwv (refinement mask 0.97 -> 0.75 A) as given 0.3073 -> 0.2865, placement
0.2994 -> 0.2780, refined 0.3061 -> 0.2800; 6oel (0.81 -> 0.75 A) 0.2711 -> 0.2725; others identical.
4nwv: refinement engine 3.55 -> 6.09 GB, validation structure factors 1.30 -> 3.26 GB and the null's
0.80 -> 3.25 GB (fewer rigid-body / scale engines side by side, all within the 13.29 GB budget); wall
121 -> 134 s (refinement +10 s, validation +0.8 s). 4ybb: unchanged from f149f0a02.

Tests: SolventMaskGrid_ShrinkActsAtLowResolution (new); ModelStructureFactorsGPU_MatchesCPU now
against the CPU mask on SolventMaskGrid, which at 3.5 A exercises the GPU's own mask grid; [ModelValidation]
(51 cases), Refinement*, ModelMaskGPU_* pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The per-slot GPU estimate counted the restraints at 30 records and 60 slots per atom (4.1 GB on a
510k-atom model where the real sets take ~0.4 GB) and every slot carried its own copy of the
reflection tables, the adjoint's lists, the phase table, the bulk-solvent mask's working buffers
(1.5 GB on a 0.7 A grid) and a scale-fit pool (2.0 GB). Now:

- RefinementGPUCard holds the tables and the mask buffers once per card; engines made like the main
  one (MakeRefinementEngineGPU(like, device, nthreads)) share it and the host setup. The mask runs
  under the card's lock on the card's stream - the same kernels in the same order.
- The slots on a card share its ModelScaleGPUPool (a fit waits for the one before it).
- The restraint reserve is the model's two restraint sets as the refinement starts (geometry,
  repulsions, NCS; B restraints), plus a quarter.
- OwnSlotDevices puts the slots on the cards in turn while they fit; the old rule sent every slot
  beyond one per card to a single card on a 4-card machine.

4YBB (509k atoms, 3.06M reflections, RTX 5080 16 GB): engine 3.50 GB own + 1.73 GB shared + 1.99 GB
pool; 2 trial slots instead of 1 (peak 10.8 GiB measured); 830 -> 580 s wall. Refined model CIF
byte-identical to the 1-slot run. [Refinement] 24 cases / 21472 assertions pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A deposition can hold part of the asymmetric unit with the strict-NCS operators that generate
the rest (mmCIF _struct_ncs_oper code 'generate', PDB MTRIX without the given flag). Model
validation and the model reference intensities expanded them; the refine-only path passed the
structure as read, so it refined and scored a fraction of the crystal. On 4nwv (an icosahedral
particle depositing 5 of its 75 chains) refine-only reported R-free 0.567 as given; with the
expansion 0.269 as given, 0.257 after placement (phenix.model_vs_data on the same two files:
R-work 0.2575, R-free 0.2590) and 0.267 refined. Models without generating operators are
unchanged.

expand_strict_ncs becomes the public ExpandStrictNcs (ModelValidation.h); RunRefineOnly calls it
and logs the chain count it went from and to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
- Refit: the AsuData of F_calc and F_mask, the row model's k_total and scaled F_mask, and the scale
  pool's packing of the points are filled in parallel, each element on its own (refit.scale on 4YBB
  84 -> 70 s summed over the slots).
- The solvent build's edits are collected once and applied to every slot's model and topology on a
  thread per slot, with the slot's topology copy for the undo: they were made slot after slot, so
  their cost grew with the number of slots. Timed as refine.build.edit.
- Test: Refinement_restrained_recovers_model refines again with one trial at a time and requires the
  same model bit for bit as three side by side.

4YBB refined model CIF byte-identical to the 1-slot reference run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
RetypeAtom found atom i by walking the whole model and rebuilt and re-finalised topo.adp (1.5M
records on a 510k-atom model) for every relabelled site, in every slot: on 4YBB 256 relabels cost
~9 s per slot per round (refine.build.edit 18.9 s for 2 rounds; serial over the slots before the
previous commit, the server's slower solvent passes on 4 cards). It now takes the residue and atom
directly, and DropBRestraints() takes the relabelled atoms' B similarities out once after the round's
edits - the same records in the same order, so the same set (Finalize is a function of the records
alone). build.edit 18.9 -> 0.4 s, solvent build 59 -> 42 s.

The solvent round's maps and audit run with the main slot's full thread count instead of the share
the trials before left it.

4YBB refined model CIF byte-identical to the reference. [Refinement],[Solvent],RefinementRestraints*
37 cases / 21584 assertions pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Displacement models (after the final ladder):
- No separate isotropic trial. It was a further 3 macro-cycles at the same weights, never adopted on 49
  refinements (always worse than where the weights ended); the chain now starts where the weights ended, its
  free -ln L scored once.
- TLSMD cuts: one body per chain and the finest cut run first; the cuts between them only where the finest
  fits the free set better (lower free -ln L) than one per chain.
- Per-atom U at 1/4 and 1 times its balanced weight; the x4 trial is dropped.
Replayed on the logs of 49 refinements (21 battery sets, 12 twin sets, 4YBB), neither rule changed an adopted
choice; a strict "stop at the first cut that does not beat the previous" did (5 of them) and was rejected.
NCS ladder: the rungs with NCS run only at the B weight the rungs without chose - NCS moved the chosen X-ray
weight a rung up in 11 of 39 refinements with copies, the B choice in none.

4YBB (RTX 5080, one slot): refinement 826 -> 641 s, 4661 -> 3490 evaluations, ADP stage 329 -> 173 s,
R-free 0.2402 unchanged, same TLS 108-group model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Two defects of the solvent-only judge of the in-refinement water build:
- Where the trajectory held B, the judge refines the solvent's B at the balanced weight, and its outcome's B
  weight became the state's: the final ladder and the displacement trials then refined every atom's B. On B-held
  sets the final ladder was far worse than the state it started from (0.2058 -> 0.2175, 0.2248 -> 0.2366) and
  never displaced it. Now the rest goes on with the B factors as the trajectory had them; the final ladder now
  displaces on such sets (two arms of one, R-free -0.001).
- Refining the solvent alone can lose what the round's sites gained (a deposited model: R-free 0.2194 before, 0.2178
  with the 41 waters as built, 0.2230 refined alone - positions only 0.2230, B only 0.2197). A round the refined
  judge refuses is kept as built where that lowers R-free (one Refit). On that set 0.2194 -> 0.2171. Rounds where
  the waters themselves do not help (as built above the state) stay refused.

Battery refine-only (--mode refine on the merge-m3 inputs), 14 of 21 sets so far with the change above:
36gk -0.0010, 6fid -0.0021, 7os3 -0.0010, 9qw2 -0.0009, the others unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The halved X-ray weight of the final ladder was never kept on 55 refinements (it was the worst of the three on
nearly all), and replaying the choice without it changed none. One trial fewer per refinement.
4YBB (RTX 5080, one slot): 641 -> 620 s, 3490 -> 3385 evaluations, R-free 0.2402 unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Resolved: the ModelGrid.h include in ModelStructureFactorsGPU.cpp (AnisoRadiusB, SolventMaskGrid).
ModelReferenceIntensities keeps its mask on the density's grid: it is a reference at fixed solvent
constants, not a scored mask, and RigidBodyGPU_MatchesCPU and ModelScaleGPU_MatchesGemmiOnAModelsPoints
make their amplitudes from it - with the finer mask those amplitudes moved enough to land the anisotropic
test cluster on a near-tie of gemmi's scale fit (residuals 2.8e-4 against 2e-4, b_star 9.5e-7 against
1.6e-7). ModelStructureFactorsGPU_MatchesCPU at 1.5 A in F 41 3 2 (F_calc 1.28e-4 of the mean |F|
against 1e-4) fails on 89b37f9bc already and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The coarse solvent grid (up to 11 x 8 = 88 fits) went in one launch, so the engine held 88 floats
per reflection for |F_calc + k_sol F_mask| and two rows of 32 for the robust residuals and weights.
The points now go in grid order in batches of MAX_FITS = 32 (the fine pass, at most 25, in one; the
coarse pass in three), the winner chosen across them in the same order with the same comparison -
a fit's sums depend on its own column only, so every fit, and the winner, is what it was. The robust
weights are written over the residuals they are made from (a fit whose c is not positive is not
weighted, so its row is never read as weights).

Scale pool on 4YBB (3.06M reflections): 1.99 -> 0.91 GB. ModelScaleGPU*, ModelScaling* pass; 4YBB
refined model CIF byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The grid, its spectrum, cuFFT's work area and the mask's own grid and spectrum move into
RefinementGPUCard beside the tables and the mask buffers. An engine takes the card's lock from its
launches to their wait in Evaluate, EvaluateWithRestraints, Fcalc and Fmask (the mask's separate lock
folded into it): the card runs the slots' evaluations one after another - the same kernels on the
same inputs, so the same bits - while the other slots do their host work, which is what the slots
overlap anyway (a 2-slot 4YBB had the card 69% busy).

4YBB, RTX 5080: engine own part 3.50 -> 2.02 GB, card part 1.73 -> 3.21 GB, scale pool 0.91 GB:
4 trial slots instead of 2 (peak 11.8 GiB measured), GPU busy 80%, 548 -> 500 s wall; refined model
CIF byte-identical. A 16 GB T4 (12.52 GB budget) fits 4 slots by the same numbers.
[Refinement],[Solvent],RefinementRestraints*,ModelScale* 51 cases / 22236 assertions pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The GPU's F_calc is the CPU path's: PutModelDensityOnGrid() and the GPU gather both cut an anisotropic atom at the
radius of its largest eigenvalue (6472ce3ff), where gemmi's put_model_density_on_grid(), the test's reference until
now, takes the largest diagonal element. Against gemmi the test therefore measured the density beyond that radius
(1.28e-4 of the mean |F| in F 41 3 2 at 1.5 A, over the 1e-4 bound), not a CPU/GPU difference. Against
PutModelDensityOnGrid() - what ModelValidation.cpp grids - it passes at the unchanged 1e-4 bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The previous commit put every engine on a card on one set of working buffers, one evaluation at a
time. On a T4 two evaluations side by side cost only 9% more each than one, so serialising them
gives back the concurrency of two slots. The buffers (grid, spectrum, cuFFT work area, the mask's
own grid and spectrum) now come in RefinementGPULane: a slot takes a lane of its own where the card's
budget holds its engine and a lane, else only its engine on the card's least used lane (under that
lane's lock from launches to wait). The bulk-solvent mask's buffers stay one per card, under their
own lock. OwnSlotPlaces plans both per card.

4YBB (engine 2.02 GB, lane 1.48 GB, card 1.73 GB, scale pool 0.91 GB): RTX 5080 3 slots on 3 lanes,
512 s (4 slots on one lane 500 s, 3 slots unshared 516 s); a T4 (12.52 GB budget) 3 slots on 2 lanes,
an L4 5 on 4. Refined model CIF byte-identical. 51 cases / 22236 assertions pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The finish's maps (and the solvent build's) re-fit the scale, the solvent and the likelihood with
Refit(), which starts its solvent grid from the last one and its D from the last bins: those were the
main slot's, from whichever trial it happened to run last - a different one for another number of
slots or cards. The maps, and what is read off them (the solvent census, the blob list, the twin null),
moved with the slot layout, by a few blobs and 0.01 A on 4YBB between 1 and 4 cards. The main context
now takes the kept state's own fit (Restore) before the solvent round's maps and before the finish,
and the final ladder's main-slot restore goes. The refined model is not touched by the finish; the
solvent build's starting fit is now the trajectory's own.

4YBB, RTX 5080: 1 slot vs 3 slots - report (but time/date/git), refined model, blobs and solvent
json identical; refined model CIF byte-identical to the reference, the report's blob counts differ
from before (3861 vs 3867 blobs). Test: Refinement_restrained_recovers_model also requires the same
mean FOM, difference-map rms, blobs and solvent sites with one trial at a time as with three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
rc181 starts from main 63f5f9613, whose tree is rc180 7782e304e's exactly, so the
merged tree is refine-merge's exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A chain is a copy of a molecule only where, besides the sequence rule, its C-alpha (P) atoms lie within 2.5 A rms of
the molecule's first copy after superposition (REFMAC5's second criterion): the same sequence in a conformation
further away is another molecule.

4NWV (a T=3 capsid expanded from 14 strict-NCS operators) has 45 protein chains of ONE sequence: A, B and C are its
quasi-equivalent subunits (A starts 14 residues earlier; A is 3.3-3.6 A from B and C, B and C 1.2 A apart). All 45
were one molecule, paired in model order A-B-C, copies 2.9 A apart, and NCS was refused. Now A x 15 and B/C x 30:
NCS kept (sigma 0.1 A), --mode refine R-free 0.2565 -> 0.2450. Every other NCS set (open arm and the MD starts) has
all its copies within 2.5 A, so its groups and results are unchanged.

Ordering each molecule's copies as a chain of nearest neighbours (strict copies beside each other) was tried too:
4NWV 0.2401, but one MD start of a four-copy set lost 0.039 and a twelve-copy set 0.001 on three starts. Not kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On 4 cards (4YBB, 4x T4, 12 slots) two phases ran as two dependent waves, the second leaving card 3 idle:
the ladder's NCS rungs (run at the B weight the rungs without NCS chose) and the displacement trials'
intermediate TLS cuts (run where the finest cut beats one body per chain). Where the slots take the whole
set at once, these now run with the others, and those not needed are left out with their evaluations (not
counted, so the logged count is the same whatever the slots). In RunTrials the first wave is one trial per
slot in slot order (breadth-first over the cards), then each slot takes the next trial as it is done
(instead of a fixed j += slots), ordered TLS, U, B refined, B held.

Every trial starts from its given model and fit and takes nothing from its slot, so the refined model, the
trials kept and the report are unchanged; new test Refinement_trials_run_ahead (CPU, 8 slots vs 1) checks it
bit for bit on two copies of a tRNA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Counted on the 368 refinements of the rc181 full battery (fee8ffa) which trial of each stage was kept,
and replayed each stage's decision without the trials left out here:

- Ladder: B refined first at the middle rung only, at the other two where it comes within two ties of
  B held there. B held was kept by 281 of 364 ladders; the rule changed the rung kept on 4 (within
  0.006 R-free at the ladder) and leaves out 412 of 1236 B-refined ladder trials.
- Ladder NCS rungs: at the rung the trials without NCS chose and the next looser one only. On 198
  ladders with copies NCS was never kept at any other rung: 232 of 594 NCS trials left out, no pick
  changed.
- Trajectory: the X-ray weight halved only in the first step (from the ladder's coarse rung). No later
  step kept the halved weight (0 of 91).
- Displacements: the 2-segment TLS cut dropped (6 choices changed, none by more than 0.001); the model's
  own TLS groups tried only where one body per chain or the finest cut lowers R-free from where the
  weights ended, the cuts between those only where also the finest fits the free set better than one
  body per chain (as before), and the U at its balanced weight only where the U at a quarter of it
  lowers R-free. Replayed: 7 of 364 choices changed (max +0.0026, mean +0.00001), 2315 -> 1527
  displacement trials.

The late trials still run ahead where the slots take them all at once (small models: no extra wave);
results do not depend on the slots or cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Trials whose need is decided by others ran with them wherever the slots took them all at once. On one
card that is no saving: the trials run ahead share the card with the ones that are needed. A model of
43k atoms with copies on one RTX 5080 (12 slots): ladder with its setup 9.9 -> 4.9 s and 12.9 -> 7.5 s
in the two twin arms, displacements 10.5 -> 9.3 s and 13.0 -> 11.2 s, refinement 76 -> 65 s. A
5.5k-atom model is about even. Several cards and the CPU (each slot with threads of its own) still
run them ahead. Timing only: outcome and evaluation count are the same whatever the slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The usage printed with no arguments was ~190 lines, many of them a paragraph each. It now opens with
how to run each mode (rotation and stills under mx - auto-detected from whether the goniometer axis
turns - refine, calibration, azint and scale), one to four example command lines each, then the ~18
options most runs need, and ends with where every option is described: the installed HTML
(share/doc/jfjoch_rugnux/html/RUGNUX_CLI.html in the release archive) and Read the Docs. 64 lines
including the banner, under 100 columns, no option's name or behaviour changed.

The full option tables move from RUGNUX_ADVANCED.md to a page of their own, docs/RUGNUX_CLI.md,
headed by the same per-mode section and the input rules the old usage carried (file order, several
stills inputs, a model or reflection file told by content, the broker URL). Options the docs did not
list are added: --no-refine-solvent, --dump-observations and the short -F; --mr, --crystfel-geometry
and --refine-modules=diagnose take over the detail the old usage had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A custom target, rugnux_docs, builds the Sphinx documentation into <build>/docs_html with a venv of
its own from docs/requirements.txt, so it needs Python 3.10+ and network for pip, not an installed
Sphinx; it is never part of the default build, and without Python 3.10 there is no target and a
status message says so. An OPTIONAL install rule puts whatever it left into the rugnux component at
share/doc/jfjoch_rugnux/html (.buildinfo excluded), so every archive generator that packs that
component - the Linux .tar.gz, the macOS rugnux .tar.gz and the Windows .zip - ships it when the
target was built, and is unchanged when it was not. About 12 MB, 3.5 MB compressed.

CI: the portable Linux job builds rugnux_docs before cpack and checks RUGNUX_CLI.html is in the
rugnux tarball.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Hidden ([.][refine_bench]): 5500 atoms in the asymmetric unit of a P 21 21 21 cell at 1.6 A, 200
evaluations with the gradient; run under nsys --cuda-graph-trace=node for the time of each kernel
without the concurrency of a refinement's slots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A warp now covers a 4 x 4 x 4 corner of the brick and walks, of each staged tile, only the atoms whose
box reaches into that corner (a ballot over the tile's masks, in tile order), and each thread takes two
points one above the other along w. A point still adds every atom whose box holds it and whose radius
reaches it, in model order, with the same arithmetic: the grid stays bit-identical to the CPU's
(ModelDensityGPU_BitIdenticalToCPU).

RefinementGPU_Benchmark on an RTX 5080: gather_kernel 216 -> 183 us per evaluation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
atom_gradient_kernel dealt the lanes of an atom's warp the whole box, about half of it beyond the
radius. It now deals them the points of the box's rows (v, w) cut to the chord the radius leaves each
row, a point to spare at either end (the radius test decides as before), numbered one after another
over a warp's width of rows. The atom's Gaussians are held in registers instead of a copy of the whole
atom, and the kernel is bounded to four blocks of 256 per SM (64 registers, no spills), twice the
warps it had at 96 registers.

The points a lane sums, and so the gradient, change in the order of their float sums.
RefinementGPU_Benchmark on an RTX 5080: atom_gradient_kernel 206 -> 159 us per evaluation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
scale_sums accumulated every Levenberg-Marquardt slot in double, which on a card with little double
throughput (T4, consumer cards) is most of a step. J^T J and J^T r now go in float, per point, per
thread and through the warp's shuffle tree, into the double sums across warps and blocks; they only
set the direction of the next step, which the WSSR - still in double - accepts or not. MODE_R and
robust_residuals take the anisotropic scale in float as the WSSR already does, and robust_weights its
1 / sqrt(1 + q^2) in float. Fixed launch shape and order as before: deterministic.

cytochrome C refine run (50k reflections) on an RTX 5080 under nsys: scale_sums<3> 59 -> 38 us per launch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The space-group search writes the lowest number of a pair the intensities cannot separate - I23 for
I2(1)3, I222 for I2(1)2(1)2(1) - and the model's group was only taken up as the written label when the
pair was enantiomorphic. A cubic I2(1)3 model that fitted (R-free 0.24 unrefined) was then refined
in I23, with its symmetry copies in the wrong places, and ended at R-free ~0.5. SameIntensities (same
rotations, same absences) now decides the candidate; the anomalous-map veto stays with real
enantiomorphs. --mode refine likewise refines in the model's group when the file names the other
group of such a pair (on the cubic in-house insulin merge: R-free 0.511 -> 0.162).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Model validation scores a model in its own space group; the refinement then ran in the group written,
which differs in its screws where the merge did not decide them (P6 written, P6(3) model, no 00l
measured) or decided them differently from the deposition. The model's copies were then packed by the
wrong screws and the refinement started from R-free ~0.4-0.5. It now runs in the model's group, as
the validation did, with the reflections that group extinguishes left out; the refined model and
maps carry that group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
axis_bricks() walked the 2 d + 1 points of a box and looked each one's brick up among those found so
far. The wrapped points are one run from imod(c - d) up and, where they pass the cell's end, a second
from 0 (2 d + 1 <= n, Supports()), so the bricks are now taken run by run, in the order the points
reach them as before. The same bricks, counted and filled the same way: the grid is unchanged.

RefinementGPU_Benchmark on an RTX 5080: count_bricks_kernel 19 -> 10 us, fill_bricks_kernel 32 -> 26 us.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
CLAUDE.md: the usage now shows only the modes and the basic options; every option is described
in docs/RUGNUX_CLI.md, so a CLI change updates the parser, that page and (for a basic option)
the usage together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
With -S (or a group taken from --model / -z), ScaleMergeAndSymmetry re-seats the integrated
reflections into the fixed group's Bravais setting when the indexed lattice's centring or metric
does not carry it - but the rotation geometry post-refinement was then handed the indexer's lattice
in the OLD setting beside the re-seated indices. Every Miller index was predicted against the wrong
basis: the held-out residual started ~5 orders of magnitude above a de-novo run's, the joint fit used
every step and "never settled", the rotation scale ran to its 0.95 bound, the detector geometry
(including a ~6 px header beam-centre error) was left at the header, and the end of the sweep was
then rejected as radiation damage.

The lattice now goes across with the reflections (the same composed change of basis the reflections
received), and the fit is parameterised in the class it is now held to. Runs without a fixed group
never re-seat before the post-refinement and are unchanged.

Measured on a split-domain C2 crystal with a ~6 px header beam-centre error: -S, -S -C and
--model -z now commit the post-refinement, move the centre as the de-novo run does, reject no
frames and reach ISa ~15 (was ~12.3), matching the de-novo run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A symmetry operator or a twin law R carries the copy pair of a pseudo-translation u to one related
by R u, and the measured intensity sums them: the factor a reflection carries is the mean of
cos(2 pi h.Ru) over those rotations, not cos(2 pi h.u). Where R u is not +-u, binning by frac(h.u)
alone averages the very classes the test looks for. On a perfectly merohedrally twinned crystal
with a strong off-axis pseudo-translation this put the vector's modulation just under the gate
although its Patterson peak was far above the null, so tNCS was reported absent, the L-test was
read unrepaired and the twin fraction was underestimated.

Where the Patterson peak is significant but the vector's own test does not call it, the same test
is now run again with each reflection binned by arccos(<cos 2 pi h.Ru>)/pi over the lattice's
proper rotations (find_lattice_symmetry: the merge's symmetry plus every twin law the metric
admits), against its own within-shell permutation null and the same ratio gate. Only where it
fires does anything downstream change: the full-resolution ladder refines the vector on the
averaged correlation, and the class vectors handed to the L-test and the twin-immune zone are all
the images R u (TranslationalNCSVectorForLTest now returns them; a partner step must keep the class
of every one, and the zone normalises by the mean cosine). Every existing call stands as it was:
the vector test, its numbers and its keys are unchanged, and the second test cannot un-call
anything.

New report keys TNCS_MODULATION_AVERAGED(_NULL), written only when the second test ran, and
TNCS_CLASSES (VECTOR | LATTICE_AVERAGED) on a detection. Synthetic test: a perfect P6 twin with an
off-axis pseudo-translation merged in P3121 reads 1.5x its control on the vector and 3.3x averaged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The documentation job uploads its HTML as the artifact docs-html (actions/upload-artifact@v3);
build-windows and build-macos now need it and download it into <build>/docs_html before cpack,
where the OPTIONAL install rule of rugnux_docs picks it up. The Windows runner has no Python 3.10+
to build the docs itself. download-artifact is not mirrored in this Gitea's actions org, so it is
taken from github.com by full URL until a mirror exists. The documentation job moves to the top of
jobs: so it is handed out early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A model placed as given - a deposited one, or one refined against earlier data of the crystal - has been
fitted to every reflection, so a free set drawn by the run is not free for it: on a deposited model R_FREE
read below R_WORK as placed, silently. The only way to hand over the depositor's test set was -z, which
also fixes the group, the cell and the frame and so switches processing onto the reference path.

--free-flags <mtz|sf.cif> reads the file as -z does and keeps only its flags. They are put on the merge
once it is on the model's axes (after the model's relabel and indexing), where the merge matches the
file's intensities by -z's rule (CC >= 0.5 over >= half the reflections), and the validation is made
again on them; the refinement and the written files carry them. The same remake now runs where -z's flags
were inherited, whose R_FREE was read on the drawn set before. R_FREE_FLAGS= DRAWN|REFERENCE|FILE says
which set R_FREE and the refinement used, and a model placed by the rigid body on a drawn set gets the
MODEL_FREE_SET warning.

-z keeps steering processing: fixing the group, cell, frame and the indexing ambiguity of a campaign is
what it is for, and its flags only mean anything on the reference's axes; a separate flags-only input is
the smaller change.

Measured on 5reo (deposited model, deposited sf): R_WORK/R_FREE as placed 0.2076/0.2003 -> 0.2052/0.2471,
refined 0.1979/0.2172 -> 0.1774/0.2331; the merge itself is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The unmerged export held the summed events with the deterministic corrections only - no partiality, no
per-frame scale, no outlier rejection, past the resolution cut too - for the reading program to scale.
aimless re-scaling that from scratch lost what rugnux's scaling had corrected, and the data read worse
there than in rugnux's own merge.

Given the merge's scaled fulls, UnmergedMtzTable now writes each summed event with the I and SIGI of the
full the merge kept for it (matched on the measured index and the nearest peak frame, within the rocking
bridge plus one frame) and leaves out an event the merge did not keep, as a scaling program's unmerged
output does (FLAG stays 0: nothing written is rejected). The other columns are the event's, unchanged. No
SCALEUSED is written: a full is a weighted combination of partials each on its own frame's scale, and no
one factor takes it back to their sum. The fulls carry the frame of their peak partial for the match, are
taken to the written frame before the table is made (where the .hkl took them before), and the reflection
files wait for the table before they take the fulls. --mode scale passes them too, reindexed with the
outcomes where the model moved the indexing (the .hkl now follows it as well). Stills and the
--export-unmerged-partials file stay unscaled; careless is pointed at the latter.

aimless (scaling again from the file, written range) overall / outer-shell CC1/2, Rmeas:
  in-house myoglobin (rugnux 0.998 / 0.279, 0.173): before 0.995 / 0.072, 0.269; after 0.998 / 0.264, 0.188
  in-house cytochrome c (rugnux 0.9997 / 0.200, 0.093): before 1.000 / 0.023, 0.119; after 1.000 / 0.064, 0.094
With ONLYMERGE, aimless now reads Rmeas 0.173 and 0.093, rugnux's own (before 0.348 and 0.200). 98.9% and
99.9% of the merge's observations are matched to an event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A model only scored was never tested against the twin laws of its lattice, so on twinned data it reported
an untwinned R-free with REFINE_TWIN= NOT_TESTED and no caveat. RefineModel now runs DecideTwin for a
score too: the model as given is scored against the data twinned by the law (the one it asks the largest
fraction of, where there are several) and the twinned score is taken where its R-free is lower and the law
beats its null by TWIN_NULL_Z standard errors - the refinement's rule, with each arm a score instead of a
refinement. The change is at the call site only. The verdict text names the law and fraction where the
twinned score was taken.

On a perfectly merohedrally twinned case the score now reads what the twinned refinement starts from
instead of the untwinned one; the depositions of 3r6o and 3mc4 read ZERO_FRACTION and score as before.
A score costs about twice what it did (one more score per law). Covered by Refinement_twin_decided, which
now scores the true model too: TWINNED at a fraction of 0.3, not at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A model that fits takes the written files to its own hand as a label (AdoptModelFrame, and molecular
replacement's ENANTIOMORPH decision). Where the run was given its group (-S, or the reference's), that is
the model overruling the user, and it happened with an Info line only. Both sites now warn, under the new
MODEL_SPACE_GROUP code, naming the group given and the one written.

Checked on the in-house cytochrome c set run with -S of the other enantiomorph than its model's: the
files take the model's group, and the warning fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
- Dataset info dock: title bar on top like the other docks (no vertical title bar).
- Files and Processing are both visible at start, Files above Processing with a splitter
  between them; the saved-layout version is bumped so the new default applies once.
- The dataset plots shade the images the merge rejected (light red) or down-weighted
  (light yellow), read from frameDisposition; hovering one says so in the status bar.
- Dragging on a plot selects an image range (light blue). Right-click offers "Process
  images A-B...", which opens the job dialog with start/end pre-filled; the range is also
  the default for the next Analyze dataset job and is cleared when a file is opened.
  A plain click still selects one image, now on release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQoEh4AZruktuKZQdrB
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQoEh4AZruktuKZQdrB
22% fewer target evaluations on the battery subset; refined R-free unchanged on 21 of 24 sets
(9z44 +0.004 and one weak in-house set +0.006, both predicted by the log replay). 4YBB on 4 T4:
742 s against 764 s, the refined model byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Where the space-group search refuses a higher point group G over the adopted A, every other index-2
subgroup S of G perfectly twinned by a law of its coset merges into G exactly as A does, so no
intensity statistic separates them; the model can. Following Zwart et al. (2008) and Lebedev &
Isupov (2014): refine in the candidate groups and compare.

- SiblingTwinSubgroups (TwinningAnalysis): the siblings of A in G with their coset laws; Catch2 test.
- Report: SYMMETRY_HYPOTHESES / SYMMETRY_HYPOTHESIS_n (adopted, adopted twinned, each sibling
  twinned, and whether the model tested it) on every run with a refusal. Report only without a model.
- --model: where the model's own group has a sibling's rotations on the data's axes, that sibling's
  merge is the twin hypothesis the model is scored on (twin target, twin-closed free set from the
  lattice-holohedry orbits), and the placement is refined in the model's own group against the
  model-choice file (absences of that group dropped).
- Best two into refinement: where the model fits the answer's merge and the twin hypothesis' within
  0.03 in R-free, both are refined (each with its own null-law twin decision) and the rival replaces
  the validation's choice only where it refines better by 0.01. MODEL_CHOICE_DECIDED_BY,
  MODEL_CHOICE_TWIN_LAW, RIVAL_REFINE_*.
- MR: sibling merges are searched beside the answer's (every sibling for a model without a group of
  its own) and chosen where the twinned R-free beats the answer's best by R_FREE_MARGIN. A model
  choice MR makes (another group or a twin hypothesis) is now refined in that group against its file.

Gated on the existing refusal: no new thresholds; sets without a refused promotion run as before.
On a perfectly merohedrally twinned P6x crystal with three NCS copies and strong tNCS (an NCS 2-fold
parallel to the twin axis), adopted as P3x21: --model now places and refines in P6x twinned by the
in-plane law, where the refinement in P3x21 diverged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Battery subset of 20 sets: no verdict or R-free change; new detections on cytc_x10sa_mr (its
sister runs already detected) and 9ea5 (Patterson peak 26%, z 43); 5nw5 stays undetected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The written group and the model's were compared by number, so a model in P 21 2 21 on reflections
written in P 21 21 2 (both No. 18, the screws on other axes) was still refined in the written
setting; they are now compared by their full symbol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The --free-flags row and the option-table changes go to docs/RUGNUX_CLI.md, which lists every
option since cli-help; the old usage text in rugnux_cli.cpp is kept as cli-help rewrote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A placement was refined in the merge's label group. Where the label leaves a screw open and the model
asserts one with the same rotations (a P6 label with a P63 model, P321 with P3121), the copies were
expanded by the wrong operators; where the model's group has more rotations than a twin hypothesis'
subgroup, only part of the crystal's content was refined. RefinableIn: the model's own group where it
has the merge's rotations (unless the anomalous map is inverted), the model expanded into the merge's
group (ExpandIntoSubgroup) where its group is larger. The best-two rival refinement is made only where
both merges hold the model whole that way: on the subset battery a P63 model refined in a P6 label lost
to its own P3 expansion-less twin rival before this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The refused group's representative can be given on the primitive lattice, which named primitive
siblings (P222 beside an adopted I41). The refused group is taken with the adopted group's centring
before its index-2 subgroups are listed. Report-only on every set the subset battery holds; a
sibling-group model of a centred crystal is now matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
TwinNull: the paired difference |Fo-|F_null|| - |Fo-|F_law|| is summed over each free twin pair before
its mean and variance are taken - both members share their two F_calc, so they were counted as two
independent terms and the standard error was too small by up to sqrt(2). The null's unrelated mates
are drawn from the reflection's own set (free with free, work with work), as a law's twin pairs are;
each set is ordered by 1/d^2 with a twin pair's members adjacent and swapped 2 apart, past the
reflection's own pair, because on the sparse free set the old step of 8 took the mate to another
resolution and made the null worse than it is (measured on the synthetic untwinned case: null R-free
0.048 -> 0.065, z -0.5 -> 2.4 with a step of 8 within the free set; 0.060, 1.3 with the step of 2).

DecideTwin: the clause R_free(twin) < R_free(untwinned) - 0.001 is dropped. It compares two
refinement paths and never bound on the rc181 battery (smallest margin among z >= 3: 0.0029); the
null test at z >= 3 is the decision.

The H-test line (TWIN_LAW_n, key unchanged) no longer says "twin fraction X or more": untwinned
crystals read 0.5 - H up to about 0.25, so it is stated as the fraction only where twinning alone
makes the pairs agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Conflict in RugnuxScaleMerge.cpp at the refinement: both branches refine in the model's own group
where it has the written group's rotations. RefinableIn's choice is kept (it honours the anomalous
veto on the hand); insulin-fix's leaving out of the reflections that group extinguishes is kept,
applied wherever the refinement group differs from the written one with the same rotations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Rule B of the twin-gate review. The null test alone (rule A, z >= 3) is short of power on small or
borderline data; where the data's own intensity statistics read twinned - an independent piece of
evidence - a lead of 2 standard errors is enough. The evidence is the verdict the report already gives:
TWINNING_VERDICT INDICATED, carried by the L-test (ESTIMATED_TWIN_FRACTION_FROM L_TEST), so neither a low
second moment alone nor an L-test a pseudo-translation left unreadable counts. No new threshold.

The caller decides what the data say (RugnuxScaleMerge, which holds the merge's twinning analysis) and
passes it as RefinementSettings::twin_data_indicated; the library applies the rule (AdoptTwinLaw), since
the twinned refinement it would adopt exists only inside DecideTwin. The verdict is computed by one function,
TwinningVerdictKey, which the TWINNING_VERDICT key now uses as well. --mode refine has no merge statistics
(its input may be amplitudes only), so there the null alone decides.

REFINE_TWIN_RULE= NULL | NULL+LTEST says which rule decided, where a law was tested to UNTWINNED or TWINNED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Ordering. A canonical pass with a model to validate now waits, before deciding whether it is superseded,
for the question GeometryWalkTakesRound asks: its own post-refinement fit and, for a move of less than a
step, the speculative probe (AwaitGeometryWalkAnswer). On a card too small for the probe beside the merge
the probe waited for the merge to end, so the answer could never be in before the model stage, and
validation/MR/refinement ran on a merge the walk then replaced (measured on a large-cell set: a model
validation on a wrong P1 supercell merge, OOM-killed above the logged host budget). The merge first
re-declares what it still holds of the card (GPUMerge::Holds), and the probe is waited for only where it
can then start (gpu_work_fits_beside_merges); otherwise nothing waits, as before. The cost - the probe's
remaining time - falls only on --model runs whose fit moved the detector; fast_live does not speculate,
so the broker's live mode is unchanged. Final outputs do not change: a superseded pass's files were
rewritten by the walk's pass anyway.

Model-cell check (owner approved). Where no change of basis of CellMappingOperators carries the model's
cell onto the data's (CellsCorrespond tolerance), the unplaced validation stops with cell_mismatch and a
reason naming both cells, instead of refractionalising the model into another lattice's cell. The report
says MODEL_VALIDATION=CELL_MISMATCH and warns MODEL_CELL_MISMATCH (in place of MODEL_NOT_VALIDATED).
Molecular replacement is still tried afterwards, as for any model not placed as given; placed paths
(MR, ExpandIntoSubgroup, RefinableIn) are untouched. --model stays processing-inert.
ModelValidation_WrongModelInAGroupOffTheDataMetric moves to cells within the tolerance so that it keeps
testing what it tested.

One host ledger. HostWorkerPool now knows the host budget and the bytes the run holds itself
(HostWorkerPool::Held): workers may hold the pool's size or what the budget leaves beside the held bytes,
whichever is less. ScaleMergeAndSymmetry holds its reflections, then engine + merge (+ post-refinement),
and at the model stage the reflections and the engine where the ledger merge keeps it; a validation
holds its own CPU grids; the change-of-basis frames reserve their grids per frame instead of all starting
at once. The reserved == 0 escape stays, so a worker larger than what is left still runs, alone.
Only what runs at the same time changes.

Tests: ModelValidation_StopsOnAModelOfAnotherLattice, HostWorkerPool_HeldBytesShareTheBudget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On a 16 GB card the merge engine alone (10.3 GB on a large cell) leaves no room for the speculative
probe (4.8 GB), so asking at the superseded decision before the analyses could never let the probe run
and the model stage still went ahead on the merge the walk replaced. The question now waits until just
before the model stage, where that stage lets go of the card anyway - the indexers, and the merge engine
where the finalist ledger does not keep it - and does so first where the answer still waits on the
probe (GeometryWalkProbePending, AwaitGeometryWalkProbe). A pass the walk will replace is then marked
superseded there: no model stage, no report, no files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A deposited structure-factor mmCIF marks the free set with _refln.status 'f', and the reader took that mark over
_refln.pdbx_r_free_flag. Where the flags are classes (0-19), the status can mark a class the depositors' refinement
did not hold out: the deposited model then fits the marked class like working data, R-work comes out above R-free,
and refinement seems to make the model worse.

The reader now keeps the numeric classes beside the status (RugnuxReflections::free_class, an extra FreeR_class
column in the conversion). Where there are more than two classes, RunRefineOnly scores the model as given on each
class (score-only, untwinned, no solvent build), within the resolution range the model file records its refinement
to, and counts each class's reflections in that range:

- STATUS: the marked class is the one the model fits worst, or its count is the nearest to the model file's test-set
  count (REMARK 3 FREE R VALUE TEST SET COUNT / _refine.ls_number_reflns_R_free);
- REMARK3_COUNT: else the worst-fit class, where its count is the nearest;
- WORST_FIT: else the worst-fit class, where it is above the marked one by more than twice the scatter of the other
  classes' R (the marked class is fitted like a working one); otherwise the mark stands.

The count is corroboration only: over the battery's PDB cache (106 files with 20 classes) the marked class is within
a few reflections of the count on nearly all, and another class has the count exactly on about 7 of them (20 classes
of about the same size), so the count alone would move correct marks. The choice is logged with R per class and
reported as REFINE_FREE_SET_SOURCE / REFINE_FREE_SET_CLASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
depositor_data took _refln.status 'f' first, so where the flags are classes (0-19) and the status marks a class the
depositors did not hold out, REFMAC's depositor-flag and depositor-data R-free were taken on working reflections.
free_class applies the rule of RunRefineOnly (ChooseFreeClass): the mark stands where the deposited model fits it
worst or its count is nearest the entry's test-set count, else the worst-fit class is taken where the count is
nearest it or it stands above the mark by twice the classes' scatter. The fit here is gemmi's Fcalc without bulk
solvent, an overall scale and B, on the reflections finer than 6 A within the refinement's range.

Over the 119 multi-class structure-factor files in the battery's PDB cache this changes the free class of one entry
(5nw5: the class whose measured reflections match the entry's test-set count exactly, where the status marks
another); the depdata/depflags REFMAC numbers of the others are unchanged. Kept as its own commit: it moves battery
REFMAC numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A first-copy placement that clashes too much is never refined, and its R-factors were printed as "R-work nan R-free
nan"; a further copy whose translation-function Z already stands out has its likelihood Z skipped, printed as "refined
nan Z nan". Neither is a failure, so the log now says so. Where none of a copy search's trials stands out, packs and
lowers R-free, the search stopped without a word; it now says how many trials there were and how many copies were
kept. Log text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The FFT search reaches 500 A (DeAlias unfolds to 1000 A), so a longer axis is never a candidate. The old
rescue that widened the bound ran only when fewer than half the validation frames indexed, kept the
coarse lattice with the LONGEST axis, and adopted it on frame count. So a hexagonal crystal with a
~1000 A c axis, indexing most frames on a spurious shorter c, never asked it, and a large-cell
orthorhombic crystal was given a ~4x-volume triclinic cell where the true cell puts about twice the
share of the spots on itself.

LongAxisHypothesis replaces it:
- asked whenever D * lambda / (pixel * r80) - the longest axis whose rows the detector separates, r80
  the pre-scan's spot width - exceeds the standard bound; the coarse search (3.5 A) reaches that
  length, at most the indexer's 1200 A limit. No frame-count gate. Not asked when a cell is given.
- every lattice the coarse schemes return, other than the standing one, is re-indexed constrained by
  its cell and counted on the pooled validation spots beyond chance; LongAxisChoice takes the largest
  excess that ValidationEvidencePrefers over the standing lattice, never an exact superlattice of it
  (the pooled count is biased toward the larger of an exactly related pair; left to the merge's
  supercell probe). Near ties are decided late on all the frames.

Both cases above now index on the true cell at the first pass. The hypothesis costs about 0.02-0.7 s
per first pass where it is asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
- LATTICE_EXPLAINS_FEW_SPOTS: the main lattice holds less than half of the non-ice spots and a leftover
  lattice that is not a domain shares two of its axes (2 %) - the census of a cell with one axis wrong,
  whose spots along the missed row split into several lattices with the same two axes. Report only; on
  the census rows of the last full battery it would name about six sets.
- A spot with a saturated pixel carries that pixel as INT32_MAX, the spot finder's mark, so its summed
  intensity is not a measurement, and a single such spot can outweigh all the others together:
  MAIN_LATTICE_INTENSITY_PCT then read near 0% or near 100% by whether that one spot was on the
  lattice. The census leaves such spots out of both sides of the intensity shares (still counted as
  spots).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Since fdafc009b J^T J and J^T r are summed in float. A fit that walks to a nonsensical scale (k_overall < 0,
B of thousands of A^2, as fits of an unrelated model can in the solvent grid) makes |F| k_aniso small enough
that every square underflows to exactly 0 in float, while J^T r keeps a residue near 1e-36. JordanSolve then
threw "Trying to reverse singular matrix. Column 0 is zeroed." out of ValidateAgainstModel
(ModelValidation_CCModelUnrelatedModelConfirmsNothing). In double the squares stayed near 1e-72 and the
solve went through. LevMarFit in ModelScaling.cpp already stops at alpha[0] == 0 for the same case in
double; the GPU run now does the same check before the solve.

Only fits that would have thrown are affected; no kernel changes, deterministic as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The unimodular set of CellMappingOperators does not reach a centred cell from its primitive one, so the
cell check took a P21 model of a C-centred orthorhombic lattice (its twin subgroup's primitive cell,
same primitive volume) for another lattice, and the run lost the twin-hypothesis placement it had made
before (open battery set, MODEL_CHOICE P21). A mismatch now also needs the primitive volumes to differ
beyond what CellsCorrespond's 5% in three lengths allows; the four-fold supercell and the third-longer
axis are still caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The choice was made among the trials alone: the R-free of the model as given was recorded but never competed, so a
refinement whose every trial raised R-free still replaced the model (a related model that cannot be refined to a
good R came out above its starting R-free). The model as given, scaled to the data (the scale and bulk-solvent fit of
the start), is now the first candidate in order of trust, judged by the rule every trial is held to: the refinement
is kept where its geometry is within bounds and its R-free lower beyond the tie, else the model is given back as it
came. The solvent build edits the model in place (waters put in, sites relabelled), so its atoms and residues as
given are kept and put back, with the placeholder pool and the relabels, and the solvent build is reported as not
kept. REFINE_AS_GIVEN says which; the twin decision then compares the arms as they ended, as before.

Only where the free set is free to the model as given (RefinementSettings::as_given_competes): a model placed as
given on a free set drawn by the run (the MODEL_FREE_SET case; in --mode refine, a file without free flags) has most
likely been refined against those reflections, its R-free as given is close to a work R, and keeping it by that
number would hand back the deposited model. On a first subset battery without this condition the model as given was
kept on four open-arm sets with R-work then above R-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The refinement's and model validation's _maps.mtz carried 2mFo-DFc and mFo-DFc as signed numbers on the model
phase, so DELFWT (and FWT where DFc > 2mFo) went negative. Coot reads that, but an MTZ amplitude column is
non-negative by convention and other readers need not cope. The structure factors - and so every map made from
them, in rugnux and in the broker - are the same numbers.

The phase columns are also wrapped to [0, 360) after the rounding to float, which could give exactly 360.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Merge twin-gate into rc181: twin decision against a per-pair null (rule A) and taken at 2-3 SE where the L-test reads twinned (rule B)
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 26m4s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 28m5s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 31m0s
Build Packages / Generate python client (push) Successful in 30s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m51s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 26m48s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 29m20s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 33m9s
Build Packages / Unit tests (push) Successful in 1h36m58s
Build Packages / Validation Battery (push) Successful in 51m10s
Build Packages / Create release (push) Successful in 7s
Build Packages / Build documentation (push) Successful in 47s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m42s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 23m47s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 26m16s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 33m41s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 31m13s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 33m44s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m32s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 23m19s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 30m53s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m30s
b13f1bfd2d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37

# Conflicts:
#	rugnux/RugnuxScaleMerge.cpp
The displacement model is chosen on R-free (beyond the tie) and -ln L of the free reflections together, but the
fewer-trials rule ran the U at its balanced weight only where the U at a quarter of it lowered R-free. Where the
quarter fitted the free reflections better than where the weights ended without lowering R-free, the balanced weight
was left out, and with it the trial the choice would have kept: on a twinned refine-only case the quarter had the best
-ln L free of the displacement trials and an R-free a little above the isotropic one, so isotropic was kept, while
the balanced weight - run before the rule - lowers both and is kept. The condition is now the decision's own: R-free
or -ln L free lower than where the weights ended. The -ln L free there is logged.

Subset battery (5 refine-arm, 9 open, 2 inhouse sets) against rc181 e56b19abb: refined R-free identical on all 16;
the balanced-weight U trial ran on one more set (both twin arms, no choice changed); target evaluations
49023 -> 49339 (+0.6%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The inputs are planned as sweeps (RugnuxSweeps): one per CBF/marCCD/SMV file-name template - the name
with its frame number taken out, so any frame of a sweep names it and `rugnux a_*.cbf` is the one sweep
a, where several frames of one sweep used to be refused - and one per rotation master. Several stills
masters stay one stills run as before (the first master decides, and the stills reader refuses a rotation
one among them); a rotation master among stills is refused, and so are sweeps at different wavelengths.
One input is not planned at all. Other sweeps beside the given ones are named in the log and not
processed.

--sweeps separate|joint. separate processes each sweep alone, as if it were the only input, into
<prefix>_s<k>_* with its own log and report; a sweep that fails is reported and the others go on. joint,
the default, refuses with several sweeps until the joint pass exists. -s/-e and --force-still are
refused with several sweeps.

rugnux_cli.cpp: the one-input code is taken out of RunRugnux. The options and what RunRugnux set up before
parsing are members of RugnuxCli (Parse), and one input is a RugnuxInputRun copied from it, in two halves:
PrepareRugnuxInput (reading the input, the experiment and the run's configuration) and RunPreparedInput
(running it and writing its files); the locals the second half reads are members. The code itself is as
it was but for exit(EXIT_FAILURE) in the per-input code, which is now an exception the caller turns into
the exit code, so that one sweep's failure does not end the others. With one input the outputs are
identical: report (but for the times and command line) and .hkl byte for byte against the rc181 binary on
a CBF small-molecule sweep and an NXmx rotation set, and on the first sweep of a --sweeps separate run.

Tests: RugnuxSweepsTest (templates, single input untouched, frames by template, masters rotation /
stills / mixed / wavelength, the stills readers and mixtures left alone, sibling sweeps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
For the joint merge of several sweeps (still to come): the engine can be given SweepSegments, the
frames of several sweeps laid end to end whose observations are fulls already combined in their own
sweep. Such an engine runs on the host and
- combines nothing again (one observation, one full), recomputes no partiality or rocking geometry,
  and measures no incident flux across sweeps;
- does not fit a scale on partials (there are none);
- smooths the per-frame scale of the fulls within each segment on its own (SmoothLogScale with its
  own cross-validated smoothness), so nothing couples across a segment boundary;
- leaves out what runs along the frames of one sweep in one setting - the damage monitor, the sweep
  ledger and its delta-CC1/2, the per-batch B, the absorption and modulation surfaces;
- fits each segment's relative B against the joint merge, orders the segments by it - the dose order
  is read from the data, not from the input order or header times - and fits the decay along that
  dose axis (the frames of the segments laid end to end in that order);
- reports per segment its scale, relative B and dose rank (Result::segments).
Without segments nothing of this runs: the arithmetic is unchanged.

Tests (RotationScaleMergeSegmentsTest, synthetic): a sweep at half the scale recovered within 3% and
the frames on either side of the boundary on the common scale; three sweeps given in the order B = 8,
0, 4 A^2 put in the dose order 0, 4, 8 with relative B 7.5 and 3.7; segments that do not cover the
frames refused; and the engine without segments gives the merged I and sigma of rc181 to the bit
(hash recorded on the unmodified engine, CPU and RTX 5080). rugnux on a CBF small-molecule sweep and an
NXmx rotation set (CPU) and the CBF sweep on the GPU: report and .hkl identical to the rc181 binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Two hypotheses of how damage accrued over several sweeps: a B rising linearly along the dose order the
segments' relative B factors put them in, or one B per sweep - a step at each boundary, flat within a
sweep. Both are regressions of ln(I_ref / I_obs) on s^2 within each frame (each frame has its own scale,
which took up its part of the B), at the weights the single-sweep slope fit uses. Each is fitted on the
even frames and scored by its weighted squared residual on the odd ones, against their own reference,
and the other way round; the one that predicts the held-out frames better is applied where it beats no
decay by the margin and physical floor a correction has to clear on one sweep. The per-sweep B is
anchored at the frame-weighted mean, as the ramp is at the dose centre. The residual is read at weights
fixed by the observations, so a correction cannot win by reshaping sigma; the sigma-free disagreement
the single-sweep decay is gated on favoured the smaller correction (on three sweeps at B 0, 4 and 8 the
right per-sweep B scored worse than a quarter of it), so it is not used for this choice. Result reports
the choice (segment_decay_model). Without segments nothing changes.

Synthetic test pairs: three sweeps each flat at its own B (8, 0, 4 A^2): held-out residual 33.1% of no
decay for the ramp, 22.4% for one B per sweep => per-sweep B. Two sweeps of one steady ramp (6 -> 12 and
0 -> 6, given in that order): ramp 30.7%, per-sweep 47.3% => dose-linear, dB 10.2 A^2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A CBF with no PILATUS "# " lines but the imgCIF axis, scan and array tables
(CBFlib's own form; what Bruker APEX writes when it converts frames) is now
read through the whole axis chain: the detector plane from three pixel
centres (2theta arm, mounting tilts, stored-image orientation), and the
rotation axis as the scanned axis carried by every axis it depends on - a
phi scan on a kappa head turns about phi as omega and kappa hold it. Every
frame is checked to turn the same axis about the same direction, so a series
whose fixed axes moved between frames is refused as more than one sweep. Two
axes moving at once, a moving detector or translation axis, and a series of
stills are refused with a message. _array_structure_list.direction is not
consulted, as CBFlib does not: read by the dictionary the Bruker conversions
are mirrored and index nothing, read as CBFlib does they index (DIALS indexes
the same files 92%). A CCD-named imgCIF is ADU, so the background gain is
measured as for SMV/marCCD.

A miniCBF phi scan at a non-zero "# Kappa" is refused: the header does not
describe the arm, and the axis was being read uncomposed. No CBF set in the
battery is affected (all kappa != 0 sets are omega scans).

New Bruker .sfrm reader (FORMAT 86 ASCII overflow table; FORMAT 100 underflow
and 16/32-bit overflow tables, the 32-bit table signed; baseline NEXP[2]
subtracted). The geometry is the imgCIF chain APEX's conversion writes for the
same frame, which on an APEX II gives identical pixels (less the baseline) and
the identical rotation axis for a kappa phi scan, with omega in the sense of
the 2theta arm (an APEX II omega run converts with the opposite increment; a
SMART 6000 omega sweep indexes only that way) and CENTER read directly in
stored columns/rows (spot refinement moves APEX's own conversion back to it by
~5 px; a SMART 6000 sweep merges at R_meas 17.9% read directly vs 30.3% the
conversion's way). Wired into rugnux and the viewer; battery discovery knows
the format.

The goniometer axis is passed as GoniometerAxis expects it (minus the
right-handed direction, the sense the crystal is turned by in IndexAndRefine);
without that every one of these sweeps needed the axis-sign rescue.

Tests: BrukerReaderTest (synthetic full-imgCIF kappa/2theta sweeps and the
refusals, SFRM tables and geometry), JFJochCBFReader_phi_scan_at_kappa_is_refused;
the existing CBF reader cases pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Manifest rows and EXTERNAL_TEST_DATA entries for two Raw Data Letter small
molecules (L-aspartic acid: index-2 reticular twin, APEX full-imgCIF kappa
run; gamma o-nitroaniline: pseudo-merohedral, read from the .sfrm frames) and
the two SMART 6000 non-merohedral protein twins of Sevvana et al. (2019):
6OR0 (two domains) and 6OQZ (three crystals, the set for multi-lattice
integration). The protein rows run the low-2theta sweep: these frames read with
the header distance give cells ~4% short, which a 2theta = 0 run absorbs into
the cell scale and a swung run does not. o-nitroaniline carries no reference
(the letter's cell could not be retrieved).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
Bruker frames state a negative increment (INCREME -0.2). Passed on as it was, the frame's oscillation
width was negative, and the per-frame mosaicity fit, which takes it as the frame's width, ran to its
2 degree cap on every Bruker sweep: rocking curves 25 frames long, partialities a tenth of what they
are, and on a sparse sweep no rocking event captured enough of itself to make a full, so the merge was
empty. The same rotation is now stated about the opposite axis by the opposite angles (imgcif::SweepAxis,
for the SFRM reader and the full-imgCIF CBF path). On the SMART 6000 2theta = 0 run of a twinned cubic
insulin crystal: mosaicity 2.000 -> 0.098 deg, R_meas 26.5% -> 5.9%, ISa 5.4 -> 23.8; the distance
post-refinement, which refused before, now commits 103.0 -> 106.5 mm and the cell comes out 77.5 A where
it was 75.1 (deposited 78.0) - the header distance of these frames is short, and the data now say so.
An APEX II sweep that merged nothing (empty merge, crash) now merges at ISa 19.

Polarization: a full imgCIF header's _diffrn_radiation_wavelength.polarizn_source_ratio is read as the
degree, in the plane its polarizn_source_norm states. An SFRM frame states the value APEX's conversion
writes for it, 1 - cos^2 of the monochromator angle (FILTER2: 0.0444 at 12.17 deg), so an .sfrm sweep and
its imgCIF conversion agree; with no monochromator (mirrors) that is 0. These are laboratory frames, which
before took the 0.99 synchrotron default.

--gain is accepted for a CCD imgCIF (CountsInADU), as for the other formats in detector units.

The CBF test now decodes through LoadImage: since rc181 ReadRawImage hands the byte-offset stream on
undecoded.

Tests: JFJochCBFReader_FullImgCIF_Polarization, JFJochSFRMReader_Geometry (backward sweep, FILTER2,
no-monochromator polarization), the existing Bruker and CBF reader cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The beam centre was read in stored columns and rows, where APEX's imgCIF conversion of the same frame
counts each coordinate from the far edge. The reader's choice was settled on sweeps that the negative
increment (previous commit) had broken; with that fixed, the conversion's placement is the better one
on both detectors. An APEX II phi sweep of a pseudo-merohedral twin: indexed 90.3% -> 97.4% of frames,
R_meas 22.0% -> 4.8%, ISa 2.1 -> 16.3, and the merge no longer takes the twin's orthorhombic
pseudo-symmetry (Cmcm -> P1, the conversion's own .cbf result to the decimal). A SMART 6000 omega
sweep: R_meas 5.9% -> 5.1%, ISa 23.8 -> 24.3; the centre refines to the same place from either start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A lattice that does not describe the frames can merge nothing - no rocking event recorded whole enough
to make a full - and the run then stopped on "RotationScaleMerge: resolution calculation failed". The
message now says that nothing was merged and why that usually happens. The empty merges seen on Bruker
swung runs were the negative-increment defect, fixed in the reader; this is only the message.

EXTERNAL_TEST_DATA: the 6OR0 row says what is now true of the SMART 6000 distance - the header distance
is ~4% short of what the data give, the refinement corrects it on a 2theta = 0 run and not yet on a
swung one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37

# Conflicts:
#	image_analysis/structure_refinement/Refinement.h
#	rugnux/RugnuxScaleMerge.cpp
Two levels, as XSCALE merges the data sets XDS made of each sweep. Each sweep is processed on its own
first (a member run: everything a run does, writing only <prefix>_s<k>_log.txt, without the model),
and its written merge hands back all of its fulls - past its resolution cut, with the reflections its
group extinguishes, and with the counting variance the merge rebuilds them from (ScaledFull var_bkg,
var_per_I, capture, d). RelateJointSweeps puts every sweep on the axes of the one with the most fulls
by ChooseReferenceSetting (axis permutations and the lattice's twin laws, scored by the merged-intensity
CC); a sweep below MIN_REFERENCE_CC on every axes is left out and named. BuildJointMergeInput lays the
sweeps' frames end to end, each full a pre-combined observation whose count, correction and variance
reproduce what its own merge gave it, and Rugnux::RunJoint runs the one pass of the pipeline on them:
no image is read, the outcomes are the indexer's, and the unchanged ScaleMergeAndSymmetry makes the
merge (RotationScaleMerge with the sweep segments, now taken from ScalingSettings::SweepSegments), the
space-group search, the cut and everything after it, into <prefix>.*. The run's log is opened first and
holds the sweeps' relation and the joint merge. Not yet: the unmerged MTZ of a joint merge, the per-sweep
report keys, and joint geometry (P1d / phase 2).

Single-sweep runs are unchanged (RunJoint's branches in RunPipeline are only taken by it; the exports
are only asked for by member runs).

Test pairs (rugnux joint against the best sweep alone, and against XSCALE, at the XDS range):
- in-house lysozyme, two 450 x 0.2 deg sweeps: P41212 (both sweeps alone too); to 1.85 A fitted against
  1.91-1.94; at 50-1.97 A R_meas 14.5% against 14.9%, I/sigma 14.1 against 9.4, ISa 24.4 against 20.8;
  XSCALE of the two XDS sweeps: R_meas 17.4%, I/sigma 9.7, ISa 16.0 / 15.2.
- 8C3E, four short CCD sweeps of a twinned P3121 crystal: the joint merge determines P3121 (the
  deposition), where the sweeps alone give P321, P6222, P321 and P622; completeness 98% against 96%.
- 5JVN, two 3600-frame sweeps: P622; to 2.32 A against 2.35; at 50-2.62 A R_meas 16.6% against 13.7%,
  I/sigma 19.7 against 17.0, ISa 11.2 against 15.3; the depositors' XSCALE: R_meas 15.5%, ISa 15.3 / 13.5.

Tests: RugnuxJointSweepsTest (a sweep indexed by the twin law put back, another crystal left out, the
segments and the pre-combined observation's count and variance).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
# Conflicts:
#	rugnux/rugnux_cli.cpp
With the SFRM reader and the sweep planner on one branch, an .sfrm frame names its sweep as any other
frame file does: several runs of SFRM frames are several sweeps (one per file-name template), read with
the SFRM reader for their rotation and wavelength, and their unlisted neighbours named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Unmerged MTZ of a joint merge (JointUnmergedMtzTable): its scaled fulls, each in the batch of its own
sweep's frame - sweep s of the merge is batches s * base + frame + 1, base the power of ten past the
longest sweep (1001.., 2001.. for sweeps of up to 999 frames) - under the batch headers that sweep's own
run wrote (its orientation, axis and angles), one for every frame of it (a frame the sweep's file has no
header for takes its nearest, turned by the frames between). Lattice-centring absences left out, as in
a single sweep's file. On the in-house lysozyme pair POINTLESS reads two runs of 900 batches and P41212,
and AIMLESS (onlymerge) merges them at R_meas 0.180, I/sigma 10.5, CC1/2 0.999 - rugnux's own numbers.

Report section 1b. SWEEPS, on a joint merge only: SWEEP_COUNT, SWEEP_REFERENCE, SWEEP_DECAY_MODEL, and per
sweep SWEEP_k_INPUT, IMAGES, INCLUDED, SPACE_GROUP_ALONE, CELL, CC_HALF_ALONE, COMPLETENESS_ALONE,
RESOLUTION_ALONE, REINDEX_OP, REINDEX_CC, BATCH_FIRST, BATCH_LAST, SCALE, RELATIVE_B and DOSE_RANK (the
joint merge's segment results, now carried to ProcessResult); a SWEEP_LEFT_OUT warning names a sweep given
but not merged. A run of one input has no such section or keys. Documented in RUGNUX_REPORT.md and
RUGNUX_CLI.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
With more than one thread, Combine() split its runs into per-thread chunks of
ceil(n_run / nt) with nt = min(nthreads, n_run); with no runs at all (a lattice
that integrated nothing) nt was 0 and the process died on SIGFPE in Step 5.
The empty case now takes the serial path, and the merge ends in the ordinary
exception. Test: RotationScaleMerge_NoReflectionsIsAnErrorNotACrash (fails
without the fix, on the host path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On a sparse rotation sweep (a few real spots per frame among many stray ones) the FFT, whose floor is
10 A, returns a cell that is a multiple of the true one - doubled along every axis, or the coincidence
lattice of two twin domains at 5 or 10 times the volume - and the frame counts that judged the
alternatives favour it, because its extra nodes catch the stray spots that put frames over the bar.
Measured on the public L-aspartic acid set (APEX II, P21 5.12/6.92/7.60, a ~50:50 non-merohedral twin,
twofold about c*, domains coinciding on h even): every one of its seven runs merged in an 8x or 10x
P1/C2 cell or not in the true one.

- Sub-lattice hypothesis: decided by a paired test on the pooled validation spots - the spots on the
  standing cell alone against those on its index-2 sub-lattice alone, less what the extra nodes take
  at a wrong spindle angle (SubLatticeSuffices, McNemar). The sub-lattice is adopted unless the
  standing cell's extra nodes hold spots of their own; before, it had to beat the standing cell by
  3.3 sigma on an unpaired comparison, which a sub-lattice of a supercell cannot do. Asked again of
  each adopted sub-lattice (index 8 at most). The nominee needs a majority of the frames OR as many
  frames as the standing cell (it needed both).
- Twin-composite hypothesis: asked at every level of that descent, before the halving (two domains
  take fewer nodes than any halving of their composite, and a halving can split it); indices 2..10,
  every index-n sub-lattice as a form mod n (SubLattice gains the general case of a form with no
  coefficient prime to n); only congruent pairs are compared, congruence allowing the axis reversals
  that turn two angles into their supplements (a monoclinic domain's reduced cell comes out with
  beta 100 or 80 deg); the domain is adopted when it beats chance on the pooled spots, not on a
  majority of frames. A domain cell that is itself a multiple is halved on, its overlap form held.

laspartic runs 01-07 (CPU), base 856ad5106 -> this: all seven P2 5.11-5.14/6.91-6.93/7.58-7.60
beta 99.7-100.5 (base: 10x P1 x2, 4x C2 x3, true cell x2); SHELXL R1 against COD 1577465
3.6-7.2% on all seven (base: no cell match x5, 17.5% and 19.4% where the cell was right but the twin
overlap was merged in). The screw is not determined yet: the overlap exclusion takes 0k0.

Tests: SubLatticeSuffices, TwinCompositeAt (synthetic monoclinic twin at index 2 and through a skewed
index-10 supercell).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
(cherry picked from commit f8544981ab647cb7c350f9d73c81d499e3d330b0)
The twin-composite hypothesis leaves every reflection the other domain overlaps out of integration.
On a monoclinic crystal twinned about c* (domains coinciding on h even) that takes the whole 0k0 row,
so the screw along b could not be read and a P21 crystal was written as P2 with the screw undetermined.

Of the overlapped reflections, those along a cell axis perpendicular to the other two whose twin partner
is the reflection itself or its Friedel mate coincide with it EXACTLY - the twin takes that axis onto
itself or its reverse - and are kept. Elsewhere the coincidence of a twin by reticular pseudo-merohedry
is only approximate (the partner's node sits beside the reflection by an offset that grows with the
index): keeping every overlapped reflection whose partner rounds to itself or its Friedel mate was
tried and raised SHELXL R1 on L-aspartic acid from 3.6-7.2% to 5.7-14.1%.

A kept reflection holds the whole crystal's intensity and every other one its domain's share, so it is
scaled by that share: 1 - the twin fraction, the other domain's share of the summed intensity of the
spots on one domain alone. Unscaled, the three 0k0 reflections of run 02 alone raised R1 from 3.6 to
4.6%.

The twin rotation is the rotation between the two domains' reduced bases (TwinCompositeEvidence::
rotation, carried in RotationIndexerResult::TwinOverlap); the partner's index is read through it.

laspartic runs 01-07 (CPU), against f8544981a: P21 on 01/02/03/04/06 (was P2, screw undetermined);
05 and 07 stay P2 - their 0k0 row holds too few reflections or none (rugnux's own warning). SHELXL R1
vs COD 1577465: 6.24/3.92/4.50/6.48/7.20/5.13/5.31% (was 5.48/3.63/4.28/5.65/7.20/5.22/5.31%).

Test: TwinCompositeAt checks the twin rotation (a twofold reversing b) and the twin fraction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
(cherry picked from commit 15bd8c39a8726c00daee9e4ec9b9e6f0d42e0124)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
ParseHeader decides between the PILATUS header and a full imgCIF description on whether a
"# Pixel_size" line is present, but asked Match for it with a pattern that has no group, and Match
returns the first group or nothing - so every miniCBF was taken for imgCIF. A header with the axis and
scan tables (Diamond's) then parsed as imgCIF with no detector type, and the reader refused it:
"Detector description cannot be empty" (lalanine, cytidine, dnba of the battery's open arm).

Test: JFJochCBFReader_PilatusHeaderWithScanTable (fails without the fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Each displacement-model trial (TLS cuts, the model's own groups, U at two weights) was a whole refinement:
the positions (30 iterations a cycle), the displacement parameters, a macro-cycle longer than a step. The
positions were 43% of a TLS trial's evaluations and 60% of a U trial's, and the third cycle moved R-free by
~0 on the traced battery. Now the trials refine the displacement parameters alone, on the positions where the
weights ended, for as long as a step - so they differ in the displacement model only - and the model chosen
then takes one step with the positions refined, kept where it displaces its trial.

Held positions alone lost where a displacement model is a large change (a TLS model 0.044 below the state:
+0.009 R-free; a U model 0.037 below: +0.003); the step with the positions gives that back.

Battery subset against rc181 297ef9dc7 (28 sets, 50 refinements, refine arm and 4YBB included):
R-free mean -0.0011, 13 lower by more than 0.002, none higher by more than 0.0015 (worst +0.0015, +0.0007 on
one refine set); refine arm -0.0016 / 0 / +0.0007 / -0.0009 / -0.0002, 4YBB -0.0004 with the same model;
target evaluations -22%. 4YBB displacement phase on one RTX 5080: 1431 -> 509 evaluations for the trials, plus
one step of the model chosen. Trial placement and order are unchanged; the outcome is the same for any number
of slots and cards (Refinement_trials_run_ahead passes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A trial that holds the positions keeps the geometry where the weights ended; where that is out of bounds, no
held trial can be within bounds and none can displace it - while a trial that refines the positions can bring
the geometry back (rmsd bonds 0.036 -> 0.019 on the synthetic twin of Refinement_twin_decided), and did before
365e496c0. There the out-of-bounds refinement then lost to the model as given, and the twin test with it
(null z 5.7 -> 2.6). So the positions are held only where the geometry is within bounds where the weights
ended; elsewhere each trial is as it was (positions refined, a macro-cycle longer than a step) and no step
follows.

None of the 50 refinements of the battery subset had its geometry out of bounds there, so its comparison
stands. The [Refinement] tag passes (26 cases, CPU).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Merge adp-trials-held fix into rc181: positions are held in the displacement trials only where the state is within the geometry bounds
Build Packages / build:portable:macos-arm64:nocuda (push) Failing after 2m23s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Failing after 9m15s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m55s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Failing after 8m39s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 28m28s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 34m39s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 36m26s
Build Packages / Validation Battery (push) Failing after 6m44s
Build Packages / Create release (push) Successful in 8s
Build Packages / Build documentation (push) Successful in 45s
Build Packages / build:portable:linux-x86_64:nocuda (push) Failing after 4m30s
Build Packages / build:jfjoch:rocky8:nocuda (push) Failing after 7m51s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m3s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 20m19s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 34m9s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 35m12s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 29m53s
Build Packages / build:jfjoch:rocky9:nocuda (push) Failing after 8m37s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 15m12s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m8s
Build Packages / Unit tests (push) Successful in 1h30m22s
39d908427f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Revert the sparse-twin merge (67cc39f4e): its sub-lattice test took half of a correct protein cell at the re-index after geometry refinement on six open sets and one more, so it waits for a fix
Build Packages / Build documentation (push) Successful in 46s
Build Packages / build:portable:macos-arm64:nocuda (push) Failing after 2m20s
Build Packages / build:portable:linux-x86_64:nocuda (push) Failing after 5m0s
Build Packages / build:jfjoch:rocky8:nocuda (push) Failing after 8m15s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 33m25s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 29m56s
Build Packages / Validation Battery (push) Failing after 6m39s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:jfjoch:rocky9:nocuda (push) Failing after 8m27s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m58s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Failing after 9m28s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Failing after 8m35s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 13m56s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m5s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 26m3s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 25m1s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 27m14s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 33m18s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 35m41s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 35m55s
Build Packages / Unit tests (push) Successful in 1h28m57s
Build Packages / Generate python client (push) Successful in 32s
d06209d79a
This reverts commit 67cc39f4e, reversing changes made to 9499d7760.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
ModelValidation: GBytes is defined outside the CUDA-only block, so the build without CUDA compiles again
Build Packages / Create release (push) Successful in 8s
Build Packages / Build documentation (push) Successful in 45s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m24s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m34s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 16m2s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 28m40s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 29m32s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 31m34s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 25m43s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 28m14s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 29m17s
Build Packages / Unit tests (push) Successful in 1h36m5s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 21m41s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 24m13s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 27m23s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 27m1s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 23m2s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 26m46s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 27m4s
Build Packages / Validation Battery (push) Failing after 6m35s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 32m16s
1f0e5ae333
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
CI: the Windows rugnux zip check looks for the viewer's executable, not the name, which the packaged JFJOCH_VIEWER.html also matches (-match ignores case)
Build Packages / Build documentation (push) Successful in 46s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m20s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m15s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 17m16s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 23m57s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m48s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 29m11s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 26m33s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m1s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 28m13s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 29m49s
Build Packages / Unit tests (push) Successful in 1h39m48s
Build Packages / Validation Battery (push) Failing after 46m29s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 24m15s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 28m15s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 30m36s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m56s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 29m36s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 24m51s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 30m26s
Build Packages / Generate python client (push) Successful in 26s
67e9c84bd1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
An exception escaping a Qt slot terminates the viewer. At the beamline it died with
"Input parameter invalid (Image out of bounds)": the grid-scan map in the dataset-info
panel asked GridScanSettings for the cell of an image outside the grid. Image and dataset
can disagree - live, the broker's image and dataset are fetched apart and can belong to
different collections, and the worker re-emits its current image against a newer dataset.
The map now marks no cell for such an image.

Opening a file with an image number it does not have (a D-Bus caller keeping its
position from the previous dataset) showed no image and kept the previous file's image,
which was then re-emitted against the new dataset. The file now opens at image 0 and the
previous image is dropped.

The rest of the viewer was audited for the same failure and guarded:
- worker slots that were not covered: dataset update, azimuthal/Bragg settings, analysis
  (Analyze, re-analysis toggle, spot-finding settings), beam-centre fit (incl. the setters
  rejecting a diverged fit), user mask update/save/upload, ROI definition, run removal;
  azimuthal settings with more bins than the mapping supports are refused up front, so they
  cannot be kept and fail every later file open;
- settings dock: azimuthal Q range, Bragg radii and experiment fields are built on a copy
  and an invalid combination keeps the previous settings; R1 and cell-angle field ranges
  now match the setters;
- azimuthal-image double-click on a bin with no pixel, azimuthal ROI handle dragged onto
  the beam centre (infinite d), ROI editor given inf/nan, non-numeric
  JUNGFRAUJOCH_HTTP_PORT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/PUBLICATIONS.md: how to cite (Rugnux: bioRxiv 2026 preprint; Jungfraujoch: J. Synchrotron
  Rad. 2023 stays primary, J. Appl. Cryst. 2026 for real-time serial / live indexing) and the
  paper list (IUCrJ 2023 MAX IV, Struct. Dyn. 2020, TORO J. Appl. Cryst. 2024); linked from the
  toctree, ACKNOWLEDGEMENT, README, RUGNUX and JUNGFRAUJOCH pages. All DOIs checked on Crossref.
- Landing page: a Publications band with two typeset title-page cards (Rugnux preprint and the
  J. Appl. Cryst. paper, with the 2023 primary reference), nav and footer links; HTML/CSS only, no
  logos or images.
- rugnux prints a one-line citation in its startup banner (fits the 78-column console) and the
  full reference ("If you find rugnux useful, please cite ...") at the head of _report.txt.
- Files jfjoch_writer writes from Rugnux live mode name both programs: the MTZ files get a
  "From Jungfraujoch <version>, data collection" HISTORY record after Rugnux's (and a Rugnux
  record where the map tables carry none), the placed/refined model mmCIF get Rugnux
  (phasing/refinement) and Jungfraujoch (data collection) in _software. The round-trip tests
  expect those additions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrMy3Kh9NQw823gEb
The primary Jungfraujoch reference gets a card of its own between the Rugnux preprint and the
J. Appl. Cryst. 2026 paper, whose card no longer carries it as a footnote. The cards now stack the
title page above the text, three across on a desktop and one per row on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrMy3Kh9NQw823gEb
The J. Synchrotron Rad. and J. Appl. Cryst. cards show the paper's first page, the Rugnux card
Fig. 2a of the preprint (the open datasets by source), each a ~20-30 kB WebP credited CC BY 4.0
under it, in place of the title pages drawn in HTML/CSS. All three are CC BY 4.0 (Crossref licence
records, and the pages say so); the IUCr pages were rendered from the PMC open-access PDFs. The
images are added to LANDING_FILES so the documentation build copies them beside index.html.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrMy3Kh9NQw823gEb
A wrong --model whose cell is not the data's lattice added MODEL_CELL_MISMATCH to the warnings, so
VERDICT, VERDICT_TEXT, PATHOLOGY_FLAGS and WARNING_COUNT depended on which wrong model was given
(21 of 25 wrong-model null pairs failed on it; data files were identical in all 25). The model
section keeps MODEL_VALIDATION=CELL_MISMATCH and its reason; the null scorer's (c) now accepts
CELL_MISMATCH with no molecular-replacement placement as the model recognised as wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rugnux report: a model that was not placed leaves every line outside MODEL VALIDATION as a run without one writes it
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m25s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m16s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 18m34s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m25s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 29m29s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 26m48s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 30m12s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 28m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 30m58s
Build Packages / Unit tests (push) Successful in 1h36m50s
Build Packages / Create release (push) Successful in 8s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m52s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 29m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 29m44s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 30m44s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 24m37s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 25m28s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 29m32s
Build Packages / Build documentation (push) Successful in 46s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 26m27s
Build Packages / Validation Battery (push) Successful in 46m40s
bf0f959000
ProcessResult::ModelPlaced() (MODEL_PLACEMENT RIGID_BODY or MR) is the one test of an accepted model.
The MODEL_HAND warning and SYMMETRY_HYPOTHESIS_n TESTED_BY_MODEL now require it: an inverted anomalous
map at a rejected model's atoms, or a hypothesis a rejected model was scored on, said something about the
model in the data-side part of the report. The wrong-model null (nullmodel.py) now checks that principle
directly: every report line outside the model section - KEY=, prose and WARNING: lines - must equal the
no-model run's, provenance aside. ResultReport_RejectedModelLeavesTheDataLines tests it for a rejected
model and a model of another lattice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--force-still only switched off rotation indexing and left the goniometer in the experiment, so a
rotation sweep processed as stills was not processed like serial data: prediction skipped the
run's rocking window (it is applied only without a goniometer) and used twice each frame's profile
radius, the per-frame mosaicity / spindle checks ran, and the unmerged export took the frames for a
sweep, summing neighbouring stills into "rocking events" and dropping summed fractions below the
stills partiality floor. The goniometer is now removed under --force-still. Results of forced-stills
runs change (one 5 keV S-SAD sweep, 1800 frames: anomalous-map S peaks 4.71/3.08 -> 4.85/3.34 sigma
mean/weakest, independent-half CC_anom 0.378 -> 0.399); real stills are unaffected.

The stills prediction stores the excitation error with its sign, |S| - 1/lambda (CPU and GPU); the
window and the truncation ranking test its magnitude, the partiality refinement squares it, and the
--mode scale reset recomputes it signed. A real serial run (4000 frames) merges bit-identically.

--export-observations (stills, off by default, processing-inert) writes <prefix>_obs.mtz: every
integrated observation of the written merge with its signed excitation error, lab q, partiality,
run rocking width, raw I/sigma/background, corrections, scale, the sigma the merge weighted it with,
the frame's A*/B*/C*, cell, G and image CC, and a bit for each merge test it fails
(MergeOnTheFly::Verdicts, every test evaluated on its own), including observations the merge drops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The stills prediction window was a fixed two run rocking widths. --stills-window-sigmas K predicts,
integrates and (with --export-observations) exports the rocking curve's tails out to K widths, which
is what a partiality model fitted from the data needs to see; --min-partiality decides separately
what the merge takes. The default stays 2 and the processing is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Re-scaling a rotation run's _process.h5 with --force-still kept the axis, so the unmerged export read
the stills as a sweep: neighbouring frames summed into "rocking events" (FRACTIONCALC above 1), the
stills cell check skipped, ROT taken from the axis. As in --mode mx, the goniometer is now removed.
On 300 frames of a 0.2 deg sweep the unmerged file goes from 13,611 summed rows to 154,656 rows, one
per observation; the merged reflections are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
No photon makes a count negative and a reflection only adds counts, so a pixel reading below
-(10 x the Poisson noise of its ring's level + 10 photons) on at least a twentieth of the pre-scan frames
(and on two) is defective, whatever it reads in between. A column of a JUNGFRAU 9M swung between
-2500 and +1000 counts from frame to frame with a normal median: its negative frames dragged the
background ring of every reflection near it far below zero, a disk summing to nothing was then integrated
huge with a one-photon sigma, and on 1500 frames of a jet run such observations zeroed the 3.4-4.9 A shell
(CCref there 0.30 -> 0.35, overall 0.12 -> 0.55 with the column masked). The rule sits beside the dead one,
on the host and the device alike; real reflections cannot trigger it. On rotation and serial sets from
EIGER, JUNGFRAU 4M and 16M detectors it masks nothing and the merges are bit-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The post-refinement now fits, per crystal and beside the orientation tilt, a radial scale eta of the
reciprocal lattice (q -> (1 + eta) q: the photon energy of an XFEL shot, or a cell the spot positions
did not pin) and a scale of the run's rocking width (the crystal's own mosaicity and size). Each changes
the partialities differently across resolution, so neither is degenerate with the scale G.

Both are held by Gaussian priors about the run whose widths come from the data: a first cycle refines
against loose priors, and the spread of those estimates over the crystals, less the uncertainty each
carries from its own reflections, is the prior of the second. A run whose crystals agree (a synthetic
test, or a stable beam) is held to the run's curve; one whose crystals differ is not.

On the quick panel (one run per set, merged MTZ): XFEL serial 18k frames, S anomalous peaks mean
6.80 -> 7.35 (weakest site 2.64 -> 1.65), CCref last shell 0.657 -> 0.653; XFEL serial 1.5k frames
CCref last shell 0.222 -> 0.252; monochromatic jet CCref last 0.405 -> 0.458 (overall 0.447 -> 0.396);
pink jet 0.299 -> 0.316; rotation sweeps as stills: lysozyme 5 keV peaks 3.36 -> 3.13 (CCref last
0.674 -> 0.689), thaumatin unchanged, a Zn site sweep: peaks 18.8 -> 20.6. On the full XFEL run CC_anom
between independent halves 0.334 -> 0.39 and R-free 0.243 -> 0.235.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The rocking width is measured from moments of the excitation error through the prediction window, and
its tails are where that is least sure. On rotation sweeps processed as stills, reflections predicted at
under a third of their peak were 30% fainter than the model at low resolution and up to 50% brighter at
high resolution, against reflections near their peak - a bias in I/p that no variance term removes.

Before the post-refinement each shell of the width table is now rescaled by the factor at which the
reflections predicted below half their peak and those above it put the same scale on the merged
intensities (ratio of sums, searched by bisection). On synthetic data with a known Gaussian width the
factors come out 0.98-1.00. On the two rotation sweeps the low-partiality/high-partiality ratio per
shell goes from 0.68-0.93 and 0.65-1.49 to 0.98-1.07 and 0.92-1.14 (one outer shell of the first set
over-corrected to 0.75).

Quick panel against the previous commit (CCref last shell / overall): XFEL serial 18k frames
0.653/0.665 -> 0.657/0.685, 1.5k frames 0.252/0.543 -> 0.291/0.559; rotation sweeps as stills
0.689/0.464 -> 0.673/0.483, 0.644/0.274 -> 0.645/0.280, Zn site sweep peaks 20.6 -> 21.0; monochromatic
jet 0.458/0.396 -> 0.464/0.355 (its lowest shell 0.33 -> 0.30).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The stills error model was the fit the rotation merge replaced (medians of sigma^2, <I>^2 and dev^2 taken
apart over bins of <I>^2, observed sigmas, one pass) and was applied to a variance rebuilt at the mean
that it had not been fitted on. It is now fitted as the rotation model is - equal-count bins of counting
I/sigma, the median normalised deviation of each, every bin relative - on the counting variance rebuilt
at the reflection mean, with a third term for the partiality model's own error,
c^2 ((1 - p) / p)^2 <I>^2, each bin split by that term's share so c is told apart from b. The means and
the model are refitted in turn four times. With the term in the variance, the merge takes observations
down to p = 0.3 (the scale and partiality references keep min_partiality). A term growing like 1/p in
the tail fitted the scatter better than the one a Gaussian rocking curve propagates (growing like
sqrt(-ln p)). Going down to p = 0.1 cost the outermost shell: on a rotation-as-serial set the CC of the
last shell to the deposited model was 0.595 / 0.628 / 0.692 / 0.685 for floors 0.1 / 0.2 / 0.3 / 0.5.

A frame with fewer than 20 reflections to fit its scale on is left out of the merge instead of merged
at the scale 1 its prediction carries.

Measured against the stills merge before the change (before the per-crystal partiality refinement):
rotation-as-serial R1 CC_anom 0.399 -> 0.487, S-site peaks 4.85/3.34 -> 5.11/3.61, last-shell CC to the
model 0.619 -> 0.621; R2 CC_anom 0.055 -> 0.082, peaks 6.72/2.35 -> 7.21/2.55, last-shell CC1/2
0.728 -> 0.742, R-free 0.238 -> 0.236. A Friedel-hand scramble of whole frames keeps CC_anom at ~0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The given-cell population filter left out every frame beyond a valley of the refined-volume histogram.
One crystal form whose cell drifts - a crystal swelling with dose, a cell refined a little differently in
different orientations - also falls into groups, and the filter then threw away part of one structure's
data: a rotation sweep processed as stills, its cell 0.25 % larger in volume in its second half, lost
841 of 1800 frames, and its two half-datasets kept opposite groups. Groups whose mean volumes differ by
less than 1 % are now merged as one form; the two forms of the stills battery's two-form sample are 9 %
apart and are still separated. (A test on the groups' merged intensities was tried first: on that sweep
the two groups correlate only 0.66-0.88 in their noise-corrected true intensities, which draws no
clearer line than the cells.)

That sweep's every-3rd-frame subset: S-site anomalous peaks 1.14/-0.96 -> 1.50/0.36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The stills merge reported no anomalous statistics. With -A it now gives, per shell and overall, CC_anom
between the frame-whole half-sets the stills CC1/2 and R_split use (so the two anomalous differences come
from different crystals) and SigAno against the merged sigmas, over the acentric reflections measured in
both hands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
With the run's width table balanced on the partials, the low-partiality observations no longer
carry the high-resolution bias that set the floor at 0.3. On the stills panel (one run per set,
the deposited model's anomalous sites and CC to its intensities) 0.2 keeps the anomalous gain of
the lower floors on real serial data and rotation frames processed as stills, with the same
outermost shell as 0.3 and 0.1; merging every observation (no floor) breaks one
rotation-as-serial set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The float32 Alvra sets merge to the same numbers as their int16 copies and only test reading the
uncompressed float32 format, at many times the I/O; the battery keeps the int16 copies, and the data
page keeps the originals as the source of the conversion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The PANEL column of the observation table matched the predicted position, which is on the
detector plane, against the panels' boxes in the stored image. Where the modules are not placed
as imaged (every CrystFEL-geometry input: stacked JUNGFRAU and AGIPD panels) nearly every
observation came out as -2 (off every panel). It is now the panel whose placed footprint holds
the point. Only the diagnostic export was affected; spot finding, integration and module
placement already used the right frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On SASE stills the rocking width is partly radial (the shot's photon-energy spread, or a cell
spread): a node outside the sphere is recorded by the higher energies, at a lower Bragg angle,
and one inside at a higher angle. The prediction put every reflection at the direction of q + S0,
so the spots of a 4.6 keV serial run sat up to +-3 px off their predictions at 45 deg 2theta,
growing with the excitation error and with resolution (the same for every module and every
azimuth). The run's strong spots now measure the radial share of the rocking width - one
relative spread s, fitted to observed minus predicted centroids on the window-sample frames - and
each prediction is placed as by the wavenumber change dk/k = s^2 |q_z| eps / sigma^2 (the
Gaussian expectation, never past the change that puts the node on the sphere).

Measured s: 2.1e-3 on the 4.6 keV JUNGFRAU serial set (radial centroid scatter 2.4 -> 1.0 px at
high resolution), ~1e-3 on the 12 keV one, 8e-4 on an AGIPD set, 0 on a 1 A JUNGFRAU 4M set.
4.6 keV set (mid subset): CCref last shell 0.673 -> 0.748, S-site peaks 7.79/1.32 -> 7.96/1.89.
12 keV set and AGIPD set unchanged within noise. Rotation data are not touched (the stills
window is never set with a goniometer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The stills error model (fit + means, iterated) had made the "error model and merge" phase 8-18x
slower than before it. Now:
- the fit puts its bin boundaries in place with nth_element on compact (key, position) pairs, the two
  halves of each split side by side, instead of sorting the whole pool by a five-field tuple; the
  per-group passes of its iteration run on the workers;
- each round's fit starts from the previous round's model, so it settles in a few iterations;
- the rounds stop once a round leaves the model where the previous one put it (to 1e-3);
- those rounds only carry the means to where the model settles, so on a large run they fit every
  stride-th observation (about four million); one last round fits them all and makes the final means;
- the observations are grouped by one parallel sort on (reflection, image order) into one flat
  array instead of a hash map of vectors, and the per-reflection passes (means, samples) run on the
  workers.
Every sum runs in a fixed order over fixed pieces, so the result is the same on any number of
threads (the fit is checked at 1 and 16 threads; the merge test checks 1 against 8). On the sets
tried the merged data are unchanged to within the last round's convergence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On the CCD sets the overall CC1/2 of the new stills error model fell (0.99 -> 0.66 on one) while every
shell but one improved and R-free held. Two mechanisms, both in a few reflections:

- A few in a hundred reflections are measured only a handful of times, at the beam stop's edge and in
  the detector's corners (where the written range now reaches), and only on weak frames or at low
  partiality, so every observation is scaled up 100-500-fold. Their half-set means are single
  observations whose noise, in absolute terms, exceeds the whole signal variance of the strongest
  shell: reflections with five or fewer observations carried 97-98% of the half-set disagreement.
  CC1/2 and R_split (and CC1/2 of the frame-whole halves) now weigh each reflection as the rotation
  merge does (cc_weight): the counting precision its halves would have had with every observation at
  one scale, over the precision they have - 1/n against sum(1/corr^2). Nothing intensity-dependent.
  The automatic resolution cutoff keeps the plain CC1/2: weighted, it moved a photon-counting set's
  cut outwards and its R-free up.

- The innermost reflections next to the beam stop carry, on weak frames, a flare the frame's scale
  does not: raw counts that stay put while G falls, read at 10-30x the median. The error-model means
  took them in; every systematic variance term scales with the mean, so the outlier band about the
  median widened by as much as the mean was off and kept them, at the weight of the rest. The means of
  the error-model rounds now leave out what the merge's outlier test will leave out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Stills module placement needs the detector's modules, which came only from NXdetector_module
groups, CrystFEL panels or --module-geometry; a Jungfraujoch master carries none (or one
whole-image module), so placement never ran on our own JUNGFRAU 4M/8M/9M data.

- A JUNGFRAU experiment that assembles its image now puts the modules it places into the start
  message, so the master file holds module_0..N (placed as imaged) and the reader gives them back.
  The broker's own processing is unchanged.
- A JUNGFRAU file without module groups (decided from the detector description) gets its modules
  from the pixel mask: every region clear of the module-gap bit must be one whole 1030 x 514
  module either way round, else none are taken. DECTRIS files, whose masks use the same bit, are
  not touched.
- Rotation runs keep processing the image as one panel where the modules sit as imaged (no
  module step there); merged data bit-identical on a JUNGFRAU 4M rotation set.

On the 8M and 9M serial sets 16/18 modules are found; the held-out test favours placement on all
three (capped rms 1.651 -> 1.615, 1.367 -> 1.350, 1.498 -> 1.365 px) but HDF5 input keeps the
existing measure-and-report rule, so only the per-panel spot finding / integration changes:
CCref last shell 0.273 -> 0.272, 0.421 -> 0.426, 0.443 -> 0.444 (noise level).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Since rc164 a serial-stills run given --model with -C/-S computed structure factors from the model and
handed them to the image pass as the reference intensities (ReferenceIntensities), the same path a
reference MTZ takes. That reference also feeds the per-image scale and CC, so the frames dropped as
collapsed, the radial scale, the indexing-ambiguity choice and from there the whole merge differed
with and without the model: on a PAL CCD set CC1/2 0.976 without it, 0.893 with it, at the same code.
A model is placed and validated after the merge; only a reference MTZ (-z) is a processing input now.
The data-only ambiguity resolution (space group given) runs as without a model, and validation still
relabels the finished merge into the model's indexing where it wins. With and without --model the
merged reflections of that set are now identical, bit for bit. Docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The per-crystal refinement of tilt, radial scale and width and the run-width balance had made the
post-refine phase 6-8x slower than with the tilt alone. Now:
- the balance gathers each frame's points on the workers and deals them into the shells in frame order,
  then bisects the shells side by side, 20 halvings (the factor to 2e-6) instead of 40;
- the Jacobian is taken by forward differences from the model the residual already evaluated, and in
  the width exactly (dp / d ln_width = -2 p ln p), four evaluations per observation instead of nine;
- exp(2 ln_width) is taken once per model, not once per reflection.
Every sum is in the order of the serial loop, so the result does not depend on the thread count. On a
CCD and a photon-counting set CC1/2 and R_split are unchanged to four digits; measured on CPU with
-N 8 under a loaded box the phase went 12.6 -> ~5.5 s against ~5.0 s before the per-crystal width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
This reverts commit 9082280311.
Crystallographic practice does not weight reflections in CC1/2, R_split or R-free; the weighting by how
far a reflection's observations were scaled up goes. The error-model means that leave out the merge's
outliers stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
On a rotation run whose radial background correction is decided per image (auto), the curve the
correction reads came from the predicted reflections' own background-ring pixels. On a smooth ice
ring that curve misses the ring's narrow peak, so the reflections on a ring were integrated high and
those beside it low: the correction was applied but changed almost nothing. The curve now comes from
the adaptive spot finder's peak-excluded ring background, as it already did on stills and on runs
whose pre-scan set the correction for the whole run. Which images are corrected is decided as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merged reflections on a detected ice ring were all written as measured. A ring biases them -
up on the ring, down on its flanks where the background ring sits on it - or only adds scatter, or
leaves them alone, and which of these holds differs from ring to ring and run to run. Each ring's
core and its two flanks are now compared with the off-ring reflections within 0.02 A^-2 of it, read
against a local Wilson fit (CalcGlobalWilsonB, which now also returns its line): a band whose mean
normalised intensity departs from its neighbours' by more than 3 standard errors is rejected, one
that is only noisier (variance ratio beyond 3 standard errors, from the samples' own kurtosis) is
down-weighted by its excess variance, and the rest is merged as before. The verdict per band is
logged. Applied to the merge accumulators, so the export and the statistics see the same reflections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rotation merge's decision for each ice-ring band (DecideIceRings) is carried to the results
report as ICE_RING_BAND_nn (ring d, band, action, reflections, bias in standard errors, variance
ratio), and the report, the ice log line and the documentation no longer say that the reflections on
the rings are kept in the merge where the rotation merge decides them ring by ring. The stills merge
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a weak sweep the adaptive spot finder's default operating point (100 noise pixels a frame) finds
a handful of spots a frame, too few for any rung of the first-pass ladder, and the run ended with no
lattice. Such a run is now made again once with the operating point ten times looser; a hundred times
drowns the spots in noise. Only a run that ends with no lattice changes. The result reports the
operating point it was found at (SPOT_FALSE_PIXELS_LOOSENED); where the second run finds no lattice
either, the per-image report is the first run's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
The doubled-cell hypothesis needed the supercell probe to measure the lattice's own reflections
(per-observation <I/sigma> of 1 or more) in the shells where the half-integer class stands clear of
the six empty classes. On a weak sweep no probe shell measures the lattice, so a class that clears
its siblings was never followed up. Such a run now merges the doubled cell as well where the class
clears the siblings in at least two shells, and reads the class's rise on the doubled merge (the
added class against the lattice's own mean intensity per probe shell) with the same rise test; the
translation and symmetry tests then decide as before. Runs where any probe shell measures the
lattice are unchanged. The report says where the rise was read (DOUBLED_CELL_RISE_READ_ON).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
On the weak-sweep arm of the doubled-cell hypothesis the translation fit was refused below 1000 added
reflections, which a weak sweep does not reach, so the hypothesis could not adopt the doubled cell
there however clearly the class rose. On that arm the fit now runs on what there is and the share the
translation modulates is read over all shells at once, with its standard error from the fit: the
doubled cell needs that share to be at least half two standard errors down. The probe arm keeps its
floor and its per-shell reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
The report printed "ISa undetermined" whenever b^2 fell within two standard errors of zero, with
the error read off how far the 16 I/sigma bins miss the two-term line. That error measures model
misfit, not counting noise, and does not shrink with more data, so on a full high-multiplicity
sweep it could hide a sane ISa (1/b ~ 52 against XDS 37 on a 360-degree sweep).

The rotation merge now resamples the pool of the reported error-model fit by a bootstrap over
unique reflections (Poisson weights hashed from replicate and reflection, 50 replicates, a pool
over 100k samples thinned and the spread rescaled by sqrt(lambda)), and the report gives
ISA with ISA_LOW / ISA_HIGH, its 95% interval. Where b's interval reaches zero, ISA= undetermined
with ISA_LOW as a lower bound ("ISa >= x"). "undetermined" without a bound stays for b out of
reach of the data or a on its clamp. Stills keep the two-SE test.

Report-only: the error model used for merging, the per-frame scale choice (which still reads the
two-SE test) and every written file are unchanged; ISa in the MTZ/mmCIF headers follows the old
rule. Bit-identical outputs against bf0f95900 on CPU on 4 battery sets plus one sweep so far
(command-line provenance and timing fields aside). Cost 0.2-0.5 s per full merge on 8 threads;
the interval does not depend on the thread count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
The rotation one-panel reset sits before the Friedel comment block it had split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The placement the stills geometry refinement finds replaces the given one where the user gave a
CrystFEL geometry - now also through --module-geometry, not only --crystfel-geometry. Modules a
Jungfraujoch file carries itself (module groups, or rebuilt from the gap mask) and an NXmx modules
file stay measured and reported only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The merge with the fitted error model no longer stops at a partiality floor of 0.2: it takes every
observation with p > 0, each weighted by the error model's a, b and c ((1 - p) / p) terms. A
reflection - each hand where the Friedel mates are kept apart - is reported only if at least one of
its observations has p >= 0.2 (or the lower --min-partiality); a reflection seen only far out on its
rocking curve rests on the partiality model alone, and without the rule the outer shells of a
small set collapse. The error model is still fitted on the observations at p >= 0.2, and the merges
the scale and the partiality post-refinement work against keep --min-partiality. The observation
count of the statistics leaves out the observations of reflections that are not reported.

Measured offline on serial and rotation-as-serial sets: +0.03 to +0.04 sigma on the sulfur
anomalous peaks of a large serial set, neutral on the small ones; the quick panel moves within its
noise. Rotation runs are unchanged (they do not use this merge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The "Header:" log line and the console note under it printed the input file's wavelength (or
the reader's 1 A placeholder where the file states none) even when --wavelength overrode it,
so a run that processed, refined and wrote everything at the given wavelength announced
another one at the top of its log. They now print the wavelength used, naming the file's
value beside it when the option replaced it. --mode scale keeps the wavelength its
reflections were integrated at, so it still prints the file's.

The report (WAVELENGTH=, XDS.INP block), the merged and unmerged MTZ, the mmCIF and the
_process.h5 were already written from the overridden experiment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Where the input is not a single HDF5 file the process file can link to - several masters read
as one (JFJochMultiReader), CBF, CXI/XFEL input - the _process.h5 carries the images itself.
Its start message was then typed by the experiment, which is pinned to signed 32-bit (the
container images are DECODED into), while the images are passed on as stored: 16-bit signed
from a JUNGFRAU in photons, unsigned 16/32-bit from an EIGER. The writer refuses an image that
contradicts the declared format, so --write-process-h5 on two Cristallina (int16) runs failed
with "Image is 16-bit signed, but the start message declares 32-bit signed".

The pixel format, error and underload values are now taken from the first image as the
reader hands it out, exactly as the linked-HDF5 case takes them from the source files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A reflection has a mean in a half-set (and in a frame-whole half-set) only if one of its observations
in that half reaches the existence partiality, as it is reported at all only if one observation does.
A half with nothing but far-tail observations of it no longer feeds CC1/2, R_split or CC_anom.

An observation's scaled intensity and merge variance are computed in double: a partial far out on
its rocking curve (p down to 1e-36 on one CCD serial set) has a 1/p and a variance no float holds,
and the overflow turned that set's CC1/2 and R_split into inf/NaN. An observation whose weight is
zero or not finite carries no information and is left out of the merge, the statistics and the
observation export (flagged as a bad value there). The number of merged observations with p < 1e-3
is logged once per run.

On that set CC1/2 0.750 -> 0.774 and R_split 0.203 -> 0.179 against the merge with the p = 0.2
floor, R-free unchanged; the panel and the other CCD set read as before this change, and rotation
runs are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A stills merge no longer reports ISA. On stills 1/b is the I/sigma ceiling of a single still
observation under the stills model - the same frames processed as a rotation sweep read several
times higher - so it describes the processing model more than the instrument and the crystal, and
read on CORRECT.LP's scale it calls good serial data useless. The report gives the error model as
ERROR_MODEL_A, ERROR_MODEL_B (XDS convention, as before) and a new ERROR_MODEL_C, the partiality
term, in the same convention: sigma^2 = a*(sigma0^2 + b*I^2 + c*((1-p)/p)^2*I^2).
ERROR_MODEL_B= undetermined where b is zero within its error (ISA= said it before).

The same on every other place that showed the stills ISa: the summary's Signal row, the mmCIF
(_reflns.jfjoch_error_model_c instead of _reflns.jfjoch_diffrn_ISa), the log line, the live status
the broker serves, and the viewer's merge-statistics window (no ISa card and no ISa diagnostic for
a stills merge). MergeStatistics::stills marks a merge made by the stills path. The 1/b the
space-group search reads (merge_isa) is unchanged. Rotation output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The stills merge no longer forms R_merge, R_meas and R_pim. On partial observations scaled to full
equivalents they measure the scatter of the partials, not the error of the merged intensity, and
read from a hundred to several thousand percent. A stills report has no R_MERGE= / R_MEAS= / R_PIM=
(nor REFRES_R_MEAS=), its shell tables drop the three columns, the summary's Signal row drops
R_meas, and the mmCIF writes ? for the three items. R_SPLIT, CC1/2, CC*, CC_anom, SigAno,
multiplicity and I/sigma are reported as before; the observation counts are unchanged.

The viewer's merge-statistics window shows R-split in place of R-meas for a stills merge. The
battery's report reader takes a shell table without an R_meas column, and the stills A/B script
drops its R_meas columns. Rotation output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
Where the input is anything but a single HDF5 master the process file can link to (several masters
read as one, CBF, XTC, CXI, ...), the _process.h5 no longer copies the images into
/entry/data/data: the copy made the file as large as the data. It now holds only the per-image
analysis, and the earlier fix (d67f10cda), which typed that copy as the images are stored, goes
with it - the int16 multi-master CrMX run that failed there writes its file without images.

The reader takes such a file (no /entry/data/data, no linked data files) as one with
/entry/detector/number images: the master's per-image analysis is read as for an integrated master
and the reflections as always, so --mode scale reads it like any other, while LoadImage and
ReadRawImage return no image instead of failing on the missing pixels (the viewer then shows none).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The stills scale and merge (ScaleOnTheFly, partiality post-refinement, error model, merge,
resolution cut, statistics) is one function, StillsScaleMerge, called by --mode mx and by
--mode scale. On a stills _process.h5 the re-merge now reproduces the merged MTZ of the run that
wrote it exactly (checked on a CrMX run with and without -S/-C, two CrMX masters read as one, an
LCLS XTC set and a EuXFEL CXI set with an indexing ambiguity). Before, it differed by an overall
scale of 0.87-0.89 and in the reflections kept. What it took:

- Frames: the cell given with -C is stored (/entry/MX/stillsGivenUnitCell) and --mode scale leaves
  out the frames outside its population as --mode mx does (LeaveOutOtherCellForms, shared). With no
  reference MTZ it resolves the indexing ambiguity from the data, as --mode mx does before merging.
- Reflections: excitation_error, observed, clipped and overloaded are written per reflection - the
  merge drops overloaded and weighs clipped reflections, and scale mode had neither - and lp/qe/flight
  are stored as double, so the reciprocal read back is the float the reflection held. A still keeps
  the resolution of its own lattice (no longer recomputed from the file's cell), its partiality and
  excitation error are no longer reset, and a mosaicity stored as NaN reads as none.
- Rocking curve: the file now holds the curve the frames were predicted through, not the one the
  post-refinement balanced against the merge, which --mode scale then balanced a second time.
- History: a space-group search merge leaves the frames' scales as it found them, so the merge
  that is written starts from the frames as integrated. RESULT-CHANGING for stills runs without -S
  (the final merge was seeded with the P1 search merge's scales): on a 3000-frame CrMX run CC1/2
  0.9592 -> 0.9588, ISa 3.57 -> 3.58, the same reflections at an overall scale of 0.88.

Rotation --mode scale is unchanged (to float rounding, from the exact correction factors).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
# Conflicts:
#	rugnux/RugnuxScaleMerge.cpp
# Conflicts:
#	image_analysis/scale_merge/Merge.cpp
#	rugnux/RugnuxScaleMerge.cpp
#	rugnux/rugnux_cli.cpp
Beyond the largest ring the detector holds whole, a ring can lie wholly in the
corners; where an aperture or cone in the beam path dims all of them, the ring's
median is the dimmed level and the beam-stop comparison along the ring sees
nothing. Such a shadow was left unmasked, and observations inside it or with
their background ring across its edge came out wild (|I/sigma| far beyond 5 in a
quarter to half of them), spoiling the merge statistics of whole shells.

FindOuterShadow compares each pixel there along its own ray instead: a
significant step down against the ray's own fall-off, which the ray's pooled
background confirms and which stays dim out to the detector's edge, starts the
shadow, and everything beyond it on the ray is masked. It runs on the pre-scan's
projection once the stills beam centre is decided, so the rays start at the
beam, and the shadow is grown by R3 - R1 so that DropShadowClipped also drops
observations whose background ring reaches it. Rotation is left as it was.

On the open still battery the two Rayonix PAL sets get their corner shadows
masked (0.24 % and 2.8 % of the detector) and their per-shell CC1/2 rises by
1-6 points below the corner-only shell; two panel sets whose corners are dark
behind an aperture get 0.2 %; the LCLS Rayonix sets, the JUNGFRAU sets and
every panel-harness set get nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The automatic cut writes one shell past the CC1/2 crossing. Past the finest
whole ring about the beam only the detector's corners record, and a reflection
there is seen a few times at most, on whichever weak frames reach it, scaled up
tens to hundreds of times: that shell is pure noise, and on several still runs
its handful of reflections decided the plain overall CC1/2 and R_split. On
stills the extension now stops at the finest whole ring, or at the crossing
where that already lies in the corners - also where no cut was made because the
extension ran past the measured edge. Rotation is unchanged.

Open still battery, against the branch before both changes: plain CC1/2 and
R_split recover on every set whose last shell was corner-only (e.g. 0.00 ->
0.99 / 5.5 % on one PAL set), the deposited-model R-free is within 0.005 or
better on all eight sets checked, at a written d_min 0.05-0.13 A coarser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
When the GPU 3D combine made no full reflection (no rocking event captured
whole enough - e.g. a sweep exposed 0.2 deg out of every 0.6 deg, or a lattice
that does not describe the frames), the GPU scale-fulls step was skipped and
the host scale-fulls loop ran instead. That loop slices the per-frame fulls
CSR that only the host combine builds, so it read empty vectors and the
process died on SIGSEGV in RunArm. The host loop now runs only on fulls the
host combined; with no fulls the merge ends in the existing "nothing to merge"
error, as the CPU path already did. Runs that make fulls are unchanged
(merged and unmerged MTZ data bit-identical on two in-house sweeps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
This reverts commit 314e50273 (cherry-picked from 0a3b5c823). Dropped by the owner: it stopped the
one-shell extension of the automatic cut on photon-counting still sets too, writing them 0.06-0.08 A
coarser. The outer-shadow mask (the previous commit but one) stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
When the GPU 3D combine made no full reflection (no rocking event captured
whole enough - e.g. a sweep exposed 0.2 deg out of every 0.6 deg, or a lattice
that does not describe the frames), the GPU scale-fulls step was skipped and
the host scale-fulls loop ran instead. That loop slices the per-frame fulls
CSR that only the host combine builds, so it read empty vectors and the
process died on SIGSEGV in RunArm. The host loop now runs only on fulls the
host combined; with no fulls the merge ends in the existing "nothing to merge"
error, as the CPU path already did. Runs that make fulls are unchanged
(merged and unmerged MTZ data bit-identical on two in-house sweeps).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
WriteJPEGToMem is the only JPEG code in the tree (encode only, no decoding),
and libjpeg-turbo came in as an ExternalProject whose imported target passed
its include dir as -isystem. Any other jpeglib.h on the include path (conda,
the distribution, the CUDA directory) could then win, and a build failed with
"Wrong JPEG library version: library is 62, caller expects 80".

stb_image_write v1.16 (commit 31c1ad3) is vendored in preview/stb/ and
compiled into JFJochJPEG.cpp alone with STB_IMAGE_WRITE_STATIC and
STBI_WRITE_NO_STDIO, so no JPEG symbol or header leaves that file. Output is
baseline JPEG (it was progressive); the API is unchanged. Size limits are
checked up front (JPEG's 65535 per side, stb's int pixel index) and an
encoder failure throws JFJochException. Quality is clamped to 1-100. stb
encodes an 18 Mpx frame in ~350 ms against ~210 ms for the scalar
libjpeg-turbo we built; accepted.

Removed: the ExternalProject and JPEG::JPEG target in preview/, nasm from the
Docker images, licenses/libjpeg-turbo.txt and its manifest row, and the CI
check that counted libjpeg-turbo copies in the viewer (the Qt JPEG/TIFF plugin
check stays, as does the viewer's plugin exclusion). Licence: MIT OR
Unlicense, recorded in licenses/ and THIRD_PARTY_NOTICES.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
The diagnostic _detector.jpg is rendered asynchronously and collected with
shared_future::get(), which rethrows. Unguarded, an encoder failure aborted
the run in the output stage, before any MTZ was written. It is now caught and
logged as a warning; the run continues without the picture (an empty
detector_jpeg is already skipped by the output writer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
node viewer/resources/generate_licenses_html.mjs. Besides the JPEG encoder
swap (libjpeg-turbo out, stb_image_write in), the committed page had fallen
behind licenses/ and lacked several components (Abseil, Mbed TLS,
TF-PSA-Crypto, the GEMMI third-party headers, traccc and others).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
This reverts commit d06209d79a.
The full battery of rc181 39d908427 lost six protein sets to sparse-twin (08ae0fa05): 7bgt, 9pbb, 6i3j,
8xtf, 4p8r and insu_I_x06da_low_isa took a cell of half the primitive volume. Two causes, two fixes.

The sub-lattice (halving) test compared the standing cell and its sub-lattice each with its own per-frame
orientation polish, so they were not nested: a sub-lattice could settle on another orientation and hold
spots the standing cell did not (6i3j: 4550 spots on the sub-lattice alone), and a correct cell lost to its
half. It now counts on the standing cell's own fit the spots whose index falls on the nodes the
sub-lattice drops, against what a wrong spindle angle puts on those nodes (half the standing cell's
chance count), Poisson at SPOT_BUDGET_SIGNIFICANCE_Z (SubLatticeSuffices(dropped, chance, sub),
DroppedNodeSpots). A correct cell's dropped nodes hold half its spots (thousands against tens); a
supercell's hold what chance puts there (L-aspartic acid: 3-9 against 2.5-7).

The twin-composite test read per-node densities that hold only for a prime index: where the two forms
share a factor with n, a single crystal fills the four classes in proportions that are not their shares
of the nodes, and an index-6 pair holding 1629/1572/1567/1540 spots read as two domains. The class on
neither must now hold no more than chance puts there - the standing cell's chance share of its spots on
the class's share of the nodes - besides the densities. And the domain must index a majority of the
frames or as many as the standing cell (7bgt adopted a domain indexing 6/60 against 60/60; the
beats-chance bar alone let it through). The genuine index-3 [111] composite of a twinned insulin set is
still adopted, as at 297ef9dc7.

Tests: SubLatticeSuffices rewritten for the nested count; TwinCompositeAt gains a case where the class
on neither holds strays beyond chance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A candidate is kept only when it matches the given cell, and on sparse twinned data the FFT never
returns the cell itself: it returns the twins' coincidence lattice (a supercell of either domain) or a
cell built from rows taken at a multiple of their length. With the true cell given, such a run found
no lattice at all. Every node of a supercell is a node of the given lattice, so the given basis is a
combination of the candidate's rows with coefficients in (1/n)Z, n the volume ratio (2 to 10); those
combinations are enumerated and, of the triples that match the given cell, span its volume and contain
the candidate's lattice, the one indexing the most spots replaces the candidate (on a twin, the stronger
domain). Measured: a reticular-twin small-molecule sweep given its true P2_1 cell went from "no
lattice" to P2_1 on that cell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
38 first-parent commits from the stills campaign (fork point bf0f95900, head e797ed720); details and
measured effects in the branch's commits. Conflicts resolved by keeping both sides: the rotation ISa
with its bootstrap interval (ISA_LOW / ISA_HIGH) stays on rotation, stills report ERROR_MODEL_A/B/C and
no ISa (ErrorModel.{h,cpp}, ResultReport.cpp, ResultReportTest.cpp, docs/RUGNUX_REPORT.md). The
rotation crash guard 4cb911d67 is the same patch as 9589e99b8 already here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Where pass 1's post-refinement leaves the distance at the header but its free fit asks for a move of a
step (1 %) or more, the distance is now asked for as a fixed point: pre-passes at distances along the free
fit's move (the direct beam held where the free fit put it), the move each one asks for against the
distance it was integrated at, and the zero of the least-squares line through them - taken only where the
distances tried bracket it (interpolated, never extrapolated). That distance is integrated once more and
adopted only where it realises a lower held-out residual than the header pass, beyond its noise, or the
validation frames prefer it - the tests a round of the geometry walk passes. Otherwise the header stands.

Why: a fit is measured on reflections integrated at the geometry it starts from and is pulled towards it.
On a detector swung out on a 2theta arm with a header 4 % short, the fit asked for 104.4 mm from data
integrated at 103 and 108.6 mm from data integrated at 109; spot-finder centroids in place of the
integrated ones moved neither. Each round of the existing walk closes a third of the gap and the walk
stopped 3 mm short. Measured (CPU, from the headers, no -C):
- two 2theta = 35 deg sweeps of one cubic crystal: 103 -> 108.0 / 107.5 mm, cell 74.4 -> 77.8 A,
  ISa 7.2 -> 13.4 and 4.5 -> 25.9, the second's space group back from P212121 to the cubic group;
- a 2theta = 45 deg sweep of an orthorhombic crystal: 183 -> 189.9 mm, cell 4.5 % -> 0.4 % short,
  ISa 4.2 -> 8.2, R_meas 32 -> 18 %;
- low-resolution sweeps at 2theta = 10 deg ask for +5.5 mm from every distance between 183 and 205 mm
  and bracket nothing: header kept, output bit-identical;
- eight controls whose header is right (free fit within 0.3 %): output bit-identical.

The beam the post-refinement fits is the PONI; on a swung detector it moves with the distance at a fixed
direct beam (7 px per mm at 35 deg), so the 15 px bound on it capped the distance near 105 mm. The bound,
and the comparison with a measured centre (which measures the direct beam), now apply to the direct beam;
on an untilted detector nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
On a large-cell merohedral serial run the reference-free resolution left about a fifth of the
crystals in the wrong indexing: the merge read twin-pair CC 0.56 against 0.15-0.31 for the
deposited data, and a twin refinement of it found a twin fraction of 0.21. Started from the
reference-resolved assignment, the resolver itself moved a fifth of the crystals away, and the
different starts ended in different mixed states - it was the comparison, not the start.

Two defects in how the crystals were put side by side:
- Intensities were divided by the run's mean in their resolution shell, and when any shell's
  mean was zero or below - which every shell past the resolution the run reaches can be, since
  integration predicts to the detector edge - all shells fell back to the raw intensities. The
  comparison was then dominated by the fall-off with resolution, which both indexings share.
- Intensities follow the Wilson distribution and a stills partial carries a multiplicative error
  as large as itself; the long tail let a few very strong reflections dominate the product of two
  crystals. Groups of strong crystals then held one another in the wrong indexing against the
  weaker majority: single-crystal moves cannot undo such a group, and which groups formed
  depended on the order of the passes.

The crystals are now compared on the square root of the intensity (sign kept), put on each
shell's mean and spread rather than divided by its mean. Every start then ends in the same
assignment. On the large-cell run 98.8% of the crystals a reference decides clearly agree with
the reference (85.7% before); the merge's twin-pair CC is 0.22, its twin refinement finds a twin
fraction of 0.025 and calls the data untwinned, R_split 7.6% -> 6.4%, and the battery R-free
0.258 -> 0.247 (deposited data 0.244, published 0.249). A trigonal serial set with the same kind
of ambiguity: twin-pair CC 0.19 -> 0.09, R-free 0.196 -> 0.191. Sets whose cells decide the
ambiguity are unchanged. Deterministic and thread-count independent as before (same sequential
passes; only the per-reflection values changed).

New unit test: 2000 weak stills with heavy-tailed intensities, a resolution fall-off and
correlated twin mates; the old comparison put 9.9% of them in the wrong indexing, now under 3%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A master with known modules (every geometry-transformed JUNGFRAU since
10bdf1d11, and inputs with CrystFEL/CXI/XTC geometry) carried the whole-image
/entry/instrument/detector/module beside module_0..N. DIALS takes every
NXdetector_module as a panel: a JF4M file became 9 panels and dials.find_spots
counted every spot twice (5-image JF4M test file: 7124 spots, 3562 of them on
the whole-image panel). The whole-image module is now written only when there
are no modules; then 8 panels, 3562 spots, the same per panel as before.

Three things DIALS needs for the module-only layout to come out right:
- dxtbx lists the groups by name, so module_10 came before module_2 and DIALS
  panel n was not module n (nor the index /entry/MX/peakPanel gives). The index
  is zero-padded to the width of the last one: module_0..7, module_00..15.
- For several modules dxtbx builds panel groups from runs of transformations
  sharing an equipment_component and composes a group in reverse depends_on
  order: with translation and rot1-3 all "detector_arm" the translation stayed
  unrotated on a tilted detector (CI dials_tilted: 5.6 mm off). The detector
  transformations no longer name one; each is then its own level.
- data_origin/data_size stay [row, column], which dxtbx reads as slow, fast.

Reader: ReadNXmxModules accepts padded and unpadded names; the image
orientation is taken from the whole-image module, then detectorSpecific, and
only then from the first module, whose axes are its own placement. Old files
(whole-image only, whole-image + module_N) read as before.

DetectorModules::Moved() compares within 1e-3 px: a module read back went
through a float length and unit vector, ~1e-4 px off, so an as-imaged JUNGFRAU
read back as moved and a rotation run on such a file kept its modules instead
of processing one panel (rugnux: "placed off the image's own grid" on a
jfjoch_hdf5_test JF4M file).

Test: JFJochReader_VerticalModules, the CrMX layout (vertical modules, mirrored
in Y), identity and a mirrored quarter-turn orientation, round trip incl.
orientation. tests/nxmx_geometry_dials_test.py maps pixels through the panel
holding them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
httplib enables zstd content-encoding whenever it finds a zstd, and found the
host's libzstd 1.4.4 through pkg-config: rugnux and jfjoch_broker linked
/usr/lib64/libzstd.so, and the BSHUF_ZSTD calls - compiled against the 1.5.7
headers - resolved to the host copy at runtime. httplib takes the
zstd::libzstd target when one exists, so zstd is now made available first and
zstd::libzstd is an alias of libzstd_static: one copy, zstd support kept.

ldd of rugnux, jfjoch_broker and jfjoch_writer now shows no libzstd.so, and
nm -D no undefined ZSTD_* other than zstd's own weak trace hooks.

CI: the portable rugnux and the packaged jfjoch_broker/rugnux/jfjoch_writer
are checked to link only glibc and the GCC runtime (plus libcufft from the
CUDA toolkit for the packaged CUDA builds).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
MSVC defines M_PI only with _USE_MATH_DEFINES; the portable code uses PI from
common/JFJochMath.h. No other M_PI or POSIX/GCC-only construct was added to the
viewer-reachable code since bf0f95900 (the __restrict__ uses are in .cu files,
the GCC pragmas in the vendored stb are guarded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
RESULT-CHANGING: -C supercell step-down on rotation runs only, not on stills
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 17m38s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 26m3s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m11s
Build Packages / Validation Battery (push) Successful in 56m13s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 30m46s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 28m56s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 27m26s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m24s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 30m56s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 9m25s
Build Packages / Build documentation (push) Successful in 2m6s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m16s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m35s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m24s
Build Packages / Unit tests (push) Successful in 1h36m54s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 25m42s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m26s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 24m25s
Build Packages / Create release (push) Successful in 9s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 29m38s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 28m3s
62457ec5ea
The step-down (659c0322a) ran in every FFT call with a reference cell, so also in stills geometry
refinement and per-frame indexing. A still is one frame, and the given cell there picks one crystal
form among several in the stream: with the step-down's tolerance (5 % on lengths, up to index 10) a
frame's supercell candidate came back as a neighbouring form or a noise lattice "matching" the given
cell. On a serial set with two P21 forms 4 % apart this indexed more sampled frames, moved the refined
geometry, widened the given-cell volume population from -6.3 % to -13 %, and merged ~12k frames of the
other form in (written cell drifted, twin test positive, R-free 0.29 -> 0.43). The step-down now runs only
when the run indexes by rotation; a still keeps refusing a candidate that does not match the given cell.
Measured: both forms of that set back to the numbers before 659c0322a (same frames left out, same
indexing rate and d_min); the twinned small-molecule rotation sweep given its true cell still indexes
on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
multisweep.json rows give rugnux several sweeps at once (`inputs`). Each names a base row of the open
or in-house arm, whose data root and reference it takes, so the merge is scored as that arm scores a
single sweep (lattice, space group, R-free with the deposited model, SHELXL for small molecules), plus
which sweeps joined (SWEEP_k_INCLUDED) against `sweeps_expect`: all, separate (different crystals),
refused (a two-wavelength pair) or one 0/1 per input. Not part of the default open,inhouse run.

Fourteen rows from archives already on disk: small wedges of one and of several crystals (8DYZ, 8DZ7,
6O2H), one crystal at several detector or goniometer settings (6OR0, 6OQZ, 8C3E), small molecules
(L-alanine, L-aspartic acid, cytidine), repeated sweeps (7L84, 6FID, the in-house lysozyme pair) and
two keep-separate controls (6IU8 two wavelengths, two lysozyme crystals). doc_check.py checks the
manifest against the new table in EXTERNAL_TEST_DATA.md; test_score.py covers the sweep scoring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
The Zenodo record 10.5281/zenodo.5836055 (CC BY 4.0): twenty 200 x 0.1 deg wedges of in-situ crystals
at room temperature, Diamond I24, none complete alone, so it has no single-sweep row. A multisweep row
may therefore name the arm whose data root holds it (`data`) and carry its own reference instead of a
base row; the deposited I121 cell and 2.43 A d_min are on EXTERNAL_TEST_DATA.md with the collection
counts updated. `sweeps_expect: most` - more than half of the sweeps must join - for many crystals of
one form, where a few may rightly be left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Every reported CC1/2 on rotation - CC_HALF, CC_STAR, REFRES_CC_HALF, the
CC1/2 and CC* columns of the shell tables, the mmCIF and the viewer - is now
the plain Pearson correlation of the 1/sigma^2-weighted half-set means, as
XDS, Aimless and DIALS report it. The per-reflection cc_weight (frame scale
against the typical scale) stays in what the processing decides on: the
automatic resolution cut, the batch delta-CC1/2 ledger, the two-pass and
beam-centre guards (MergeStatisticsShell::cc_half_internal), and the
report's judgement of whether FITTED_RESOLUTION is quotable. Stills were
already unweighted.

Adds the inert diagnostic report keys FITTED_RESOLUTION_PLAIN and
RESOLUTION_CUT_PLAIN (rotation): the same logistic fit on the plain CC1/2
and the limit it would have written. Nothing is cut at them.

Merged and unmerged MTZ and .hkl are bit-identical to before on three
rotation sets with weak stretches and one stills set; the written d_min is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
A serial sample holding two crystal forms whose cells lie within the indexing
tolerance of the run's cell was merged as one: both forms' frames went into
the de-novo merge, and the written cell was a blend of the two. Now the frames
on the run's cell are put on its axes, their log edges projected on the
direction of largest spread, and the existing valley test (CellPopulation)
finds the populations along it; the largest is merged, the next (>= 10 % of
the frames, >= 1 % apart in shape) is left out and the written cell is
recomputed from the merged frames. Angles are not used: per-frame stills
angles carry the indexing setting (monoclinic angle on different axes, or
held at 90 deg) more than the crystal.

The report gains CELL_FORMS / OTHER_CELL_FORM_FRAMES / _FRACTION / _CELL in
section 2, a CELL_FORMS pathology flag with a "Crystal forms" summary row,
and a VERDICT_TEXT sentence with the -C to process the other form. With -C
the given-cell population filter is unchanged; the largest population it
leaves out is reported the same way. --mode scale leaves out the same frames.

On a 9000-image subset of a public two-form LCLS set without -C: 181 of 587
frames (31 %) identified as the minor form and left out; merge 4.31 A P1
CC1/2 0.51 -> 3.35 A P2 CC1/2 0.68.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
The refinement's solvent step (waters built and removed, ions relabelled, and the
solvent audit) now runs only with --refine-solvent; --no-refine-solvent is the
default. When off, no audit is made, no <prefix>_solvent.json is written, and the
report carries REFINE_SOLVENT=NOT_PERFORMED in place of the REFINE_SOLVENT_ keys.
RefinementSettings::solvent and RugnuxConfig::refine_solvent default to false, so
the broker's live rugnux follows; RefinementResult::solvent becomes optional.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
A sweep joins the joint merge when its isomorphism ratio CC_AB/(CC*_A CC*_B) is clearly in the
same-crystal band, is left out when clearly below it, and in between is kept if it lowers the merge's
held-out residual or adds unique reflections without raising it beyond twice its error. The reference
is the sweep reaching the finest resolution (an unresolved ISa does not count). A joined sweep whose
cell disagrees with the reference's is processed once more from the distance that would make them
agree, kept only if its held-out residual drops. In segmented merges each sweep has its own error
model (a, b). Members' fulls are released once the joint merge has its copy. New SWEEP_k_* report
keys; single-sweep runs unchanged.

Squashed from branch multisweep-phase-c (8db078665..0b4bcedde).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Each exported full carries the diffracted beam's direction in its crystal (components along the cell
vectors, from its frame's lattice); the joint input reindexes it onto the reference's axes. In a merge of
several sweeps the crystal-frame spherical-harmonic surface, cross-validated as in a single sweep, is fitted
once over all of them: a chi or kappa series measures one absorption surface from many directions.
Single-sweep merges are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Both options are accepted by rugnux but were missing from RUGNUX_CLI.md, which the usage text names
as the list of every option. ISA_ASYMPTOTIC, which the report's own explanation mentions, had no
row in RUGNUX_REPORT.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
The credits paragraph framed the software as existing algorithms
reimplemented and moved to the GPU. That undersells the work: the
methods are taken further (hypotheses carried forward and decided on
the evidence, data-driven decisions instead of fixed thresholds,
autodetection so a bare invocation gives the best answer). Reword the
one sentence along the lines of the preprint's own introduction, keeping
the credit to the community's methods and the acknowledgements link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
The number to quote is read off the CC1/2 the shell table prints (rotation: the plain fit) and judged
against those shells; the cut stays on the internal weighted fit, reported as
FITTED_RESOLUTION_INTERNAL. FITTED_RESOLUTION_PLAIN is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
Acknowledgement: KAMO for the reference-sweep choice
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m20s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 22m57s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 26m35s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 28m29s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 25m27s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 25m12s
Build Packages / Unit tests (push) Successful in 1h35m1s
Build Packages / Validation Battery (push) Successful in 53m4s
Build Packages / Create release (push) Successful in 8s
Build Packages / Build documentation (push) Successful in 58s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 11m18s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 16m15s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 24m42s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 29m35s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 30m23s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 30m10s
Build Packages / Generate python client (push) Successful in 30s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 28m55s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 29m43s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 28m23s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 31m29s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m44s
80629e11fe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqrcrEZy6XBUCMZvyT
leonarski_f merged commit a9d5367013 into main 2026-11-02 12:05:21 +01: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#92