feat: add numeric and symbolic scripts

This commit is contained in:
Hans Aschauer 2026-07-26 14:09:49 +02:00
parent 6ea7900b55
commit e80b7c3582
38 changed files with 3314 additions and 0 deletions

View file

@ -0,0 +1,96 @@
"""
01_werner_qubit_symbolic.py
Exact symbolic (sympy) check: the two-qubit Werner state
rho(p) = p |Psi-><Psi-| + (1-p) I/4, |Psi-> = (|01>-|10>)/sqrt(2)
is invariant under U (x) U for every U in SU(2). Since the adjoint
representation of SU(2) on the traceless qubit Bloch space R^3 is
irreducible (single isotype), the correlation matrix is forced to be
proportional to the identity. We check this exactly and compare the
resulting nuclear-norm threshold to the exact PPT/separability threshold.
Result: BOTH give exactly p = 1/3 -- the order-1 correlation-matrix
criterion is exactly tight here (a low-dimensional special case, since
PPT=separable for 2x2 systems by the Horodecki theorem).
Requires: sympy. Runtime: a few seconds.
"""
import sympy as sp
from sympy import sqrt, I, simplify, Matrix, eye, zeros, re, symbols
X = Matrix([[0, 1], [1, 0]])
Y = Matrix([[0, -I], [I, 0]])
Z = Matrix([[1, 0], [0, -1]])
I2 = eye(2)
def kron(A, B):
mA, nA = A.shape
mB, nB = B.shape
out = zeros(mA * mB, nA * nB)
for i in range(mA):
for j in range(nA):
out[i * mB:(i + 1) * mB, j * nB:(j + 1) * nB] = A[i, j] * B
return out
def op_A(P):
return kron(P, I2)
def op_B(P):
return kron(I2, P)
p = symbols('p', real=True)
psi = zeros(4, 1)
psi[1, 0] = 1 / sqrt(2)
psi[2, 0] = -1 / sqrt(2)
rho_singlet = simplify(psi * psi.H)
rho_p = simplify(p * rho_singlet + (1 - p) * eye(4) / 4)
print("rho(p) =")
sp.pprint(rho_p)
def entry(rho, ops):
M = None
for op in ops:
M = op if M is None else M * op
return simplify(re(simplify((rho * M).trace())))
plist = [X, Y, Z]
T = Matrix(3, 3, lambda i, j: entry(rho_p, [op_A(plist[i]), op_B(plist[j])]))
print("\nCorrelation matrix T(p) =")
sp.pprint(T)
G = simplify(T.T * T)
eigs = G.eigenvals()
singular_values = []
for ev, mult in eigs.items():
singular_values += [simplify(sqrt(ev))] * mult
nuclear_norm = simplify(sum(singular_values))
print("\nNuclear norm ||M_A(rho(p))||_* =", nuclear_norm)
print("Shadow-map threshold (||.||_* = 1):", sp.solve(sp.Eq(nuclear_norm, 1), p))
def partial_transpose_B(M):
Mpt = zeros(4, 4)
for a in range(2):
for b in range(2):
for c in range(2):
for dd in range(2):
i, j = a * 2 + b, c * 2 + dd
i2, j2 = a * 2 + dd, c * 2 + b
Mpt[i2, j2] = M[i, j]
return Mpt
rho_pt = partial_transpose_B(rho_p)
print("\nEigenvalues of the partial transpose rho(p)^{T_B}:")
for e_ in rho_pt.eigenvals().keys():
print(" ", simplify(e_), " = 0 at p =", sp.solve(sp.Eq(e_, 0), p))

View file

@ -0,0 +1,103 @@
"""
02_werner_qutrit_symbolic.py
Same check as 01_werner_qubit_symbolic.py, generalized to d=3 (qutrits),
using the sqrt(3/2)-scaled Gell-Mann convention fixed in the paper's own
Tiles example (tr(sigma_i sigma_j) = d delta_ij = 3 delta_ij).
State family: rho(p) = p * P_anti/dim(P_anti) + (1-p) * I/9
(the natural qutrit "Werner state" built from the antisymmetric subspace
of C^3 x C^3, dimension 3), invariant under U(x)U for all U in U(3).
Result (the interesting part): the order-1 shadow-map/correlation-matrix
criterion gives p_c = 1/2, but the TRUE separability threshold (Werner
1989, p_sep = 1/(d+1)) is p_c = 1/4. Unlike the qubit case, the criterion
is here only a valid but NOT tight sufficient condition -- symmetry forces
"concentration" of the signal (single isotype => correlation matrix
proportional to identity) but not "sharpening" of the threshold itself.
Requires: sympy. Runtime: under a minute.
"""
import sympy as sp
from sympy import sqrt, I, simplify, Matrix, eye, zeros, re, symbols, Rational
d = 3
lam = [None] * 8
lam[0] = Matrix([[0, 1, 0], [1, 0, 0], [0, 0, 0]])
lam[1] = Matrix([[0, -I, 0], [I, 0, 0], [0, 0, 0]])
lam[2] = Matrix([[1, 0, 0], [0, -1, 0], [0, 0, 0]])
lam[3] = Matrix([[0, 0, 1], [0, 0, 0], [1, 0, 0]])
lam[4] = Matrix([[0, 0, -I], [0, 0, 0], [I, 0, 0]])
lam[5] = Matrix([[0, 0, 0], [0, 0, 1], [0, 1, 0]])
lam[6] = Matrix([[0, 0, 0], [0, 0, -I], [0, I, 0]])
lam[7] = (1 / sqrt(3)) * Matrix([[1, 0, 0], [0, 1, 0], [0, 0, -2]])
c = sqrt(Rational(3, 2))
sigma = [simplify(c * L) for L in lam]
for i in range(8):
for j in range(8):
val = simplify((sigma[i] * sigma[j]).trace())
assert val == (3 if i == j else 0), (i, j, val)
I3 = eye(3)
def op_A(P):
return sp.Matrix(sp.kronecker_product(P, I3))
def op_B(P):
return sp.Matrix(sp.kronecker_product(I3, P))
V = zeros(9, 9)
for a in range(3):
for b in range(3):
V[b * 3 + a, a * 3 + b] = 1
I9 = eye(9)
P_anti = simplify((I9 - V) / 2)
dim_anti = simplify(P_anti.trace()) # = 3
p = symbols('p', real=True)
rho_p = simplify(p * P_anti / dim_anti + (1 - p) * I9 / 9)
def entry(rho, ops):
M = None
for op in ops:
M = op if M is None else M * op
return simplify(re(simplify((rho * M).trace())))
T = Matrix(8, 8, lambda i, j: entry(rho_p, [op_A(sigma[i]), op_B(sigma[j])]))
print("Correlation matrix T(p) (should be proportional to I_8):")
sp.pprint(T)
norm_const = 1 / sqrt(4) # (d_a-1)(d_bar_a-1) = 2*2 = 4
Mn = simplify(norm_const * T)
nuclear_norm = simplify(8 * sp.Abs(Mn[0, 0]))
print("\nShadow-map nuclear norm:", nuclear_norm)
print("Shadow-map threshold:", sp.solve(sp.Eq(nuclear_norm, 1), p))
def partial_transpose_B_d(M, dim):
Mpt = zeros(dim * dim, dim * dim)
for a in range(dim):
for b in range(dim):
for cc in range(dim):
for dd in range(dim):
i, j = a * dim + b, cc * dim + dd
i2, j2 = a * dim + dd, cc * dim + b
Mpt[i2, j2] = M[i, j]
return Mpt
rho_pt = partial_transpose_B_d(rho_p, 3)
print("\nPartial-transpose eigenvalues (PPT / true-separability threshold):")
for ev in rho_pt.eigenvals().keys():
print(" ", simplify(ev), " = 0 at p =", sp.solve(sp.Eq(ev, 0), p))
print("\nExpected: shadow-map threshold p=1/2 (NOT tight);"
" true threshold (Werner 1989, p_sep=1/(d+1)) p=1/4.")

