#!/usr/bin/env python3
"""prime_slos_explore.py — Spectral signature of primes in SLOS concentration.

Tests whether prime-based label sets consistently sit between Sidon
(pure, maximal concentration) and dense non-Sidon (minimal concentration)
in eigenvalue product and SLOS KL divergence — across scales from small
primes (2,3,5,7,11,13,17) up to primes near 1 billion.

Hypothesis:
  Primes have partial Sidon-like additive structure: sums of primes are
  more constrained than sums of consecutive integers (non-Sidon) but less
  constrained than sums of powers of 2 (Sidon). If true, the ordering
  Sidon < primes < non-Sidon (by KL divergence from uniform) should hold
  at every scale and every set size.

Method:
  1. Generate label sets at 4 scales (small, 10^3, 10^6, 10^9)
  2. For each: compute eigenvalue product distinct_ratio (all sets)
  3. For each: compute exact tensor network entropy (N ≤ 8)
  4. For a subset: run sampled SLOS (100k shots) to cross-validate
  5. Report whether the ordering Sidon < primes < non-Sidon holds
"""

import sys
import os
import math
import json
import time
import hashlib
import itertools
from pathlib import Path

import numpy as np

REPO_ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS_DIR = REPO_ROOT / ".openresearch" / "artifacts"
OUTPUT_PATH = ARTIFACTS_DIR / "prime_slos_explore.json"

# ── Prime generation ────────────────────────────────────────────────────────

