Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# News

## v0.4.17 - 2026-06-14

- Add a `lazy=true` option to `QuantumOpticsRepr`. When set, `express` returns `LazySum`, `LazyProduct`, and `LazyTensor` for symbolic operator sums, products, and tensor products (and lazy sums of products for commutators and anticommutators) instead of materializing dense matrices. The lazy output is available when the running QuantumInterface provides the `lazy` field (v0.4.3 or later).

## v0.4.16 - 2026-04-01

- Add `QuantumClifford.Register` symbolic `apply!` methods to the `QuantumClifford` extension.
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "QuantumSymbolics"
uuid = "efa7fd63-0460-4890-beb7-be1bbdfbaeae"
authors = ["QuantumSymbolics.jl contributors"]
version = "0.4.16"
version = "0.4.17"

[workspace]
projects = ["benchmark", "docs"]
Expand Down
29 changes: 28 additions & 1 deletion docs/src/express.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,31 @@ Operator(dim=5x5)
0.0+0.0im 0.0+0.0im 2.0+0.0im 0.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 3.0+0.0im 0.0+0.0im
0.0+0.0im 0.0+0.0im 0.0+0.0im 0.0+0.0im 4.0+0.0im
```
```

## Lazy Operator Output

By default `QuantumOpticsRepr` materializes dense or sparse matrices. For expressions that
carry useful structure, such as a Hamiltonian written as a sum of local tensor-product
terms, passing `lazy=true` instead builds the lazy operators of `QuantumOpticsBase`. Symbolic
sums, products, and tensor products are translated to `LazySum`, `LazyProduct`, and
`LazyTensor`, which postpone the matrix construction (commutators and anticommutators become
lazy sums of products):

```julia
julia> H = σˣ⊗σˣ + σʸ⊗σʸ;

julia> lazy = express(H, QuantumOpticsRepr(lazy=true))
LazySum(...)

julia> lazy isa LazySum
true

julia> dense(lazy) ≈ express(H, QuantumOpticsRepr()) # agrees with the eager conversion
true
```

