Normal ordering, expectation values, and trace

This tutorial explains how the CCR layer rewrites operator expressions into normally ordered form and how scalar quantities such as overlaps, expectation values, traces, and partial traces are extracted symbolically.

The key idea is that Symop does not evaluate matrices directly. Instead, it rewrites ladder-operator words using commutation relations and keeps the result as symbolic sums of normally ordered monomials.

Background

In the CCR layer, ladder operators satisfy commutation relations of the form

\[[a_i, a_j^\dagger] = \langle m_i \mid m_j \rangle,\]

where the overlap on the right-hand side may be nontrivial when the underlying modes are not orthogonal.

A general operator word is rewritten into a sum of normally ordered monomials,

\[W = o_1 o_2 \cdots o_L \;\longmapsto\; \sum_k c_k M_k,\]

where each monomial \(M_k\) stores all creators on the left and all annihilators on the right.

This symbolic representation is the basis for:

  • ket construction and ket multiplication

  • overlaps and inner products

  • density operators and traces

  • symbolic operator actions

Setup

import symop.viz as viz

from symop.core.operators import ModeOp
from symop.modes.labels import ModeLabel
from symop.modes.labels.path import Path
from symop.modes.labels.polarization import Polarization
from symop.modes.envelopes import GaussianEnvelope

from symop.ccr.algebra.ket.poly import KetPoly
from symop.ccr.algebra.op.poly import OpPoly
from symop.ccr.algebra.density.poly import DensityPoly

env = GaussianEnvelope(omega0=1.0, sigma=1.0, tau=0.0)

mode = ModeOp(
    label=ModeLabel(
        path=Path("A"),
        polarization=Polarization.H(),
        envelope=env,
    ),
    user_label="a",
)

Normal ordering example: \(a a^\dagger\)

A simple example is the word

\[a a^\dagger.\]

From the commutation relation we expect

\[a a^\dagger = a^\dagger a + 1.\]
expr = KetPoly.from_word(ops=(mode.ann, mode.cre))
viz.display(expr)
2026-04-24T11:36:59.652301 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

The result contains:

  • an identity contribution (scalar term)

  • the reordered monomial \(a^\dagger a\)

This illustrates the core mechanism: non-normal-ordered expressions expand into normally ordered terms plus contraction contributions.

Algorithmic picture

The normal-ordering algorithm proceeds left-to-right.

For each operator:

  • annihilators are appended to the annihilation block

  • creators are appended to the creation block

  • each creator also contracts with existing annihilators

Each contraction contributes a scalar

\[\langle m_i \mid m_j \rangle.\]

This produces the exact normal-ordered expansion without constructing matrices.

Step-by-step normal ordering

It is useful to see explicitly how the symbolic rewrite proceeds on a small word. Consider

\[a_a \, a_b^\dagger.\]

There are two cases:

  • if the modes are orthogonal, the commutator vanishes and the word is only reordered

  • if the modes overlap, an additional scalar contraction term appears

For orthogonal modes, the result is just the reordered monomial:

mode_b = ModeOp(
    label=ModeLabel(
        path=Path("B"),
        polarization=Polarization.H(),
        envelope=env,
    ),
    user_label="b",
)

expr_orth = KetPoly.from_word(ops=(mode.ann, mode_b.cre))
viz.display(expr_orth)
2026-04-24T11:36:59.712727 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

Since the modes are distinct, there is no contraction and the output contains only the normally ordered term.

For the same mode, the contraction is nonzero:

expr_same = KetPoly.from_word(ops=(mode.ann, mode.cre))
viz.display(expr_same)
2026-04-24T11:36:59.770240 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

This corresponds to

\[a_a a_a^\dagger = a_a^\dagger a_a + 1.\]

Algorithmically, the rewrite can be read as:

  1. start from the empty monomial

  2. append \(a_a\) to the annihilator list

  3. insert \(a_a^\dagger\)

  4. keep the reordered term \(a_a^\dagger a_a\)

  5. contract once with the existing annihilator to produce the scalar term

In other words, normal ordering produces both:

  • the pass-through reordered monomial

  • every allowed single contraction contribution

Two-mode expectation values

Expectation values become especially informative when two modes are involved. Consider the number operator in mode \(a\) acting on a one-particle state in mode \(b\):

\[\hat n_a = a_a^\dagger a_a, \qquad |\psi_b\rangle = a_b^\dagger.\]

If the two modes are orthogonal, the expectation value should vanish:

\[\langle \psi_b | \hat n_a | \psi_b \rangle = 0.\]

Construct the state and operator symbolically:

psi_b = KetPoly.from_ops(creators=(mode_b.cre,))
n_a = OpPoly.n(mode)
psi_b_out = n_a @ psi_b
viz.display(psi_b)
2026-04-24T11:36:59.824394 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
viz.display(psi_b_out)
2026-04-24T11:36:59.869913 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

Now compute the expectation value:

psi_b.inner(psi_b_out)
0j