def primes_upto(limit):
    """Segmented sieve for primes up to limit. Returns list."""
    if limit < 2:
        return []
    sieve = bytearray(b'\x01') * (limit + 1)
    sieve[0:2] = b'\x00\x00'
    for i in range(2, int(limit ** 0.5) + 1):
        if sieve[i]:
            step = i
            start = i * i
            sieve[start:limit+1:step] = b'\x00' * ((limit - start) // step + 1)
    return [i for i, is_prime in enumerate(sieve) if is_prime]

def prime_clusters(count, offset_start=0):
    """Return `count` consecutive primes starting from offset_start-th prime."""
    # For small and medium limits, use sieve
    if offset_start < 1_000_000:
        limit = max(offset_start * 2 + 100, 10_000_000)
        all_primes = primes_upto(limit)
        if offset_start + count > len(all_primes):
            # Extend with nextprime for large offsets
            import sympy
            p = sympy.prime(offset_start + 1)
            cluster = []
            for _ in range(count):
                cluster.append(p)
                p = sympy.nextprime(p)
            return cluster
        return all_primes[offset_start:offset_start + count]
    else:
        import sympy
        p = sympy.prime(offset_start + 1)
        cluster = []
        for _ in range(count):
            cluster.append(p)
            p = sympy.nextprime(p)
        return cluster

# ── Sidon/non-Sidon reference sets ──────────────────────────────────────────

def sidon_pow2_set(size):
    return [1 << i for i in range(size)]

def nonsidon_consecutive_set(size, start=1):
    return list(range(start, start + size))

# ── Matrix and eigenvalue product ───────────────────────────────────────────

def build_sum_matrix(labels):
    n = len(labels)
    S = np.zeros((n, n), dtype=np.float64)
    for i in range(n):
        for j in range(n):
            S[i, j] = float(labels[i] + labels[j])
    max_val = S.max()
    if max_val > 0:
        S /= max_val
    return S

def matrix_to_unitary(S):
    A = np.array(S, dtype=np.float64)
    eigenvalues, eigenvectors = np.linalg.eigh(A)
    U = eigenvectors @ np.diag(np.exp(-1j * eigenvalues * math.pi / 4)) @ eigenvectors.conj().T
    return U, eigenvalues

def eigenvalue_products(eigenvalues, k):
    products = []
    for indices in itertools.combinations_with_replacement(range(len(eigenvalues)), k):
        prod = 1.0 + 0j
        for idx in indices:
            prod *= eigenvalues[idx]
        products.append(prod)
    return products

def product_distribution(products):
    rounded = [round(p.real, 8) + round(p.imag, 8) * 1j for p in products]
    counts = {}
    for r in rounded:
        counts[r] = counts.get(r, 0) + 1
    total = len(products)
    distinct = len(counts)
    max_degen = max(counts.values()) if counts else 0
    probs = [c / total for c in counts.values()]
    prod_entropy = -sum(p * math.log2(p) for p in probs if p > 1e-15) if probs else 0.0
    max_possible_entropy = math.log2(distinct) if distinct > 1 else 1.0
    return {
        "distinct": distinct,
        "total": total,
        "distinct_ratio": distinct / max(total, 1),
        "max_degen_ratio": max_degen / max(total, 1),
        "product_entropy": round(prod_entropy, 4),
        "entropy_ratio": round(prod_entropy / max_possible_entropy, 4) if distinct > 1 else 1.0,
    }

# ── Tensor network entropy (exact, no sampling) ────────────────────────────

def _symmetrize_2(T):
    return (T + T.swapaxes(0, 1)) / math.sqrt(2)

def _symmetrize_3(T):
    s = sum(T.transpose(p) for p in itertools.permutations([0, 1, 2]))
    return s / math.sqrt(6)

def _fock_probs_1(U):
    col0 = U[:, 0]
    probs = np.abs(col0) ** 2
    return probs[probs > 1e-15]

def _fock_probs_2(U):
    N = U.shape[0]
    col0, col1 = U[:, 0], U[:, 1]
    T = _symmetrize_2(np.outer(col0, col1))
    output_density = np.abs(T) ** 2
    fock = []
    for i in range(N):
        for j in range(i, N):
            p = float(output_density[i, j] + output_density[j, i]) if i != j else float(output_density[i, i])
            if p > 1e-15:
                fock.append(p)
    return np.array(fock)

def _fock_probs_3(U):
    N = U.shape[0]
    col0, col1, col2 = U[:, 0], U[:, 1], U[:, 2]
    T_dist = np.einsum('i,j,k->ijk', col0, col1, col2)
    T = _symmetrize_3(T_dist)
    output_density = np.abs(T) ** 2
    fock = []
    for i in range(N):
        for j in range(i, N):
            for k in range(j, N):
                if i == j == k:
                    p = float(output_density[i, i, i])
                elif i == j:
                    p = float(output_density[i, i, k] + output_density[i, k, i] + output_density[k, i, i])
                elif j == k:
                    p = float(output_density[i, j, j] + output_density[j, i, j] + output_density[j, j, i])
                else:
                    p = float(output_density[i, j, k] + output_density[i, k, j] +
                              output_density[j, i, k] + output_density[j, k, i] +
                              output_density[k, i, j] + output_density[k, j, i])
                if p > 1e-15:
                    fock.append(p)
    return np.array(fock)

def tensor_entropy(U, k):
    if k == 1:
        probs = _fock_probs_1(U)
    elif k == 2:
        probs = _fock_probs_2(U)
    elif k == 3:
        probs = _fock_probs_3(U)
    else:
        return None, None
    total = np.sum(probs)
    if total <= 0:
        return 0.0, 0
    p = probs / total
    entropy = float(-np.sum(p * np.log2(p + 1e-15)))
    nonzero = int(np.sum(p > 1e-15))
    max_ent = math.log2(max(nonzero, 1))
    return entropy, entropy / max_ent if max_ent > 0 else 1.0

# ── Run SLOS via Perceval (sampled) ─────────────────────────────────────────

def run_slos(U, k, n_shots=100000):
    """Run Perceval SLOS for a given unitary and photon number."""
    try:
        import perceval as pcvl
    except ImportError:
        return None
    n_modes = U.shape[0]
    circuit = pcvl.Unitary(U[:n_modes, :n_modes])
    input_state = pcvl.BasicState([1] * k + [0] * (n_modes - k))
    processor = pcvl.Processor("SLOS", circuit)
    processor.with_input(input_state)
    sampler = pcvl.algorithm.Sampler(processor)
    results = sampler.sample_count(n_shots)
    probs = [c / n_shots for c in results["results"].values()]
    entropy = -sum(p * math.log2(p) for p in probs if p > 1e-15)
    nonzero = sum(1 for p in probs if p > 1e-10)
    max_ent = math.log2(max(nonzero, 1))
    return {
        "entropy": round(entropy, 6),
        "entropy_ratio": round(entropy / max_ent, 4) if max_ent > 0 else 1.0,
        "nonzero": nonzero,
        "max_prob": round(max(probs), 6) if probs else 0,
    }

# ── Main exploration ────────────────────────────────────────────────────────

def explore():
    print("=" * 60)
    print("  Prime SLOS Spectral Exploration")
    print("=" * 60)
    
    scales = {
        "small": 0,        # first primes: 2,3,5,7,11,13,17,19
        "kilo": 168,       # primes near 1000 (~168th prime = 997)
        "million": 78498,  # primes near 10^6 (~78498 primes below 1M)
        "billion": None,   # primes near 10^9
        "trillion": None,  # primes near 10^12
        "quadrillion": None, # primes near 10^15
        "quintillion": None, # primes near 10^18
    }
    
    sizes = [5, 6, 7, 8, 10]
    k_values = [2]
    results = []
    
    scales_list = []
    
    # Generate Sidon references
    print("\n--- Generating reference sets ---")
    for size in sizes:
        scales_list.append({
            "desc": f"sidon_pow2_s{size}",
            "category": "sidon",
            "labels": sidon_pow2_set(size),
            "scale": "reference",
        })
        scales_list.append({
            "desc": f"nonsidon_seq_s{size}",
            "category": "nonsidon",
            "labels": nonsidon_consecutive_set(size),
            "scale": "reference",
        })
    
    # Map scale names to starting values for sympy.nextprime
    scale_starts = {
        "small": None,      # Use sieve (offset-based)
        "kilo": None,       # Use sieve (offset-based)
        "million": None,    # Use sieve (offset-based)
        "billion": 10**9,
        "trillion": 10**12,
        "quadrillion": 10**15,
        "quintillion": 10**18,
    }
    
    # Generate prime sets at each scale
    print("\n--- Generating prime sets at all scales ---")
    for scale_name, offset in scales.items():
        start_val = scale_starts[scale_name]
        
        if start_val is not None:
            # Large scale: use sympy.nextprime from starting value
            try:
                import sympy
                for size in sizes:
                    p = sympy.nextprime(start_val)
                    cluster = []
                    for _ in range(size):
                        cluster.append(p)
                        p = sympy.nextprime(p)
                    scales_list.append({
                        "desc": f"primes_{scale_name}_s{size}",
                        "category": "prime",
                        "labels": cluster,
                        "scale": scale_name,
                    })
            except ImportError:
                print(f"  sympy not available, skipping {scale_name} scale")
            continue
        
        for size in sizes:
            try:
                cluster = prime_clusters(size, offset)
                scales_list.append({
                    "desc": f"primes_{scale_name}_s{size}",
                    "category": "prime",
                    "labels": cluster[:size],
                    "scale": scale_name,
                })
            except Exception as e:
                print(f"  Error at {scale_name} size={size}: {e}")
    
    print(f"\n--- Computing eigenvalue products for {len(scales_list)} sets ---")
    t0 = time.time()
    
    for entry in scales_list:
        labels = entry["labels"]
        
        # Eigenvalue products (fast)
        S = build_sum_matrix(labels)
        U, eigenvalues = matrix_to_unitary(S)
        
        prods = {}
        for k in [2]:
            products = eigenvalue_products(eigenvalues, k)
            prods[k] = product_distribution(products)
        
        # Tensor network entropy (exact, N ≤ 8)
        tensor = {}
        for k in [1, 2, 3]:
            if len(labels) <= 8:
                ent, ratio = tensor_entropy(U, k)
                if ent is not None:
                    tensor[k] = {"entropy": round(ent, 4), "entropy_ratio": round(ratio, 4)}
        
        # SLOS (sampled, N ≤ 8, K=2 only)
        slos = {}
        if len(labels) <= 8:
            slos_result = run_slos(U, 2, 100000)
            if slos_result:
                slos[2] = slos_result
        
        result = {
            "desc": entry["desc"],
            "category": entry["category"],
            "scale": entry["scale"],
            "n_labels": len(labels),
            "labels": labels[:5] if len(labels) > 5 else labels,  # Truncate for readability
            "labels_full": labels,
            "eigenvalue_products": {str(k): v for k, v in prods.items()},
            "tensor_entropy": {str(k): v for k, v in tensor.items()},
            "slos": {str(k): v for k, v in slos.items()},
        }
        results.append(result)
        
        prod_r = prods[2]["distinct_ratio"]
        tensor_str = f" tensor_K2_ER={tensor[2]['entropy_ratio']:.3f}" if 2 in tensor else ""
        slos_str = f" slos_K2_ER={slos[2]['entropy_ratio']:.3f}" if 2 in slos else ""
        print(f"  {entry['desc']:30s} K=2 prod_ratio={prod_r:.4f}{tensor_str}{slos_str}")
    
    elapsed = time.time() - t0
    print(f"\n  Computed {len(scales_list)} sets in {elapsed:.1f}s")
    
    # ── Analysis: does the ordering hold? ─────────────────────────────────
    print("\n--- Analysis: ordering test ---")
    
    analysis = []
    for size in sizes:
        # Collect by category
        sidon_vals = [r for r in results if r["category"] == "sidon" and r["n_labels"] == size]
        nonsidon_vals = [r for r in results if r["category"] == "nonsidon" and r["n_labels"] == size]
        prime_vals = [r for r in results if r["category"] == "prime" and r["n_labels"] == size]
        
        for k in k_values:
            sk = str(k)
            for pv in prime_vals:
                prod_r = pv["eigenvalue_products"].get(sk, {}).get("distinct_ratio", None)
                tensor_er = pv.get("tensor_entropy", {}).get(sk, {}).get("entropy_ratio", None)
                
                if prod_r is None:
                    continue
                
                # Compare with Sidon and non-Sidon baselines
                s_prod = sidon_vals[0]["eigenvalue_products"][sk]["distinct_ratio"] if sidon_vals else None
                n_prod = nonsidon_vals[0]["eigenvalue_products"][sk]["distinct_ratio"] if nonsidon_vals else None
                
                # For product ratio: lower = more concentrated
                # Expected: Sidon < primes < non-Sidon
                if s_prod is not None and n_prod is not None:
                    if s_prod <= prod_r <= n_prod:
                        product_ordering = "CONFIRMED"
                    elif prod_r < s_prod:
                        product_ordering = "ABOVE_SIDON"
                    elif prod_r > n_prod:
                        product_ordering = "BELOW_NONSIDON"
                    else:
                        product_ordering = "UNORDERED"
                else:
                    product_ordering = "NO_BASELINE"
                
                # For tensor entropy ratio: lower = more concentrated
                # Expected: Sidon < primes < non-Sidon
                if tensor_er is not None and s_prod is not None and n_prod is not None:
                    s_tensor = sidon_vals[0].get("tensor_entropy", {}).get(sk, {}).get("entropy_ratio", None)
                    n_tensor = nonsidon_vals[0].get("tensor_entropy", {}).get(sk, {}).get("entropy_ratio", None)
                    if s_tensor is not None and n_tensor is not None:
                        if s_tensor <= tensor_er <= n_tensor:
                            tensor_ordering = "CONFIRMED"
                        elif tensor_er < s_tensor:
                            tensor_ordering = "ABOVE_SIDON"
                        elif tensor_er > n_tensor:
                            tensor_ordering = "BELOW_NONSIDON"
                        else:
                            tensor_ordering = "UNORDERED"
                    else:
                        tensor_ordering = "NO_BASELINE"
                else:
                    tensor_ordering = "NO_TENSOR"
                
                entry = {
                    "size": size,
                    "k": k,
                    "desc": pv["desc"],
                    "scale": pv["scale"],
                    "prod_ratio": prod_r,
                    "tensor_entropy_ratio": tensor_er,
                    "sidon_prod_ratio": s_prod,
                    "nonsidon_prod_ratio": n_prod,
                    "product_ordering": product_ordering,
                    "tensor_ordering": tensor_ordering,
                }
                analysis.append(entry)
                
                print(f"  {pv['desc']:35s} K={k} prod_r={prod_r:.4f} [{product_ordering}] "
                      f"tensor_ER={tensor_er} [{tensor_ordering}]")
    
    # ── Summary ───────────────────────────────────────────────────────────
    print("\n--- Summary ---")
    confirmed = sum(1 for a in analysis if a["product_ordering"] == "CONFIRMED")
    above = sum(1 for a in analysis if a["product_ordering"] == "ABOVE_SIDON")
    below = sum(1 for a in analysis if a["product_ordering"] == "BELOW_NONSIDON")
    total = len(analysis)
    print(f"  Product ordering Sidon < primes < non-Sidon:")
    print(f"    CONFIRMED: {confirmed}/{total}")
    print(f"    Above Sidon (even more concentrated): {above}/{total}")
    print(f"    Below non-Sidon (less concentrated): {below}/{total}")
    
    t_confirmed = sum(1 for a in analysis if a["tensor_ordering"] == "CONFIRMED")
    t_above = sum(1 for a in analysis if a["tensor_ordering"] == "ABOVE_SIDON")
    t_below = sum(1 for a in analysis if a["tensor_ordering"] == "BELOW_NONSIDON")
    t_total = sum(1 for a in analysis if a["tensor_ordering"] != "NO_TENSOR" and a["tensor_ordering"] != "NO_BASELINE")
    print(f"  Tensor entropy ordering Sidon < primes < non-Sidon:")
    print(f"    CONFIRMED: {t_confirmed}/{t_total}")
    print(f"    Above Sidon: {t_above}/{t_total}")
    print(f"    Below non-Sidon: {t_below}/{t_total}")
    
    # ── Save ──────────────────────────────────────────────────────────────
    output = {
        "schema": "prime_slos_explore_v1",
        "claim_boundary": "prime-slos-spectral-signature:ordering-test",
        "config": {"sizes": sizes, "k_values": k_values, "scales": list(scales.keys())},
        "results": results,
        "analysis": analysis,
        "summary": {
            "confirmed": confirmed,
            "above_sidon": above,
            "below_nonsidon": below,
            "total": total,
            "tensor_confirmed": t_confirmed,
            "tensor_total": t_total,
        },
    }
    ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
    with open(OUTPUT_PATH, "w") as f:
        json.dump(output, f, indent=2, default=str)
    print(f"\n  Saved to {OUTPUT_PATH}")
    print(f"  To run with SLOS (if Perceval available): needs perceval installed")
    print("=" * 60)

if __name__ == "__main__":
    explore()
