Prototype for the tail campaign (output unchanged, checked on 4nwv --mode scale --no-gpu: same .hkl, same
.mtz data):
- rsm/s and rsm/m TIMING sub-marks for each correction surface, the disposition and each stage of
MergeAndStats (the pieces the tail is spent in had no marks of their own).
- RefineDecay takes the terms MeasureRadiationDamageB just built (nothing between them changes corr)
instead of building them again, and evaluates its decay factor once per term where it used it twice.
- The merge's per-group and per-full scratch (MergeFields, FullsStaging, the accumulators, the R_meas sums
and their staging, the ISa scatter) default-initialised: each is written in full before it is read, and
std::vector zeroed - and faulted in - gigabytes of it on one thread per P1 merge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
ScaledObservations sorted its sample of partials (up to 8 M) with one std::sort on the
critical path of the Analyses step, and joined the per-image lists serially: 0.97 s of
the 1.22 s the step waits for the anisotropy on a median-sized triclinic set (4x T4 box).
The sort key now carries the image and the reflection's place in it, a total order any
sort leaves the same, so ParallelSort runs it on all threads.
ComputeSmoothGWindow took log(g[j]) once per window a frame falls in, on one thread, every
round of the scaling loop: about 0.7 s on the same set. Each frame's log is taken once;
the window sums add the same values in the same order.
Output identical (p.hkl, report) on three sets, live and offline.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CphfSakjtC9c4M35wQe6SP
The density's walk now places its points in single precision (the Metal density series), so it is no
longer gemmi's bit for bit: the two gridding tests compare it to gemmi within 1e-5 relative plus four
cutoff-sized terms (a point at a radius' edge can fall on the other side), and check that 1 and 4
threads give the same grid bit for bit. The solvent mask is still compared to gemmi's bit for bit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
On macOS, where a Metal device may be used (not under --no-gpu), PutModelDensityOnGrid puts the density
on the grid with a Metal gather (ModelDensityMetal), as ModelDensityGPU.cu does under CUDA: the grid in
bricks of 4^3 points, one threadgroup each; every point adds, in model order, each atom whose box holds
it and whose radius reaches it. No float atomics. The arithmetic is the CPU walk's of the previous commit,
spelled out in ModelDensityMetal.metal - the point's place in single precision in the same order, gemmi's
float expapprox ported as is, fast math and contraction off - so the grid is the CPU's bit for bit
(ModelDensityMetal_BitIdenticalToCPU, [portable][Metal]: a triclinic cell and a P 21 21 21 one, a quarter
of the atoms anisotropic, boxes wrapping at every face). The symmetrize stays on the CPU.
- Each brick's atoms: (brick, atom) pairs counted and filled on the CPU in parallel over slabs of w,
each worker taking the atoms in order, so the lists keep model order; at most the atoms' boxes' bricks.
- The kernel writes straight into the grid FFTW reads where its pages can be handed to the GPU (large
vectors are page-aligned on macOS), into its own buffer and copied otherwise.
- 4^3 bricks rather than CUDA's 8^3: every point tests each atom reaching its brick, and the smaller brick
took a half to a third of the kernel time. The box test is a subtraction against a start the host wraps.
- Every grid goes to the GPU: giving it only grids of 256k points or more, or models of 8000 atoms or
more, left 9s02's MR 0.8 s slower.
- A box wider than the cell (an atom reaching a point through two images) stays on the CPU, whose walk
adds both. A Metal failure puts that grid and every later one on the CPU, with one warning.
- Each call runs in an autorelease pool of its own (see the rotation function's).
Apple M6 (16 GB), alternating with the rotation-function build (the previous commit but one), two each:
- 4nwv: wall 295.8/292.6 -> 275.2/277.8 s; model validation 48.9/49.0 -> 41.0/40.9 s; MR 130.2/130.3 ->
121.4/122.5 s (rescoring 40.9/40.3 -> 35.6/36.5 s, null 31.7/32.1 -> 26.8/26.6 s); user CPU 1998/2011 ->
1692/1681 s; peak footprint 17.8/18.5 -> 17.2/18.6 GB. The density's CPU time, sampled over a whole run,
316 CPU-s (16% of the busy samples) -> none, the pairs' sort 31 CPU-s.
- 9s02: wall 76.7/76.5 -> 72.9/73.1 s; MR 16.9/16.8 -> 13.5/13.4 s.
- 9zlo: wall 70.4/70.7 -> 69.8/69.6 s; MR 3.1/3.1 -> 2.2/2.2 s.
p.hkl, the placed model and the maps are byte-identical to the previous commit's (density on the CPU) on
all three, and so are the MR and validation logs but for their timings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
PutModelDensityOnGrid walked each atom's box as gemmi does, with every point's place relative to the
atom and its test against the atom's radius in double. The Apple GPU has no double, so a Metal gather
could only come to the same grid to rounding. The density now has its own walk, UseDensityBoxesByPlane:
the same planes, atoms, box and order as before, with the point's place in single precision in one
fixed order of operations (f = centre - (u, v, w); x = m00 fx + (m01 fy + m02 fz), y = m11 fy + m12 fz,
z = m22 fz with gemmi's upper-triangular orth_n; r2 = (y y + z z) + x x, taken where not above the
radius squared). A gather that spells the same arithmetic out then finds the same points with the same
bits, and gemmi's float expapprox and the Gaussian sums are already single precision. The solvent mask
keeps the double walk: its points are 0 or 1, and a point at the edge would flip.
The density differs from gemmi's in the last bits, so everything computed from it moves at that level.
9s02 on an Apple M6 against the previous commit: p.hkl unchanged; the MR decision (AGREES, P 21 21 2)
and every R-work/R-free within 0.001; validation R(all) 0.3719 -> 0.3718; the validation rigid body
took 29 evaluations instead of 22 to the same place (rotation 0.003 deg); Z of rejected candidates in
other groups by up to 0.7; the model and maps differ in their last digits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
The calls run on the search's worker threads, which have no autorelease pool, so each call's autoreleased
command buffers and every buffer they held lived until the thread exited: about 7 MB per call. The Bragg
engine already runs its calls in a pool. On an Apple M6 this removes a 4-5 GB rise of 4nwv's peak
footprint seen with more Metal calls, and probably the rotation-function build's unexplained +1.7 GB; a
20-call worker-thread test grew 0.41 -> 0.55 GB before and stays flat after. Output unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
The manifest of a run made with --model-check now carries "refmac" (REFMAC's version as its banner states
it, e.g. "REFMAC 5.8.0431, CCP4 9.0.014"), as does every "rechecks" entry, and the report lists it beside
the rugnux binary, so a REFMAC R-free can be traced to the REFMAC that computed it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The full battery (rc176-1bdce7e56-full) traced three changes to it, all reproduced with the anderson-solver
binary alone: an in-house insulin set lost its cubic group (I 2 3 -> I 2 2 2, the cut moving 1.63 -> 1.75 A),
a weak split myoglobin set fell in CC1/2 (0.70 -> 0.60) and R_free (0.438 -> 0.456), and 9gdj took a third
first pass (+10 s): pushed further on the measured-centre arm's poor lattice, the scaling moved both arms'
statistics so the early beam-centre comparison no longer called the loss beyond its noise. Kept on branch
anderson-solver (747a30dba) to come back to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
ReadRaw opened each frame with TIFFOpen(path, "r"), which memory-maps the file; on macOS the 32 MB
frame is then paged in under libtiff's copy out of it, and that copy (DumpModeDecode's memmove) was
23% of rugnux's busy CPU samples during 4nwv's image passes. With "rm" libtiff reads the file instead:
memmove 0.9% of the samples. The pixels are the same bytes either way.
Apple M6, 4nwv (the timing benchmark's moved model), alternating runs, two each:
- wall 291.1/285.9 s -> 283.4/284.5 s; system CPU 197/172 s -> 160/160 s
- pre-scan 3.9/3.8 -> 3.4/3.3 s, indexing 8.7/7.5 -> 6.8/6.9 s, metric-symmetry check 5.0/5.1 ->
2.9/3.0 s; the integration passes, bound by the GPU, within 0.6 s
p.hkl and the placed model are byte-identical. (Reading by strips straight into the frame on top of
this, TIFFReadEncodedStrip: 282.2/273.6 s, not clearly apart from "rm" alone.) On Linux the three
read paths measured the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
The partials and fulls scaling loops are alternating least squares that converge linearly:
over the rc176 battery's 2527 loops a round shrinks the step by a median 0.86 (partials) and
0.93 (fulls), up to 0.98, so the 1e-3 step tolerance stopped them 0.6% / 1.4% (median) and up to
17% (fulls p90) short of their fixed point, and 313 of them were stopped as "stalled" when they
were only slow - a stalled fulls loop also skipped ShrinkToRestrained.
Each round is now accelerated in log G by type-II Anderson mixing (Walker & Ni 2011), depth 5,
each frame weighted by its observation count as in the step test; frames below the credible floor
take the plain round's scale. The history is dropped when the weighted residual grows or the set
of fitted frames changes (new AndersonMixing class, unit-tested on a synthetic slow ALS loop). The
mixed scales are gauge-pinned again before the rescale, and the stop test is unchanged: it reads
the round's residual F(x) - x, so "stalled" now means a loop that does not settle under
acceleration either.
Measured (CPU, -N 8): 4nwv 311 -> 82 iterations over 9 loops, 9v16 76 iterations over 11 loops,
the same counts as the env-gated prototype on tail-solver; no history restart fired on either.
Depth 3/5/8 give 92/91/90 iterations on 4nwv. A restart on a change of the fulls loop's
cross-validated lambda was tried and gave nothing (83 vs 82). Same space group and merging
statistics on 4nwv and 9v16.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The per-image pass of a run that found no lattice left indexing_result unset,
so the live scan result and the per-image arrays showed "not asked" rather
than "not indexed". Set it false there: GET /result/scan now gives index 0 for
every image and /entry/MX/imageIndexed is 0, as a collection without rugnux
reports a frame that failed to index.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
On macOS, where a Metal device may be used (not under --no-gpu), each merge's rotation stage - the sphere
and the peaks' refinement - runs on the GPU: the same trilinear interpolation of the same transform in
float, one threadgroup per orientation, every bin's sums of |F|^2, |F|^4 and |F|^2 (E^2 - 1) in a fixed
order, and the correlation made of them in double on the host. The GPU reads its own copy of the
transform in 2 x 2 x 2 bricks (1.3x faster than the grid's rows, as the eight corners then lie on fewer
cache lines), made for the rotation stage and given back before the translation function; the seeds'
orientations later on stay on the CPU. Where the GPU fails, the stage goes on on the CPU with a warning.
The kernel is MolecularReplacementMetal.metal, carried in the binary as the Bragg integration's are.
Elsewhere nothing changes.
Measured on an Apple M6 (16 GB), alternating with rc176 + the CPU prefetch, two runs each:
- 4nwv (the timing benchmark's moved model): rotation 78.9 -> 30.7 s, wall 344.5 -> 298.8 s; peak
footprint 16.1-16.8 -> 18.5 GB.
- 9s02: rotation 2.99 -> 0.28 s, wall 79.2 -> 76.4 s.
p.hkl, the placed model and the maps are identical to the CPU's on both, and so is every line of the
molecular-replacement log (RF Z, TF Z, R values). The RF itself agrees with the CPU's to 1e-4, with the
same best orientation (MolecularReplacementMetal_MatchesCPU, [portable]).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
The CPU rotation function interpolates the molecular transform at points scattered over its grid, four
cache lines per point. Once the grid is far larger than the caches (835 MB for 4nwv: 16.9e9
interpolations over 17255 orientations) the loop waits on memory, at ~92 ns per interpolation per core
on an Apple M6. MolecularTransform::Prefetch() asks for the rows a point's corners lie on, 4
reflections ahead of the one being computed, through one portable helper (__builtin_prefetch under
GCC/Clang, nothing elsewhere). Same values, same order, so the output is bit-identical.
Only for a transform of 256 MB or more: a smaller one sits mostly in the caches, where the extra
address arithmetic only costs. In a standalone copy of the loop on the M6: 30% slower at 13-90 MB,
even at ~130-175 MB, 1.5x faster at 400 MB, 1.75-1.8x at 835 MB (prefetch distance 4 to 8 equally
good).
M6 Mac mini, 4nwv, alternating runs on a quiet machine, against the previous commit:
- rotation function 139.1/138.8 s -> 79.1/79.3/78.9 s
- wall 400.7/415.7 s -> 338.0/343.1/345.6 s
- --no-gpu: 450.4 s -> 385.4 s (rotation 140.3 -> 84.1 s)
- 9s02 (14k reflections, 13 MB transform, below the threshold): rotation 2.83 s -> 2.98 s, wall
79.4 -> 80.4 s
p.hkl, p_model.cif/pdb, the maps and the MTZ/CIF reflection data are byte-identical in every run
(the files differ only in the git tag and command line in their headers). [MolecularReplacement]
tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
On the dashboard a run with no verdict, results or progress (a failed one) shows its step table
across the whole row instead of in the right half beside nothing. On the Rugnux tab the summary
tiles highlight "unit cell" rather than any label containing "cell". The image count drawn beside
the detector bar is opaque, so the "now" line passing behind it no longer crosses the text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Dashboard: the Rugnux card leaves the narrow right column for a full-width row between the first
plot and the second, with the timeline, the verdict and key results (space group, cell, resolution,
completeness, signal) beside the step table; it collapses to one column on a narrow screen and
renders nothing (no empty grid cell) when rugnux has not run. The measurement statistics row that
showed "-" is the cell given with the collection, now labelled "Unit cell (given)".
Timeline: the deadline sets the axis only while the analysis runs, so a finished run fills the
axis; marker labels that would overlap ("now" on "last image") move up a row; the image count goes
beside the detector bar when it does not fit in it; a step title too narrow to read is left to the
tooltip instead of being cut to one letter.
Rugnux tab: the failure message is a wrapped alert under the header, so the latency number stays
right-aligned; summary tiles are highlighted by label only, and only space group and cell share a
row, so no half-empty rows.
Status bar: with rugnux live on, the collection message names the analysis state alongside
("Data collection without problems · rugnux analysis: failed", raised to a warning when it failed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
A rotation run whose first pass found no lattice threw NoLatticeFound before
any canonical pass, so no image was ever reported as the canonical pass: the
live scan result and the master file's per-image arrays were empty, and the
CLI wrote nothing per image. Run() now catches it, runs one output pass over
every frame with spot finding and the per-image analysis but indexing off
(SpotFindingPass: header geometry, the run's spot finding, pre-scan mask kept;
the first pass, supercell probe, rotation finalize and merge skip a pass with
indexing off), reports it through the observer as the canonical pass, hands
the result back on the exception (NoLatticeFound::per_image) and rethrows -
the verdict and the report are unchanged. Skipped when cancelled and when Run
has no observer (the live lattice probe).
The CLI writes that result where a run that indexed nothing puts its
per-image analysis: the _process.h5 (streamed by the sink with --no-merge or
--write-process-h5, otherwise written from the end message) and the _plot.txt;
the exit status stays 1. An output pass with no sink now fills the end
message's per-image arrays (ScanResultGenerator) for that file, which it
claimed to carry but did not. The no-lattice hint no longer points at
--mode azint for the per-image analysis.
Measured offline on two 16M no-crystal controls (1800 frames): the extra pass
is 1.6 s, wall +1.2-2.3 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The receiver's own rotation indexer sees no image in a rugnux-live collection, so the scan result
the broker serves (/result/scan) went without the rotation lattice, cell and Bravais symbol. They are
now taken from rugnux's end message, as the end message itself already does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
No reader parsed a polarization, and ApplyRugnuxExperimentDefaults then set
P = 0.99 (an undulator value) unconditionally, so laboratory-source data were
corrected as if from a synchrotron. Every d*TREK (Rigaku Saturn/A200) header in
the open arm states SOURCE_POLARZ=0.5 1 0 0, i.e. unpolarized; at Cu K-alpha and
2 A the wrong factor puts a +-33% azimuthal modulation on the intensities.
- d*TREK SMV: SOURCE_POLARZ is the fraction f followed by the plane normal (as
dxtbx's FormatSMVRigaku reads it); stored as degree 2f - 1, negative where the
normal lies along lab x (plane containing y).
- miniCBF: "# Polarization f" is the fraction in the horizontal plane; stored as
2f - 1 ("None" or a missing line leave it unset).
- ApplyRugnuxExperimentDefaults sets 0.99 only where the file stated none;
--polarization still overrides. ADSC SMV, marCCD and NXmx state nothing and
keep 0.99.
- The "Header:" log line reports the stated polarization.
RESULT-CHANGING: d*TREK home-source sets (0.99 -> 0), one Rigaku miniCBF set
(0.99 -> 0), miniCBF sets stating 0.99 (0.99 -> 0.98).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
deadline_s defaulted to 0 (no cutoff), so a difficult crystal could hold the end of a collection for
as long as the analysis took. 60 s bounds it; 0 still means no cutoff. The OpenAPI default, the
broker and receiver defaults and the docs say 60; the generated clients are regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Without a GPU (macOS), rc176's worker pool - 1/3 of the 12.9 GB budget on a 16 GB Mac - let only two
molecular-replacement placements run at once, as each reserved 32 B per grid point (2.1 GB on 4nwv)
against a measured ~0.95 GB. MR rescoring went from 38.6 s to 69 s and 4nwv's wall time from 402 s
(budget off) to 444 s.
On the M6 Mac mini (16 GB), 4nwv, two runs each in alternating order on a quiet machine:
- old build 234774ff0: 418.4 s, 20.0-21.3 GiB peak footprint, 576-682k swapouts
- rc176 as shipped: 444.2 s, 14.5-15.2 GiB, 39-66k swapouts
- this change: 402.4 s, 15.5-15.8 GiB, 66-93k swapouts
- budget switched off: 401.7 s, 21.8-23.3 GiB, 268-433k swapouts
9zlo/9s02/9gjx: 71.9/79.0/82.8 s (old build 73.6/82.2/83.6), no swap. p.hkl byte-identical in every
run: the pool decides only what runs at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018My7tqt8a4LyKKjP3FhScT
RESULT-CHANGING.
AnalyzeTwinImmuneZones flags zones_ambiguous when the acentric control reads more compressed
than a perfect twin's 0.541 by three standard errors: no twin fraction can do that, so something
else (overlapping spots, neighbour correlation) averages reflections with unrelated ones, it
compresses the zones too, and a genuine centric zone then reads acentric. SearchSpaceGroup's own
zone gate already skips such a reading, and the struct documents that no verdict is read from it
either way - but Rugnux's TwinZoneVerdict still returned -1 on it, only noting the ambiguity in
its text, and so let a refusal stand on a zone it had just called uninformative.
9zmu (deposited P6522, c ~493 A, 95% of predicted spots overlap a neighbour) regressed in rc176
from P6122 (enantiomorph, pass) to P321: its P1 <|L|> of 0.394 (overlap, not twinning - second
moment 2.23; the model's twin fraction 0.08 and R-free 0.287 twinned or not) put it in the
partial-twin band, so since 7c1befd48 the search asks the zone of the holohedral 622 too. On the
Lorentz-filtered merge the control read 0.552 +- 0.002 (just inside the twin range) and the zone
-1378 nats, so that arm refused 622; the all-observation arm confirmed it, and the arbitration
read the zone on the all-observation merge: control 0.513 (ambiguous), zone -319 nats, verdict
-1, refusal stands, P321. Now that reading is undecided, the rule stays as before the zone was
asked, and the all-observation 622 is taken: P6122 again, R-free 0.2825 (as rc175).
Changed: 9zmu P321 -> P6122 (pass). Unchanged against rc176 run 20261017-1924 (space group,
verdict, ISa, R-free identical): 3r6o I41, 8c3e P321, 3mc4 R3, and every other open set whose
rc176 log has REFUSED / twin-immune / "Twinning indicators BEFORE" (2wnn 4bwl 5ebi 5j23 6fid
6iu6 6iu8 6iu9 6p8j 6pxb 6pxc 6vww 6z9g 6zqr 7k1l 7kcn 8xte 8xtg 9fcf 9hnc 9hs7 9i80 cuhf2),
20 random passing open sets (2wnz 3p85 5jvn 6cdl 6f3p 6u7g 6zr0 7n2s 7orr 7os3 7ou1 7tcd 8egn
8xbp 8y74 9min 9rp9 9t6s 9v16 9yzk) and the five in-house sets with a TWIN_ZONE_DECISION.
In rc176 9zmu was the only set whose decisive zone verdict was an ambiguous one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The rugnux library plans what runs side by side against ProcessConfig::host_memory_budget_bytes,
by default 75% of the machine's memory. In the broker most of the machine is the image buffer,
which has to hold the whole collection for the live run, so that default would plan against
memory that is already taken. The receiver now sets the budget to the default less the image
buffer and a 16 GiB margin for the rest of the broker (never 0, which would mean the default;
at worst the run schedules in series), decided once and the same for the main run and the
60-degree probe, which share the process-wide HostWorkerPool. ImageBuffer gets a size getter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Branch reviewed at e16db07ff (review: owner's decisions accepted - _maps.mtz instead of .ccp4 in the
viewer, -o "" computes everything incl. MR/validation, Ctrl+C/error paths may leave fewer files, the
writer links gemmi/zlib) and merged at 55184eb09 with its review fixes (END size guard, frame drop as
in Lite, outputs released before re-runs, pool priority, docs, frontend gating).
Conflicts: rugnux/Rugnux.h (kept rc176's host_memory_budget_bytes, output_prefix removed as on the
branch), rugnux/RugnuxPipeline.h (MtzTable future + rc176's GPUMerge pointer), MolecularReplacement.h/.cpp
(rc176's GPU cost model, packed transform and split functions with the branch's RugnuxModel signature;
search_model with rc176's other_budgets, result.model_name). tests/RugnuxLargeTest.cpp: rc176's
host-budget test writes its files through WriteRugnuxOutputs now that the library writes none.
Generated code (broker/gen, frontend client, Python client docs, Redoc) regenerated from the merged
jfjoch_api.yaml with update_version.sh (version unchanged).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
UnmergedMtzTable's columns hold NaN for missing values, and vector<float> == compares NaN unequal to
itself, so the serial-vs-parallel check could fail spuriously. Each column is compared with memcmp,
as the test did before the MtzTable change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard card and the Rugnux page polled GET /rugnux/status every second even with the mode
off, and on a token-protected dataset the dashboard showed a "Dataset token required" card for a
feature nobody had switched on. Both now read GET /config/rugnux first and enable the status query
only when `enable` is set: the dashboard reads the settings once on mount (the settings panel
invalidates them on a change) and shows nothing with the mode off; the Rugnux page says the mode is
off above its settings. Generated client unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- docs/CBOR.md: merged and map_coefficients are the generic MTZ table (title, cell, space group,
history, sort order, datasets, columns with typed float values, batches), documented as the
serializer encodes it; the placed_model row names every field's type.
- docs/JFJOCH_BROKER.md: the uploaded model is parsed and kept in memory, nothing on disk; the image
buffer must hold the whole collection, else it is forwarded only, with a warning; the result files
the writer makes and the END size limit; deadline_s and what /cancel and a cut do.
- JFJochServices.h: SetRugnuxModel keeps the model in memory, it writes no file.
- jfjoch_api.yaml: PUT /config/rugnux/model answers 400 also for a body that is not a model
(description text only; generated code not regenerated here).
- rugnux_cli.cpp: --fast-live is in the usage text. Its adaptive_integration_radius = false stays: the
CLI does not go through MakeRugnuxLiveSetup and ProcessConfig does not imply it from fast_live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pool is made by the first thread that uses it and its workers inherit that thread's nice value.
That thread was the nice-10 rugnux thread, so without CAP_SYS_NICE / RLIMIT_NICE the workers stayed
at nice 10 for the life of the broker - and the comment in RugnuxThread said the opposite.
JFJochReceiverRugnux's constructor now makes the pool first, at the broker's priority, so the
existing "lower it only where it can be raised back" logic is what decides. The pool constructor also
waits for every worker to record its thread id, so a SetNice right after construction reaches all of
them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ProcessResult::outputs (unmerged and merged tables, map coefficients) stayed alive while a re-run that
replaces the result ran, so the peak host memory held two sets. The old outputs are now released
before each re-run whose result replaces them: the near-tie re-run in Rugnux::Run, and in
RefinedPasses the demoted lattice arm, the geometry-walk rounds, the starved-background re-run, the
supercell-collapse re-run and the header-geometry redo. The doubled-cell hypothesis is left alone -
both cells' outputs are kept there. Nothing that is returned or written changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JFJochReceiverRugnux::ForwardThread called SerializeImage directly, so an image whose CBOR metadata
overflowed its buffer slot threw: the collection was cancelled and the slot left InPreparation.
JFJochReceiverLite handles that by dropping the one image (SerializeImageOrDrop). Its Configure was
also copied by hand. Both are now static members of JFJochReceiverLite - SerializeImageOrDrop and
ApplyStartMessage, the former Configure moved verbatim - and JFJochReceiverRugnux calls them; Lite's
own behaviour is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every image pusher serialises END into its fixed MESSAGE_SIZE_FOR_START_END (256 MiB) buffer, and an
overflow there throws out of SendEndMessage: the writer never gets END and the collection errors. The
try in AddToEndMessage only covered filling the message. AddToEndMessage now serialises the finished
END once into a scratch buffer of the same size (uninitialised, so only the bytes written are
touched) and, where it does not fit, drops the merged data, map coefficients and placed model, with
an error in the log and a warning in the live status (RugnuxLiveStatus::Warning). The pusher then
serialises exactly the message that was checked.
The receiving side already takes an END this large: the TCP puller accepts payloads up to
JFJOCH_TCP_MAX_PAYLOAD_SIZE (1 GiB), the ZMQ sockets set no ZMQ_MAXMSGSIZE, and the puller's ring
buffer serves a message larger than its capacity from the heap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VERSION and update_version.sh: the version strings, and the clients regenerated from
broker/jfjoch_api.yaml - which also brings the C++ model, Python client docs, frontend client and
Redoc page up to the /live/events endpoint already in the spec - plus docs/THIRD_PARTY_NOTICES.md
regenerated from the root file (the timing benchmark's model entries).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
No files. The receiver no longer makes a /tmp scratch directory or reads
rugnux's files back: AddToEndMessage moves the merged MtzTable and the map
coefficients out of ProcessResult::outputs and converts the placed model with
PlacedModelFromStructure (now given the data cell, as WritePlacedModel is).
FillRugnuxResultBlocks and MtzTableFromFile are gone; their test now builds the
blocks from the same in-memory outputs and still checks the writer reproduces
rugnux's own files byte for byte. The live status report is built from the
result with the writer's file prefix as its label. AddToEndMessage never
throws: a failure is logged and the END goes without the blocks.
Review fixes:
- MakeRugnuxLiveSetup sets nthreads_auto, as a bare `rugnux` does, so the
per-image loop keeps to GPUWorkersPerCard per card instead of starting one
GPU worker per CPU thread; the 60-degree probe gets a quarter of the
threads (at least 2).
- A failure setting rugnux up (start message, reader, thread start) marks the
live status Failed and nothing else; the collection goes on as in Lite.
MeasurementThread and ForwardThread also catch std::exception, so no
exception skips the latch or the reader's Ended().
- The ParallelFor pool is lowered to nice 10 only where it can be raised back
(CAP_SYS_NICE, or RLIMIT_NICE reaching 0); otherwise it is left alone and
that is logged once.
- The preview and metadata sockets get the END without the result blocks.
- Receiver plots and scan result are fed only from the canonical pass
(RugnuxObserver::OnImagePass): the pre-pass reports [0, E) again at the
header geometry and the plots accumulate, so it would count those images
twice. The canonical pass reads every image once.
- A collection the image buffer cannot hold is still not analysed, and now
says so in the broker status (Warning) as well as the rugnux status.
With rugnux live off nothing changes: the extra END resets are of empty
blocks and analysis_warning is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WritePlacedModel was handed the result of find_spacegroup_by_name unchecked,
and PlacedModelStructure indexed every per-atom vector by atom_name's length.
A malformed block from the wire could crash the writer. Now the per-atom
vectors must all have atom_name's length and aniso must be empty or six per
atom (PlacedModelStructure throws otherwise), and an unknown data space group
is an error; WriteRugnuxResultFiles logs either and writes nothing for the
model, as it already did for an MTZ table WriteMtz refuses.
StreamWriter: "Received end message" is logged only with -v again, exactly as
before this branch. The wall-clock times of the rugnux files are still logged
where they are written, which only happens when the END carries them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A /cancel or deadline cut while pass 2 was still integrating fell back to the
established pass 1 - a geometry pre-pass that merges nothing - so the END went
out without merged data. Live, pass 2 streams behind the frames, so this is
exactly where a cut during the collection lands.
Now, in fast_live, a cut inside the canonical pass's image loop stops it
reading further images, and the pass goes on as a partial sweep: its
integration outcomes are trimmed to the frames read (as a pre-pass trims its
own, so the per-frame smoothing in the merge does not run over an empty tail),
and it scales and merges them, making the normal outputs. The model stage is
left out after a cut, as before. Only a pass cut before it read any image (or
any other pass) still gives way to established_.
The CLI (fast_live false) is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every rugnux worker waiting for an image called ImageBuffer::GetImage every
millisecond, and GetStatus every 100 polls. Each call takes the buffer's mutex
and scans every slot, so sixteen waiting workers contended with the forward
threads for the lock they put the images in under (GetImageSlot, ReadyToSend).
Now one waiting thread at a time asks the buffer, at most every 2 ms, with a
single GetStatus, and notes the images it holds in a per-reader bitmap; the
other waiters sleep on a condition variable and are woken when it is
refreshed or the collection ends. GetImage is called once, for an image known
to be there (or given up on). MAX_IMAGE_LAG and Ended() mean what they did.
ImageBuffer itself is unchanged.
The test gains eight readers waiting at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Peak live heap, CPU path -N10, before -> after: 4nwv scaling 14.8 -> 11.9 GiB, analyses 13.5 -> 7.3;
8qaw scaling 34.0 -> 27.1; 9v16 17.2 -> 13.5; 9zlo/9gjx/9s02 7.7/7.4/8.9 -> 6.3/6.3/7.5. With the
budget of a 16 GB machine, 4nwv validation 18.4 -> 10.8-12.3 and MR ~17 -> 12.5. GPU 4nwv 16.2 ->
14.8. Byte-identical to its base on GPU and --no-gpu (timing standard + inhouse, 4nwv/8qaw/9v16/
lyso_x06da_ref/cytc_x10sa), at the machine's budget and with a 16 GB one. Conflict in
RugnuxScaleMerge.cpp: perf-8gb-cpu's card-byte declaration of the merge and this branch's host-budget
decisions on the post-refinement and the all-observation arm, kept both, in that order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The rugnux library wrote its results itself: _process.h5 through a
FileWriter, the merged/twin/_mr reflection files, the unmerged MTZs, the
maps and their MTZ, the placed models, _detector.jpg and _plot.txt, and
on a doubled cell it renamed files on disk. It now writes none of them.
- MtzTable (common/MtzTable.h): an MTZ file's header, columns and batch
headers with no file behind it. It replaces MergedReflectionsMessage
and MapCoefficientsMessage in the END message, with one CBOR encoding.
WriteMtz (writer/WriteRugnuxFiles.h) is the one serialiser, used by
rugnux and jfjoch_writer alike; it refuses a table whose columns differ
in length, name a missing dataset, carry a malformed batch header or
name a space group gemmi does not know. MergedMtzTable and
UnmergedMtzTable build the tables the old writers built.
- ProcessResult::outputs (RugnuxOutputs) holds, for the output pass only:
the answer's merge (MTZ table plus what the .cif/.hkl are written from),
the twin hypothesis' and the model's choice's merges, the unmerged and
unmerged-partials tables, the maps (coefficients always; the grids only
with ProcessConfig::keep_map_grids), the placed models, the detector
JPEG and the per-image plot table, and the start message of the
no-lattice _process.h5. Map RMS is taken from the grid in memory, as the
CCP4 header stated it, whether or not the map is kept.
- The per-image analysis goes to a caller-supplied RugnuxImageSink;
rugnux and the viewer give a FileWriter-based ProcessFileSink. The
geometry pre-pass no longer makes a _01_process.h5.
- The doubled cell keeps both output sets: the answer's in outputs, the
other's in DoubledCell::other_outputs with its suffix; the report names
files by suffix after the caller's prefix.
- The output prefix, write_process_h5 and the observation dump path are
gone from ProcessConfig; --dump-observations is a callback.
- What used to be gated on having files to write (model validation and
MR, the twin-hypothesis merges, the finalist ledger, the scaled fulls,
the unmerged tables) is gated on the output pass instead, so a caller
with no prefix gets the same results.
- RugnuxOutputFiles (new library JFJochRugnuxOutput, linked by rugnux,
the viewer and the tests) writes every file from the result with the
names and formats rugnux has always used. The viewer writes its job's
files the same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gemmi reads any text it does not recognise as PDB and can hand back a
structure with an empty model in it, so 'no models' let garbage through.
Count the atom sites instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The analysis read the --model file itself, four to five times per run
(the model kind, every validation, molecular replacement, the stills
reference intensities) and located the PAE sidecar from the file name.
The library is to do no file I/O, so the model now arrives in memory:
- RugnuxModel (image_analysis/structure_refinement/RugnuxModel.h): the
gemmi structure as read, whether it is ModelCIF, the PAE matrix where a
prediction came with one, and the name the report gives it.
- ParseRugnuxModel / ReadRugnuxModelFile (writer/RugnuxModelFile.h, in
JFJochRugnuxFiles): bytes in, gzip by magic, PDB/mmCIF by content,
_ma_qa_metric detection as before; the file reader takes the PAE JSON
from the same place PaePathFor named.
- ProcessConfig::model replaces model_path; PrepareSearchModel,
RunMolecularReplacement, ValidateAgainstModel and
ModelReferenceIntensities take the model; result fields name it by
model_name.
- rugnux reads the model before the run starts and stops with an error
where the file holds no model (it used to run and report the model as
not validated). The viewer reads it when the job is set up.
- The broker parses an uploaded model at once and answers 400 where it is
not one; it keeps the parsed model in memory, with no temporary
directory or file. It takes no PAE.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RESULT-CHANGING in principle, unchanged in practice: diagnostic builds counted 0 disagreements of
the SNR and count tests over ~1.9e10 first-pass and 2.9e7 candidate tests on lyso_x06da_ref,
cytc_x10sa, myob_x10sa, yag_x10sa_20keV, 4nwv and thau_bl1a_3p8keV, and 2 clip-bound flips each on
lyso, yag and 4nwv; p.hkl bit-identical before and after on all six. New knife-edge parity test
fails on the old GPU code in both directions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Byte-identical to an rc176 base on GPU and --no-gpu: standard and inhouse timing benchmarks and a
run of lyso_x06da_ref, cytc_x10sa, myob_x10sa, yag_x10sa_20keV, 4nwv. With the card faked to
8.55 GB the geometry check drops 0.81 -> 0.17 s (myob) and 0.44 -> 0.09 s (lyso); the GPU Bragg
prediction kernel on 4nwv 1968 -> 415 ms on an RTX 5080 (loaded box, kernel time). A merge above
~9 GB (8qaw class) now makes the probe wait for it on a 16 GB card too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4