feat: add numeric and symbolic scripts
This commit is contained in:
parent
6ea7900b55
commit
e80b7c3582
38 changed files with 3314 additions and 0 deletions
BIN
scripts/O_seed.npz
Normal file
BIN
scripts/O_seed.npz
Normal file
Binary file not shown.
|
|
@ -79,3 +79,87 @@ When adding another script, add a short entry above with:
|
|||
- required dependencies beyond NumPy, if any;
|
||||
- the paper values, table, or figure it reproduces;
|
||||
- notes about normalization conventions if the output includes both raw and normalized quantities.
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Shadow-Map GME-Analyse: Skriptpaket
|
||||
|
||||
Alle Skripte aus der Diskussion zu "Symmetric Shadow Maps and Multipartite Correlation
|
||||
Criteria", konsolidiert und bereinigt (keine überholten/fehlerhaften Zwischenversionen).
|
||||
Qubit-Reihenfolge durchgehend (A,B,C,D) = (0,1,2,3).
|
||||
|
||||
## Grundmodule (werden von allem anderen importiert)
|
||||
|
||||
- **core.py** -- Parametrisierung reiner biseparabler 4-Qubit-Zustände
|
||||
(`state_1_3`, `state_2_2`, `random_pure_state`).
|
||||
- **core2.py** -- schnelle, vektorisierte Berechnung des vollen Pauli-Korrelationstensors
|
||||
und der Einzelpartei-Shadow-Map (`full_tensor`, `phi_sym`).
|
||||
- **cluster.py** -- die volle 15×15 bigraduierte Cluster-Shadow-Map `M_S` für
|
||||
Zwei-Qubit-Quellcluster (`cluster_map`), plus Tensor-Extraktion für gemischte Zustände.
|
||||
- **subblock.py** -- der "fully-active" 9×9-Unterblock von `M_S`.
|
||||
- **party_blocks.py** -- rohe Einzelpartei-zu-Einzelpartei 3×3-Korrelationsblöcke.
|
||||
|
||||
## Kapitel 1: Universelle Sättigungs-Decke (bewiesen)
|
||||
|
||||
- **universal_ceiling.py** -- geschlossene Formel für das globale Maximum von
|
||||
`||M_S||_*` über *alle* Zustände (Cauchy-Schwarz + Spur-Identität), reproduziert
|
||||
`sqrt(6)`, `6/sqrt(7)`, `2.19089...` (n=3,4,5) und `5.0` (2-Qubit-Cluster, n=4) exakt.
|
||||
|
||||
## Kapitel 2: Warum Φ_sym und einzelne M_S keine GME-Zeugen sind
|
||||
|
||||
- **optimize_phi_sym.py** -- zeigt, dass die biseparable Suprema von Φ_sym (2|2- und
|
||||
1|3-Schnitt) und das globale Supremum über *alle* Zustände identisch sind.
|
||||
- **optimize_cluster_witness.py** -- zeigt dasselbe für einzelne `||M_AB||_*`, plus
|
||||
erste (noch zu enge) Suche nach `min(M_AB,M_AC,M_AD)` über 1|3-biseparable Zustände.
|
||||
- **pairwise_correlation_demo.py** -- Exkurs: warum paarweise Korrelationsblöcke
|
||||
ebenfalls kein sauberes Kriterium liefern (Graphzustände haben selbst verschwindende
|
||||
Paar-Blöcke).
|
||||
|
||||
## Kapitel 3: Der funktionierende GME-Zeuge `min(M_AB,M_AC,M_AD)`
|
||||
|
||||
- **mixture_search.py** -- Kernstück: exakte geschlossene Form für die
|
||||
Bell-Paar-Mischungsfamilie (`p=1/2` optimal, Wert exakt 3), plus allgemeine
|
||||
Mischungssuchen (2/3/4/6 Komponenten), die alle bei 3.0 landen.
|
||||
- **sum_bound_proof.py** -- der rigorose Beweis der Schranke `<= 11/3` (Summe statt
|
||||
Minimum ist konvex; LP bestätigt, dass Gleichgewichtung optimal für diese Technik ist).
|
||||
|
||||
## Kapitel 4: SDP-Verschärfung über PPT-Mixtures
|
||||
|
||||
Benötigt zusätzlich `pip install cvxpy` (Solver SCS ist enthalten).
|
||||
|
||||
- **sdp_ppt_mixture.py** -- PPT-Mixture-SDP + alternierendes (Frank-Wolfe-artiges)
|
||||
Suchschema über duale Zeugen. **Wichtig:** `order='C'` in beiden `cp.reshape`-Aufrufen
|
||||
ist notwendig (Standard-Fortran-Order von cvxpy passt sonst nicht zur row-major
|
||||
Konstruktion von `PT_MATRIX`/`COEFF`).
|
||||
- **compute_seed_witnesses.py** -- erzeugt `O_seed.npz` (optimale duale Zeugen für den
|
||||
bekannten 3.0-Punkt). Vor den beiden folgenden Skripten einmal ausführen.
|
||||
- **diagnostic_seeded_run.py** -- Sanity-Check: ein einzelner SDP-Schritt mit den
|
||||
exakten Zeugen des bekannten Punktes muss `t >= 3.0` liefern (sonst Implementierungsfehler).
|
||||
- **seeded_exploration.py** -- Stabilitäts-/Störungstest um den bekannten Punkt herum,
|
||||
über die *volle* PPT-Mixture-Menge (echte Obermenge der biseparablen Zustände).
|
||||
|
||||
## Empfohlene Ausführungsreihenfolge
|
||||
|
||||
```bash
|
||||
python3 universal_ceiling.py
|
||||
python3 optimize_phi_sym.py # dauert ~1-2 Minuten
|
||||
python3 optimize_cluster_witness.py # dauert ~1-2 Minuten
|
||||
python3 pairwise_correlation_demo.py
|
||||
python3 mixture_search.py # die Mischungs-Optimierungen sind langsam (Powell
|
||||
# skaliert schlecht mit der Parameterzahl) --
|
||||
# einzelne Restarts können 1-2 Minuten dauern
|
||||
python3 sum_bound_proof.py
|
||||
|
||||
# für Kapitel 4 (SDP):
|
||||
pip install cvxpy
|
||||
python3 compute_seed_witnesses.py
|
||||
python3 diagnostic_seeded_run.py
|
||||
python3 seeded_exploration.py
|
||||
```
|
||||
|
||||
## Status der zentralen Vermutung
|
||||
|
||||
- **Bewiesen:** `min(M_AB,M_AC,M_AD) <= 11/3` für jeden biseparablen Zustand.
|
||||
- **Numerisch sehr robust (mehrere unabhängige Methoden, inkl. SDP über die größere
|
||||
PPT-Mixture-Menge), aber nicht formal bewiesen:** die wahre Schranke ist exakt `3.0`.
|
||||
|
|
|
|||
50
scripts/cluster.py
Normal file
50
scripts/cluster.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""cluster.py -- the full 15x15 bigraduated cluster shadow map M_S for a 2-qubit source
|
||||
cluster S (within 4 qubits), and helpers for building the Pauli tensor of a general
|
||||
(possibly mixed) density matrix."""
|
||||
import numpy as np
|
||||
|
||||
I2 = np.eye(2, dtype=complex)
|
||||
X = np.array([[0, 1], [1, 0]], dtype=complex)
|
||||
Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
|
||||
Z = np.array([[1, 0], [0, -1]], dtype=complex)
|
||||
_PAULI = [I2, X, Y, Z]
|
||||
|
||||
|
||||
def cluster_map(C, s0, s1):
|
||||
"""C: (4,4,4,4) Pauli correlation tensor. S = {s0,s1} (source cluster), complement =
|
||||
the other two qubits. Returns the normalized 15x15 matrix M_S(rho), normalization
|
||||
1/sqrt((d_S-1)(d_Sc-1)) = 1/sqrt(3*3) = 1/3 for two-qubit clusters in 4 qubits."""
|
||||
others = [k for k in range(4) if k not in (s0, s1)]
|
||||
c0, c1 = others
|
||||
rows = [(i, j) for i in range(4) for j in range(4) if (i, j) != (0, 0)]
|
||||
cols = [(i, j) for i in range(4) for j in range(4) if (i, j) != (0, 0)]
|
||||
M = np.zeros((15, 15))
|
||||
for ri, (ia, ib) in enumerate(rows):
|
||||
for ci, (ic, idd) in enumerate(cols):
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[s0] = ia
|
||||
idx[s1] = ib
|
||||
idx[c0] = ic
|
||||
idx[c1] = idd
|
||||
M[ri, ci] = C[tuple(idx)]
|
||||
return M / 3.0
|
||||
|
||||
|
||||
def nuc(M):
|
||||
return np.linalg.svd(M, compute_uv=False).sum()
|
||||
|
||||
|
||||
def full_tensor_mixed(rho):
|
||||
"""Pauli-tensor extraction for a general (possibly mixed) 16x16 density matrix rho,
|
||||
via direct trace. Slower than core2.full_tensor (which needs a pure-state vector)
|
||||
but works for explicit mixtures (e.g. the Smolin state)."""
|
||||
def kron4(a, b, c, d):
|
||||
return np.kron(np.kron(a, b), np.kron(c, d))
|
||||
C = np.zeros((4, 4, 4, 4))
|
||||
for i0 in range(4):
|
||||
for i1 in range(4):
|
||||
for i2 in range(4):
|
||||
for i3 in range(4):
|
||||
op = kron4(_PAULI[i0], _PAULI[i1], _PAULI[i2], _PAULI[i3])
|
||||
C[i0, i1, i2, i3] = np.real(np.trace(rho @ op))
|
||||
return C
|
||||
43
scripts/compute_seed_witnesses.py
Normal file
43
scripts/compute_seed_witnesses.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""compute_seed_witnesses.py -- computes and saves the exact optimal dual witnesses
|
||||
O_AB, O_AC, O_AD (each with operator norm 1) for the known-good state
|
||||
|
||||
rho_mix = 0.5 * (Bell_AB x Bell_CD) + 0.5 * (Bell_AC x Bell_BD)
|
||||
|
||||
which is a manifestly valid PPT-mixture state (explicit convex combination of two
|
||||
product states) scoring EXACTLY (3.0, 3.0, 3.0) for
|
||||
(||M_AB||_*, ||M_AC||_*, ||M_AD||_*) -- see mixture_search.py for the derivation.
|
||||
|
||||
Run this once to produce O_seed.npz, which diagnostic_seeded_run.py and
|
||||
seeded_exploration.py both load.
|
||||
"""
|
||||
import numpy as np
|
||||
from cluster import cluster_map, nuc
|
||||
from mixture_search import bellpair_state, full_tensor_mixed_fast
|
||||
|
||||
CLUSTERS = [('AB', 0, 1), ('AC', 0, 2), ('AD', 0, 3)]
|
||||
|
||||
|
||||
def true_norms_and_witnesses(M_vals):
|
||||
norms, O_opt = {}, {}
|
||||
for name, M in M_vals.items():
|
||||
U, s, Vt = np.linalg.svd(M)
|
||||
norms[name] = s.sum()
|
||||
O_opt[name] = U @ Vt
|
||||
return norms, O_opt
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
psi1 = bellpair_state(((0, 1), (2, 3)))
|
||||
psi2 = bellpair_state(((0, 2), (1, 3)))
|
||||
rho_mix = 0.5 * np.outer(psi1, np.conj(psi1)) + 0.5 * np.outer(psi2, np.conj(psi2))
|
||||
|
||||
C = full_tensor_mixed_fast(rho_mix)
|
||||
M_vals = {name: cluster_map(C, s0, s1) for name, s0, s1 in CLUSTERS}
|
||||
|
||||
norms, O_seed = true_norms_and_witnesses(M_vals)
|
||||
print('norms at rho_mix:', norms, ' (expect all == 3.0)')
|
||||
for k, v in O_seed.items():
|
||||
print(f' operator norm of O_seed[{k}] =', np.linalg.norm(v, ord=2), '(should be 1.0)')
|
||||
|
||||
np.savez('O_seed.npz', **O_seed)
|
||||
print('Saved O_seed.npz')
|
||||
61
scripts/core.py
Normal file
61
scripts/core.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""core.py -- pure-state parametrizations for biseparable 4-qubit states.
|
||||
|
||||
state_1_3(params, source): pure state, product across `source` | (other 3 qubits).
|
||||
state_2_2(params, pair1, pair2): pure state, product across pair1 | pair2 (each a
|
||||
2-qubit tuple of indices).
|
||||
|
||||
Qubit order throughout this project is (A,B,C,D) = (0,1,2,3), and a 16-dim state
|
||||
vector is indexed linearly as idx = i0*8 + i1*4 + i2*2 + i3.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def random_pure_state(dim, rng):
|
||||
v = rng.normal(size=dim) + 1j * rng.normal(size=dim)
|
||||
return v / np.linalg.norm(v)
|
||||
|
||||
|
||||
def state_1_3(params, source):
|
||||
"""Pure state product across `source` (single qubit) | (other 3 qubits).
|
||||
params: 18 reals = 2 (single-qubit Bloch angles theta,phi) + 16 (3-qubit target
|
||||
complex amplitudes, given as 8 real + 8 imaginary parts)."""
|
||||
theta, phi = params[0], params[1]
|
||||
q_src = np.array([np.cos(theta / 2), np.exp(1j * phi) * np.sin(theta / 2)], dtype=complex)
|
||||
tgt_re = params[2:2 + 8]
|
||||
tgt_im = params[2 + 8:2 + 16]
|
||||
q_tgt = tgt_re + 1j * tgt_im
|
||||
q_tgt = q_tgt / np.linalg.norm(q_tgt)
|
||||
others = [k for k in range(4) if k != source]
|
||||
full = np.zeros(16, dtype=complex)
|
||||
for s in range(2):
|
||||
for t_idx in range(8):
|
||||
bits = [(t_idx >> 2) & 1, (t_idx >> 1) & 1, t_idx & 1]
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[source] = s
|
||||
for oi, o in enumerate(others):
|
||||
idx[o] = bits[oi]
|
||||
lin = idx[0] * 8 + idx[1] * 4 + idx[2] * 2 + idx[3]
|
||||
full[lin] = q_src[s] * q_tgt[t_idx]
|
||||
return full
|
||||
|
||||
|
||||
def state_2_2(params, pair1, pair2):
|
||||
"""Pure state product across pair1 | pair2 (each a 2-qubit index tuple).
|
||||
params: 16 reals = 8 (pair1 complex amplitudes) + 8 (pair2 complex amplitudes)."""
|
||||
p1 = params[0:4] + 1j * params[4:8]
|
||||
p1 = p1 / np.linalg.norm(p1)
|
||||
p2 = params[8:12] + 1j * params[12:16]
|
||||
p2 = p2 / np.linalg.norm(p2)
|
||||
full = np.zeros(16, dtype=complex)
|
||||
for a in range(2):
|
||||
for b in range(2):
|
||||
for c in range(2):
|
||||
for d in range(2):
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[pair1[0]] = a
|
||||
idx[pair1[1]] = b
|
||||
idx[pair2[0]] = c
|
||||
idx[pair2[1]] = d
|
||||
lin = idx[0] * 8 + idx[1] * 4 + idx[2] * 2 + idx[3]
|
||||
full[lin] = p1[a * 2 + b] * p2[c * 2 + d]
|
||||
return full
|
||||
61
scripts/core2.py
Normal file
61
scripts/core2.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""core2.py -- fast, vectorized computation of the 4-qubit Pauli/Bloch correlation
|
||||
tensor and the single-party combined shadow map M_a, plus the source-aggregated
|
||||
functional Phi_sym.
|
||||
|
||||
C[i0,i1,i2,i3] = tr(rho * sigma_i0 x sigma_i1 x sigma_i2 x sigma_i3), i_k in {0,1,2,3}
|
||||
(0 = identity, 1,2,3 = X,Y,Z), for a PURE state psi (rho = |psi><psi|).
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
I2 = np.eye(2, dtype=complex)
|
||||
X = np.array([[0, 1], [1, 0]], dtype=complex)
|
||||
Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
|
||||
Z = np.array([[1, 0], [0, -1]], dtype=complex)
|
||||
PAULI = np.stack([I2, X, Y, Z]) # shape (4,2,2), index (pauli_label, row, col)
|
||||
|
||||
|
||||
def full_tensor(psi):
|
||||
"""psi: length-16 complex state vector, qubit order (A,B,C,D).
|
||||
Returns the (4,4,4,4) real Pauli correlation tensor."""
|
||||
t = psi.reshape(2, 2, 2, 2)
|
||||
tc = np.conj(t)
|
||||
C = np.einsum('abcd,iae,jbf,kcg,ldh,efgh->ijkl',
|
||||
tc, PAULI, PAULI, PAULI, PAULI, t, optimize=True)
|
||||
return C.real
|
||||
|
||||
|
||||
# all (j0,j1,j2) index combinations excluding the all-identity (0,0,0) target sector
|
||||
_COLS = np.array([(j0, j1, j2) for j0 in range(4) for j1 in range(4) for j2 in range(4)
|
||||
if (j0, j1, j2) != (0, 0, 0)])
|
||||
|
||||
|
||||
def shadow_map_singleparty(C, a):
|
||||
"""Normalized combined single-party shadow map M_a(rho): 3 x 63 real matrix,
|
||||
normalization 1/sqrt(2^(n-1)-1) = 1/sqrt(7) for n=4 qubits."""
|
||||
others = [k for k in range(4) if k != a]
|
||||
rows = np.array([1, 2, 3])
|
||||
n_cols = len(_COLS)
|
||||
Ridx = np.repeat(rows, n_cols)
|
||||
J0 = np.tile(_COLS[:, 0], 3)
|
||||
J1 = np.tile(_COLS[:, 1], 3)
|
||||
J2 = np.tile(_COLS[:, 2], 3)
|
||||
idx_full = [None] * 4
|
||||
idx_full[a] = Ridx
|
||||
idx_full[others[0]] = J0
|
||||
idx_full[others[1]] = J1
|
||||
idx_full[others[2]] = J2
|
||||
vals = C[idx_full[0], idx_full[1], idx_full[2], idx_full[3]]
|
||||
M = vals.reshape(3, n_cols)
|
||||
return M / np.sqrt(7.0)
|
||||
|
||||
|
||||
def nuclear_norm(M):
|
||||
return np.linalg.svd(M, compute_uv=False).sum()
|
||||
|
||||
|
||||
def phi_sym(psi):
|
||||
"""Phi_sym(rho) = (1/4) sum_a ||M_a(rho)||_*, for a pure 4-qubit state psi.
|
||||
Returns (mean_value, [values per party A,B,C,D])."""
|
||||
C = full_tensor(psi)
|
||||
vals = [nuclear_norm(shadow_map_singleparty(C, a)) for a in range(4)]
|
||||
return float(np.mean(vals)), vals
|
||||
54
scripts/diagnostic_seeded_run.py
Normal file
54
scripts/diagnostic_seeded_run.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""
|
||||
DIAGNOSTIC: run a single SDP step seeded with the EXACT optimal dual witnesses of the
|
||||
known-good point rho_mix = 0.5*(Bell_AB x Bell_CD) + 0.5*(Bell_AC x Bell_BD), which is a
|
||||
manifestly valid PPT-mixture state (explicit convex combination of two product states)
|
||||
scoring EXACTLY (3.0, 3.0, 3.0) for (||M_AB||_*, ||M_AC||_*, ||M_AD||_*).
|
||||
|
||||
Since rho_mix is feasible and, for these SPECIFIC witnesses O_S = U_S V_S^T (from its own
|
||||
SVD), achieves t = sum_S tr(O_S^T M_S(rho_mix)) = 3.0 exactly, any correct implementation
|
||||
of
|
||||
|
||||
max_{rho in PPT-mixtures} min_S tr(O_S^T M_S(rho))
|
||||
|
||||
MUST return an optimal value >= 3.0 (the SDP maximizes over a set containing rho_mix).
|
||||
|
||||
If the reported optimal t comes back < 3.0 here, that is conclusive evidence of an
|
||||
implementation bug in the SDP construction itself (not just a weakness of the alternating
|
||||
heuristic / random restarts).
|
||||
|
||||
Run this BEFORE re-running the full alternating_search -- it isolates the problem.
|
||||
"""
|
||||
import numpy as np
|
||||
from sdp_ppt_mixture import solve_fixed_witness_step, true_norms_and_witnesses, CLUSTERS
|
||||
|
||||
data = np.load('O_seed.npz')
|
||||
O = {name: data[name] for name, _, _ in CLUSTERS}
|
||||
|
||||
print("Loaded seed witnesses (each should have operator norm 1):")
|
||||
for name, Om in O.items():
|
||||
print(f" ||O_{name}||_op =", np.linalg.norm(Om, ord=2))
|
||||
|
||||
print()
|
||||
print("Solving ONE SDP step with these witnesses...")
|
||||
rho_val, t_val, M_vals = solve_fixed_witness_step(O, verbose=True)
|
||||
|
||||
print()
|
||||
print("SDP optimal t =", t_val)
|
||||
print("Expected: t >= 3.0 (since rho_mix itself is feasible and scores exactly 3.0 here)")
|
||||
print()
|
||||
norms, _ = true_norms_and_witnesses(M_vals)
|
||||
print("True nuclear norms of the returned optimal rho:", norms)
|
||||
|
||||
if t_val < 2.99:
|
||||
print()
|
||||
print("!!! t < 3.0 -- there IS an implementation bug in the SDP construction. !!!")
|
||||
print("Next diagnostic step: check prob.status, and manually verify PSD/PPT of")
|
||||
print("rho_val's 7 constituent blocks (may need to re-solve while keeping rho_gammas")
|
||||
print("accessible, i.e. return them from solve_fixed_witness_step for inspection).")
|
||||
else:
|
||||
print()
|
||||
print("t >= 3.0 as expected: the SDP construction is correct.")
|
||||
print("The earlier runs' convergence to 7/3 was the alternating heuristic getting")
|
||||
print("stuck in a (large-basin) symmetric fixed point from random starts -- not a bug.")
|
||||
print("Fix: warm-start alternating_search from these O_seed witnesses (or from a small")
|
||||
print("random perturbation of them) instead of purely random O's.")
|
||||
55
scripts/diagnostic_seeded_run_1.py
Normal file
55
scripts/diagnostic_seeded_run_1.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""
|
||||
DIAGNOSTIC: run a single SDP step seeded with the EXACT optimal dual witnesses of the
|
||||
known-good point rho_mix = 0.5*(Bell_AB x Bell_CD) + 0.5*(Bell_AC x Bell_BD), which is a
|
||||
manifestly valid PPT-mixture state (explicit convex combination of two product states)
|
||||
scoring EXACTLY (3.0, 3.0, 3.0) for (||M_AB||_*, ||M_AC||_*, ||M_AD||_*).
|
||||
|
||||
Since rho_mix is feasible and, for these SPECIFIC witnesses O_S = U_S V_S^T (from its own
|
||||
SVD), achieves t = sum_S tr(O_S^T M_S(rho_mix)) = 3.0 exactly, any correct implementation
|
||||
of
|
||||
|
||||
max_{rho in PPT-mixtures} min_S tr(O_S^T M_S(rho))
|
||||
|
||||
MUST return an optimal value >= 3.0 (the SDP maximizes over a set containing rho_mix).
|
||||
|
||||
If the reported optimal t comes back < 3.0 here, that is conclusive evidence of an
|
||||
implementation bug in the SDP construction itself (not just a weakness of the alternating
|
||||
heuristic / random restarts).
|
||||
|
||||
Run this BEFORE re-running the full alternating_search -- it isolates the problem.
|
||||
"""
|
||||
import numpy as np
|
||||
import cvxpy as cp
|
||||
from sdp_ppt_mixture import solve_fixed_witness_step, CLUSTERS
|
||||
|
||||
data = np.load('O_seed.npz')
|
||||
O = {name: data[name] for name, _, _ in CLUSTERS}
|
||||
|
||||
print("Loaded seed witnesses (each should have operator norm 1):")
|
||||
for name, Om in O.items():
|
||||
print(f" ||O_{name}||_op =", np.linalg.norm(Om, ord=2))
|
||||
|
||||
print()
|
||||
print("Solving ONE SDP step with these witnesses...")
|
||||
rho_val, t_val, M_vals = solve_fixed_witness_step(O, verbose=True)
|
||||
|
||||
print()
|
||||
print("SDP optimal t =", t_val)
|
||||
print("Expected: t >= 3.0 (since rho_mix itself is feasible and scores exactly 3.0 here)")
|
||||
print()
|
||||
norms, _ = __import__('sdp_ppt_mixture').true_norms_and_witnesses(M_vals)
|
||||
print("True nuclear norms of the returned optimal rho:", norms)
|
||||
|
||||
if t_val < 2.99:
|
||||
print()
|
||||
print("!!! t < 3.0 -- there IS an implementation bug in the SDP construction. !!!")
|
||||
print("Next diagnostic step: check prob.status, and manually verify PSD/PPT of")
|
||||
print("rho_val's 7 constituent blocks (may need to re-solve while keeping rho_gammas")
|
||||
print("accessible, i.e. return them from solve_fixed_witness_step for inspection).")
|
||||
else:
|
||||
print()
|
||||
print("t >= 3.0 as expected: the SDP construction is correct.")
|
||||
print("The earlier runs' convergence to 7/3 was the alternating heuristic getting")
|
||||
print("stuck in a (large-basin) symmetric fixed point from random starts -- not a bug.")
|
||||
print("Fix: warm-start alternating_search from these O_seed witnesses (or from a small")
|
||||
print("random perturbation of them) instead of purely random O's.")
|
||||
96
scripts/dps_hierarchy/01_werner_qubit_symbolic.py
Normal file
96
scripts/dps_hierarchy/01_werner_qubit_symbolic.py
Normal 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))
|
||||
103
scripts/dps_hierarchy/02_werner_qutrit_symbolic.py
Normal file
103
scripts/dps_hierarchy/02_werner_qutrit_symbolic.py
Normal 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.")
|
||||
54
scripts/dps_hierarchy/03_dps_level2_demo.py
Normal file
54
scripts/dps_hierarchy/03_dps_level2_demo.py
Normal 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}")
|
||||
50
scripts/dps_hierarchy/04_tiles_noise_scan.py
Normal file
50
scripts/dps_hierarchy/04_tiles_noise_scan.py
Normal 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}%)")
|
||||
42
scripts/dps_hierarchy/05_local_filtering.py
Normal file
42
scripts/dps_hierarchy/05_local_filtering.py
Normal 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%)")
|
||||
42
scripts/dps_hierarchy/06_dps_level2_filtered.py
Normal file
42
scripts/dps_hierarchy/06_dps_level2_filtered.py
Normal 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}%)")
|
||||
30
scripts/dps_hierarchy/07_dps_level3_single.py
Normal file
30
scripts/dps_hierarchy/07_dps_level3_single.py
Normal 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]")
|
||||
84
scripts/dps_hierarchy/08_dps_level_k_bisection.py
Normal file
84
scripts/dps_hierarchy/08_dps_level_k_bisection.py
Normal 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).")
|
||||
86
scripts/dps_hierarchy/09_dps_level3_filtered_bisection.py
Normal file
86
scripts/dps_hierarchy/09_dps_level3_filtered_bisection.py
Normal 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).")
|
||||
96
scripts/dps_hierarchy/README.md
Normal file
96
scripts/dps_hierarchy/README.md
Normal 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.
|
||||
167
scripts/dps_hierarchy/common.py
Normal file
167
scripts/dps_hierarchy/common.py
Normal 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
|
||||
110
scripts/dps_hierarchy/dps_hierarchy.py
Normal file
110
scripts/dps_hierarchy/dps_hierarchy.py
Normal 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")
|
||||
70
scripts/dps_hierarchy/dps_level3_bisection_state.json
Normal file
70
scripts/dps_hierarchy/dps_level3_bisection_state.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
scripts/dps_hierarchy/requirements.txt
Normal file
8
scripts/dps_hierarchy/requirements.txt
Normal 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.
|
||||
209
scripts/ghz3_perturbation_symbolic.py
Normal file
209
scripts/ghz3_perturbation_symbolic.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""
|
||||
ghz3_perturbation_symbolic.py
|
||||
|
||||
Exact symbolic (sympy) first-order degenerate perturbation theory for the
|
||||
GHZ3 shadow map under single-qubit dephasing noise on party B.
|
||||
|
||||
Background / formalism
|
||||
-----------------------
|
||||
At rho0 = GHZ3, the normalized combined shadow map M_A(rho0) has an exactly
|
||||
threefold-degenerate singular value sigma = sqrt(2/3), with orthonormal
|
||||
bases U0 (15x3, target side) and V0 = I_3 (3x3, source side) -- see
|
||||
ghz3_shadow_map_symbolic.py.
|
||||
|
||||
For a perturbation rho(eps) = rho0 + eps * delta_rho, the map itself is
|
||||
exactly linear: M_A(rho(eps)) = M_A(rho0) + eps * M_A(delta_rho).
|
||||
To first order in eps, the perturbed singular values within the degenerate
|
||||
block are
|
||||
|
||||
sigma_i(eps) = sigma + eps * lambda_i(K) + O(eps^2),
|
||||
|
||||
where K is the symmetrized projection of the perturbing map onto the
|
||||
degenerate subspace:
|
||||
|
||||
K = (1/2) * ( U0^T M_A(delta_rho) V0
|
||||
+ V0^T M_A(delta_rho)^T U0 ).
|
||||
|
||||
This is the direct analogue, for singular values, of ordinary degenerate
|
||||
perturbation theory for Hermitian eigenvalues. K is automatically real
|
||||
symmetric here because U0, V0 are real orthonormal bases.
|
||||
|
||||
Physical perturbation studied here: single-qubit dephasing on party B,
|
||||
i.e. the (unnormalized, direction-only) Lindbladian jump direction
|
||||
|
||||
delta_rho^(P) = P_B rho0 P_B - rho0, P in {X, Y, Z}.
|
||||
|
||||
P = Z models T2-type dephasing in the computational (stabilizer) basis --
|
||||
the dominant error channel on most physical qubit platforms. P = X, Y model
|
||||
dephasing along an axis that does not commute with the GHZ3 stabilizer
|
||||
group.
|
||||
|
||||
We show, exactly:
|
||||
- Z-dephasing on B: eigenvalues of K are {-2*sqrt(6)/3 (x2), 0 (x1)}
|
||||
-> the "z" target-response channel is exactly protected to first order,
|
||||
while the "x","y" channels decay at twice the generic rate.
|
||||
- X- or Y-dephasing on B: eigenvalues of K are {-sqrt(6)/3 (x3)}
|
||||
-> fully isotropic decay, no protected direction.
|
||||
|
||||
The physical reason: Z_A Z_B is a stabilizer generator of GHZ3 and commutes
|
||||
with Z_B, so the z-channel survives Z_B-dephasing unchanged to first order;
|
||||
X_AX_B, Y_AY_B do not commute with Z_B and decay.
|
||||
|
||||
A numeric finite-difference cross-check (against the singular values of the
|
||||
exactly perturbed matrix, not just the leading-order K prediction) is
|
||||
included at the end.
|
||||
|
||||
Run: python3 ghz3_perturbation_symbolic.py
|
||||
"""
|
||||
|
||||
import sympy as sp
|
||||
from sympy import sqrt, I, simplify, Matrix, eye, zeros, re, Rational, N
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 1. Rebuild the same primitives as in ghz3_shadow_map_symbolic.py
|
||||
# (kept self-contained so this script can be run standalone)
|
||||
# ---------------------------------------------------------------------
|
||||
X = Matrix([[0, 1], [1, 0]])
|
||||
Y = Matrix([[0, -I], [I, 0]])
|
||||
Z = Matrix([[1, 0], [0, -1]])
|
||||
I2 = eye(2)
|
||||
PAULIS = {'x': X, 'y': Y, 'z': Z}
|
||||
|
||||
|
||||
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 kron3(a, b, c): return kron(kron(a, b), c)
|
||||
def op_A(P): return kron3(P, I2, I2)
|
||||
def op_B(P): return kron3(I2, P, I2)
|
||||
def op_C(P): return kron3(I2, I2, P)
|
||||
|
||||
|
||||
def ghz3_state():
|
||||
psi = zeros(8, 1)
|
||||
psi[0, 0] = 1 / sqrt(2)
|
||||
psi[7, 0] = 1 / sqrt(2)
|
||||
return simplify(psi * psi.H)
|
||||
|
||||
|
||||
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())))
|
||||
|
||||
|
||||
def build_M(rho):
|
||||
"""Same 15x3 unnormalized shadow-map matrix as in the companion script."""
|
||||
rows = []
|
||||
for pb in ['x', 'y', 'z']:
|
||||
rows.append([entry(rho, [op_A(PAULIS[pa]), op_B(PAULIS[pb])])
|
||||
for pa in ['x', 'y', 'z']])
|
||||
for pc in ['x', 'y', 'z']:
|
||||
rows.append([entry(rho, [op_A(PAULIS[pa]), op_C(PAULIS[pc])])
|
||||
for pa in ['x', 'y', 'z']])
|
||||
for pb in ['x', 'y', 'z']:
|
||||
for pc in ['x', 'y', 'z']:
|
||||
rows.append([entry(rho, [op_A(PAULIS[pa]), op_B(PAULIS[pb]), op_C(PAULIS[pc])])
|
||||
for pa in ['x', 'y', 'z']])
|
||||
return Matrix(rows)
|
||||
|
||||
|
||||
NORM_CONST = 1 / sqrt(3) # combined-map normalization for n=3 qubits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 2. Degenerate-perturbation-theory machinery
|
||||
# ---------------------------------------------------------------------
|
||||
def Kmatrix(delta_rho, U0, V0):
|
||||
"""Symmetrized first-order splitting matrix for the degenerate block
|
||||
spanned by (U0, V0), given a perturbation direction delta_rho."""
|
||||
Md = simplify(NORM_CONST * build_M(delta_rho))
|
||||
A = simplify(U0.T * Md * V0)
|
||||
return simplify(Rational(1, 2) * (A + A.T))
|
||||
|
||||
|
||||
def main():
|
||||
rho0 = ghz3_state()
|
||||
M0 = build_M(rho0)
|
||||
Mn0 = simplify(NORM_CONST * M0)
|
||||
|
||||
# Degenerate subspace bases (see companion script for derivation):
|
||||
# Gram matrix Mn0^T Mn0 = (2/3) I_3 exactly, so V0 = I_3 and
|
||||
# U0 = Mn0 rescaled to unit-norm columns.
|
||||
sigma = sqrt(Rational(2, 3))
|
||||
U0 = simplify(Mn0 / sigma)
|
||||
V0 = eye(3)
|
||||
|
||||
print(f"Unperturbed degenerate singular value: sigma = {sigma} "
|
||||
f"= {float(sigma):.6f} (should be sqrt(6)/3, threefold)\n")
|
||||
|
||||
ops_B = {
|
||||
'Z (T2-type, computational-basis dephasing)': op_B(Z),
|
||||
'X': op_B(X),
|
||||
'Y': op_B(Y),
|
||||
}
|
||||
|
||||
K_store = {}
|
||||
for label, OB in ops_B.items():
|
||||
delta_rho = simplify(OB * rho0 * OB - rho0)
|
||||
K = Kmatrix(delta_rho, U0, V0)
|
||||
K_store[label] = (K, delta_rho)
|
||||
|
||||
print("=" * 70)
|
||||
print(f"Dephasing on qubit B along {label}")
|
||||
print("K =")
|
||||
sp.pprint(K)
|
||||
|
||||
eigs = K.eigenvals()
|
||||
print("Exact eigenvalues of K (first-order singular-value shifts):")
|
||||
for ev, mult in eigs.items():
|
||||
print(f" {sp.nsimplify(ev)} (multiplicity {mult}) "
|
||||
f"= {float(ev):.6f}")
|
||||
print(f"trace(K) = {simplify(sp.trace(K))} "
|
||||
f"= {float(sp.trace(K)):.6f} "
|
||||
f"(this is d/d(eps) ||M_A(rho(eps))||_* at eps=0)\n")
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# 3. Numeric finite-difference cross-check (independent of the
|
||||
# symbolic K-matrix machinery): compute the *exact* singular
|
||||
# values of M_A(rho0 + eps*delta_rho) for small eps and compare
|
||||
# to sigma + eps*lambda_i(K).
|
||||
# -------------------------------------------------------------
|
||||
print("=" * 70)
|
||||
print("Finite-difference cross-check for Z-dephasing on B")
|
||||
print("(exact singular values of the perturbed matrix vs. first-order "
|
||||
"prediction from K)\n")
|
||||
|
||||
K_Z, delta_rho_Z = K_store['Z (T2-type, computational-basis dephasing)']
|
||||
eig_list = sorted(K_Z.eigenvals().keys(), reverse=True) # e.g. [0, -2sqrt6/3, -2sqrt6/3]
|
||||
# build the multiset of 3 eigenvalues (respecting multiplicity)
|
||||
eig_multiset = []
|
||||
for ev, mult in K_Z.eigenvals().items():
|
||||
eig_multiset += [ev] * mult
|
||||
eig_multiset = sorted(eig_multiset, reverse=True)
|
||||
|
||||
for eps_val in [sp.Rational(1, 100), sp.Rational(1, 1000)]:
|
||||
rho_eps = rho0 + eps_val * delta_rho_Z
|
||||
M_eps = simplify(NORM_CONST * build_M(rho_eps))
|
||||
G_eps = simplify(M_eps.T * M_eps)
|
||||
sv_exact = sorted([sp.sqrt(ev) for ev in G_eps.eigenvals().keys()
|
||||
for _ in range(G_eps.eigenvals()[ev])],
|
||||
key=lambda v: float(v), reverse=True)
|
||||
sv_predicted = sorted([sigma + eps_val * ev for ev in eig_multiset],
|
||||
key=lambda v: float(v), reverse=True)
|
||||
print(f"eps = {eps_val} :")
|
||||
print(" exact singular values:", [f"{float(v):.6f}" for v in sv_exact])
|
||||
print(" 1st-order prediction :", [f"{float(v):.6f}" for v in sv_predicted])
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
158
scripts/ghz3_shadow_map_symbolic.py
Normal file
158
scripts/ghz3_shadow_map_symbolic.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""
|
||||
ghz3_shadow_map_symbolic.py
|
||||
|
||||
Exact symbolic (sympy) construction of the combined shadow map M_A(rho) for the
|
||||
three-qubit GHZ state, with source party A and target complement {B,C}.
|
||||
|
||||
This reproduces, with exact algebraic numbers (no floating point), the claim
|
||||
from Section "Qubit examples" of the paper:
|
||||
|
||||
For |GHZ_3> = (|000> + |111>)/sqrt(2), the three singular values of the
|
||||
normalized combined shadow map M_A(rho) are all equal to sqrt(2/3),
|
||||
i.e. ||M_A(rho)||_* = sqrt(6).
|
||||
|
||||
Convention (matches the .tex draft):
|
||||
- Pauli generators sigma_1=X, sigma_2=Y, sigma_3=Z, normalized by
|
||||
tr(sigma_i sigma_j) = 2 delta_ij (qubit case, d_a = 2).
|
||||
- Target sectors for source A are T in { {B}, {C}, {B,C} }, stacked as
|
||||
rows of one 15 x 3 matrix (3 from B, 3 from C, 9 from BC).
|
||||
- Combined shadow map normalization: 1/sqrt((d_a-1)(d_bar_a-1))
|
||||
= 1/sqrt(1*3) = 1/sqrt(3) for n=3 qubits (Eq. "combined-map" in the note).
|
||||
|
||||
Run: python3 ghz3_shadow_map_symbolic.py
|
||||
"""
|
||||
|
||||
import sympy as sp
|
||||
from sympy import sqrt, I, simplify, Matrix, eye, zeros, re, Rational
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 1. Pauli matrices (exact, symbolic entries)
|
||||
# ---------------------------------------------------------------------
|
||||
X = Matrix([[0, 1], [1, 0]])
|
||||
Y = Matrix([[0, -I], [I, 0]])
|
||||
Z = Matrix([[1, 0], [0, -1]])
|
||||
I2 = eye(2)
|
||||
PAULIS = {'x': X, 'y': Y, 'z': Z}
|
||||
|
||||
|
||||
def kron(A, B):
|
||||
"""Kronecker (tensor) product of two sympy matrices, built manually
|
||||
so everything stays exact/symbolic (no numeric backend needed)."""
|
||||
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 kron3(a, b, c):
|
||||
"""Tensor product of three single-qubit operators -> 8x8 matrix."""
|
||||
return kron(kron(a, b), c)
|
||||
|
||||
|
||||
# Embeddings of a single-qubit operator P onto party A, B, or C
|
||||
# within the 3-qubit Hilbert space (order A ⊗ B ⊗ C).
|
||||
def op_A(P): return kron3(P, I2, I2)
|
||||
def op_B(P): return kron3(I2, P, I2)
|
||||
def op_C(P): return kron3(I2, I2, P)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 2. The GHZ_3 state
|
||||
# ---------------------------------------------------------------------
|
||||
def ghz3_state():
|
||||
"""Density matrix of (|000> + |111>)/sqrt(2), as an 8x8 sympy Matrix."""
|
||||
psi = zeros(8, 1)
|
||||
psi[0, 0] = 1 / sqrt(2) # |000>
|
||||
psi[7, 0] = 1 / sqrt(2) # |111>
|
||||
rho = psi * psi.H # outer product, .H = conjugate transpose
|
||||
return simplify(rho)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 3. Correlation-tensor entries and the shadow-map matrix
|
||||
# ---------------------------------------------------------------------
|
||||
def entry(rho, ops):
|
||||
"""tr(rho * op1 * op2 * ...), simplified and forced real
|
||||
(expectation values of Hermitian operators in a Hermitian state
|
||||
are always real; re(...) just discards a numerically/symbolically
|
||||
residual zero imaginary part)."""
|
||||
M = None
|
||||
for op in ops:
|
||||
M = op if M is None else M * op
|
||||
return simplify(re(simplify((rho * M).trace())))
|
||||
|
||||
|
||||
def build_M(rho):
|
||||
"""Unnormalized shadow-map matrix M_A(rho): 15 (target) x 3 (source A).
|
||||
|
||||
Row blocks, in order:
|
||||
rows 0-2 : target sector T = {B} (source index x,y,z; target x,y,z)
|
||||
rows 3-5 : target sector T = {C}
|
||||
rows 6-14 : target sector T = {B,C} (9 = 3x3 combinations)
|
||||
Column index: source generator on A, in order x,y,z.
|
||||
"""
|
||||
rows = []
|
||||
for pb in ['x', 'y', 'z']:
|
||||
rows.append([entry(rho, [op_A(PAULIS[pa]), op_B(PAULIS[pb])])
|
||||
for pa in ['x', 'y', 'z']])
|
||||
for pc in ['x', 'y', 'z']:
|
||||
rows.append([entry(rho, [op_A(PAULIS[pa]), op_C(PAULIS[pc])])
|
||||
for pa in ['x', 'y', 'z']])
|
||||
for pb in ['x', 'y', 'z']:
|
||||
for pc in ['x', 'y', 'z']:
|
||||
rows.append([entry(rho, [op_A(PAULIS[pa]), op_B(PAULIS[pb]), op_C(PAULIS[pc])])
|
||||
for pa in ['x', 'y', 'z']])
|
||||
return Matrix(rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# 4. Main: build, normalize, and diagonalize
|
||||
# ---------------------------------------------------------------------
|
||||
def main():
|
||||
rho0 = ghz3_state()
|
||||
print("tr(rho0) =", simplify(rho0.trace()), " (sanity check, should be 1)\n")
|
||||
|
||||
M0 = build_M(rho0)
|
||||
print("Unnormalized shadow matrix M0 (15x3):")
|
||||
sp.pprint(M0)
|
||||
|
||||
# Combined-map normalization for n=3 qubits: 1/sqrt((d_a-1)(d_bar_a-1)) = 1/sqrt(3)
|
||||
norm_const = 1 / sqrt(3)
|
||||
Mn0 = simplify(norm_const * M0)
|
||||
|
||||
# Singular values of Mn0 are sqrt(eigenvalues of the Gram matrix Mn0^T Mn0).
|
||||
# This avoids sympy's (slower/less robust) generic SVD and is exact here
|
||||
# because Mn0^T Mn0 is a small 3x3 symmetric matrix.
|
||||
G = simplify(Mn0.T * Mn0)
|
||||
print("\nGram matrix Mn0^T Mn0 =")
|
||||
sp.pprint(G)
|
||||
|
||||
eigs = G.eigenvals() # dict: eigenvalue -> multiplicity
|
||||
print("\nEigenvalues of the Gram matrix (= squared singular values):")
|
||||
for ev, mult in eigs.items():
|
||||
sigma = simplify(sqrt(ev))
|
||||
print(f" lambda = {ev} (multiplicity {mult}) -> sigma = {sigma}"
|
||||
f" = {float(sigma):.6f}")
|
||||
|
||||
print("\nExpected from the paper: sigma = sqrt(2/3) = sqrt(6)/3 ≈ 0.816497,"
|
||||
" threefold degenerate.")
|
||||
|
||||
# Save U0, V0 (orthonormal bases of the degenerate singular subspace) for
|
||||
# reuse in the perturbation-theory script. Since the Gram matrix is
|
||||
# exactly (2/3) * I_3 here, the source space is untouched (V0 = I_3) and
|
||||
# U0 is simply Mn0 rescaled to unit-norm columns.
|
||||
sigma_val = sqrt(Rational(2, 3))
|
||||
U0 = simplify(Mn0 / sigma_val)
|
||||
V0 = eye(3)
|
||||
print("\nU0 (15x3, orthonormal columns spanning the degenerate target subspace):")
|
||||
sp.pprint(U0)
|
||||
print("\nCheck U0^T U0 = I_3:", simplify(U0.T * U0))
|
||||
|
||||
return rho0, U0, V0, sigma_val
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
171
scripts/mixture_search.py
Normal file
171
scripts/mixture_search.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""mixture_search.py -- searching (and partly proving) the biseparable supremum of
|
||||
min(||M_AB||_*, ||M_AC||_*, ||M_AD||_*) via convex mixtures of pure product states
|
||||
across different bipartitions.
|
||||
|
||||
min() of convex functions is NOT itself convex, so (unlike Phi_sym or a single
|
||||
||M_S||_*) the supremum over biseparable states can genuinely lie ABOVE what any single
|
||||
pure product state achieves, and can only be found by explicitly searching mixtures.
|
||||
|
||||
Contains:
|
||||
- exact closed-form derivation/verification for the 2-component Bell-pair mixture
|
||||
family rho(p) = p*(Bell_AB x Bell_CD) + (1-p)*(Bell_AC x Bell_BD):
|
||||
M_AB(p) = 5 - 4p, M_AC(p) = 1 + 4p, M_AD(p) = 3 + 2|2p-1|
|
||||
so min(...)(p) is maximized EXACTLY at p=1/2, value = 3 (proven by hand from the
|
||||
2x2-block eigenvalue structure of the mixed correlation tensor).
|
||||
- general K-component mixture optimizations (free internal state parameters + free
|
||||
softmax weights) that repeatedly rediscover this same value 3.0 as the best found,
|
||||
across 2-, 3-, 4- and 6-component mixture families.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize
|
||||
from core import state_2_2, state_1_3
|
||||
from cluster import cluster_map, nuc
|
||||
|
||||
I2 = np.eye(2, dtype=complex)
|
||||
X = np.array([[0, 1], [1, 0]], dtype=complex)
|
||||
Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
|
||||
Z = np.array([[1, 0], [0, -1]], dtype=complex)
|
||||
_PAULI = [I2, X, Y, Z]
|
||||
|
||||
|
||||
def _kron4(a, b, c, d):
|
||||
return np.kron(np.kron(a, b), np.kron(c, d))
|
||||
|
||||
|
||||
_OPS = np.zeros((4, 4, 4, 4, 16, 16), dtype=complex)
|
||||
for _i0 in range(4):
|
||||
for _i1 in range(4):
|
||||
for _i2 in range(4):
|
||||
for _i3 in range(4):
|
||||
_OPS[_i0, _i1, _i2, _i3] = _kron4(_PAULI[_i0], _PAULI[_i1], _PAULI[_i2], _PAULI[_i3])
|
||||
_OPS_FLAT = _OPS.reshape(256, 16, 16)
|
||||
|
||||
|
||||
def full_tensor_mixed_fast(rho):
|
||||
"""Fast Pauli-tensor extraction for a general (possibly mixed) 16x16 density
|
||||
matrix, via a single vectorized einsum over all 256 precomputed Pauli operators."""
|
||||
vals = np.einsum('kij,ji->k', _OPS_FLAT, rho)
|
||||
return vals.real.reshape(4, 4, 4, 4)
|
||||
|
||||
|
||||
def triple_mixed(rho):
|
||||
C = full_tensor_mixed_fast(rho)
|
||||
return nuc(cluster_map(C, 0, 1)), nuc(cluster_map(C, 0, 2)), nuc(cluster_map(C, 0, 3))
|
||||
|
||||
|
||||
def bellpair_state(pairing):
|
||||
bell = np.array([1, 0, 0, 1]) / np.sqrt(2)
|
||||
(p1a, p1b), (p2a, p2b) = pairing
|
||||
psi = np.zeros(16, dtype=complex)
|
||||
for x in range(2):
|
||||
for y in range(2):
|
||||
for u in range(2):
|
||||
for v in range(2):
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[p1a] = x
|
||||
idx[p1b] = y
|
||||
idx[p2a] = u
|
||||
idx[p2b] = v
|
||||
lin = idx[0] * 8 + idx[1] * 4 + idx[2] * 2 + idx[3]
|
||||
psi[lin] = bell[x * 2 + y] * bell[u * 2 + v]
|
||||
return psi
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Exact closed form for the 2-component Bell-pair mixture family
|
||||
# ---------------------------------------------------------------------
|
||||
def bell_mixture_exact_formula(p):
|
||||
M_AB = 5 - 4 * p
|
||||
M_AC = 1 + 4 * p
|
||||
M_AD = 3 + 2 * abs(2 * p - 1)
|
||||
return M_AB, M_AC, M_AD
|
||||
|
||||
|
||||
def bell_mixture_numeric(p):
|
||||
rho1 = np.outer(bellpair_state(((0, 1), (2, 3))), np.conj(bellpair_state(((0, 1), (2, 3)))))
|
||||
rho2 = np.outer(bellpair_state(((0, 2), (1, 3))), np.conj(bellpair_state(((0, 2), (1, 3)))))
|
||||
rho = p * rho1 + (1 - p) * rho2
|
||||
return triple_mixed(rho)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# General K-component mixture optimizations
|
||||
# ---------------------------------------------------------------------
|
||||
def neg_min_mixture_2comp(params):
|
||||
"""2 components: pure states biseparable across AB|CD and AC|BD, full internal
|
||||
freedom, weight via sigmoid."""
|
||||
x1, x2 = params[0:16], params[16:32]
|
||||
w = 1 / (1 + np.exp(-params[32]))
|
||||
rho1 = np.outer(state_2_2(x1, (0, 1), (2, 3)), np.conj(state_2_2(x1, (0, 1), (2, 3))))
|
||||
rho2 = np.outer(state_2_2(x2, (0, 2), (1, 3)), np.conj(state_2_2(x2, (0, 2), (1, 3))))
|
||||
rho = w * rho1 + (1 - w) * rho2
|
||||
return -min(triple_mixed(rho))
|
||||
|
||||
|
||||
def neg_min_mixture_3comp(params):
|
||||
"""3 components: pure states biseparable across each of the three 2|2 cuts, full
|
||||
internal freedom, softmax weights."""
|
||||
x1, x2, x3 = params[0:16], params[16:32], params[32:48]
|
||||
w = np.exp(params[48:51] - np.max(params[48:51]))
|
||||
w = w / np.sum(w)
|
||||
psis = [state_2_2(x1, (0, 1), (2, 3)), state_2_2(x2, (0, 2), (1, 3)), state_2_2(x3, (0, 3), (1, 2))]
|
||||
rho = sum(wi * np.outer(p, np.conj(p)) for wi, p in zip(w, psis))
|
||||
return -min(triple_mixed(rho))
|
||||
|
||||
|
||||
def neg_min_mixture_4slot(params):
|
||||
"""4 slots: all three 2|2 cut types plus one 1|3 cut type, full internal freedom,
|
||||
softmax weights (optimizer is free to zero out unused slots)."""
|
||||
x0, x1, x2, x3 = params[0:16], params[16:32], params[32:48], params[48:66]
|
||||
w = np.exp(params[66:70] - np.max(params[66:70]))
|
||||
w = w / np.sum(w)
|
||||
psis = [state_2_2(x0, (0, 1), (2, 3)), state_2_2(x1, (0, 2), (1, 3)),
|
||||
state_2_2(x2, (0, 3), (1, 2)), state_1_3(x3, 0)]
|
||||
rho = sum(wi * np.outer(p, np.conj(p)) for wi, p in zip(w, psis))
|
||||
return -min(triple_mixed(rho))
|
||||
|
||||
|
||||
def neg_min_mixture_6slot(params):
|
||||
"""6 slots: AB|CD, AC|BD, AD|BC, AB|CD (2nd copy), AC|BD (2nd copy), A|BCD -- allows
|
||||
two independently-parametrized states of the SAME cut type to mix together too."""
|
||||
xs = [params[16 * k:16 * (k + 1)] for k in range(5)]
|
||||
x5 = params[80:98]
|
||||
w = np.exp(params[98:104] - np.max(params[98:104]))
|
||||
w = w / np.sum(w)
|
||||
psis = [state_2_2(xs[0], (0, 1), (2, 3)), state_2_2(xs[1], (0, 2), (1, 3)),
|
||||
state_2_2(xs[2], (0, 3), (1, 2)), state_2_2(xs[3], (0, 1), (2, 3)),
|
||||
state_2_2(xs[4], (0, 2), (1, 3)), state_1_3(x5, 0)]
|
||||
rho = sum(wi * np.outer(p, np.conj(p)) for wi, p in zip(w, psis))
|
||||
return -min(triple_mixed(rho))
|
||||
|
||||
|
||||
def multistart(objective, nparams, n_restarts, seed0, label, maxiter=2000):
|
||||
best = -np.inf
|
||||
bx = None
|
||||
for i in range(n_restarts):
|
||||
rng = np.random.default_rng(seed0 + i)
|
||||
x0 = rng.normal(size=nparams) * 0.7
|
||||
res = minimize(objective, x0, method='Powell',
|
||||
options={'maxiter': maxiter, 'xtol': 1e-8, 'ftol': 1e-10})
|
||||
v = -res.fun
|
||||
if v > best:
|
||||
best = v
|
||||
bx = res.x
|
||||
print(f'{label}: best min(M_AB,M_AC,M_AD) = {best:.6f} ({n_restarts} restarts)')
|
||||
return best, bx
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Exact closed form vs numeric verification, Bell-pair mixture family ===")
|
||||
for p in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
formula = bell_mixture_exact_formula(p)
|
||||
numeric = bell_mixture_numeric(p)
|
||||
print(f" p={p:.2f} formula={tuple(round(x, 4) for x in formula)} "
|
||||
f"numeric={tuple(round(x, 4) for x in numeric)}")
|
||||
|
||||
print()
|
||||
print("=== General mixture optimizations (stress-testing the p=1/2 optimum, 3.0) ===")
|
||||
multistart(neg_min_mixture_2comp, 33, 6, 3000, "2-component (AB|CD + AC|BD, free params)")
|
||||
multistart(neg_min_mixture_3comp, 51, 3, 5000, "3-component (all three 2|2 cuts, free weights)")
|
||||
multistart(neg_min_mixture_4slot, 70, 2, 7000, "4-slot (three 2|2 + one 1|3, free weights)")
|
||||
multistart(neg_min_mixture_6slot, 104, 1, 8000, "6-slot (duplicated cut types)")
|
||||
84
scripts/optimize_cluster_witness.py
Normal file
84
scripts/optimize_cluster_witness.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""optimize_cluster_witness.py --
|
||||
(1) optimize the individual cluster map ||M_AB||_* over different biseparable cut types,
|
||||
showing that its universal ceiling (5.0) is reached not just by the "home" cut AB|CD
|
||||
but also by a state biseparable across the UNRELATED cut AC|BD (Bell_AC x Bell_BD);
|
||||
(2) optimize min(||M_AB||_*, ||M_AC||_*, ||M_AD||_*) over 1|3-biseparable pure states,
|
||||
finding a naive ceiling of 7/3 -- later shown (via mixture_search.py) to be beatable
|
||||
by proper MIXTURES, since min() is not convex.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize
|
||||
from core import state_1_3, state_2_2
|
||||
from core2 import full_tensor
|
||||
from cluster import cluster_map, nuc
|
||||
|
||||
|
||||
def triple(psi):
|
||||
C = full_tensor(psi)
|
||||
return nuc(cluster_map(C, 0, 1)), nuc(cluster_map(C, 0, 2)), nuc(cluster_map(C, 0, 3))
|
||||
|
||||
|
||||
# --- witness ||M_AB||_* over various biseparable cut types ---
|
||||
def obj_A_BCD(params):
|
||||
psi = state_1_3(params, 0)
|
||||
C = full_tensor(psi)
|
||||
return -nuc(cluster_map(C, 0, 1))
|
||||
|
||||
|
||||
def obj_C_ABD(params):
|
||||
psi = state_1_3(params, 2)
|
||||
C = full_tensor(psi)
|
||||
return -nuc(cluster_map(C, 0, 1))
|
||||
|
||||
|
||||
def obj_AC_BD(params):
|
||||
psi = state_2_2(params, (0, 2), (1, 3))
|
||||
C = full_tensor(psi)
|
||||
return -nuc(cluster_map(C, 0, 1))
|
||||
|
||||
|
||||
def obj_AB_CD(params):
|
||||
psi = state_2_2(params, (0, 1), (2, 3))
|
||||
C = full_tensor(psi)
|
||||
return -nuc(cluster_map(C, 0, 1))
|
||||
|
||||
|
||||
# --- min(M_AB,M_AC,M_AD) over 1|3-biseparable pure states ---
|
||||
def neg_min_A_BCD(params):
|
||||
psi = state_1_3(params, 0)
|
||||
return -min(triple(psi))
|
||||
|
||||
|
||||
def neg_min_B_ACD(params):
|
||||
psi = state_1_3(params, 1)
|
||||
return -min(triple(psi))
|
||||
|
||||
|
||||
def run(obj, nparams, args, n_restarts, seed0, label, maxiter=2500):
|
||||
best = -np.inf
|
||||
bx = None
|
||||
for i in range(n_restarts):
|
||||
rng = np.random.default_rng(seed0 + i)
|
||||
x0 = rng.normal(size=nparams)
|
||||
res = minimize(obj, x0, args=args, method='Powell',
|
||||
options={'maxiter': maxiter, 'xtol': 1e-9, 'ftol': 1e-11})
|
||||
v = -res.fun
|
||||
if v > best:
|
||||
best = v
|
||||
bx = res.x
|
||||
print(f'{label}: best value = {best:.8f} ({n_restarts} restarts)')
|
||||
return best, bx
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== ||M_AB||_* over various biseparable cut types (universal ceiling = 5) ===")
|
||||
run(obj_A_BCD, 18, (), 8, 10, "biseparable A|BCD")
|
||||
run(obj_C_ABD, 18, (), 8, 20, "biseparable C|ABD")
|
||||
run(obj_AC_BD, 16, (), 8, 30, "biseparable AC|BD <-- reaches 5 (Bell_AC x Bell_BD)")
|
||||
run(obj_AB_CD, 16, (), 6, 40, "biseparable AB|CD (home cut, sanity <= 1)")
|
||||
|
||||
print()
|
||||
print("=== min(M_AB,M_AC,M_AD) over 1|3-biseparable states (naive ceiling = 7/3) ===")
|
||||
run(neg_min_A_BCD, 18, (), 10, 300, "max over biseparable A|BCD")
|
||||
run(neg_min_B_ACD, 18, (), 10, 400, "max over biseparable B|ACD")
|
||||
print("For comparison: GHZ4 / connected graph states also give min = 7/3 =", 7 / 3)
|
||||
66
scripts/optimize_phi_sym.py
Normal file
66
scripts/optimize_phi_sym.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""optimize_phi_sym.py -- multi-start optimization of Phi_sym over biseparable pure
|
||||
states (1|3 and 2|2 cuts) and over ALL pure 4-qubit states (unconstrained).
|
||||
|
||||
Key finding: all three searches converge to the SAME value, 6/sqrt(7) -- i.e. Phi_sym's
|
||||
biseparable supremum equals its global supremum over the entire state space, achieved
|
||||
both by connected 4-qubit graph states AND by a trivial biseparable state (two Bell
|
||||
pairs). This shows Phi_sym cannot certify genuine multipartite entanglement at that
|
||||
threshold.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize
|
||||
from core import state_1_3, state_2_2
|
||||
from core2 import phi_sym
|
||||
|
||||
|
||||
def neg_phi_1_3(params, source):
|
||||
psi = state_1_3(params, source)
|
||||
val, _ = phi_sym(psi)
|
||||
return -val
|
||||
|
||||
|
||||
def neg_phi_2_2(params, pair1, pair2):
|
||||
psi = state_2_2(params, pair1, pair2)
|
||||
val, _ = phi_sym(psi)
|
||||
return -val
|
||||
|
||||
|
||||
def neg_phi_general(params):
|
||||
v = params[:16] + 1j * params[16:]
|
||||
v = v / np.linalg.norm(v)
|
||||
val, _ = phi_sym(v)
|
||||
return -val
|
||||
|
||||
|
||||
def run_multistart(objective, nparams, args, n_restarts, seed0, label):
|
||||
best = -np.inf
|
||||
bx = None
|
||||
for i in range(n_restarts):
|
||||
rng = np.random.default_rng(seed0 + i)
|
||||
x0 = rng.normal(size=nparams)
|
||||
res = minimize(objective, x0, args=args, method='Powell',
|
||||
options={'maxiter': 2000, 'xtol': 1e-9, 'ftol': 1e-11})
|
||||
v = -res.fun
|
||||
if v > best:
|
||||
best = v
|
||||
bx = res.x
|
||||
print(f'{label}: best Phi_sym = {best:.10f} ({n_restarts} restarts)')
|
||||
return best, bx
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# biseparable across 2|2 cut AB|CD -> exact optimum 6/sqrt(7), achieved by
|
||||
# Bell_AB (x) Bell_CD (verified in closed form: two maximal Bell pairs)
|
||||
v22, x22 = run_multistart(neg_phi_2_2, 16, ((0, 1), (2, 3)), 15, 100,
|
||||
"biseparable 2|2 (AB|CD)")
|
||||
# biseparable across 1|3 cut A|BCD -> exact optimum (1+18/sqrt(7))/4,
|
||||
# achieved by (any single qubit) (x) GHZ_3(B,C,D)
|
||||
v13, x13 = run_multistart(neg_phi_1_3, 18, (0,), 15, 200,
|
||||
"biseparable 1|3 (A|BCD)")
|
||||
# fully unconstrained over ALL pure 4-qubit states -> same ceiling 6/sqrt(7)
|
||||
vgen, xgen = run_multistart(neg_phi_general, 32, (), 20, 500,
|
||||
"unconstrained (all 4-qubit states)")
|
||||
|
||||
print()
|
||||
print("6/sqrt(7) =", 6 / np.sqrt(7))
|
||||
print("(1+18/sqrt(7))/4 =", (1 + 18 / np.sqrt(7)) / 4)
|
||||
65
scripts/pairwise_correlation_demo.py
Normal file
65
scripts/pairwise_correlation_demo.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""pairwise_correlation_demo.py -- compares the raw single-party-to-single-party 3x3
|
||||
correlation blocks for a trivially biseparable state (two Bell pairs), GHZ4, and the
|
||||
ring graph state. Shows that "some pairwise block vanishes" is NOT a valid biseparability
|
||||
signature: the ring graph state (genuinely entangled) also has several exactly-vanishing
|
||||
pairwise blocks -- a well-known feature of graph states, confirmed here directly.
|
||||
"""
|
||||
import numpy as np
|
||||
from core2 import full_tensor
|
||||
from party_blocks import party_block, nuc
|
||||
|
||||
|
||||
def show(psi, label):
|
||||
C = full_tensor(psi)
|
||||
print(label)
|
||||
for a, b, name in [(0, 1, 'A-B'), (0, 2, 'A-C'), (0, 3, 'A-D'),
|
||||
(1, 2, 'B-C'), (1, 3, 'B-D'), (2, 3, 'C-D')]:
|
||||
print(f' {name}: ||M_party||_* = {nuc(party_block(C, a, b)):.4f}')
|
||||
|
||||
|
||||
def bellpair_state(pairing):
|
||||
bell = np.array([1, 0, 0, 1]) / np.sqrt(2)
|
||||
(p1a, p1b), (p2a, p2b) = pairing
|
||||
psi = np.zeros(16, dtype=complex)
|
||||
for x in range(2):
|
||||
for y in range(2):
|
||||
for u in range(2):
|
||||
for v in range(2):
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[p1a] = x
|
||||
idx[p1b] = y
|
||||
idx[p2a] = u
|
||||
idx[p2b] = v
|
||||
lin = idx[0] * 8 + idx[1] * 4 + idx[2] * 2 + idx[3]
|
||||
psi[lin] = bell[x * 2 + y] * bell[u * 2 + v]
|
||||
return psi
|
||||
|
||||
|
||||
def ring_graph_state():
|
||||
plus = np.array([1, 1]) / np.sqrt(2)
|
||||
psi = np.kron(np.kron(plus, plus), np.kron(plus, plus))
|
||||
|
||||
def apply_CZ(psi, a, b):
|
||||
psi = psi.reshape([2] * 4)
|
||||
idx = [slice(None)] * 4
|
||||
idx[a] = 1
|
||||
idx[b] = 1
|
||||
psi[tuple(idx)] *= -1
|
||||
return psi.reshape(16)
|
||||
|
||||
psi = apply_CZ(psi, 0, 1)
|
||||
psi = apply_CZ(psi, 1, 2)
|
||||
psi = apply_CZ(psi, 2, 3)
|
||||
psi = apply_CZ(psi, 3, 0)
|
||||
return psi
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
show(bellpair_state(((0, 1), (2, 3))), 'Bell_AB x Bell_CD:')
|
||||
print()
|
||||
ghz4 = np.zeros(16, dtype=complex)
|
||||
ghz4[0] = 1 / np.sqrt(2)
|
||||
ghz4[15] = 1 / np.sqrt(2)
|
||||
show(ghz4, 'GHZ4:')
|
||||
print()
|
||||
show(ring_graph_state(), 'ring graph state:')
|
||||
18
scripts/party_blocks.py
Normal file
18
scripts/party_blocks.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""party_blocks.py -- extract the raw (unnormalized) single-party-to-single-party 3x3
|
||||
correlation blocks M_{a->b} from the Pauli correlation tensor."""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def party_block(C, a, b):
|
||||
M = np.zeros((3, 3))
|
||||
for i, ia in enumerate([1, 2, 3]):
|
||||
for j, ib in enumerate([1, 2, 3]):
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[a] = ia
|
||||
idx[b] = ib
|
||||
M[i, j] = C[tuple(idx)]
|
||||
return M
|
||||
|
||||
|
||||
def nuc(M):
|
||||
return np.linalg.svd(M, compute_uv=False).sum()
|
||||
225
scripts/sdp_ppt_mixture.py
Normal file
225
scripts/sdp_ppt_mixture.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""
|
||||
SDP-based sharpening of the biseparable threshold for min(||M_AB||_*, ||M_AC||_*, ||M_AD||_*)
|
||||
on 4 qubits, via the PPT-mixture relaxation (Jungnitsch-Moroder-Guehne 2011 style SDP).
|
||||
|
||||
WHY NOT A ONE-SHOT SDP
|
||||
-----------------------
|
||||
||M_S(rho)||_* is CONVEX in rho. Maximizing a convex function over a convex set is itself
|
||||
a non-convex problem -- no SDP solver can do this directly.
|
||||
|
||||
THE FIX -- alternating (Frank-Wolfe) scheme using the dual (support-function) form of the
|
||||
nuclear norm:
|
||||
||X||_* = max_{||O||_op <= 1} tr(O^T X)
|
||||
For FIXED O_AB, O_AC, O_AD (each with operator norm <= 1), min_S tr(O_S^T M_S(rho)) is a
|
||||
min of THREE LINEAR functions of rho, hence CONCAVE, hence
|
||||
max_{rho in PPT-mixtures} min_S tr(O_S^T M_S(rho))
|
||||
IS a legitimate concave-maximization problem -> a genuine SDP.
|
||||
|
||||
Loop:
|
||||
1) fix O's -> solve the SDP -> get rho*
|
||||
2) at rho*, compute the TRUE nuclear norms and their exact dual witnesses
|
||||
O_S = U_S V_S^T (from the SVD of M_S(rho*)) -> update O's
|
||||
3) repeat
|
||||
|
||||
This is a heuristic (finds a local stationary point of a genuinely non-convex problem),
|
||||
but it searches the FULL convex PPT-mixture body (a strict superset of the biseparable
|
||||
states), not just a hand-picked family of pure-state mixtures -- a much stronger stress
|
||||
test of the conjectured biseparable supremum (~3.0) than black-box optimization over a
|
||||
parametrized ansatz.
|
||||
|
||||
Requires: pip install cvxpy numpy scipy
|
||||
Every piece of linear algebra here (Pauli-tensor extraction, partial-transpose
|
||||
permutation, the fast coefficient-matrix construction) was verified against a slow
|
||||
reference implementation in pure numpy before being translated to cvxpy; only the
|
||||
cvxpy Problem-building/solving itself is unverified in the sandbox this was written in
|
||||
(no cvxpy, no network there).
|
||||
"""
|
||||
import numpy as np
|
||||
import cvxpy as cp
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. Pauli tensor operators, index k = i0*64 + i1*16 + i2*4 + i3
|
||||
# ----------------------------------------------------------------------
|
||||
I2 = np.eye(2, dtype=complex)
|
||||
X = np.array([[0, 1], [1, 0]], dtype=complex)
|
||||
Y = np.array([[0, -1j], [1j, 0]], dtype=complex)
|
||||
Z = np.array([[1, 0], [0, -1]], dtype=complex)
|
||||
PAULI = [I2, X, Y, Z]
|
||||
|
||||
|
||||
def kron4(a, b, c, d):
|
||||
return np.kron(np.kron(a, b), np.kron(c, d))
|
||||
|
||||
|
||||
PAULI_OPS = np.zeros((256, 16, 16), dtype=complex)
|
||||
for i0 in range(4):
|
||||
for i1 in range(4):
|
||||
for i2 in range(4):
|
||||
for i3 in range(4):
|
||||
k = i0 * 64 + i1 * 16 + i2 * 4 + i3
|
||||
PAULI_OPS[k] = kron4(PAULI[i0], PAULI[i1], PAULI[i2], PAULI[i3])
|
||||
|
||||
CLUSTERS = [('AB', 0, 1), ('AC', 0, 2), ('AD', 0, 3)]
|
||||
|
||||
|
||||
def build_coeff_matrix(s0, s1):
|
||||
"""(225,256) complex matrix Coeff s.t., for rho flattened row-major (vec[a*16+b]=rho[a,b]),
|
||||
(Coeff @ vec).reshape(15,15).real / 3.0 == the normalized bigraduated cluster map M_S,
|
||||
S = {s0,s1}, complement the other two qubits. Verified against a slow trace-based
|
||||
reference (max abs diff ~2e-17)."""
|
||||
others = [k for k in range(4) if k not in (s0, s1)]
|
||||
c0, c1 = others
|
||||
rows = [(i, j) for i in range(4) for j in range(4) if (i, j) != (0, 0)]
|
||||
cols = [(i, j) for i in range(4) for j in range(4) if (i, j) != (0, 0)]
|
||||
Coeff = np.zeros((225, 256), dtype=complex)
|
||||
entry = 0
|
||||
for (ia, ib) in rows:
|
||||
for (ic, idd) in cols:
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[s0] = ia
|
||||
idx[s1] = ib
|
||||
idx[c0] = ic
|
||||
idx[c1] = idd
|
||||
k = idx[0] * 64 + idx[1] * 16 + idx[2] * 4 + idx[3]
|
||||
# trace(rho @ P_k) = sum_{a,b} rho[a,b] P_k[b,a]; row-major vec_rho[a*16+b]=rho[a,b]
|
||||
Coeff[entry, :] = PAULI_OPS[k].T.flatten()
|
||||
entry += 1
|
||||
return Coeff
|
||||
|
||||
|
||||
COEFF = {name: build_coeff_matrix(s0, s1) for name, s0, s1 in CLUSTERS}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 2. Partial transpose as an explicit (256,256) permutation matrix
|
||||
# ----------------------------------------------------------------------
|
||||
def partial_transpose_perm(T, n=4):
|
||||
perm = np.zeros(4 ** n, dtype=int)
|
||||
for row in range(2 ** n):
|
||||
rbits = [(row >> (n - 1 - i)) & 1 for i in range(n)]
|
||||
for col in range(2 ** n):
|
||||
cbits = [(col >> (n - 1 - i)) & 1 for i in range(n)]
|
||||
new_row = [cbits[i] if i in T else rbits[i] for i in range(n)]
|
||||
new_col = [rbits[i] if i in T else cbits[i] for i in range(n)]
|
||||
new_row_idx = sum(b << (n - 1 - i) for i, b in enumerate(new_row))
|
||||
new_col_idx = sum(b << (n - 1 - i) for i, b in enumerate(new_col))
|
||||
perm[new_row_idx * (2 ** n) + new_col_idx] = row * (2 ** n) + col
|
||||
return perm
|
||||
|
||||
|
||||
def perm_matrix(T, n=4):
|
||||
perm = partial_transpose_perm(T, n)
|
||||
P = np.zeros((4 ** n, 4 ** n))
|
||||
for new_idx, old_idx in enumerate(perm):
|
||||
P[new_idx, old_idx] = 1.0
|
||||
return P
|
||||
|
||||
|
||||
# The 7 bipartitions of {A,B,C,D}={0,1,2,3}; PT taken w.r.t. the listed (smaller) side.
|
||||
# PPT is equivalent for either side of a bipartition, so this choice is arbitrary but fixed.
|
||||
BIPARTITIONS = [
|
||||
('AB|CD', {0, 1}), ('AC|BD', {0, 2}), ('AD|BC', {0, 3}),
|
||||
('A|BCD', {0}), ('B|ACD', {1}), ('C|ABD', {2}), ('D|ABC', {3}),
|
||||
]
|
||||
PT_MATRIX = {name: perm_matrix(side) for name, side in BIPARTITIONS}
|
||||
|
||||
|
||||
def cvxpy_flatten_rowmajor(rho_expr):
|
||||
"""16x16 cvxpy expression -> length-256 cvxpy expression, row-major."""
|
||||
return cp.hstack([rho_expr[i, :] for i in range(16)])
|
||||
|
||||
|
||||
def cvxpy_partial_transpose(rho_expr, name):
|
||||
vec = cvxpy_flatten_rowmajor(rho_expr)
|
||||
pt_vec = PT_MATRIX[name] @ vec
|
||||
return cp.reshape(pt_vec, (16, 16), order='C') # MUST match row-major PT_MATRIX construction
|
||||
|
||||
|
||||
def cvxpy_cluster_maps(rho_expr):
|
||||
vec = cvxpy_flatten_rowmajor(rho_expr)
|
||||
out = {}
|
||||
for name, s0, s1 in CLUSTERS:
|
||||
flat = COEFF[name] @ vec / 3.0
|
||||
out[name] = cp.real(cp.reshape(flat, (15, 15), order='C')) # MUST match row-major COEFF construction
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 3. PPT-mixture SDP + one alternating step
|
||||
# ----------------------------------------------------------------------
|
||||
def solve_fixed_witness_step(O, solver=cp.SCS, verbose=False):
|
||||
"""O: dict name(in {'AB','AC','AD'}) -> 15x15 real array with operator norm <= 1.
|
||||
Returns (rho_value, sdp_optimal_t, dict of numeric M_S values)."""
|
||||
rho_gammas = {}
|
||||
constraints = []
|
||||
for name, side in BIPARTITIONS:
|
||||
r = cp.Variable((16, 16), hermitian=True)
|
||||
constraints.append(r >> 0) # PSD
|
||||
constraints.append(cvxpy_partial_transpose(r, name) >> 0) # PPT across this cut
|
||||
rho_gammas[name] = r
|
||||
|
||||
rho = sum(rho_gammas.values())
|
||||
constraints.append(cp.real(cp.trace(rho)) == 1)
|
||||
|
||||
M = cvxpy_cluster_maps(rho)
|
||||
t = cp.Variable()
|
||||
for name, _, _ in CLUSTERS:
|
||||
constraints.append(t <= cp.sum(cp.multiply(O[name], M[name])))
|
||||
|
||||
prob = cp.Problem(cp.Maximize(t), constraints)
|
||||
prob.solve(solver=solver, verbose=verbose)
|
||||
|
||||
if rho.value is None:
|
||||
raise RuntimeError(f"SDP did not solve to a usable solution (status={prob.status}).")
|
||||
|
||||
M_vals = {name: M[name].value for name, _, _ in CLUSTERS}
|
||||
return rho.value, prob.value, M_vals
|
||||
|
||||
|
||||
def true_norms_and_witnesses(M_vals):
|
||||
"""Exact nuclear norms of the numeric M_S matrices, plus their optimal dual witnesses
|
||||
O_S = U_S V_S^T (operator norm exactly 1, achieves tr(O_S^T M_S) = ||M_S||_*)."""
|
||||
norms, O_opt = {}, {}
|
||||
for name, M in M_vals.items():
|
||||
U, s, Vt = np.linalg.svd(M)
|
||||
norms[name] = s.sum()
|
||||
O_opt[name] = U @ Vt
|
||||
return norms, O_opt
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 4. Alternating search with multiple random restarts
|
||||
# ----------------------------------------------------------------------
|
||||
def alternating_search(n_restarts=5, n_iters=15, seed0=0, verbose=True):
|
||||
best_min, best_rho = -np.inf, None
|
||||
for r in range(n_restarts):
|
||||
rng = np.random.default_rng(seed0 + r)
|
||||
O = {}
|
||||
for name, _, _ in CLUSTERS:
|
||||
A = rng.normal(size=(15, 15))
|
||||
O[name] = A / np.linalg.norm(A, ord=2) # operator norm 1
|
||||
if verbose:
|
||||
print(f"--- restart {r} ---")
|
||||
for it in range(n_iters):
|
||||
rho_val, t_val, M_vals = solve_fixed_witness_step(O)
|
||||
norms, O = true_norms_and_witnesses(M_vals)
|
||||
cur_min = min(norms.values())
|
||||
if verbose:
|
||||
nice = {k: round(v, 4) for k, v in norms.items()}
|
||||
print(f" iter {it:2d}: SDP t={t_val:.4f} true norms={nice} min={cur_min:.4f}")
|
||||
if cur_min > best_min:
|
||||
best_min, best_rho = cur_min, rho_val
|
||||
if verbose:
|
||||
print()
|
||||
return best_min, best_rho
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Proven upper bound (pure-state extreme points + convexity of the sum): 11/3 =", 11 / 3)
|
||||
print("Conjectured true biseparable / PPT-mixture supremum: ~3.0")
|
||||
print()
|
||||
best_min, best_rho = alternating_search(n_restarts=5, n_iters=15)
|
||||
print("=" * 60)
|
||||
print("Best min(||M_AB||_*, ||M_AC||_*, ||M_AD||_*) found over PPT-mixtures:", best_min)
|
||||
print("- if this stays at/near 3.0 across restarts -> strong evidence 3.0 is exact")
|
||||
print("- if it clearly exceeds 3.0 -> best_rho is a concrete witness state to inspect")
|
||||
69
scripts/seeded_exploration.py
Normal file
69
scripts/seeded_exploration.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""
|
||||
Seeded exploration around the known-good point (3.0, 3.0, 3.0).
|
||||
|
||||
Two questions this answers:
|
||||
1) Is 3.0 a STABLE fixed point of the alternating scheme (perturb the witnesses a
|
||||
little, does it converge back to 3.0)?
|
||||
2) Does searching the FULL PPT-mixture body (strictly larger than biseparable states)
|
||||
starting from near this point ever find something BETTER than 3.0?
|
||||
|
||||
If nothing beats 3.0 even when explicitly seeded nearby and given many iterations, that
|
||||
is now fairly strong evidence -- across both a from-scratch parametrized search (earlier)
|
||||
and this SDP-based search over the larger PPT-mixture relaxation -- that 3.0 is the true
|
||||
supremum (at least for PPT-mixtures, hence an upper bound on the biseparable one too,
|
||||
since biseparable subset PPT-mixtures).
|
||||
"""
|
||||
import numpy as np
|
||||
from sdp_ppt_mixture import solve_fixed_witness_step, true_norms_and_witnesses, CLUSTERS
|
||||
|
||||
data = np.load('O_seed.npz')
|
||||
O_seed = {name: data[name] for name, _, _ in CLUSTERS}
|
||||
|
||||
|
||||
def project_to_unit_opnorm(A):
|
||||
"""Rescale A to have operator norm exactly 1 (SVD-based projection)."""
|
||||
U, s, Vt = np.linalg.svd(A)
|
||||
return U @ Vt if s.max() == 0 else A / s.max()
|
||||
|
||||
|
||||
def run_seeded(perturbation_strength, n_iters=25, seed=0, verbose=True):
|
||||
rng = np.random.default_rng(seed)
|
||||
O = {}
|
||||
for name, _, _ in CLUSTERS:
|
||||
noise = rng.normal(size=(15, 15)) * perturbation_strength
|
||||
O[name] = project_to_unit_opnorm(O_seed[name] + noise)
|
||||
|
||||
best_min = -np.inf
|
||||
history = []
|
||||
for it in range(n_iters):
|
||||
rho_val, t_val, M_vals = solve_fixed_witness_step(O)
|
||||
norms, O = true_norms_and_witnesses(M_vals)
|
||||
cur_min = min(norms.values())
|
||||
history.append(cur_min)
|
||||
best_min = max(best_min, cur_min)
|
||||
if verbose:
|
||||
nice = {k: round(v, 5) for k, v in norms.items()}
|
||||
print(f" iter {it:2d}: SDP t={t_val:.5f} norms={nice} min={cur_min:.5f}")
|
||||
return best_min, history
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Stability check: seed EXACTLY at the known optimum (no perturbation) ===")
|
||||
best0, _ = run_seeded(perturbation_strength=0.0, n_iters=10, seed=0)
|
||||
print(f" best min found: {best0:.6f} (should stay essentially at 3.0)\n")
|
||||
|
||||
print("=== Perturbation sweep: does it converge back to 3.0, drift, or improve? ===")
|
||||
results = {}
|
||||
for strength in [0.05, 0.1, 0.2, 0.4, 0.7, 1.0]:
|
||||
print(f"--- perturbation strength {strength} ---")
|
||||
best, hist = run_seeded(perturbation_strength=strength, n_iters=25, seed=1, verbose=True)
|
||||
results[strength] = best
|
||||
print(f" final best: {best:.6f}\n")
|
||||
|
||||
print("=" * 60)
|
||||
for s, v in results.items():
|
||||
print(f" perturbation {s:.2f} -> best min found = {v:.6f}")
|
||||
overall_best = max(results.values())
|
||||
print()
|
||||
print("Overall best across all perturbed seeded runs:", overall_best)
|
||||
print("Compare: 3.0 (conjectured exact), 11/3 =", 11/3, "(proven upper bound)")
|
||||
25
scripts/subblock.py
Normal file
25
scripts/subblock.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""subblock.py -- extract the "fully active" 9x9 sub-block M_{S->S^c} (both source and
|
||||
target sectors fully active, i.e. every party involved) from the full bigraduated
|
||||
cluster map. This is Corollary "sub-block witnesses" specialized to V=S, T=S^c."""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def fully_active_block(C, s0, s1):
|
||||
others = [k for k in range(4) if k not in (s0, s1)]
|
||||
c0, c1 = others
|
||||
M = np.zeros((9, 9))
|
||||
rows = [(i, j) for i in [1, 2, 3] for j in [1, 2, 3]]
|
||||
cols = [(i, j) for i in [1, 2, 3] for j in [1, 2, 3]]
|
||||
for ri, (ia, ib) in enumerate(rows):
|
||||
for ci, (ic, idd) in enumerate(cols):
|
||||
idx = [0, 0, 0, 0]
|
||||
idx[s0] = ia
|
||||
idx[s1] = ib
|
||||
idx[c0] = ic
|
||||
idx[c1] = idd
|
||||
M[ri, ci] = C[tuple(idx)]
|
||||
return M / 3.0 # same normalization convention as cluster.cluster_map
|
||||
|
||||
|
||||
def nuc(M):
|
||||
return np.linalg.svd(M, compute_uv=False).sum()
|
||||
71
scripts/sum_bound_proof.py
Normal file
71
scripts/sum_bound_proof.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""sum_bound_proof.py -- proof ingredients for the RIGOROUS bound
|
||||
rho biseparable => min(||M_AB||_*, ||M_AC||_*, ||M_AD||_*) <= 11/3
|
||||
|
||||
Step 1: verify (numerically, over each cut type) that
|
||||
||M_AB||_* + ||M_AC||_* + ||M_AD||_* <= 11
|
||||
for every PURE state product across a single bipartition (the extreme points of the
|
||||
biseparable set). Since the SUM of nuclear norms IS convex (unlike the min!), this bound
|
||||
then extends by convexity to ALL biseparable (mixed) states -- this is the key trick that
|
||||
lets a convexity/extreme-point argument work here even though it fails for min() itself.
|
||||
|
||||
Step 2: verify via linear programming that uniform weights (1/3,1/3,1/3) are optimal for
|
||||
turning the sum bound into a bound on min(...) via min(a,b,c) <= w.(a,b,c) for any
|
||||
w in the simplex -- i.e. that 11/3 is the best bound achievable by this proof technique
|
||||
(cannot be tightened just by re-weighting).
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.optimize import minimize, linprog
|
||||
from core import state_1_3, state_2_2
|
||||
from core2 import full_tensor
|
||||
from cluster import cluster_map, nuc
|
||||
|
||||
|
||||
def sum3(psi):
|
||||
C = full_tensor(psi)
|
||||
return nuc(cluster_map(C, 0, 1)) + nuc(cluster_map(C, 0, 2)) + nuc(cluster_map(C, 0, 3))
|
||||
|
||||
|
||||
def neg_sum_2_2(params, pair1, pair2):
|
||||
return -sum3(state_2_2(params, pair1, pair2))
|
||||
|
||||
|
||||
def neg_sum_1_3(params, source):
|
||||
return -sum3(state_1_3(params, source))
|
||||
|
||||
|
||||
def run(obj, nparams, args, n_restarts, seed0, label):
|
||||
best = -np.inf
|
||||
for i in range(n_restarts):
|
||||
rng = np.random.default_rng(seed0 + i)
|
||||
x0 = rng.normal(size=nparams)
|
||||
res = minimize(obj, x0, args=args, method='Powell',
|
||||
options={'maxiter': 1500, 'xtol': 1e-9, 'ftol': 1e-11})
|
||||
v = -res.fun
|
||||
if v > best:
|
||||
best = v
|
||||
print(f'{label}: max sum = {best:.6f} ({n_restarts} restarts)')
|
||||
return best
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Step 1: max(||M_AB||+||M_AC||+||M_AD||) over each pure single-cut family ===")
|
||||
r1 = run(neg_sum_2_2, 16, ((0, 1), (2, 3)), 8, 6000, 'cut AB|CD')
|
||||
r2 = run(neg_sum_2_2, 16, ((0, 2), (1, 3)), 8, 6100, 'cut AC|BD')
|
||||
r3 = run(neg_sum_1_3, 18, (0,), 8, 6200, 'cut A|BCD')
|
||||
r4 = run(neg_sum_1_3, 18, (1,), 8, 6300, 'cut B|ACD')
|
||||
print()
|
||||
print('Overall max sum over ALL single-cut pure product states:', max(r1, r2, r3, r4))
|
||||
print('(convexity of the sum then extends this bound to ALL biseparable mixtures)')
|
||||
|
||||
print()
|
||||
print("=== Step 2: is uniform weighting (1/3,1/3,1/3) optimal for the resulting bound? ===")
|
||||
# extreme points of the "sum" bound: (1,5,5), (5,1,5), (5,5,1)
|
||||
c = [0, 0, 0, 1]
|
||||
A_ub = [[1, 5, 5, -1], [5, 1, 5, -1], [5, 5, 1, -1]]
|
||||
b_ub = [0, 0, 0]
|
||||
A_eq = [[1, 1, 1, 0]]
|
||||
b_eq = [1]
|
||||
bounds = [(0, 1), (0, 1), (0, 1), (None, None)]
|
||||
res = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds, method='highs')
|
||||
print('LP-optimal weights:', res.x[:3], ' bound t=', res.x[3])
|
||||
print('11/3 =', 11 / 3, ' (confirms uniform weights are optimal for this proof technique)')
|
||||
183
scripts/symmetry_oracle.py
Normal file
183
scripts/symmetry_oracle.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
# symmetry_oracle.sage
|
||||
#
|
||||
# Zwei unabhaengige, computergestuetzte "Symmetrie-Orakel" fuer den vollen
|
||||
# Bloch-Tensor C(rho) eines Graphzustands, angewandt auf einen Schnitt S | S^c.
|
||||
# Verallgemeinert das von Hand gerechnete Ring-Graphzustand-Reflexions-Beispiel
|
||||
# (Section 6 des Papers) zu einem Werkzeug, das man auf beliebige Graphen mit
|
||||
# n <~ 6-8 Knoten anwenden kann.
|
||||
#
|
||||
# (A) Stabilisator-Mechanismus (Lemma "stabilizer-degeneracy"):
|
||||
# exakte GF(2)-symplektische Rechnung an den Stabilisatorerzeugern
|
||||
# K_v = X_v * prod_{u ~ v} Z_u. Liefert -- wenn die Injektivitaetshypothese
|
||||
# fuer psi erfuellt ist -- Singulaerwert und Vielfachheit von M_tilde_S(rho_H)
|
||||
# in geschlossener Form, ohne jede Numerik.
|
||||
#
|
||||
# (B) Schwache (cut-faktorisierende) Symmetrie-Hypothese (Prop. "block-diagonal"):
|
||||
# H = Stab_{Aut(G)}(S) (setwise), gefunden durch direkte Enumeration von
|
||||
# Aut(G) (fuer n<=6-8 unproblematisch). Die induzierte Permutationsdarstellung
|
||||
# von H auf S wird ueber die Charaktertafel in Aut-Irreduzible zerlegt --
|
||||
# das sagt voraus, in welche Isotypen-Bloecke M_S zerfaellt (nicht die
|
||||
# Singulaerwerte selbst, die bleiben zustandsabhaengig).
|
||||
#
|
||||
# Die beiden Mechanismen sind gemaess Remark "two-mechanisms" im Paper
|
||||
# unabhaengig und werden hier bewusst getrennt und gegeneinander gegengeprueft,
|
||||
# nicht kombiniert.
|
||||
#
|
||||
# Ausfuehren mit: sage symmetry_oracle.sage
|
||||
#
|
||||
# HINWEIS: Dieses Skript wurde ohne Zugriff auf eine laufende Sage-Instanz
|
||||
# geschrieben (reines Nachrechnen von Hand als Validierung, siehe unten). Die
|
||||
# Mathematik ist geprueft; falls eine einzelne Sage-Methode in eurer Version
|
||||
# anders heisst, sollte der eingebaute Konsistenz-Check (assert) das sofort
|
||||
# anzeigen statt still falsche Zahlen zu liefern.
|
||||
|
||||
from sage.all import *
|
||||
|
||||
|
||||
def graph_state_generator_matrix(G):
|
||||
"""
|
||||
n x 2n GF(2)-Matrix [I | A], deren Zeilen die symplektischen Vektoren der
|
||||
Stabilisatorerzeuger K_v = X_v * prod_{u ~ v} Z_u sind.
|
||||
Konvention: Spalten 0..n-1 = X-Anteile, n..2n-1 = Z-Anteile (wie im Paper,
|
||||
Lemma "code-support").
|
||||
"""
|
||||
n = G.num_verts()
|
||||
A = G.adjacency_matrix().change_ring(GF(2))
|
||||
I = identity_matrix(GF(2), n)
|
||||
return I.augment(A), n
|
||||
|
||||
|
||||
def restrict_columns(Gen, S, n):
|
||||
cols = sorted(S) + [n + v for v in sorted(S)]
|
||||
return Gen.matrix_from_columns(cols)
|
||||
|
||||
|
||||
def stabilizer_mechanism(G, S):
|
||||
"""Mechanismus (A), siehe Kopfkommentar."""
|
||||
Gen, n = graph_state_generator_matrix(G)
|
||||
S = list(S)
|
||||
Sc = [v for v in G.vertices() if v not in S]
|
||||
Gen_S = restrict_columns(Gen, S, n) # phi: Restriktion auf S (Quelle)
|
||||
Gen_Sc = restrict_columns(Gen, Sc, n) # psi: Restriktion auf S^c (Ziel)
|
||||
rank_phi, rank_psi = Gen_S.rank(), Gen_Sc.rank()
|
||||
ker_phi_dim, ker_psi_dim = n - rank_phi, n - rank_psi
|
||||
|
||||
result = {
|
||||
"S": S, "Sc": Sc, "n": n,
|
||||
"dim_ker_phi": ker_phi_dim, "dim_im_phi": rank_phi,
|
||||
"dim_ker_psi": ker_psi_dim, "dim_im_psi": rank_psi,
|
||||
"psi_injective": (ker_psi_dim == 0),
|
||||
}
|
||||
if result["psi_injective"]:
|
||||
dS, dSc = 2 ** len(S), 2 ** len(Sc) # Qubit-Fall, d=2 pro Partei
|
||||
ker_phi_size = 2 ** ker_phi_dim
|
||||
im_phi_size = 2 ** rank_phi
|
||||
mult = im_phi_size - 1
|
||||
sv = sqrt(QQ(ker_phi_size) / QQ((dS - 1) * (dSc - 1)))
|
||||
result["singular_value"] = sv
|
||||
result["multiplicity"] = mult
|
||||
result["nuclear_norm_contribution"] = mult * sv
|
||||
return result
|
||||
|
||||
|
||||
def weak_hypothesis_prediction(G, S):
|
||||
"""Mechanismus (B), siehe Kopfkommentar."""
|
||||
Aut = G.automorphism_group()
|
||||
S = list(S)
|
||||
S_frozen = frozenset(S)
|
||||
# H = Stab_{Aut(G)}(S) setwise, durch direkte Enumeration (robust, |Aut(G)|
|
||||
# ist fuer n<=6-8 klein genug, dass das kein Performanceproblem ist).
|
||||
stab_elements = [g for g in Aut if frozenset(g(v) for v in S) == S_frozen]
|
||||
H = PermutationGroup(stab_elements)
|
||||
|
||||
reps = H.conjugacy_classes_representatives()
|
||||
sizes = [len({h * g * h ** (-1) for h in H}) for g in reps]
|
||||
order = H.order()
|
||||
CT = H.character_table()
|
||||
|
||||
id_idx = [i for i, g in enumerate(reps) if g == H.one()][0]
|
||||
degrees = [CT[i, id_idx] for i in range(CT.nrows())]
|
||||
assert sum(d ** 2 for d in degrees) == order, \
|
||||
"Konsistenz-Check fehlgeschlagen: Summe der Quadrate der Irrep-Dimensionen != |H|."
|
||||
|
||||
perm_char = [sum(1 for v in S if g(v) == v) for g in reps]
|
||||
|
||||
decomposition = []
|
||||
for i in range(CT.nrows()):
|
||||
chi = [CT[i, j] for j in range(len(reps))]
|
||||
mult = sum(sizes[j] * perm_char[j] * chi[j].conjugate()
|
||||
for j in range(len(reps))) / order
|
||||
if mult != 0:
|
||||
decomposition.append((mult, degrees[i]))
|
||||
|
||||
# Zweiter, von der Spaltenreihenfolge unabhaengiger Konsistenz-Check:
|
||||
# sum_lambda m_lambda * dim(V_lambda) muss = dim(Perm(S)) = |S| sein.
|
||||
# Schlaegt dieser Check fehl, stimmt die Zuordnung reps <-> CT-Spalten
|
||||
# nicht ueberein (dann bitte melden statt der Ausgabe zu trauen).
|
||||
total_dim = sum(m * d for m, d in decomposition)
|
||||
assert total_dim == len(S), (
|
||||
f"Konsistenz-Check fehlgeschlagen: sum(m*dim) = {total_dim} != |S| = {len(S)}. "
|
||||
"Vermutlich Reihenfolge-Mismatch zwischen conjugacy_classes_representatives() "
|
||||
"und character_table()-Spalten -- bitte melden, dann fixen wir das gemeinsam."
|
||||
)
|
||||
|
||||
return {"H": H, "order": order, "decomposition": decomposition}
|
||||
|
||||
|
||||
def report(G, S, label):
|
||||
print("=" * 70)
|
||||
print(f"{label}: Schnitt S={sorted(S)} | S^c={[v for v in G.vertices() if v not in S]}")
|
||||
print("=" * 70)
|
||||
|
||||
stab = stabilizer_mechanism(G, S)
|
||||
print("\n-- (A) Stabilisator-Mechanismus --")
|
||||
print(f" dim ker(phi) = {stab['dim_ker_phi']}, dim im(phi) = {stab['dim_im_phi']}")
|
||||
print(f" dim ker(psi) = {stab['dim_ker_psi']}, dim im(psi) = {stab['dim_im_psi']}")
|
||||
if stab["psi_injective"]:
|
||||
sv, mult, contrib = stab["singular_value"], stab["multiplicity"], stab["nuclear_norm_contribution"]
|
||||
print(" psi injektiv -> Lemma greift exakt.")
|
||||
print(f" Singulaerwert = {sv} (numerisch {float(sv):.6f})")
|
||||
print(f" Vielfachheit = {mult}")
|
||||
print(f" Beitrag zur Nuklearnorm = {contrib} (numerisch {float(contrib):.6f})")
|
||||
else:
|
||||
print(" psi NICHT injektiv -> Lemma greift nicht direkt auf den vollen M_S-Block;")
|
||||
print(" der Ueberschuss sitzt in tieferen Sektoren (vgl. Diskussion des")
|
||||
print(" diagonalen Schnitts in Section 6 des Papers).")
|
||||
|
||||
weak = weak_hypothesis_prediction(G, S)
|
||||
print("\n-- (B) Schwache Symmetrie-Hypothese (Aut(G)-Stabilisator) --")
|
||||
print(f" H = Stab_Aut(G)(S), |H| = {weak['order']}")
|
||||
print(" Isotypenzerlegung von Perm(S) unter H (Multiplizitaet, Dimension):")
|
||||
for mult, deg in weak["decomposition"]:
|
||||
print(f" m={mult}, dim={deg} -> {mult} Kopie(n) eines {deg}-dim. Blocks,")
|
||||
print(" je x3 fuer die interne Pauli-Richtung (x,y,z)")
|
||||
print()
|
||||
|
||||
|
||||
# --- Validierung an einem bekannten Fall: der Ring-Graphzustand aus dem Paper ---
|
||||
|
||||
ring = Graph({0: [1, 3], 1: [0, 2], 2: [1, 3], 3: [0, 2]}) # 4-Zyklus 0-1-2-3-0
|
||||
|
||||
report(ring, [0, 1], "Ring-Graphzustand, 'benachbarter' Schnitt")
|
||||
report(ring, [0, 2], "Ring-Graphzustand, 'diagonaler' Schnitt")
|
||||
|
||||
# Erwartung (Tabelle zum Ring-Graphzustand im Paper, dort 1-indiziert als
|
||||
# {1,2}|{3,4} bzw. {1,3}|{2,4}, hier 0-indiziert):
|
||||
#
|
||||
# benachbarter Schnitt {0,1}|{2,3}:
|
||||
# (A) psi injektiv, Nuklearnorm-Beitrag = 5 <-- sollte exakt 5 ausgeben
|
||||
# (B) H = <(0 1)(2 3)> ~= Z_2, Perm(S) = trivial + sign, je Multiplizitaet 1
|
||||
# (das ist exakt die im Paper von Hand hergeleitete Zerlegung in die
|
||||
# symmetrische/antisymmetrische Kombination (e_x^(1) +- e_x^(2))/sqrt(2))
|
||||
#
|
||||
# diagonaler Schnitt {0,2}|{1,3}:
|
||||
# (A) psi NICHT injektiv, weil X_0 X_2 in H vollstaendig auf S getragen ist
|
||||
# -- passend zur Bemerkung im Paper, dass hier der volle Sektor nur
|
||||
# saettigt und der Ueberschuss aus tieferen Sektoren kommt.
|
||||
|
||||
# --- Eigenes Beispiel: hier einen n=5/6-Graphen eintragen ---
|
||||
eigener_graph = Graph({0: [1, 2], 1: [0, 2, 3], 2: [0, 1, 4], 3: [1, 4], 4: [2, 3]})
|
||||
report(eigener_graph, [0, 1], "eigenes Beispiel")
|
||||
|
||||
star = Graph({0: [1,2,3]}) # Stern: Zentrum 0, Blätter 1,2,3
|
||||
report(star, [1,2,3], "Stern, S = Blätter")
|
||||
45
scripts/universal_ceiling.py
Normal file
45
scripts/universal_ceiling.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""universal_ceiling.py -- closed-form universal ceiling for shadow-map nuclear norms,
|
||||
derived via Cauchy-Schwarz (rank bound) + a trace identity for the Pauli correlation
|
||||
tensor of a pure n-qubit state.
|
||||
|
||||
Single-party source (m=1) in n qubits:
|
||||
max_rho ||M_a(rho)||_* = 3 * sqrt(2^(n-2) / (2^(n-1)-1))
|
||||
reproduces sqrt(6) (n=3), 6/sqrt(7) (n=4), 2.19089... (n=5) -- exactly the "common
|
||||
values" the paper reports numerically for GHZ_n / line_n / ring_n / connected graph
|
||||
states.
|
||||
|
||||
General m-qubit cluster source S (m <= n/2):
|
||||
max_rho ||M_S(rho)||_* = sqrt( (2^(2m)-1)(2^n - 2^(n-2m)) / ((2^m-1)(2^(n-m)-1)) )
|
||||
which reduces to the m=1 formula above, and gives exactly 5 for (n=4, m=2) -- matching
|
||||
the numerically found ceiling for the 2-qubit cluster maps M_AB, M_AC, M_AD.
|
||||
|
||||
Equality holds iff (i) the m-qubit source marginal rho_S is maximally mixed
|
||||
(tr(rho_S^2) = 1/2^m), and (ii) the resulting shadow map has all singular values equal
|
||||
("isotropic"). This ceiling is saturated not only by highly symmetric stabilizer states
|
||||
(GHZ_n, connected graph states) but also by simple biseparable states across an
|
||||
UNRELATED cut (e.g. two Bell pairs) -- which is why Phi_sym / a single ||M_S||_* cannot
|
||||
serve as a genuine multipartite entanglement witness on their own.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def universal_ceiling(n, m):
|
||||
num = (2 ** (2 * m) - 1) * (2 ** n - 2 ** (n - 2 * m))
|
||||
den = (2 ** m - 1) * (2 ** (n - m) - 1)
|
||||
return np.sqrt(num / den)
|
||||
|
||||
|
||||
def universal_ceiling_singleparty(n):
|
||||
return 3 * np.sqrt(2 ** (n - 2) / (2 ** (n - 1) - 1))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Single-party (m=1) ceiling for n=3,4,5:")
|
||||
for n in [3, 4, 5]:
|
||||
print(f" n={n}: {universal_ceiling_singleparty(n):.6f} "
|
||||
f"(general formula gives: {universal_ceiling(n, 1):.6f})")
|
||||
print(" compare: sqrt(6) =", np.sqrt(6), " 6/sqrt(7) =", 6 / np.sqrt(7))
|
||||
|
||||
print()
|
||||
print("2-qubit cluster (m=2) ceiling for n=4 qubits:")
|
||||
print(f" {universal_ceiling(4, 2):.6f} (matches the numerically found value 5.0)")
|
||||
Loading…
Add table
Add a link
Reference in a new issue