Files
Jungfraujoch/image_analysis/indexing/SpindleBlindFraction.cpp
T
leonarski_fandClaude Opus 5.5 4591837888 Fix broker SEGV in FilterFFTResults: sort keys computed once, not in the comparator
The length sort in FFTIndexer::FilterFFTResults recomputed Coord::Length() inside
its comparator. Under LTO with -march=x86-64-v3 (CI and production builds) GCC
inlines Length() at several sites in std::__introsort_loop and contracts
x*x+y*y+z*z into FMAs differently at each (fma(x,x,y*y) for the element keys,
fma(y,y,x*x) for the pivot recomputed after a swap). The same element's key then
differs by one ulp between comparisons. Shortlist lengths come from quantised FFT
bins, so exact ties are common on noise frames; on such a tie the unguarded
partition scan passes its sentinel and runs off the index array.

Evidence:
- rc.172 jfjoch_broker disassembly: pivot key after swap at 0x925a83 is rounded
  differently from the scan keys.
- The deployed rc.172 introsort, called directly on finite golden-spiral
  directions x binned lengths, faults at binary +0x5259ca (the journal's crash
  address) in up to ~0.5% of sorts. No NaN is involved; the earlier NaN guards
  could not help.
- New test FFTIndexer_ManyNoiseFrames: unfixed rc.172 built with the CI flags
  (-march=x86-64-v3 -flto=auto) segfaults in the same introsort from
  FilterFFTResults on the GPU FFT path (noise frame 1632), 3/3 runs; passes with
  this fix. A non-LTO build keeps Length() out of line and cannot crash, which
  is why the suite never caught it.

Keys are now precomputed and sorted with stable_sort. The same pattern was
fixed in SpindleBlindFraction (broker-reachable, 62 lattice rows, |a|=|b| ties)
and LePageLattice::PlaneBasis (rugnux, v/-v exact ties); tie order in the
latter may change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:43:52 +02:00

