General relations and signs

This note compares the CascadeDecays.jl helicity-frame construction with the Dalitz-plot decomposition implemented in ThreeBodyDecays.jl (TBD). It establishes the general relation, including spinful final states, reference-angle phases, and residual signs. For the simpler meson case with spin-zero pions, see COMPASS: three-pion meson amplitudes.

The important point is that the comparison must keep the reference topology fixed. In the examples below the reference is always

\[((1,2),3), \qquad \mathrm{refk}=3.\]

TBD writes the three-body amplitude using the reference production-plane orientation and Wigner small-$d$ rotations. CascadeDecays follows the full helicity-frame paths and therefore naturally produces full Wigner $D(\alpha,\beta,\gamma)$ rotations. For the fixed reference topology the ordinary reference-angle phase that must be attached to the TBD amplitude is

\[\exp\!\left(i\lambda_3\phi_{12}\right),\]

where $\phi_{12}$ is the inner azimuth of the reference isobar $(12)$. This is a fixed-reference phase. It is not a target-topology spectator phase.

The identity behind the phase is

\[D^{j*}_{\nu,\lambda_R-\lambda_3}(\phi,\theta,0) D^{j_R*}_{\lambda_R,\lambda_1-\lambda_2}(\phi_{12},\theta_{12},0) = D^{j*}_{\nu,\lambda_R-\lambda_3}(\phi,\theta,\phi_{12}) \exp(i\lambda_3\phi_{12}) d^{j_R}_{\lambda_R,\lambda_1-\lambda_2}(\theta_{12}).\]

The last step comes from the rightmost Euler angle of the first $D$ matrix,

\[D^{j*}_{\nu,\mu}(\phi,\theta,\phi_{12}) = D^{j*}_{\nu,\mu}(\phi,\theta,0)\,\exp(i\mu\phi_{12}), \qquad \mu=\lambda_R-\lambda_3,\]

and from the inner isobar factor

\[D^{j_R*}_{\lambda_R,\lambda_1-\lambda_2}(\phi_{12},\theta_{12},0) = \exp(-i\lambda_R\phi_{12}) d^{j_R}_{\lambda_R,\lambda_1-\lambda_2}(\theta_{12}).\]

Multiplying the two phases leaves $\exp(i\lambda_3\phi_{12})$.

The rest of this page keeps one clean kinematic point: an aligned (12)3 event with physical $\Lambda_c^+ \to p K^- \pi^+$ masses, followed by a small but visible rotation

p |> Rz(0.1) |> Ry(0.2) |> Rz(0.3)

The larger angles make endpoint-$Z$ phases visible in the printed tables.

Setup

using LinearAlgebra
using Printf
using FourVectors
using InstructionalDecayTrees
using ThreeBodyDecays:
    ThreeBodyMasses,
    ThreeBodyParities,
    ThreeBodySpins,
    ThreeBodySystem,
    aligned_four_vectors,
    Invariants,
    x2σs,
    wr,
    cosζ,
    ispositive,
    @jp_str
import ThreeBodyDecays: DecayChainLS, amplitude as tbd_amplitude

using CascadeDecays

include(joinpath(pkgdir(CascadeDecays), "compat", "ThreeBodyCompat.jl"))
using .ThreeBodyCompat

const LC_MASSES = (m0 = 2.28646, p = 0.93827208816, K = 0.493677, π = 0.13957039)
const UNIT_LINESHAPE = ConstantLineshape(1.0 + 0.0im)
unit_X(σ) = UNIT_LINESHAPE(σ)

fourvector(p) = FourVector(Float64(p[1]), Float64(p[2]), Float64(p[3]); E = Float64(p[4]))

ms = ThreeBodyMasses(LC_MASSES.p, LC_MASSES.K, LC_MASSES.π; m0 = LC_MASSES.m0)
σs_nt = x2σs([0.42, 0.31], ms; k = 3)
σs = Invariants(σs_nt.σ1, σs_nt.σ2, σs_nt.σ3)

finals_cm = fourvector.(aligned_four_vectors(σs_nt, ms; k = 3))
event_transform(p) = p |> Rz(0.1) |> Ry(0.2) |> Rz(0.3)
objs = Tuple(event_transform(p) for p in finals_cm)

const REF_K = 3
const REF_TOPOLOGY = three_body_topology(REF_K)
nothing

A reference-phase TBD amplitude

The next function is deliberately written in one place. It shows the three operations needed to obtain the TBD amplitude in the convention we compare to CascadeDecays:

  1. Compute the production-plane orientation from the fixed reference topology.
  2. Evaluate the TBD amplitude using refζs from that same reference.
  3. Multiply by the fixed reference phase $\exp(i\lambda_{\mathrm{ref}}\phi_{\mathrm{ref,inner}})$.
