First-pass memos: verified by a test flag, not RUGNUX_VERIFY_FIRST_PASS_MEMO

The recompute-and-compare check of the first-pass memos (a probe's
validation evidence, the per-frame spot lists, the RotationIndexer outcome)
is now ProcessConfig::verify_first_pass_memo, passed to RotationIndexer with
VerifyMemo(). No command-line option; only tests set it.

A mismatch now throws MemoMismatch, which the passes' catch-and-continue
sites treat as fatal: before, a mismatch inside a rotation-scale probe was
caught and logged as a probe that did not complete, so verification could
pass without having checked anything.

Rugnux_FirstPassMemoMatchesRecomputation ([large], the LFS rotation set,
~15 s on a GPU) runs the two-pass with post-refinement and scaling, which
takes the walk's probes and the canonical pass through every memo, with the
verification on. Checked that a forced mismatch fails it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
This commit is contained in:
2026-09-28 22:46:00 +02:00
co-authored by Claude Opus 5.5
parent 23812b902c
commit 0fa1954ae4
5 changed files with 77 additions and 19 deletions
@@ -9,7 +9,6 @@
#include "../lattice_search/LatticeSearch.h"
#include "../indexing/MultiLatticeSearch.h"
#include <cstdlib>
#include <future>
#include <mutex>
#include <stdexcept>
@@ -255,7 +254,7 @@ void RotationIndexer::RunIndexing() {
if (k == key)
kept = outcome;
}
if (kept && !std::getenv("RUGNUX_VERIFY_FIRST_PASS_MEMO")) {
if (kept && !verify_memo) {
axis_ = kept->axis;
updated_geom_ = kept->updated_geom;
search_result_ = kept->search_result;
@@ -272,7 +271,7 @@ void RotationIndexer::RunIndexing() {
const IndexingOutcome outcome = Outcome();
if (kept) {
if (!SameOutcome(*kept, outcome))
throw std::runtime_error("RotationIndexer: the indexing memo differs from a recomputation");
throw MemoMismatch("RotationIndexer: the indexing memo differs from a recomputation");
return;
}
auto &memo = Memo();
@@ -6,6 +6,7 @@
#include <memory>
#include <vector>
#include <mutex>
#include <stdexcept>
#include "../../common/DiffractionSpot.h"
#include "../../common/DiffractionExperiment.h"
@@ -38,6 +39,12 @@ struct RotationIndexerResult {
std::optional<TiltWalk> tilt_walk;
};
// What a memo verification throws where a reused result differs from its recomputation - its own
// type, so that no pass that catches a failure and carries on can take it for an ordinary one.
struct MemoMismatch : std::runtime_error {
using std::runtime_error::runtime_error;
};
class RotationIndexer {
public:
// RunIndexing is deterministic in what it reads - the accumulated spots and their angles, the
@@ -45,8 +52,8 @@ public:
// of the pool it indexes with - and a rotation run asks the same question again and again (the
// canonical pass repeats its rotation-scale probe, a probe repeats its pass's rescue ladder).
// So what it computes is kept, process-wide, under exactly those inputs, and a second
// RotationIndexer asking with the same inputs takes the answer. RUGNUX_VERIFY_FIRST_PASS_MEMO
// recomputes anyway and throws on any difference.
// RotationIndexer asking with the same inputs takes the answer. VerifyMemo(true) recomputes anyway
// and throws on any difference.
struct IndexingOutcome {
std::optional<GoniometerAxis> axis;
DiffractionGeometry updated_geom;
@@ -68,6 +75,7 @@ private:
// caller lowers it to seed the search on the strongest few, exactly as the per-frame indexer
// escalates 30 -> 80 -> all, for a pattern where the deep list is mostly not this crystal.
size_t max_spots_per_image = DEFAULT_MAX_SPOTS_PER_IMAGE;
bool verify_memo = false;
const bool index_ice_rings;
const bool real_time; // see the constructor
@@ -108,6 +116,8 @@ public:
RotationIndexer(const DiffractionExperiment& x, IndexerThreadPool& indexer, bool real_time = false);
// Keep only this many spots per image in the accumulated cloud (strongest, non-ring first).
void MaxSpotsPerImage(size_t n) { max_spots_per_image = n > 0 ? n : DEFAULT_MAX_SPOTS_PER_IMAGE; }
// Recompute on a memo hit and throw if the two differ - the tests' check that the memo is exact.
void VerifyMemo(bool v) { verify_memo = v; }
// angle_deg is the image's mid-exposure rotation angle; if omitted, the goniometer angle at
// `image` is used (only valid when `image` is the goniometer's own image index).
void ProcessImage(int64_t image, const std::vector<SpotToSave>& spots,
+13 -11
View File
@@ -148,6 +148,9 @@ namespace {
// has quietly changed. Fail the run instead, and let the operator free the card and repeat it.
bool IsFatalResourceError(const std::exception &e) {
if (dynamic_cast<const std::bad_alloc *>(&e)) return true;
// Not a resource, but just as fatal: a memo verification (verify_first_pass_memo) that found a
// difference must end the run, not pass for a probe that scored nothing.
if (dynamic_cast<const MemoMismatch *>(&e)) return true;
const auto *jf = dynamic_cast<const JFJochException *>(&e);
return jf != nullptr && (jf->Category() == JFJochExceptionCategory::GPUCUDAError
|| jf->Category() == JFJochExceptionCategory::MemAllocFailed);
@@ -3397,7 +3400,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
// detector. Nothing between here and the first pass changes what the key reads.
if (full && indexing_probe_only_ && !force_rotation_result_.has_value()
&& !config_.forced_rotation_lattice.has_value() && config_.rotation_indexing && config_.two_pass_rotation
&& !std::getenv("RUGNUX_VERIFY_FIRST_PASS_MEMO")) {
&& !config_.verify_first_pass_memo) {
const std::vector<double> first_pass_key = FirstPassInputKey();
const auto memo = std::find_if(first_pass_memo_.begin(), first_pass_memo_.end(),
[&](const FirstPassMemo &m) { return m.key == first_pass_key; });
@@ -3579,7 +3582,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
logger.Info("Rotation indexer lattice forced externally - skipping first pass");
} else if (full && config_.rotation_indexing && config_.two_pass_rotation) {
// An indexing probe over inputs a first pass has already been run on returned above, except
// under RUGNUX_VERIFY_FIRST_PASS_MEMO, which runs the pass and compares.
// under verify_first_pass_memo, which runs the pass and compares.
const std::vector<double> first_pass_key = FirstPassInputKey();
std::optional<ValidationSpotEvidence> memo_hit;
const auto memo = std::find_if(first_pass_memo_.begin(), first_pass_memo_.end(),
@@ -3668,7 +3671,6 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
};
int prefetch_batches = 0;
double prefetch_time_s = 0.0;
const bool verify_spots = std::getenv("RUGNUX_VERIFY_FIRST_PASS_MEMO") != nullptr;
const auto prefetch_spots = [&](const std::vector<int> &ordinals) {
// What an earlier pass already found under the same key (first_pass_spots_) is taken from
// there.
@@ -3685,7 +3687,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
std::vector<int> wanted;
for (const int ordinal : ordinals)
if (!spot_cache.contains(ordinal)) {
if (const auto it = kept.find(ordinal); it != kept.end() && !verify_spots)
if (const auto it = kept.find(ordinal); it != kept.end() && !config_.verify_first_pass_memo)
spot_cache.emplace(ordinal, it->second);
else
wanted.push_back(ordinal);
@@ -3731,9 +3733,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
}
for (size_t i = 0; i < wanted.size(); i++) {
if (const auto it = kept.find(wanted[i]); it != kept.end() && !SameSpots(it->second, found[i]))
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
fmt::format("First-pass spot memo differs from a recomputation "
"for image {}", wanted[i]));
throw MemoMismatch(fmt::format("First-pass spot memo differs from a recomputation "
"for image {}", wanted[i]));
entry->second.emplace(wanted[i], found[i]);
}
}
@@ -3963,6 +3964,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
const DiffractionExperiment *x = nullptr) {
auto ri = std::make_unique<RotationIndexer>(x ? *x : experiment_, pool);
ri->MaxSpotsPerImage(first_pass_spots_per_image);
ri->VerifyMemo(config_.verify_first_pass_memo);
for (size_t i = 0; i < ordinals.size(); i++) {
if (cancelled_ || ri->AccumulationFull())
break;
@@ -5466,10 +5468,9 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
result.validation_evidence = evidence;
if (memo_hit && (memo_hit->spots != evidence.spots || memo_hit->on_lattice != evidence.on_lattice
|| memo_hit->by_chance != evidence.by_chance))
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
fmt::format("First-pass memo mismatch: stored {}/{}/{}, recomputed {}/{}/{}",
memo_hit->on_lattice, memo_hit->spots, memo_hit->by_chance,
evidence.on_lattice, evidence.spots, evidence.by_chance));
throw MemoMismatch(fmt::format("First-pass memo mismatch: stored {}/{}/{}, recomputed {}/{}/{}",
memo_hit->on_lattice, memo_hit->spots, memo_hit->by_chance,
evidence.on_lattice, evidence.spots, evidence.by_chance));
// Kept only where the pass ended on the inputs it started from, and did not take the
// beam-centre ladder, which a probe (beam_center_searched_) never would.
if (!geometry_prepass && !beam_center_ladder_ran && FirstPassInputKey() == first_pass_key)
@@ -5657,6 +5658,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
const CrystalLattice primary_reduced = reduced(lattice);
for (int id = 1; id <= 3; id++) {
RotationIndexer ri(x, *indexer_pool);
ri.VerifyMemo(config_.verify_first_pass_memo);
for (const int f : spread_ordinals) {
std::vector<SpotToSave> left;
const auto &sp = spot_cache.at(f);
+8 -3
View File
@@ -202,6 +202,11 @@ struct ProcessConfig {
// a user-fixed -S writes nothing, because its centring absences were never integrated.
bool write_p1_crosscheck = true;
bool finalist_ledger = false; // --finalist-ledger; report-only symmetry evidence table
// Where a rotation run would reuse its first-pass memos - a probe's validation evidence, a frame's
// spot list, an indexing outcome - recompute instead and throw if the two differ. Set by the tests
// that prove the reuse exact; not on the command line.
bool verify_first_pass_memo = false;
};
// A rotation lattice's score on the validation frames spread over the whole sweep: their spots (off
@@ -731,7 +736,7 @@ class Rugnux {
// pass read (FirstPassInputKey). The first pass is deterministic in them, so an indexing probe
// whose key matches takes the stored evidence instead of indexing again - the geometry walk's
// probe at the geometry in hand is the canonical pass's own first pass run over. With
// RUGNUX_VERIFY_FIRST_PASS_MEMO set the probe runs anyway and the run fails if the two differ.
// ProcessConfig::verify_first_pass_memo the probe runs anyway and the run fails if the two differ.
struct FirstPassMemo {
std::vector<double> key;
ValidationSpotEvidence evidence;
@@ -740,8 +745,8 @@ class Rugnux {
// The first pass's spot lists, kept across passes under everything a frame's list depends on
// (SpotFindingKey): a probe and the canonical pass after it, or a pass repeating an earlier
// pass's rescue, find the spots of the same frames under the same settings again. Shared with the
// copies the run makes of itself. RUGNUX_VERIFY_FIRST_PASS_MEMO finds them again and throws on a
// difference.
// copies the run makes of itself. ProcessConfig::verify_first_pass_memo finds them again and
// throws on a difference.
struct FirstPassSpots {
std::mutex m;
std::vector<std::pair<std::vector<double>, std::map<int, std::vector<SpotToSave>>>> entries;
+42
View File
@@ -79,3 +79,45 @@ TEST_CASE("Rugnux_Rotation", "[large]") {
reader.Close();
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
}
// A rotation run reuses what its first pass found - an indexing probe's validation evidence, each
// frame's spot list, the rotation indexer's outcome - wherever it asks the same question again: in the
// rotation-scale walk's probes and in the canonical pass after them. verify_first_pass_memo recomputes
// at every one of those hits instead and throws where the recomputation differs, so this run passing
// is the check that the reuse is exact. The geometry post-refinement and the scaling it runs inside
// are on because they are what starts the walk.
TEST_CASE("Rugnux_FirstPassMemoMatchesRecomputation", "[large]") {
const auto master = jfjoch_test::LargeDataFile("rotation_master.h5");
if (!master)
SKIP("rotation_master.h5 not available (git-lfs data not pulled)");
RegisterHDF5Filter();
JFJochHDF5Reader reader;
REQUIRE_NOTHROW(reader.ReadFile(*master));
auto dataset = reader.GetDataset();
REQUIRE(dataset);
DiffractionExperiment experiment(dataset->experiment);
IndexingSettings indexing;
indexing.Algorithm(IndexingAlgorithmEnum::Auto);
indexing.RotationIndexing(true);
experiment.ImportIndexingSettings(indexing);
ProcessConfig config;
config.mode = ProcessMode::FullAnalysis;
config.nthreads = default_threads();
config.spot_finding = DiffractionExperiment::DefaultDataProcessingSettings();
config.spot_finding.indexing = true;
config.rotation_indexing = true;
config.two_pass_rotation = true;
config.rotation_postrefine_geometry = true;
config.run_scaling = true;
config.verify_first_pass_memo = true;
Rugnux process(reader, experiment, *dataset->pixel_mask, config);
ProcessResult result;
REQUIRE_NOTHROW(result = process.Run());
CHECK(result.consensus_cell.has_value());
reader.Close();
}