Files
Jungfraujoch/tests/SyntheticMergedReflections.h
T
leonarski_fandClaude Opus 5 3171b071e6 Space-group search: judge a promotion against its parent, not against the error model
The point-group decision moved with the AMOUNT of data at fixed physics: a
partially twinned trigonal crystal was promoted into the twin's holohedry
whenever the search happened to see a larger first-pass merge, and kept its
true subgroup when it saw a smaller one. Simulation over 6 noise draws with
only the merge multiplicity varying: the twin is promoted 0/6 at multiplicity
2 and 6/6 at 18, while the genuine control is promoted 6/6 throughout. The
cause is that every existing gate is a ratio to the merge error model -
b_parent grows toward the true systematic scatter as sigma shrinks with
1/sqrt(N), while b_cand is already saturated by the twin's disagreement, so
the ratio slides down through a fixed veto. The parent statistic moves with
data amount and the candidate statistic does not.

Gate promotions on the operator disagreement H = <|I1-I2|/(I1+I2)> instead,
as the ratio of the operators a promotion ADDS to the parent group's own
operators on the same reflections. There is no sigma in it, so it cannot
drift with the error model, and the parent normalisation cancels data
quality. Measured over 27 runs, 5 promotion types and 450-1800 images:
genuine symmetry 0.862-1.219, merohedral twins 1.270-2.084. On the synthetic
grid it is flat across a 9x change in multiplicity - genuine pinned at 1.00,
twins 3-12x the bound - which is precisely the property the old gates lacked.
chi^2 and the systematic-b stay as secondary vetoes; they protect against
non-crystallographic pseudo-symmetry, which is where correlation-based
scoring is weak.

Pick the parent carefully: 422 has two maximal subgroups of order 4, and on a
tetragonal crystal twinned by 2[100] the rival (222) is CC-confirmed too and
CONTAINS the twin laws, so normalising against it hides the twin among the
promotion's own real operators (ratio 8.19 against the true parent, 0.78
against the rival). Where several parents tie, judge on the most damning.

Also:

- Report a refused promotion instead of silently processing lower. Merging a
  twin in the twin's holohedry averages non-equivalent reflections into each
  other and cannot be undone from the output; keeping the subgroup costs only
  redundancy. The refusal names the group and the number that caused it.

- Stop the twinning report from arguing in a circle. It ran after adoption and
  conditioned on the adopted group, so a promotion into a holohedral Laue
  class made it print "no merohedral twin law exists" - the test was
  conditioned on the decision it should audit. Twinning is now also measured
  on the subgroup merge before adoption, and the post-adoption text says when
  its own conclusion is not authoritative.

- Compare PRIMITIVE cell volumes in the first-pass scheme tie-break. A centred
  setting's cell is an exact integer multiple of its primitive one (a
  rhombohedral lattice in hexagonal axes is exactly 3x), so the
  integer-supercell test fired on a pure setting difference and demoted a good
  scheme to a threefold-smaller merge - which is what let the twin see the
  small merge to begin with.

Rotation battery, 33 crystals: point-group agreement 30/33 -> 29/33, one
crystal moved. That crystal (P422 -> P222) is the one with the known
unresolved integration defect where reflections near the rotation-axis plane
are wildly mis-integrated; its symmetry mates genuinely disagree, and its
lower-symmetry merge is measurably better (ISa 2.72 -> 3.63, high-shell CC
75.4 -> 86.0). The threshold was not moved to accommodate it: 1.25 sits inside
the measured gap and widening it would admit real twins. Separately the
tie-break improved one crystal's CC1/2 from 77.7 to 84.0.

Tests: a synthetic twin-fraction x multiplicity grid, which is what the search
had never had - the existing tests are noise-free and exercise only Stage B
absences.

