v1.0.0-rc.172 #82

Merged
leonarski_f merged 154 commits from rc172-2 into main 2026-09-22 06:48:38 +02:00
Owner
  • Fixed jfjoch_broker cancelling every data collection with a CUDA "out of memory" error after long operation: GPU memory no longer leaks with each collection.
  • Rugnux scales a rotation sweep until the per-frame scales settle instead of for a fixed three rounds, and says so when they did not - merged intensities, and the space group, resolution cut and frame rejection read off them, change accordingly; --scaling-iterations is now the cap on that loop (default 100).
  • Rugnux places every frame of a marCCD, SMV or miniCBF series at the spindle angle its own header states, so a series with missing frames, or with angles written modulo 360, is no longer read at the wrong geometry or refused.
  • Every rotation run writes two diagnostic files beside its reflections: <prefix>_detector.jpg, the detector projection with the pixel mask and the detected beam-stop shadow drawn on it, and <prefix>_plot.txt, one row per image.
* Fixed `jfjoch_broker` cancelling every data collection with a CUDA "out of memory" error after long operation: GPU memory no longer leaks with each collection. * Rugnux scales a rotation sweep until the per-frame scales settle instead of for a fixed three rounds, and says so when they did not - merged intensities, and the space group, resolution cut and frame rejection read off them, change accordingly; `--scaling-iterations` is now the cap on that loop (default 100). * Rugnux places every frame of a marCCD, SMV or miniCBF series at the spindle angle its own header states, so a series with missing frames, or with angles written modulo 360, is no longer read at the wrong geometry or refused. * Every rotation run writes two diagnostic files beside its reflections: `<prefix>_detector.jpg`, the detector projection with the pixel mask and the detected beam-stop shadow drawn on it, and `<prefix>_plot.txt`, one row per image.
leonarski_f added 154 commits 2026-09-22 06:48:09 +02:00
One runner for the open (vs PDB deposition) and in-house (vs XDS) arms,
replacing the scattered scripts outside the repository. Each run writes a
new, read-only directory named <date>_<rugnux git>_<label> holding a copy
of the binary with its sha256 and build flags, manifest.json (inputs,
references, options, host, GPU sharing) and results.json in one schema
for all arms, plus per-set work directories and a Markdown and a
self-contained HTML report (per-arm and per-population pass rates,
resolution gain, ISa/R_meas distributions, timing, failures, delta vs a
baseline run).

- Dataset lists and references are data files: open.json (151 public
  sets, deposited group/cell/resolution, pinned inputs) and inhouse.json
  (standard test crystals and no-crystal controls, XDS references). The
  private arm's manifest stays outside the repository; a private run gets
  its own run directory and report.
- Scoring is setting-invariant: lattice by Niggli-reduced primitive cell
  (beta vs 180-beta, C2 vs I2), symmetry via sgequiv on the open arm and
  by point group against XDS, then merge usability. Re-scoring the rc169
  and rc170 battery outputs reproduces their verdicts.
- Input discovery (`discover`) ranks sweeps by frame count, never by
  file size or sort order; a run never searches, it runs what the
  manifest names.
- `compare A B` flags per-set changes beyond noise; `--rerun-changed`
  reruns them with A's saved binary to separate real changes from
  run-to-run noise.
- `--tier smoke` selects a 22-set subset spanning the populations; a
  full-arm run takes a lock; `--gpulock` queues on the shared GPU and
  marks the timing as not a reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extends the lysozyme carve-out to thaumatin, insulin, cytochrome C and myoglobin
(plus the in-house no-crystal controls), and allows the public PDB depositions of
the open battery arm. The private arm stays out of the repository entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The crystal was measured at two wavelengths; both sweeps are kept. The native
sweep carries the deposited resolution, the 1.89 A sweep is scored on symmetry
and cell only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Site config: site.example.json is committed; the local site.json (git-ignored) or
  $JFJOCH_BATTERY_SITE / --site names this machine's paths and the private manifest.
  Data roots are the arms' own directories, so copying them and editing the site
  config moves the battery.
- Run states: manifest records the runner's pid; a run is complete, running, aborted
  (Ctrl-C/SIGTERM, or `abort RUN`) or unfinished. compare and report refuse a run
  that did not finish unless --allow-incomplete, and the report says so.
- `list` shows runs and their state; `run` defaults its label to the rugnux version
  and its baseline to the site's persisted one; a baseline must be complete and of
  the same privacy as the run; a private report cannot be written into the repo.
- XDS reference d_min: when the finest shell of XDS's table has no significant
  CC1/2, XDS merged past its own signal (no resolution cut, or a cut at the detector
  edge) and its limit is the detector's reach. The reference is then where XDS's
  CC1/2 falls through 0.30 (rugnux's own target), and that is also the range forced
  on rugnux. The manifest keeps XDS's limit (dmin_xds) and the rule (dmin_rule).
  Forcing the old 1.08 A on the weak insulin set turned rugnux's I23 into I222
  (ISa 12.5); forced at the derived 1.81 A it is I23 again (ISa 18.0). Changed in
  the inhouse arm: insu_I_weak 1.08 -> 1.81, lyso_half_image 0.80 -> 2.60,
  cytc_eiger 2.04 -> 2.27, lyso_strong 1.18 -> 1.24; every other inhouse limit stands.
- inhouse.json follows the directory rename (ids and inputs, data root
  /home/data/inhouse); `remap` applies such a rename and keeps the old ids as
  aliases, so runs from before it still compare set by set.
- Optional open-arm model check (model_check.py, when present): R-free of the
  deposited model against each merge, recorded and plotted.
- results.json has a fixed, documented row schema (results_schema 1); the HTML
  report gains inline-SVG plots (verdicts per arm, d_min and R-free ratios per set).
- README.md: arms, data layout and provenance, prerequisites, running, run
  directory, results schema, report, comparing, protocol, adding datasets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- rugnux_vs_xds.py: deleted. The inhouse and private arms of tools/battery run the
  same comparison against CORRECT.LP, from a fixed manifest instead of a directory
  scan, with persisted runs and a standard report.
- lyso_test/anomalous_scoreboard.sh: deleted. A one-dataset SHELXC/ANODE scoreboard
  with hard-coded personal paths, superseded by rugnux_anomalous.py.
- rugnux_vs_pdb.py -> tools/battery/model_sweep.py. The rugnux --model sweep is
  not superseded, but it now takes its inputs from the open arm's manifest and the
  battery site config, so both tools process the same files.
- docs/TESTS.md and rugnux_stills_ab.py point at the battery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the mention of the retired rugnux_vs_xds.py with a short Battery section:
the canonical commands, never a full run without the owner's go-ahead, judge
against a persisted baseline, the private arm stays out of the repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Directories follow <protein>_<beamline>_<feature> (e.g. lyso_x10sa_90deg_1,
thau_bl1a_6p5keV, thau_micromax_pink); previous ids stay as aliases so older
runs still pair up in compare. The ice-only control moved out of the in-house
arm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Run after rugnux has written its MTZ, so processing never sees the model. Fetches the
entry (cached), matches the data to the model's setting by trying every Niggli-cell
change of basis and keeping the one that best fits the model's Fcalc on a mid-resolution
shell, re-expresses the data in the deposited space group and cell, and has REFMAC compute
R-work/R-free of the unmodified model (rigid-body mode read at its first cycle: no
dictionaries, no ligand stripped, no refinement). Baseline: the same protocol on the
depositor's own structure factors and free set, plus rugnux's data on the depositor's flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Our merge and the depositor's structure factors are both scored on the
depositor's free set by the same protocol, and the report plots that ratio.
R-free on our own free set reads low, because those reflections were mostly
work reflections in the deposited refinement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The L-test now divides each intensity by its resolution-shell mean before
forming pairs, over the shells whose <I/sigma> reaches 1 (the floor the
second moment already used). Two index steps are not the same resolution,
and on a small cell with a steep fall-off the raw pairs read <|L|> up to
0.08 high: an untwinned crystal 0.568 and a partial twin 0.427 where
phenix.xtriage reads 0.486 and 0.360. Normalised, the four reference merges
(6toc, 6iu9, 5j23, a lysozyme) agree with xtriage within 0.02 (0.487,
0.376, 0.374, 0.480). Selection stays by shell, never by the reflection's
own I/sigma, which biases <|L|> down. Equal-count shells were tried for
the second moment and widen its gap to xtriage, so the shells are kept.

The L-test is no longer switched off in holohedral Laue classes. Merging
I(h) with I(Th) under a false operator gives (I(h)+I(Th))/2 for every twin
fraction, the perfect-twin distribution, so <|L|> < 0.42 there is reported
as an adopted operator averaging unequal intensities (promotion suspect, or
a twin law absorbed into the point group) - a warning, not a veto. Over 42
holohedral merges of the corpus the genuine ones read 0.436-0.513, the
over-promoted H3 twin 0.374, and one ~490 A-axis crystal 0.365.

The verdict is one line (TwinningVerdictLine) used by the stats text, the
report prose, the summary row, the warning and the viewer; a new
TWINNING_VERDICT key names it. The twin fraction comes from the statistic
that carries the verdict (the L-test unless the call rests on the second
moment alone), is not quoted from a second moment under a detected
pseudo-translation, and is not quoted at all on a merge under a suspect
operator.

The pre-search numbers (measured on the P1 search merge) are user-visible,
are measured with that merge's own pseudo-translation declared, and leave
out the reflections the lattice centring extinguishes - an R lattice in
its hexagonal cell otherwise pairs present with absent reflections (5j23:
0.633 with them, 0.40 without).

Report-only: no space-group decision, merge or intensity changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the all-observation and Lorentz-filtered search merges disagree, the
higher point group is adopted. The losing arm's answer was dropped, so a
promotion one arm refused on its twin gates (H ratio, added-operator R)
went through with nothing in the output saying a gate had fired - and the
report printed the adopted group's failing H ratio ("2.02 against a bound
of 1.85") as if it were a margin. Seen on an H3 partial twin (5j23),
refused on both twin gates by the all-observation merge and promoted to
R32 by the filtered one.

Now the losing arm's refusal (group and reason), or the fact that it never
confirmed the operators, is logged as a warning and written to the report
(SPACE_GROUP_ARM_OVERRIDE). A bound the adopted group exceeds is printed as
"EXCEEDS its bound - that gate fired and was overridden", for the H ratio
and for the best-operator R. The comment that claimed both arms keep their
vetoes now describes the rule as implemented. The rule itself is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The free-set hash was keyed on the Laue ASU of the merging group, so a twin
law - a lattice symmetry the crystal lacks - put nearly every free
reflection's twin mate in the working set (measured: 97-98% of the
free-touching twin pairs mixed, on the main P3121 output of 6iu9 and on
every law of the 6toc P1 cross-check), and each file of one crystal carried
a different free set (P1 vs merged agreed on 88-92% of reflections).

With the cell given, the key is now the reflection's orbit, Friedel mate
included, under the lattice holohedry: the metric point group of the cell
(gemmi Le Page two-folds, 3 deg obliquity, lattice of the cell's own basis
vectors so every file gets the same group; this contains the merging
group). Twin mates share a flag, and the merged MTZ/mmCIF, the P1
cross-check and any subgroup re-merge carry one free set (nested where the
small-data floor lifts the fraction of one file more than another). This is
phenix.refine's default (use_lattice_symmetry). Where the cell does not
carry the merging group, the merging group's key is used as before. The
small-data floor still counts reflections of the merging group, so the free
fraction is unchanged. Reference free sets are untouched.

On 6toc, 6iu9, 5j23: mixed twin pairs 0 for every law; P1 and merged flags
agree on 100% (6iu9, 5j23) and nested on 6toc; free fraction 0.050-0.051 as
before (6toc merged 0.086, floor unchanged). Intensities and space groups
identical to rc171.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Under a twin law T a reflection whose twin mate is itself (up to the true
group and Friedel) is untouched at every twin fraction. For each index-2
subgroup H of the adopted point group, those are the reflections centric in
the group and acentric in H: centric if the operators the group adds over H
are real, acentric if they are a twin law or a pseudo-symmetry - the one
intensity statistic that still separates the two at fraction 0.5, where
every operator statistic reads "real".

Read on the P1 cross-check merge: epsilon-1 reflections in shells with
<I/sigma> >= 5 (noise inflates every class towards centric), each class
normalised against its own mean in bins of ~100 reflections and within the
two phase classes of a detected pseudo-translation, Wilson outliers above
E^2 = 20 dropped. Reported per subgroup: the added operators, n,
<|E^2-1|> +- SE read absolutely against 0.968 / 0.736, and the centric-over-
acentric Wilson log-likelihood in nats with both densities convolved with
each reflection's error (flooring E^2 at its sigma instead read a genuine
1.2 A lysozyme zone as acentric), with the acentric control beside it.
Log, report prose and TWIN_ZONE_n keys. Nothing reads it back; no decision
changes.

On the reference sets: 6toc P4222, all three subgroups centric (1.04-1.07,
+253 to +534 nats); 6iu9 P3121 over P31 acentric (0.789, -294 nats); 5j23
R32 over R3 acentric (0.805, -304 nats, tNCS-class normalised); lysozyme
P41212 centric (0.90, +476 to +1297); a P21 myoglobin centric (0.930, +135).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FITTED_RESOLUTION was withheld whenever the fit was finer than the d_min of
the last shell whose CC1/2 still reached the target. A shell's CC1/2 is an
average over the shell, so on every ordinary fall-off the crossing lies
inside the first shell whose average is below the target - 0.01-0.1 A finer
than the number the check allowed - and the report said "no resolution could
be fitted" on data whose fall-off is textbook: 68 of 100 battery datasets,
with the fit 0.01-0.02 A inside the first sub-target shell. The comparison is
now against that shell's d_min, and the RESOLUTION_FIT flag fires only where
the fit is finer than the shell the crossing can lie in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The per-reflection median the merge rejects outliers against was an
unweighted median of the scaled intensities. On a sweep where the crystal
barely diffracts over a long stretch, observations from those frames are
scaled up by 1/G together with their sigmas, and in P1 at multiplicity ~3
two of them outvote one well-measured observation: the median becomes
noise and the well-measured observation is rejected against its own small
sigma. On one P1 sweep with ~44% such frames this rejected 52,574
observations (2,131 once those frames are dropped) and took the
correlation of the merged intensities with an external reference from
0.92 to 0.68 at low resolution. The median is now weighted by
1/(sigma*corr)^2, which leaves it unchanged where the observations are
comparably precise: rejections 3,932, correlation 0.92.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The resolution cutoff, the shell table and the overall CC1/2 counted every
unique reflection equally. The merge itself is inverse-variance weighted, so
an observation from a frame the crystal barely diffracted on enters it at
1/G^2 of a good one - honestly, with its sigma - but a reflection measured
only on such frames is scaled-up noise that then counts as much as a
well-measured pair in every Pearson CC1/2 read off the merge. On a sweep
where half the frames are weak the curve collapses at every resolution: the
cut lands at 3.7 A on a P1 crystal whose good frames reach 1.5 A, and the
UNUSABLE verdict fires (CC1/2 0.40 beside I/sigma 10).

