Skip to content

Lazy conjugation at evaluation: retrieval-time CanonTransform, Re/Im eval nodes, Adjoint IR retired - #603

Draft
kshitij-05 wants to merge 24 commits into
kshitij/feature/conjugation-symbolicfrom
kshitij/feature/conjugation-eval
Draft

Lazy conjugation at evaluation: retrieval-time CanonTransform, Re/Im eval nodes, Adjoint IR retired#603
kshitij-05 wants to merge 24 commits into
kshitij/feature/conjugation-symbolicfrom
kshitij/feature/conjugation-eval

Conversation

@kshitij-05

Copy link
Copy Markdown
Member

Conjugation PR 2 of the series; stacked on #602 (kshitij/feature/conjugation-symbolic), to be retargeted to master when #602 merges.

What

Serve conjugation at evaluation time through the retrieval transform instead of an Adjoint IR node.

  • EvalExpr::canon_phase_ generalizes to CanonTransform {phase, conj, braket_swap}: the map from the cached canonical value to the value this node denotes. Excluded from the node's slot hash, as the phase already was.
  • Result::apply_transform(transform, annot) (one backend virtual; the TA implementation fuses relabel, .conj() and scale) replaces apply_phase on retrieval. EvalOp::Adjoint, the NeedLeftAdj stage, the Constant{1} sentinel and Result::adjoint are retired.
  • Leaf boundary: every spelling of a leaf lands on one cache slot in canonical spelling. Conjugate tensors fold bra/ket at the eval boundary too (fold_conjugate_braket has one setting everywhere), starred Nonsymm leaves and -labeled adjoints are served via the transform instead of throwing, ToT leaves fold via conjugated_tensors.
  • Uniform-conjugate hoisting through sum and product hash streams, so conj(A·B) and A·B share intermediates.
  • First-class EvalOp::RealPart / EvalOp::ImagPart nodes; simplify folds A + A* into 2 Re A by default (FoldConjugatePairs::Yes, hardened); fold_conjugate_pairs_of_real_sum deprecated. The optimizer sees through the Re/Im wrappers.

Verification

  • Unit tests: the conjugation battery ([conjugation], [eval_expr], uniform-conjugate reuse tests) plus the existing suite; run with SEQUANT_ASSERT_BEHAVIOR=THROW.
  • MPQC (CCk, X2C): h2o CCk −0.13510773505222942 with the fold on; dch PNS-MP2 −1.04169026886 (default path), both matching the pre-PR references within the known X2C thread scatter.

Known limitation