View file

@ -0,0 +1,54 @@
"""
03_dps_level2_demo.py
Demonstrates the level-2 DPS SDP (via dps_hierarchy.build_dps_problem) on
two test states:
A) the qutrit Werner state -- sanity check. PPT is already exactly
tight for this family (p_c=1/4, see 02_werner_qutrit_symbolic.py), so
DPS-2 cannot improve on it; well away from the boundary both should
agree.
B) the Tiles UPB bound-entangled state -- the interesting case. Plain
PPT is blind (min eigenvalue ~0, "PPT to machine precision" as noted
in the paper's own Tiles example), but DPS level 2 correctly detects
the entanglement.
Expected runtime: well under a minute with SCS.
"""
import cvxpy as cp
from common import RHO_TILES, werner_qutrit, plain_ppt_feasible, plain_ppt_min_eig
from dps_hierarchy import build_dps_problem, dps_feasible
SOLVER = cp.SCS # swap to cp.MOSEK if you have a license -- likely much faster
prob, rho_param, sigma = build_dps_problem(d=3, k=2)
print(f"DPS level-2 SDP built: sigma shape {sigma.shape}\n")
print("=" * 70)
print("Sanity check: qutrit Werner state (known exact threshold p=1/4)")
print("=" * 70)
for p in [0.20, 0.40]:
rho = werner_qutrit(p)
ppt_ok = plain_ppt_feasible(rho)
eig = plain_ppt_min_eig(rho)
ok = dps_feasible(prob, rho_param, rho, solver=SOLVER, eps=1e-6)
print(f"p={p:.2f}: PPT feasible={ppt_ok} (min eig {eig:+.5f}) "
f"DPS-2 feasible={ok}")
print("\n" + "=" * 70)
print("Tiles UPB bound-entangled state (the interesting case)")
print("=" * 70)
ppt_ok = plain_ppt_feasible(RHO_TILES)
eig = plain_ppt_min_eig(RHO_TILES)
print(f"Plain PPT feasible: {ppt_ok} (min eigenvalue: {eig:.10f})")
ok = dps_feasible(prob, rho_param, RHO_TILES, solver=SOLVER, eps=1e-6)
print(f"DPS level-2 feasible: {ok} "
f"({'NOT detected' if ok else 'ENTANGLEMENT DETECTED'})")
print("\nRobustness across solver tolerances (guards against SDP numerical "
"artifacts near a threshold -- see the chat for a case where this "
"mattered):")
for eps in [1e-5, 1e-6, 1e-7, 1e-8]:
dps_feasible(prob, rho_param, RHO_TILES, solver=SOLVER, eps=eps, max_iters=50000)
print(f" eps={eps}: status={prob.status}")

View file

@ -0,0 +1,50 @@
"""
04_tiles_noise_scan.py
Noise-robustness comparison: find the critical white-noise fraction p_c at
which each criterion stops detecting entanglement of
rho(p) = p * rho_Tiles + (1-p) * I/9
Reproduces (see the chat for the full discussion/derivation):
- plain shadow-map / de Vicente Bloch-representation criterion:
p_c ~ 0.9493 (tolerance ~5.07%)
- DPS level 2:
p_c ~ 0.951 (tolerance ~4.9%)
i.e. DPS level 2 barely improves on the much cheaper order-1 correlation
criterion for THIS state -- see 05_local_filtering.py for the much bigger
lever (local filtering).
Expected runtime: seconds for the shadow-map part; a few minutes for the
DPS-2 bisection (12-16 SDP solves).
"""
import cvxpy as cp
from common import noisy_tiles, shadow_map_nuclear_norm
from dps_hierarchy import build_dps_problem, dps_feasible
SOLVER = cp.SCS
print("Shadow-map criterion threshold:")
lo, hi = 0.0, 1.0
for _ in range(40):
mid = (lo + hi) / 2
if shadow_map_nuclear_norm(noisy_tiles(mid)) > 1:
hi = mid
else:
lo = mid
print(f" p_c = {hi:.5f} (tolerance {100 * (1 - hi):.2f}%)")
print("\nDPS level-2 threshold:")
prob, rho_param, sigma = build_dps_problem(d=3, k=2)
lo, hi = 0.0, 1.0
for i in range(16):
mid = (lo + hi) / 2
ok = dps_feasible(prob, rho_param, noisy_tiles(mid), solver=SOLVER,
eps=1e-7, warm_start=True)
if ok:
lo = mid
else:
hi = mid
print(f" [{i + 1}] p={mid:.5f} feasible={ok}")
print(f" p_c = {hi:.5f} (tolerance {100 * (1 - hi):.2f}%)")