A NOTE ON WHAT WAS TRIED AND REJECTED, so it is not rebuilt: the obvious
"physics-anchored" statistic is the disattenuated cross-validated correlation
rho = corr(I_half0(h), I_half1(Rh)) / corr(I_half0, I_half1), which is 1 for
real symmetry at any data quality and 2a(1-a)/((1-a)^2+a^2) for a twin. It
passes the synthetic grid perfectly and FAILS ON REAL DATA IN BOTH
DIRECTIONS - five false refusals of genuine symmetry on the battery, and it
waves through a twin (rho 0.998) that H refuses. The reason is that cc_half
correlates the two halves of the SAME reflection and so measures only random
error, while cc_cross compares DIFFERENT reflections carrying different
systematic error; dividing by cc_half removes the noise and leaves a
systematic floor that varies by crystal AND by operator. Genuine rho measures
0.9987 on strong data and 0.73 on weak. A synthetic generator validates a
statistic's arithmetic, never its premise, and this premise - that the only
departure from exact symmetry is noise - is false for every real crystal.
Any per-operator agreement statistic needs a same-crystal reference; an
absolute threshold on one cannot be made to work by tuning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:05:16 +02:00

240 lines
12 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <optional>
#include <random>
#include <string>
#include <tuple>
#include <vector>
#include "../common/Reflection.h"
#include "gemmi/symmetry.hpp"
#include "gemmi/unitcell.hpp"
// Synthetic merged intensities for the space-group / point-group search tests.
//
// The set produced here is what SearchSpaceGroup is fed in production: a P1 merge (one entry per
// Friedel-canonical hkl) with intensities, sigmas and half-set intensities. Unlike the noise-free
// set in SearchSpaceGroupTest.cpp it models the three things the point-group decision actually
// depends on:
//
// * a merohedral TWIN - the crystal's true point group is a subgroup of index 2 of the metric
// (lattice) point group, and the twin law is the operator that separates them:
// I_obs(h) = (1-alpha) I_true(h) + alpha I_true(twin h)
// I_true is a function of the TRUE (sub)group's asu, so the subgroup symmetry is exact and only
// the extra supergroup operator is broken - by (1-2 alpha) (I_true(h) - I_true(twin h)). At
// alpha = 0.5 the two are identical and the twin is indistinguishable from real symmetry.
// Setting the true group to the SUPERgroup instead gives the untwinned high-symmetry control.
//
// * the MERGE MULTIPLICITY, modelled the way the real merge behaves (Merge.h,
// SigmaWithSystematicFloor): the random part of the merged sigma averages down as
// 1/sqrt(multiplicity) while the systematic part (b*I - absorption, partiality, beam flicker;
// correlated across a reflection's repeats) does not, so the merged sigma is
// max(sigma_statistical, b*|I|). Multiplicity therefore changes the sigmas but NOT the physics,
// and no symmetry decision may depend on it.
//
// * an ERROR-MODEL MISCALIBRATION - real merged sigmas come out under-estimated (~1.7x), which is
// what pushes the merge's reduced chi^2 to ~3 and switches SearchSpaceGroup between its
// chi^2-ratio and systematic-b regimes.
//
// Intensities follow a Wilson (exponential) distribution with a resolution fall-off, so they span a
// realistic dynamic range; the "structure factor" is a hash of the asu index, not a draw from the
// RNG stream, so the same crystal is reproduced bit-for-bit whatever the multiplicity or the twin
// fraction and two runs differ only in the knob under test.
namespace jfjoch_test {
struct SyntheticMergeParams {
// Symmetry the structure factors actually have: the generated intensities are exactly
// invariant under it, whatever the twin fraction.
std::string true_space_group = "R 3 :H";
// Supergroup of index 2 over true_space_group; its extra operator is the twin law. Set it
// equal to true_space_group to model a crystal that GENUINELY has the higher symmetry -
// there is then no extra operator, twinning by a real symmetry operator is a no-op, and the
// twin fraction has no effect.
std::string twin_supergroup = "R 32 :H";
// Merohedral twin fraction alpha in [0, 0.5]. 0 = untwinned, 0.5 = perfect twin (whose
// intensities are exactly invariant under the twin law, hence indistinguishable from a
// crystal that really has the supergroup symmetry).
double twin_fraction = 0.0;
// Number of observations merged into each reflection; at least 2, so both half-sets exist.
int multiplicity = 6;
// Error model. error_model_b is the b the merge FITTED and floors its merged sigmas with
// (ISa = 1/b); true_systematic_b is the intensity-proportional systematic scatter actually
// present in the data - unset means the two agree, i.e. a perfectly fitted error model.
// sigma_miscalibration is how many times too SMALL the merged sigmas come out overall
// (> 1 = under-estimated, the usual case; < 1 = over-estimated).
double error_model_b = 0.05;
std::optional<double> true_systematic_b;
double sigma_miscalibration = 1.7;
// Intensity distribution: mean intensity at infinite resolution, Wilson B fall-off, and the
// background variance that keeps sigma finite for near-zero (systematically absent)
// reflections.
double mean_intensity = 8000.0;
double wilson_b_A2 = 25.0;
double background_variance = 400.0;
double d_min_A = 2.5;
uint32_t seed = 20260727;
};
namespace detail {
inline uint64_t Mix64(uint64_t x) {
x ^= x >> 33; x *= 0xff51afd7ed558ccdULL;
x ^= x >> 33; x *= 0xc4ceb9fe1a85ec53ULL;
x ^= x >> 33; return x;
}
// Uniform in (0, 1), a pure function of the Miller index.
inline double UniformFromHkl(const gemmi::Op::Miller& hkl) {
const uint64_t x = Mix64(static_cast<uint64_t>(hkl[0] + 512) * 0x9e3779b97f4a7c15ULL ^
Mix64(static_cast<uint64_t>(hkl[1] + 512)) ^
(Mix64(static_cast<uint64_t>(hkl[2] + 512)) << 1));
return (static_cast<double>(x >> 11) + 0.5) * (1.0 / 9007199254740992.0);
}
inline std::vector<std::array<int, 9>> RotationSetOf(const gemmi::SpaceGroup& sg) {
std::vector<std::array<int, 9>> out;
for (const auto& op : sg.operations().derive_symmorphic().sym_ops) {
std::array<int, 9> rot{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
rot[i * 3 + j] = op.rot[i][j];
out.push_back(rot);
}
std::sort(out.begin(), out.end());
return out;
}
}
// The twin law: a rotation of the supergroup that is not in the subgroup. Any of them gives the
// same twinned intensities (an index-2 subgroup is normal, so the coset members differ by a
// subgroup operator, which leaves I_true unchanged), so the first one found is used.
inline gemmi::Op TwinLaw(const gemmi::SpaceGroup& sub, const gemmi::SpaceGroup& super) {
const auto sub_rots = detail::RotationSetOf(sub);
for (const auto& op : super.operations().derive_symmorphic().sym_ops) {
std::array<int, 9> rot{};
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
rot[i * 3 + j] = op.rot[i][j];
if (!std::binary_search(sub_rots.begin(), sub_rots.end(), rot))
return gemmi::Op{op.rot, {0, 0, 0}, op.notation};
}
return gemmi::Op::identity();
}
// A cell consistent with the space group's crystal system. Synthetic throughout - the tests must
// not carry the cell of any real sample.
inline gemmi::UnitCell SyntheticCellFor(const gemmi::SpaceGroup& sg) {
switch (sg.crystal_system()) {
case gemmi::CrystalSystem::Triclinic: return {33, 37, 41, 85, 95, 105};
case gemmi::CrystalSystem::Monoclinic: return {37, 43, 51, 90, 101, 90};
case gemmi::CrystalSystem::Orthorhombic: return {37, 43, 51, 90, 90, 90};
case gemmi::CrystalSystem::Tetragonal: return {47, 47, 63, 90, 90, 90};
case gemmi::CrystalSystem::Trigonal:
case gemmi::CrystalSystem::Hexagonal: return {51, 51, 71, 90, 90, 120};
case gemmi::CrystalSystem::Cubic: return {57, 57, 57, 90, 90, 90};
}
return {50, 50, 50, 90, 90, 90};
}
inline std::vector<MergedReflection> GenerateSyntheticMerged(const SyntheticMergeParams& p) {
const gemmi::SpaceGroup& sub = gemmi::get_spacegroup_by_name(p.true_space_group);
const gemmi::SpaceGroup& super = gemmi::get_spacegroup_by_name(p.twin_supergroup);
const gemmi::Op twin = TwinLaw(sub, super);
const gemmi::UnitCell cell = SyntheticCellFor(sub);
const gemmi::GroupOps gops = sub.operations();
const gemmi::ReciprocalAsu rasu(&sub);
// True (untwinned) intensity: Wilson-distributed |F|^2 of the subgroup asu, with a
// resolution fall-off. Systematically absent reflections (here: the lattice centering) carry
// no intensity - they are what Stage B confirms the centering from.
auto true_intensity = [&](const gemmi::Op::Miller& hkl) -> double {
if (gops.is_systematically_absent(hkl))
return 0.0;
const auto asu = rasu.to_asu_sign(hkl, gops).first;
const double e_squared = -std::log(detail::UniformFromHkl(asu)); // mean 1, exponential
const double d = cell.calculate_d(hkl);
return p.mean_intensity * e_squared * std::exp(-p.wilson_b_A2 / (2.0 * d * d));
};
// Half-set split of the multiplicity (n0 >= n1); both halves see the same systematic error.
const int n_obs = std::max(2, p.multiplicity);
const int n_half[2] = {(n_obs + 1) / 2, n_obs / 2};
std::mt19937 rng(p.seed);
std::normal_distribution<double> gauss(0.0, 1.0);
const int hmax = static_cast<int>(std::ceil(cell.a / p.d_min_A)) + 1;
const int kmax = static_cast<int>(std::ceil(cell.b / p.d_min_A)) + 1;
const int lmax = static_cast<int>(std::ceil(cell.c / p.d_min_A)) + 1;
std::vector<MergedReflection> merged;
for (int h = -hmax; h <= hmax; ++h)
for (int k = -kmax; k <= kmax; ++k)
for (int l = -lmax; l <= lmax; ++l) {
// One entry per Friedel pair, matching the Friedel-merged P1 set the search gets.
if (std::make_tuple(h, k, l) <= std::make_tuple(-h, -k, -l))
continue;
const gemmi::Op::Miller hkl{{h, k, l}};
const double d = cell.calculate_d(hkl);
if (!(d >= p.d_min_A))
continue;
const auto twinned = twin.apply_to_hkl(hkl);
const double i_obs = (1.0 - p.twin_fraction) * true_intensity(hkl) +
p.twin_fraction * true_intensity(twinned);
// Statistical error of one observation, and of the merge of n of them.
const double sigma_one = std::sqrt(i_obs + p.background_variance);
// Systematic error: a property of the reflection, identical in every observation
// of it, so it survives the merge - this is what the b*|I| sigma floor models.
const double systematic =
p.true_systematic_b.value_or(p.error_model_b) * i_obs * gauss(rng);
MergedReflection r;
r.h = h;
r.k = k;
r.l = l;
r.d = static_cast<float>(d);
double sum_n_i = 0.0;
for (int half = 0; half < 2; ++half) {
const double sigma_stat_half =
sigma_one / std::sqrt(static_cast<double>(n_half[half]));
const double i_half = i_obs + systematic + sigma_stat_half * gauss(rng);
r.I_half[half] = static_cast<float>(i_half);
r.sigma_half[half] = static_cast<float>(
std::max(sigma_stat_half, p.error_model_b * std::abs(i_half)) /
p.sigma_miscalibration);
sum_n_i += n_half[half] * i_half;
}
const double i_merged = sum_n_i / n_obs;
const double sigma_stat = sigma_one / std::sqrt(static_cast<double>(n_obs));
r.I = static_cast<float>(i_merged);
// Merge.h SigmaWithSystematicFloor, then thrown off by the error-model
// miscalibration.
r.sigma = static_cast<float>(
std::max(sigma_stat, p.error_model_b * std::abs(i_merged)) / p.sigma_miscalibration);
merged.push_back(r);
}
return merged;
}
}