A Re-wrapped ToT (CSV) energy summand evaluates through a materializing nested einsum instead of the trace-mode scalar reduction (14 GB on dch). MPQC therefore ships the CSV energy fold opt-in; the fix (route the wrapper's inner root through the ResultExpr scalar-head treatment) is the recorded follow-up T20 in doc/dev/plans/2026-09-01-lazy-conj-eval-plan.md.

Design: doc/dev/specs/2026-09-01-lazy-conj-eval-design.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AkZPie5Up85F78r94P4jQs

kshitij-05 and others added 24 commits September 1, 2026 17:03
…oint IR retired from binarize; product-level conj hoisting

Plan tasks 4+6+7(product half) as one green unit (see the deviations
section appended to the plan doc): the conjugation marker composes as a
PURE conj bit (slot deltas belong to the canonicalizer fold alone),
collect_tensor_factors re-materializes denoted spellings for TN identity
and slot counting, child hashes carry a conj/swap structural salt, and
uniformly conjugated products hoist the conjugation onto the node
transform (the Kramers-tracing cache-reuse mechanism). binarize(Tensor)
is a plain leaf; the PR-1 refusal gates and the make_adjoint_node
channels are gone; IR-shape and boundary tests reblessed to the
transform model.
…lling normalization

The ToT branch consumes the canonicalize_slots conjugated_tensors report
(PR-1's field gains its consumer): fold ON, the canonical labeling's
swapped-orientation report composes {conj, braket_swap} and the stored
spelling is respelled canonical, so flat and ToT leaves share one
contract. '⁺'-label and marker channels factored into
normalize_leaf_spelling, used by both branches.
…-scalar wraps

The hoist decision is taken over the factor/summand node transforms and
strips the conj salt from the child-hash stream, so a uniformly
conjugated product, sum, or real-scalar-wrapped term hashes onto its
unconjugated counterpart's slot with {conj} on the node -- pairwise
intermediates included (every prefix of a uniform factor list is
uniform). Mixed marks keep their structural salts. Pinned by
conj_hoisting_structural_identity.
CanonTransform moves to its own header (canon_transform.hpp) so the
result layer sees it without depending on eval_expr. Base virtual with
throwing default; ResultScalar conjugates non-arithmetic values; the TA
backend (flat + ToT) fuses phase * conj * relabel in one expression,
conj elided for real numeric_type exactly as adjoint(). The trivial
transform is the caller's short-circuit, mirroring apply_phase.
BTAS/tapp implementations land with the evaluate switch (T9).
The stack machine's apply_phase generalizes to the full transform: a
leaf's result is served as its DENOTED value (the leaf evaluator serves
the canonical spelling; the hand-up applies the transform, and
finish_phase_b's involution store keeps the cache canonical), the
NeedLeftAdj stage, EvalOp::Adjoint, is_adjoint, the export Adjoint case
and Result::adjoint (all four backends) are gone, and every aliasing
check keys on canon_transform().trivial(). BTAS/tapp gain fused
apply_transform implementations; symmetrize/antisymmetrize wrappers use
the DENOTED bra rank. ToT leaves carry array-faithful Nested
(outer;inner) canon_indices via tot_indices. The TA test fixture adopts
the canonical-serving contract (keys and stored arrays are canonical-
spelling shaped; literal spellings are served through the leaf's
transform); IR-shape and end-to-end expectations reblessed to the
transform model. Both suites green (debug 6558; TA-enabled 159612
assertions).
x and x^* (and b^n vs conj(b^n)) share one cache slot: the unmarked
spelling is stored and hashed, the conj bit rides the CanonTransform,
applied on retrieval by ResultScalar::apply_transform and
re-materialized as a marker by the export layer's denoted_expr.
The left-fold's factor prefixes hoist independently: a uniformly
conjugated prefix strips its factors' conj salts (per-PREFIX, keeping
C.C^* vs C^*.C order-insensitive) and records {conj} on its node, so
the (A^*.B^*) intermediate of A^*.B^*.C is a cache hit on the A.B slot
while mixed roots stay identity-distinct. conj_eval_cache_reuse pins
the whole contract with a counting yielder on random complex data:
whole-term partner, \mathcal{T}-shaped sum of products, and the
buried-intermediate CSE -- zero new leaf yields plus numeric equality
with the conjugated reference in every shape. Also purges Theta in
favor of \mathcal{T} notation throughout.
An accidental clang-format -i over the spec .md (commit 26f74c9)
reflowed the whole file into C++-style line breaks. Restore the original
text, keeping that commit's one intended edit (Theta -> \mathcal{T}
notation) and 21fe72e's Implementation-deltas section.
Owner decision: fold_conjugate_pairs_of_real_sum is fragile (reality
assertion, unfolded difference pairs) and retires in favor of the exact
fold_conjugate_pairs, which needs eval-side Re/Im nodes -- so those move
from Plan B into this PR, with the MPQC call-site switch and smoke
re-certification.
The conjugate-pair fold emits Constant(2) * RealPart(s); Re/Im are
projections (not involutions), so they cannot ride in CanonTransform and
become unary IR nodes over the shared inner subtree (Constant{1} sentinel
right child). Node hash = inner's salted hash + op, so Re(s), Im(s) and
bare s occupy distinct slots while the inner stays shared.
Result::real_part()/imag_part() virtuals (default-throwing; ResultScalar
serves both backends' scalars); linearize_eval_node reconstructs the
wrappers; export throws loudly pending its Plan-B support.
The parked default flips now that eval ingests Re/Im nodes. Hardening:
simplify() folds only fully c-number expressions (operator-carrying
intermediates head back into Wick, which does not ingest the wrappers);
the fold merges Re/Im-wrapped summands at entry and exit via
Re(x*) = Re(x) / Im(x*) = -Im(x) over canonical representatives, so
wrappers from different passes combine and cancel exactly; Re/Im
canonicalize their inner in place (+ real-scalar hoist via the byproduct
contract) and Product invalidates its memoized hash when a subfactor
mutates; has_tensor sees through the wrappers. Tests: fold-shape
assertions compare canonically, UCC energy term counts updated for the
folded pairs (46->23, 20->14, 74->41), test_mbpt_cc's shadowing
has_tensor lambda delegates to the core one, re_im_evaluation pins its
symbolic environment.
Fragile back-compat variant: it asserts (unverifiably) that the sum's
value is real and silently leaves {s, -s*} difference pairs unfolded.
Use fold_conjugate_pairs. Removal deferred until MPQC's CC energy path
switches over.
get_unique_indices: a scalar-valued Re/Im wrapper has no external indices
(the CSV/PNS pipeline feeds folded energy expressions through it).
binarize_re_im: the wrapper node carries a Variable placeholder like every
other non-leaf scalar node -- binarize(ResultExpr)'s scalar path set_labels
the root's Variable, and the previous RealPart-typed expr made that a
wrong-type downcast (UB under an IGNORE-assert build).
An opaque wrapper came back untouched from optimize_impl, leaving its
inner product to evaluate in naive left-to-right order (measured 14.4 GB
vs 1.7 GB peak RSS on a Kramers-CSV MP2 energy with three folded terms).
Optimize the wrapped expression and re-wrap; regression-tested for Re,
Im, and a wrapped summand inside a Sum.
Adjoint nodes are gone (retrieval-time CanonTransform); the report's
full_expr now renders the RealPart/ImagPart unary wrappers instead.
…ia lazy TA conj, T20)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AkZPie5Up85F78r94P4jQs
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.

1 participant