From 785a087b9e62df66d64e16daf75467b16c984259 Mon Sep 17 00:00:00 2001 From: Tyler Sax Date: Tue, 21 Jul 2026 10:14:52 -0500 Subject: [PATCH] recover symmetries for same-site orbital swap case Background: I was running FeAs against the derived-symmetry report we just landed in #369, and it came back with 4 ops vs the expected 8. The solver was rejecting C4, C4^3 and the two diagonal mirrors. Problem: FeAs's two orbitals (d_xz, d_yz) both sit at a_vec = 0. set_symmetry_matrices builds the band image by transforming r + a_b and looking for an orbital at the image position carrying the same flavor. Two orbitals on one site make the position test uninformative -- it matches every band equally. The "flavor" breaks the tie, and that pins each band to itself. The band image comes out the identity for every op, unconditionally. solveSignsForOp took the image on faith and solved only the signs. Signs can flip an entry, but they cannot move it to another band. So the solver falsely rejected real symmetries. The (small) fix: treat the geometric image as a candidate rather than an oracle. solveSignsForOp splits into tryPermutation (same function we had before, returning false where it used to throw) and deriveOrbitalOpForOp, which checks the geometric candidate first and otherwise walks the remaining permutations. Throw only when none of them works. Result 1: FeAs goes from 4 derived ops to 8, and is added as a characterization fixture to pin the number. Result 2: A Kagome win falls out of the same change. Kagome labels its three symmetry-equivalent sublattices with distinct flavors, so its matching finds no admissible image at all and records the -1 that c401448b added a guard for. Deriving P from H0 does more than dodge the out-of-bounds read -- it recovers the ops geometry could not place, and Kagome goes from 2 derived ops to 12. --- .../symmetrization/solve_orbital_op_signs.hpp | 125 ++++++++++-------- .../phys/symmetrization/CMakeLists.txt | 15 ++- .../phys/symmetrization/fe_as_input.json | 37 ++++++ .../symmetrize_characterization_test.cpp | 86 +++++++----- 4 files changed, 171 insertions(+), 92 deletions(-) create mode 100644 test/integration/phys/symmetrization/fe_as_input.json diff --git a/include/dca/phys/dca_step/symmetrization/solve_orbital_op_signs.hpp b/include/dca/phys/dca_step/symmetrization/solve_orbital_op_signs.hpp index 80e602c55..f40ffb39f 100644 --- a/include/dca/phys/dca_step/symmetrization/solve_orbital_op_signs.hpp +++ b/include/dca/phys/dca_step/symmetrization/solve_orbital_op_signs.hpp @@ -9,9 +9,12 @@ // Derives the orbital-operation matrix U_S of each point-group operation from H0(k) and populates // cluster_symmetry::get_orbital_op(), without reading hand-coded Lattice::transformationSignOf*. // -// Scope: for every analytic model, U_S is a real signed permutation U_S = D P, where the -// permutation P is already encoded in the symmetry table (the band image .second) and the only -// unknown is the diagonal of +/-1 signs D = diag(sigma). +// Scope: for every analytic model, U_S is a real signed permutation U_S = D P. Both halves are +// derived from H0: the permutation P (which orbital maps to which) and the diagonal of +/-1 signs +// D = diag(sigma). The symmetry table's band image (.second) is only the first candidate for P; +// when H0 rejects it -- geometry cannot see an on-site orbital rotation, such as the dxz/dyz swap +// under C4 of two orbitals sharing a_vec = 0 -- the remaining permutations are searched. n_b <= 3 +// for every shipped model, so that is at most 3! = 6 candidates. // // The symmetry table stores not the exact momentum image S k but its folded representative // k_new = S k - G, and with intra-cell orbital offsets a_b the fold is not free: H0 picks up the @@ -27,22 +30,25 @@ // the odd parity of p_x under a mirror at a zone-boundary momentum) instead of the intrinsic // orbital transform. // -// The analytic models shipped with DCA++ have at most n_b = 3 orbitals, so once the gauge -// sigma(0) = +1 is fixed there are at most 2^(n_b-1) <= 4 candidate sign vectors. We simply -// enumerate them and keep the one that satisfies every coupling constraint. Four conditions make -// an operation fail: +// For a candidate permutation, the signs are found by enumerating the at most 2^(n_b-1) <= 4 +// vectors with the gauge sigma(0) = +1 and keeping the one that satisfies every constraint. +// A malformed candidate (an out-of-range or repeated band image -- only the geometric one can be) +// is rejected up front; an admissible one is rejected, and the next tried, when: // -// * a missing band image in the symmetry table (the -1 value recorded by set_symmetry_matrices -// when it finds no admissible image) means the op has no candidate permutation at all; -// * a magnitude mismatch (an entry vanishing on only one side) means the permutation is not a -// symmetry of H0; -// * a non-real or non-unit ratio means the op needs a non-signed-permutation U_S (genuine orbital -// mixing, not yet supported) or is not a symmetry; -// * no candidate sign vector satisfying all couplings means no signed-permutation U_S exists. +// * a coupling vanishes on only one side (a magnitude mismatch -- the permutation is not a +// symmetry of H0) +// * a fold-corrected ratio is not a real +/-1 (this permutation would need a +// non-signed-permutation U_S) +// * no sign vector satisfies all couplings +// +// Only when every band permutation is rejected does the operation fail outright: it then needs a +// genuinely non-signed-permutation U_S (orbital mixing or a complex phase out of scope) or is not +// a symmetry of H0. #ifndef DCA_PHYS_DCA_STEP_SYMMETRIZATION_SOLVE_ORBITAL_OP_SIGNS_HPP #define DCA_PHYS_DCA_STEP_SYMMETRIZATION_SOLVE_ORBITAL_OP_SIGNS_HPP +#include #include #include #include @@ -67,37 +73,25 @@ constexpr double kCouplingZeroTol = 1e-10; // magnitude from 1 must both be below this. constexpr double kSignRealTol = 1e-9; -// Solves the +/-1 signs of one operation s and writes that op's U_S block into u_s. fold_phase is -// the diagonal folding phase phi (callable as fold_phase(k, band, s)) that undresses the folded -// H0 samples, so the solved signs are the intrinsic orbital transform. Throws if op s is not a -// signed-permutation symmetry of H0. Shared by the whole-group populator -- which lets the throw -// propagate, since a model must be a genuine symmetry group -- and by the group verification, -// which catches it to classify the op. +// Tests one candidate band permutation `image` against H0. Returns true, and writes that op into +// u_s, iff `image` is a signed-permutation symmetry of H0: every nonzero coupling gives a real +// +/-1 (fold-corrected) ratio, and some sign vector satisfies the resulting products. template -void solveSignsForOp(int s, int nb, int nk, const SymFunc& sym, const FoldFunc& fold_phase, - const H0Function& H0, UFunc& u_s) { - // Band permutation P: row b's single entry lands in column image[b]. This is the geometry-derived - // half of U_S (which orbital maps to which); it is k-independent for a point-group op, so read it - // at k = 0. Only the signs that decorate it are unknown. - std::vector image(nb); +bool tryPermutation(const std::vector& image, int s, int nb, int nk, const SymFunc& sym, + const FoldFunc& fold_phase, const H0Function& H0, UFunc& u_s) { + // Reject a malformed candidate before it indexes H0: an out-of-range entry (the -1 recorded when + // set_symmetry_matrices finds no admissible image) or a repeated one (two orbitals sharing a site + // and a flavor collide). + std::vector is_hit(nb, false); for (int b = 0; b < nb; ++b) { - image[b] = sym(0, b, s).second; - // set_symmetry_matrices records a silent -1 sentinel when its position/flavor matching finds - // no admissible image (its own throw is commented out), so the table cannot be assumed - // complete here. Reject the op before the sentinel is used to index H0. - if (image[b] < 0 || image[b] >= nb) - throw std::out_of_range( - "solveOrbitalOpSignsFromH0: no band image in the symmetry table for band " + - std::to_string(b) + " under op " + std::to_string(s) + - " -- the geometric position/flavor matching found no admissible image, so the op has no " - "candidate permutation."); + if (image[b] < 0 || image[b] >= nb || is_hit[image[b]]) + return false; + is_hit[image[b]] = true; } // Gather one sign-product constraint per nonzero coupling, over all cluster momenta. Each nonzero - // coupling forces sigma(b0) sigma(b1) to the +/-1 fold-corrected ratio of the two H0 entries. The - // ratio must be a real +/-1 for a signed permutation to exist at all; the two checks below - // (magnitude match, real +/-1) are independent of the signs themselves, so they are tested here - // and reported as the first two rejection branches. + // coupling forces sigma(b0) sigma(b1) to the +/-1 fold-corrected ratio of the two H0 entries; the + // ratio must be a real +/-1 for a signed permutation to exist under this permutation at all. struct Constraint { int b0, b1, product; }; @@ -114,10 +108,7 @@ void solveSignsForOp(int s, int nb, int nk, const SymFunc& sym, const FoldFunc& if (zero_lhs && zero_rhs) continue; // coupling absent on both sides: consistent, but no constraint. if (zero_lhs != zero_rhs) - throw std::logic_error( - "solveOrbitalOpSignsFromH0: H0 magnitude mismatch for op " + std::to_string(s) + - " -- the band permutation is not a symmetry of H0 (a coupling vanishes on only one " - "side)."); + return false; // magnitude mismatch: this permutation is not a symmetry of H0. // Undress the fold before taking the ratio. phi lives at the image bands -- the rhs entry // sits at (image(b0), image(b1)) -- with G determined by (k, s); the stored table carries // the band as a free index, so the image-band lookup is direct. @@ -125,10 +116,7 @@ void solveSignsForOp(int s, int nb, int nk, const SymFunc& sym, const FoldFunc& const std::complex ratio = lhs / (fold * rhs); if (std::abs(std::imag(ratio)) > kSignRealTol || std::abs(std::abs(ratio) - 1.) > kSignRealTol) - throw std::domain_error( - "solveOrbitalOpSignsFromH0: H0 ratio is not a real +/-1 for op " + std::to_string(s) + - " -- the operation requires a non-signed-permutation U_S (genuine orbital mixing) or " - "is not a symmetry of H0."); + return false; // non-signed-permutation ratio: this permutation would need a dense U_S. constraints.push_back({b0, b1, std::real(ratio) > 0. ? 1 : -1}); } @@ -152,15 +140,44 @@ void solveSignsForOp(int s, int nb, int nk, const SymFunc& sym, const FoldFunc& break; } } - // No sign vector satisfied every constraint, so no signed permutation reproduces H0 under this op if (!found) - throw std::logic_error( - "solveOrbitalOpSignsFromH0: no consistent +/-1 signs for op " + std::to_string(s) + - " -- the H0 couplings admit no signed-permutation U_S."); + return false; // no sign vector reproduces H0 under this permutation. // Materialize U_S = D P one entry at a time: sign sigma(b) at (row b, column image[b]). for (int b = 0; b < nb; ++b) u_s(b, image[b], s) = static_cast(sigma[b]); + return true; +} + +// Derives op s's orbital operation U_S = D P from H0 -- both the permutation P and the signs D -- +// and writes it into u_s. H0 is the authority: U_S is whatever signed permutation makes +// H0(S k) = U_S H0(k) U_S^dagger hold. +template +void deriveOrbitalOpForOp(int s, int nb, int nk, const SymFunc& sym, const FoldFunc& fold_phase, + const H0Function& H0, UFunc& u_s) { + // The geometric image is the canonical candidate: verify it against H0 first. + std::vector geometric(nb); + for (int b = 0; b < nb; ++b) + geometric[b] = sym(0, b, s).second; + + if (tryPermutation(geometric, s, nb, nk, sym, fold_phase, H0, u_s)) + return; + + // H0 did not admit it; check the remaining permutations, in std::next_permutation order so the + // search is deterministic, skipping the geometric candidate already tried. + std::vector image(nb); + for (int b = 0; b < nb; ++b) + image[b] = b; + do { + if (image != geometric && tryPermutation(image, s, nb, nk, sym, fold_phase, H0, u_s)) + return; + } while (std::next_permutation(image.begin(), image.end())); + + throw std::logic_error( + "solveOrbitalOpSignsFromH0: no signed-permutation U_S reproduces H0 under op " + + std::to_string(s) + + " for any band permutation -- the operation needs a non-signed-permutation U_S (genuine " + "orbital mixing or a complex phase) or is not a symmetry of H0."); } } // namespace detail @@ -185,7 +202,7 @@ void solveOrbitalOpSignsFromH0(const H0Function& H0) { const int n_ops = SymDmn::dmn_size(); for (int s = 0; s < n_ops; ++s) - detail::solveSignsForOp(s, nb, nk, sym, fold_phase, H0, u_s); + detail::deriveOrbitalOpForOp(s, nb, nk, sym, fold_phase, H0, u_s); } // Returns the sorted op indices that pass the sign-consistency (H0-invariance) check -- the @@ -210,7 +227,7 @@ std::vector verifiedSymmetryOps(const H0Function& H0) { std::vector verified; for (int s = 0; s < n_ops; ++s) { try { - detail::solveSignsForOp(s, nb, nk, sym, fold_phase, H0, u_s); + detail::deriveOrbitalOpForOp(s, nb, nk, sym, fold_phase, H0, u_s); verified.push_back(s); } catch (const std::exception&) { diff --git a/test/integration/phys/symmetrization/CMakeLists.txt b/test/integration/phys/symmetrization/CMakeLists.txt index 7653c0dea..07d66c8a6 100644 --- a/test/integration/phys/symmetrization/CMakeLists.txt +++ b/test/integration/phys/symmetrization/CMakeLists.txt @@ -22,8 +22,8 @@ dca_add_gtest(symmetrize_characterization_threeband_D4_test TEST_DEFINES ThreebandD4 ) -# Regression case for solveSignsForOp's -1 guard: Kagome produces the -1 which the solver -# must reject instead of indexing H0 out of bounds. +# Regression case for deriveOrbitalOpForOp's -1 guard: Kagome produces the -1 which the solver +# must skip instead of indexing H0 out of bounds. dca_add_gtest(symmetrize_characterization_kagome_test FAST GTEST_MPI_MAIN @@ -33,6 +33,17 @@ dca_add_gtest(symmetrize_characterization_kagome_test TEST_DEFINES KagomeNoSym ) +# The motivating case for deriving P from H0: FeAs's two orbitals share a site, so the geometric +# band image is always the identity and half of D4 is recoverable only by searching for the swap. +dca_add_gtest(symmetrize_characterization_fe_as_test + FAST + GTEST_MPI_MAIN + INCLUDE_DIRS ${DCA_INCLUDES};${PROJECT_SOURCE_DIR};${FFTW_INCLUDE_DIR} + LIBS ${DCA_LIBS} + CUSTOM_SOURCE symmetrize_characterization_test.cpp + TEST_DEFINES FeAs + ) + dca_add_gtest(symmetrize_characterization_singleband_chain_test FAST GTEST_MPI_MAIN diff --git a/test/integration/phys/symmetrization/fe_as_input.json b/test/integration/phys/symmetrization/fe_as_input.json new file mode 100644 index 000000000..2be024ef7 --- /dev/null +++ b/test/integration/phys/symmetrization/fe_as_input.json @@ -0,0 +1,37 @@ +{ + "physics": { + "beta" : 1., + "chemical-potential" : 0. + }, + + "FeAs-model": + { + "t1" : -1., + "t2" : 1.3, + "t3" : -0.85, + "t4" : -0.85, + "U" : 4., + "V" : 2., + "J" : 0.5, + "Jp" : 0.5 + }, + + "domains": { + "real-space-grids": { + "cluster": [[4, 0], [0, 4]], + "sp-host": [[8, 0], [0, 8]] + }, + + "imaginary-time": { + "sp-time-intervals": 128 + }, + + "imaginary-frequency": { + "sp-fermionic-frequencies": 64 + } + }, + + "DCA": { + "interacting-orbitals": [0, 1] + } +} diff --git a/test/integration/phys/symmetrization/symmetrize_characterization_test.cpp b/test/integration/phys/symmetrization/symmetrize_characterization_test.cpp index 495ab4729..80d7f75b3 100644 --- a/test/integration/phys/symmetrization/symmetrize_characterization_test.cpp +++ b/test/integration/phys/symmetrization/symmetrize_characterization_test.cpp @@ -64,6 +64,7 @@ #include "dca/phys/parameters/parameters.hpp" #include "dca/profiling/null_profiler.hpp" #include "dca/phys/models/analytic_hamiltonians/Kagome_hubbard.hpp" +#include "dca/phys/models/analytic_hamiltonians/fe_as_lattice.hpp" #include "dca/phys/models/analytic_hamiltonians/singleband_chain.hpp" #include "dca/phys/models/analytic_hamiltonians/square_lattice.hpp" #include "dca/phys/models/analytic_hamiltonians/threeband_hubbard.hpp" @@ -150,10 +151,10 @@ struct SinglebandChain { // CASE 4 -- Kagome lattice declared with no_symmetry<2>, mirroring the production // instantiation in test/unit/phys/dca_step/cluster_solver/test_setup.hpp. This is the -// regression case for solveSignsForOp's -1 guard: the hexagonal has the 12 D6 ops, -// but the orbital position+flavor matching in set_symmetry_matrices filters them all out, -// and records (-1, -1). The H0 sign solver must reject the ops instead of indexing H0 with -// band -1 (an OOB). +// regression case for deriveOrbitalOpForOp's -1 guard: the hexagonal has the 12 D6 ops, +// but the orbital position+flavor matching in set_symmetry_matrices filters them all out +// and records (-1, -1). Deriving P from H0 recovers all 12 -- the search skips the -1 +// candidate and finds the sublattice permutation geometry could not place. struct KagomeNoSym { using Scalar = double; using Lattice = dca::phys::models::KagomeHubbard>; @@ -163,7 +164,26 @@ struct KagomeNoSym { static std::vector expectedFailingKOps() { return {}; } static std::vector expectedFailingROps() { return {}; } static std::vector expectedUnverifiedKOps() { return {}; } - static constexpr int expected_num_derived_symmetries = 2; + static constexpr int expected_num_derived_symmetries = 12; +}; + +// CASE 5 -- FeAs (minimal two-band iron-pnictide model, PRB 77, 220503): the motivating case for +// deriving P from H0. Its two orbitals (d_xz, d_yz) share a site (a_vec = 0 for both), so +// position/flavor matching can only return the IDENTITY band image. But H0's two diagonals are the +// same dispersion with k_x and k_y exchanged, so the four D4 ops exchanging the axes (C4, C4^3, the +// two diagonal mirrors) are symmetries only with the band SWAP: geometry derives 4 ops, H0 all 8. +struct FeAs { + using Scalar = double; + // FeAsLattice ignores its template argument: it hardcodes FeAsPointGroup = {identity, C2}, the + // largest group the geometry-derived permutation could support. + using Lattice = dca::phys::models::FeAsLattice; + static constexpr char Input[] = "fe_as_input.json"; + static constexpr int expected_num_symmetries = 2; + static std::vector expectedFailingReps() { return {}; } + static std::vector expectedFailingKOps() { return {}; } + static std::vector expectedFailingROps() { return {}; } + static std::vector expectedUnverifiedKOps() { return {}; } + static constexpr int expected_num_derived_symmetries = 8; }; // Templated test fixture @@ -673,25 +693,21 @@ TYPED_TEST(SymmetrizeCharacterizationTest, PerOpMapRealSpace) { // TEST 8 (a/b/c/d): rejection fixtures for the sign-consistency solver. // -// solveOrbitalOpSignsFromH0 has four throw branches. No shipped model exercises any of the -// four on a correct H0 and a complete symmetry table -- the spine cases all pass -- so we -// perturb a copy of the (exactly symmetric) H0 (8a-8c), or the table itself (8d), to break one -// operation and assert the solver throws for the intended reason. +// solveOrbitalOpSignsFromH0 derives the permutation from H0, throwing only when no band +// permutation reproduces it. No shipped model triggers that on a correct H0 -- the spine cases all +// pass -- so we perturb a copy of the (exactly symmetric) H0 (8a-8c) to break one op past every +// permutation, or corrupt the table (8d) to show the search recovers what geometry cannot place. // // These need a multi-orbital model. A single band has no off-diagonal coupling to corrupt, // and on the spine's square cluster every nonzero single-band entry sits at a k that is a // fixed point of every op (the BZ center and corner), so no single-entry perturbation can // break a symmetry there. Threeband (d, px, py) is the driver; square and singleband skip. -// 8a/8b -- the two ways a single broken off-diagonal coupling is rejected. Both perturb the -// strongest off-diagonal H0 entry (and its Hermitian partner) so its symmetry image no longer -// matches, differing only in how: -// * vanish it (-> magnitude mismatch: a coupling present on one side of the relation but gone on -// the other, so the band permutation is no longer a symmetry of H0); -// * scale it by 1.5 (-> non-unit ratio: the H0 ratio that would fix the sign product is no longer -// a real +/-1, the signature of an op needing a non-signed-permutation U_S -- genuine orbital -// mixing -- or simply not a symmetry). -// They share everything but the perturbation, so they live in one test with two assertion blocks. +// 8a/8b -- two ways a single broken off-diagonal coupling breaks the op past any permutation. Both +// perturb the strongest off-diagonal H0 entry (and its Hermitian partner), differing only in how: +// * vanish it (a coupling present on one side of the relation but gone on the other); +// * scale it by 1.5 (the H0 ratio that would fix a sign product is no longer a real +/-1). +// Either way the search exhausts every permutation and throws the unified message. TYPED_TEST(SymmetrizeCharacterizationTest, RejectsBrokenOffDiagonalCoupling) { using Fixture = SymmetrizeCharacterizationTest; using KCluster = typename Fixture::KCluster; @@ -737,7 +753,7 @@ TYPED_TEST(SymmetrizeCharacterizationTest, RejectsBrokenOffDiagonalCoupling) { const std::string msg = captureThrowMessage([&] { dca::phys::solveOrbitalOpSignsFromH0(H0p); }); ASSERT_FALSE(msg.empty()) << "sign solver did not reject a vanished coupling."; - EXPECT_NE(msg.find("magnitude mismatch"), std::string::npos) << "actual: " << msg; + EXPECT_NE(msg.find("for any band permutation"), std::string::npos) << "actual: " << msg; } // Scale by 1.5: the entry stays nonzero but its ratio to the image is not +/-1. @@ -748,7 +764,7 @@ TYPED_TEST(SymmetrizeCharacterizationTest, RejectsBrokenOffDiagonalCoupling) { const std::string msg = captureThrowMessage([&] { dca::phys::solveOrbitalOpSignsFromH0(H0p); }); ASSERT_FALSE(msg.empty()) << "sign solver did not reject a non-unit coupling ratio."; - EXPECT_NE(msg.find("not a real"), std::string::npos) << "actual: " << msg; + EXPECT_NE(msg.find("for any band permutation"), std::string::npos) << "actual: " << msg; } } @@ -756,7 +772,8 @@ TYPED_TEST(SymmetrizeCharacterizationTest, RejectsBrokenOffDiagonalCoupling) { // jointly satisfied by any assignment. At the zone corner all three bands couple (d-px, d-py, // px-py), so the d-px and d-py signs force the px-py sign product, and a px<->py swap op // reads the px-py sign off H0 directly. Flipping one direction of the px-py coupling -// makes the direct reading contradict the forced product: no sign vector works. +// makes the direct reading contradict the forced product: no sign vector works -- and, because the +// flip is asymmetric, no other band permutation rescues it either, so the search still throws. TYPED_TEST(SymmetrizeCharacterizationTest, RejectsInconsistentSigns) { using Fixture = SymmetrizeCharacterizationTest; using KCluster = typename Fixture::KCluster; @@ -798,7 +815,7 @@ TYPED_TEST(SymmetrizeCharacterizationTest, RejectsInconsistentSigns) { const std::string msg = captureThrowMessage([&] { dca::phys::solveOrbitalOpSignsFromH0(H0p); }); ASSERT_FALSE(msg.empty()) << "sign solver did not reject an inconsistent sign system."; - EXPECT_NE(msg.find("no consistent"), std::string::npos) << "actual: " << msg; + EXPECT_NE(msg.find("for any band permutation"), std::string::npos) << "actual: " << msg; } // TEST 9: the folding phase must not be absorbed into U_S (PR #368 review). At a zone-boundary @@ -847,19 +864,18 @@ TEST(SolveOrbitalOpSigns, BoundaryFoldDoesNotMaskPxParity) { }; USTable u_s; - dca::phys::detail::solveSignsForOp(0, 3, 1, Sym{}, Fold{}, H0{}, u_s); + dca::phys::detail::deriveOrbitalOpForOp(0, 3, 1, Sym{}, Fold{}, H0{}, u_s); EXPECT_DOUBLE_EQ(u_s(0, 0, 0), 1.); EXPECT_DOUBLE_EQ(u_s(1, 1, 0), -1.); // The intrinsic p_x parity, not the fold-dressed +1. EXPECT_DOUBLE_EQ(u_s(2, 2, 0), 1.); } -// 8d -- missing band image: the geometric position/flavor matching in set_symmetry_matrices -// records -1 when it finds no admissible image, so the solver cannot assume a complete table. -// Unlike 8a-8c this perturbs the symmetry table, not H0: the whole-group populator must throw, -// and the non-throwing classifier must omit the op instead of indexing H0 with -1 (an OOB). The -// table is shared static state, so it gets restored. -TYPED_TEST(SymmetrizeCharacterizationTest, RejectsMissingBandImage) { +// 8d -- recovery from a missing band image: set_symmetry_matrices records -1 when its position/ +// flavor matching finds no admissible image. Unlike 8a-8c this perturbs the symmetry table, not H0: +// the solver skips the -1 candidate (no OOB) and recovers the op from the search, so the populator +// succeeds and the classifier still includes it. The table is static state, so it gets restored. +TYPED_TEST(SymmetrizeCharacterizationTest, RecoversFromMissingBandImage) { using Fixture = SymmetrizeCharacterizationTest; using KCluster = typename Fixture::KCluster; @@ -871,19 +887,17 @@ TYPED_TEST(SymmetrizeCharacterizationTest, RejectsMissingBandImage) { const int saved_image = sym(0, 0, s_target).second; sym(0, 0, s_target).second = -1; + // The populator must NOT throw: the -1 makes the geometric candidate inadmissible, but the search + // should continue const std::string msg = captureThrowMessage([&] { dca::phys::solveOrbitalOpSignsFromH0(this->H0_); }); const std::vector verified = dca::phys::verifiedSymmetryOps(this->H0_); sym(0, 0, s_target).second = saved_image; - ASSERT_FALSE(msg.empty()) << "sign solver did not reject a -1 sentinel band image."; - EXPECT_NE(msg.find("no band image"), std::string::npos) << "actual: " << msg; - - std::vector expected = baseline; - expected.erase(std::find(expected.begin(), expected.end(), s_target)); - EXPECT_EQ(verified, expected) - << "classifier did not cleanly omit (only) the op with the sentinel image."; + EXPECT_TRUE(msg.empty()) << "solver failed to recover from a -1 valued band image: " << msg; + EXPECT_EQ(verified, baseline) + << "the H0 search did not recover the op whose geometric band image was corrupted."; } } // namespace