function multiply_helicity_phase(A, axis::Integer, two_j::Integer, ϕ::Real)
    two_j == 0 && return A
    B = copy(A)
    two_λs = collect((-two_j):2:two_j)
    for I in CartesianIndices(A)
        λ = two_λs[I[axis]] / 2
        B[I] *= cis(λ * ϕ)
    end
    return B
end

function tbd_reference_amplitude(objs, tbs, chain, refk; initial_frame = CurrentFrame())
    reference_topology = three_body_topology(refk)

    # A. The reference production-plane orientation.
    po = reference_plane_orientation(reference_topology, objs; initial_frame)

    # B. TBD amplitude in the fixed reference convention.
    σ = three_body_σs(objs)
    σ_tbd = Invariants(σ.σ1, σ.σ2, σ.σ3)
    A = tbd_amplitude(chain, po, σ_tbd; refζs = three_body_refζs(refk))

    # C. The fixed-reference inner phase exp(i λ_ref ϕ_ref_inner).
    ϕ_ref_inner = po.γ
    two_j_ref = tbs.two_js[refk]
    A_phase = multiply_helicity_phase(A, refk, two_j_ref, ϕ_ref_inner)

    return (; amplitude = A_phase, raw_amplitude = A, po, ϕ_ref_inner)
end
nothing

For this event the reference orientation is:

po_ref = reference_plane_orientation(REF_TOPOLOGY, objs; initial_frame = CurrentFrame())
(α = po_ref.α, cosβ = po_ref.cosβ, γ = po_ref.γ)
(α = 0.3000000000000002, cosβ = 0.9800665778412415, γ = 0.0999999999999998)

The last angle is the phase angle $\phi_{12}$.

External-rotation table

Before comparing full amplitudes, it is useful to compare the rotations alone. The table below uses IDT path comparisons for every target topology and every possible spin-1/2 final particle. It prints the IDT full Wigner $D$ branch and the TBD signed small-$d$ branch as rotation parameters only.

idt_relative_zyz(ref_path, target_path) =
    wigner_zyz(compare_instruction_paths(ref_path, target_path, objs).relative; atol = 1e-9)

The TBD side has only the signed Wigner angle $\zeta$. A positive TBD rotation is represented as $(0,\zeta,0)$. A negative one is represented as $(\pi,\zeta,-\pi)$; this keeps the same small-$d$ rotation but exposes the spinor branch.

function tbd_relative_zyz(k, particle, σs, ms2)
    w = wr(k, REF_K, particle)
    ζ = acos(cosζ(w, σs, ms2))
    return ispositive(w) ? (ϕ = 0.0, θ = ζ, ψ = 0.0) : (ϕ = π, θ = ζ, ψ = -π)
end
nothing
function path_label(path)
    join(map(path) do instr
        s = sprint(show, instr)
        kind = occursin("ToHelicityFrameParticle2", s) ? "H2" : "H1"
        m = match(r"\(\(([^)]*)\)\)", s)
        m === nothing && (m = match(r"\(([^()]*)\)", s))
        args = m === nothing ? "?" : replace(m.captures[1], ", " => "")
        "$kind($args)"
    end, " -> ")
end

function fmt_zyz(z)
    @sprintf("(% .3f, % .6f, % .3f)", z.ϕ, z.θ, z.ψ)
end

function rotation_rows()
    rows = String[]
    for k in (1, 2), particle in (1, 2, 3)
        target_topology = three_body_topology(k)
        ref_path = helicity_frame_path(REF_TOPOLOGY, particle; initial_frame = CurrentFrame())
        target_path = helicity_frame_path(target_topology, particle; initial_frame = CurrentFrame())
        idt = idt_relative_zyz(ref_path, target_path)
        tbd = tbd_relative_zyz(k, particle, σs, ms^2)

        push!(rows, @sprintf(
            "%-8s %-13d %-20s %-20s %-25s %-25s",
            k == 1 ? "(23)1" : "(31)2",
            particle,
            path_label(ref_path),
            path_label(target_path),
            fmt_zyz(idt),
            fmt_zyz(tbd),
        ))
    end
    return rows
end

println("target   spin_particle ref_path             target_path          IDT relative ZYZ          TBD relative ZYZ")
foreach(println, rotation_rows())
nothing
target   spin_particle ref_path             target_path          IDT relative ZYZ          TBD relative ZYZ
(23)1    1             H1(12) -> H1(1)      H2(1)                (-3.122,  0.481443, -0.000) ( 0.000,  0.481443,  0.000)
(23)1    2             H1(12) -> H2(2)      H1(23) -> H1(2)      ( 3.142,  0.527521,  3.142) ( 3.142,  0.527521, -3.142)
(23)1    3             H2(3)                H1(23) -> H2(3)      (-0.000,  0.125041,  0.100) ( 0.000,  0.125041,  0.000)
(31)2    1             H1(12) -> H1(1)      H1(31) -> H2(1)      ( 0.000,  1.442399,  6.283) ( 0.000,  1.442399,  0.000)
(31)2    2             H1(12) -> H2(2)      H2(2)                ( 3.111,  0.193292, -3.142) ( 3.142,  0.193292, -3.142)
(31)2    3             H2(3)                H1(31) -> H1(3)      (-3.142,  0.129644,  3.242) ( 3.142,  0.129644, -3.142)

