From a628f06868bb4485d83a29319acb64664cc55a36 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 5 Oct 2026 09:06:16 +0200 Subject: [PATCH] Space-group search: an ask that promotes over a twin-gate refusal is decided by the twin-immune zone Open set 5ebi (deposited P2_1, a = c and beta = 120: a monoclinic crystal on a hexagonal metric, pseudo-merohedrally twinned, <|L|> 0.433) regressed from P2_1 to C222_1 with dq-sm2-rest baf2017c9. Of that commit's two changes it is the sub-lattice ask's R-contrast operator test, not the hot-patch mask: 5ebi is a CCD (.img) sweep, so HotPixelFinder never runs on it. Without the R contrast every metric two-fold that passes min_operator_cc was kept, LePage closed them into the whole hexagonal metric and no sub-lattice ask was made. With it the false hexagonal two-folds drop out, the two in-plane two-folds of the C-centred orthohexagonal cell survive (CC 0.97, R 0.061 against 0.045 for the genuine screw: contrast 0.96), and the ask on that sub-lattice adopted C222_1. The main search had refused 622 on the added-operator R (contrast 0.70 against 0.72), but the ask's own search cannot see that: the gates there are relative and the twin law passes them on its own. Stage A's L-test-under-merge gate does not reach it either - C222_1 is not holohedral on a hexagonal metric - and read per operator it does not separate it: merging under the in-plane two-fold moves <|L|> only 0.425 -> 0.418 (14% of the gap to 0.375). Fix: where a twin gate of the search in hand refused a higher group, an ask that would promote re-opens what that gate answered, so it is arbitrated as every other promotion over a refusal is - by TwinZoneVerdict on the operators it adds over the group in hand (that group carried into the ask's setting). Acentric with margin and the group in hand stands. 5ebi: zone 222 over 2, n 1865, calibrated -23.9 nats -> twin law, P 1 21 1 kept. cuhf2 (the case baf2017c9 fixed) has no twin-gate refusal in hand, so its zone is not read and P422 stands. Targeted battery vs the rc174-all2 full run (20261004-2350_f64f76_all2-full), same base: reg4-v1 (32 sets: 5ebi, cuhf2, sub-lattice sets 6iu6 6iu8 6iu9 6z8o 8t7r 9ea5, twinned/pseudo- merohedral 9hnc 3meb 2wnn 2xfw 2wnz 4bwl 2wnq insu_H_x06da_twin, controls lyso_x06da_ref thau_x10sa_0p1deg 5reo, all 14 small-molecule sets): only 5ebi changes - C2221 -> P1211 (pass), ISa 10.9 -> 14.8, R_meas 12.3% -> 10.2%, R_free (deposited model) 0.571 -> 0.537, cell dev 40% -> 0.05%. Every other set identical in space group, ISa, R_meas, R_free, d_min and SHELXL R1. reg4-v1b (the 12 other open/in-house sets whose run adopts a metric or sub-lattice ask): identical. Private arm (the 7 sets whose mask changed with baf2017c9 and the 4 that take an ask): identical. Still failing as before: 4bwl, 2wnq (near-perfect twins, C222_1 via the metric ask with no refusal in hand), 6iu9, 9hnc, kdp. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01K5K8jvPPbmCrbqnWkddTuB --- docs/CHANGELOG.md | 1 + rugnux/Rugnux.cpp | 50 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 26eb487a0..d79003a13 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,7 @@ * Rugnux keeps the screw axes it found when a higher point group is adopted after the twin-law check, instead of writing the group without screws (e.g. P 4 2 2 for P4_2 2_1 2). * Rugnux drops a rotation reflection whose spot held a saturated (overloaded) pixel, as XDS does, and reports the count as `OBSERVATIONS_REJECTED_OVERLOAD=`. * Rugnux fits a rotation sweep's per-frame scale on the partial reflections, even with few reflections per frame, when scaling from the fulls alone leaves no measurable error model. +* Rugnux no longer adopts an intermediate space group (e.g. C222_1 for a twinned P2_1 on a hexagonal metric) when the higher group was refused and the twin-immune reflections show the added two-folds to be a twin law. * Rugnux names glide planes in groups without a centre of symmetry (e.g. I-42d, Fdd2), reports a group with the same absences (C2/c and Cc) as an alternative, and states how the centre was decided (`SPACE_GROUP_CENTRE`, `CENTRE_STATISTICS_*`). ### 1.0.0-rc.173 diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index c368b1c5c..567735e58 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -7824,8 +7824,53 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b if (alt.centring_type() == cand.centering) { chosen = alt; break; } const bool point_group_higher = s2.best_space_group.has_value() && s2.point_group_order > sg_search.point_group_order; - const bool higher = point_group_higher - && chosen->centring_type() == cand.centering; + bool higher = point_group_higher + && chosen->centring_type() == cand.centering; + // A promotion over a refusal is arbitrated by the twin-immune zone of the added + // operators (TwinZoneVerdict), here as where the two arms disagree: where a twin + // gate of the search in hand refused a higher group, this ask reopens the question + // that gate answered, for whichever of the refused group's operators the new + // lattice holds, and its own search cannot answer it - the gates there are relative + // too, and a twin law or a pseudo-symmetry the refused group's other operators + // diluted passes them alone. Measured on a monoclinic crystal on a hexagonal metric, + // a pseudo-merohedral twin: 622 refused on the added-operator R, the C-centred + // orthorhombic sub-lattice then asked, its two in-plane two-folds at R 0.061 against + // 0.045 for the genuine screw, and C 2 2 21 adopted with the zone of those two-folds + // reading acentric. The group in hand is carried into the candidate's setting for + // it: h -> Q h with Q the reindex from the basis it was found in (read where GEMMI's + // table names the result, as every setting the search enumerates is). + std::string zone_refusal; + if (higher && sg_search.refused_by_twin_gate && sg_search.point_group_representative) { + gemmi::Mat33 q = reindex; + if (commit_reindex) + q = reindex.multiply(commit_reindex->inverse()); + const gemmi::Mat33 q_inv = q.inverse(); + gemmi::GroupOps lower_ops; + lower_ops.cen_ops = gemmi::centring_vectors(cand.centering); + for (const auto &op : sg_search.point_group_representative->operations().sym_ops) { + // apply_to_hkl uses rot^T, so the Miller-index matrix of op is rot^T / DEN. + gemmi::Mat33 m; + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + m.a[i][j] = static_cast(op.rot[j][i]) / gemmi::Op::DEN; + const gemmi::Mat33 m_new = q.multiply(m).multiply(q_inv); + gemmi::Op op_new = gemmi::Op::identity(); + for (int i = 0; i < 3; ++i) + for (int j = 0; j < 3; ++j) + op_new.rot[i][j] = static_cast(std::lround(m_new[j][i])) * gemmi::Op::DEN; + lower_ops.sym_ops.push_back(op_new); + } + if (const gemmi::SpaceGroup *lower_sg = gemmi::find_spacegroup_by_ops(lower_ops)) { + SearchSpaceGroupResult lower = sg_search; + lower.point_group_representative = *lower_sg; + if (TwinZoneVerdict(merged_all, *o2.cell, s2, lower, result) < 0) { + zone_refusal = sg_search.refused_point_group_hm + " was refused (" + + sg_search.refused_reason + "), and the twin-immune zone of the " + "operators this group adds reads acentric"; + higher = false; + } + } + } const auto &uc = cand.conventional.GetUnitCell(); // The two asks are told apart by what they were asked against: the class a search // has already been given, or the rotations the intensities have actually confirmed. @@ -7849,6 +7894,7 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b s2.best_space_group.has_value() ? s2.best_space_group->xhm() : "nothing", s2.point_group_order, sg_search.point_group_order, higher ? " - adopted" + : !zone_refusal.empty() ? " - keeping the group already found; " + zone_refusal : (point_group_higher ? " - keeping the group already found; the point group IS " "higher, but no candidate of it carries the centring the "