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
RESULT-CHANGING for the GPU adaptive spot finder, only where a clip bound sat within its last bit of
an integer.
clip_keep formed mean -/+ clip_k * sigma as one fused multiply-add each; AdaptiveSpotFinderCPU::ClipRings
computes the product and then the sum (contraction is off project-wide). The two bounds differ in the
last bit, and where that crosses an integer pixel value every pixel of that value in the ring moves in
or out of the clip, so the ring mean/sigma - and the ring's threshold - differ. clip_keep now rounds
product then sum (__fmul_rn, __fsub_rn / __fadd_rn), still in one place for every kernel.
Measured: a diagnostic build evaluating both forms in every clip_keep found 2 differing tests each on
lyso_x06da_ref (of 4.7e7), yag_x10sa_20keV (of 2.2e8) and 4nwv (of 7.0e7), none on cytc_x10sa,
myob_x10sa and thau_bl1a_3p8keV. The battery GPU runs before and after give bit-identical p.hkl on
all six sets.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
RESULT-CHANGING. 3r6o I4122 -> I41 (fail -> pass; ISa 4.15 -> 7.77, R-free 0.303 -> 0.270);
8c3e P622 -> P321 (fail -> unscored: the c screw is undeterminable from its sweep, the point group
is right). Unchanged on the other 18 sets of a GPU battery (run 20261017-1241_520254_twin-isa-t2b
vs 20261016-0057) and on a CPU screen of 53 controls. The reference_mismatch key on 3mc4 and 3r6o
is a note in the report only: nothing is skipped or re-scored. The report.py conflict was the note
column, which now carries both the SHELXL R_free note and the reference note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The first copies' placements are refined side by side, one thread each, and without a GPU each makes
its structure factors and rigid body on the CPU - about a gigabyte of grids on a large cell, five at
once at the 4nwv molecular-replacement peak (14.9 GiB live with a 16 GB machine's budget). Each now
reserves 32 B per point of its grid before it starts, as a null replicate does. What each computes,
and where its score goes, does not change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Images reach the buffer in order give or take a few, so one still missing when the buffer holds an
image 100 past it is lost, not late: the read returns false instead of waiting for the end of the
collection. The newest image number comes from ImageBuffer::GetStatus, which scans every slot under
the buffer's lock, so it is asked only every 100 polls (~10 Hz).
The test puts CBOR images into an ImageBuffer the way the receiver does and checks read-back of the
pixels, waiting for an image not yet there, the lag limit, the end of the collection and the bounds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner decision: an outlier that has an explanation stays in the battery, because it tells
something; removing it makes the results opaque. So 3mc4 and 3r6o run the REFMAC and
depositor-data checks like every other set again, and their numbers count in the plots and
statistics. The manifest's reference_mismatch note (the frames are a ~30% twinned crystal,
the deposition another, untwinned one) is printed in the per-set table's note column and
under the REFMAC R_free ratio plot. battery.py is back to its state before 52025483d.
Checked: battery recheck of 20261017-1139_e25876_twin-isa-mismatch - REFMAC ran on both
(R_free ratio 1.362 / 1.363, dep_cc_delta_all -0.163 / -0.319) and the report shows the note
beside both rows; test_score.py OK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The plain R_free ratio sets the R_free of rugnux's --model placement against the R_free the depositor
published; for an entry refined only in SHELXL (anisotropic, riding H, often atomic resolution) that
published number is lower for reasons of program, not data, so the ratio reads high (1.69 on the
0.6 A Z-DNA entry, where the like-for-like REFMAC ratio is ~1.07). The deposition's refinement
program (_software, classification 'refinement') is now recorded per set, and such a row's note and
its point in the R_free ratio plot say the ratio is not like-for-like and point at the REFMAC ratio.
An entry naming SHELXL beside another refinement program gets no note. In the open arm this marks
6cdl, 6jgh, 6jgj and 7atg; nothing is excluded or re-scored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
RESULT-CHANGING for the GPU spot finder (classic and adaptive, which shares this kernel for its
local-box passes) - only on pixels where the two used to disagree.
Two differences between pixel_result and the CPU's StrongInWindow:
- SNR test. Both form T = float(var) * strong2 in float. The CPU compares the int64 square
(in_minus_mean)^2 with (int64) ceil(T); the GPU compared it with T directly, which converts the
square to float. Below 2^24 the two differ only when the square equals ceil(T) with T not an
integer (an exact-integer strong2 such as 9 or 16 never gives that); past 2^24, where T is an
integer, the GPU's rounded square can land on T and fail a pixel the CPU accepts. The GPU now
uses the CPU's integer-vs-ceil comparison.
- Count threshold. At photon_count_threshold = 0 the CPU still requires val > 0; the GPU took 0 as
"no threshold". It matters only for a pixel <= 0 above a negative local mean (a
pedestal-subtracted detector); the adaptive finder always runs this pass with threshold 0. The GPU
now applies val > threshold unconditionally, with the threshold carried as int64 like the setting.
Measured: a diagnostic build evaluating both semantics in every pixel_result found zero
disagreements in ~2e10 first-pass and ~2.9e7 candidate tests over lyso_x06da_ref, cytc_x10sa,
myob_x10sa, yag_x10sa_20keV, 4nwv and thau_bl1a_3p8keV (JUNGFRAU); the battery GPU runs before and
after give bit-identical p.hkl on all six sets. The new knife-edge test constructs both cases (a
checkerboard window whose square is var * 25 exactly and rounds onto the float threshold; a 0 above
a negative checkerboard) and failed on the GPU before this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
RESULT-CHANGING.
A tetragonal crystal in 4 twinned ~0.3 by k,h,-l (3r6o, I4_1) was merged in I4_122. Since the
beam-stop fix both search arms passed 422 (H ratio 1.51 < 1.85); before it the Lorentz-filtered
arm refused at 2.13x and was overridden by the all-observation arm. Either way the twin-immune
zone - the one reading a twin cannot fake - was never asked: the Stage A zone gate skipped any
candidate that holds every rotation of its lattice, on the argument that no twin law exists for
such a group. But the subgroup's twin law is one of the candidate's own operators, so the
holohedral promotion is exactly the commonest merohedral-twin case (4 -> 422, 3 -> 32 on a
rhombohedral lattice, 321 -> 622), and the L-test-under-merge gate leaves such a twin in the
higher group by design. The gate now runs whenever the P1 merge reads twinned
(<|L|> in [0.375, 0.44)), holohedral or not. 3r6o reads 422 over 4 at -58 nats (acentric).
With 422 refused, the all-observation arm took 222 over 4: the same-order tie-break credits a
group's axial two-folds with the screw evidence of their rows, and on a body-centred crystal
the I centring kills the odd class of every principal row (30 nats each), so a 222 of the real
2-fold plus two twin laws beat the crystal's own 4. Rows whose odd class a centring extinguishes
are no longer counted where the merge reads that centring on its general reflections (the
centring class at < 0.25 of the rest's mean E^2; measured 0.11-0.13 for I on 3r6o, 0.97-1.00
for the absent centrings).
Changed (CPU screen of 53 sets against the kept rc175 run 20261016-0057, plus 3r6o):
- 3r6o: I4_122 -> I4_1 (deposited I4_1), TWINNING INDICATED, twin law h,-k,-l ~0.32;
ISa 4.15 -> 7.77, R_meas 0.218 -> 0.146, CC1/2 0.989 -> 0.993.
- 8c3e: P622 -> P321 with P3_121 | P3_221 as alternatives, screw on c undetermined (deposited
P3_121, twinned): 622 refused at -70.8 nats; ISa 5.69 -> 6.91.
Unchanged: 3mc4, 5j23, 6toc, 6oww, 6iu5/6/9, 2wnn, 2xfw, 4bwl, 5ebi, 2wnq, 6vww, 7k1l, 6pxb,
8xte, 8xtg, 9i80, 5uth, 6h5t, 8k1g, 6moj, 7kcn, 5vml, 3p85, 7rji, 8xtf, 9h0q, 7l84, 6v2r, 7t5t,
5m17, 6p8p, 3meb, 5cc8, 9lxl, 8v4j, 6zqr, 9fcf, 5src, 11if, cuhf2, lyso_x06da_ref,
lyso_x10sa_90deg_1, thau_x10sa_16keV, thau_bl1a_6p5keV, insu_H_x06da_twin/notwin,
insu_I_x06da_ref, cytc_x06da_1, myob_x06da_split, kdp_x10sa_20keV (space group identical,
ISa within run noise; 7l84/5src/11if differ only by the model-adopted enantiomorph label).
Test: a body-centred 4 twinned at 0.45, given its cell, stays in 4 (222 without the centring
rule). [SearchSpaceGroup], [twinning], [twin] pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
bragg_rot_kernel_3d ran one thread for every (h, k, l) of the box on every frame, while the CPU
predictor already skips the l of an (h, k) column whose phi solution cannot lie within the
rocking-curve test's reach (BraggPredictionRot: f(l) = |p0|^2 + 2 S0.p0 bounded by f_max, at
most two intervals of l). The GPU predictor now walks the same l:
- RotationColumnRange.h: the CPU predictor's phi limit and column bound, moved into a
host/device header shared by both, unchanged in arithmetic except that the four divisions
by 2 C*.C* are a product with its reciprocal (each end is widened by a whole index, which
covers the rounding).
- column_range_kernel: the l range of every column, one thread a column, in double; the
column's b and c are evaluated as polynomials in h and k from coefficients set up on the
host, and the blocks are 32 threads, because a consumer card runs double at a small
fraction of the float rate on a unit the warps of a multiprocessor share (a warp per column
with the range on lane 0 was slower than the whole box).
- bragg_rot_kernel_columns: a warp per column, its l spread over the lanes; per (h, k, l) the
same compute_reflections_rot. The output is sorted afterwards (OrderAndSelect), so the
predictions and their order are those of the whole box.
Kernel time on the RTX 5080 (nsys, whole run): 4nwv 1968 -> 415 ms (bragg 374 + ranges 41),
myob 45.5 -> 49.9 ms (small box: the ranges cost more than they save there, ~4 ms a run). On
the Windows laptop bragg_rot_kernel_3d was 9.4 s of 4nwv.
BraggPredictionRotGPU_ColumnRangeKeepsEveryReflection: on 300 random lattices, geometries and
rocking widths the pruned walk predicts bit for bit what the whole box does (the GPU twin of
the CPU test).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Writing the merged reflections - p.mtz, p.cif, p.hkl, ~250 MB for 4nwv - takes 5.3 s on the
Windows laptop against 0.46 s on Linux. The mmCIF rows (twelve "%.4f" fields each) and the
HKLF 4 records (two "%.2f") are formatted with snprintf, ~50 M calls for 4nwv; the likely
Windows cost is the MSVC runtime's printf (slower float formatting, locale handling per call) -
not measured there yet. Fmt and the HKLF 4 number now go through std::to_chars(fixed, precision) where the
standard library has it for floating point (__cpp_lib_to_chars: libstdc++ 11+, MSVC); libc++
before macOS 13.3 does not, and keeps snprintf.
The same digits: both round the exact binary value correctly, halfway cases to even. Checked
against glibc on 20 M float-valued doubles at 4 and 2 decimals and on 8 M exact binary
fractions (every tie), 0 differences. On this machine (gcc-toolset-13) 52 against 194 ns a
field. p.hkl, the MTZ data and p.cif (apart from its provenance lines) byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The speculative geometry probe waited for the main line's merge on every card below 12 GB
(gpu_takes_turns), so on an 8 GB laptop card it always ran after the merge, as a serial
"Geometry check" at the end of the run (Windows RTX PRO 2000: myob 1.4-1.7 s, lyso 0.6 s,
4nwv 3.2-4.4 s, against ~0.15 s where it overlaps). Now it is decided from bytes, up front and
never from free memory:
- GPUMerge::Holds(bytes): the main line says what its merge holds of the card once the scaling
engine is ingested - the engine's DeviceBytes (an upper bound), twice where the all-observation
arm gets an engine of its own - before the post-refinement that may start the probe is let go.
A merge that has not said yet counts as leaving no room. Before the model stage it says the
whole card: model validation and MR budget 80% of the card for themselves.
- GPUWorkBeside(after_merges, bytes): the probe waits for the merges only where its own bytes
and theirs exceed 80% of the first card's total (the same 80% the merge's and the model's GPU
budgets use; on WDDM the rest is the paging margin). The image-pass / probe-pass turn-taking
below 12 GB is unchanged.
- Rugnux::IndexingProbeGPUBytes: the probe's first-pass spot workers at 24 bytes a pixel
(GPUWorkersPerCard's figure) plus 0.65 GB per FFT indexer (1.24 GB measured for two).
The spot-worker count is now one function (FirstPassSpotWorkers) for both.
- The model stage waits for GPU work beside to end before it plans its GPU engines.
Estimates on an 8.55 GB card: myob probe 4.1 GB + merge 0.6 GB -> beside (was: after);
4nwv probe 3.9 GB + merge 4.2 GB -> still after. On the RTX 5080 (16.3 GB): myob 4.8 + 0.6,
4nwv 4.5 + 8.4 = 13.0 of 13.04 GB -> beside, as before.
Concurrency only: what each pass computes is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Validated on the 4x T4 server (round 2): p.hkl, p_model.pdb, MR_* and the candidate table
identical on 9zlo/9s02/9gjx/4nwv against round 1 on 1 and 4 cards; 4nwv on 4 cards 140.8 -> 127.9 s
wall (MR 34.0 -> 21.2 s; rotation 18.4 -> 4.8 s), 9zlo/9s02/9gjx 1-3% faster. Conflicts were the
crash fix both branches carry: the branch's side taken (the per-card Reserve of the device test).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The frames of 3mc4 and 3r6o are a crystal twinned ~30% by k,h,-l - XDS on the same
frames reads the same H-test (0.29/0.27) as rugnux (0.29/0.29), in every 60-frame wedge
and in P1 - while the deposited structure factors read untwinned (H ~0). The deposited
data are another crystal of the same form, so the REFMAC R-free ratio and dep_cc on these
two sets measured the mismatch, not rugnux.
open.json carries the reason as "reference_mismatch"; run and recheck then record
refmac_status/dep_status "skipped" with it, and the report lists both skips (the
depositor-data skip list is shown even when no set was compared). Space group, cell and
d_min are still scored against the deposition.
Checked: battery run --only 3mc4,3r6o --model-check --gpulock
(20261017-1139_e25876_twin-isa-mismatch) - both sets skipped with the reason;
test_score.py OK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
A DECTRIS rotation collection, with the mode switched on (PUT /config/rugnux), goes to
JFJochReceiverRugnux instead of JFJochReceiverLite: the images are forwarded as before, without the
per-image analysis, and one rugnux thread reads them back from the image buffer
(JFJochImageBufferReader) as they arrive. END waits for rugnux and carries its lattice, refined
geometry and per-image results. A small second run on the first 60 degrees reports early whether
there is a lattice. The live state, steps and result are at GET /rugnux/status and on a new
"Rugnux live" frontend page.
rugnux: ProcessConfig::fast_live takes every decision where it is first made and does nothing again
once the sweep is in (near-tie re-checks and the run made again, rotation-scale walk, metric-symmetry
arms, geometry walk, the supercell and quality guards, the doubled cell), so the run ends a fixed
time after the last image. A cut (/cancel, or deadline_s after the last image) hands over what is
established. Off by default: the CLI and the battery are unchanged (--fast-live is hidden, with
--simulate-live).
With the mode off the broker is unchanged. Not built yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New gemmi-only library JFJochRugnuxFiles (writer/), linked by JFJochWriter, so
the writer needs no analysis library and builds with JFJOCH_WRITER_ONLY:
- WriteMergedMtz / WriteMapCoefficientsMtz / PlacedModelStructure make the
files from the structured blocks; WriteRugnuxResultFiles writes whichever
blocks END carries beside the master, each under a temporary name and renamed
into place, an existing file replaced only when the run's overwrite is set.
A file that fails costs only itself (logged), not the run.
- FillRugnuxResultBlocks reads the blocks back from the files rugnux wrote at
a prefix (p.mtz, p_maps.mtz, p_model.cif), for the broker to fill END.
WriteMtzReflections now goes through WriteMergedMtz (MergedReflectionsForWriter
builds the block); its output is byte-identical to before, checked by md5 on a
synthetic set in both the Friedel-merged and the anomalous layout.
StreamWriter writes the files after the master and before the END ACK, so the
ACK means they are in place; END receipt and each rename are logged at INFO
with the wall-clock time, for last-frame-to-files latency. The broker's direct
HDF5 pusher writes them too.
Test: rugnux's three files -> blocks -> CBOR -> writer reproduces all of them
(and _model.pdb) byte for byte; a ~25k-reflection END is 2.7 MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Structured results of an analysis run inside the broker, for jfjoch_writer to
turn into <file_prefix>.mtz, _maps.mtz and _model.cif itself: the merged
reflections and the map coefficients one typed array per MTZ column, the placed
model one array per atom field (coordinates as float64). Each block is a nested
CBOR map under its own optional END key; a reader that does not know a key
skips it, as for every other END key, and unknown keys inside a block are
skipped the same way. Space groups travel as gemmi's extended H-M name, which
round-trips to the same table entry for all 564 settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An entry refined in SHELXL often deposits its structure factors with every reflection 'o' and no
pdbx_r_free_flag, so the depositor-data baseline was skipped and the set had no REFMAC ratio; the
report then showed only the plain R_free ratio, REFMAC on our data against the depositors' SHELXL
R-free, which is a comparison of programs. Such an entry now gets a 5% free set from a fixed hash
of the asymmetric-unit hkl (order-independent, the same reflections free in both data sets and
every run), and baseline_note - shown as refmac_reason - records that it is synthetic.
On the 0.6 A Z-DNA entry of the open arm (7atg, the only such set in the last full run) the
first-cycle REFMAC ratio is 1.067 (0.171 vs 0.160) and the refined shared-free-set ratio 1.084
(0.166 vs 0.153, 2629 shared free reflections), where the plain R_free ratio read 1.69. Entries
with a deposited free set take the unchanged path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Fixes the GPU-path crash after MR on 8sqo, 8v4o and 9zmu (all three now place in the deposited group) and the CPU-only ModelValidation_CCModelUnrelatedModelConfirmsNothing failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Two ways the scale fit's normal matrix went singular and gemmi's jordan_solve threw "Trying to
reverse singular matrix. Column 0 is zeroed.", ending the run:
1. The isotropic start (fit_isotropic_b_approximately) takes log(Fobs/|Fcalc|) over the strong
reflections. At a phase-restricted reflection whose Fcalc and Fmask share a phase, some solvent
pair of the k_sol/b_sol grid can cancel the model exactly in float, |Fcalc + k_sol
exp(-b_sol s^2) Fmask| = 0. log(Fobs/0) = inf made that grid point's k_overall and B NaN, every
sum of the Levenberg-Marquardt NaN, and the solve failed. This is what stopped three open-arm
sets in the output pass after MR on the GPU (the scale fit after model validation's rigid body):
one strong reflection, one grid point. The CPU path has the same exposure; which grid point
hits an exact zero depends on the last bits of Fcalc, so the --no-gpu run of the same set went
through. Such a reflection has no log ratio and is now left out of the start - in the GPU kernel,
in FixedSolventFit::Start and, as a marked local change, in gemmi's own
fit_isotropic_b_approximately, so the three stay equal and the held-solvent fits are covered too.
2. In FitModelScale's CPU Levenberg-Marquardt (ModelValidation_CCModelUnrelatedModelConfirmsNothing
in a CUDA=OFF build): on 66 reflections of an unrelated model the fit walked to k_overall -424
and a B where k exp(-B s^2) |F| underflows at every reflection; the squares in J^T J were all 0
while J^T r kept a residue near 1e-210, which jordan_solve rejects. The fit now ends there, at
its best so far, as it would at the lambda limit; that grid point then loses on R. The GPU fit
takes the derivatives in float and their squares in double, so it cannot reach this state.
Both changes only touch fits that threw before. Byte identity checked against an rc176 build on
the standard and inhouse timing benchmarks and on 4nwv, 8tyy, 6oel (p.hkl, p_model.pdb, MTZ data,
MR_* report keys).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
ReleaseMergeScratch and ReleaseAllButPerFrameScale assigned `= {}` to std::vector members, which is
the initializer-list assignment: it empties a vector and keeps its memory, so only the struct-typed
members were ever handed back. They now free (Free(v): v = T()). And every RunArm hands back the
fulls, their staging and group CSR and the merge's working arrays when it returns - each is rebuilt
before it is read - instead of keeping them beside the next arm's combine and through the analyses:
4nwv on the CPU at 12 threads, live peak in scaling 13.0 -> 11.9 GiB, in the analyses 11.7 -> 7.3.
Output unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Engine placement: the rigid-body engines past this card's first go one card after the other in turn, from
the next card and this one last, each within half of its budget. A small cell on four cards (24 engines of
0.09 GB) had put all of them on this card, which has room for them, where rc176 spread them; now 6/6/6/6.
A large cell on four 15.6 GB cards keeps T+1/T+2/T+2/T+2.
Layout: packing was refused wherever it cost any card an engine, which kept 4nwv plain on four T4s although
the packed rotation function on ONE card (15.1 s) already beat plain over four (18.5 s). Now the two layouts'
plans are compared on a small cost model, decided once before the search from sizes and counts alone:
seconds = rotation interpolations (orientations x reflections x operators) x s/interpolation / cards with a copy
+ rigid-body fits at once x engine GB x s/(fit GB) / engines
Constants measured on 4nwv on Tesla T4 (17255 x 244183 x 4 interpolations): 75.8 s plain and 15.1 s packed on
one card; rescoring + null 28.5 s with one engine and 11.6 s with seven, 9 fits of 2.10 GB, so 19.7 s shared
among the engines. Packing must still keep the search, the scores and (where plain has it) the rigid body on
the GPU, and the budget must hold both grids while the packed one is made. Expected on four T4s: packed
T+1/T+1/T+1/T+1, 8.7 s against 21.8 s plain. One card is unchanged (the engines are the same either way, so
packed). The layout line now logs both expected times and the search's size.
Layout and engine count change where the work runs, never a number.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The engines past this card's first now go one card after the other, from the next card and this one last,
each within half of its own budget, as MR's do. Before, this card took every extra engine it had room for
and the other cards only the rest, so a small cell on four cards ran all its fits on one. Where this card
has no room for a second engine (a large cell on 15.6 GB cards) the plan is unchanged: 1/3/3/3. This card's
scale fits take the room its engines leave. Engines are interchangeable and deterministic, so only where
fits run changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
The run's budget (ProcessConfig::host_memory_budget_bytes, else 75% of the machine's RAM) is decided
once and logged, and sizes the host worker pool. Three concurrencies of the scaling chain are
decided on it from counts known before each starts (RotationScaleMerge::HostIngestBytes,
HostMergeBytes, HostBytesBound), each logged where it serialises:
- the quality guard's merge beside the main ingest: where they would not fit together, the guard
merges first;
- the canonical pass's post-refinement beside the merge chain: done before the first merge;
- the all-observation P1 search merge beside the main engine's (GPU path): started where the search
first reads it, after the main engine's merges and the release of their scratch.
None of these reads what the other writes while it runs, so only the order changes: a [large] test
runs the rotation set at a 1 TB and a 1 MB budget and compares the .hkl byte for byte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
Without a GPU (and for the observation dump) RotationScaleMerge kept a full Obs copy of every
partial - 84 B, 9.8-10.9 GiB on a 123 M-partial set, 2.6-2.9 GiB on 4nwv - beside the reflections it
was copied from, which the run keeps anyway. A partial is now a PartialObs: a pointer to its
reflection plus frame, prescaling_corr (with the flux divided out), the recomputed partiality and the
working corr. Every other field is read from the reflection, which nothing changes after integration
but h,k,l (read only at the ingest). Fields the CPU never read on a partial (h,k,l, delta_phi,
var_per_I, capture, group) are gone. Same values in the same arithmetic order: output unchanged.
The geometry pre-pass handed each image's reflections back right after the ingest; an engine that
reads them (ReadsReflections) now has the post-refinement view gathered once it is reset, after the
pre-pass merge - the gather reads only reflection fields, so it is the same gather.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
- The CPU translation function runs as many rotations at once as the host worker pool holds (8 B per
grid point each, 550 MiB on a 400 A cell; 33 at once at 32 threads was 18 GiB), at most nthreads.
Counted, not reserved: the rotations run on ParallelFor's pool, whose workers must not wait on memory.
- Null replicates (model validation's and MR's) whose structure factors are made on the CPU reserve
32 B per point of their grid (ModelGridPoints) against the pool before they start, so the nine
replicates, and nulls run side by side, hold at most the pool between them.
Every replicate and rotation computes what it did and is stored by index: no result depends on how
many run at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4
PointSurfaces held four padded (2h x 2w) r2c workspaces and three spectra at once - ~4.2 GiB on an
18 Mpx detector at any thread count, the Mac's peak on small proteins. Two workspaces now, the image's
spectrum handed back before the squared image's is made: 24 instead of 44 bytes per padded point.
Each transform is built and run as before (own buffers, own plan), so the surfaces are bit-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXc1zD1yxHqaR1DGdCTHL4