The bosonic cutoff still applies, so `QuantumOpticsRepr(cutoff=4, lazy=true)` keeps the
finite Fock dimension for each factor. A tensor factor that itself spans several subsystems
cannot be placed in a `LazyTensor`, and that tensor product falls back to the eager
construction.
63 changes: 63 additions & 0 deletions ext/QuantumOpticsExt/QuantumOpticsExt.jl
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ using QuantumSymbolics:
NumberOp, CreateOp, DestroyOp,
FockState,
MixedState, IdentityOp,
SAddOperator, SMulOperator, STensorOperator, SCommutator, SAnticommutator,
qubit_basis
import QuantumSymbolics: express, express_nolookup
using TermInterface
Expand Down Expand Up @@ -100,6 +101,68 @@ express_nolookup(p::PauliNoiseCPTP, ::QuantumOpticsRepr) = LazySuperSum(SpinBasi

express_nolookup(s::SOuterKetBra, r::QuantumOpticsRepr) = projector(express(s.ket, r), express(s.bra, r))

##
# Lazy QuantumOptics output, opt-in via `QuantumOpticsRepr(lazy=true)`.
#
# The translation follows how structured operators are assembled natively in
# QuantumOptics.jl: a sum of local tensor terms becomes a `LazySum` of `LazyTensor`s, an
# operator product becomes a `LazyProduct`, and a (anti)commutator becomes a lazy sum of
# products. A `LazyTensor` records only the non-trivial factors and leaves identities on the
# remaining subsystems implicit, so the locality of each term survives the conversion instead
# of being flattened into a dense matrix. With `lazy` unset, every method reproduces the
# eager generic conversion verbatim, so the default behaviour is untouched.
#
# Scalar prefactors are handled for free: `prefactorscalings` lifts them out of products and
# tensors at the symbolic level, and the generic fallback then multiplies the resulting lazy
# operator by the number (`LazySum`, `LazyProduct`, and `LazyTensor` each support that).
#
# `lazy` is read through `hasproperty` so the extension still loads, and stays eager, against
# QuantumInterface releases that predate the field.
##

_islazy(r::QuantumOpticsRepr) = hasproperty(r, :lazy) && r.lazy

function express_nolookup(s::SAddOperator, r::QuantumOpticsRepr)
_islazy(r) || return operation(s)(express.(arguments(s), (r,))...)
summands = collect(s.dict) # `obj => coefficient` pairs
LazySum([coeff for (_, coeff) in summands], [express(obj, r) for (obj, _) in summands])
end

function express_nolookup(s::SMulOperator, r::QuantumOpticsRepr)
_islazy(r) || return operation(s)(express.(arguments(s), (r,))...)
LazyProduct(express.(arguments(s), (r,))...)
end

function express_nolookup(s::STensorOperator, r::QuantumOpticsRepr)
_islazy(r) || return operation(s)(express.(arguments(s), (r,))...)
factors = arguments(s)
ops = express.(factors, (r,))
# A `LazyTensor` factor must live on a single subsystem of the composite basis. A factor
# that is itself multipartite (e.g. an expressed two-qubit gate) cannot be placed, so we
# keep the eager tensor product for that term.
any(o -> o.basis_l isa CompositeBasis || o.basis_r isa CompositeBasis, ops) && return ⊗(ops...)
bl = tensor((o.basis_l for o in ops)...)
br = tensor((o.basis_r for o in ops)...)
# Store only the non-identity factors; `LazyTensor` fills the omitted subsystems with
# identities, which is the structure-preserving point of the lazy backend. Identity is
# detected on the symbolic `IdentityOp` factor, avoiding a materialized comparison.
active = [i for (i, f) in enumerate(factors) if !(f isa IdentityOp)]
isempty(active) && return identityoperator(bl)
LazyTensor(bl, br, active, (ops[active]...,))
end

function express_nolookup(s::SCommutator, r::QuantumOpticsRepr)
_islazy(r) || return operation(s)(express.(arguments(s), (r,))...)
a, b = express(s.op1, r), express(s.op2, r)
LazySum([1, -1], [LazyProduct(a, b), LazyProduct(b, a)])
end

function express_nolookup(s::SAnticommutator, r::QuantumOpticsRepr)
_islazy(r) || return operation(s)(express.(arguments(s), (r,))...)
a, b = express(s.op1, r), express(s.op2, r)
LazySum([1, 1], [LazyProduct(a, b), LazyProduct(b, a)])
end

include("should_upstream.jl")

end
101 changes: 101 additions & 0 deletions test/general/lazy_express_tests.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using Test
using QuantumSymbolics

@testset "Lazy QuantumOpticsRepr expressions" begin
using QuantumOptics

# The lazy backend needs the `lazy` field that `QuantumOpticsRepr` gains in the companion
# QuantumInterface change. Against an older QuantumInterface the field is absent and the
# lazy path is unavailable, so skip rather than fail.
if !hasproperty(QuantumOpticsRepr(), :lazy)
@info "QuantumInterface has no `lazy` field on `QuantumOpticsRepr`; skipping lazy express tests."
else
eager = QuantumOpticsRepr()
lazy = QuantumOpticsRepr(lazy=true)

@testset "constructors preserved" begin
@test QuantumOpticsRepr().lazy == false
@test QuantumOpticsRepr(4).cutoff == 4
@test QuantumOpticsRepr(4).lazy == false
@test QuantumOpticsRepr(cutoff=4, lazy=true).lazy == true
end

@testset "sums" begin
op = X + Y + Z
@test express(op, lazy) isa LazySum
@test isapprox(dense(express(op, lazy)), express(op, eager))
end

@testset "scaled sums keep coefficients in the factors" begin
op = 2*X + 3*Y
lz = express(op, lazy)
@test lz isa LazySum
@test Set(Complex.(lz.factors)) == Set([2.0 + 0im, 3.0 + 0im])
@test isapprox(dense(lz), express(op, eager))
end

@testset "products" begin
op = X * Y
@test express(op, lazy) isa LazyProduct
@test isapprox(dense(express(op, lazy)), express(op, eager))
end

@testset "tensor products" begin
op = tensor(X, Y)
@test express(op, lazy) isa LazyTensor
@test isapprox(dense(express(op, lazy)), express(op, eager))
end

@testset "lazy tensors omit implied identities" begin
lt = express(tensor(X, I), lazy)
@test lt isa LazyTensor
@test length(lt.operators) == 1 # the identity subsystem is left implicit
@test isapprox(dense(lt), express(tensor(X, I), eager))

lt2 = express(tensor(I, Z), lazy)
@test lt2 isa LazyTensor
@test length(lt2.operators) == 1
@test lt2.indices == [2]
@test isapprox(dense(lt2), express(tensor(I, Z), eager))
end

@testset "sums and products of tensors" begin
op = tensor(X, I) + tensor(I, Z)
lz = express(op, lazy)
@test lz isa LazySum
@test isapprox(dense(lz), express(op, eager))

prod_op = tensor(X, I) * tensor(I, Z)
lp = express(prod_op, lazy)
@test lp isa LazyProduct
@test isapprox(dense(lp), express(prod_op, eager))
end

@testset "commutators and anticommutators" begin
σx, σy = express(X, eager), express(Y, eager)
@test express(commutator(X, Y), lazy) isa LazySum
@test isapprox(dense(express(commutator(X, Y), lazy)), dense(σx*σy - σy*σx))
@test isapprox(dense(express(anticommutator(X, Y), lazy)), dense(σx*σy + σy*σx))
end

@testset "tensor falls back to eager when a factor spans several subsystems" begin
op = tensor(CNOT, X)
lz = express(op, lazy)
@test !(lz isa LazyTensor)
@test isapprox(dense(lz), express(op, eager))
end

@testset "cutoff still applies with lazy" begin
r = QuantumOpticsRepr(cutoff=4, lazy=true)
lz = express(Create + Destroy, r)
@test lz isa LazySum
@test basis(lz) == FockBasis(4)
@test isapprox(dense(lz), express(Create + Destroy, QuantumOpticsRepr(cutoff=4)))
end

@testset "default representation stays eager" begin
@test express(X + Y, eager) isa Operator
@test express(X + Y, QuantumOpticsRepr(2, false)) isa Operator
end
end
end
Loading