Files
Jungfraujoch/tests/SearchSpaceGroupTwinTest.cpp
T
leonarski_fandClaude Opus 5.5 7c1befd484 SearchSpaceGroup: ask the twin-immune zone of a lattice-holohedral candidate too, and do not read centring absences as screws
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
2026-10-17 13:01:11 +02:00

386 lines
21 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include "../image_analysis/scale_merge/SearchSpaceGroup.h"
#include "SyntheticMergedReflections.h"
#include "gemmi/symmetry.hpp"
#include <cmath>
#include <iomanip>
#include <iostream>
#include <limits>
#include <optional>
#include <set>
#include <sstream>
#include <string>
#include <vector>
// Point-group decision on MEROHEDRALLY TWINNED data.
//
// SearchSpaceGroupTest.cpp exercises Stage B (systematic absences) on noise-free, exactly-symmetric
// intensities. The decision that actually goes wrong on real crystals is Stage A: whether the extra
// operator of the metric holohedry is a real symmetry or a twin law. That decision runs through the
// merge chi^2 gate, its systematic-b rescue and the operator gates - none of which the noise-free
// set can reach, because it has no errors for a chi^2 to be reduced by.
//
// Three crystals are modelled per lattice, all with the SAME metric symmetry (a merohedral twin has
// the supergroup's metric, so the lattice cannot arbitrate - only the intensities can):
// * genuine supergroup - structure factors invariant under the supergroup; must be promoted;
// * untwinned subgroup - structure factors invariant under the subgroup only; must NOT be;
// * twinned subgroup - the same crystal at twin fraction alpha; must NOT be promoted for any
// 0 <= alpha < 0.5, because promoting averages the two twin domains into
// one intensity and the twin is then unrecoverable downstream.
// At alpha = 0.5 the twin is physically indistinguishable from real symmetry, so only "terminates
// and returns one of the two" is asserted.
//
// Each is measured through two merge-quality regimes and five merge multiplicities. Neither changes
// any physics - they change only how well the same crystal was measured and how honest its sigmas
// are - so no decision above may move with them. That is the property the harness exists to pin
// down; every case below asserts it outright.
namespace {
using jfjoch_test::SyntheticMergeParams;
struct TwinCrystal {
std::string name;
std::string sub; // the crystal's true space group when twinned
std::string super; // supergroup of index 2; its extra operator is the twin law
gemmi::CrystalSystem system; // metric (lattice) symmetry, as rugnux passes it from indexing
// The OTHER maximal subgroup of the supergroup of the same order as `sub`, when one exists.
// 422 has two - 4 and 222 - and only one of them is the crystal. Which one a parent-normalised
// statistic divides by decides the promotion, so the harness reports both; empty when the
// supergroup has only one maximal subgroup of that order (32 over 3) and the choice cannot arise.
std::string rival_parent;
};
const std::vector<TwinCrystal> crystals = {
{"trigonal 3 -> 32 (R3 / R32, twin law k,h,-l)", "R 3 :H", "R 32 :H",
gemmi::CrystalSystem::Trigonal, ""},
{"tetragonal 4 -> 422 (P4 / P422, twin law h,-k,-l)", "P 4", "P 4 2 2",
gemmi::CrystalSystem::Tetragonal, "P 2 2 2"},
};
// How honest the merged sigmas are. Both regimes are ways a fitted error model misses in
// practice, and neither is a property of the crystal's symmetry.
struct MergeQuality {
std::string name;
double sigma_miscalibration;
double error_model_b;
std::optional<double> true_systematic_b;
};
const std::vector<MergeQuality> merge_quality = {
// The usual case: merged sigmas come out ~1.7x too small across the board, with the error
// model's b matching the systematic scatter that is actually there.
{"sigmas 1.7x too small", 1.7, 0.05, std::nullopt},
// The other way a fitted error model misses: the statistical sigmas come out somewhat too
// LARGE while b - the asymptotic per-observation I/sigma, ISa = 1/b - is fitted 3x too
// optimistic, so the systematic scatter present is 3x what the merged sigmas admit.
{"ISa 3x too optimistic", 0.6, 0.02, 0.06},
};
const std::vector<int> multiplicities = {2, 3, 6, 9, 18};
const std::vector<double> twin_fractions = {0.0, 0.05, 0.10, 0.20, 0.35, 0.50};
struct Decision {
std::string point_group;
std::string space_group;
size_t n_merged = 0;
std::vector<SpaceGroupOperatorScore> operators;
std::string report;
};
// true_group is the symmetry the structure factors have: the subgroup for the twin series, the
// supergroup for the genuine-high-symmetry control (where the twin law is a real symmetry
// operator, so the twin fraction has no effect).
Decision Decide(const TwinCrystal& c, const MergeQuality& q,
const std::string& true_group, double alpha, int multiplicity) {
SyntheticMergeParams p;
p.true_space_group = true_group;
p.twin_supergroup = c.super;
p.twin_fraction = alpha;
p.multiplicity = multiplicity;
p.sigma_miscalibration = q.sigma_miscalibration;
p.error_model_b = q.error_model_b;
p.true_systematic_b = q.true_systematic_b;
// One fixed seed for the whole harness: every case draws the same unit-normal stream, so two
// cases differ only in the knob under test and every test is reproducible.
p.seed = 20260727;
const auto merged = jfjoch_test::GenerateSyntheticMerged(p);
REQUIRE(merged.size() > 5000); // a realistic dataset, not a handful of reflections
SearchSpaceGroupOptions opt; // as rugnux/Rugnux.cpp sets it
opt.merge_friedel = true;
opt.lattice_system = c.system;
const auto result = SearchSpaceGroup(merged, opt);
Decision d;
d.point_group = result.point_group_hm;
d.space_group = result.best_space_group.has_value()
? result.best_space_group->short_name() : "none";
d.n_merged = merged.size();
d.operators = result.operator_scores;
d.report = SearchSpaceGroupResultToText(result);
return d;
}
std::string PointGroupOf(const std::string& space_group_name) {
return gemmi::get_spacegroup_by_name(space_group_name).point_group_hm();
}
std::string ShortNameOf(const std::string& space_group_name) {
return gemmi::get_spacegroup_by_name(space_group_name).short_name();
}
// The hkl triplets SearchSpaceGroup labels a space group's own rotations with. Lets a test tell the
// crystal's real symmetry operators from the twin law among result.operator_scores.
std::set<std::string> OperatorTripletsOf(const std::string& space_group_name) {
std::set<std::string> out;
const auto& sg = gemmi::get_spacegroup_by_name(space_group_name);
for (const auto& op : sg.operations().derive_symmorphic().sym_ops) {
if (op.rot == gemmi::Op::identity().rot)
continue;
out.insert(gemmi::Op{op.rot, {0, 0, 0}, op.notation}.as_hkl().triplet('h'));
}
return out;
}
// The statistic the promotion is actually decided on: the operator disagreement
// H = median|I1-I2|/(I1+I2) over the operators the promotion ADDS, divided by the same over the
// parent group's own operators, measured on the same reflections. Mirrors what SearchSpaceGroup computes
// for the sub -> super step, so a test can report the margin the max_operator_h_ratio bound has.
//
// The parent normalisation is the whole design, not a detail. An ABSOLUTE per-operator bound cannot
// work: two reflections related by a real symmetry operator still disagree, because they carry
// DIFFERENT systematic error - absorption, illumination, partiality - and how much of that a
// crystal has is a property of the measurement, not of its symmetry. So a genuine operator's own
// disagreement ranges over whatever the data quality happens to be, and any fixed bound placed on
// it rejects good crystals at one end or waves twins through at the other. Dividing by the parent
// operators - already confirmed, measured on the same reflections, carrying the same systematic
// floor - cancels the data quality and leaves only the question being asked: does the ADDED
// operator relate intensities as equal as the parent's do (real symmetry), or systematically less
// equal (a twin law mixing non-equivalent reflections)?
double HRatioOfPromotion(const std::vector<SpaceGroupOperatorScore>& operators,
const std::string& parent_group) {
const auto parent_ops = OperatorTripletsOf(parent_group);
double h_added = 0.0, h_parent = 0.0;
int n_added = 0, n_parent = 0;
for (const auto& s : operators) {
if (s.n_pairs < 200) // SearchSpaceGroupOptions::min_pairs_for_h
continue;
// The median, which is what the promotion is gated on (SearchSpaceGroup).
if (parent_ops.count(s.op_triplet_hkl) > 0) { h_parent += s.h_stat; ++n_parent; }
else { h_added += s.h_stat; ++n_added; }
}
if (n_added == 0 || n_parent == 0 || h_parent <= 0.0)
return std::numeric_limits<double>::quiet_NaN();
return (h_added / n_added) / (h_parent / n_parent);
}
std::string Describe(const TwinCrystal& c, const MergeQuality& q, double alpha, int multiplicity) {
std::ostringstream os;
os << c.name << ", " << q.name << ", twin fraction " << std::fixed << std::setprecision(2)
<< alpha << ", multiplicity " << multiplicity;
return os.str();
}
}
// Positive control: a crystal whose structure factors really do have the higher symmetry must be
// promoted to it. Guards the twin tests below against a criterion that simply never promotes.
TEST_CASE("SearchSpaceGroup promotes a genuinely high-symmetry crystal",
"[SearchSpaceGroup][twin]") {
for (const auto& c : crystals)
for (const auto& q : merge_quality)
for (int mult : multiplicities) {
DYNAMIC_SECTION(c.name << ", " << q.name << ", genuine supergroup, multiplicity " << mult) {
const auto d = Decide(c, q, c.super, 0.0, mult);
INFO(d.report);
CHECK(d.point_group == PointGroupOf(c.super));
CHECK(d.space_group == ShortNameOf(c.super));
}
}
}
// Negative control: an UNTWINNED crystal of the true subgroup (alpha = 0) must not be promoted - its
// extra metric operator relates reflections that are simply not equivalent.
TEST_CASE("SearchSpaceGroup keeps an untwinned low-symmetry crystal in its subgroup",
"[SearchSpaceGroup][twin]") {
for (const auto& c : crystals)
for (const auto& q : merge_quality)
for (int mult : multiplicities) {
DYNAMIC_SECTION(Describe(c, q, 0.0, mult)) {
const auto d = Decide(c, q, c.sub, 0.0, mult);
INFO(d.report);
CHECK(d.point_group == PointGroupOf(c.sub));
CHECK(d.space_group == ShortNameOf(c.sub));
}
}
}
// A partial merohedral twin must stay in its true subgroup. Promoting it averages the two twin
// domains into one intensity, which no later step can undo: the twin fraction is not recoverable and
// the merged data are simply wrong.
TEST_CASE("SearchSpaceGroup keeps a partially twinned crystal in its true subgroup",
"[SearchSpaceGroup][twin]") {
for (const auto& c : crystals)
for (const auto& q : merge_quality)
for (double alpha : {0.05, 0.10, 0.20, 0.35})
for (int mult : multiplicities) {
DYNAMIC_SECTION(Describe(c, q, alpha, mult)) {
const auto d = Decide(c, q, c.sub, alpha, mult);
INFO(d.report);
CHECK(d.point_group == PointGroupOf(c.sub));
CHECK(d.space_group == ShortNameOf(c.sub));
}
}
}
// A body-centred crystal in 4, twinned near-perfectly by a 2-fold of 422, given its cell as rugnux
// gives it. The agreement gates pass 422 at this fraction and only the twin-immune zone refuses it;
// what remains is a tie of order 4 between 4 and a 222 made of the crystal's 2-fold and two twin laws.
// The I centring extinguishes the odd class of every principal row, and that must not be read as
// three screw axes for the 222.
TEST_CASE("SearchSpaceGroup keeps a near-perfectly twinned body-centred 4 in 4", "[SearchSpaceGroup][twin]") {
SyntheticMergeParams p;
p.true_space_group = "I 4";
p.twin_supergroup = "I 4 2 2";
p.twin_fraction = 0.45;
p.multiplicity = 6;
p.seed = 20260727;
const auto merged = jfjoch_test::GenerateSyntheticMerged(p);
SearchSpaceGroupOptions opt;
opt.merge_friedel = true;
opt.lattice_system = gemmi::CrystalSystem::Tetragonal;
opt.cell = jfjoch_test::SyntheticCellFor(gemmi::get_spacegroup_by_name("I 4"));
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
CHECK(result.point_group_hm == "4");
}
// A PERFECT (alpha = 0.5) merohedral twin produces intensities that are exactly invariant under the
// twin law: I_obs(h) = I_obs(twin h) for every reflection. No intensity statistic can tell it from a
// crystal that genuinely has the higher symmetry - the information is not in the data (it takes a
// different measurement, e.g. the |E| distribution's second moment, to even suspect it). So the only
// thing asserted here is that the search terminates and returns one of the two.
TEST_CASE("SearchSpaceGroup on a perfect merohedral twin returns one of the two symmetries",
"[SearchSpaceGroup][twin]") {
for (const auto& c : crystals)
for (const auto& q : merge_quality)
for (int mult : multiplicities) {
DYNAMIC_SECTION(Describe(c, q, 0.5, mult)) {
const auto d = Decide(c, q, c.sub, 0.5, mult);
INFO(d.report);
REQUIRE(d.space_group != "none");
CHECK((d.point_group == PointGroupOf(c.sub) ||
d.point_group == PointGroupOf(c.super)));
}
}
}
// THE property this harness exists for. Multiplicity changes only the sigmas - a merged sigma averages
// down as 1/sqrt(n) while the systematic error the crystal carries does not - so it changes
// how well the SAME crystal is measured, never what its symmetry is. A symmetry decision that moves
// when the same crystal is merged 2x instead of 18x is a defect of the criterion, not a property of
// the data.
//
// This is what a criterion thresholded on merge chi^2 or on a systematic-b RATIO cannot deliver: both
// are ratios to an error model that multiplicity and the sigma calibration move, so the tetragonal
// 4 -> 422 twin at alpha 0.20 / 0.35 used to flip - promoted at multiplicity 2 and kept at 18 with
// under-calibrated sigmas, and the other way round with an over-optimistic ISa. The operator
// disagreement ratio H_added/H_parent holds instead because there is no sigma in it at all: it
// compares intensities with intensities, and normalising against the parent group's own operators on
// the same reflections divides out both the data quality and the systematic floor that multiplicity
// and the error model move. An absolute bound on a single operator's H would not survive this - see
// HRatioOfPromotion.
TEST_CASE("SearchSpaceGroup point-group decision does not depend on merge multiplicity",
"[SearchSpaceGroup][twin]") {
for (const auto& c : crystals)
for (const auto& q : merge_quality)
for (double alpha : twin_fractions) {
DYNAMIC_SECTION(Describe(c, q, alpha, 2) + " vs multiplicity 18") {
const auto low = Decide(c, q, c.sub, alpha, 2);
const auto high = Decide(c, q, c.sub, alpha, 18);
INFO("multiplicity 2:\n" << low.report << "\nmultiplicity 18:\n" << high.report);
CHECK(low.point_group == high.point_group);
CHECK(low.space_group == high.space_group);
}
}
}
// Diagnostic, not run by default: ./jfjoch_test "[twin-h]"
// Prints the operator-disagreement ratio H_added/H_parent that the promotion is decided on, for the
// genuine high-symmetry crystal and for each twin fraction, across both merge-quality regimes and
// every multiplicity - i.e. how much margin the max_operator_h_ratio bound actually has, and whether
// either side of it drifts with data quality or data amount.
TEST_CASE("SearchSpaceGroup operator H ratio margins", "[.][twin-h]") {
SearchSpaceGroupOptions defaults;
std::cout << "H_added / H_parent for the sub -> super promotion; bound "
<< defaults.max_operator_h_ratio << " (above = refused as a twin)\n";
for (const auto& c : crystals)
for (const auto& q : merge_quality)
// Both normalisations where the supergroup has two maximal subgroups of the same order:
// against the crystal's true parent, and against its rival.
for (const auto& parent : c.rival_parent.empty()
? std::vector<std::string>{c.sub}
: std::vector<std::string>{c.sub, c.rival_parent}) {
std::cout << "\n" << c.name << "\n merge quality: " << q.name
<< " normalised against " << ShortNameOf(parent)
<< (parent == c.sub ? " (the crystal's own parent)" : " (the RIVAL parent)")
<< "\n";
std::cout << " " << std::setw(22) << std::left << "true symmetry / alpha" << std::right;
for (int mult : multiplicities)
std::cout << std::setw(12) << ("mult " + std::to_string(mult));
std::cout << "\n " << std::setw(22) << std::left << "genuine supergroup" << std::right;
for (int mult : multiplicities)
std::cout << std::setw(12) << std::fixed << std::setprecision(3)
<< HRatioOfPromotion(Decide(c, q, c.super, 0.0, mult).operators, parent);
std::cout << "\n";
for (double alpha : twin_fractions) {
std::ostringstream label;
label << "subgroup, alpha " << std::fixed << std::setprecision(2) << alpha;
std::cout << " " << std::setw(22) << std::left << label.str() << std::right;
for (int mult : multiplicities)
std::cout << std::setw(12) << std::fixed << std::setprecision(3)
<< HRatioOfPromotion(Decide(c, q, c.sub, alpha, mult).operators, parent);
std::cout << "\n";
}
}
SUCCEED();
}
// Diagnostic, not run by default (hidden by the [.] tag):
// ./jfjoch_test "[twin-table]"
// prints the decision for every (true symmetry, twin fraction, multiplicity) combination in both
// merge-quality regimes - the table a redesign of the point-group criterion should be judged against.
TEST_CASE("SearchSpaceGroup twin decision table", "[.][twin-table]") {
for (const auto& c : crystals)
for (const auto& q : merge_quality) {
const auto reference = Decide(c, q, c.super, 0.0, 6);
std::cout << "\n" << c.name << "\n merge quality: " << q.name
<< " (true subgroup " << ShortNameOf(c.sub)
<< ", supergroup " << ShortNameOf(c.super) << ", "
<< reference.n_merged << " merged reflections)\n";
std::cout << " " << std::setw(22) << std::left << "true symmetry / alpha" << std::right;
for (int mult : multiplicities)
std::cout << std::setw(12) << ("mult " + std::to_string(mult));
std::cout << "\n " << std::setw(22) << std::left << "genuine supergroup" << std::right;
for (int mult : multiplicities)
std::cout << std::setw(12) << Decide(c, q, c.super, 0.0, mult).space_group;
std::cout << "\n";
for (double alpha : twin_fractions) {
std::ostringstream label;
label << "subgroup, alpha " << std::fixed << std::setprecision(2) << alpha;
std::cout << " " << std::setw(22) << std::left << label.str() << std::right;
for (int mult : multiplicities)
std::cout << std::setw(12) << Decide(c, q, c.sub, alpha, mult).space_group;
std::cout << "\n";
}
}
SUCCEED();
}