Changelog

All notable changes to SecondQuantizedAlgebra.jl will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[v0.10.0]

Numeric conversion (to_numeric/numeric_average/expect) was redesigned to be extensible, type-stable, and multi-backend. This is a breaking release.

Added

  • A second numeric backend, QuantumToolbox.jl, alongside QuantumOpticsBase.jl. Both are wired in through Julia package extensions and selected with the backend singletons QuantumOpticsBackend() / QuantumToolboxBackend().

  • A uniform Hilbert-space entry point to_numeric(op, h::HilbertSpace, dims; backend, parameter, time_parameter, operators, adjoint_ops, op_type). It builds the backend basis from dims (Fock cutoff / spin number) and is the only form that works for both backends. The backend defaults to the single loaded one.

  • Open backend hooks numeric_operator, numeric_basis, numeric_subbasis, numeric_embed, numeric_identity, numeric_num_subsystems, numeric_assemble, numeric_assemble_td, numeric_materialize, numeric_expect, and numeric_backend are exported. Downstream packages can implement another backend and add numeric support for existing operator roles; OpKind remains a closed symbolic-role set.

  • Differentiable control: a time_parameter value may be a (p, t) -> value function of the solver parameter vector. On QuantumToolbox this yields a QobjEvo differentiable with respect to p (SciMLSensitivity with Enzyme/Mooncake), enabling gradient-based optimal control via sesolve/mesolve; QuantumOptics rejects the form. See the Kerr-resonator control example.

  • Some TTFX motivated changes and more complicated precompile workload. #223

Changed (breaking)

  • QuantumOpticsBase moved from a hard dependency to a weak dependency. Using the numeric API now requires loading a backend: add using QuantumOpticsBase (or using QuantumToolbox) next to using SecondQuantizedAlgebra. The lightweight QuantumInterface.jl is a new hard dependency (it owns the /tensor/expect/basis generics that the algebra extends).
  • The time-dependent form (time_parameter non-empty) returns the backend's native time-dependent operator: a TimeDependentSum (QuantumOptics) or a QobjEvo (QuantumToolbox), both directly consumable by mesolve/master_dynamic/sesolve. It is no longer a t -> op(t) closure.
  • Time-dependent conversion accepts only op_type=nothing or identity; eager op_type materializers are rejected because a time-varying operator cannot be materialized once during conversion.
  • Product-space dims must have exactly one entry per symbolic subspace. Indexed numeric layouts validate that every physical slot is unique and in range instead of silently collapsing multiple sites onto a simple basis.