This table deliberately prints only the two rotation parameterizations. It does not compare their spin matrices. A sign comparison belongs to the full amplitude check below, where the fixed-reference phase $\exp(i\lambda_3\phi_{12})$ is included.

Full toy amplitude checks

Now we repeat the exercise with simple toy amplitudes:

\[1/2^+ \to f^+ + P^- + P^-,\]

where only one final particle is a spin-1/2 fermion at a time. The reference is still fixed to refk = 3, and each target chain is compared to the reference-phase-corrected TBD amplitude.

The isobar quantum numbers are chosen so that a chain whose spectator is the fermion uses a vector 1- isobar of the two pseudoscalars, while the other chains use a fermionic 1/2- isobar.

function classify(A, B; atol = 1e-10)
    diff_plus = maximum(abs, A .- B)
    diff_minus = maximum(abs, A .+ B)
    sign =
        diff_plus < atol ? "+1" :
        diff_minus < atol ? "-1" :
        "not sign-only"
    return (; sign, diff_plus, diff_minus)
end

function toy_quantum(spin_particle)
    two_js = ntuple(i -> i == spin_particle ? 1 : 0, 3)
    tbs = ThreeBodySystem(ms, ThreeBodySpins(two_js...; two_h0 = 1))
    labels = ntuple(i -> i == spin_particle ? "1/2+" : "0-", 3)
    quantum = SystemSpinParities(labels...; jp0 = "1/2+")
    parities = ntuple(i -> i == spin_particle ? '+' : '-', 3)
    Ps = ThreeBodyParities(parities...; P0 = '+')
    return (; two_js, tbs, quantum, Ps)
end

isobar_jp_for(spin_particle, k) = k == spin_particle ? jp"1-" : jp"1/2-"

function compare_toy_amplitudes(spin_particle)
    (; tbs, quantum, Ps) = toy_quantum(spin_particle)
    rows = String[]
    for k in (1, 2, 3)
        topology = three_body_topology(k)
        jp = isobar_jp_for(spin_particle, k)
        tbd_chain = DecayChainLS(; k, Xlineshape = unit_X, jp, Ps, tbs)
        cd_chain = minimal_ls_decay_chain(
            topology,
            quantum,
            ((three_body_isobar_pair(k) => Propagator(jp, UNIT_LINESHAPE)),),
        )
        task = KinematicTask(
            (topology,);
            reference_topology = REF_TOPOLOGY,
            wigner_finals = (1, 2, 3),
            initial_frame = CurrentFrame(),
        )
        point = KinematicPoint(task, objs)

        A_tbd = tbd_reference_amplitude(objs, tbs, tbd_chain, REF_K).amplitude
        A_cd = amplitude(cd_chain, point)
        c = classify(A_tbd, A_cd)
        push!(rows, @sprintf(
            "%-8d %-14s %-18.2e %.2e",
            k,
            c.sign,
            c.diff_plus,
            c.diff_minus,
        ))
    end
    return rows
end

for spin_particle in (1, 2, 3)
    println()
    println("spin_particle = ", spin_particle)
    println("k        sign           max |TBD-CD|       max |TBD+CD|")
    foreach(println, compare_toy_amplitudes(spin_particle))
end
nothing
spin_particle = 1
k        sign           max |TBD-CD|       max |TBD+CD|
1        +1             3.72e-16           1.37e+00
2        -1             1.20e+00           5.84e-16
3        +1             6.21e-16           1.25e+00

spin_particle = 2
k        sign           max |TBD-CD|       max |TBD+CD|
1        -1             1.32e+00           1.59e-15
2        +1             7.27e-16           1.40e+00
3        +1             6.21e-16           1.25e+00

spin_particle = 3
k        sign           max |TBD-CD|       max |TBD+CD|
1        +1             1.71e-15           1.41e+00
2        +1             1.28e-15           1.38e+00
3        +1             1.12e-15           1.35e+00

These amplitude checks are a useful warning. The fixed reference phase $\exp(i\lambda_3\phi_{12})$ is necessary and it fixes the particle-3 endpoint phase in the non-reference chains. With physical LS choices the remaining differences are global signs. The two clean central signs are:

  • (23)1 with particle 2 spin gives -1;
  • (31)2 with particle 1 spin gives -1.