Each merged reflection now carries cc_weight: the precision its half-sets
would have had with every observation at the run's typical frame scale, over
the precision they have. G_ref = sum G^3 / sum G^2 over the usable
observations is the precision-weighted typical scale, which the dead frames
cannot drag down however many there are; the factor per observation is
max(1, (G_ref/G)^2), with G the frame's total scale (partial scale, flux and
the fulls' own G) taken before the correction surfaces and before collapsed
frames are dropped, so nothing intensity- or resolution-dependent enters it.
On a sweep without a weak stretch every weight is 1 and the CC1/2 is the
plain Pearson it was. The cutoff fit, the shell table and the overall CC1/2
(and so the UNUSABLE verdict and the report's shell checks) all read the same
weighted statistic.

The two extra per-group sums are accumulated on both merge paths, the host
loop and MergeAccumKernel, from one per-frame factor array; a host recompute
of the device sums agrees to 1e-15 relative on every merge after the
corrected corr is uploaded, and a host-merge run gives the same cut, space
group, CC1/2 and ISa on five sets.

Weighting by the half-set error variance alone (1/(v0+v1)) is not this: v
grows with the intensity, so it weights the weak end of the intensity
distribution and biases homogeneous data coarser.

Measured (written resolution, together with the weighted outlier median):
a P1 sweep with a long weak stretch 3.73 -> 1.40 A against a 1.63 A XDS
reference, UNUSABLE withdrawn (overall CC1/2 0.40 -> 0.97); a second 3.61 ->
3.00 A; one with most of the sweep out of beam 7.35 -> 5.20 A. Lysozyme,
thaumatin and two insulin sets unchanged to 0.01 A (one lysozyme sweep with a
weak wedge 1.13 -> 1.16 A, from the median), same space groups throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The (a, b) error-model fit already refuses to report ISa when its top intensity
bin has no leverage on b. It still printed 1/b when b had leverage but came out
at zero within its own error - the strongest bins scatter no more than
counting statistics say (the fitted b^2 is negative and clamps to 0), or there
are too few samples for the bin medians to mean anything. ISa then reports the
noise in b as an I/sigma: on one low-resolution sweep consecutive merges of the
same data gave ISa 0, 28, 46, 113 and 130.

The standard error of b^2 is taken from the 16 bins' own scatter about the
fitted line, and when b^2 is less than two standard errors above zero the
merge result carries isa_resolved = false. Only what is printed follows it:
the report's ISA key reads "undetermined", the summary line says so, the
asymptote is not printed and the mmCIF carries "?". The fitted ISa itself is
unchanged and is still what the space-group search's present-reflection cut
and the refused-point-group arbitration read, so no decision moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A weak crystal inside a powder of its own microcrystals plus ice puts only
~5% of each frame's spots on its lattice. The pooled first-pass test accepted
that lattice (5.5% of validation spots against 0.6% at a wrong spindle angle),
but then:

- every frame failed the 20% per-frame floor (LATTICE_MIN_INDEXED_FRACTION),
  so nothing was integrated and the merge was skipped (indexing rate 0);
- the background-measured beam centre, ~3 px from the file's and correct,
  was not adopted because the arbiter counts validation frames, which are 0/60
  at every centre.

Changes:
- When fewer than 1/6 of the validation frames clear the per-frame floor AND
  the sweep's pooled on-lattice fraction is itself below that floor, integrate
  every frame from the sweep's lattice (as XDS/DIALS do) and leave frame
  selection to scaling. Sweeps sparse only in spots per frame keep the floor.
- Merge when a rotation lattice was found even if no frame "indexed" on its own.
- When neither centre indexes a validation frame, adopt the measured centre if
  its pooled excess over chance beats the file's by 3.29 sigma.

On the case above: P2 at the XDS cell, merged to 1.77 A, CC1/2 0.95, ISa 3.4
(XDS with the same lattice: ISa 3.3-5.5). Three healthy/partially-indexing
rotation sets are bit-identical; a two-wavelength CBF set that currently
merges 42 frames on a wrong cell moves (beam centre adopted, 755 frames).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DropCollapsedFullScales zeroes the corr of fulls whose frame scale collapsed on
the host copy of the fulls; on the resident-fulls (GPU) path that zero reached
the device only with the corrected corr pushed after the surfaces. The merge
that measures CC1/2 before the correction surfaces runs in between, so on the
GPU it still merged the dropped frames' fulls while the host path excluded
them - the two builds measured different observations.

The corr is now pushed to the device right after the drop, and the later push
covers only the surfaces and the frame rejection.

This changes the input of the two-pass quality guard (cc_half_before_corrections)
on GPU runs where a full scale collapsed; the final merge is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each post-refinement now records, beside what it commits, what the integration
it was handed realises at the geometry it started from: the held-out residual
at nominal (the same value the log prints on the left of "held-out ... ->"),
its standard error over the held-out residual values, and the cell the pass's
own indexing refined. Nothing here changes a decision.

Two helpers read them: HeldOutResidualFell (a pass at a new geometry realises a
lower residual than another by more than the standard error of the difference)
and ReindexPushesCellBack (re-indexing at a committed geometry returns, on the
length the fit moved most, a cell on the side the fit moved away from). The
geometry walk in RunAllPasses uses both in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rotation geometry walk kept re-integrating while the post-refinement at
the adopted geometry still committed a move, up to four rounds. It never ended
on its own: every walking run hit the cap, and with the cap lifted to twelve
every one hit twelve. Each round's fit predicted a held-out gain the
re-integration at its geometry then did not realise - the realised residual
stayed flat from the first round, or passed a minimum and rose again while the
distance crept on along the distance/cell-scale degeneracy - so the cap decided
where on the drift the run stopped.

A round is now kept only when the held-out residual measured by the pass
re-integrated at its geometry falls below the best round's by more than the
standard error of the difference. The first round that does not ends the walk,
and the run goes back to the best round (one more pass, since the canonical
files were written by the last). The walk also stops when re-indexing pushes
the fitted cell back on the same length in the same direction on two rounds
running: the fit keeps asking for a cell nothing ratifies. The cap is 8; a
walk still paying at the cap keeps its last (= best) round, sets
geometry_not_converged, and the report flags GEOMETRY_NOT_CONVERGED. Either
way the kept round is judged against the header-geometry pass by
RefinedPassIsWorse, as before. PASS_DECISION lists distance and realised
residual per round and why the walk ended.

Runs whose post-refinement does not commit a move of more than one step do not
walk and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the XDS-referenced arms rugnux merges Friedel mates separately whenever
XDS ran with FRIEDEL'S_LAW=FALSE, in forced and unforced mode alike, so the
merging statistics compare like with like.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every map -> structure-factor transform on the model path - Fcalc from the
model density and Fmask from the bulk-solvent mask, in the fit, the rigid-body
evaluations (two per evaluation, ~120 evaluations per placement), the frame
probe and the stills model reference - went through gemmi's vendored pocketfft,
single-threaded. They now go through MapToFPhi (rugnux/ModelFFT.{h,cpp}): an
FFTW r2c (fftw3f, already fetched and linked) planned with the guru interface on
the same layout gemmi uses (u fastest, w halved), scaled by V/N, into the same
FPhiGrid, so gemmi's prepare_asu_data() extracts the reflections exactly as
before. gemmi's vendored code is not modified. The F -> map transforms of the
output maps are left on gemmi.

Plans are FFTW_ESTIMATE only (MEASURE times candidates and could choose
differently between runs), made under a mutex - FFTW's planner is not
thread-safe, and the null replicates and the parallel Jacobian call this
concurrently - cached per grid size for the life of the process, and executed
with fftwf_execute_dft_r2c on fftwf_malloc buffers (new-array execution needs
the planning arrays' alignment, which a std::vector does not promise).

NOT bit-identical with pocketfft: the two libraries order their arithmetic
differently, so structure factors move in the last float bits and everything
downstream (scale fit, rigid body, R-factors, maps) can move in the last
printed digit. Verify by tolerance, separately from the parallelisation commits:
MODEL_* keys equal to printed precision and the same MODEL_FIT / hand /
indexing decisions on the audit set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The battery is bound by reading images from disk, so instead of one battery run per setting each
set now runs in all its variants in a row while its images are still in the page cache:

  open arm        bare, then model  (rugnux --model <deposited coordinates>)
  XDS arms        bare, then xds    (XDS's resolution range forced, -A where XDS was anomalous)

bare (plain rugnux <input>) runs first on every arm, so its time always carries the set's disk
read whichever variants are selected; each row records first_read and the report's timing table
says which variant's times are cold. --variants runs a subset; --unforced is gone (--variants
bare). Rows, work dirs (work/<arm>/<set>/<variant>/), compare and the report are per variant;
compare pairs (arm, set, variant) on the variants both runs have on an arm, and a schema-1 run is
read as one variant. The model variant takes the coordinates and published R-free from
model_check's RCSB cache (site key pdb_cache) and records rugnux's R-free/R-work and the ratio;
the REFMAC check is now opt-in (--model-check) and its keys moved to refmac_*. results_schema 2.

model_sweep.py is retired: the model variant replaces it (its --spot/--scaling-low-resolution 50
were rugnux's defaults, so the command is the same).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FitModelScale fits ~113 (k_sol, b_sol) grid points, each a full
Levenberg-Marquardt over every working reflection, and it is the largest single
cost of --model (every fit_model call: the first fit, the twin-law probe, the
fit after the rigid body, each null replicate). It ran on one thread.

Each point starts from fit_isotropic_b_approximately(), which sets k_overall
and b_star from the data and the point's solvent pair alone, so no point
depends on the one before it. The points of each pass (coarse, then the
refinement around the coarse winner) now run in contiguous chunks, each on its
own copy of the Scaling, and the winner is read off afterwards in grid order
with the serial rule (lowest finite R, first on a tie). Each point's arithmetic
is unchanged, so the result is the serial loop's bit for bit. Where
fit_isotropic_b_approximately() has five or fewer reflections to fit on it
returns without setting anything and points would chain, so there the grid is
still walked serially on the caller's Scaling.

Inside a null replicate (a pool worker) the chunks run inline, as before.

To check: MODEL_* keys and md5 of the maps/.mtz/_model.cif identical with and
without this commit on the audit set; model-phase time on a fit-dominated set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
XDS was rerun with the pin shadow masked and the frames where the crystal
leaves the beam excluded; the reference is now P43212 to 1.65 A instead of a
failed P1 run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rejected rigid-body placement put the atoms back and then recomputed the full
Fcalc and solvent mask at the restored positions. Nothing reads them: the real
model's ModelState is only fitted again after its own compute_model_factors
(the re-placement on the merged indexing, the null-failure fallback), and a
null replicate's state is discarded. The atoms are still put back; the
structure factors are left as they are.

Where the lattice has twin laws, the indexing probe fits the identity labelling
first, and place_and_fit then made the identical fit again - same
ModelState (nothing moves the model between the two), same reflections. The
probe now hands that Fit over, for the real model when the identity wins and
always for a null replicate (placed against the merged indexing). fit_model is
deterministic, so the reused Fit is the one the second call produced.

Bit-identical by construction: no value that is read changes. To check: MODEL_*
keys and md5 of maps/.mtz/_model.cif identical with and without this commit on
the audit set, including sets with twin laws and a null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The crystal does have a single P2 lattice, holding ~6% of each frame's spots
on top of ice and protein powder; XDS indexes it with the ice rings excluded.
The reference is now that P2 run to 2.0 A instead of a failed P1 run, and the
set is renamed from aggregate to sparse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rigid-body target is an Fcalc + solvent mask + scale re-fit per
evaluation, and its forward-difference Jacobian made six of them one after the
other after the central one - 7 of the ~8 evaluations per LM iteration.

The six shifted evaluations now run in parallel, column j on its own Evaluator
over its own copy of the model (an evaluation moves every atom, so two cannot
share one), set to the same zone and given the central evaluation's bulk-solvent
pair - the pair the serial loop's shifted evaluations used, since the zone's
solvent is fitted on the zone's first evaluation and every Evaluate starts with
the central one. Each column's arithmetic is the serial one and the Jacobian is
assembled in column order, so the refinement is bit-identical; the evaluation
count is kept as the serial loop kept it (up to and including a failing column).
The per-zone solvent fit (FitModelScale) gets the thread count as well.

Inside a null replicate (a pool worker) the columns run inline, as before.

To check: RIGID_BODY_* and MODEL_* keys and md5 of maps/.mtz/_model.cif identical
with and without this commit on the audit set; rigid-body seconds on a large
model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A long-running broker began cancelling every data collection with

    Device decoding failed (CUDA (GPU) error (out of memory)), falling back to host decompression
    CUDA (GPU) error (out of memory)

while nvidia-smi showed the cards less than a fifth full. Two defects, both in CUDAMemHelpers.h and
both from the pooled allocator (cudaMallocAsync) that came with rc.162.

The leak. cuda_allocation_stream() kept one stream per (thread, device) in a thread_local map of raw
cudaStream_t and never destroyed them. That was written against rugnux, where the worker threads live
as long as the process. The broker starts fresh std::async threads for every data collection - 16 or
64 of them - so every collection left that many streams behind. Measured: 0.56 MB of device memory
per leaked stream, linear to 4928 streams, never returned, with nothing on the host side growing.

The RAII wrapper (CudaStream) was there but not used at this site, and using it as-is - a stream
destroyed when its thread exits - would not have been safe: ShadowFinder builds its GPU accumulator
on a throw-away std::async thread and frees it from another thread long after, and that free is
ordered on the allocating thread's stream. So the streams are still never destroyed, but a thread
now only borrows one: CudaStream objects live in a process-wide per-device idle list, a thread takes
one on first use and hands it back when it exits. Their number is bounded by the threads that were
ever alive at once instead of by the threads ever started. The list itself is deliberately leaked, so
that nothing calls into CUDA during static destruction.

Replaying the broker's pattern against the real header, 60 collections of 64 threads:
    before   3840 streams, 260 -> 2424 MB of device memory
    after      64 streams, 260 ->  358 MB

The stale error. Every helper here throws a named message, yet the log carried the raw CUDA string,
so the failure came through a cuda_err() and not from an allocation. CudaDevicePtr falls back to
cudaMalloc when cudaMallocAsync fails, silently - but the failed call stays behind as the thread's
last error, and the cudaGetLastError() that follows the next kernel launch reports it. The buffers
were all allocated; the frame was lost anyway, once on the device-decode route (caught, hence the
warning) and once more on the host fallback (fatal). The pooled attempt failing, and a stream that
cannot be created, are both handled by falling back, so both now clear the error they leave.

What finite resource the production cards ran out of at under 4 GB used was not established - no
cap on the number of streams was found up to 4928 on the card this was measured on. The leak is the
only thing on this path that grows with uptime.

tests/CUDAMemHelpersTest.cpp: later threads end up on the same stream, concurrent threads on
different ones, a buffer is freed cleanly after its allocating thread has exited (and another has
borrowed its stream), and a pool that cannot serve a request leaves no error behind - the last by
capping a memory pool at 4 MB so that the pooled attempt fails and the fallback succeeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
FFTW's planner (every fftwf_plan_* and fftwf_destroy_plan) shares global state
and is not thread-safe; executing a plan is. FFTIndexerCPU and BeamCenterFFTCPU
each guarded their planning with a lock of their own, TranslationalNCS and the
viewer's spectrum with none, and ModelFFT with a third - which does not stop
two of them planning at once. common/FFTWPlannerLock.h holds the one mutex they
all now take. No numerical change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The per-shell SigAno and CCanom of the rotation merge were accumulated on the
report grid, but the overall pair of numbers - the SIGANO / CC_ANOM keys and
the mmCIF's pdbx_absDiff_over_sigma_anomalous - were summed over every Bijvoet
pair the fulls hold, including the ones past the automatic resolution cut that
the written reflections do not contain. On a run the cut trims, the overall
line therefore described more data than the shells above it add up to. Both
overall numbers now count only the pairs that land in a shell, as the rest of
the table does; a run with a manual limit, whose ingest already ends at the
limit, is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
gemmi::transform_map_to_f_phi conjugates the forward FFT at the end (a
structure factor is the sum over exp(+2 pi i h.x), a forward FFT the sum over
exp(-2 pi i h.x)); MapToFPhi did not, so every model-path Fcalc and Fmask came
out with its phase negated. Amplitudes, and so the R-factors and the scale,
were unaffected; the phases of the maps and everything read off them (map
coefficients, density at atom centres) were not. Caught by
ModelValidation_MapToFPhiMatchesGemmi, which now passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Comparing a run with another program's table has meant running rugnux AT that
program's resolution range (--scaling-high-resolution), which is a different
run: the range moves the cut, the space-group decision and everything after
them, so the comparison buys itself a different answer. --report-resolution
<dmin>[,<dmax>] instead leaves the run alone and adds a second table to
section 3 of the report - the REFRES_* keys and a shell table - binned from the
same merged reflections over the range given, with the completeness
denominator enumerated over that range and the shells in equal steps of 1/d^2
so they read row for row against a CORRECT.LP at the same range. Report-only:
the merged files and every decision are byte-identical with and without it.

The table holds only what the run kept. Where the reference range is finer
than the run's own limit, the shells past it are printed as not merged (with
their possible count) rather than as zeros, REFRES_SHELLS_PAST_LIMIT counts
them so a consumer can tell "not merged" from a measured zero, REFRES_
COMPLETENESS counts their reflections as missing, and the other overall numbers
are over the shells the run reached; nothing is read from the observations the
run judged to carry no signal. REFRES_ISA is the error model refitted on the
reflections of the table alone, in XDS's convention (rotation only; the stills
model is fitted over the whole range already).

On the rotation path the statistics block of MergeAndStats becomes a lambda
over a shell grid, called once for the run's own grid and once for the
reference one; the reference call floors every observation-level count at the
cut by group d, the rule the erase applied. The stills MergeStats takes a
declared range, whose bounds are the grid's whether or not any reflection
reaches them. Both --mode mx and --mode scale report it, the viewer's command
line echoes it, and the docs describe the keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The rot3d merge tests each observation against the weighted median of its
reflection, and only from three observations up: at two the median is one of
them. With -A each hand of a Friedel pair is its own reflection, so a P1
anomalous merge sits at about two observations a hand and almost nothing is
tested - on one P1 sweep the merge rejected 0 observations with -A against
tens of thousands without it. A single observation a hundred times its
partner's intensity (300 sigma apart, a 3.9 A reflection) then survived into
the merge, carried 94% of the weighted variance of its CC1/2 bin, took that
bin from 0.80 to 0.18, and the automatic cut with it: 3.46 A and UNUSABLE,
where the same data without -A cut at 1.40 A.

A hand with fewer than three observations now takes the weighted median of
both hands together, where the pair has three. I(+) and I(-) differ by the
anomalous signal, a few percent of I and far inside the six-sigma test, so
the mate supplies the observations the hand is missing. A hand with three of
its own keeps its own median exactly as before, and a Friedel-merged run is
unchanged. The medians are formed on the host, so the device merge reads the
same ones.

Measured with -A: the P1 sweep 3.46 A / UNUSABLE -> 1.49 A (1.63 A XDS
reference; 1.40 A without -A, unchanged), 0 -> 3,699 rejections. Two lysozyme
and one insulin set: same cut, space group, CC1/2 and ISa. On one of the
lysozyme sets 16 more observations are rejected, every shell's CCanom is
unchanged, and the overall CCanom goes 0.31 -> -0.02: the whole-range figure
was being carried by a handful of wild pairs, while the shells ranged -18% to
+12% and XDS's overall anomalous correlation is 1%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The INDEXING_AMBIGUITY warning was suppressed whenever --model was given on
rotation data, decided before the model was read. A model that then decided
nothing - not tested, rejected, or a probe winner that did not beat its own null
- still silenced it, so WARNING_COUNT, PATHOLOGY_FLAGS and possibly VERDICT
differed from a run without the model although the written reflections were
identical.

The warning is now issued exactly as without a model, and withdrawn (from the
warnings and from the statistics text) after model validation only where the
indexing probe decided the indexing: the model fits and the winner's R-free
margin beats the random-placement null (ModelValidationResult::indexing_decided,
set where the decision is taken). A reference MTZ, or the model reference on
serial stills with -C and -S, suppresses it up front as before.

Verified bare vs --model on three open-arm sets: merged MTZ data identical in
all three; warnings identical where the model decided nothing (identity probe
without null; no twin law); withdrawn where the probe decided (+33 sigma).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The own statistics table's grid bottoms 0.1% below the finest kept reflection,
and the observation-level counts (N_obs, R_meas, the Bijvoet split behind SigAno
and CCanom) re-walk the fulls, which still hold every ingested group. Groups in
that band just below the auto cut were erased from the merge, so they were not
in N_uniq, but their observations still counted in N_obs and R_meas of the
finest shell. The own table now floors those counts at the cut by group d, the
rule the erase applies - the same floor the reference-range table already used
(now passed as a double, so the comparison is exactly the erase's).

Report-only: on an auto-cut cubic in-house set the written MTZ, HKL, P1 MTZ, unmerged
MTZ and image table are byte-identical; TOTAL_OBSERVATIONS 378754 -> 377933,
MULTIPLICITY 40.31 -> 40.22 (now equal to the reference-range table's), finest
shell N_obs 60873 -> 60052, R_meas 1136.9% -> 1129.2%, CCanom -0.6% -> -0.3%;
overall R_MEAS, CC1/2, SigAno unchanged at printed precision; the mmCIF's
pdbx_number_measured_all / pdbx_redundancy / last shell row follow. A
detector-limited lysozyme run (no auto cut) is byte-identical throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finalize reserved npredicted reflections and kept only the ones whose fit succeeded. On a dense
long-axis pattern more than half the predicted reflections lose their background ring, so each
retained per-image vector carried more dead capacity than data for the rest of the pass. Count the
kept ones first and reserve exactly that. No result changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
--model and --report-resolution are both processing-inert (merged reflections
bit-identical with and without them), so the bare/model and bare/xds variant
pairs collapse into one command per arm:

  open:          rugnux -o p --no-export-unmerged --model <deposited coords> <input>
                 (no --model where there is no deposited model)
  inhouse/private: rugnux -o p --no-export-unmerged [-A] --report-resolution
                 <reference d_min>,<XDS d_max> <input>

Every row is rugnux's own result (own cut vs the reference, as `bare` was), and
on the XDS arms also carries the REFRES_* table, scored against CORRECT.LP's
completeness, multiplicity, R_meas, CC1/2 and ISa, with ISa and R_meas ratio
plots and a like-for-like table; REFRES_SHELLS_PAST_LIMIT > 0 marks the row as
coverage (rugnux's cut coarser than the reference). Nothing is forced on the
processing any more. On the open arm the space group is scored on the data's
own determination (SOHNCKE_SPACE_GROUP where SPACE_GROUP_ENANTIOMORPH is
ASSUMED_FROM_MODEL), the model's label kept as sg_label; R_FREE/R_WORK/CC_MODEL
/MODEL_FIT are trend fields (placement-only, own free set); REFMAC stays opt-in.

results.json schema 3: one row per set, no variant/first_read; older runs are
read through their bare rows (open-arm model R-factors folded in), so compare
and report keep working against them. Every time is now a first read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ingest wrote every observation's 24-byte SortKey into an index-order array and then scattered that
array into its h buckets - two key arrays alive together at the top of the ingest. Count the
buckets per chunk of frames straight from the source reflections and scatter from them instead:
a frame's reflections are consecutive in the key numbering, so the chunks are consecutive index
ranges and each bucket lands in the same index order as before. The key array is byte-identical.

BuildInRangeObservations also hands back the remap temporaries (old runs, per-run maps, new_idx)
before the observation array is built rather than at the end of the function.

Measured on a 3600-frame long-axis rotation set (95.7 M partials in the geometry pre-pass): the
pre-pass ingest high-water drops 15.45 -> 14.1 GB (sampled RSS, -N 6); merged MTZ/HKL/CIF, the P1
cross-check MTZ and the per-image table are byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
--report-resolution now gets XDS's own d_min, the range its CORRECT.LP totals
cover, so the comparison is exact; shells past rugnux's cut print as not
merged. The derived reference d_min stays what rugnux's own cut is scored
against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The post-refinement probe on a canonical pass only measures, and it reads nothing the scaling
engine produces - the reflections' hkl, I, sigma and positions are final once the images are
integrated, and the engine writes back per-frame fields only. It ran after the first merge,
beside the engine's arrays, where its gathered observations and rocking events set the pass's
memory high-water mark. Run it before the engine is built instead; the pre-pass keeps its
original place (it consumes the smoothed mosaicity the merge writes back).

Measured on a 3600-frame long-axis rotation set: the canonical pass's high-water drops from
14.8 GB to under 12.8 GB (sampled RSS, -N 6); merged MTZ/HKL/CIF, P1 MTZ, per-image table and the
report are byte-identical, the probe's own result included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
At 16 keV a native crystal has no usable anomalous signal, so XDS's CORRECT
was rerun with FRIEDEL'S_LAW=TRUE and the set no longer runs with -A.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On the resident (GPU) path the ingest's narrow host record sits on top of the source reflections
for the whole smoothing and partiality recompute, and on a long axis that moment is the run's
memory high-water mark. Store its Miller indices in 16 bits and move the rocking-event flag into
their padding: 40 -> 32 bytes a partial. Stage the device upload in slices of two million
observations rather than eight, which takes the thirteen staging arrays from ~400 MB to ~100 MB.
Neither changes a value.

Measured on a 3600-frame long-axis rotation set (-N 6): VmHWM 13.79 -> 12.80 GiB on top of the
previous commits (15.0-15.1 GiB before any of them); merged MTZ/HKL/CIF, P1 MTZ, per-image table and
the report are byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
parse_correct_lp also reads the first row of CORRECT.LP's last resolution
table: its high-resolution limit (dmin_low) and R_meas (r_meas_low).
inhouse.json regenerated with `refs --arm inhouse --write`; the only
change is the two new keys per set (the private manifest was regenerated
the same way, outside the repository).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The owner's scoring decision: no R_meas criterion, and merge quality
judged only by CC1/2 relative to XDS.

- The global rule (R_meas > 60% or CC1/2 < 0.5 fails) is gone.
- XDS arms: a set fails `merge` when (1/CC1/2 - 1) of the reference-range
  table is more than twice XDS's, XDS's CC1/2 taken at the bottom of its
  0.1% rounding. CC1/2 = S/(S+E), so 1/CC1/2 - 1 = E/S at any CC1/2, and
  E goes as 1/observations: 2x is XDS's merge with half its observations.
  Stored as cc_half_noise_ratio. No REFRES table or no XDS CC1/2: no
  criterion.
- Open arm: no merge criterion; CC1/2 and R_meas stay reported numbers.
- Low-resolution R_meas is reported, not scored: the lowest shell of the
  own table, the reference-range table and XDS's (lowres_*), with
  lowres_r_meas_ratio in the like-for-like table and a ratio plot.
- Every schema-3 run is re-scored when it is read (report, compare,
  baseline delta) with today's scorer and today's manifest rows, so both
  sides of a comparison are scored alike. results.json keeps the verdicts
  as scored at run time; rows without a lattice keep them.

Re-scoring the rc172 reference run: inhouse 29 -> 27 pass (three new
merge fails, one R_meas fail lifted), open 137 -> 138 (one R_meas fail
lifted).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Data quality - resolution, CC1/2, R_meas, completeness, ISa - is no longer a
pass/fail criterion anywhere; it is reported beside the reference in the tables
and plots for a human to judge. The CC1/2 noise ratio against XDS stays as a
reported guide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
-A set MergeFriedel(false), and RotationScaleMerge builds one grouping for the
whole pass from it, so every decision on an anomalous rotation run was taken on
hand-separated groups: per-frame scaling, the correction surfaces, the outlier
median, the error model, the CC1/2 half-sets and the resolution cut, the
delta-CC1/2 frame ledger and the space-group search. That halves the
multiplicity each of those gates was calibrated at, and flipped the bare run's
symmetry, resolution and frame-rejection calls on several sets - none of them
through the anomalous signal (a 422 refused on an added-operator R, a 23
refused on a chi^2 gate, a screw row read on hand rows, thin hands exempt from
the outlier median letting one hot pair own a CC1/2 bin).

A rotation run now always merges with Friedel's law: the Rugnux constructor
turns MergeFriedel back on when the run is rotation (IsRotationIndexing, which
--force-still clears), and --mode scale does the same for its rotation merge.
Nothing is lost for anomalous use: the merge always keeps the Bijvoet split, and
I(+)/I(-), F(+)/F(-), SigAno and CCanom are written and reported from it
whether or not -A was given. -A on a rotation run is therefore a no-op on the
processing, which is the point; stills keep -A as their only anomalous route.
A bare run is untouched.

This is step 1 of the -A design (decide with pairs merged, present with them
split). The per-hand statistics table that -A should select is not built yet,
so an -A rotation run now reports the Friedel-merged table (FRIEDELS_LAW=
TRUE, Laue multiplicity and completeness). The thin-hand pair median of the
previous commit is unreachable from rugnux on rotation data but kept for any
direct MergeFriedel(false) caller of RotationScaleMerge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
H5Pget_filter2 takes cd_nelmts as the number of elements the cd_values buffer can hold and
writes up to that many. It was given sizeof(cd_values) = 32 for an 8-element array, so a
filter carrying more than 8 parameters would have been written past the end of the buffer.
Bitshuffle carries 5, so nothing overflowed in practice.

The "Weird value" message printed cd_values[1] while the test above it is on cd_values[4].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eAE2K7i5JGDwgwifiCfuA
(cherry picked from commit dd1af9576c)
parse_correct_lp also reads the first row of CORRECT.LP's last resolution
table: its high-resolution limit (dmin_low) and R_meas (r_meas_low).
inhouse.json regenerated with `refs --arm inhouse --write`; the only
change is the two new keys per set (the private manifest was regenerated
the same way, outside the repository).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
None of this has been built on a Mac - there is none yet. It is the list a read-only audit of the
viewer/rugnux subtree produced, plus a serial -fsyntax-only pass of every reachable .cpp with
clang 16 + libc++ on Linux, which found exactly one error (the first item).

- JFJochDatasetInfoChartView: std::vector<fftwf_complex> does not compile with libc++, whose
  construct_at refuses an array element type (float[2]). Use std::vector<std::complex<float>> and
  the reinterpret_cast every other FFTW call site already uses.
- libcurl: GSSAPI off on Linux, and neither TLS nor GSSAPI on macOS. The viewer never sets
  CURLOPT_HTTPAUTH, so Negotiate was dead weight that cost a krb5-devel build dependency; on macOS
  curl's FindGSS refuses the system Heimdal outright, and with Secure Transport gone from curl
  (8.15) TLS would mean a Homebrew OpenSSL - the only host library a Mac build would need.
  Linux keeps OpenSSL. CURL_USE_GSSAPI is forced OFF rather than left unset so an existing build
  tree drops its cached ON.
- libjpeg-turbo ExternalProject: CMAKE_SYSTEM_NAME/PROCESSOR were forwarded unconditionally, which
  puts even a native sub-build into cross-compiling mode, and CMAKE_OSX_ARCHITECTURES / SYSROOT /
  DEPLOYMENT_TARGET were not forwarded at all. Now the same rule the zlib-ng sub-build follows.
- ShadowAccumulatorGPU.cu was added on the JFJOCH_USE_CUDA option (default ON) instead of
  JFJOCH_CUDA_AVAILABLE like every other .cu, so a machine without nvcc got a CUDA source in a
  target with no CUDA language.
- CMAKE_OSX_DEPLOYMENT_TARGET defaults to 12.0 (overridable). Left unset, CMake takes the build
  machine's OS version and the .dmg starts nowhere older.
- Standard headers that were only arriving transitively (<chrono>, <cmath>, <limits>, <cstring>,
  <atomic>, <thread>, <string>); newer libc++ releases keep removing such transitive includes.

Checked: the seven changed sources pass clang 16 + libc++ -fsyntax-only. The CMake changes are
not configured or built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015eAE2K7i5JGDwgwifiCfuA
(cherry picked from commit 5a7282759a)
The owner's scoring decision: no R_meas criterion, and merge quality
judged only by CC1/2 relative to XDS.

- The global rule (R_meas > 60% or CC1/2 < 0.5 fails) is gone.
- XDS arms: a set fails `merge` when (1/CC1/2 - 1) of the reference-range
  table is more than twice XDS's, XDS's CC1/2 taken at the bottom of its
  0.1% rounding. CC1/2 = S/(S+E), so 1/CC1/2 - 1 = E/S at any CC1/2, and
  E goes as 1/observations: 2x is XDS's merge with half its observations.
  Stored as cc_half_noise_ratio. No REFRES table or no XDS CC1/2: no
  criterion.
- Open arm: no merge criterion; CC1/2 and R_meas stay reported numbers.
- Low-resolution R_meas is reported, not scored: the lowest shell of the
  own table, the reference-range table and XDS's (lowres_*), with
  lowres_r_meas_ratio in the like-for-like table and a ratio plot.
- Every schema-3 run is re-scored when it is read (report, compare,
  baseline delta) with today's scorer and today's manifest rows, so both
  sides of a comparison are scored alike. results.json keeps the verdicts
  as scored at run time; rows without a lattice keep them.

Re-scoring the rc172 reference run: inhouse 29 -> 27 pass (three new
merge fails, one R_meas fail lifted), open 137 -> 138 (one R_meas fail
lifted).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The x86/ARM split asked for on macOS already exists: BitShuffleBlock.h (fbc507839) sends
the per-block transform to bitshuffle_hperf everywhere except aarch64 with NEON, where it
uses the classic bitshuffle's NEON path, and both call sites go through it. Nothing new is
needed for Apple Silicon - clang --target=arm64-apple-macos defines __aarch64__ and
__ARM_NEON exactly as aarch64 Linux does, both vendored files compile to arm64 Mach-O
objects, the seam resolves to bshuf_(un)trans_bit_elem and bshuf_using_NEON() is 1. The
header now says why the switch is a preprocessor one: a universal build compiles it once
per architecture, which a CMake-time answer cannot follow.

What had never happened is the NEON code actually running. It has now, under
qemu-aarch64 in the project's cross image: 504 blocks (elem 1/2/4/8, 8 elements up to the
128 kB block, odd multiples of 8, random and detector-like data) plus 54 whole-buffer
bitshuffle/LZ4 streams with element counts that are no multiple of 8 or of the block. The
output is byte-identical (same md5) across aarch64 NEON, aarch64 without NEON (seam falls
back to hperf's portable code), x86 hperf AVX2 via ifunc, hperf SSE2, hperf portable
fallback, and classic SSE2 / AVX2 / scalar; every implementation decodes every other.

Throughput, one thread on a loaded Zen 3, 16/32-bit, GB/s encode/decode: hperf AVX2
~9-12/~10-11, classic AVX2 ~8/~5.5-8, classic SSE2 ~4-5/~4.5-5.5, hperf portable fallback
auto-vectorised ~2.8/~5, not vectorised ~1/~1.8. Classic 128-bit SIMD beating hperf's
fallback is the x86 stand-in for the choice the seam makes on ARM; no ARM hardware was
available, so the NEON-vs-fallback ranking on a real core is still unmeasured.

The one thing that would have failed on aarch64 is the Bshuf_SSE test, which required
SSE2 outright. It now accepts NEON as well, which also makes it the check that a Mac or
DGX Spark build did not end up on the scalar path.

Not built: no CMake configure or jfjoch_test build was run on this machine (busy); the
test expression was compiled and run standalone on x86 only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 5957b15493dfd0cc8a18fb780a18c2573a6eccde)
Data quality - resolution, CC1/2, R_meas, completeness, ISa - is no longer a
pass/fail criterion anywhere; it is reported beside the reference in the tables
and plots for a human to judge. The CC1/2 noise ratio against XDS stays as a
reported guide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The ingest-time geometry smoothing (delta_phi from the smoothed lattice, one exact-Bragg angle per
rocking event, partiality from the smoothed mosaicity) used to run over a 32-byte host record per
observation, copied from the source reflections and held beside them. It now reads hkl, frame, d
and zeta straight from the source reflections, frame by frame, and keeps arrays only for what it
rewrites (delta_phi, partiality) and what the rocking-event walks read in raw-hkl order
(image_number, and on the resident path the usability flag): 13 bytes an observation instead of 32.
The full-Obs path runs the same code and copies the two rewritten fields back.

Same arithmetic in the same order; merged output byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The alternating per-frame scaling used to run a fixed three rounds of a Cauchy-reweighted fit
against a reference that included half a sweep's rocking-curve tails. Three rounds left a short
sweep merged in P1 far from its answer (a 90 deg tetragonal sweep refused its 422 with the P1
scales anti-correlated with the converged ones), and more rounds did not help: the fit had no
fixed point. Two things made it walk. The objective is invariant under G -> cG with the reference
-> reference/c, so every round moved every scale by a constant factor; and the robust loss, iterated
against a reference refitted each round, drops the strong reflections of a frame whose scale is off
by a third (ten-sigma residuals) and lets the weak ones carry it further off - measured on a 360 deg
sweep the scales shrank 10-30% per round for thirty rounds and the H ratio of a genuine 222 read 21x.

Now the loop pins its gauge every iteration (G divided by the precision-weighted typical frame
scale G_ref = sum G^3 / sum G^2, one definition shared with the CC1/2 weight), fits the plain
weighted least-squares slope with the weights the reference uses and only on the observations the
reference is built from (the partiality floor), and stops when the rms |log(G_new/G_old)| over the
frames falls below 1e-3. With the same weights on both sides the alternating fit is exact
coordinate descent on one objective and cannot climb; measured, the 360 deg sweep settles in 19
rounds and the 90 deg one in 30-50, each pass's partials and fulls loops alike.

--scaling-iterations is now the cap (default 100). A loop that reaches it is logged, the report
prints SCALING_ITERATIONS and raises SCALING_NOT_CONVERGED, and the correction surfaces run to the
same tolerance under their own cap. On the GPU the loop runs one iteration per call so the pin and
the step test read the same numbers as on the host; the fulls' reset is split out of ScaleFulls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Pooled run B: twin report-only evidence and free-R on the lattice holohedry,
information-weighted resolution cutoff with weighted outlier median and ISa
rework, sparse-lattice integration and beam-centre arbiter, GPU/CPU collapsed
scale upload, geometry walk on the realised residual, --model speed-ups with
FFTW structure factors, memory reductions for large-cell sweeps, -A decisions
Friedel-merged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The beam-centre check carries both centres forward when indexing at each
returns the same cell in a DIFFERENT metric symmetry, and RunAllPasses then
adopts whichever arm merges better on its P1 search merge. That question is
asked before the short-axis pass and before the geometry post-refinement,
either of which can replace the lattice it was asked about: on a crystal whose
shortest axis is under the FFT floor the check compares two supercells that the
short-axis pass then throws away, and both arms end on the same sub-cell in the
same class, post-refined to the same centre within a fifth of a pixel.

The arbiter was then deciding between two integrations of ONE hypothesis on a
statistic whose difference between them is noise. On a small-molecule rotation
sweep whose per-frame scales collapse on a fifth of the frames, the two arms'
search merges came out 0.543 and 0.594 - a 0.051 margin against the 0.05 the
run moves off the file's centre for - and the adopted arm took the run from
P 21 21 21 at 0.65 A to P 1 2 1 at 1.79 A on the same cell.

The arms are now compared on the lattices they ENDED on: where both hold the
same class, the same centring and primitive volumes within 2 %, the centre did
not decide the metric symmetry after all, the merges are not asked to arbitrate
it, and the file's centre stays (the post-refinement moves it wherever the spots
put it). Where they still differ the merge comparison runs exactly as before,
and both branches now log the two arms' final classes. ProcessResult carries the
rotation lattice's class for the comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The map-coefficient to real-space transform was still gemmi's bundled pocketfft while the
map to structure-factor direction had moved to FFTW. MapFromFPhi does that inverse with
FFTW - the same half-l XYZ layout, the same 1/V scale, NaN coefficients read as zero, the
same conjugation convention - and the output maps are now built with it. Plans are
FFTW_ESTIMATE, cached per grid size and direction, and made under the shared planner lock.

No pocketfft code is instantiated in rugnux any more (75 symbols -> 0); gemmi's fourier.hpp
is still included for its non-FFT helpers (get_size_for_hkl, get_f_phi_on_grid), so the
vendored header and its notice stay.

Tested element-wise against gemmi on odd and even grids, with arbitrary phases, negative
indices and symmetry/Friedel expansion in three space groups, plus a round trip; both new
tests fail if the conjugation is dropped. On the eight --model audit sets the merged MTZ is
byte-identical, the map coefficients are identical, the CCP4 maps agree to 3e-7 relative
(map CC 1.00000000) and every reported key is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The whole-run passes retain every frame's integrated reflections until scaling is done - thousands
of vectors of a few megabytes each, allocated by the image workers in the allocator's per-thread
arenas. When a pass hands them back, most of that memory stays in those arenas as holes, and the
next pass's workers (new threads) do not reuse it, so on a fine-sliced long axis gigabytes of freed
reflections were carried to the end of the run.

IndexAndRefine now copies each retained frame's reflections into a ReflectionArena: 64 MiB blocks,
each its own mapping, carved by a bump pointer and returned to the system in one piece when the
last vector in them is gone. IntegrationOutcome::reflections becomes a std::vector with an allocator
that uses the arena when given one and plain new/delete otherwise (copies go to the heap), so the
read sites are unchanged; the few functions that took the vector by type now take a span.

No arithmetic changes; merged output byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A promotion the operator correlations confirm and a gate refuses (the H ratio, the added-operator
R, the merge chi^2) used to be settled two ways that both read the per-frame scales: the two-arm
rule let the other arm's confirmation outvote the refusal, and the remerge arbiter compared the
R_meas and ISa of the two pinned merges. Neither holds once the scales have settled: a partial
twin at a fraction one arm's coverage hides passes there (an H3 twin promoted to R32, a P31 one to
P3121), and the lower group's converged merge - the same scales fitted against fewer equivalents -
agrees with itself better than the true group on a genuine step (a 2 -> 222 step reading R_meas
0.094 -> 0.103), so the arbiter refuses real symmetry.

The refusal is now decided by the twin-immune zone of the operators the promotion adds: the
reflections centric in the higher group and acentric in the lower are their own twin mates, so
they read centric if the operators are real and acentric if they are a twin law or a
pseudo-symmetry, whatever the twin fraction and however the scales were fitted. Read on the
all-observation P1 merge in the two-arm rule and on the adopted group's own merge at the arbiter
(where a weak crystal's P1 search merge has no shell with signal to read), for a group two orders
above the other, with the pseudo-translation normalisation the L-test uses, and decided on the
sign with the screw-axis convention of 20 nats: acentric and the refusal stands on all
observations, centric and the higher group is adopted, undecided and the rules stay as they were.
Measured: a genuine 222 step reads +150 to +1630 nats, the trigonal twins -130 nats and below.

A refusal that stood on the zone names the law: the first operator the refused group adds, at the
fraction the operator's own H implies (else the pre-search L-test), reported as TWIN_LAW and
written as the mmCIF _pdbx_reflns_twin loop of the merge in the true group. Every lattice twin law
outside the adopted group is reported with its own H, implied fraction, R and correlation on the
merge expanded to P1 (TWIN_LAW_n), following Yeates (1997) Methods Enzymol. 276, 344-358.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The joint post-refinement is asked twice, with the distance free and with it held at
the header. Where freeing it lowers the fit's held-out residual by more than that
residual's own standard error, the free fit is committed exactly as before. Where it
does not, the fit cannot tell - its own gain need not survive re-integration - so
both hypotheses are carried: the canonical pass runs at the free geometry as before
and once more at the header distance, and the run keeps the one whose RE-INTEGRATED
held-out residual is lower, the header's on a tie. The report says which
(POSTREFINE_DISTANCE_HELD), the pass decision names both realised residuals, and the
log prints the free fit's formal sd(distance) and its correlation with the cell
lengths, from the covariance of the solution.

Why: at a detector far enough away that no reflection reaches more than a few
degrees of 2theta, a longer distance and a larger cell move every spot the same way
to first order (the difference is of order sin^2 theta of the spot's position -
0.4 px rms per per cent over a 2M detector at 820 mm, against 2-3 px at usual
distances), so the fit finds a distance/cell pair that fits its own observations a
little better than the header, commits it, and the pass re-integrated there asks for
the next pair: a walk along the degenerate direction the realised residual never
ratifies. XDS leaves the distance out of IDXREF by default and its documentation
says to remove it from CORRECT where it drifts on low-resolution data; DIALS fixes
the wavelength for the same reason. Here the data are asked instead of a rule.

Measured: a sweep at 820 mm reaching 2theta ~ 9 deg no longer walks to 846 mm with
the cell 3.3 % too large (pool-B stopped it at 825.6 mm, still +1.1 %); it keeps
820 mm and its cell agrees with the reference at that distance to 0.2 %, R_meas
101 % -> 70 %, two passes fewer. 9yl4 keeps 600 mm (cell 0.5 % -> 0.1 % from the
deposition), 7mzt keeps 400 mm (R_meas 169 % -> 145 %, CC1/2 0.961 -> 0.986), 5lzl
keeps 689 mm on a tie (cell -0.3 % instead of +0.25 %, ISa 13.9 -> 11.6). Merged
output byte-identical on a lysozyme reference sweep at 110 mm (the free distance
wins its realised comparison), 8egn and 8pqd (decisive in-fit gains, no second arm)
and 8qq7 (the quality guard had already reverted it); one extra canonical pass
wherever the two arms are run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The device merge's per-group sums were downloaded into a full-length host array per field and then
unpacked into the per-group accumulators, so every merge held two full copies of them - on a large
cell searched in P1 that is most of a gigabyte beside the accumulators themselves. MergeAccum now
leaves the sums on the device and MergeAccumRange downloads a million groups at a time, each slice
unpacked straight into the accumulators.

Same values, same integer reject count; merged output byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Three follow-ups to the converging scale loop, all on sweeps with a stretch the crystal barely
diffracted on:

- The sweep ledger's scale channel (and with it delta-CC1/2's "normal frame" test) and the
  space-group search's scale floor measure a frame against the precision-weighted typical frame
  G_ref (TypicalFrameScale) instead of the run median: on a sweep that spent most of its turn out
  of the beam the median frame is itself a dead one, and every stretch then reads as typical. The
  two collapse guards stay on the median on purpose - a frame that collapsed toward zero and was
  not quite dropped has its fulls re-fitted with a scale of 1/G, a cubic mean is then theirs, and
  the floor read against it dropped three quarters of every live frame's fulls (measured).

- delta-CC1/2's sigma-tau statistic enters each reflection with the information it carries (the
  same per-frame factor as the CC1/2 weight), so a dead stretch, whose scaled-up noise flooded the
  mean error variance of every mixed reflection and read as harm up to the 25% cap, now costs
  about nothing and is left to the ledger.