View file

@ -0,0 +1,42 @@
"""
05_local_filtering.py
Applies the operator-Sinkhorn local-filtering (SLOCC normal-form)
algorithm to the Tiles state family, then re-evaluates the plain
shadow-map criterion on the FILTERED state.
Reproduces the literature's "Filter Covariance Matrix Criterion"
(Gittsovich, Guehne, Hyllus, Eisert, "Unifying several separability
conditions using the covariance matrix criterion", arXiv:0803.0757,
Proposition IV.13) threshold almost exactly:
filtered shadow-map (this script): p_c ~ 0.8722 (tolerance 12.78%)
literature Filter-CMC (Prop IV.13): p_c = 0.8723 (tolerance 12.77%)
i.e. local filtering + the paper's OWN, already-existing order-1
criterion reproduces a specialized literature result almost to 4 decimal
places, with no new criterion needed -- just the right pre-processing.
Expected runtime: a few seconds (filtering is cheap linear algebra,
no SDP involved here; ~20-30 Sinkhorn iterations per state).
"""
from common import noisy_tiles, RHO_TILES, operator_sinkhorn, shadow_map_nuclear_norm
print("Filtering the pure Tiles state (p=1):")
rho_f = operator_sinkhorn(RHO_TILES, verbose=True)
print(" shadow-map nuclear norm BEFORE filtering:", shadow_map_nuclear_norm(RHO_TILES))
print(" shadow-map nuclear norm AFTER filtering:", shadow_map_nuclear_norm(rho_f))
print("\nBisection for the filtered-shadow-map threshold:")
lo, hi = 0.80, 0.95
for _ in range(20):
mid = (lo + hi) / 2
rho_pf = operator_sinkhorn(noisy_tiles(mid))
nn = shadow_map_nuclear_norm(rho_pf)
if nn > 1:
hi = mid
else:
lo = mid
print(f" p_c = {hi:.5f} (tolerance {100 * (1 - hi):.2f}%)")
print(" Literature (Filter-CMC, Prop. IV.13, arXiv:0803.0757): "
"p_c = 0.87230 (tolerance 12.77%)")

View file

@ -0,0 +1,42 @@
"""
06_dps_level2_filtered.py
Applies DPS level 2 to the FILTERED Tiles state family (filtering + higher
extension order, combined). The interesting (somewhat counter-intuitive)
result: filtering helps DPS-2 only marginally --
p_c ~ 0.9426 (tolerance 5.74%)
-- much less than it helps the plain shadow-map criterion alone
(p_c ~ 0.8722, tolerance 12.78%, see 05_local_filtering.py). I.e. for this
state, "which local basis you filter into" matters far more than "how
many extension copies you add" -- filtering and DPS-extension-order are
not equally powerful levers here, and they don't simply stack.
Expected runtime: a few minutes (DPS-2 bisection with re-filtering the
state at each bisection point).
"""
import cvxpy as cp
from common import noisy_tiles, operator_sinkhorn
from dps_hierarchy import build_dps_problem, dps_feasible
SOLVER = cp.SCS
prob, rho_param, sigma = build_dps_problem(d=3, k=2)
print("DPS level 2 on the filtered pure Tiles state (p=1):")
rho_f = operator_sinkhorn(noisy_tiles(1.0))
ok = dps_feasible(prob, rho_param, rho_f, solver=SOLVER, eps=1e-7)
print(f" feasible={ok}")
print("\nBisection for the filtered-DPS-2 threshold:")
lo, hi = 0.5, 0.95
for i in range(14):
mid = (lo + hi) / 2
rho_pf = operator_sinkhorn(noisy_tiles(mid))
ok = dps_feasible(prob, rho_param, rho_pf, solver=SOLVER, eps=1e-7, warm_start=True)
if ok:
lo = mid
else:
hi = mid
print(f" [{i + 1}] p={mid:.5f} feasible={ok}")
print(f" p_c = {hi:.5f} (tolerance {100 * (1 - hi):.2f}%)")

View file

@ -0,0 +1,30 @@
"""
07_dps_level3_single.py
A single DPS level-3 feasibility check on the pure Tiles state, to confirm
the construction works and see its cost before committing to a full
noise-threshold bisection (see 08_dps_level_k_bisection.py).
In the original sandbox this took ~137s with SCS (single solve, cold
start). Expect similar or better on a modern laptop; likely far faster
with an interior-point solver (MOSEK, if you have a license) since the
PSD cone here (81x81 complex Hermitian) is small by modern SDP standards
-- SCS is a first-order method tuned for large sparse problems and is not
especially fast on small/dense feasibility problems like this one.
Expected result: status "infeasible" (i.e. entanglement IS detected).
"""
import time
import cvxpy as cp
from common import RHO_TILES
from dps_hierarchy import build_dps_problem, dps_feasible
SOLVER = cp.SCS # try cp.MOSEK if available -- likely much faster at this size
prob, rho_param, sigma = build_dps_problem(d=3, k=3)
print(f"sigma shape: {sigma.shape} "
f"PPT-constraint (PSD cone) size: {3 * 3 ** 3} x {3 * 3 ** 3}")
t0 = time.time()
ok = dps_feasible(prob, rho_param, RHO_TILES, solver=SOLVER, eps=1e-6, max_iters=20000)
print(f"DPS level-3 feasible: {ok} status={prob.status} [{time.time() - t0:.1f}s]")

View file