The result is zero because no identity contribution survives after symbolic normal ordering: the operator counts excitations in mode \(a\), but the state occupies mode \(b\).

For comparison, the number operator in the matching mode gives a nonzero value:

psi_a = KetPoly.from_ops(creators=(mode.cre,))
psi_a_out = n_a @ psi_a
viz.display(psi_a_out)
2026-04-24T11:37:00.016520 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
psi_a.inner(psi_a_out)
(1+0j)

This shows the symbolic logic of expectation-value evaluation:

  1. apply the operator polynomial to the ket

  2. expand the result into normally ordered monomials

  3. form the symbolic inner product with the original ket

  4. extract the identity contribution

Already normal-ordered input

If the input is already normal ordered, no contraction occurs.

ordered = KetPoly.from_word(ops=(mode.cre, mode.ann))
viz.display(ordered)
2026-04-24T11:37:00.088153 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

Constructing from operators vs words

  • KetPoly.from_ops assumes normal ordering

  • KetPoly.from_word performs CCR rewriting

psi_ops = KetPoly.from_ops(creators=(mode.cre,), annihilators=(mode.ann,))
psi_word = KetPoly.from_word(ops=(mode.cre, mode.ann))
viz.display(psi_ops)
2026-04-24T11:37:00.134369 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
viz.display(psi_word)
2026-04-24T11:37:00.175708 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

Scalar overlaps

Scalar quantities come from the identity monomial.

psi = KetPoly.from_ops(creators=(mode.cre,))
psi.inner(psi)
(1+0j)

Only the identity contribution survives.

Operator action on kets

Operator polynomials act using @.

a = OpPoly.a(mode)
adag = OpPoly.adag(mode)

vacuum = KetPoly.from_ops(coeff=1.0)
created = adag @ vacuum
viz.display(created)
2026-04-24T11:37:00.260771 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
acted = a @ created
viz.display(acted)
2026-04-24T11:37:00.310285 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

This reflects:

  • creation → append operator

  • annihilation → contraction + reordered term

Expectation values

Expectation values are computed via symbolic contraction.

\[\langle \psi | \hat n | \psi \rangle, \qquad \hat n = a^\dagger a.\]

For a single-photon state in the matching mode, the expected value is 1.

psi = KetPoly.from_ops(creators=(mode.cre,))
n_op = OpPoly.n(mode)
psi_out = n_op @ psi
viz.display(psi)
2026-04-24T11:37:00.355601 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
viz.display(psi_out)
2026-04-24T11:37:00.399989 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
psi.inner(psi_out)
(1+0j)

The scalar result comes from identity extraction after normal ordering.

From kets to density operators

A pure density operator is

\[\rho = |\psi\rangle\langle\psi|.\]
rho = DensityPoly.pure(created)
viz.display(rho)
2026-04-24T11:37:00.478951 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

Trace

The trace is

\[\mathrm{Tr}(\rho) = \sum_i c_i \langle R_i \mid L_i \rangle.\]
rho.trace()
(1+0j)

Purity

\[\mathrm{Tr}(\rho^2)\]
rho.purity()
1.0

Left and right operator action

\[\rho \mapsto W\rho, \qquad \rho \mapsto \rho W.\]

Right action is implemented internally via daggered reversed words.

rho_left = rho.apply_left((mode.cre,))
viz.display(rho_left)
2026-04-24T11:37:00.564123 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
rho_right = rho.apply_right((mode.ann,))
viz.display(rho_right)
2026-04-24T11:37:00.634815 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

Partial trace

Partial trace contracts a subset of modes.

\[\mathrm{Tr}_T(\rho) = \sum_i c_i \langle R_i^T \mid L_i^T \rangle |L_i^K\rangle\langle R_i^K|.\]
mode_b = ModeOp(
    label=ModeLabel(
        path=Path("B"),
        polarization=Polarization.H(),
        envelope=env,
    ),
    user_label="b",
)

psi = KetPoly.from_ops(creators=(mode.cre, mode_b.cre))
rho = DensityPoly.pure(psi)
viz.display(rho)
2026-04-24T11:37:00.713558 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
rho_reduced = rho.partial_trace((mode_b,))
viz.display(rho_reduced)
2026-04-24T11:37:00.784550 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/

The traced mode is contracted, leaving a reduced operator on the remaining mode.

End-to-end example

psi0 = KetPoly.from_ops(coeff=1.0)
psi1 = adag @ psi0
rho1 = DensityPoly.pure(psi1)
viz.display(psi1)
2026-04-24T11:37:00.835192 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
viz.display(rho1)
2026-04-24T11:37:00.877526 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/
rho1.trace(), rho1.purity()
((1+0j), 1.0)

Summary

The CCR layer operates entirely symbolically:

  • operator words are rewritten via commutation

  • results are stored as normally ordered monomials

  • scalar quantities come from identity contributions

  • density operations are built on the same mechanism

This enables overlaps, expectation values, traces, and reductions without matrix representations.

See also