- The scale loop's step test weighs each frame by its merge weight (its observations at its scale
  squared): a dead frame's scale is fitted on noise and wanders by orders of magnitude every
  iteration, carries nothing into the merge, is dropped after the loop, and must not hold the loop
  open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Where the pre-pass cannot tell whether its distance move paid, the two hypotheses were judged by
running each one as a full canonical pass - scaling, space-group search, correction surfaces,
every merge, the model step and the output files - although the comparison reads exactly one
number from each: the held-out residual the post-refinement measures right after integration,
before the scaling engine is even built. Where the header arm won, a third full pass then
reproduced the second's numbers digit for digit to write the files.

Both arms are now measured before the canonical pass by passes that stop as soon as that
post-refinement has measured, and only the arm that wins is run as a canonical pass. So a run
that takes the arm pays two image loops plus one canonical pass instead of two or three
canonical passes.

Result-neutral by construction - the residuals compared, the comparison, the geometry adopted
and the pass that writes are unchanged. Verified byte-identical p.mtz, p.hkl, p.cif and p_P1.mtz,
and identical reports bar the command line and the clock, on eight rotation datasets covering
both arms (seven where the header wins, one where the post-refined distance does).

Measured on the datasets whose logs show the arm: it takes an open-arm corpus pass from 261 to
218 minutes, the in-house arm from 25 to 22 and the private arm from 24 to 19, with the worst
single set going 594 -> 316 s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Both arrays are one record per integrated observation - tens of millions on a fine-sliced long axis,
gigabytes each - and both carry the raw hkl only to sort and group on. A Miller index needs sixteen
bits (|h| <= a / d_min, in the hundreds even on the longest axis at atomic resolution), which takes
the ingest sort key from 24 to 20 bytes and the post-refine partial from 32 to 28.

