From 7ceffde5a43b57db7f1bb9497132ddcd0d8e23d1 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Fri, 17 Jul 2026 22:35:12 +0200 Subject: [PATCH] rugnux: rank space groups by net absences, not gross (fix C222 over-centering) Stage B of SearchSpaceGroup chose the largest centering that explained the most absences and was self-consistent, ranking candidates by the gross observed-absent count. A false super-centering over-claims: on a C222 crystal, F222 predicts every C absence (all genuinely weak) PLUS a block of C-present reflections it wrongly calls absent. Its absent class is then bimodal - many true zeros diluting a strong block - so its mean abs (and the violation-rate arm) slip under the centering strength gate, which cannot see the over-claim in isolation. With the gross-count ranking F222 then beat the true C222, and the strength gate is scale-invariant here, so real C-centered data trips it identically (the rotation battery just has no C-centered crystal on this path; the synthetic unit test does). Rank instead by the absences GENUINELY explained, absent_observed - absent_violations, then by fewer violations, then lower space-group number. C222 and F222 net to the same count (F222's genuine absences ARE C222's), so the fewer-violations tie-break keeps the honest, less-centred C222. Symmetric: on a real F222 crystal F explains strictly more weak absences and still wins. Also tighten the "indistinguishable alternatives" grouping to require the same absent AND violation counts, so a super-centering is never reported as an equal alternative. No tuned threshold is changed; the ranking is a no-op for any 0-violation winner. Fixes the SearchSpaceGroup C222 synthetic-space-group test. Rotation battery (32 crystals, de-novo) is space-group-identical to before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scale_merge/SearchSpaceGroup.cpp | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/image_analysis/scale_merge/SearchSpaceGroup.cpp b/image_analysis/scale_merge/SearchSpaceGroup.cpp index f87b805e..4939e945 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.cpp +++ b/image_analysis/scale_merge/SearchSpaceGroup.cpp @@ -622,8 +622,17 @@ SearchSpaceGroupResult SearchSpaceGroup( // A candidate is eligible when its absences are confirmed and there are enough of them to // trust (the symmorphic group, with no absences, is always eligible as the fallback). Rank - // eligible candidates by how many absences they explain - the screw/centering content that is - // both real and maximal wins, instead of defaulting to the symmorphic group. + // eligible candidates by how many absences they GENUINELY explain - absent_observed minus the + // violations, not the gross count. A false super-centering over-claims: F222 on a C222 crystal + // predicts every C absence (all genuinely weak) PLUS a block of C-present reflections it wrongly + // calls absent, so its gross count is larger yet its net count only equals C222's. Its diluted + // absent class (many true zeros + a strong block) also slips under the strength/rate gate, so the + // gate cannot veto it alone; netting the violations puts the two level, and the fewer-violations + // and lower-number tie-breaks then keep the honest, less-centred C222. The ranking is symmetric: + // on a genuine F222 crystal F explains strictly more weak absences and still wins. + auto net_absent = [](const SpaceGroupCandidateScore& s) { + return s.absent_observed - s.absent_violations; + }; auto eligible = [&](const SpaceGroupCandidateScore& s) { return s.consistent && (s.absent_observed == 0 || s.absent_observed >= opt.min_absent_observed); }; @@ -631,16 +640,24 @@ SearchSpaceGroupResult SearchSpaceGroup( [&](const SpaceGroupCandidateScore& a, const SpaceGroupCandidateScore& b) { if (eligible(a) != eligible(b)) return eligible(a); - if (a.absent_observed != b.absent_observed) - return a.absent_observed > b.absent_observed; - // Tie (e.g. I23 vs I2_13, indistinguishable by absences): lower space-group number. + if (net_absent(a) != net_absent(b)) + return net_absent(a) > net_absent(b); + if (a.absent_violations != b.absent_violations) + return a.absent_violations < b.absent_violations; // prefer the honest, less over-claiming group + // Genuinely indistinguishable (e.g. I23 vs I2_13, or an enantiomorphic pair): lower + // space-group number is the representative. return a.space_group.number < b.space_group.number; }); if (!result.candidates.empty() && eligible(result.candidates.front())) { - const int best_absent = result.candidates.front().absent_observed; + // Alternatives are only the candidates with the SAME absence signature - identical absent AND + // violation counts - as the winner: the enantiomorphic / origin-ambiguous partners the data + // truly cannot separate. A super-centering that nets the same count but over-claims differs in + // its violation count and is therefore not reported as an equal alternative. + const int sel_absent = result.candidates.front().absent_observed; + const int sel_violations = result.candidates.front().absent_violations; for (auto& s : result.candidates) { - if (!eligible(s) || s.absent_observed != best_absent) + if (!eligible(s) || s.absent_observed != sel_absent || s.absent_violations != sel_violations) continue; s.selected = true; if (!result.best_space_group.has_value())