191 lines
9.8 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <cmath>
#include "SpindleBlindFraction.h"
#include "../../common/JFJochMath.h" // PI - M_PI is not standard, and MSVC does not define it
namespace {
// Only rows short enough to be a plausible symmetry axis count. The cut is relative to the
// crystal's own shortest row, not an absolute length, so it works the same for a 40 A cell and
// a 200 A one: measured over 107 solved cells, 2.5 x the shortest row covers 88% of all
// symmetry axes and 98% of crystals' shortest one.
constexpr float MAX_ROW_LENGTH_RATIO = 2.5f;
// The reference length is taken over the strong rows only. A long-cell still makes the pass
// invent short spurious rows - measured at 0.4-0.6 of the true row's peak - and taking the
// reference over every row would let one of those shrink the window until the real aligned row
// fell outside it, turning a severe orientation into a reported zero.
constexpr float MIN_MAGNITUDE_RATIO = 0.5f;
// Where the search grid can no longer resolve the crystal's rows, the pass stops returning them
// and starts returning short spurious ones instead, and the shortlist becomes internally
// inconsistent: its strong rows are many times longer than its shortest entry. Measured on the
// stills of 22 solved crystals - 22 independent mounts, so 22 is the sample size, not the
// several hundred frames they contributed - the ratio never exceeded 2.34 and was 1.00 at the
// median; on a
// synthetic still whose cell is past the grid's reach it runs 4-13. Past this the score would be
// reporting a cone it cannot see into, so it reports nothing instead.
constexpr float MAX_REFERENCE_LENGTH_RATIO = 3.0f;
}
float SpindleThetaMax_deg(float wavelength_A, float d_min_A) {
if (wavelength_A <= 0 || d_min_A <= 0)
return 0;
const float sin_theta = std::min(1.0f, wavelength_A / (2.0f * d_min_A));
return static_cast<float>(std::asin(sin_theta) * 180.0 / PI);
}
float BlindConeSelfOverlap(float x) {
if (x >= 1.0f)
return 0.0f;
if (x <= 0.0f)
return 1.0f;
return static_cast<float>(2.0 / PI) *
(std::acos(x) - x * std::sqrt(1.0f - x * x));
}
std::optional<SpindleSeverity> SpindleBlindFraction(const std::vector<Coord> &rows,
const std::vector<float> &magnitudes,
const Coord &spindle,
float theta_max_deg) {
if (rows.empty() || rows.size() != magnitudes.size() || theta_max_deg <= 0)
return {};
const float axis_length = spindle.Length();
if (axis_length < 1e-6f)
return {};
const Coord axis = spindle.Normalize();
float max_magnitude = 0;
for (const auto &m : magnitudes)
max_magnitude = std::max(max_magnitude, m);
float reference_length = 0;
for (size_t i = 0; i < rows.size(); i++)
if (magnitudes[i] >= MIN_MAGNITUDE_RATIO * max_magnitude) {
const float l = rows[i].Length();
if (reference_length == 0 || l < reference_length)
reference_length = l;
}
if (reference_length == 0)
return {};
float shortest_length = reference_length;
for (const auto &r : rows)
shortest_length = std::min(shortest_length, r.Length());
if (reference_length > MAX_REFERENCE_LENGTH_RATIO * shortest_length)
return {};
std::vector<size_t> eligible;
for (size_t i = 0; i < rows.size(); i++) {
const float length = rows[i].Length();
if (length <= 0 || length > MAX_ROW_LENGTH_RATIO * reference_length)
continue;
if (magnitudes[i] < MIN_MAGNITUDE_RATIO * max_magnitude)
continue;
eligible.push_back(i);
}
SpindleSeverity ret;
bool scored = false;
const auto consider = [&](const Coord &direction, float row_length_A) {
const float len = direction.Length();
const float cos_beta = std::min(1.0f, std::fabs(direction * axis) / len);
const float beta_deg = static_cast<float>(std::acos(cos_beta) * 180.0 / PI);
// A direction PERPENDICULAR to the spindle is as damaging as one along it, and far more
// common: a lone 2-fold about it carries the blind cone onto the cone's opposite lobe, which
// the same sweep leaves equally unmeasured. That is Friedel's rescue, which is no rescue -
// the cone is double-sided. (An axis of order >= 3 there DOES repair the cone - measured
// unrepaired fraction 0.000 for orders 3, 4 and 6 against 1.000 for order 2 - but a still
// cannot know the order, and the lone diad is the worst case this bound assumes.) Both ends
// of the range are the bad case and the safe zone lies between them, so the miss-angle is
// folded about 45 deg. Checked against a Monte-Carlo of the true spherical overlap the
// folded form is within 0.006 to theta_max = 20 deg and 0.024 to 45 deg; unfolded it is
// wrong by a full 1.0 at beta = 90 deg, reporting the worst case as the best.
const float fold_deg = std::min(beta_deg, 90.0f - beta_deg);
const float score = BlindConeSelfOverlap(fold_deg / theta_max_deg);
if (!scored || score > ret.score) {
ret.score = score;
ret.row_length_A = row_length_A;
ret.miss_angle_deg = beta_deg;
scored = true;
}
};
for (const auto i : eligible)
consider(rows[i], rows[i].Length());
// A lone 2-fold on an axis LONGER than the length window is invisible above - not among the
// shortlist's rows, and excluded by the window even when it is - but its direction is still
// recoverable: the normal to two direct-lattice rows is itself a reciprocal-lattice row, and a
// symmetry axis is parallel in the direct and reciprocal bases, so for a monoclinic cell
// cross(a, c) IS the unique-axis direction whatever the length of b. Score the normals of the
// strong in-window row pairs alongside the rows themselves; measured on a synthetic lone-diad
// crystal with a 300 A unique axis, the fraction of severe mounts reported severe at the 0.5
// trigger rises from 0.60 to 1.00 and the engagement rate on harmless mounts of that class
// does not move. The guard only rejects a numerically degenerate normal; the shortlist already
// keeps its rows 5 deg apart.
for (size_t a = 0; a < eligible.size(); a++)
for (size_t b = a + 1; b < eligible.size(); b++) {
const Coord n = rows[eligible[a]] % rows[eligible[b]];
if (n.Length() > 1e-4f * rows[eligible[a]].Length() * rows[eligible[b]].Length())
consider(n, 0.0f); // 0 = a direction inferred from a pair, not a measured row
}
return ret;
}
std::optional<SpindleSeverity> SpindleBlindFractionFromLattice(const CrystalLattice &lattice,
const Coord &spindle,
float theta_max_deg) {
// Candidate rows: the direct lattice's shortest few distinct directions, drawn from the index
// box up to +/-2 - the range in which the symmetry axes of a reduced or conventional basis
// lie. The count matches what the FFT shortlist resolves in practice (four or five distinct
// rows - see FilterFFTResults), so the bound is taken over comparable evidence on either path.
// That parity is load-bearing: a worst case over every enumerable direction saturates towards
// "always engage" - measured on a generic triclinic cell it fires on 100% of harmless mounts,
// against 74% for this selection at theta_max = 15 deg - and an always-firing trigger decides
// nothing. The diad-detection rate stays 1.00 on the monoclinic classes either way, because a
// dropped axis row is recovered by the pair normals exactly as an invisible one is.
std::vector<Coord> all;
all.reserve(62);
for (int u = 0; u <= 2; u++)
for (int v = (u == 0) ? 0 : -2; v <= 2; v++)
for (int w = (u == 0 && v == 0) ? 1 : -2; w <= 2; w++)
all.push_back(lattice.Vec0() * static_cast<float>(u)
+ lattice.Vec1() * static_cast<float>(v)
+ lattice.Vec2() * static_cast<float>(w));
// Keys computed once, not inside the comparator - see FilterFFTResults: an FMA-contracted Length()
// can round differently at different inlined sites, and on the ties a lattice is full of
// (|a| = |b|, |a+b| = |a-b|) that lets std::sort run off the array.
std::vector<std::pair<float, Coord>> by_length;
by_length.reserve(all.size());
for (const auto &c : all)
by_length.emplace_back(c.Length(), c);
std::stable_sort(by_length.begin(), by_length.end(),
[](const auto &a, const auto &b) { return a.first < b.first; });
for (size_t i = 0; i < all.size(); i++)
all[i] = by_length[i].second;
constexpr size_t MAX_LATTICE_ROWS = 6;
const float cos_5_deg = std::cos(5.0f * static_cast<float>(PI) / 180.0f);
std::vector<Coord> rows;
for (const auto &r : all) {
if (rows.size() >= MAX_LATTICE_ROWS
|| r.Length() > MAX_ROW_LENGTH_RATIO * all.front().Length())
break;
bool distinct = true;
for (const auto &k : rows)
if (std::fabs(r * k) / (r.Length() * k.Length()) > cos_5_deg) {
distinct = false;
break;
}
if (distinct)
rows.push_back(r);
}
const std::vector<float> magnitudes(rows.size(), 1.0f);
return SpindleBlindFraction(rows, magnitudes, spindle, theta_max_deg);
}