Same comparisons, same order; merged output byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A screw's predicted-absent class is one axial row - half a dozen to a few dozen
reflections - and its evidence is a SUM over them, so it is decided by its
largest member. The file's own LIMIT comment said so; 7n2s is that limit firing
on real data. Between two scaling passes that differed only in which weak frames
were rejected, one of eight dead 0k0 moved from 14 +- 9 to 99 +- 10 while the
other seven did not move at all, and the zone fell from 30.1 nats to 17.1 and
lost the 2(1) under a bound of 20. That reflection was never measured to the
precision its sigma claimed: its two half-set merges read 198 and 2.5.

The zone's sum is now taken with its single largest member dropped and rescaled
for the trim - divided by n - H_n, the expected sum of the other n-1 under the
null, and multiplied back by n. ScrewZoneEvidence reads the result exactly as
before: same statistic, same floor, same bound, same calibration, with a robust
estimate of the zone's deadness in place of a fragile one. One member only,
whatever the zone holds: a zone with two strong absences is a zone that is not
extinct. On a uniformly dead zone the rescale under-states by 2.2 nats at eight
absences and 3.9 at sixty-four - it only ever refuses, never claims. Glide zones
keep the untrimmed sum: a plane holds hundreds to thousands of reflections and
no single one can carry the verdict.

7n2s -> P 1 21 1 (zone 27.6 nats, set by the seven reflections that did not
move), matching its deposit; a second monoclinic crystal decided six nats under
the bound (7 absent, 1 violation, 13.9 nats) reaches 21.9 and its 2(1) as well.
Unchanged on 7mzt, 7k1l, 11if, 9hs7, 9zlo and four in-house reference sets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The zone verdict that arbitrates a refused promotion read its zone absolutely, against the
centric and acentric Wilson expectations, and an absolute reading is only as good as the
normalisation under it. On two refused 622 promotions - a 6/m crystal and a 312 one, whose added
operators disagreed at 4.7x and 5.2x the parent's H and merged to an R_meas of 0.34 under the
higher group - every class read centric: a 67 A^2 anisotropy, isotropically normalised in bins
of 100, spread one shell's expected intensity over a factor of 15 between its directions, and
the acentric control read 1.00 against its 0.74. The zone read the same as the control, +0.10
nats per reflection, and +124 nats over 947 reflections rescued a twin law.

Two things change. The anisotropy is fitted on the acentric reflections of the shells read
(ln I = c + s^T Q s, by least squares) and its deviatoric part taken out of every intensity
before anything is normalised; that alone brings the controls of the crystals measured to
0.73-0.77. What no normalisation removes, the control then certifies: an acentric population
reads -0.130 nats per reflection when the normalisation is right, a twinned one reads below
that, so whatever the control reads above it is the normalisation's - anisotropy, a
pseudo-translation, a pseudo-centring, noise all inflate every class towards centric alike -
and the zone, normalised the same way, carries the same per reflection; the calibrated evidence
has it taken off, and that is what the verdict reads. The two rescued twin laws now read -130
and -78 nats and stay refused, with the law named; the genuine promotions measured read +137 to
+580 (a pseudo-centred orthorhombic crystal whose control reads 0.99 still +334); the trigonal
and hexagonal partial twins -129 to -420 as before. The report prints the control's excess and
the calibrated evidence beside the raw one.

The metric-lattice re-ask carried a second copy of the two-arm rule without the zone: a P31
partial twin whose 32 the main rule had refused on the zone was promoted by that ask's
Lorentz-filtered arm. It now puts the same verdict to a refusal the other arm would outvote.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A screw axis whose row the sweep never recorded - it lies in the spindle's
blind cone, or outside the resolution range - is not a group the data refused,
it is a question nobody asked. The search already offered the whole set in
SPACE_GROUP_ALTERNATIVES, but the per-zone screw table that would say it in
words is printed for the SELECTED candidate only, and the selected candidate
in exactly this case is the one with no screw zones, so the run's account of
the open axis was a blank.

