Skip to content

mbpt: closed-shell Kramers (time-reversal) CC tracer (round 2) - #566

Draft
kshitij-05 wants to merge 52 commits into
masterfrom
kshitij/feature/kramers-tracing-round2
Draft

mbpt: closed-shell Kramers (time-reversal) CC tracer (round 2)#566
kshitij-05 wants to merge 52 commits into
masterfrom
kshitij/feature/kramers-tracing-round2

Conversation

@kshitij-05

Copy link
Copy Markdown
Member

Summary

Adds a closed-shell Kramers (time-reversal-symmetry) tracer for relativistic
2-/4-component coupled cluster to mbpt, plus the eval-layer CSE fix needed to
evaluate the complex tensors it produces. This is the "round-2" rebuild of the
Kramers tracer: a small, self-contained API that mirrors the existing spin
tracer in structure but folds Kramers configurations under time reversal instead
of applying Ms conservation.

The tracer takes an all-spinor (Kramers-free) closed-shell expression and emits a
sum over time-reversal-canonical Kramers-block representatives, each with the
{sign, conjugation, permutation} transform that reconstructs the remaining
blocks. A consumer (here, MPQC's CCk / SpinorMP2) evaluates one representative
block per symmetry orbit and fills the rest from the transforms, so the
relativistic residual is computed on a reduced set of unique blocks (doubles:
16 → 5).

All numerics are validated downstream in MPQC against the full-spinor
("spinorbital") oracle: MP2, CCD, CCSD, and CCSDT all reproduce the spinor
energy
(e.g. HSeOH/6-31G X2C: CCD 2.6e-11, frozen CCSDT 2.1e-10). The unit
tests here cover the symbolic structure (orbit counts, fold classes,
reconstruction transforms).

What's added

SeQuant/domain/mbpt/spinor.{hpp,cpp} — the tracer and its orbit machinery:

API Role
closed_shell_kramers_trace(expr, ext_groups, fold_T, expand_g) Energy tracer: 2ⁿ Kramers configs, no Ms filter, global-T (conjugate) pairs folded to 2 Re[...].
closed_shell_kramers_CC_trace(expr, expand_g, use_T) CC residual tracer: A-expands the leading antisymmetrizer Â, folds external Kramers configs under the rank-general external antisymmetry + T. Returns one Kramers-labeled block per external representative.
kramers_config_orbits(n, bit_perms, use_T) Orbits of n-bit configs under bit-permutation generators + global time reversal (the orbit primitive).
kramers_external_generators(rank) The shared external-antisymmetry generator set (Sₖ adjacent transpositions of each external group) — single source used by the tracer, the amplitude allocator, and the leaf reconstruction.
kramers_external_blocks(n, antisym_perms, use_T, symm_perms) Like kramers_config_orbits but each member carries its reconstruction transform block(cfg) = sign·[conj]·permute(block(canonical)).
has_antisymmetrizer(expr) Predicate to route a residual (has Â) vs a fully-contracted energy (no Â).
RealPart / ImagPart (+ real_part / imaginary_part) Symbolic markers for the 2 Re fold; symbolic-only (no native TN evaluation yet — see below).

SeQuant/core/eval/eval_node_compare.hpp — split CSE cache keys by
canon_phase. For complex/Kramers tensors whose leaf value is reconstructed
downstream, two contractions can share a canonical colored graph yet evaluate to
genuinely different (not merely sign-flipped) tensors; without this they
over-merge under CSE and reuse the wrong intermediate. The split only ever
separates cache entries (never merges), so it is correctness-preserving and
real closed-shell paths (all phase +1) are unaffected.

tests/unit/test_spinor.cpp — orbit counts and reconstruction transforms:
MP2-energy fold, rank-general external folds (ranks 1/2/3), CCD R2 → 5 labeled
blocks, internal-fold classes (pp-ladder, ring, separable T2² quad), and the
kramers_external_blocks reconstruction transforms (antisymmetric residual and
raw-g forms). Build glue in CMakeLists.txt / tests/unit/CMakeLists.txt.

Known limitations / follow-ups

  • BraKetSymmetry::Conjugate is not yet exploited by TensorNetworkV3. The
    eval_node_compare canon_phase split is the interim correctness measure; the
    faithful fix is to carry conjugation through canonicalization (TODO noted in
    the header). This is the keystone for the planned next step — teaching the
    canonicalizer time-reversal symmetry so the downstream reconstruction machinery
    dissolves.
  • No native Re()/Im() evaluation of a tensor network — the tracer emits
    RealPart/ImagPart markers and the consumer either honors them or uses the
    fold_T=false form (sum the complex blocks, take the real part).
  • Hermiticity (bra↔ket + conj) folding is deferred; the global-T fold is the one
    applied here.

Testing

  • tests/unit/test_spinor.cpp — symbolic structure (orbit counts, fold classes,
    reconstruction transforms).
  • End-to-end numerical validation lives in the consuming MPQC branch
    (SpinorMP2 / CCk): Kramers-restricted MP2/CCD/CCSD/CCSDT all match the
    full-spinor energy to the SCF/precision floor.

Add spinor.{hpp,cpp}: a Kramers tracer for closed-shell relativistic
(2-/4-component) theories, structured like the naive spin tracer
spintrace_impl but folding whole-config time-reversal (T) partners into
RealPart-wrapped representatives inline. Reuses the Spin quantum number as
the Kramers label (alpha=up, beta=down); expands the integral g while
keeping the amplitude t antisymmetric; leaves particle-interchange (sigma)
to canonicalize. Also adds symbolic RealPart/ImagPart Expr markers.

External-index antisymmetry, hermiticity, and the per-column internal-T-reach
are deferred (CC phase).

test_spinor.cpp: traces the MP2 energy 1/4 g-bar t-bar into 7 canonical
Re[g.t-bar] representatives whose outer coefficients sum to 2^n.
…lic)