@ -0,0 +1,84 @@
"""
08_dps_level_k_bisection.py
General, RESUMABLE bisection for the DPS level-k noise-robustness
threshold of the Tiles state family. Each run performs STEPS_PER_RUN
bisection steps and saves progress to a JSON state file, so you can call
it repeatedly (e.g. in a shell loop, or across separate sessions) without
losing progress -- useful since each SDP solve can take anywhere from
under a second (k=2) to a few minutes (k=3, with SCS) depending on k,
your hardware, and the solver.
Usage:
python3 08_dps_level_k_bisection.py
Configure LEVEL, SOLVER, STEPS_PER_RUN, and EPS below.
The state file is named dps_level{LEVEL}_bisection_state.json.
--------------------------------------------------------------------
Progress already made in the original chat session for LEVEL=3 (8 SCS
solves, ~135-227s each) is included alongside this script as
dps_level3_bisection_state.json:
bracket so far: [0.90982, 0.91080] (i.e. p_c ~ 0.910-0.911)
Just run this script (with LEVEL=3, the default) to continue narrowing
it -- it will pick up automatically from that saved state. Delete the
state file to start over, or change LEVEL to try a different extension
order (4, 5, ... but see README.md for how fast the PPT-constraint size,
and hence the cost, grows: 3*3^k).
--------------------------------------------------------------------
"""
import json
import os
import time
import cvxpy as cp
from common import noisy_tiles
from dps_hierarchy import build_dps_problem, dps_feasible
LEVEL = 3
SOLVER = cp.SCS # swap to cp.MOSEK if available -- likely much faster
STEPS_PER_RUN = 1 # raise this if your machine/solver is fast enough
EPS = 1e-5 # solver tolerance; tighten once you have a rough bracket
DEFAULT_BRACKET = (0.70, 0.951) # 0.951 is a proven-safe upper bound (= DPS level-2 threshold,
# since DPS level 3 can only detect at <= that noise level)
STATE_FILE = f"dps_level{LEVEL}_bisection_state.json"
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
state = json.load(f)
print(f"Resuming from saved state: bracket [{state['lo']:.5f}, {state['hi']:.5f}], "
f"{state['iter']} iterations so far.")
else:
state = {"lo": DEFAULT_BRACKET[0], "hi": DEFAULT_BRACKET[1], "iter": 0, "log": []}
print(f"Starting fresh: bracket {DEFAULT_BRACKET}")
prob, rho_param, sigma = build_dps_problem(d=3, k=LEVEL)
print(f"sigma shape: {sigma.shape}, PPT-constraint (PSD cone) size: "
f"{3 * 3 ** LEVEL} x {3 * 3 ** LEVEL}\n")
for _ in range(STEPS_PER_RUN):
lo, hi = state["lo"], state["hi"]
mid = (lo + hi) / 2
t0 = time.time()
feasible = dps_feasible(prob, rho_param, noisy_tiles(mid), solver=SOLVER,
eps=EPS, max_iters=20000, warm_start=True)
dt = time.time() - t0
if feasible:
state["lo"] = mid
else:
state["hi"] = mid
state["iter"] += 1
state["log"].append({"iter": state["iter"], "p": mid, "feasible": feasible,
"time_s": round(dt, 1), "status": prob.status})
print(f"[iter {state['iter']}] p={mid:.5f} feasible={feasible} "
f"status={prob.status} ({dt:.1f}s) "
f"bracket now [{state['lo']:.5f}, {state['hi']:.5f}]")
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
print(f"\nCurrent bracket: [{state['lo']:.5f}, {state['hi']:.5f}] "
f"(width {state['hi'] - state['lo']:.5f})")
print("Run again to continue narrowing it further (progress is saved).")

View file

@ -0,0 +1,86 @@
"""
09_dps_level3_filtered_bisection.py
Combines local filtering (operator-Sinkhorn, as in 05/06) with DPS level 3
(as in 07/08): at each candidate noise level p, first bring rho(p) to its
local-filtering normal form, then run the DPS level-3 feasibility SDP on
the FILTERED state.
Precedent from level 2 (06_dps_level2_filtered.py): filtering helped DPS-2
only modestly (4.90% -> 5.74% tolerance), far less than it helped the
plain order-1 shadow-map criterion alone (-> 12.78%, see 05). Expect a
similarly modest improvement here, NOT a jump to ~13% territory.
Cost note: this is the most expensive script in the collection. Each
solve costs about as much as plain DPS-3 (07/08) -- filtering itself is
cheap, the SDP solve dominates -- so a full bisection needs roughly the
same total time as 08's bisection, i.e. another dozen-ish solves at
~85-140s each on hardware like yours.
On the starting bracket: for the level-2 case, filtering turned out to
help (0.9426 < 0.9510), but this is NOT something proven in general here
-- local filtering does not obviously commute with the k-extension
structure the way it does with plain separability (which is SLOCC-
invariant by definition). So, unlike 08's upper bound (0.951, rigorously
justified by DPS monotonicity in k alone), the bracket below is only an
empirically-motivated starting guess, not a proven bound. If a bisection
step ever reports "feasible" surprisingly close to hi, that's a sign the
true threshold may be above the assumed bracket -- widen it and restart
if so.
Usage:
python3 09_dps_level3_filtered_bisection.py
"""
import json
import os
import time
import cvxpy as cp
from common import noisy_tiles, operator_sinkhorn
from dps_hierarchy import build_dps_problem, dps_feasible
LEVEL = 3
SOLVER = cp.SCS # swap to cp.MOSEK if available -- likely much faster
STEPS_PER_RUN = 1
EPS = 1e-6
DEFAULT_BRACKET = (0.8, 0.95) # empirically-motivated, NOT rigorously proven (see above)
STATE_FILE = f"dps_level{LEVEL}_filtered_bisection_state.json"
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
state = json.load(f)
print(f"Resuming from saved state: bracket [{state['lo']:.5f}, {state['hi']:.5f}], "
f"{state['iter']} iterations so far.")
else:
state = {"lo": DEFAULT_BRACKET[0], "hi": DEFAULT_BRACKET[1], "iter": 0, "log": []}
print(f"Starting fresh: bracket {DEFAULT_BRACKET}")
prob, rho_param, sigma = build_dps_problem(d=3, k=LEVEL)
print(f"sigma shape: {sigma.shape}, PPT-constraint (PSD cone) size: "
f"{3 * 3 ** LEVEL} x {3 * 3 ** LEVEL}\n")
for _ in range(STEPS_PER_RUN):
lo, hi = state["lo"], state["hi"]
mid = (lo + hi) / 2
t0 = time.time()
rho_filtered = operator_sinkhorn(noisy_tiles(mid))
feasible = dps_feasible(prob, rho_param, rho_filtered, solver=SOLVER,
eps=EPS, max_iters=20000, warm_start=True)
dt = time.time() - t0
if feasible:
state["lo"] = mid
else:
state["hi"] = mid
state["iter"] += 1
state["log"].append({"iter": state["iter"], "p": mid, "feasible": feasible,
"time_s": round(dt, 1), "status": prob.status})
print(f"[iter {state['iter']}] p={mid:.5f} feasible={feasible} "
f"status={prob.status} ({dt:.1f}s) "
f"bracket now [{state['lo']:.5f}, {state['hi']:.5f}]")
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
print(f"\nCurrent bracket: [{state['lo']:.5f}, {state['hi']:.5f}] "
f"(width {state['hi'] - state['lo']:.5f})")
print("Run again to continue narrowing it further (progress is saved).")

View file

@ -0,0 +1,96 @@
# Entanglement-detection scripts from this chat
This is the code from a conversation that started with the "symmetric shadow
maps" paper (`symmetric_shadow_maps_formal.tex`) and worked outward through a
chain of entanglement-detection techniques on the two-qutrit **Tiles**
bound-entangled state (Bennett-DiVincenzo-Mor-Shor-Smolin-Terhal UPB state,
already used as a benchmark in the paper): symmetric-state sanity checks,
DPS symmetric-extension SDPs, noise-robustness thresholds, local filtering,
and (in progress) a third DPS extension level.
**Note: none of this has been re-run/verified after being assembled into
this package** (per your request) -- it's a straight extraction of the
code from the chat. The numbers quoted in each docstring/comment are what
the sandbox actually produced during the conversation; treat them as
"expected results to check against" rather than guaranteed.
## Setup
```
pip install -r requirements.txt
```
`scs` is the default (open-source, first-order) SDP solver used throughout.
If you have a MOSEK license (free for academics), it is very likely much
faster for these problem sizes -- just change `SOLVER = cp.SCS` to
`SOLVER = cp.MOSEK` near the top of scripts 03, 04, 06, 07, 08.
## Files, in the order they came up in the conversation
| File | What it does | Expected result | Rough runtime |
|---|---|---|---|
| `common.py` | Shared utilities: Gell-Mann generators (paper convention), Tiles state, qutrit Werner state, correlation-matrix/shadow-map criterion, plain PPT check, operator-Sinkhorn filter. Imported by scripts 03-06. | -- | -- |
| `dps_hierarchy.py` | General, level-`k`-parametrized DPS symmetric-extension SDP builder (used for levels 2 and 3, and usable for higher `k`). | -- | -- |
| `01_werner_qubit_symbolic.py` | Exact (sympy) check: 2-qubit Werner state, `SU(2)` symmetry forces the correlation matrix `∝ I`. | Shadow-map threshold *exactly* matches PPT: **p_c = 1/3** both ways. | seconds |
| `02_werner_qutrit_symbolic.py` | Same, generalized to qutrits (antisymmetric-subspace Werner state). | Shadow-map threshold **p_c = 1/2**, but true threshold (Werner 1989) is **p_c = 1/4** -- the order-1 criterion is valid but NOT tight in d=3 (unlike d=2). | under a minute |
| `03_dps_level2_demo.py` | DPS level 2 via `dps_hierarchy`. Sanity check on Werner qutrit; then the interesting case: Tiles state, where plain PPT is exactly blind (min eigenvalue ≈ 0) but DPS-2 detects it. | Werner: consistent with p=1/4 away from the boundary. Tiles: PPT feasible=True, DPS-2 feasible=False (detected). Robust across solver tolerances 1e-5..1e-8. | under a minute |
| `04_tiles_noise_scan.py` | Noise-threshold bisection for (a) the plain shadow-map criterion and (b) DPS level 2, on the noisy Tiles family. | Shadow-map **p_c ≈ 0.9493** (5.07% tolerance); DPS-2 **p_c ≈ 0.951** (4.9%) -- i.e. DPS-2 barely improves on the much cheaper order-1 criterion for this state. | a few minutes (DPS-2 bisection) |
| `05_local_filtering.py` | Operator-Sinkhorn local filtering (SLOCC normal form) + the plain shadow-map criterion on the filtered state. | **p_c ≈ 0.8722** (12.78% tolerance) -- matches the literature's "Filter Covariance Matrix Criterion" (Gittsovich, Gühne, Hyllus, Eisert, arXiv:0803.0757, Prop. IV.13: p_c = 0.8723, 12.77%) to ~4 decimal places. | seconds |
| `06_dps_level2_filtered.py` | DPS level 2 applied to the *filtered* state (combining both levers). | **p_c ≈ 0.9426** (5.74%) -- filtering helps DPS-2 only marginally, much less than it helps the plain shadow-map (05). Filtering and DPS-extension-order are not equally powerful levers here, and don't simply stack. | a few minutes |
| `07_dps_level3_single.py` | A single DPS level-3 feasibility check on the pure Tiles state, to confirm level 3 is tractable at all. | status = infeasible (detected). Took **~137s** with SCS in the original sandbox. | ~1-3 minutes |
| `08_dps_level_k_bisection.py` | General, **resumable** bisection for the DPS level-`k` noise threshold, one step per invocation, progress saved to JSON. Defaults to `LEVEL=3`. | See below -- **in progress**. | ~2-4 min per step with SCS (k=3) |
| `dps_level3_bisection_state.json` | Saved progress for the level-3 bisection from the original session (8 SCS solves already spent). | Current bracket: **[0.90982, 0.91080]**, i.e. `p_c ≈ 0.910-0.911`. | -- |
| `09_dps_level3_filtered_bisection.py` | Combines local filtering (05) with DPS level 3 (07/08): filter the state, then run the level-3 SDP on it. Resumable, same pattern as 08. | Untested/in progress -- based on the level-2 precedent (06), expect only a modest improvement over plain level 3, not a jump to ~13%. Starting bracket is an educated guess, not a proven bound (see the script's docstring). | most expensive script here: ~85-140s per solve, ~12-16 solves for a full bisection |
## Where the level-3 bisection currently stands
```json
{"lo": 0.90982, "hi": 0.91080, "iter": 8}
```
So DPS level 3 detects entanglement for `p ≳ 0.910`, i.e. roughly
**9.0% noise tolerance** -- already better than level 2's 4.9-5.7%, but
still well short of the 12.77-12.78% that local filtering alone achieves.
Just re-run `08_dps_level_k_bisection.py` (it picks up the saved state
automatically) to narrow this further.
## The overall picture that emerged (for reference)
| Method | p_c | Noise tolerance |
|---|---|---|
| plain PPT | ~1.0 | ~0% (knife-edge) |
| shadow-map / de Vicente Bloch criterion (order 1) | 0.9493 | 5.07% |
| DPS level 2 | 0.9510 | 4.90% |
| DPS level 2 + filtering | 0.9426 | 5.74% |
| DPS level 3 (partial result so far) | ~0.910 | ~9.0% (narrowing) |
| **local filtering + shadow-map (order 1)** | **0.8722** | **12.78%** |
| literature: Filter-CMC (Prop. IV.13) | 0.8723 | 12.77% |
| literature: best known positive map | 0.8744 | 12.56% |
Headline takeaway: for this particular state, **local filtering (a SLOCC
pre-processing step) is a far bigger lever than increasing the DPS
extension order**, and the two don't stack additively -- filtering the
state and then applying the cheapest possible (order-1) criterion already
matches a specialized literature result almost exactly, while adding DPS
levels on top gives comparatively little.
## A performance note on why level 3+ gets slow
The DPS SDP *variable* is parametrized on `A ⊗ Sym^k(B)`, dimension
`d · C(d+k-1, k)` -- polynomial in `k` (this is the "exploit the built-in
Bose symmetry of the extension copies" trick). But the **PPT constraint**
itself has to be checked on the full, unsymmetrized embedding
`A ⊗ B_1 ⊗ ... ⊗ B_k`, dimension `d^(k+1)` -- exponential in `k`. Since
SDP solver cost is governed by the size of the PSD cone (the PPT
constraint), not by the number of free variables, this is why level 3
(81×81 cone) is already much slower than level 2 (27×27 cone), and level 4
(243×243) would be slower still. A proper fix would exploit
representation-theoretic structure of the PPT constraint itself, not just
of the extension -- that's a bigger undertaking than what's implemented
here.
If you have MOSEK (or another interior-point solver): try it first for
levels 3-4. Interior-point methods are usually much faster than SCS on
small/medium, dense SDPs like these -- SCS is tuned for large sparse
problems and is likely the main reason level 3 took ~137s-227s per solve
here rather than a fraction of a second.

View file

@ -0,0 +1,167 @@
"""
common.py
Shared numpy utilities for the Tiles-state / DPS-hierarchy scripts (03-08).
Convention: qutrits (d=3), Gell-Mann generators scaled so that
tr(sigma_i sigma_j) = d * delta_ij = 3 * delta_ij, matching the paper's own
convention (see the Tiles benchmark in symmetric_shadow_maps_formal.tex).
"""
import numpy as np
d = 3
# --- Gell-Mann matrices, paper convention ---
_lam = [None] * 8
_lam[0] = np.array([[0, 1, 0], [1, 0, 0], [0, 0, 0]], dtype=complex)
_lam[1] = np.array([[0, -1j, 0], [1j, 0, 0], [0, 0, 0]], dtype=complex)
_lam[2] = np.array([[1, 0, 0], [0, -1, 0], [0, 0, 0]], dtype=complex)
_lam[3] = np.array([[0, 0, 1], [0, 0, 0], [1, 0, 0]], dtype=complex)
_lam[4] = np.array([[0, 0, -1j], [0, 0, 0], [1j, 0, 0]], dtype=complex)
_lam[5] = np.array([[0, 0, 0], [0, 0, 1], [0, 1, 0]], dtype=complex)
_lam[6] = np.array([[0, 0, 0], [0, 0, -1j], [0, 1j, 0]], dtype=complex)
_lam[7] = (1 / np.sqrt(3)) * np.array([[1, 0, 0], [0, 1, 0], [0, 0, -2]], dtype=complex)
GELLMANN = [np.sqrt(3 / 2) * L for L in _lam]
I3 = np.eye(3, dtype=complex)
I9 = np.eye(9, dtype=complex)
def opA(P):
return np.kron(P, I3)
def opB(P):
return np.kron(I3, P)
def e(i):
v = np.zeros(3)
v[i] = 1
return v
# --- The Tiles UPB bound-entangled state (Bennett, DiVincenzo, Mor, Shor,
# Smolin, Terhal 1999), as used in the paper's own qutrit benchmark ---
def _build_tiles():
sqrt2, sqrt3 = np.sqrt(2), np.sqrt(3)
upb = [
np.kron(e(0), (e(0) - e(1)) / sqrt2),
np.kron(e(2), (e(1) - e(2)) / sqrt2),
np.kron((e(0) - e(1)) / sqrt2, e(2)),
np.kron((e(1) - e(2)) / sqrt2, e(0)),
np.kron((e(0) + e(1) + e(2)) / sqrt3, (e(0) + e(1) + e(2)) / sqrt3),
]
P_UPB = sum(np.outer(v, v) for v in upb)
return ((np.eye(9) - P_UPB) / 4).astype(complex)
RHO_TILES = _build_tiles()
def noisy_tiles(p):
"""rho(p) = p * rho_Tiles + (1-p) * I/9"""
return p * RHO_TILES + (1 - p) * I9 / 9
# --- Qutrit Werner state (antisymmetric-subspace family), Werner 1989 ---
def _swap_matrix(dim=3):
V = np.zeros((dim * dim, dim * dim))
for a in range(dim):
for b in range(dim):
V[b * dim + a, a * dim + b] = 1
return V
SWAP_3 = _swap_matrix(3)
P_ANTI = (np.eye(9) - SWAP_3) / 2
DIM_ANTI = np.trace(P_ANTI).real # = 3
def werner_qutrit(p):
"""rho(p) = p * P_anti/3 + (1-p) * I/9. Known exact separability
threshold: p = 1/(d+1) = 1/4 (Werner 1989)."""
return p * P_ANTI / DIM_ANTI + (1 - p) * I9 / 9
# --- Correlation matrix / shadow-map criterion (paper Section "tensor
# viewpoint" / Tiles benchmark) ---
def correlation_matrix(rho):
T = np.zeros((8, 8))
for i in range(8):
for j in range(8):
T[i, j] = np.trace(rho @ opA(GELLMANN[i]) @ opB(GELLMANN[j])).real
return T
def shadow_map_nuclear_norm(rho):
"""||M_A(rho)||_*, normalization sqrt((d_A-1)(d_B-1)) = 2 for qutrits.
Separable states satisfy this <= 1 (Theorem "cut-bound" in the note)."""
T = correlation_matrix(rho)
return np.linalg.svd(T / 2.0, compute_uv=False).sum()
# --- Plain PPT (Peres-Horodecki) check ---
def plain_ppt_min_eig(rho, dim=3):
rho_pt = np.zeros((dim * dim, dim * dim), dtype=complex)
for a in range(dim):
for b in range(dim):
for ap in range(dim):
for bp in range(dim):
i, j = a * dim + b, ap * dim + bp
i2, j2 = a * dim + bp, ap * dim + b
rho_pt[i2, j2] = rho[i, j]
return np.linalg.eigvalsh(rho_pt).min()
def plain_ppt_feasible(rho, dim=3, tol=1e-9):
return plain_ppt_min_eig(rho, dim) >= -tol
# --- numpy partial traces, used only by the operator-Sinkhorn filter ---
def partial_trace_B_np(X, dim=3):
T = X.reshape(dim, dim, dim, dim)
return np.einsum('ikjk->ij', T)
def partial_trace_A_np(X, dim=3):
T = X.reshape(dim, dim, dim, dim)
return np.einsum('kikj->ij', T)
def _inv_sqrt_psd(M, eps=1e-12):
w, v = np.linalg.eigh(M)
w = np.clip(w, eps, None)
return (v * (w ** -0.5)) @ v.conj().T
def operator_sinkhorn(rho, dim=3, max_iter=3000, tol=1e-11, verbose=False):
"""Local-filtering (SLOCC) normal-form algorithm: alternately rescale
each side by (reduced state)^{-1/2} until both marginals are maximally
mixed. Standard algorithm (Verstraete-Dehaene-DeMoor 2001/2003); the
resulting fixed point is the Leinaas-Myrheim-Ovrum (2006) normal form."""
X = rho.copy() / np.trace(rho).real
devA = devB = None
for it in range(max_iter):
rhoA = partial_trace_B_np(X, dim)
rhoA /= np.trace(rhoA).real
devA = np.linalg.norm(rhoA - np.eye(dim) / dim)
FA = np.kron(_inv_sqrt_psd(rhoA), np.eye(dim))
X = FA @ X @ FA.conj().T
X /= np.trace(X).real
rhoB = partial_trace_A_np(X, dim)
rhoB /= np.trace(rhoB).real
devB = np.linalg.norm(rhoB - np.eye(dim) / dim)
FB = np.kron(np.eye(dim), _inv_sqrt_psd(rhoB))
X = FB @ X @ FB.conj().T
X /= np.trace(X).real
if devA < tol and devB < tol:
if verbose:
print(f" Sinkhorn converged after {it + 1} iterations")
break
else:
if verbose:
print(f" Sinkhorn did NOT fully converge in {max_iter} iters "
f"(devA={devA:.2e}, devB={devB:.2e})")
return X

View file

@ -0,0 +1,110 @@
"""
dps_hierarchy.py
General DPS (Doherty-Parrilo-Spedalieri) level-k symmetric-extension SDP,
for a bipartite qudit state rho_AB with local dimension d, extending party
B to k Bose-symmetric copies.
Key design point (discussed at length in the chat this was extracted
from): the SDP *variable* sigma is parametrized directly on
A x Sym^k(C^d), dimension d * C(d+k-1,k) -- POLYNOMIAL in k. But the PPT
constraint (sigma^{T_A} >= 0) must be checked on the full, unsymmetrized
embedding A x B_1 x ... x B_k, dimension d^{k+1} -- EXPONENTIAL in k. So
this construction saves on free parameters but NOT on the size of the
PSD cone that actually drives SDP solve time. See the README for measured
timings (k=2: ~27x27 cone, sub-second; k=3: ~81x81 cone, ~2-4 minutes
with SCS in the original sandbox).
Requires: numpy, cvxpy.
"""
import math
from itertools import permutations
import numpy as np
import cvxpy as cp
def sym_isometry(d, k):
"""Isometry W, shape (d**k, dim Sym^k(C^d)), spanning the totally
symmetric subspace of (C^d)^{tensor k}. Built by brute-force averaging
over all k! permutations of the k tensor factors -- fine for k up to
~6-7; for larger k this construction itself becomes the bottleneck,
independently of the SDP."""
n = d ** k
P = np.zeros((n, n))
for perm in permutations(range(k)):
M = np.zeros((n, n))
for idx in np.ndindex(*([d] * k)):
new_idx = tuple(idx[perm[i]] for i in range(k))
row = 0
col = 0
for i in range(k):
row = row * d + new_idx[i]
col = col * d + idx[i]
M[row, col] = 1
P += M
P /= math.factorial(k)
eigvals, eigvecs = np.linalg.eigh(P)
cols = [eigvecs[:, i] for i in range(n) if abs(eigvals[i] - 1) < 1e-9]
return np.column_stack(cols)
def partial_trace_keep_first_copy(full_expr, d, k):
"""full_expr indexed by (a, b_1, ..., b_k) with combined index
a*d**k + b_1*d**(k-1) + ... + b_k. Traces out b_2..b_k, keeping (a,b_1)
-- i.e. returns the marginal on A x (first copy of B)."""
rest_dim = d ** (k - 1)
rows = []
for a in range(d):
for b1 in range(d):
row = []
for ap in range(d):
for b1p in range(d):
terms = [full_expr[(a * d + b1) * rest_dim + r,
(ap * d + b1p) * rest_dim + r]
for r in range(rest_dim)]
row.append(sum(terms))
rows.append(row)
return cp.bmat(rows)
def partial_transpose_first_system(full_expr, d1, d2):
"""Partial transpose on the first (d1-dim) system of a
(d1*d2) x (d1*d2) matrix. Has the same eigenvalues as transposing the
second system instead (standard fact: M^{T_A} and M^{T_B} always share
a spectrum, since M^{T_B} = (M^{T_A})^T)."""
rows = []
for i in range(d1):
for kk in range(d2):
row = []
for j in range(d1):
for l in range(d2):
row.append(full_expr[j * d2 + kk, i * d2 + l])
rows.append(row)
return cp.bmat(rows)
def build_dps_problem(d, k):
"""Returns (prob, rho_param, sigma) for the level-k DPS feasibility
SDP. Set rho_param.value = <(d*d)x(d*d) target state>, then call
dps_feasible(...) or prob.solve(...) directly."""
W = sym_isometry(d, k)
dim_sym = W.shape[1]
Iso = np.kron(np.eye(d), W) # d**(k+1) x (d * dim_sym)
sigma = cp.Variable((d * dim_sym, d * dim_sym), hermitian=True)
full = Iso @ sigma @ Iso.conj().T
ptrace = partial_trace_keep_first_copy(full, d, k)
pt = partial_transpose_first_system(full, d1=d, d2=d ** k)
rho_param = cp.Parameter((d * d, d * d), hermitian=True)
constraints = [sigma >> 0, cp.trace(sigma) == 1,
ptrace == rho_param, pt >> 0]
prob = cp.Problem(cp.Minimize(0), constraints)
return prob, rho_param, sigma
def dps_feasible(prob, rho_param, rho_target, solver=cp.SCS, **solve_kwargs):
"""Solve the (already-built) DPS problem for a given target state and
return True iff a valid extension was found (i.e. rho_target is NOT
certified entangled at this level)."""
rho_param.value = rho_target
prob.solve(solver=solver, **solve_kwargs)
return prob.status in ("optimal", "optimal_inaccurate")

View file

@ -0,0 +1,70 @@
{
"lo": 0.9098203125,
"hi": 0.9103105468749999,
"iter": 9,
"log": [
{
"iter": 1,
"p": 0.8254999999999999,
"feasible": true,
"time_s": 144.7,
"status": "optimal"
},
{
"iter": 2,
"p": 0.88825,
"feasible": true,
"time_s": 223.2,
"status": "optimal_inaccurate"
},
{
"iter": 3,
"p": 0.9196249999999999,
"feasible": false,
"time_s": 135.9,
"status": "infeasible"
},
{
"iter": 4,
"p": 0.9039375,
"feasible": true,
"time_s": 226.1,
"status": "optimal_inaccurate"
},
{
"iter": 5,
"p": 0.91178125,
"feasible": false,
"time_s": 139.1,
"status": "infeasible"
},
{
"iter": 6,
"p": 0.9078593749999999,
"feasible": true,
"time_s": 226.9,
"status": "optimal_inaccurate"
},
{
"iter": 7,
"p": 0.9098203125,
"feasible": true,
"time_s": 227.1,
"status": "optimal_inaccurate"
},
{
"iter": 8,
"p": 0.91080078125,
"feasible": false,
"time_s": 139.0,
"status": "infeasible"
},
{
"iter": 9,
"p": 0.9103105468749999,
"feasible": false,
"time_s": 83.2,
"status": "infeasible"
}
]
}

View file

@ -0,0 +1,70 @@
{
"lo": 0.90107421875,
"hi": 0.9013671875,
"iter": 9,
"log": [
{
"iter": 1,
"p": 0.875,
"feasible": true,
"time_s": 140.1,
"status": "optimal_inaccurate"
},
{
"iter": 2,
"p": 0.9125,
"feasible": false,
"time_s": 83.4,
"status": "infeasible"
},
{
"iter": 3,
"p": 0.89375,
"feasible": true,
"time_s": 146.0,
"status": "optimal_inaccurate"
},
{
"iter": 4,
"p": 0.903125,
"feasible": false,
"time_s": 84.0,
"status": "infeasible"
},
{
"iter": 5,
"p": 0.8984375,
"feasible": true,
"time_s": 149.6,
"status": "optimal_inaccurate"
},
{
"iter": 6,
"p": 0.90078125,
"feasible": true,
"time_s": 148.3,
"status": "optimal_inaccurate"
},
{
"iter": 7,
"p": 0.9019531249999999,
"feasible": false,
"time_s": 84.1,
"status": "infeasible"
},
{
"iter": 8,
"p": 0.9013671875,
"feasible": false,
"time_s": 84.6,
"status": "infeasible"
},
{
"iter": 9,
"p": 0.90107421875,
"feasible": true,
"time_s": 148.0,
"status": "optimal_inaccurate"
}
]
}

View file

@ -0,0 +1,8 @@
numpy
sympy
cvxpy
scs
# Optional, much faster for the DPS SDPs (03, 04, 06, 07, 08) if you have
# a license (free for academics): mosek, and set SOLVER = cp.MOSEK in
# those scripts.