The search now names the axes on which two SELECTED candidates disagree about
whether the row carries screw absences at all, with why the row could not be
judged (never recorded / no control class). The report writes the axes as
SPACE_GROUP_SCREW_UNDETERMINED= beside SPACE_GROUP_ALTERNATIVES and explains
them in prose; the adoption logs a warning naming the axis and the set. An
enantiomorphic or origin-ambiguous pair predicts the same absences on every
row and is not named here - that ambiguity is the hand, or the origin.

Nothing about the decision moves: the group adopted, the alternatives and the
written .mtz/.cif/.hkl are exactly as before, because a reflection file cannot
hold "maybe a screw".

The battery scorer mirrors its existing "hand only" rule: a set differing from
its reference only by a screw the run reports as undeterminable, with the
reference among the groups it offered, scores unscored/screw_undetermined
instead of a sym_screw failure. All three conditions are necessary, so a screw
called wrongly where the row WAS measured stays a failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The full-resolution merge-degradation gate divided the added operators' mean
intensity-weighted R by the smallest R anywhere in the crystal. That denominator is an
extreme order statistic, so it has a pole: one unusually clean operator condemns every
other genuine one. It is not a rare accident. A rotation about an axis near the spindle
maps a reflection onto one recorded at nearly the same detector position, so the lab-frame
systematics cancel for that operator alone; measured over the rotation corpus the spread
WITHIN a genuine group reaches 2.0-2.6x, the whole width of the old bound of 2.0. On a weak
cubic crystal whose near-spindle 3-fold read 0.074 against 0.16-0.22 for its ten other
genuine operators, every one of them was refused, the search kept only the group generated
by the reference operator itself (which reads 1.00 by construction), and the same 222
hypothesis on the same merge read 2.45 under a cubic enumeration and 1.02 under an
orthorhombic one - a statistic that moves with which OTHER operators were enumerated.

The added operators are now placed on the scale the crystal itself defines, between the R
of unrelated reflections and the R of the best-agreeing operator:

    contrast = (random_pairing_r - r_added) / (random_pairing_r - global_best_operator_r)

random_pairing_r is new: the same intensity-weighted R over shell-matched pairs of
reflections no symmetry relates, measured on the merge (deterministic, no RNG). Both ends
are hypothesis-free, and the form has no pole - an unusually clean reference widens the
denominator by a few per cent instead of driving the divisor towards zero. The test
abstains where the best-agreeing operator is itself no better than half way to unrelated
reflections, i.e. where the merge holds no clean end to measure from, as it already
abstains when there is no second operator at all.

Calibrated over 149 rotation datasets with a known answer, read as the gate reads it:
genuine promotions reach down to 0.77 and the worst false one reads 0.695, so the bound is
0.72. It keeps every refusal the old ratio made on that corpus except the weak cubic one,
which now promotes to its cubic group; the twins that read above the genuine range are
refused by the H gate and the twin-immune zone, as before.

The reported value, the point-group report line, the finalist ledger and the
gate-fired-and-was-overridden note all follow the contrast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A series of one file per image was laid out end to end: the rotation start came
from the first file and the step from the difference between the first two, so a
series with frames missing came out compressed - one 179.8 degree deposited sweep
of 1108 files out of 1800 was read as 111 degrees, and every frame past the first
gap was analysed at the wrong spindle angle. Indexing then found a lattice that
took 4% of the validation spots, and two other gapped series aborted outright
with "it is not this crystal's lattice".

Every one of these formats writes each image's own start angle in its own header,
so the sweep is fully recoverable. The new reader/SweepLayout places each frame at
the slot its own angle puts it in and leaves a missing frame as a gap - a slot with
no file, which ReadRawImage reports as nothing to read, which every image loop in
the pipeline already passes over. The goniometer's start + increment * image_number
is then the true angle of every image, and the sweep range, the per-10-degree
delta-CC1/2 batches and the sweep-quality ledger all read the rotation the headers
describe. The rotation step is the smallest move between two frames that really are
adjacent, not the first pair.

The three readers shared this code by duplication; it is now written once. The same
place refuses what is not a sweep rather than averaging it into one: headers that
disagree about the detector distance, the beam centre, the wavelength or the
oscillation width, angles that do not sit on a single step (a folder of screening
shots), or two frames claiming the same angle - each naming the frames. A series
that does not turn at all is left exactly as it was.

A directory holding fewer files than its own numbering spans is also reported, with
both counts: that is the signal that a sweep was not unpacked or copied whole, which
otherwise shows up only as a resolution nobody can explain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Whether a sweep determines its detector distance at all was decided by running the canonical pass
twice - once at the post-refined distance, once at the header's - and keeping whichever realised the
lower held-out residual, the header's on a tie. The first-stage test that sent a run down that arm
compared the free and held fits on the POOLED held-out residual, and pooling is what made it
uninformative: the positional family cannot see the distance along the degenerate direction, so it
dilutes the one family that can, while the unpaired standard error of a heavy-tailed mean of squares
is 2-25 % of the mean against in-fit differences of 0.1-1 %. The test therefore said "cannot tell" on
four fifths of the fitted sweeps and the arm ran on most of a corpus, buying by prior what it could
not measure.

The two hypotheses are now compared on the one residual family that can tell them apart. The
excitation residual never involves the detector, so it is blind to the distance itself; what it sees
is the cell scale, and a held fit at a wrong header distance is forced into a wrong cell scale by the
spot positions, which the rocking angles then refuse - measured on a sweep whose header was 1.4 %
long, the held fit's held-out excitation residual is seventeen times the free fit's. Where freeing
the distance lowers that residual by more than its own standard error the free fit is committed as
before; where it does not, the held fit is committed - header distance, refined beam, cell,
orientation and axis - and the walk and the commit run held. The positional residual is deliberately
not consulted: its in-fit gain along the degenerate direction is the one re-integration erases.

The question is asked only while the run is still at the file's distance. A run that has walked off
the header has already refuted that hypothesis by re-integrating, and asking it again at every round
stalls a walk short of its fixed point, because the excitation standard error at the walk's tail is
outlier-dominated (measured: a walk stopped 0.4 % early, seven passes, cell 0.65 % off against
0.26 %, ISa 10.6 against 14.0).

So there is no arm, and with it go the two probe passes that measured it and the canonical pass
the losing arm used to cost: the decision costs two Ceres solves. On thirteen
rotation sweeps covering both verdicts, every decision the arm took by evidence or by its tie rule is
reproduced at the fit, except where the excitation family sees what the pooled test could not and the
free distance - the better cell against an external reference - is taken instead; merged intensities
are unchanged where the verdict is.

POSTREFINE_DISTANCE_HELD now means "the committed fit held the header distance", and is cleared where
a later geometry walk left it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The 8XBP deposition (RCSB, verified against the cached mmCIF) gives
C 1 2 1, 148.29 50.78 60.21 90 92.33 90, collected 2023-06-23. The
sweep in the open arm was collected 2023-06-21 and gives b = 51.83,
2.0% from the deposited b, with a and c agreeing to 0.1%; the reduced
primitive volume ratio is 1.02 and the set fails the lattice test.

DIALS 3.29 indexes the same master de novo at b = 51.777(11), 0.09%
from rugnux and 90 sigma from the deposit, and the deposited model
refines against the rugnux merge at R-free 0.30 / CC 0.89. The
deposited cell therefore does not describe these images.

ref_override supplies the DIALS cell in place of the deposited one.
The deposited space group and d_min are kept, and ref still records
the deposition. No scoring code and no threshold is changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The translational-NCS vector was refined only on d >= 4 A, the band its
detection gate reads, where an error of 0.02 costs the contrast almost
nothing. The L-test's partner steps and the twin-immune zone's per-class
normalisation then assign a class, cos(2 pi h.u), to reflections at full
resolution, where the phase error is |h| times the error in the vector: past
about |h| = 12 the classes were no longer the physical ones, and on one corpus
crystal they were anti-correlated with them over a whole band.

The vector is now re-refined against all the intensities, up a ladder that
doubles the number of reflections read at each step and starts each step from
the previous one's answer, so the phase is never extrapolated further than it
is known. The objective is the correlation between E^2 and cos(2 pi h.u) -
neither the gate's max/min bin ratio nor the fitted amplitude survives a
full-resolution population, both being ratios that run away where the cosine
has little variance. The gate itself is untouched: which crystals are called
is unchanged, only where the vector points.

Measured as that correlation in bands of |h|, before against after:
0.57/0.61, 0.42/0.52, 0.19/0.30, 0.14/0.18 on one crystal and 0.14/0.52,
-0.15/0.42, 0.00/0.20, -0.02/0.09 on another. The acentric control of the
twin-immune zone moves towards its analytic 0.736 where the classes change
(0.671 -> 0.748 on one), and no space-group or twinning verdict moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The "the cell metric carries more rotations than the class in hand" ask fired only
when the metric carried MORE. A lattice class is named from a Niggli-reduced cell on
relative tolerances, so it can also name more symmetry than the lattice carries: a
cell whose gamma is 1.4 degrees off 120 is named hexagonal, and one with a = b whose
gamma is 2 degrees off 90 is named tetragonal, where Le Page - which measures each
two-fold's obliquity - finds four rotations and a C-centred orthorhombic conventional
cell at 0.02 to 0.05 degrees. Asked only on ">", the comparison was then 4 against 12
(or 8) and the ask stayed silent, so the crystal was searched under a holohedry its
own lattice does not have, every operator of that holohedry was refused, and it fell
to a primitive monoclinic subgroup - the one mechanism built to rescue that case
disarmed by the very over-call it exists to correct.

Ask whenever the two differ. The ask adopts only on a strictly higher point group
confirmed by the same search with the same gates, so asking against a lower metric
class cannot demote; it costs one search.

Measured on three rotation datasets whose true lattice is C-centred orthorhombic and
which were indexed on its primitive sub-cell: two now reach the C-centred cell and
adopt C 2 2 2 and C 2 2 2_1, both agreeing with an independent POINTLESS run on the
same P1 merge to the third decimal of every cell edge. The third reaches the same cell
and is then refused by the merge chi^2 ratio at 1.96 against a bound of 1.85 - a
separate gate, untouched here. Eight datasets that decide correctly today, five of
them exercising this ask, are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A 2700 frame, 540 degree sweep was refused at load as "screening images taken at
scattered angles". Nothing about it is irregular: every consecutive difference is
a clean step, and the headers simply write the start angle modulo 360, so the
second and third revolutions repeat angles the first already had.

The unwrap handled that correctly. What failed was the STEP. It was taken to be
the smallest difference between two adjacent frames - a single recorded value, and
of all the samples the one biased furthest low by read-back noise. These headers
are written from 32-bit floats, so that one sample was 1.3e-4 low, and placing a
frame by dividing its distance from the FIRST frame by that step multiplied the
error by the frame number: by frame 1924 the drift had reached a quarter of a step
and the on-grid check refused the rest of the sweep.

The fix separates the two jobs the step was doing. The smallest difference is now
only a guess, used to count how many steps apart each pair of NEIGHBOURS is; a
local difference carries the noise of one reading and nothing accumulates, however
long the sweep. The step itself is then a straight line fitted through (steps,
angle) over every frame, so the noise averages out instead of one unlucky pair
setting the scale for thousands of frames. Over the whole corpus this changes the
step for 4 of 82 numbered series and leaves the other 78 bit-identical; of the 4,
three were drifting by 0.17 to 0.35 of a step across the sweep and one by 1e-8.

Counting steps between neighbours is also what carries a sweep past a full turn:
each fold is one step forward, so the count keeps climbing through 360 and a frame
taken on the second revolution lands beyond the first rather than on top of it. A
series that genuinely goes BACK - two sweeps of one crystal concatenated - folds to
a large negative count in a single pair and still collides, so that refusal keeps
working; its message now says what it means.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The alternating scale fit has a second degenerate direction beyond the gauge, and that one is not
harmless. A frame whose scale drifts below its neighbours' loses its vote in the references it is
fitted against - an observation's weight there goes as G^2 - which moves the references away from
it, which drives its scale further down. Where every frame is measured against hundreds of
reflections the references barely notice one frame and the loop settles; on a fine-sliced sweep of
a small cell, where a frame holds two or three reflections and a rocking curve spans a dozen or
more frames, the frames at the tails of the curves lose against the frames at their peaks and the
loop walks the bulk of the sweep down fifteen decades. The convergence test then said the loop had
settled, because its own weight (frame observations at G^2) vanishes for exactly the frames that
are collapsing.

So the scale is now smoothed over the rotation INSIDE the loop, at the window the merge already
smoothed G over afterwards (the post-loop smoothing is the same operation and is not repeated):
every frame in a window shares the window's geometric mean, no frame can lose its vote alone, and
what a frame's own observations cannot determine its neighbourhood's do. The window is floored at
six rocking curves - within one curve a change of scale and an error of the partiality model are
the same thing, and a window holding a curve or two fits the model's error as scale. The step test
is weighted by each frame's observation count alone, and a loop whose step has not fallen below
nine tenths of its smallest value for five rounds stops and says so rather than walking further.

Measured against the previous behaviour: a fine-sliced sweep that collapsed to P1 with CC1/2 0.000
merges in its true orthorhombic group at CC1/2 0.95; a monoclinic small-cell set goes from an
unusable 2.13 A merge to 0.81 A at R_meas 11.5%; another from R_meas 33% / ISa 2.2 to 6.1% / 14.2;
two powder-ring sets and a short-sweep tetragonal one improve. The space group is unchanged on
every protein control measured, and the short-sweep and de-novo orthorhombic gains of the
convergent loop are kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The Bravais class is chosen by a walk that reads two axes as EQUAL when they
agree to a fixed relative tolerance, and the class carrying that equality is
then imposed on everything below: the cell is refined with a = b, reflections
are predicted from it, and they are integrated at those predicted positions.
On a small cell the tolerance is far wider than the spot positions resolve, so
a genuinely orthorhombic crystal whose a and b differ by 2 % is integrated as
tetragonal and every decision downstream is read off the wreckage.

Measure the equality instead of assuming it. The indexer already refines each
candidate a second time with nothing held, so the first pass now reports how
far that free refinement leaves the two axes apart. A relative split of eps
displaces a reflection at radius r by eps/2 * r pixels: where that displacement
at the far corner of the detector stays inside the integration disc, imposing
the equality moves nothing out of its own box and the higher symmetry is kept
with no extra pass. Where it does not, both hypotheses are run as probe-only
passes - the promoted class, and the class the same walk carries when it is
granted no length equality at all - and whichever realises the lower held-out
positional residual is kept, the promoted class on a tie.

Two small-molecule sets whose axes differ by 2 % now index, refine and merge in
their own orthorhombic lattice instead of a tetragonal mean: one goes from a
cell 1 % wrong and P 1 at CC1/2 0.19 to the deposited cell within 0.2 % and
P 2 2 2 at CC1/2 0.95, the other from a tetragonal mean to a cell matching its
reference to 0.4 %. Protein sets whose symmetry is real (P41212, P4222, I23,
P6422, F4132) are unchanged: their free refinements leave the axes 0.02-0.10 %
apart, a few tenths of a pixel, so the question is never asked. Where it was
asked on a weak sweep whose free refinement diverged, the arms decided for the
higher symmetry and the output was identical.

No new threshold: the comparison is the integration radius the run already
integrates at, and the arms are judged by HeldOutResidualFell, as the geometry
walk's rounds are. postrefine_probe_only_ returns for the arms' sake - a pass
run only to measure stops before the scaling engine is built, so neither arm
pays for a merge or a space-group search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A screw zone's evidence drops its largest member to defend against one badly-measured
reflection whose sigma lies about it, and such a reflection is by construction weak - a
fraction of the row it sits on. A member that has passed the violation test AND stands
at or above the mean of its row's own present class is a different animal: not a
measurement that moved, but a reflection that is there. Trimming it removed the single
datum that refutes the claim, and the violation-count deferral then read the inflated
evidence to forgive the very violation that had been trimmed out of it.

Nested screw ORDERS are decided entirely on this. 6_1 extinguishes l != 6n and 6_2/6_4
extinguish l != 3n, so the two differ only on l = 3n not 6n. On a hexagonal crystal
whose 00l row holds five present reflections, the strongest of the whole row lay in
that difference: trimmed, 6_1 read the row as perfectly dead and won on the count of
absences alone - nine at 50.6 nats with one violation against seven at 43.3 with none
- and the run reported the wrong screw order with the right one ranked below it. With
the violation left in, 6_1 reads 6.8 and is refused. An independent POINTLESS run on
the same P1 merge puts the 6_1 condition at probability 0.000 and the 3n condition at
0.998.