Extend spinor.{hpp,cpp} toward Kramers-restricted CCSD (symbolic phase):

- kramers_config_orbits: rank-agnostic n-bit configuration orbits under a set
  of bit-permutation generators + optional global time reversal T (the fold
  engine).
- closed_shell_kramers_CC_trace: stages 1-2 of the CCSD pipeline. Factor out
  the antisymmetrizer  (its bra/ket give the external virt/occ groups), fold
  the external indices (rank-general S_k transpositions + T) into the
  symmetry-unique blocks, and fold the internal contracted indices (enumerate
  + sum, sigma-merge via canonicalize). A-expand, g-expansion and the g TRS
  folds (stages 3-5), and the internal-T-reach, are deferred.
- kramers_external_blocks: per-member sign/conj/perm reconstruction transforms
  (compute the canonical block, fill the rest) -- the eval-time external
  reconstruction (notes' open item).

Validated symbolically (test_spinor.cpp): singles/doubles/triples external
folds (2/5/8 blocks); CCD driver/pp-ladder/ring/quad internal folds
(5 blocks; 3/4/9 internal classes -- the quad's 9-vs-10 noted); reconstruction
transforms (perm/conj<->config invariant, T-pair signs).
Add an optional symm_perms argument (sign +1 permutation generators, default
none) alongside antisym_perms (sign -1), so the eval-time external
reconstruction also covers a raw, non-antisymmetrized g leaf: it folds under
particle interchange sigma (a symmetry, sign +1) + T into 6 blocks, vs the
antisymmetric residual's {P_ab, P_ij} + T into 5. Tested (test_spinor.cpp): the
{sigma} + T case yields 6 blocks with the sigma members carrying sign +1.
Thin public wrapper over the file-local find_antisymmetrizer, so a caller (the
CC spintrace dispatch) can distinguish a residual equation (carries a leading Â)
from a fully contracted scalar (the energy, no Â) and route each to the right
Kramers tracer.
- closed_shell_kramers_CC_trace: fold each residual term over its OWN contracted
  indices, not the whole block over the union of all terms' internals. A term
  invariant under an index (e.g. the driver, which has no internal index) was
  over-counted 2^k. Only surfaces on the full multi-term R2 — the unit tests feed
  single terms, so single-term behavior is unchanged (114 [spinor] assertions
  still pass).
- closed_shell_kramers_trace: add fold_T (default true). With fold_T=false, emit
  every configuration (sigma-merged) verbatim with no RealPart wrapper — the
  complex config sum whose real part the caller takes — for evaluators that
  cannot evaluate Re() of a tensor network (the CCk energy observable).
When expand_g=false, the integral g is kept antisymmetric (ḡ) instead of
expanded to the level-1 raw-g form, so the evaluator fetches the factory [as]
block and the cross-Kramers antisymmetry is handled inside the integral. The CCk
energy path uses expand_g=false; g-expansion (level-1) remains the default and a
later optimization stage. Default expand_g=true preserves SpinorMP2 behavior.
The CCSD Kramers trace now A-expands the antisymmetrizer  (expand_A_op) into
explicit signed external permutations, so the full external antisymmetry —
cross-Kramers pairs included — is explicit per term. The earlier strip-Â +
within-block bitwise form could not antisymmetrize cross-Kramers t-dependent
terms (a post-hoc block sign only flips, it does not antisymmetrize).

Adds two flags: expand_g (expand each ḡ to raw NonSymm leaves so the evaluator
fetches raw ⟨..|..⟩ blocks instead of the factory [as] block, which omits the
cross-Kramers swap for mixed-Kramers pairs) and use_T (fold external configs
under global time reversal; default on).
The eval-node CSE identity (the canonical colored connectivity graph) is value-
invariant for ordinary tensors but not for complex tensors whose leaf value is
reconstructed downstream (e.g. relativistic Kramers t-amplitudes via an external
recon): two contractions can share a canonical graph yet evaluate to genuinely
different (NOT merely sign-flipped) tensors, surfacing as a +1/-1 canon_phase
collision. Keying canon_phase into the eval-node equality splits those apart.
This only ever splits cache entries (never merges), so it is correctness-
preserving; real closed-shell paths (all phase +1) are unaffected and lose no CSE.

Interim hack. TODO: replace with a faithful conjugation-aware eval-node identity
once TensorNetworkV3 exploits BraKetSymmetry::Conjugate (carry the conjugation
through canonicalization, the way canon_phase carries the sign).
compose() composed the reconstruction perm in reversed order
(tx.perm[g_perm[k]] instead of g_perm[tx.perm[k]]) for the convention
block(cfg)[v] = block(canon)[v_perm]. The two orders agree for the abelian
S1/S2 external groups of singles/doubles (all involutions), so CCD/CCSD were
unaffected, but differ for the non-abelian S3+ groups of triples and higher,
where 3-cycles reconstructed to the wrong tensor. Validated offline at ranks
1-4 against a fully antisymmetric + TRS reference (reversed order fails at
rank 3; this order passes to machine precision at every rank).
The S_rank adjacent-transposition generator set for the external Kramers fold
(virtual group [0,rank), occupied group [rank,2*rank)) was hand-built inline in
closed_shell_kramers_CC_trace and, MPQC-side, in two more places (the amplitude
allocator and the leaf reconstruction). Provide it as one shared helper and use
it in the tracer. Pure refactor.
@kshitij-05
kshitij-05 marked this pull request as draft June 30, 2026 16:28
kshitij-05 and others added 19 commits June 30, 2026 17:56
Orphaned when 7f69992 switched A-handling to expand_A_op; its only
references are its own definition and a self-recursive call, so it is
unused. clang -Wunneeded-internal-declaration (-Werror) in SeQuant's
unit build rejects it (the MPQC build that validated the branch uses
looser flags, so it slipped through).
Two eval nodes that share a canonical graph/leaf but differ in
antisymmetric-reorder parity evaluate to negatives of each other
(+T vs -T), so they must not share a CSE cache slot. This was enforced
by a special-cased canon_phase check in TreeNodeEqualityComparator,
flagged as a hack pending a "faithful" fix.

Make canon_phase a first-class part of the node identity: fold it into
EvalExpr::hash_value() (a no-op for real closed-shell paths, where every
phase is +1, so the equality structure is unchanged) and keep the
comparator check as the hash-collision guard. This retires the hack and
fixes silent CSE over-merging of complex/Kramers contractions
(Kramers-restricted CCD now matches the spinor reference).
Add an opt-in exploit_conjugate mode to TensorNetworkV3::canonicalize_slots
(threaded via CreateGraphOptions). When enabled, a BraKetSymmetry::Conjugate
tensor's bra and ket slots are colored identically (as for Symm), so its two
bra<->ket orientations fold onto one canonical graph; the accompanying
conjugation (T{bra;ket} = conj(T{ket;bra})) is recorded as a new
SlotCanonicalizationMetadata::conj byproduct, detected by comparing the
canonical positions of the bra/ket bundle vertices -- the same comparison
canonicalize() uses for its explicit bra<->ket swap.

Default off: Conjugate bra/ket stay distinctly colored (historical behavior),
so all existing canonicalization is byte-unchanged. Groundwork for exploiting
Hermitian (Conjugate) braket symmetry in eval-node CSE (CSV coefficients and,
later, time-reversal folding).
Layer the exploit_conjugate byproduct from canonicalize_slots onto the
eval tree, so the two bra<->ket orientations of a Conjugate leaf share one
cached value.

EvalExpr(Tensor, exploit_conjugate) threads the flag into the ToT leaf's
canonicalize_slots call and records the conjugation in a new canon_conj()
bit. That bit is kept OUT of hash_value() (only canon_phase is folded
there), so a Conjugate leaf and its bra<->ket swap hash identically and
share a cache slot. binarize(Tensor, opts) then turns the conjugated
orientation into an EvalOp::Adjoint over the bare canonical leaf, carrying
the SAME canonical index order -- so the existing adjoint evaluator's
result(post) = operand(pre).conj() degenerates to a pure elementwise
conjugation (post == pre, no transpose) on retrieval. This reuses the
tested '+'-adjoint machinery rather than adding a new eval op.

Default off leaves every existing path byte-identical: the new binarize
branch is skipped and canonicalize_slots is called with
exploit_conjugate=false, exactly as before.

Test [exploit_conjugate] (replaces the throwaway probe): a proto-indexed
Conjugate leaf and its adjoint fold to one hash with exactly one carrying
the byproduct; binarize wraps the swapped orientation in EvalOp::Adjoint
over the shared bare leaf with matching canonical indices; off by default
keeps the two distinct.
Extend the exploit_conjugate conjugation channel to the flat (protoindex-free)
block-canonicalization leaf path, so a flat BraKetSymmetry::Conjugate tensor and
its bra<->ket-swapped partner fold onto one cached value the same way the
ToT/canonicalize_slots path already does. This is the path the standard (flat)
Kramers CCk t/g -- Conjugate under Field::Complex -- take.

TensorBlockCanonicalizer::apply() already folds the two bra<->ket orientations
of a Symm tensor (a free relabeling). Factor that color-based swap into a shared
orient_braket_by_color() (apply()'s Symm branch reuses it, byte-for-byte
unchanged) and add fold_conjugate_braket(), which applies the same swap to a
Conjugate tensor and reports whether it swapped -- for Conjugate the swap
carries a conjugation (C{ket;bra} = conj(C{bra;ket})), so it is a byproduct the
caller must consume, not a free relabeling.

The EvalExpr flat-leaf ctor branch calls it under exploit_conjugate and records
the result in canon_conj_ (kept out of the hash, so the two orientations share a
cache slot); binarize(Tensor)'s existing EvalOp::Adjoint wrap then serves the
swapped orientation as a pure elementwise conjugation on retrieval, exactly as
for the ToT path.

Default off leaves every existing path byte-identical. Limitation: equal-color
bra/ket bundles (identical spaces) are not folded on the flat path -- that needs
a full index-pattern comparison, which only the bliss/ToT path does; the flat
color rule matches apply()'s Symm fold.

Test [exploit_conjugate] gains a flat-leaf section (C{a_1;i_1}:N-C-S) mirroring
the ToT checks: off -> distinct, on -> fold + exactly one conjugated + Adjoint
over the shared bare leaf.
EvalExpr(Tensor)'s canonicalize_slots call passed {} for
named_index_compare in order to reach the exploit_conjugate argument. An
empty comparator is NOT the declared default: canonicalize_slots then
falls back to an internal space()-only lambda, whereas the declared
default (default_idxptr_slottype_lesscompare) orders named indices by
proto-index count first. That proto-count-first order is what lays a
CSV/ToT coefficient's canon_indices out with occupieds first -- the
layout CCk's osv_coeff_layout/pno_coeff_layout detectors rely on. So {}
silently mis-ordered them and CSV-CCk (he2/h2o/he10-csv) aborted with
csv_rank==0 ("Only OSV and PNO coefficients available").

Pass default_idxptr_slottype_lesscompare{} explicitly, restoring the
comparator every ToT leaf had before the exploit_conjugate arg was
threaded. Flat leaves (block-canon else-branch) are unaffected.
CostProfile (peak/flops/exec) over the factorized IR via a zero-data dry-run
evaluation, driving the batched cost model's predictions.
Perf-first (DenseTimeSpace) objective with peak_threshold as a ceiling;
role-split (contracted/external) batchability; order-aware placement over the
combined nest; per-node batch annotations consumed by the evaluator.
External-mode scatter + contracted accumulate; cache scope chain with
fall-through; slice-on-use; per-level placement driven by a per-canonical
lifetime mask (cross-occurrence meet) unioned with contracted residency;
iterative (stack-safe) tree traversal.
Index-space occupancy predicates robust to non-physical spaces; logger;
is_valid accepts Power; convention.
The orthonormal-basis overlap branch of csv_transform reused
drop_proto_indices() (which keeps the base ordinal, e.g. a↑_1) as the
contracted dummy of the C†C expansion. Two overlaps sharing a base
virtual then collide on the same dummy, so that index appears >2 times in
the term and the eval-time TensorNetworkV3 build rejects it
("Edge::add_vertex: v is already connected"). Take the spin-labeled base
space from drop_proto_indices() (which correctly preserves the ↑/↓ label)
but mint a unique tmp index in it. No effect on the non-relativistic
(comma-only, single-overlap) path.
make_spin{alpha,beta} stamps a whole proto-bundle with the outer index's single Kramers spin, wrong for a CSV/PNS virtual whose proto is the occ pair (each occ carries its own up/down). Rebuild kv.second's proto-bundle from the original config-mapped proto, unconditionally: rebuilding only when some proto-index was in the map missed the all-external-proto case (a down internal virtual of an up external pair in the vv-Fock coupling), whose amplitude then spanned two proto tuples and gained a spurious pair of outer occ axes.
Cost +inf for a bipartition that sums a shared index which a tensor-of-tensor einsum cannot contract, so the DP de-nests against the CSV coefficients first: (1) a non-proto index that is an outer/batch axis of both ToT operands (the bare C.C overlap over the CSV expansion index mu); (2) a proto-value of a ToT operand (the off-diagonal occ Fock summing the amplitude's own pair index). Self-gating: never fires for flat-only networks, nor for f.C/g.C (fresh CSV mode) nor intra-pair vv-Fock (inner virtual).
With kramers=true every spin-labeled expansion dummy (including the
overlap C+C dummy) is summed over both Kramers flavors instead of
inheriting the CSV index's label: per-index-column ms conservation is
correct nonrelativistically but drops the mixed-label C blocks of a
time-reversal-paired spinor basis.
…nicalization

The label-ignoring default (ignore_named_index_labels=Yes) makes same-space
named indices graph-automorphic, so bliss breaks the orbit by input vertex
order: equivalent summands canonicalize to DIFFERENT forms (defeating the
HashingAccumulator merge) and which named label lands on which slot becomes
input-dependent. Canonicalize summands with IgnoreNamedIndexLabel::No —
merging across a sum requires meaningful named labels, exactly as
Sum::canonicalize_impl already does. Adds a CSV-style regression test
(equivalent g*C*C networks under dummy relabeling + factor reorder must
canonicalize identically and merge to 2x one term).
…form)

Computes the reconstruction transform block(cfg) = sign*[conj]*perm(
block(canon)) directly from the bit representations — order-preserving
within-group bit matching with parity tracking, conj branch via complement
with the (-1)^(#down of pre-image) time-reversal sign. No orbit tables and
no generator-word composition, so the rank>=3 composition-order subtleties
cannot arise by construction. Adds kramers_external_groups(rank).

Validated by [orbit-transform]: a synthetic group-antisymmetric, exactly
T-symmetric spinor tensor at ranks 1-3; both the existing table generator
(kramers_external_blocks) and the new transform reproduce every directly
extracted flavor block to 1e-12 (the test also pins the perm convention:
input slot p feeds output slot perm[p]). Groundwork for the rank-general
ToT (CSV/PNS) Kramers reconstruction.
Two terms identical under the i1<->i2 dummy exchange (symmetric proto
bundle <i1,i2>) must canonicalize identically for like-term merging to
fold them.

- csv_proto_dummy_fold ([!shouldfail]): with DENORMALIZED bundles
  (members that are not the tensor's own legs, as emitted by the Kramers
  trace) each term gets a different bundle-only member promoted to a
  named index by TNV3's pure-proto rule, pinning a different label in
  each -- the canonical forms never match.
- csv_proto_dummy_fold_normalized (passes): with bundles equal to the
  tensor's own occupied legs (the reset_csv_protos invariant) nothing is
  bundle-only, nothing is pinned, and the existing machinery folds the
  pair (canonical forms equal; transform_sum_expr collapses to 2x).

Together these show the term-count fix needs bundle normalization on the
producer side, not TN surgery.
The flat-leaf exploit_conjugate channel folds the two bra<->ket
orientations of a Conjugate tensor onto one cache slot, serving the
swapped orientation via EvalOp::Adjoint. The ToT (proto-indexed / CSV)
leaf path lacked the fold: TNV3's canonicalize_slots does not exploit
conjugate braket symmetry, so the orientations landed in separate slots.
Apply TensorBlockCanonicalizer::fold_conjugate_braket to the ToT leaf
before the network canonicalization (the color rule is label-independent
and proto-safe) and compose the byproduct into canon_conj_; the ToT TA
Result backend already implements adjoint() (conj recurses into nested
tiles). Off by default (exploit_conjugate opt-in unchanged).

tot_conjugate_braket_fold: hashes fold, canon_conj marks the swapped
orientation, no behavior change without the opt-in. The [spinor] single
assertion failure and the full-suite crash are pre-existing (verified
against the pre-change tree).
Opt-in (exploit_conjugate): canonicalize_slots reports whether folding the
two bra<->ket orientations of a Hermitian (BraKetSymmetry::Conjugate)
tensor onto one canonical orientation introduced a conjugation, so
eval-node CSE can share one cache slot between the orientations and
serve the swapped one via an adjoint. Default off; no behavior change
without the opt-in.
Layer the exploit_conjugate byproduct from canonicalize_slots onto the
eval tree, so the two bra<->ket orientations of a Conjugate leaf share one
cached value.

EvalExpr(Tensor, exploit_conjugate) threads the flag into the ToT leaf's
canonicalize_slots call and records the conjugation in a new canon_conj()
bit. That bit is kept OUT of hash_value() (only canon_phase is folded
there), so a Conjugate leaf and its bra<->ket swap hash identically and
share a cache slot. binarize(Tensor, opts) then turns the conjugated
orientation into an EvalOp::Adjoint over the bare canonical leaf, carrying
the SAME canonical index order -- so the existing adjoint evaluator's
result(post) = operand(pre).conj() degenerates to a pure elementwise
conjugation (post == pre, no transpose) on retrieval. This reuses the
tested '+'-adjoint machinery rather than adding a new eval op.

Default off leaves every existing path byte-identical: the new binarize
branch is skipped and canonicalize_slots is called with
exploit_conjugate=false, exactly as before.

Test [exploit_conjugate] (replaces the throwaway probe): a proto-indexed
Conjugate leaf and its adjoint fold to one hash with exactly one carrying
the byproduct; binarize wraps the swapped orientation in EvalOp::Adjoint
over the shared bare leaf with matching canonical indices; off by default
keeps the two distinct.
Extend the exploit_conjugate conjugation channel to the flat
(protoindex-free) block-canonicalization leaf path, so a flat
BraKetSymmetry::Conjugate tensor and its bra<->ket-swapped partner fold
onto one cached value the same way the ToT/canonicalize_slots path
already does. This is the path flat complex-field Conjugate leaves take.

TensorBlockCanonicalizer::apply() already folds the two bra<->ket
orientations of a Symm tensor (a free relabeling). Factor that
color-based swap into a shared orient_braket_by_color() (apply()'s Symm
branch reuses it, byte-for-byte unchanged) and add
fold_conjugate_braket(), which applies the same swap to a Conjugate
tensor and reports whether it swapped -- for Conjugate the swap carries a
conjugation (C{ket;bra} = conj(C{bra;ket})), so it is a byproduct the
caller must consume, not a free relabeling.

The EvalExpr flat-leaf ctor branch calls it under exploit_conjugate and
records the result in canon_conj_ (kept out of the hash, so the two
orientations share a cache slot); binarize(Tensor)'s existing
EvalOp::Adjoint wrap then serves the swapped orientation as a pure
elementwise conjugation on retrieval, exactly as for the ToT path.

Default off leaves every existing path byte-identical. Limitation:
equal-color bra/ket bundles (identical spaces) are not folded on the flat
path -- that needs a full index-pattern comparison, which only the
bliss/ToT path does; the flat color rule matches apply()'s Symm fold.

Test [exploit_conjugate] gains a flat-leaf section (C{a_1;i_1}:N-C-S)
mirroring the ToT checks: off -> distinct, on -> fold + exactly one
conjugated + Adjoint over the shared bare leaf.
EvalExpr(Tensor)'s canonicalize_slots call passed {} for
named_index_compare in order to reach the exploit_conjugate argument. An
empty comparator is NOT the declared default: canonicalize_slots then
falls back to an internal space()-only lambda, whereas the declared
default (default_idxptr_slottype_lesscompare) orders named indices by
proto-index count first. That proto-count-first order is what lays a
proto-indexed (ToT) coefficient's canon_indices out with occupieds
first -- a layout downstream coefficient-shape detectors rely on. So {}
silently mis-ordered them and broke such consumers.

Pass default_idxptr_slottype_lesscompare{} explicitly, restoring the
comparator every ToT leaf had before the exploit_conjugate arg was
threaded. Flat leaves (block-canon else-branch) are unaffected.
The flat-leaf exploit_conjugate channel folds the two bra<->ket
orientations of a Conjugate tensor onto one cache slot, serving the
swapped orientation via EvalOp::Adjoint. The ToT (proto-indexed) leaf
path lacked the fold: TNV3's canonicalize_slots does not exploit
conjugate braket symmetry, so the orientations landed in separate slots.
Apply TensorBlockCanonicalizer::fold_conjugate_braket to the ToT leaf
before the network canonicalization (the color rule is label-independent
and proto-safe) and compose the byproduct into canon_conj_; the ToT TA
Result backend already implements adjoint() (conj recurses into nested
tiles). Off by default (exploit_conjugate opt-in unchanged).

Test tot_conjugate_braket_fold: hashes fold, canon_conj marks the
swapped orientation, no behavior change without the opt-in.
For a sum whose value the caller asserts to be real, a summand and its
adjoint contribute Re(s + s*) = Re(2 s). fold_conjugate_pairs_of_real_sum
detects adjoint-related summand pairs via canonical forms (robust to dummy
renaming and factor reordering) and folds each pair onto its first member
with a doubled scalar. Self-adjoint summands are left untouched. This is
the symbolic-layer counterpart of the eval-layer exploit_conjugate channel:
it removes conjugate-duplicate terms from the expression itself instead of
folding them onto one cache slot at evaluation time.
For a sum whose value the caller asserts to be real, a summand and its
adjoint contribute Re(s + s*) = Re(2 s). fold_conjugate_pairs_of_real_sum
detects adjoint-related summand pairs via canonical forms (robust to dummy
renaming and factor reordering) and folds each pair onto its first member
with a doubled scalar. Self-adjoint summands are left untouched. This is
the symbolic-layer counterpart of the eval-layer exploit_conjugate channel:
it removes conjugate-duplicate terms from the expression itself instead of
folding them onto one cache slot at evaluation time.
fold_conjugate_pairs_of_real_sum gains an optional conjugate_op: a map
from a summand to an expression the caller asserts to equal the summand's
complex conjugate in value. Default remains the algebraic adjoint. A
custom map lets callers exploit domain identities that express the
conjugate as an index relabeling instead of a bra<->ket swap (e.g. leaf
tensors whose label-flipped blocks equal the complex conjugate), so
conjugate pairs written in that form are recognized and folded.
…rs_trace

The Kramers configuration enumeration ran over the index set of the WHOLE
traced expression, so in a mixed-rank sum a summand that does not carry
every index was replicated once per configuration of the indices it lacks:
2^(n_missing) phantom duplicates. First victim: the CCSD energy, whose
2-slot f.t1 terms sat next to 4-slot g.t2/g.t1.t1 terms and came out 4x
(measured term-by-term on HSeOH/X2C: four identical copies of each f.t1
config value), inflating the reported energy by 3x the singles
contribution -- a deterministic 1.95e-6 kramers-vs-spinor gap. Uniform-rank
sums (MP2/MP1/CCD energies) were unaffected, which is why every earlier
validation passed. Tracing each summand over its own index set has no
phantom bits and is term-for-term equivalent for uniform-rank sums.

Validation (HSeOH/X2C/cc-pVDZ, method=standard): CCSD kramers
-0.36004971754209 vs spinor -0.36004971746212 (8.0e-11; was 1.95e-6);
CCD unchanged -0.35674766919813 (== spinor 5.5e-11); ClHC=CHI CSV-CCk MP1
loc tpns=1e-5 acceptance bit-identical (-0.489514986573).
For a Kramers-restricted reference the one-body Fock operator commutes
with time reversal, so in a Kramers-paired basis Fock elements between
opposite Kramers partners vanish: f^{p↑}_{q↓} = 0, for the occupied,
virtual and mixed blocks alike. Terms carrying such a factor can
therefore be dropped at derivation time.

Add drop_mixed_kramers_fock_terms() and a defaulted-off flag on both
Kramers tracers. On a rank-2 CSV/MP1 residual this takes each external
block from 22 to 12 terms (110 -> 60 overall), measured identical on
h2o (C2v), ClHC=CHI (Cs) and HSeOH (C1), canonical and localized.

Verified numerically on HSeOH/X2C (Se, strong spin-orbit): the crossed
blocks vanish with SCF convergence in lockstep with the Brillouin block
⟨i|F|a⟩, which is guaranteed zero for a converged SCF, staying ~8x below
it throughout:

  SCF target   ⟨i|F|a⟩    f^{i↑}_{j↓}   f^{a↑}_{b↓}
  1e-9         2.2e-06    2.7e-07       2.2e-07
  1e-11        1.4e-07    1.8e-08       1.4e-08
  1e-13        2.4e-09    3.0e-10       2.4e-10
  1e-15        2.0e-11    2.5e-12       2.0e-12

against a diagonal of 4.7e+2, i.e. ~4e-15 relative at the tightest
point. Measuring this needs the DF-consistent Fock: the leaf builder
requests [df] and so receives the SCF's own converged Fock (verified
bit-identical to SD's F_); a hand-built formula without [df] gets an
exact-J/K Fock and shows a spurious ~1e-5 residual.

The flag defaults off because the premise is a property of the
REFERENCE, not of the algebra: it holds only while the orbital bases
stay Kramers-paired (canonical, or a localizer applying the same
transform to both partners). A localizer that mixes Kramers partners
invalidates it.

Little arithmetic is saved -- at production CSV screening those blocks
already fall below the sparse-shape cutoff -- so the gain is derivation
size, not FLOPs.

A residual block is never annihilated wholesale: downstream consumers
read the block's external layout from its expression and a bare
Constant{0} carries none, so a block whose terms all drop is kept
verbatim.
Covers the CSV/ToT half of the BraKetSymmetry::Conjugate channel: a
proto-indexed (ToT) Conjugate leaf and its bra<->ket-swapped partner
canonicalize to the same graph and hash under exploit_conjugate, with
canon_conj set on exactly one of the pair, so binarize can serve the
swapped orientation through an EvalOp::Adjoint wrapper over the shared
cached operand.
The eval-node CSE identity (the canonical colored connectivity graph) is value-
invariant for ordinary tensors but not for complex tensors whose leaf value is
reconstructed downstream (e.g. relativistic Kramers t-amplitudes via an external
recon): two contractions can share a canonical graph yet evaluate to genuinely
different (NOT merely sign-flipped) tensors, surfacing as a +1/-1 canon_phase
collision. Keying canon_phase into the eval-node equality splits those apart.
This only ever splits cache entries (never merges), so it is correctness-
preserving; real closed-shell paths (all phase +1) are unaffected and lose no CSE.

Interim hack. TODO: replace with a faithful conjugation-aware eval-node identity
once TensorNetworkV3 exploits BraKetSymmetry::Conjugate (carry the conjugation
through canonicalization, the way canon_phase carries the sign).
Two eval nodes that share a canonical graph/leaf but differ in
antisymmetric-reorder parity evaluate to negatives of each other
(+T vs -T), so they must not share a CSE cache slot. This was enforced
by a special-cased canon_phase check in TreeNodeEqualityComparator,
flagged as a hack pending a "faithful" fix.

Make canon_phase a first-class part of the node identity: fold it into
EvalExpr::hash_value() (a no-op for real closed-shell paths, where every
phase is +1, so the equality structure is unchanged) and keep the
comparator check as the hash-collision guard. This retires the hack and
fixes silent CSE over-merging of complex/Kramers contractions
(Kramers-restricted CCD now matches the spinor reference).
fold_conjugate_pairs_of_real_sum gains an optional conjugate_op: a map
from a summand to an expression the caller asserts to equal the summand's
complex conjugate in value. Default remains the algebraic adjoint. A
custom map lets callers exploit domain identities that express the
conjugate as an index relabeling instead of a bra<->ket swap (e.g. leaf
tensors whose label-flipped blocks equal the complex conjugate), so
conjugate pairs written in that form are recognized and folded.
Covers the CSV/ToT half of the BraKetSymmetry::Conjugate channel: a
proto-indexed (ToT) Conjugate leaf and its bra<->ket-swapped partner
canonicalize to the same graph and hash under exploit_conjugate, with
canon_conj set on exactly one of the pair, so binarize can serve the
swapped orientation through an EvalOp::Adjoint wrapper over the shared
cached operand.
…ture/kramers-tracing-round2

# Conflicts:
#	SeQuant/core/eval/eval_expr.cpp
#	SeQuant/core/tensor_network/v3.cpp
#	tests/unit/test_canonicalize.cpp
#	tests/unit/test_eval_expr.cpp
…jectives)

Brings in the foundational block of evaleev/feature/multimode-batched-eval
(9f938f1..1001236) for `BatchPolicy::peak_threshold`: a peak-memory
budget in BYTES that makes batching budget-driven instead of always-on.
Under the space-first objective it is a hard feasibility gate on root
selection (default +infinity => every schedule feasible => no batching);
under the time-first one it is not a gate but the trigger for external-mode
emission, which also needs `batch_spectator_indices`.

That is the knob our CSV-CCk batching lacks. Batching there is gated purely
on aux_target_size > 0, so it is all-or-nothing: measured on clihc2h it
costs 47% at tpns=1e-5 (16.9 kept/pair, footprint does not bind) and wins
21-24% at tpns=0 (134 kept/pair, it does). A byte budget expresses exactly
that distinction.

Only the six foundational commits are taken, not the 190-commit branch --
the whole-scope executor, remat and ordered-schedule work on top is
in-progress research we do not need for the threshold.

Conflict resolution, 23 hunks:
  - batch_policy.hpp, optimize.cpp, test_optimize.cpp -> theirs. Upstream
    splits the single `is_batchable_index` FIELD into per-role building
    blocks (`is_batchable_contracted_index` / `is_batchable_external_index`)
    with a derived `is_batchable_index()` METHOD as their union, drops
    SEQUANT_DESIGNATED_INIT_ONLY from BatchPolicy, and moves the batching
    config onto CostParams. Our side was only the old call shape.
  - options.hpp, eval_expr.hpp -> union; both sides added adjacent,
    independent members (our exploit_conjugate next to their
    node_batch_axes).
  - test_eval_expr.cpp -> union; our "exploit_conjugate eval fold" and their
    "eval_expr_batched_here_typed" collided on a shared closing brace.

Note for consumers: `DensePeakSize` / `DensePeakSizeBatched` are renamed
`DenseSpaceTime` / `DenseSpaceTimeBatched`, and setting a batchability
predicate now means setting a role building block rather than the field.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants