#!/usr/bin/env python3 """ Entropy-based observable-diameter estimator on complex projective space CP^n. Interpretation -------------- We identify CP^n with the projective pure-state space of C^(n+1). To define an entanglement entropy observable we choose a factorization n + 1 = d_A * d_B, so the projective space is CP^(d_A d_B - 1). For a projective point [psi], represented by a unit vector psi in C^(d_A d_B), define the observable S_A([psi]) = -Tr(rho_A log_2 rho_A), rho_A = Tr_B |psi> List[Tuple[int, int]]: dims: List[Tuple[int, int]] = [] for item in spec.split(","): token = item.strip().lower() if not token: continue if "x" not in token: raise ValueError(f"Bad dimension token '{item}'. Use forms like 4x8,8x16.") a_str, b_str = token.split("x", 1) d_a = int(a_str) d_b = int(b_str) if d_a <= 1 or d_b <= 1: raise ValueError("Both subsystem dimensions must be >= 2.") if d_a > d_b: d_a, d_b = d_b, d_a dims.append((d_a, d_b)) if not dims: raise ValueError("No dimensions were parsed.") return dims def haar_matrix(d_a: int, d_b: int, rng: np.random.Generator) -> np.ndarray: real = rng.normal(size=(d_a, d_b)) imag = rng.normal(size=(d_a, d_b)) return (real + 1j * imag) / math.sqrt(2.0) def reduced_density_from_matrix(g: np.ndarray) -> np.ndarray: rho = g @ g.conj().T tr = float(np.trace(rho).real) rho /= tr return rho def entropy_bits_from_rho(rho: np.ndarray, tol: float = 1e-14) -> float: eigvals = np.linalg.eigvalsh(rho) eigvals = np.clip(eigvals.real, 0.0, 1.0) eigvals = eigvals[eigvals > tol] if eigvals.size == 0: return 0.0 return float(-np.sum(eigvals * np.log2(eigvals))) def random_state_and_entropy( d_a: int, d_b: int, rng: np.random.Generator ) -> Tuple[np.ndarray, float]: g = haar_matrix(d_a, d_b, rng) rho_a = reduced_density_from_matrix(g) entropy_bits = entropy_bits_from_rho(rho_a) psi = g.reshape(-1) psi /= np.linalg.norm(psi) return psi, entropy_bits def partial_diameter(samples: np.ndarray, mass: float) -> Tuple[float, float, float]: if not 0.0 < mass <= 1.0: raise ValueError("mass must lie in (0, 1].") x = np.sort(np.asarray(samples, dtype=float)) n = x.size if n == 0: raise ValueError("samples must be non-empty") if n == 1: return 0.0, float(x[0]), float(x[0]) m = int(math.ceil(mass * n)) if m <= 1: return 0.0, float(x[0]), float(x[0]) widths = x[m - 1 :] - x[: n - m + 1] idx = int(np.argmin(widths)) left = float(x[idx]) right = float(x[idx + m - 1]) return float(right - left), left, right def fubini_study_distance(psi: np.ndarray, phi: np.ndarray) -> float: overlap = abs(np.vdot(psi, phi)) overlap = min(1.0, max(0.0, float(overlap))) return float(math.acos(overlap)) def empirical_lipschitz_constant( states: Sequence[np.ndarray], values: np.ndarray, rng: np.random.Generator, num_pairs: int, ) -> Tuple[float, float]: n = len(states) if n < 2 or num_pairs <= 0: return float("nan"), float("nan") ratios = [] values = np.asarray(values, dtype=float) for _ in range(num_pairs): i = int(rng.integers(0, n)) j = int(rng.integers(0, n - 1)) if j >= i: j += 1 d_fs = fubini_study_distance(states[i], states[j]) if d_fs < 1e-12: continue ratio = abs(values[i] - values[j]) / d_fs ratios.append(ratio) if not ratios: return float("nan"), float("nan") arr = np.asarray(ratios, dtype=float) return float(np.max(arr)), float(np.quantile(arr, 0.99)) def hayden_mean_lower_bound_bits(d_a: int, d_b: int) -> float: return math.log2(d_a) - d_a / (2.0 * math.log(2.0) * d_b) def hayden_beta_bits(d_a: int, d_b: int) -> float: return d_a / (math.log(2.0) * d_b) def hayden_alpha_bits(d_a: int, d_b: int, kappa: float) -> float: dim = d_a * d_b return (math.log2(d_a) / math.sqrt(HAYDEN_C * (dim - 1.0))) * math.sqrt(math.log(1.0 / kappa)) def hayden_one_sided_width_bits(d_a: int, d_b: int, kappa: float) -> float: return hayden_beta_bits(d_a, d_b) + hayden_alpha_bits(d_a, d_b, kappa) def hayden_lower_cutoff_bits(d_a: int, d_b: int, kappa: float) -> float: return math.log2(d_a) - hayden_one_sided_width_bits(d_a, d_b, kappa) def levy_hayden_scaling_width_bits(d_a: int, d_b: int, kappa: float) -> float: dim = d_a * d_b half_width = (math.log2(d_a) / math.sqrt(HAYDEN_C * (dim - 1.0))) * math.sqrt(math.log(2.0 / kappa)) return 2.0 * half_width def hayden_deficit_tail_bound_bits(d_a: int, d_b: int, deficits_bits: np.ndarray) -> np.ndarray: beta = hayden_beta_bits(d_a, d_b) dim = d_a * d_b log_term = math.log2(d_a) shifted = np.maximum(np.asarray(deficits_bits, dtype=float) - beta, 0.0) exponent = -(dim - 1.0) * HAYDEN_C * (shifted ** 2) / (log_term ** 2) bound = np.exp(exponent) bound[deficits_bits <= beta] = 1.0 return np.clip(bound, 0.0, 1.0) def page_average_entropy_bits(d_a: int, d_b: int) -> float: # Exact Page formula in bits for d_b >= d_a. harmonic_tail = sum(1.0 / k for k in range(d_b + 1, d_a * d_b + 1)) nats = harmonic_tail - (d_a - 1.0) / (2.0 * d_b) return nats / math.log(2.0) @dataclass class SystemResult: d_a: int d_b: int projective_dim: int num_samples: int kappa: float mass: float entropy_bits: np.ndarray partial_diameter_bits: float interval_left_bits: float interval_right_bits: float mean_bits: float median_bits: float std_bits: float page_average_bits: float hayden_mean_lower_bits: float hayden_cutoff_bits: float hayden_one_sided_width_bits: float levy_scaling_width_bits: float empirical_lipschitz_max: float empirical_lipschitz_q99: float normalized_proxy_max: float normalized_proxy_q99: float def simulate_system( d_a: int, d_b: int, num_samples: int, kappa: float, rng: np.random.Generator, lipschitz_pairs: int, ) -> Tuple[SystemResult, List[np.ndarray]]: entropies = np.empty(num_samples, dtype=float) states: List[np.ndarray] = [] for idx in tqdm(range(num_samples),desc=f"Simulating system for {d_a}x{d_b} with kappa={kappa}", unit="samples"): psi, s_bits = random_state_and_entropy(d_a, d_b, rng) entropies[idx] = s_bits states.append(psi) mass = 1.0 - kappa width, left, right = partial_diameter(entropies, mass) lip_max, lip_q99 = empirical_lipschitz_constant(states, entropies, rng, lipschitz_pairs) normalized_proxy_max = width / lip_max if lip_max == lip_max and lip_max > 0 else float("nan") normalized_proxy_q99 = width / lip_q99 if lip_q99 == lip_q99 and lip_q99 > 0 else float("nan") result = SystemResult( d_a=d_a, d_b=d_b, projective_dim=d_a * d_b - 1, num_samples=num_samples, kappa=kappa, mass=mass, entropy_bits=entropies, partial_diameter_bits=width, interval_left_bits=left, interval_right_bits=right, mean_bits=float(np.mean(entropies)), median_bits=float(np.median(entropies)), std_bits=float(np.std(entropies, ddof=1)) if num_samples > 1 else 0.0, page_average_bits=page_average_entropy_bits(d_a, d_b), hayden_mean_lower_bits=hayden_mean_lower_bound_bits(d_a, d_b), hayden_cutoff_bits=hayden_lower_cutoff_bits(d_a, d_b, kappa), hayden_one_sided_width_bits=hayden_one_sided_width_bits(d_a, d_b, kappa), levy_scaling_width_bits=levy_hayden_scaling_width_bits(d_a, d_b, kappa), empirical_lipschitz_max=lip_max, empirical_lipschitz_q99=lip_q99, normalized_proxy_max=normalized_proxy_max, normalized_proxy_q99=normalized_proxy_q99, ) return result, states def write_summary_csv(results: Sequence[SystemResult], out_path: Path) -> None: fieldnames = [ "d_a", "d_b", "projective_dim", "num_samples", "kappa", "mass", "partial_diameter_bits", "interval_left_bits", "interval_right_bits", "mean_bits", "median_bits", "std_bits", "page_average_bits", "hayden_mean_lower_bits", "hayden_cutoff_bits", "hayden_one_sided_width_bits", "levy_scaling_width_bits", "empirical_lipschitz_max_bits_per_rad", "empirical_lipschitz_q99_bits_per_rad", "normalized_proxy_max_rad", "normalized_proxy_q99_rad", ] with out_path.open("w", newline="") as fh: writer = csv.DictWriter(fh, fieldnames=fieldnames) writer.writeheader() for r in results: writer.writerow( { "d_a": r.d_a, "d_b": r.d_b, "projective_dim": r.projective_dim, "num_samples": r.num_samples, "kappa": r.kappa, "mass": r.mass, "partial_diameter_bits": r.partial_diameter_bits, "interval_left_bits": r.interval_left_bits, "interval_right_bits": r.interval_right_bits, "mean_bits": r.mean_bits, "median_bits": r.median_bits, "std_bits": r.std_bits, "page_average_bits": r.page_average_bits, "hayden_mean_lower_bits": r.hayden_mean_lower_bits, "hayden_cutoff_bits": r.hayden_cutoff_bits, "hayden_one_sided_width_bits": r.hayden_one_sided_width_bits, "levy_scaling_width_bits": r.levy_scaling_width_bits, "empirical_lipschitz_max_bits_per_rad": r.empirical_lipschitz_max, "empirical_lipschitz_q99_bits_per_rad": r.empirical_lipschitz_q99, "normalized_proxy_max_rad": r.normalized_proxy_max, "normalized_proxy_q99_rad": r.normalized_proxy_q99, } ) def plot_histogram(result: SystemResult, outdir: Path) -> Path: plt.figure(figsize=(8.5, 5.5)) ent = result.entropy_bits plt.hist(ent, bins=40, density=True, alpha=0.75) plt.axvline(math.log2(result.d_a), linestyle="--", linewidth=2, label=r"$\log_2 d_A$") plt.axvline(result.mean_bits, linestyle="-.", linewidth=2, label="empirical mean") plt.axvline(result.page_average_bits, linestyle=":", linewidth=2, label="Page average") local_min = float(np.min(ent)) local_max = float(np.max(ent)) local_range = max(local_max - local_min, 1e-9) if result.hayden_cutoff_bits >= local_min - 0.15 * local_range: plt.axvline(result.hayden_cutoff_bits, linestyle="-", linewidth=2, label="Hayden cutoff") plt.axvspan(result.interval_left_bits, result.interval_right_bits, alpha=0.18, label=f"shortest {(result.mass):.0%} interval") plt.xlim(local_min - 0.12 * local_range, local_max + 0.35 * local_range) plt.xlabel("Entropy of entanglement S_A (bits)") plt.ylabel("Empirical density") plt.title( f"Entropy distribution on CP^{result.projective_dim} via C^{result.d_a} ⊗ C^{result.d_b}" ) plt.legend(frameon=False) plt.tight_layout() out_path = outdir / f"entropy_histogram_{result.d_a}x{result.d_b}.png" plt.savefig(out_path, dpi=180) plt.close() return out_path def plot_tail(result: SystemResult, outdir: Path) -> Path: deficits = math.log2(result.d_a) - np.sort(result.entropy_bits) n = deficits.size ccdf = 1.0 - (np.arange(1, n + 1) / n) ccdf = np.maximum(ccdf, 1.0 / n) x_grid = np.linspace(0.0, max(float(np.max(deficits)), result.hayden_one_sided_width_bits) * 1.05, 250) bound = hayden_deficit_tail_bound_bits(result.d_a, result.d_b, x_grid) plt.figure(figsize=(8.5, 5.5)) plt.semilogy(deficits, ccdf, marker="o", linestyle="none", markersize=3, alpha=0.5, label="empirical tail") plt.semilogy(x_grid, bound, linewidth=2, label="Hayden lower-tail bound") plt.axvline(hayden_beta_bits(result.d_a, result.d_b), linestyle="--", linewidth=1.8, label=r"$\beta$") plt.xlabel(r"Entropy deficit $\log_2 d_A - S_A$ (bits)") plt.ylabel(r"Tail probability $\Pr[\log_2 d_A - S_A > t]$") plt.title(f"Entropy-deficit tail for C^{result.d_a} ⊗ C^{result.d_b}") plt.legend(frameon=False) plt.tight_layout() out_path = outdir / f"entropy_tail_{result.d_a}x{result.d_b}.png" plt.savefig(out_path, dpi=180) plt.close() return out_path def plot_concentration_summary(results: Sequence[SystemResult], outdir: Path) -> Path: x = np.array([r.projective_dim for r in results], dtype=float) partial_width = np.array([r.partial_diameter_bits for r in results], dtype=float) std = np.array([r.std_bits for r in results], dtype=float) mean_deficit = np.array([math.log2(r.d_a) - r.mean_bits for r in results], dtype=float) plt.figure(figsize=(8.5, 5.5)) plt.plot(x, partial_width, marker="o", linewidth=2, label=r"shortest $(1-\kappa)$ entropy interval") plt.plot(x, std, marker="s", linewidth=2, label="empirical standard deviation") plt.plot(x, mean_deficit, marker="^", linewidth=2, label=r"mean deficit $\log_2 d_A - \mathbb{E}S_A$") plt.xlabel(r"Projective dimension $n = d_A d_B - 1$") plt.ylabel(r"Bits") plt.title("Empirical concentration of the entropy observable on CP^n") plt.legend(frameon=False) plt.tight_layout() out_path = outdir / "entropy_partial_diameter_vs_projective_dimension.png" plt.savefig(out_path, dpi=180) plt.close() return out_path def plot_normalized_proxy(results: Sequence[SystemResult], outdir: Path) -> Path | None: good = [r for r in results if r.normalized_proxy_q99 == r.normalized_proxy_q99] if not good: return None x = np.array([r.projective_dim for r in good], dtype=float) y_max = np.array([r.normalized_proxy_max for r in good], dtype=float) y_q99 = np.array([r.normalized_proxy_q99 for r in good], dtype=float) plt.figure(figsize=(8.5, 5.5)) plt.plot(x, y_max, marker="o", linewidth=2, label="width / sampled Lipschitz max") plt.plot(x, y_q99, marker="s", linewidth=2, label="width / sampled Lipschitz q99") plt.xlabel(r"Projective dimension $n = d_A d_B - 1$") plt.ylabel("Empirical normalized proxy (radians)") plt.title("Lipschitz-normalized entropy proxy for observable diameter") plt.legend(frameon=False) plt.tight_layout() out_path = outdir / "normalized_entropy_proxy_vs_projective_dimension.png" plt.savefig(out_path, dpi=180) plt.close() return out_path def print_console_summary(results: Sequence[SystemResult]) -> None: print("dA dB CP^n mean(bits) part_diam(bits) Page(bits) Hayden_cutoff(bits) L_emp_q99") for r in results: lip_q99 = f"{r.empirical_lipschitz_q99:.4f}" if r.empirical_lipschitz_q99 == r.empirical_lipschitz_q99 else "nan" print( f"{r.d_a:2d} {r.d_b:2d} {r.projective_dim:5d} " f"{r.mean_bits:10.6f} {r.partial_diameter_bits:15.6f} " f"{r.page_average_bits:10.6f} {r.hayden_cutoff_bits:20.6f} {lip_q99}" ) def build_argument_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--dims", default="4x4,8x8,12x12,16x16,32x32,64x64,128x128", help="Comma-separated subsystem sizes, e.g. 4x4,8x8,8x16", ) parser.add_argument("--samples", type=int, default=10**6, help="Samples per system") parser.add_argument("--kappa", type=float, default=1e-3, help="Observable-diameter loss parameter kappa") parser.add_argument( "--lipschitz-pairs", type=int, default=6000, help="Number of random state pairs used for empirical Lipschitz estimation", ) parser.add_argument("--seed", type=int, default=7, help="RNG seed") parser.add_argument( "--outdir", type=str, default="cpn_entropy_output", help="Output directory for CSV and plots", ) return parser def main() -> None: parser = build_argument_parser() args = parser.parse_args() if not 0.0 < args.kappa < 1.0: raise ValueError("kappa must lie in (0, 1)") if args.samples < 10: raise ValueError("Use at least 10 samples per system") dims = parse_dims(args.dims) rng = np.random.default_rng(args.seed) outdir = Path(args.outdir) outdir.mkdir(parents=True, exist_ok=True) results: List[SystemResult] = [] for d_a, d_b in dims: result, _states = simulate_system( d_a=d_a, d_b=d_b, num_samples=args.samples, kappa=args.kappa, rng=rng, lipschitz_pairs=args.lipschitz_pairs, ) results.append(result) plot_histogram(result, outdir) results = sorted(results, key=lambda r: r.projective_dim) write_summary_csv(results, outdir / "entropy_observable_summary.csv") plot_concentration_summary(results, outdir) plot_normalized_proxy(results, outdir) plot_tail(results[-1], outdir) print_console_summary(results) print(f"\nWrote results to: {outdir.resolve()}") if __name__ == "__main__": main()