Trim only among members that are not both flagged present and at full row strength.
Both halves of the condition are needed and the corpus separates them: the reflection
above stands at 1.94 of its row's mean, where two monoclinic crystals whose 0k0 are
genuinely dead carry one violation each at 0.31 and 0.74 of their row - the
mis-measurement the trim exists for, and one that costs a real 2_1 if it stays in.
Zones with no violations are bit-identical, and so is every candidate whose absent
class is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Smoothing the per-frame scale inside the loop runs before the guard that drops frames whose scale
collapsed, and the window is a geometric mean - so a frame the crystal did not diffract on was both
dragging its whole window down by its logarithm and being lifted toward its neighbours, which is how
it escaped the guard meant to drop it. Measured: on sweeps with a genuinely dead stretch the guard
went from eight fires at up to a million times below the median to five at six hundred times, and on
a long clean sweep with a dead block from six fires to none.

A frame already below the guard's floor is now left out of every window and keeps its own fitted
scale, so what reaches the guard is the scale the fit produced. The floor is the guard's own, read
the same way - no new criterion and no new constant.

The guard's fires come back where the fit still collapses those frames (on the two dead-stretch
sets to seven and six, at up to three and seven million times below the median; on the long clean
sweep to six, the count it had before), and with them the error-model asymptote: ISa 7.9 back to 9.2
on one dead-stretch set, 36.7 to 38.0 on the clean one. Where the restraint stops the fit from
driving a frame below the floor at all there is nothing to exclude and nothing changes. No result
moves the wrong way: the fine-sliced, small-cell and powder sets keep the merges the restraint
recovered - two of them improve, one by CC1/2 0.916 to 0.943 - and every space group is unchanged,
including the short-sweep tetragonal pair and the de-novo orthorhombic set the loop was fixed for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The beam-centre check indexes a second first pass at the measured centre and,
where the two centres return cells related by an integer volume factor, says so
and decides nothing. That abstention is right in general - the validation frame
count leans to the sub-cell, since a cell twice as long must place every spot
twice as accurately to score the same - but it leaves one direction
unrecoverable. Where the MEASURED centre holds the LARGER cell, the run starts
on the sub-cell and stays there: the second pass re-finds the larger cell at the
refined geometry and its supercell guard refuses it for being larger than
pass 1's. The run then merges a halved axis at full indexing rate, with good
merging statistics and nothing to say it is wrong.

Decide that one direction, on the pooled spots rather than on the frames: the
excess each lattice puts on itself over its own wrong-spindle null, which is the
measurement the failed-pass branch of the same check already adopts a centre on,
and the one statistic that does not credit a denser lattice for accidental hits.
The opposite direction is deliberately left alone - a second pass is free to drop
to the smaller cell and does, on every corpus crystal that reaches this branch
that way.

Measured on a tetragonal crystal whose header centre is 3.6 px out across the
spindle, more than the geometry absorbs: the file's centre gives a halved a axis
(43/60 frames, 25.4% of the pooled spots on the lattice) and the measured one the
deposited cell (60/60, 43.3%). Before, the run merged the halved cell and the
deposited model refined to R-free 0.575; after, it merges the deposited lattice
to 1.62 A with R-free 0.254 against the deposition's 0.203.

Five other corpus crystals reach the same branch with the measured centre on the
SMALLER cell; all five are unchanged (space group, cell and R-free identical), as
are two crystals whose halved or doubled axis comes from elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The departure from a = b that decides whether the metric-symmetry arms run at
all was read by pushing the free (triclinic) refinement through the search
result's `reindex`. That matrix is stale for a whole population: where the walk
matches a metrically hexagonal lattice on its C-centred orthorhombic character,
RotationIndexer re-expresses `conventional` in hexagonal axes but leaves
`reindex` describing the setting it replaced. The C-centred setting has
b = a*sqrt(3) BY CONSTRUCTION, so a against b there is
2(sqrt(3)-1)/(sqrt(3)+1) = 53.6 % apart on every hexagonal lattice there is,
whatever the crystal.

So the gate fired on the whole hexagonal and trigonal population - eighteen
sets in one corpus run, all reporting 53.1-53.7 % - and the arms then decided
two classes that fit equally well on differences in the third or fourth
significant figure. Three of those coin flips landed on the ortho-hexagonal
supercell and took a P 61 2 2, a P 65 2 2 and a P 31 with them.

The departure is now computed by LengthEqualityDeparture, which derives the
primitive-to-conventional map from the pair of cells the search result carries
instead of trusting `reindex`, and reads a against b in that basis. The two
cells describe one lattice, so the map is the integer relabelling of its basis
vectors. The measurement no longer depends on which character the walk happened
to match.

Re-measured, the hexagonal and trigonal sets report 0.00-0.41 %, the band the
tetragonal and cubic proteins were already in, and the gate does not fire on any
of them; the genuine pseudo-symmetric small-molecule cases stand alone at 0.98,
1.85 and 2.61 %. The three lost groups come back, and the sets that merely
tolerated the noise are bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Five open-arm rows do not reproduce their deposited space group or cell. Four are
scoring rows that disagreed with a published entry with nothing written down about
why, which is not a state a reader should have to discover from the manifest.

A tetragonal row whose deposited asymmetric unit holds two chains related by the very
two-fold the higher group adds, to 0.16 A CA RMSD - coordinate error at its
resolution - is overridden to the higher group. Merging there costs 0.0006 in R_meas
for 1.75x the multiplicity and correlates better with the deposited model, and an
independent reference implementation reads the same point group on our own P1 merge.
The refinement comparison is recorded as SPLIT: one purpose-built program makes the
higher group better at half the parameters and calls the deposited assignment
incorrect, an independent restrained refinement on a symmetry-consistent free set
makes the lower group better, and the gap between refinement protocols exceeds the
difference being measured.

One trigonal row becomes unscored rather than overridden: the evidence points at a
higher point group but the deposited copies differ by more than coordinate error, and
a purpose-built program settles on a different supergroup whose operators these data
do not support. The set runs; it is not scored.

Two trigonal rows had already been overridden, by editing the reference itself, with
no reason recorded. The deposition is restored verbatim to ref and the change moved
into ref_override, which leaves the effective reference and every verdict unchanged.
One of them carries strong centric-zone evidence; the other is marked OPEN, because
the one test that separates real symmetry from pseudo-symmetry reads against the
promotion the row is scored on, and may belong unscored instead.

The documentation frames all of these as open questions rather than as errors in the
PDB, and states the limit that applies to every one of them: a merohedral twin at a
fraction of exactly one half and a crystal that genuinely has the higher symmetry
predict identical intensities, so no test available to us can show that an operator
holds exactly rather than very nearly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Written on every rotation run, next to the reflections, with no flag to
switch them on:

<prefix>_detector.jpg - the mean projection the beam-stop pre-scan built,
which is the very image the shadow detection tested, coloured with the
viewer's own default map from common/ColorScale.h: white-to-indigo for the
counts, grey for the module and chip gaps, coral for the detected shadow,
magenta for the rest of the pixel mask. A shadow claimed where the
background is unbroken, or a dark patch left uncoloured, is then visible at
a glance instead of being a pixel count in the log. The projection is the
one the pre-scan already holds, so no frame is read twice; the picture is
rendered there and held as JPEG bytes until the canonical pass writes it,
because on a two-pass run the pre-scan belongs to the superseded pass.

<prefix>_plot.txt - one row per processed image, whitespace-separated with
a single '#' legend line over its own columns, so gnuplot plots it as it
stands: image, spindle angle, background, resolution, spot count, scale,
sigma_M, CC to merge, and whether the image's observations reached the
merged data. A quantity nothing measured is written nan, so the columns
never shift and no row is ever left out.

sigma_M is emitted as the pipeline produces it - a per-image Gaussian
rocking width, smoothed in frame order and filled with the run median where
a frame could not be fitted - and the documentation says so rather than
presenting it as a crystal property.

<prefix>_image.dat stays: the in-tree comparison scripts read it and it
carries the smoothing wedge, which the new file does not. The docs name
_plot.txt as the one to script against.

Measured on a 1800-image rotation sweep: 29.6/30.4 s before against
29.8/30.5 s after, and the merged reflections are byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A handful of open-arm rows disagree with their deposition on a knife edge that no test
available to us settles. They were handled three different ways - silently overridden to
our answer, marked unscored, or left failing - and none of the three says what is true:
either answer is acceptable as long as the program picks one of them.

A manifest row can now list `ref_alternatives`. Each entry replaces the reference fields
it names - a space group, a cell, or both - and the row passes if the answer matches any
of its references, the deposition included. `ref` keeps the deposited values verbatim in
every case. Every alternative must carry `why`: an accepted alternative with no stated
reason raises rather than passing, so the mechanism cannot be used to launder a failure.

The report keeps these rows visible rather than folding them into the passes: a summary
column counting them, their own segment in the verdict bars, and a section naming each
row, what we read, what was deposited and the reason both are accepted.

Five rows use it. Four are symmetry: a tetragonal row where the refinement test is split
and its spread exceeds the effect, and three trigonal rows where we read a higher point
group - one where the evidence favours our answer, one where our own twin-immune test
favours the deposition, one unresolved in either direction. The fifth is a cell: a real
tNCS supercell whose (0,1/2,1/2) sublattice is what was deposited, both being correct
descriptions of the same lattice. The documentation frames all of them as open questions,
not as errors in a deposition, and states the limit: a merohedral twin at exactly one half
and true higher symmetry predict identical intensities, so no test can close them even in
principle.

`test_score.py` covers the new path: both answers accepted, the other hand of an
alternative, a third answer still failing, the cell case, and the missing-justification
schema error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Four lines for 118 commits: the broker GPU-memory leak that cancelled
collections, the rotation scaling loop now running to convergence (which
moves merged intensities and every decision read off them), the
per-frame sweep placement that makes gapped and angle-wrapped CCD/CBF
series read correctly, and the two diagnostic files a rotation run now
writes without being asked.

Left out deliberately, as developer-facing or niche: -A no longer
changing what a rotation run decides, --report-resolution, the R-free
holohedry keying, the mmCIF twin loop, and every internal refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The twin-law report block dereferenced the adopted space group without the
guard every other use of it carries; a de-novo run whose search refused a
point group but left no adopted group would crash there, after the merge.

The joint post-refinement's distance-correlation array was read on every run
that holds the header distance, but only written when the solve is usable, so
an unusable first solve printed four indeterminate values as correlations.

The merge-degradation gate ran neither of its two branches when the data
confirm more than one operator and the best of them is itself close to the
random-pairing end: the contrast has no scale to read on such a merge, but the
candidate was then promoted on no merge-degradation evidence at all. It now
falls back to the noise-floor ratio, as it does when only one operator is
confirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The screw_undetermined rule compared the axial rows of our answer with those of
the reference as deposited. Which axis carries the disagreement is a property of
the setting, so where the two cells are a permutation of each other the rule
looked at one axis and SPACE_GROUP_SCREW_UNDETERMINED named another, and a row
whose open screw the run had explicitly declined to decide was scored as a wrong
space group. The comparison now runs against the offered alternative of the
reference's number, which is already written in our axes; a group offered under
more than one setting fires if any of them fits.

Verdicts moved on 7mzt only (fail/sym_screw -> unscored/screw_undetermined) in
both persisted runs re-scored; no other row changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The report decided whether to name a twin law by substring-matching
ProcessResult::twin_zone_decision, a field every zone read overwrites.
A run reads the zone up to four times, so the match saw the last
comparison made rather than the refusal that stood, and TWIN_LAW and the
_pdbx_reflns_twin mmCIF loop could appear on a crystal whose refusal was
overturned - or be missing on one whose refusal stood.

TwinZoneVerdict already returns the verdict; it now records it on the
result, and the decision point reads that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
twin_zone_decision was documented as empty when no twin gate fired, but
an undecided read - no zone reflections, or evidence inside the margin -
wrote it too, and the report then printed "a twin gate refused this
promotion, and the twin-immune zone of the operators it adds decided it"
over a line reading "undecided".

An undecided read now goes to the log and leaves the field alone, so the
field holds what was decided and the prose is true of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A lattice arm that indexed nothing (or post-refined nothing) measures no
held-out residual, and PASS_DECISION then read "realised a held-out
residual of nan against 1.234e-03 ..., so the metric symmetry stands" -
a comparison the run could not make, stated as one it did. The fallback
itself is right: the metric symmetry stands. Only the claim changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
SweepLayout rejected any inter-frame difference below half the header's
oscillation width as read-back jitter. A series that steps by less than
it exposes - overlapping wedges, or a writer that puts the whole sweep's
range in that field - then had every difference rejected, no step was
found, and the files fell into the "series that never turns" branch,
which lays them out end to end and, unlike the gapped path, said
nothing. The jitter bound is now absolute (1e-4 deg, below any step an
instrument makes and above 32-bit float read-back noise), and the
end-to-end fallback warns when the angles do span a rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
MarCCD::CanRead and SMV::CanRead read the first plausible file of a
directory outside their own try, and the CLI made all three CanRead
calls outside the try that reports a bad input. "rugnux <dir>" whose
alphabetically-first plausible file is unreadable therefore terminated
with no message. CanRead now answers false for anything it cannot read,
and the CLI asks the question where it can report the answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The header offset comes from TIFF tag 34710 unchecked and is handed to
ReadPrefix, which allocates that many bytes before reading. This runs
during format autodetection, on files nobody has said are marCCD, so a
foreign or corrupt TIFF could have the program allocate ~4 GB for a
probe. The probe now reads at most what the file holds; a short read was
always the normal outcome and the caller already checks the size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The scanned circle was read as the first start/end pair that differs,
and two_theta is the first pair in the goniostat block. A file that
leaves its end block at zero while the detector sits on a swung-out
2-theta arm therefore named the arm as the spindle and stopped before
reaching phi; with no rotation range recorded either, the increment fell
back to -start_angle, which differs per frame, and the sweep was refused
outright.

The header's own rotation_axis index (offset 732) is taken where it
names a spindle circle, and the pair scan is the fallback for files that
never filled it in - read past two_theta, which no sweep turns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
AnalyzeTwinning takes the lattice centring so that the reflections the
centring extinguishes stay out of both statistics, but only the
pre-promotion test passed it. The final, reported verdict and --mode
scale did not, and a merged ASU in a centred group still carries keys in
the absent class - noise, and an L-test partner step landing on one
pairs a real reflection with an absent one. Both now pass the adopted
group's centring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
r_contrast_bound carried a calibration (0.70 against a bound of 0.65)
that min_operator_r_contrast and the ledger text no longer agree with;
TrimmedZoneSum's header still promised "its single largest member
dropped", which the caller no longer supplies since the screw fix
withholds a member standing at or above its row's mean; and
mean_relative_scale still said "/ run median" though the gauge is now
TypicalFrameScale. Comments only - no behaviour changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
_image.dat carried the per-image scaling table before _plot.txt existed, and the
two were written from the same ScalingResult with six columns between them. Drop
it: the writer, its call site and its documentation entries go, and _plot.txt
gains the one column of the old file that was not already there and is not dead -
cc_n, the number of observations cc_to_merge was taken over, without which that
correlation cannot be read at the sparse end of a sweep. The old file's wedge_deg
is NOT carried over: image_scale_wedge_deg is only ever reset, never assigned, so
that column was nan on every run that ever wrote it.

_plot.txt is now written on a stills run too - it is the only per-image scaling
table there is, and the columns a sweep would fill come out nan - while the
picture stays rotation-only, as only the rotation pre-scan builds the projection
it draws. "merged" reads nan rather than 0 where no merge stands behind the run,
so --no-merge no longer looks like a sweep every frame of which was thrown out.

The picture's contrast now comes from the background instead of the peaks: the
ramp saturates at eight times the median measured pixel, where it used to
saturate at the brightest 0.1 % as the preview and the viewer do. That percentile
belongs to the Bragg spots, so the background - which is what a shadow is read
off, being a place where it is MISSING - sat in the first few per cent of the
scale and rendered as a flat white. The median is pure background whatever the
crystal, and eight times it falls between the 94th and the 99th percentile of the
projection on every set measured. Two times the median, the first value tried,
falls at the 73rd to 90th instead and floods the inner half of the detector to a
flat indigo, hiding the deficit at the other end of the scale. The colours are
untouched - they are the viewer's, from common/ColorScale.h - and only the
intensity mapping moves.

Processing is unchanged: p.hkl and p.mtz are byte-identical before and after on a
rotation sweep, and the wall clock does not move.

rugnux_stills_ab.py reads scale_G from _plot.txt instead of the retired file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Two cases on the existing byte-for-byte marCCD fixture: a file whose
end-angle block is zeroed while the detector sits on a 2-theta arm must
still name phi as the circle that turned, and a file whose tag 34710
names an offset far past its end must be refused rather than allocated
for. Also drops a bound in SweepLayout that the absolute jitter
threshold makes redundant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
With the format probes no longer throwing, a directory no reader claims
fell through to the HDF5 reader, which is never what a directory is: the
answer - that no one-file-per-image sweep was found in it - came out
buried under a page of HDF5 stack output. It is now stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The zone table is read on the P1 cross-check merge but was normalised by
result.tncs, a pseudo-translation refined on the adopted group's merge. Where
the pseudo-translation is not compatible with the adopted symmetry that merge
has already averaged across its own phase classes, so the vector fitted there
is poorly determined: measured on one crystal, the correlation between E^2 and
cos(2 pi h.u) reads 0.04 on the group merge against 0.25 on the P1 one, with
the optimum elsewhere entirely. A vector that names the wrong classes
normalises the zone by noise. That merge is also the one a committed change of
basis re-indexes, so a vector measured before the search is not even in its
frame.