Changed

  • Averages of provably Hermitian operators (adjoint(A) == A) now carry symtype === Real instead of Number. This gives a faster simplify path and makes conj(⟨a'a⟩) fold to ⟨a'a⟩ rather than an inert conj(...) wrapper; indexed sums and lifted time-dependent variables inherit the typing, which survives substitute. Resolves #171.

Fixed

  • An elementary function of a literal zero left unevaluated by Symbolics (e.g. the exp(0) factor produced when exp(im*ω*t) is Euler-expanded to cos(ω t)*exp(0) + exp(0)*im*sin(ω t)) is now folded to its exact value in the coefficient algebra, so it no longer leaks into printed equations. Only exact identities at argument 0 fold (exp/cos/cosh → 1, sin/tan/sinh/tanh → 0); non-zero or non-constant arguments (exp(2), sin(π)) stay symbolic.

[v0.9.4]

Fixed

  • LaTeX rendering of an index whose name carries numeric per-slot suffixes no longer produces an invalid double subscript. As the pipeline transforms an index it accumulates suffixes through (i::Index)(k) (for example an index reaches i_2_1 when a second distinct atom i(2) is later collapsed to its position-1 representative), and _latex_index_suffix emitted the name verbatim as _{i_2_1}. The unbraced _2_1 is a double subscript that MathJax rejects with "Double subscripts: use braces to clarify", so the whole equation was left unrendered in Documenter pages and notebooks. The slot numbers are now joined into a single comma subscript (i_2_1 renders as i_{2,1}); bare index names are unchanged.

Added

  • CollectiveNLevelSpace and CollectiveTransition provide an exact collective description of $N$ identical multilevel systems in the permutation-symmetric subspace. A collective transition represents $S^{ij} = \sum_k |i\rangle_k\langle j|$ and obeys the closed $\mathfrak{su}(n)$ algebra $[S^{ij}, S^{kl}] = \delta_{jk}S^{il} - \delta_{li}S^{kj}$, including the two-term case $[S^{12}, S^{21}] = S^{11} - S^{22}$. Integer and symbolic levels, simple and product-space constructors, adjoints, fundamental-operator generation, Unicode/LaTeX printing, and the is_collective_transition predicate are supported. Collective transitions deliberately cannot be site-indexed and do not use the single-site completeness expansion: $\sum_i S^{ii} = N I$, rather than $I$.
  • to_numeric converts a CollectiveTransition on a QuantumOpticsBase.ManyBodyBasis with an NLevelBasis one-body basis through manybodyoperator. This gives exact finite-$N$ dynamics in a symmetric space of dimension $\binom{N+n-1}{n-1}$ and works unchanged inside composite bases. For two levels it reproduces the spin-$N/2$ representation, with $S^{12}=S_+$, $S^{21}=S_-$, and $(S^{11}-S^{22})/2=S_z$ in the QuantumOpticsBase convention.
  • OP_COLLECTIVE_TRANSITION extends the public OpKind tags with value 7. It is appended after OP_MOMENTUM, preserving the values 0:6 and structural ordering keys of every existing operator role.
  • A public constructor and body accessor for the moment-layer indexed-sum node, completing the read/write API alongside the existing is_indexed_sum, has_sum_metadata, get_sum_indices, and get_sum_non_equal. SecondQuantizedAlgebra.indexed_sum(body, indices; non_equal = NonEqualPair[]) wraps an averaged body in a sum node over indices (mirroring what average builds for a summed QAdd), and SecondQuantizedAlgebra.get_sum_body(x) reads the summed body back out. This lets downstream code (for example cumulant expansion factorizing a summed moment into a product form) re-wrap a body while preserving its summation scope. Resolves #209.
  • Public accessors and rebuilders for Op/Index internals, so downstream packages no longer reach into struct fields or the private constructors. operator_index(op) reads an operator's site Index; acts_on(idx::Index) returns an index's subspace as a one-element Vector{Int} (matching the return type of acts_on(::QSym)); set_acts_on(op, aon) rebinds an operator to a new subspace; and SecondQuantizedAlgebra.rename(x, name) renames an Op or an Index in place of the internal Op/Index constructor (preserving every other field). This is the SecondQuantizedAlgebra half of QuantumCumulants issue #300.

Changed

  • The internal _commute_pair hook now carries up to two residual terms. Fock, phase-space, and spin operators continue to use one residual slot; collective transitions use both when both Kronecker deltas contribute. The eager commute pass and the leaf commutator fast path retain and combine both branches.
  • Passing a String where a name Symbol is expected now throws a clear ArgumentError telling the user to use a Symbol (:x), instead of a cryptic MethodError. This covers Hilbert-space constructors (FockSpace, NLevelSpace, PauliSpace, SpinSpace, PhaseSpace), operator constructors (Destroy, Create, Transition, Pauli, Spin, Position, Momentum), and the index-related constructors Index, IndexedVariable, and DoubleIndexedVariable. Names remain Symbols by design: a single canonical name type keeps name comparison and interning type-stable, so strings are rejected rather than silently converted.

Documentation

  • Added a guide comparing indexed sums with exact collective symmetric-subspace operators, developer documentation for the two-residual commute contract and the collective-transition/spin relationship, and a finite-$N$ Tavis–Cummings example using ManyBodyBasis

[v0.9.3]

Fixed

  • The diagonal split in _accumulate_with_diag! no longer leaks the diagonal coefficient into the off-diagonal body when a summation index appears only in a term's coefficient. Multiplying a sum such as Σ_k u(l,k)·σ_l by an operator peels off the diagonal k = l slice, but the substitution dropped the split's k ≠ l constraint instead of rewriting it onto the collapsed index. A sibling scope then re-admitted k = l, so the off-diagonal body's coefficient became u(l,k) + u(l,l) and the diagonal was over-counted (for example commutator(σ_l^{22}, Σ_{j,k} u(j,k)(σ_j^{21}+σ_j^{12})) gained a spurious u(l,l) in every Σ_k term). The diagonal contribution now rewrites each sum_idx ≠ β constraint onto ext_idx, exactly as the Σ diagonal split already does, so the off-diagonal body keeps its bare coefficient and the diagonal is emitted once. This is the SecondQuantizedAlgebra half of QuantumCumulants issue #198.

[v0.9.2]

Changed

  • to_numeric now returns a concrete operator by default, and its representation depends only on op_type, not on the shape of the expression. Previously a bare operator translated to a LazyTensor and a sum to a LazySum (via the positional path), while the keyword path materialized sparse only in some branches, so composing subexpressions silently changed the return type and operators handed to solvers were lazy by default. The default op_type is now sparse (was identity), applied uniformly to bare operators, products, and sums. Pass op_type=dense for a dense operator, or op_type=identity to opt into the natural lazy representation (LazyTensor/LazyProduct/LazySum) for large tensor-product spaces where an operator is local to a few subsystems. The lazy embedding is kept internal, so a term is still assembled from lazy factors and materialized exactly once. Code relying on the previous lazy default must now pass op_type=identity.

[v0.9.1]

Fixed

  • Printing a sum of averages whose operators have different concrete types (for example ⟨a'a⟩, which wraps a QAdd, alongside ⟨σ⟩, which wraps a QSym) no longer errors. When SymbolicUtils sorts such a sum for display it unwraps the operator constants and falls back to comparing the operator types, which previously threw MethodError: no method matching isless(::Type{QAdd}, ::Type{...}). QField types now compare by name, so heterogeneous operator sums render as before.

[v0.9.0]

Added

  • substitute now replaces operators as well as scalar parameters in a single, simultaneous pass. A rule dictionary is split by key: QSym keys replace operator leaves, every other key substitutes into coefficients through SymbolicUtils. Operator replacements are applied once to the original leaves and the replacement expression is not searched again for operator keys, so a mode transformation such as a => g*a + h*b is well-defined even though its right-hand side contains the key a. Missing adjoint rules are generated automatically (a => b also implies a' => b'); pass replace_adjoint=false to match only the keys supplied explicitly.
  • A keyword form of to_numeric(op, basis; parameter, time_parameter, operators, adjoint_ops, op_type). Scalar parameters are substituted first, then the expression is translated with custom numeric operators (missing adjoints are added when adjoint_ops=true), and each emitted operator is passed through op_type. When time_parameter is non-empty the result is a closure t -> op(t); its values may be numbers or functions of time, and a key may be a bare variable v or conj(v). A vector form to_numeric(ops::AbstractVector, basis; kwargs...) forwards the keywords to each element.
  • to_num(c::Coeff) is now public and documented as the supported way to read the coefficient returned when iterating a QAdd. Coeff and CNum are now declared public, and the package's public-API declarations use SciMLPublic.jl instead of a hand-rolled @public macro.

Changed

  • The ordering of the displayed expressions are now ordered first by Hilbert space and then by operator kind.

[v0.8.3]

Changed

  • average of a many-term operator is now O(n) instead of O(n²): a 500-term sum drops from about 31 ms to 9.6 ms.
  • Wide symbolic prefactors (large sums or products of parameters, as high-order perturbation theory generates) build in one pass instead of pairwise, O(n²) → O(n log n): at n=800 about 12.5× faster for a sum, 17× for a product.

[v0.8.2]

Changed

  • commutator(::QAdd, ::QSym) and commutator(::QAdd, ::QAdd) on operators without summation indices now stream each term-pair contribution straight into one shared result dict instead of materializing intermediates. The previous code wrapped every term of the left operand in a temporary single-term QAdd and built a*b, b*a, and their difference as separate QAdds before merging into the accumulator, so a commutator over an n-term operand allocated O(n) throwaway dicts (O(n·m) for two n- and m-term sums). The fast path emits +tₐ·t_b and −t_b·tₐ directly through _emit_product!, which already runs the eager canonicalization, and lets the dict collect like terms. Operators that carry summation indices keep the existing path, where the per-term _absorb_pinned_sums index-scope bookkeeping must run. Results are byte-identical to a*b - b*a. Measured on the many-mode operator Σ_{i,j} g aᵢ† aⱼ: QAdd×QSym about 1.9× to 2.2× faster with about 5× less memory, and QAdd×QAdd about 2.2× faster with about 5× less memory at M=4/8/12.

Fixed

  • conj now folds as an involution in the coefficient algebra and in qadjoint. Conjugating a complex factor twice (or taking the adjoint of a scalar conj(x)) returned a nested conj(conj(x)) that never simplified and survived downstream, leaving dead terms (e.g. a conj(0.0) that failed to collapse to zero after a complex coupling was substituted to a real value). Double conjugation now returns the original factor, and conj of an expression that reduces to a constant folds to a native coefficient.

[v0.8.1]

Added

  • sum and reduce(+, …) over a Vector{QAdd} now accumulate in place instead of folding with Base.:+. Building an n-term QAdd by repeated addition is O(n²) because every + copies the whole backing dict before inserting; the new _QAddBuilder (exposed through the MutableArithmetics.jl interface, so MA.@rewrite and operate!! loops work too) threads one shared dict through the entire reduction and materializes once. Results are byte-identical to the +-chain and Base.:+ value semantics are unchanged. The fast path is the bracketed comprehension sum([ω[k]*a[k]'*a[k] for k in 1:M]) (a bare generator stays on the generic fold). Measured on the many-mode Hamiltonian Σ_k ω_k a_k† a_k: about 3.9×/5.9×/11.8× faster and 5.5×/6.2×/11.4× less memory at M=8/16/24, the win growing with system size. The product path is intentionally left as repeated * (a prototype builder showed no win; the intrinsic distributive expansion dominates), see the devdocs.

Changed

  • hash(::Op) short-circuits the common non-indexed case. A profile of the product path found that about a third of the time goes to hashing QTerm dict keys, and hash(::Op) was recursing through the shared NO_INDEX constant's Num fields on every call even though most operators carry no index. The hash now folds NO_INDEX to a precomputed sentinel and only hashes a real Index when one is present. The per-operator hash is about 31% faster and term-building operations (products, powers, commutators, substitute) are about 5% to 6% faster end-to-end; for example (a + a†)^12 drops from about 379 µs to 344 µs. Hash values are internal dict keys only (never persisted) and isequal is unchanged, so canonical results are identical.

[v0.8.0]

Changed

  • Breaking: the seven operator types collapsed into one concrete Op. Destroy, Create, Transition, Pauli, Spin, Position, and Momentum were separate QSym subtypes; they are now a single concrete struct Op <: QSym carrying a kind::OpKind tag plus shared packed fields. The role names stay as constructor functions, so every existing Destroy(h, :a) or Transition(h, :σ, 1, 2) call still builds the right operator with no source change. What breaks is type-level dispatch: code that branched on isa Destroy (or used ::Transition method signatures, or typeof(op) == Pauli) must switch to the exported predicates is_destroy, is_create, is_transition, is_pauli, is_spin, is_position, is_momentum, or read the role with optype(op)::OpKind. Direct field reads also moved: a Transition's i/j/ground_state/n_levels are now l1/l2/g/nlev, and a Pauli/Spin axis is l1. Op and the predicates are exported. Because operator products are now stored as a concrete Vector{Op} (QTerm.ops), the per-operator hooks (_site_compare, _can_commute, _commute_pair, _reduce_pair, hash) dispatch and inline statically instead of through the former abstract-QSym dynamic dispatch, and _commute_pair/_reduce_pair return concrete Tuple{Op, …} with no tuple boxing; custom hash/isequal on Op exclude the index's Num fields. Measured speedups over the v0.7.1 baseline from the operator collapse alone: Fock (a·a†)^10 about 2.7×, single-mode H^4 about 2.1×, many-mode H^2 (M=8) about 1.7×, with roughly 30% fewer allocations on the Fock-dominated cases (#164).

  • Parameter-polynomial coefficient arithmetic (the Poly tail of Coeff) allocates less: single-term times single-term (the common ω·J case) skips the sort/compact pass, term canonicalisation compacts its owned buffer in place instead of allocating a second vector, and a scalar-times-term product shares the other operand's factor vectors instead of re-merging. Results are byte-identical (canonical form unchanged) and the arithmetic stays fully type-stable. This cuts about 18% of allocations on many-mode H^2 (M=8) and 11% on single-mode H^4, bringing the combined speedup over v0.7.1 to about 1.9× and 2.2× on those cases (#164).

  • Breaking: Op and Index are now isbits via interned integer ids (#137). Operator and index names are stored as an interned name_id::Int32 (resolved back to a Symbol with the new operator_name(op) / index_name(idx) accessors), and an index's summation range as an interned range_id::Int32 (recovered as the original Num with the new index_range(idx) accessor). Index drops its range::Num and sym::Num fields: the symbolic variable is reconstructed on demand by the new index_sym(idx) (SymbolicUtils hashconsing makes it the same object, so substitution and get_variables are unchanged), and a per-site slot::Int32 replaces the per-slot/numeric sym payload. With every field a bits type, Op is isbits at 44 bytes (down from 72) and QTerm.ops::Vector{Op} is a dense inline buffer the GC never scans, hashed and compared on pure integers. Canonical ordering is preserved exactly and stays deterministic across sessions via a lexicographic name-rank table (interned ids are insertion-order; ranks are name-order). What breaks: code reading op.name, idx.name, idx.range, or idx.sym directly must switch to operator_name/index_name/index_range/index_sym; Op.space_index and Index.space_index are now Int32. The intern tables are module-global, written only at construction under a lock; their ids are not stable across sessions (never serialize them). Measured end-to-end speedups over v0.8.0: Fock (c·c')^5 normal-order about 1.4×, indexed Jaynes-Cummings H simplify about 1.3×, nested-commutator and simplify about 1.1–1.2×, with no regressions.

[v0.7.2]

Changed

  • Complex parameters and irreducible scalar couplings now stay on the native Poly coefficient path instead of escalating to the Complex{Num} symbolic tail, where every later product/sum round-tripped through SymbolicUtils hashconsing. _is_atom/_rec now recognize any non-algebraic one-argument call on an atom (real(g), imag(g), exp, sin, ...) as a single opaque polynomial atom; a @variables g::Complex parameter (stored as Complex(real(g), imag(g))) therefore no longer poisons downstream coefficient arithmetic. Conjugation of a Poly coefficient is now native (_conj_poly: conjugate scalars and atoms, re-canonicalize) rather than materializing each term through to_num. On the downstream QuantumInputOutput.jl SLH cascade benchmark this lowers a 3-cavity hamiltonian/lindblad build from about 140 µs to about 7.6 µs (roughly 18×).
  • Radicals of a single parameter now canonicalize on the native Poly path: Monomial exponents are Rational{Int}, so sqrt(p) is stored as p^(1//2) (likewise cbrt(p) as p^(1//3), and p^(m//n)). Building sqrt(p) and squaring it folds to p natively, matching the sqrt(p)^2 -> p fold Symbolics performs at the Num level; previously sqrt(p) was an opaque integer-exponent atom, so the two construction paths produced distinct, non-isequal coefficients and QAdd like-term dedup could miss merges. A radical of a non-atom (sqrt(g*κ), (g+κ)^(1//2)) is not distributed (unsound in general) and stays a Complex{Num} symbolic leaf, consistent with how Symbolics treats it.

[v0.7.1]

changed

  • The per-term operator machinery is faster on product and power workloads, lowering the floor that remained after the Coeff rework. QTerm now caches its hash in a field computed once at construction, so the repeated dict probes, inserts, and growth-rehashes that key every canonicalization no longer re-walk the operator vector (whose abstract QSym eltype forces a dynamic dispatch per element); isequal also short-circuits on the cached hash before comparing operators. The reduce pass gained a type-domain _may_reduce gate consulted before _reduce_pair: only same-type Transition and Pauli pairs can compose, so every Fock, Spin, and PhaseSpace pair (the bulk of bosonic products) now skips the dynamically dispatched _reduce_pair call whose (kind, op, factor) tuple would otherwise box on each adjacent pair. Measured speedups over the post-Coeff baseline: Fock (a·a†)^10 about 1.74×, many-mode H^2 (M=8) about 1.81×, single-mode H^4 about 1.54×, with roughly half the allocations (#141, #164).

[v0.7.0]

Fixed

  • Averaging an indexed sum whose coefficient depends on the summation index no longer drops the scope or lets the index dangle outside the Σ. average previously stamped SumIndices/SumNonEqual metadata on the average leaf and emitted coeff * leaf; SymbolicUtils discards metadata on composite/numerically-scaled nodes, so average(Σ(u(kk,k), k)) collapsed to a bare u(kk,k) and an index-dependent coefficient was hoisted outside the sum. Index-dependent terms are now wrapped in a dedicated moment-layer node (SumFunc/sym_sum) carrying the summation scope as a SumScope argument, so the whole averaged body stays inside the sum and the representation survives Add/Mul canonicalization. Because the scope rides as an argument (not metadata, which isequal/hash ignore), differently-scoped sums over the same body no longer wrongly cancel in a subtraction. New is_indexed_sum predicate; has_sum_metadata/get_sum_indices/get_sum_non_equal retained and now read the node (#175).

Changed

  • make_time_dependent on an averaged indexed sum now yields Σ(i) ⟨a_i⟩(t) (per-site time-dependent moments under the sum) instead of ⟨Σ_i a_i⟩(t) (one collective lumped variable), matching the non-lifted display Σ(i) ⟨a_i⟩ and giving indexable per-site unknowns for indexed equations.
  • Operator prefactors are stored as a concrete Coeff with three forms (a native ComplexF64 fast path, a sparse parameter polynomial for products and sums of named parameters, and a Complex{Num} fallback) instead of always Complex{Num}. Numeric and parameter-polynomial coefficient arithmetic stays native and never routes through SymbolicUtils hashconsing; a coefficient lowers to Complex{Num} only at the symbolic boundaries (substitute/average/printing/prefactor). The polynomial arithmetic is fully type-stable, with factor identity via objectid/=== (which assumes SymbolicUtils hashconsing is enabled, the default). Polynomial coefficients are kept in canonical expanded form, so (g+h)^2 is stored as g^2 + 2*g*h + h^2. Measured speedups over Complex{Num}: numeric power expansion about 2.1×, single-mode H^4 about 2.65×, many-mode H^2 about 3.5×, nested commutator about 2.4× (#164, #183).

[v0.6.5]

Added

  • @variables η::Number declares a complex coefficient that stays a single atomic symbol, conjugating to the symbolic conj(η). Unlike ::Complex (which splits into independent real/imaginary unknowns and pays (a+bi)(c+di) expansion on every product), ::Number keeps coefficient arithmetic on one symbol (η * η → η²). Adjoint routes coefficients through a symtype-aware _conj_cnum, so (η a)†(η a) correctly carries |η|² = conj(η)·η, and to_numeric reduces such coefficients (e.g. an unfolded conj of a complex literal) via build_function.

[v0.6.4]

Fixed

  • change_index now correctly zeros DoubleIndexedVariable nodes tagged identical=false even when they appear nested inside a larger product or sum. Previously _check_not_identical only examined the root of the substituted expression, so a J(i,j) factor inside J(i,j) * x would survive a j → i substitution intact instead of collapsing to zero. The fix walks the full expression tree, collects every NotIdentical-tagged node whose two index arguments became equal, and replaces them all via a single Symbolics.substitute pass.

[v0.6.3]

Changed

  • Lifted time-dependent averages now display as ⟨op⟩(t) in both the REPL and latexify output, instead of exposing the internal _avg_… variable name. The AverageOperator metadata drives the rendering through new show_metadata/_toexpr_metadata hooks, so a moment's MTK unknown reads the same as its average (#173).

[v0.6.2]

Added

  • index_slot (public): recover the concrete position k of a per-slot index symbol minted by (i::Index)(k), read from metadata rather than parsed out of the symbol's name. (i::Index)(k) now stamps k as metadata; since symbol equality and hashing ignore metadata, a per-slot index still dedup-equals its name-only counterpart (#170).

[v0.6.1]

Added

  • order_key, term_order_key, qadd_order_key (public): a total, identity-faithful structural ordering for operators, products, and sums, built from one per-type order_key method. The key ties two operators exactly when they are isequal, giving downstream packages a reproducible way to pick canonical representatives and compare expressions without round-tripping through show or hash (#169).

[v0.6.0]

Changed

  • Average expressions now use Number as their Symbolics symtype, and the new make_time_dependent helper lifts them into ModelingToolkit-style time-dependent unknowns while preserving average metadata for round-tripping.

[v0.5.2]

Fixed

  • Commuting commutators with symbolic rational coefficients no longer bloat derived expressions (#162). Deriving [Hₖ, op] for an Hₖ that commutes with op should vanish, but with a rational coupling such as γ/|xᵢ-xⱼ|³ the two halves land as γ/D + (-γ)/D, which Symbolics leaves un-combined and _iszero_cnum does not see as zero; the spurious term then survives and inflates every downstream RHS. Fixed at two levels:
    • _addto_key! cancels exact-negation prefactor pairs for symbolic coefficients (the numeric path stays allocation-free).
    • commutator distributes over terms and skips disjoint-subspace pairs ([aₖ, bₗ] = 0), so the cancelling products are never formed.

[v0.5.1]

Fixed

  • Σ now propagates index-inequality constraints onto the diagonal when a summed index collapses onto an external one. When splitting the diagonal of Σ_{i≠j} σᵢ²¹ σₖ¹², the i = k contribution now inherits k ≠ j instead of dropping it. Previously the constraint was discarded, letting a later sum re-admit the k = j point and double-count the diagonal of nested and ollective double sums (#161).

Changed

  • LaTeX rendering for averaged expressions now uses \langle \cdots \rangle, and averaged indexed sums preserve their \sum prefix in latexify output via the new Symbolics LaTeX hooks (#153).

Documentation

  • Document why alpha-equivalent sums (Σᵢ σᵢ + Σⱼ σⱼ) are not auto-collected and clarify the bound-index naming policy in the developer docs; add a "read the devdocs first" note for contributors ([#156]). Resolves [#134].

[v0.5.0]

Breaking release: redesign of the algebraic core. Most public entry points keep their names and meaning. The substantive changes fall into three groups: direct renames, constructs replaced by more general machinery, and behavioural changes in shared API names. This entry is the migration reference for users with code written against SecondQuantizedAlgebra.jl v0.4 or QuantumCumulants.jl, which shares the v0.4 API surface.

Renamed

One-to-one renames. Replace the left column with the right column anywhere it appears in your code.

v0.4 / QuantumCumulants.jlv0.5
reorder(expr, [(α, β), …])assume_distinct_index(expr, [(α, β), …])
simplify(expr, h)expand_completeness(simplify(expr))
normal_order(expr, h)expand_completeness(normal_order(expr))
@cnumbers a b c@variables a b c (reexported from Symbolics)
@rnumbers a b c@variables a::Real b::Real c::Real
Parameter(:x)@variables x and use x

Removed

A handful of constructs that lived at the type level in v0.4 have been replaced by mechanisms that scale better and avoid bespoke types.

ClusterSpace. Indexed families of identical subsystems are now expressed through the Index and Σ machinery. Where v0.4 wrote

hc = ClusterSpace(NLevelSpace(:atom, 2), N, k)
σ = Transition(hc, :σ, 1, 2)
H = Δ * sum(σ' * σ)

v0.5 writes

ha = NLevelSpace(:atom, 2)
i  = Index(ha, :i, N, ha)
σ(i) = IndexedOperator(Transition(ha, :σ, 1, 2), i)
H = Δ * Σ(σ(i)' * σ(i), i)

N stays symbolic through equation derivation. The cumulant truncation order, which used to be the k parameter on ClusterSpace, is now a property of the moment expansion and is passed to the downstream solver (e.g. meanfield(…; order=k) in QuantumCumulants.jl) rather than baked into the Hilbert space.

SingleSum / DoubleSum / SpecialIndexedTerm. These wrapper types are gone. Σ(expr, i) constructs a QAdd directly, with the summation index recorded on the QAdd.indices field and per-term inequality constraints recorded on each term's ne vector. The SpecialIndexedTerm role (carrying (α, β) ∈ ne constraints attached to a single product) is filled by the per-term ne field. Code that pattern-matched on these types should iterate the resulting QAdd and inspect term.ne directly.

NumberedOperator / insert_index. v0.5 keeps indices symbolic throughout. Numeric basis instantiation for an indexed family of operators is the responsibility of the downstream numeric layer, not the algebra.

IndexedAverageSum / IndexedAverageDoubleSum. Averaging an indexed QAdd now lifts the indices and per-term ne onto the resulting BasicSymbolic as metadata via SymbolicUtils.setmetadata. There is no dedicated indexed-average type; the same averaging machinery handles indexed and non-indexed cases uniformly.

QMul. All arithmetic returns QAdd. Code that branched on QMul-vs-QAdd can drop the branch: multiplication is uniform-return-type. Iteration over a QAdd yields Pair{QTerm, CNum}; reach into term.ops and term.ne directly for the operator string and its constraints.

order_by_index. Operators with symbolic indices on the same Hilbert subspace are sorted automatically when _partial_sort! can resolve their relationship (sum bound, declared inequality). When the relationship is undetermined, declare the pairs with assume_distinct_index to trigger the sort.

Changed

A few API names survive but their behaviour has shifted. The differences are intentional and pay off in correctness or performance, so be aware of them when porting tests.

σᵍᵍ no longer expands automatically. In v0.4, every product passing through * rewrote ground-state projectors via σᵍᵍ = 1 - Σ_{k≠g} σᵏᵏ. In v0.5, σᵍᵍ is a legitimate canonical-form atom and stays atomic through *, normal_order, and simplify. Use expand_completeness(expr) to materialise the identity explicitly. The change preserves like-term collection across operations and avoids an n_levels^k blow-up for products containing many ground-state factors.

simplify now includes coefficient simplification. In v0.4, simplify applied algebraic identities to operators only. In v0.5, simplify is normal_order followed by Symbolics.simplify on each surviving coefficient and a drop of summation indices that no surviving term depends on. The expensive symbolic simplification deliberately runs once per surviving term rather than on every internal dict insertion, so the cost shows up at the simplify call site rather than inside *.

Transition carries ground_state and n_levels. The constructor signature Transition(h, :σ, i, j) still works and infers these from the surrounding NLevelSpace, but the fields now live on the operator itself rather than being looked up on demand. Downstream code that previously had to consult h to expand completeness no longer needs the Hilbert space.

Free indices stay in physical order under *. Two operators carrying different symbolic indices on the same Hilbert subspace, with no Σ binding either and no declared inequality, are stored in their physical order rather than being sorted alphabetically by name. Declare the pairs with assume_distinct_index(q, [(j, k)]) to trigger sorting (and any same-site collapse that follows).

Migration

Side-by-side, an indexed Tavis-Cummings Hamiltonian.

# v0.4 / QuantumCumulants.jl
hc = FockSpace(:cavity)
ha = NLevelSpace(:atom, 2)
h  = hc ⊗ ha
@qnumbers a::Destroy(h, 1)
σ(i, j, k) = IndexedOperator(Transition(h, :σ, i, j), k)
@cnumbers N Δ
g(i) = IndexedVariable(:g, i)
i = Index(h, :i, N, ha)
H = -Δ * a' * a + Σ(g(i) * (a' * σ(1, 2, i) + a * σ(2, 1, i)), i)

# v0.5
hc = FockSpace(:cavity)
ha = NLevelSpace(:atom, 2)
h  = hc ⊗ ha
@qnumbers a::Destroy(h, 1)
σ(i, j, k) = IndexedOperator(Transition(h, :σ, i, j, 2), k)
@variables N Δ
g(i) = IndexedVariable(:g, i)
i = Index(h, :i, N, ha)
H = -Δ * a' * a + Σ(g(i) * (a' * σ(1, 2, i) + a * σ(2, 1, i)), i)

The differences are local: @cnumbers becomes @variables, and Transition takes an explicit n_levels argument (2 for a two-level atom). Everything downstream (commutators, averaging, equation derivation) uses the same names with the same meaning.

Unchanged

These names keep their meaning across the migration. Code that only uses them should port without changes:

FockSpace, NLevelSpace, PauliSpace, SpinSpace, PhaseSpace, ProductSpace, , tensor, @qnumbers, Destroy, Create, Transition, Pauli, Spin, Position, Momentum, Index, IndexedOperator, IndexedVariable, DoubleIndexedVariable, Σ, , change_index, get_indices, commutator, anticommutator, acts_on, find_operators, fundamental_operators, unique_ops, prefactor, operators, substitute, expand, normal_order, simplify, normal_to_symmetric, symmetric_to_normal, average, undo_average, is_average, to_numeric, numeric_average.

<!– Links generated by Changelog.jl –>

[v0.5.0]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.5.0 [v0.5.1]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.5.1 [v0.5.2]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.5.2 [v0.6.0]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.6.0 [v0.6.1]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.6.1 [v0.6.2]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.6.2 [v0.6.3]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.6.3 [v0.6.4]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.6.4 [v0.6.5]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.6.5 [v0.7.0]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.7.0 [v0.7.1]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.7.1 [v0.7.2]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.7.2 [v0.8.0]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.8.0 [v0.8.1]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.8.1 [v0.8.2]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.8.2 [v0.8.3]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.8.3 [v0.9.0]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.9.0 [v0.9.1]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.9.1 [v0.9.2]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.9.2 [v0.9.3]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.9.3 [v0.9.4]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.9.4 [v0.10.0]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/releases/tag/v0.10.0 [#156]: https://github.com/qojulia/SecondQuantizedAlgebra.jl/issues/156