Measure it on the P1 cross-check merge itself instead. The nearest existing
vector, pre_tncs, is out of scope there and predates any change of basis, so
this is one extra AnalyzeTranslationalNCS (+2 to +10 s on the crystals
measured, the largest on a 800k-reflection P1 merge).

Report-only, and verified so: over five rotation crystals with a detected
pseudo-translation the merged reflections (p.hkl, p.mtz, p.cif, p_P1.mtz) are
byte-identical, and the adopted space group, the twinning verdict and
TWIN_ZONE_DECISION - the deciding path, which takes its vector from the merge
the search ran on - are unchanged. What moves is the reported
TWIN_ZONE_n/TWIN_ZONE_CONTROL rows: <|E^2-1|> by up to 0.02 on a zone and 0.06
on a control, the zone evidence by up to 35 nats out of 1000-1600.

Also say in the pseudo-symmetry text which vector the modulation belongs to:
the ratio is read on the search's own vector over d >= 4 A, which is what its
control is drawn against, while the vector printed beside it is that one
re-refined on the whole data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The point-group consistency gate refuses a promotion whose merged reduced
chi^2 exceeds max_merge_chi2_ratio times the best subgroup's. A merged reduced
chi^2 decomposes as a^2 + b^2<(I/sigma)^2>: a measurement part a fitted error
model pins near 1, plus a systematic part. The ratio is meant to compare the
systematic parts, but it divides by the total, so the allowance it grants is
(ratio - 1) x (a^2 + b_ref^2<(I/sigma)^2>). On a reference merge that is
cleanly scaled the systematic term vanishes and only the fixed a^2 term is
left, which makes the "ratio" an absolute bound on the added disagreement
scaled by the crystal's own <(I/sigma)^2> - so the better the data are
measured, the harsher the gate, and a crystal is refused for the cleanliness
of its own reference merge.

Measured on one crystal collected twice at two resolutions: the added
operators cost the same absolute chi^2 (+1.16 and +1.07) and the same
systematic b (0.080 and 0.074) both times, but the cleaner reference
(chi2_ref 1.21 against 1.44) turned that into 1.96x against 1.74x, and only
the better-measured of the two was refused its centred orthorhombic group -
which merges to higher completeness, higher multiplicity, higher I/sigma,
lower R_meas and higher CC1/2 than the monoclinic subgroup it was held in.

So below chi2_ref_informative the ratio is not read. The reference has no
systematic error to be a denominator, and the candidate is judged on the
absolute form of the same question instead, against the standard
chi2_ref_reliable already sets: the merge under it must itself look
calibrated. That keeps the degenerate case the ratio read correctly - a
noiseless reference against a false operator whose own merged chi^2 is in the
thousands - and leaves the H and added-operator-R gates untouched as the
necessary conditions no abstention can lift. max_merge_chi2_ratio is
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The zone that arbitrates a refused point group is read on the merge that
follows a committed change of basis (Rugnux.cpp: the reindex re-indexes every
integrated reflection and re-ingests the rotation merge, and the merge the zone
reads is made after that), but the pseudo-translation it was normalised by is
measured on the search merge, before it. The zone spends that vector assigning
each reflection a phase class by cos(2 pi h.u), which is a phase class only if h
and u are in the same basis; left in the old one it partitions the reflections
at random and normalises the zone by noise, with nothing in the numbers to say
so.

The reindex is (h,k,l)_new = M (h,k,l)_old, so h.u is preserved by
u_new = (M^T)^-1 u_old - the ordinary transformation of a real-space fractional
coordinate. Transform rather than re-measure: the vector is better determined on
the search merge (a merge already folded under the adopted symmetry has averaged
across the very phase classes the vector names), the transform is exact, and it
costs nothing where re-measuring costs a second pseudo-translation analysis.

Reachability: over eleven battery runs no crystal both commits a change of basis
and carries a detected pseudo-translation into a refused point group, so no
merged result in the corpus moves. The population that commits one has just
grown, which is why it is worth fixing now rather than when it first bites.

How wrong the vector is depends on it. A half-integer vector compatible with the
committed centring survives unchanged - the two expressions differ by a lattice
vector and every allowed reflection sees the same class. A general one does not:
for an I-centring commit, cos(2 pi h.u) read in the two bases correlates -0.01
over the allowed reflections and disagrees in sign on 51% of them.

Verified unchanged where nothing is committed: three rotation crystals with a
detected pseudo-translation - two of them arbitrating a refused point group on
this zone, one of them committing a reindex without a refusal - give
byte-identical output.hkl, output.mtz, output.cif, output_P1.mtz and
output_unmerged.mtz, and the same space group and TWIN_ZONE_DECISION.
[tncs], [twinning] and [SearchSpaceGroup] pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
rc.172 stopped two CUDA failures from staying behind as the thread's last error, both inside
CUDAMemHelpers.h: a pooled allocation that fell back to cudaMalloc, and a stream that could not be
created. The reason those mattered was general, and four other places have it too.

Nothing but cudaGetLastError() takes an error away. A later successful call neither clears it nor
returns it - measured: after a cudaMalloc that cannot be served, cudaStreamSynchronize,
cudaMemcpyAsync and a small cudaMalloc all report success, and the "out of memory" is still waiting
for whoever calls cudaGetLastError() next. In this code that is the check after a kernel launch. So
wherever a CUDA failure is caught and the work carries on by another route, the error it left is
reported over the route that went fine:

  - the device decoder in MXAnalysisWithoutFPGA::Analyze, whose failure is answered by decompressing
    on the host - and the host route runs kernels of its own. This is the broker's path, and it is
    the pair of log lines a long-running broker showed, one warning naming the device route and one
    fatal error naming nothing.
  - the indexer worker, which turns a failed attempt into a result and goes on to the next frame -
    on the same thread, so one failure would read as an indexer that never works again.
  - the beam stop's GPU accumulate and the FFT beam-centre capture, both of which fall back to the
    host.

Each discharges it now, through cuda_clear_error() in CUDAWrapper (a no-op without CUDA). A sticky
error is not cleared by this and nothing here pretends otherwise.

tests/CudaHandledErrorTest.cpp: that a failed allocation leaves an error which survives later
successful work until it is cleared, and that an image whose device decode fails comes out of
Analyze whole and leaves nothing behind for the next one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
CI: a hand-built release does not re-run the unit tests
Build Packages / Create release (push) Successful in 16s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m22s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m52s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m14s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m48s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m3s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 15m47s
Build Packages / build:windows:nocuda (push) Successful in 17m52s
Build Packages / build:windows:cuda (push) Successful in 20m26s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m55s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 19m30s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m23s
Build Packages / build:rugnux:windows (push) Successful in 10m59s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 19m56s
Build Packages / Build documentation (push) Successful in 1m36s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m53s
Build Packages / build:rpm (rocky8) (push) Successful in 17m33s
Build Packages / build:rpm (rocky9) (push) Successful in 18m1s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 14m9s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 11m13s
Build Packages / Unit tests (push) Successful in 1h11m43s
d10f48cb9a
The push that put the commit on a branch has already run the suite on that
very commit, and the merge to main runs it again. Only the manual
release build skips it, and only because the wait there is pure delay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The per-frame scale is smoothed over the rotation inside both scaling loops, which is what stopped a
fine-sliced sweep of a small cell from walking its own scales down fifteen decades. But the window is
a prior about the dose and the crystal, and on a merge where one frame carries hundreds of
observations the frame's own data outvote it. Smoothing them anyway does not remove the per-frame
structure, it moves it into the error model: on a 360 degree merge at 14x multiplicity the scale the
window averaged away is 2.2% rms, which is most of the 2.4% of unmodelled error the change added
there, and it cost ISa 29.0 -> 23.8 and 43.3 -> 38.0 on two long high-multiplicity protein sweeps.

So the loop over the fulls now ends with one free fit against the settled reference, shrunk frame by
frame back toward the restrained scale by how much of the difference is real. Real and noise separate
with nothing to tune, because a rocking event spans several frames and the fit's noise does not:
whatever moved a frame's scale moved its neighbour's, so the lag-1 covariance of the difference is
the systematic's variance and what is left of the total is the fit's. A sweep that measures its
scales to a fraction of a percent keeps nearly all of what it measured; one sliced at a twentieth of
its rocking curve, where a frame holds twenty-five fulls and the difference has no correlation with
its neighbour at all, keeps none of it and is scaled exactly as before. Neither is told which it is.

Only the fulls, and only off a settled loop. A partial's scale is the direction the collapse runs in
and handing any of it back re-opens it - shrunk or not, it costs ISa 23.8 -> 14.8 on the same merge
and takes a small-cell monoclinic set from 14.2 to 2.7 - while a full is a whole rocking event and
cannot enter it; and a loop that walked until it was stopped has measured nothing to hand back.

Measured against the current behaviour. Two long high-multiplicity protein sweeps: ISa 23.8 -> 27.0
and 38.0 -> 41.3, with R_meas and the anomalous signal moving with them (SigAno 0.929 -> 0.949,
CC_anom 0.18 -> 0.22 on the first). A long-wavelength, high-multiplicity series collected at three
goniometer settings - the case the restraint is right for, sliced twenty times finer than its rocking
curve: ISa 13.7 -> 13.9, 14.2 -> 14.6, 12.5 -> 12.8, SigAno up on all three, and the anomalous peak
heights at the deposited model's own sites unchanged to a tenth of a sigma. The fine-sliced, small-cell
and powder sets the restraint was added for are unchanged to the last reported digit, as are the
short-sweep tetragonal pair and every space group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The added-operator gates read a MEAN over the operators a promotion adds, so they are
blind to an added set that MIXES real operators with false ones: the mean sits between
the two populations and passes. A pseudo-symmetric metric produces exactly that. On open
set 5ebi - a monoclinic crystal whose lattice is exactly hexagonal (a = c, beta = 120) -
the eight operators the 622 promotion adds over 6 include two genuine 2-folds at the
noise floor (R 0.067, 0.069, against a merge floor of 0.067) and four false ones at
0.227-0.263; the mean of 0.187 reads contrast 0.74 against the 0.72 bound and the group
is admitted. The run merged in P 6(1)22 where the deposition is P 1 2(1) 1, at R_meas
0.319 and ISa 2.3 instead of 0.101 and 10.8.

POINTLESS on our own P1 cross-check merge is unambiguous: P 6/m m m likelihood 0.051
against 0.854 for the true Laue class, with the 3-fold and 6-fold reading Rmeas 0.40-0.42
where the identity reads 0.098 and the three genuine 2-folds 0.113-0.126. Our own operator
table says the same thing; only the summary over it does not.

So ask instead that the candidate's operators agree with EACH OTHER - a point group is
the claim that all of its operators are symmetries of one crystal, so none of them may
reproduce the merge several times worse than another does. Measured over the 129 rotation
datasets of one battery run that adopted a group of order 3 or more, as the worst-agreeing
over the best-agreeing operator within the adopted group: every correct adoption sits at
1.0-2.64 (the highest being the weak cubic crystal whose near-spindle 3-fold is unusually
clean - the one that motivated the contrast gate), and the single over-called group reads
4.87. The bound is 3.2, with 0.56 of headroom below and 1.67 above, and deliberately well
clear of the 2.0-2.6 spread a genuine group reaches - which is where the retired
ratio-to-best gate's bound of 2.0 sat, and why that gate had to go.

This refuses 622, both 32 rungs and 6 on that crystal and adopts 2, restoring the
P 1 1 2(1) the search gave before the contrast gate landed. The statistic is read within
one candidate, never across candidates, so it adds no new normalisation by a reference
measured on the same crystal. It is reported in the refusal text and as a ledger column.

Controls, all at -N 32 against the same-commit baseline binary: the ten sets with the
largest operator spread in that run are unchanged and still correct, including the two
nearest the bound at 2.64 and 2.30. Two of them differ from the stored battery row only
in the screw hand, which the baseline binary reproduces at the same thread count.
SearchSpaceGroup, MergeStats, HKLKey and ResolutionCutoff test cases pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Merge branch 'd-5ebi' into rc172-2
Build Packages / Create release (push) Successful in 14s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m19s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m35s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m19s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m8s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 16m4s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m54s
Build Packages / build:windows:nocuda (push) Successful in 17m27s
Build Packages / build:windows:cuda (push) Successful in 19m56s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 15m28s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 23m18s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 17m34s
Build Packages / Generate python client (push) Successful in 1m4s
Build Packages / build:rugnux:windows (push) Successful in 10m42s
Build Packages / Build documentation (push) Successful in 1m15s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 18m21s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m0s
Build Packages / build:rpm (rocky8) (push) Successful in 17m39s
Build Packages / build:rpm (rocky9) (push) Successful in 17m50s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 15m7s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 15m25s
Build Packages / Unit tests (push) Successful in 1h13m6s
881d449d2e
The scatter of a Friedel-merged reflection contains the anomalous difference, so
an error model fitted on it books that difference as instrument error. Measured
on a native-sulfur set at 6 keV: sqrt(ab) reads 0.0565 pooled against 0.0531 on
the hands, an absorbed term of 1.9% - the textbook Bijvoet ratio of that crystal
at that energy, reported as detector noise - and ISa 17.7 instead of 18.9.

An error-model sample is now the residual about its own HAND's mean wherever the
hand has two usable observations of its own, about the pooled pair where it has
not. A hand's own scatter never contains dI whatever its size, so this needs no
"is the anomalous signal strong?" gate and holds in either mode; it is what
dials.scale does, which builds its error-model table Friedel-separated
unconditionally. A centric has I(+) = I(-) by symmetry and is not split, and a
merge that already separates the hands has its group AS the hand and splits
nothing, so nothing outside a rotation merge moves.

Only the (a, b) fit reads the hands. The merge weights, the per-frame scaling,
the half-sets and every decision keep the one Friedel-merged grouping of the
previous step.

Measured bare on nine sets, ordered by their own CCanom. The absorbed term falls
with the anomalous signal - 2.4%, 2.3%, 1.9% on three low-energy sets, 1.4% and
1.1% on two long-wavelength ones - and on the three sets with no anomalous signal
there is nothing to remove: sqrt(ab) comes out marginally larger, ISa 1-2% lower.
ISa rises by 11.5%, 9.3% and 6.4% where the signal is strongest. Space group,
resolution, completeness and anomalous multiplicity are unchanged everywhere.

What this does NOT do is add anomalous signal, and the claim should not be read
that way. The anomalous difference map read at the deposited model's own sulfurs
moves by at most 0.05 sigma on eight sets, and CCanom - a correlation, which no
error model can move - by less than 0.01. This makes the reported uncertainty
honest, so a downstream program that weights by sigma is given an honest one; the
differences themselves are the same differences.

Merged intensities therefore change on every set - the fit changes, so the sigmas
and the weights do - and there is no set on which this is bit-identical. There
cannot be one without the strength gate the design rules out. The runs are
bit-reproducible, so none of the above is scatter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A flat n-sigma test against a pooled Friedel pair's median deletes a hand of
every reflection whose anomalous significance |dI|/sigma(dI) exceeds n sqrt(m):
S > 12 at multiplicity 4, S > 24 at 16. On native sulfur nothing is lost; on a
Se-peak or derivative set it is the top of the difference distribution that
goes, which is the part a substructure search ranks first.

The cut becomes n * sqrt(sigma^2 + v_anom), where v_anom is one observation's
share of the shell's own measured Bijvoet variance - the MEDIAN over the shell's
pairs of dI^2 - sigma^2(dI), clamped at zero and quartered, because an
observation of one hand sits dI/2 from the pooled mean. A median and not a mean:
a mean would let the one wild pair the test exists to catch inflate its own
tolerance. Where a shell has no anomalous signal this is the plain n sigma, so
there is no "is the signal strong?" gate anywhere in it.

v_anom is measured inside the pass that uses it, from the same quantity the
SigAno accumulator reads and with the error model's own sigmas. The design took
it from the previous pass; that would have made the written merge depend on how
many merges the pipeline happened to run before it, which is not a property of
the data.

Measured bare on nine sets. Rejections fall exactly where the anomalous signal
is: -60% on a 3.3 A long-wavelength set, -31% on a 6 keV one, -28% on a 5 keV
lysozyme set, and by nothing at all on the three sets with no anomalous signal.
Nothing else moves: space group, resolution, completeness, anomalous
multiplicity and ISa are unchanged everywhere, R_meas and CC1/2 in the fourth
digit. The anomalous difference map at the deposited model's own sulfurs is
unchanged or very slightly better on all four sets it was read on (mean peak
+0.05, +0.01, -0.06, -0.02 sigma), so nothing that was signal is being thrown
away and nothing that was noise is being kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Since a rotation run decides with Friedel's law on, -A changed nothing at all -
not even the table, which stayed the Friedel-merged one. That made the report
unreadable against an anomalous CORRECT.LP: XDS with FRIEDEL'S_LAW=FALSE counts
its uniques, multiplicity, completeness, R_meas and CC1/2 per hand, and a
Friedel-merged table has roughly twice the multiplicity of one.

The merge now also reports itself counted with Friedel's law OFF - an acentric
reflection as two reflections, one per Bijvoet hand, a centric as one - as
ANOM_* and ANOM_REFRES_* keys and a shell table beside the run's own. It costs
no second merge: the per-hand accumulator that SigAno, CCanom and the I(+)/I(-)
export are already formed on carries every number in it, and the completeness
denominator is the same enumeration counted two per acentric. The intensities,
the scaling, the error model and every decision behind both tables are the
Friedel-merged ones, so this is a second way of counting one merge.

-A is therefore presentation only: it selects FRIEDELS_LAW, which says which of
the two tables was asked for. Both are printed either way. On a 6 keV set the
per-hand table reads 15.54 multiplicity and 95.1% completeness over the
reference range against the Friedel-merged 28.65 and 97.5%, where the XDS
reference is 12.42 and 92.4%.

Report-only: the written reflections, the MTZ and the mmCIF are byte-identical
to the run before it, and an -A run is byte-identical to a bare one but for the
FRIEDELS_LAW line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
The XDS arms passed -A wherever the reference had been measured with
FRIEDEL'S_LAW=FALSE, so that the merging statistics would be comparable. That
reason is gone: a rotation run decides with Friedel's mates merged whatever -A
says, and now reports itself counted both ways out of that one run, so -A is
presentation only and passing it changes nothing a battery reads. Verified on a
6 keV set: the reflections, the MTZ and the mmCIF of an -A run are byte-identical
to a bare one, and the reports differ in the FRIEDELS_LAW line alone.

Comparability now comes from the report instead. Where a reference kept the
mates apart, the scorer takes uniques, completeness, multiplicity, <I/sigma>,
R_meas and CC1/2 from the run's ANOM_REFRES_* block - the same merge counted
per Bijvoet hand, which is how XDS counted. ISa is not among them: there is one
error model, it is fitted on the hands, and REFRES_ISA is it.

The report reader also learns the third shell table, so it stops taking the
per-hand table's first row for the reference range's low-resolution shell.

Re-scored on six sets. The anomalous rows stop comparing a Friedel-merged
multiplicity against a per-hand one (28.65 against XDS's 12.42 becomes 15.54;
19.69 against 8.85 becomes 11.09), completeness moves 1-2 points onto the
reference's own footing, R_meas ratios move a few percent and the CC1/2 noise
ratio rises, as it must when CC1/2 is read at the reference's multiplicity. No
verdict moves: pass and fail are decided on the lattice, the space group and the
resolution, none of which these numbers reach. The two non-anomalous rows score
exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
R_WORK and R_FREE score the data against a model scaled by k_overall, a
symmetry-constrained anisotropic B and a flat bulk solvent. That scale is
deliberate - a free per-shell rescale would reshape each map's radial
amplitude profile and a batch of maps would no longer be comparable - but it
can only bend one way with resolution, so whatever a dataset's own radial
profile does that k*exp(-B s^2) cannot follow is reported as R. Two reductions
of one crystal whose amplitudes differ radially therefore differ in R_FREE for
a reason that has nothing to do with either fitting the model: measured on one
such pair of runs, R_FREE moved a median +0.042 over eleven sets where
phenix.model_vs_data, which scales per shell, moved +0.002.

Report a second reading of the same fit: R_MODEL_SHELL_SCALED, one free scale
per resolution shell of the merge table, with R_MODEL as the same sum without
it and MODEL_RADIAL_MISFIT (RMS of ln k_shell) as the size of the rescale, so
the reason a comparison is or is not readable is on the page. Over all the
reflections, not the free 5%: nothing here is refined, so work and free
estimate the same quantity and the split only costs precision.

Nothing is written from it. On two sets, every output file - both maps, the
anomalous map, the map coefficients, the MTZ, the HKL and the CIF - is byte
for byte what the same commit produced without this change, and R_WORK and
R_FREE are unchanged.

On the pair above, the new R moves a median +0.0005 and holds every set
inside 0.0065 except the one that is a real regression, which it puts first
at +0.0174 where the old metric ranked it seventh. The battery reports it
beside R_free, and scores neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
R_model with one free scale per resolution shell is the number that can be
read between two runs; the placement R beside it cannot, because the fixed
model is scaled by k and an anisotropic B only and a change in the
amplitudes' radial profile has nowhere to go but the residual. Both are
still printed, with the radial misfit next to them, so a reader can see
when the placement R stopped meaning anything.

Nothing is scored on any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Every symmetry equivalent of a reflection sits at one d, and an ASU group's
reference is that group's own weighted mean - a free parameter. A surface factor
that is a function of d alone therefore multiplies a whole group identically and
cancels out of the fit exactly: with A radial, the weight 1/(sigma corr A)^2 and
the scaled observation I corr A both carry A, and the round's `cross` and `ref2`
come out numerically independent of it.

So the resolution direction of these surfaces is a null direction of the
objective, not a slowly-converging one. The update along it is the same on every
round, and the surface walks at constant speed until the [0.25, 4] clamp stops
it. "NOT settled in 30 round(s)" is that walk, not slow convergence, and raising
the cap from 3 to 30 took it ten times further rather than converging it. What
pushes it is the `Is > 0` filter, which drops the negative half of a weak cell's
observations and leaves the survivors up-fluctuated - a bias that is a function
of the cell's signal-to-noise, hence of resolution. Nothing can see it: a factor
shared by a whole group cancels out of R_meas, CC1/2 and chi^2, and the even/odd
cross-validation that gates the surface carries it equally in both halves.

Simulated on synthetic data with a flat truth, an outer shell at I/sigma = 1 is
suppressed 4% after 3 rounds and 24% after 30 while the held-out gain reads 0.1%.
Measured on five open-arm sets, the shipped surfaces suppress the outer shell by
2-13x against the depositors' own structure factors - an independent measurement
of the same crystal forms, and no model.

The direction is now projected out at the parameterisation instead of being
bounded by an iteration count: after each round the surface is divided by its own
weighted geometric mean within each resolution shell, leaving the azimuthal and
positional variation that absorption and a flat field physically are. The
projection removes the overall level too, so it subsumes the old global gauge.
On the same simulation it recovers a 12% cos(2 phi) flat field to CC 0.986 at
full amplitude while the radial ramp goes to 1.00.

The surfaces' log line now also reports the ramp the gauge removed (45x and 56x
on two of the five) and how many cells sit on the clamp, both of which used to
happen silently.

Expect R_meas to RISE where this engages: the shipped surface was lowering a
ratio of sums across shells without tightening a single equivalent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
A scale factor shared by every symmetry equivalent of a reflection cancels out of
R_meas, CC1/2, chi^2 and every cross-validation the scaling runs, because each
ASU group's reference is its own mean. Nothing in the report could see one, and a
wrong radial profile therefore went unnoticed while every internal statistic
improved.

The CC(model, data) table gains an I/I_MODEL column: the shell's mean merged
intensity over the mean |F_model|^2 of the scaled model, after the model has
already been given its own overall scale, anisotropic B and bulk solvent. That
family is smooth and nearly exponential in s^2, so what is left is the part of
the run's resolution dependence the model does not agree with. It reads 1.000
where the two agree and drifts away from it where they do not. A headline line
reports the outermost shell's value.

It cannot say which of the two is right - a real crystal can out-diffract or
under-diffract its deposited model - only that they disagree, which is the one
thing nothing else here reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
# Conflicts:
#	rugnux/ModelValidation.cpp
The per-cell fit in ApplyCellSurface dropped every observation with Is <= 0.
Both of its sums are linear in Is - there is no logarithm and no division by
it - so the filter protected nothing arithmetic; the only hazard of keeping
the negatives is a cell whose `cross` sums to <= 0, which now keeps its
factor for that round instead of dividing by it.

What the filter did was select on the noise: it kept the up-fluctuated half
of a weak cell, inflating `cross` by the truncated mean, so each cell was
pulled down by an amount set by its own signal-to-noise. Per round, on a
Wilson-distributed cell with six equivalents: 0.1% at <I>/sigma = 5, 1.0% at
2, 4.9% at 1, 15% at 0.5. That push is almost entirely along the surface's
resolution null direction, which is why the surfaces walked.

Simulated with a flat truth and a 12% cos(2 phi) flat field:
- no gauge: shell at I/sigma = 1 suppressed 40% after 30 rounds with the
  filter, flat to 1% without it;
- 16x16 detector grid, gauge on, outer I/sigma = 0.5: with the filter the
  fitted field carries 1.9x its true amplitude at CC 0.48 to the truth and
  the gauge removes a 5x ramp; without it 1.04x, CC 0.97, 1.09x.

On 12 open rotation sets against the gauge build (rc172-2):
- the null walk the gauge removes collapses where it was large (45.9x ->
  2.0x, 5.0x -> 2.4x, 3.1x -> 1.7x; a 56x and a 4.9e5x walk are gone because
  those surfaces no longer pass cross-validation);
- held-out gains lose the part the filter inflated (23.2 -> 20.1%,
  14.1 -> 12.9%);
- sets at I/sigma >= 10: per-shell CC(Fo, Fc) against the deposited model
  moves by -0.006..+0.003 (fine half), ISa unchanged;
- the two weakest sets (I/sigma 6.8 and 4.6) lose a surface the gate no
  longer admits, and with it 0.019 / 0.031 of fine-half CC(Fo, Fc).
  Forcing the unfiltered surface on the first recovers all of it, so there
  it is the gate that refuses a real correction; on the second the
  unfiltered surface is worth nothing and the filtered one's benefit is
  not a correction the fit can reproduce.

"NOT settled in 30 rounds" does not go away (10 of 17 applied surfaces ->
8 of 15): it has a second cause the filter was not driving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Three changes to ApplyCellSurface, each measured offline on the surfaces' own input terms from twelve
open rotation sets (36 surfaces), against per-shell CC of the merge to the deposited model's Fc.

The gate. The held-out score was sum|Is - Iref| / sum Iref pooled over every shell the fit reaches.
The surfaces are fitted before the resolution cutoff, so on a weak set the score was carried by shells
with no signal (one set: 8 of 20 shells at <I/sigma> < 0.1, a quarter of the numerator), where any
spread in A raises |Is - Iref| because Is is A times noise; and it weighted each residual equally where
the merge weights it by 1/(sigma corr A)^2. It is replaced by the quantity the surface is for: fit on
even frames and merge the odd ones with it, fit on odd and merge the even ones, and correlate the two
half-set means within each resolution shell, against the same with no surface. A surface fitted to
noise lowers that correlation, so the gate needs no margin. On surfaces moving the external CC by more
than 0.005 either way, the old gate agreed with the model on 6 of 12 and the new one on 13 of 14; and
no threshold on the old score separated them - below its 2% sat surfaces worth +0.017 and +0.009 and
surfaces worth -0.026 and -0.013. CV_MIN_RELATIVE_GAIN still gates the decay and relative-B fits,
which this does not touch.

The regulariser. The per-round update A *= (ref2 + lambda) / (cross + lambda) was described as a
Tikhonov pull to 1, but lambda cancels at the fixed point ref2 = cross: it only slowed a weak cell to
cross / (cross + lambda) of its step, so the 30-round cap regularised by early stopping and the weak
cells were still moving when it hit - most of what "NOT settled" reported - and a cell with no
information was fitted to its noise and pinned on the [0.25, 4] clamp. The update is now a damped
Gauss-Newton step on log A with a N(0, 0.1^2) prior: ref2 is the information of the cell's log scale,
so the fixed point ref2 - cross = P log A moves a cell off 1 in proportion to what its observations
say. Offline, applied surfaces that settle within the cap: most of them, against a minority; cells on
the clamp: 0-4 per surface against up to 318. On the weakest set the time x detector surface goes from
181 clamped cells and -0.031 external CC to none and +0.001.

The gauge. The per-shell projection is exact only where a cell sits at one resolution - a detector
patch. A goniometer-frame cell collects directions from the whole sweep, so there it removed real
absorption, which varies with scattering angle: +0.016 -> +0.022 and +0.017 -> +0.020 on the two sets
where that surface is worth anything. That surface keeps only the global level fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Real runs of the two preceding commits against the gauge build, on twelve open rotation sets, scored as
the outer-half mean of per-shell CC(Fo, Fc) on the common reflection set with ONE Fc for every arm:
+0.0013 on average, 8 up / 3 down / 1 level; the largest moves +0.0070, +0.0057, +0.0056 and -0.0066.
Applied surfaces left "NOT settled" at the 30-round cap: 10 -> 5. On the set with an unmasked beam-stop
arm, with the arm masked: 0.7647 -> 0.7654 against the filtered build, while removing the filter
alone, under the old gate, still costs 0.023 - the cost was the gate, not the shadow.

Scoring against each arm's OWN Fc, as the earlier comparisons did, is not a fixed referent: the model
is re-scaled to every arm's data, and on one set that alone was worth 0.03 of that CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
Scaling: the modulation surface's log line names its real grid
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 23m57s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 18m27s
Build Packages / build:rugnux:windows (push) Successful in 11m6s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 20m2s
Build Packages / Generate python client (push) Successful in 32s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m4s
Build Packages / Build documentation (push) Successful in 2m1s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 20m19s
Build Packages / build:rpm (rocky8) (push) Successful in 17m28s
Build Packages / build:rpm (rocky9) (push) Successful in 18m20s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 15m20s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 12m54s
Build Packages / Unit tests (push) Successful in 1h12m13s
Build Packages / Create release (push) Successful in 15s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m59s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m16s
Build Packages / build:viewer-tgz:cpu (push) Successful in 11m16s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m2s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m37s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m6s
Build Packages / build:windows:nocuda (push) Successful in 17m34s
Build Packages / build:windows:cuda (push) Successful in 20m8s
b7b58a6680
It has been 24x24 since the grid was refined from 16x16; the label was not updated.

Open item for the battery arm this branch is for: on one open set (I/sigma ~10, 1.44 A) the
time x detector surface is worth 0.007 less outer-half CC(Fo, Fc) than on the gauge build. It is
the filter removal, not the gate or the prior - the same surface, gated the same way, loses it with
the filter gone - and it reproduces offline on the surface's own input: +0.041 with the filter,
+0.025 without, +0.024 under the prior, all of it in the outer five shells. Not explained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
--model: resolve an indexing ambiguity by reindexing the data, not by moving the model
Build Packages / Create release (push) Successful in 16s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m56s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m23s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m29s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m6s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m45s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m7s
Build Packages / build:windows:nocuda (push) Successful in 17m34s
Build Packages / build:windows:cuda (push) Successful in 20m6s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 23m34s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 17m21s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 18m52s
Build Packages / build:rugnux:windows (push) Successful in 10m56s
Build Packages / Generate python client (push) Successful in 35s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 18m41s
Build Packages / Build documentation (push) Successful in 1m22s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m38s
Build Packages / build:rpm (rocky8) (push) Successful in 18m22s
Build Packages / build:rpm (rocky9) (push) Successful in 18m39s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 15m20s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 13m14s
Build Packages / Unit tests (push) Successful in 1h15m6s
c54785e3df
Where the model and the data share a point group and the lattice has more symmetry than that
point group, the model's change-of-basis probe used to absorb the alternative indexing: it saw the
twin law as "another description of the lattice", put the MODEL through it, and the data kept
whichever indexing their own run had picked. The indexing probe that relabels the data into the
model's indexing (with its null gate) therefore never fired. For a campaign of one crystal form
processed against one model that left the MTZs in different conventions.

Now those operators are left out of the change-of-basis candidates when the two groups have the
same rotations (I23 vs I2(1)3 and enantiomorphic pairs count as the same point group), so the
model stays in its own indexing and the existing probe reindexes the data - only where the model
fits and its lead beats the lead a random placement of it takes; otherwise the data keep their
indexing, as before. A model in a different point group, or with a reference MTZ fixing the
indexing, goes through the change of basis exactly as before.

The P1 cross-check file is now reindexed along with the merged and unmerged output: it is merged
from the scaler's copy of the observations, which the relabelling did not reach.

Measured on a cubic I23 series of seven datasets against one I2(1)3 model: three came out in the
other indexing before and are now reindexed (-x,-z,-y / -y,-x,-z, det +1); merging statistics
identical, merged/unmerged/P1 reflections identical up to the relabelling with I(+)/I(-) in place,
anomalous peak heights at the model's sulfurs unchanged within placement noise; pairwise CC of
the merged intensities as written goes from 0.56-0.64 across the two groups to >= 0.95 for every
pair. Holohedral, different-setting and different-point-group controls are byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
leonarski_f merged commit 6dfe065365 into main 2026-09-22 06:48:38 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mx/Jungfraujoch#82