#!/usr/bin/env python3
"""
PROVENANCE: PROOF

Computation 169, the atom criterion for non-balance, and the step that does not close it
=========================================================================================
sec:open-structural carries one CONDITION rather than a derivation: the elementary
directions must not balance, |sum_a v(a)| > 0.  comp 127 showed it is independent of
magnitude positivity; comp 152 replaced a circular measure-zero defence with an
open-condition statement and then found the obstacle, that the articulated symmetry
package does NOT force it, exhibiting an invariant law that balances 31% of the time at
|D| = 6.  comp 152 called the residue "a coordination premise, weaker than
independence".

THIS COMPUTATION SHARPENS THAT RESIDUE.  IT DOES NOT CLOSE IT.

An earlier version of this file claimed to close it, and the book removed non-balance
from its named-premise register on that claim.  The closing step was wrong; it is
withdrawn here, the refutation is part 3, and the register entry is restored.  What
remains is worth having, and is stated at its true strength below.

THE CHAIN, AND WHERE IT STOPS
------------------------------
  (i)   Balance requires v(a) = -sum_{b != a} v(b) for each a: ONE point, determined by
        the others.  So

            P(balance) = E[ P( v(a) = c(rest) | rest ) ],   c = the cancelling value,

        an identity rather than an estimate.  SOUND, and measured in part 1.
  (ii)  If the conditional law of v(a) given the others is atomless for even ONE a in D,
        the inner probability is a.s. zero and so is P(balance).  SOUND.
  (iii) So balance needs EVERY property's conditional law to carry an atom, sitting
        exactly on the cancelling value.  SOUND, and this is the sharpening: balance
        needs TOTAL coordination, and one unpinned property anywhere destroys it.
        Measured in part 2, as a codimension.
  (iv)  WITHDRAWN.  The earlier version argued: an atom is a distinguished direction, a
        law on S^6 charging a single point names that point, naming a point costs six
        parameters under G_2, and P1 supplies none, therefore non-balance.

WHY (iv) FAILS, AND THE COUNTEREXAMPLE IS ALREADY IN THE BOOK
--------------------------------------------------------------
Steps (i)-(iii) deliver a CONDITIONAL atom, whose location c(rest) depends on the other
properties.  Step (iv) prices an UNCONDITIONAL atom sitting at a FIXED point of S^6.
Those are different objects.  A conditional atom is a CORRELATION between properties; it
names no direction, and it is perfectly compatible with a fully invariant joint law.

comp 152's own mu_pm is the counterexample, and part 3 measures it: v(a) = eps_a u with
one shared u drawn UNIFORMLY on S^6 and i.i.d. signs.  The law is SO(7)-invariant and
names no direction; every property's conditional law given the others is a PURE atom, so
it carries the maximal possible atomic mass; the atom sits on the cancelling value
whenever the other signs sum to +-1; and it pays ZERO parameters, because u is random
rather than named.  It balances 31% of the time.

So a law with maximal conditional atoms and no parameter cost balances, and step (iv)'s
pricing does not apply to the object steps (i)-(iii) deliver.

WHAT THIS LEAVES
-----------------
Balance requires TOTAL coordination between the properties: every one of them pinned by
the rest to an exact value with positive weight.  That is a much sharper statement of
comp 152's residue than "a coordination premise", and part 2 shows how sharp: five
maximally coordinated properties cannot rescue balance from ONE that is not.

But PST does not forbid total coordination.  mu_pm exhibits it while satisfying every
symmetry the theory has articulated.  So NON-BALANCE REMAINS A CONDITION, adopted and
not derived, and the book says so.

WHAT WOULD ACTUALLY CLOSE IT: a reason the substrate cannot carry a shared hidden
variable of mu_pm's kind, i.e. an argument about correlation rather than about
direction.  That is a different argument from comp 168's and this computation does not
have it.

A "pass": P(balance) tracks a planted conditional atom's mass; one unpinned property
turns 31% into a codimension-6 scaling with the exponent read from an exact law;
mu_pm satisfies (i)-(iii) at zero parameter cost and balances at C(6,3)/2^6; and the
quantised variant balances at 1/14 for |D| = 2 while the continuum does not balance.
"""

import itertools
import math

import numpy as np


def rule(ch="="):
    print(ch * 100)


def unit(rng, n):
    v = rng.normal(size=(n, 7))
    return v / np.linalg.norm(v, axis=1, keepdims=True)


# ---------------------------------------------------------------------------------
def part1(rng):
    rule()
    print("  PART 1.  The identity: P(balance) = E[conditional mass on the cancelling value]")
    rule()
    print("""
      Five properties are drawn, and the sixth has a conditional law putting mass p
      EXACTLY on the cancelling value c(rest) and spreading 1 - p elsewhere.  Balance
      then happens precisely when the atom is hit, so the identity predicts P = p with
      no free constant.

      NOTE ON THE MODEL: the cancelling value c(rest) is a point of R^7 and generally
      not of unit norm, so this part works in R^7 rather than on S^6.  The identity and
      step (ii) are insensitive to that, since both are statements about a conditional
      law charging a single point, whatever the ambient set.  An earlier draft placed
      the atom at c/|c| instead, which is a DIFFERENT point and cannot cancel; that
      version measured P = 0 at every p, correctly.
""")
    n, T = 6, 40000
    print(f"      {'planted p':>12} {'measured P(balance)':>22} {'|difference|':>15}")
    worst = 0.0
    for p in (0.0, 0.1, 0.25, 0.5, 0.75, 1.0):
        hits = 0
        for _ in range(T):
            rest = unit(rng, n - 1)
            c = -rest.sum(0)
            v = np.vstack([rest, c]) if rng.random() < p else np.vstack([rest, unit(rng, 1)])
            if np.linalg.norm(v.sum(0)) < 1e-9:
                hits += 1
        meas = hits / T
        worst = max(worst, abs(meas - p))
        print(f"      {p:>12.2f} {meas:>22.4f} {abs(meas - p):>15.4f}")
    ok = worst < 0.02
    print(f"\n      P(balance) tracks the planted atom mass, max error {worst:.4f}   : {ok}")
    print("      At p = 0 that is step (ii): an atomless conditional gives null balance.")
    return ok


def part2(rng):
    rule()
    print("  PART 2.  One unpinned property destroys balance: the sharpening that stands")
    rule()
    print("""
      Take comp 152's mu_pm at |D| = 6, which balances 31% of the time, and leave ONE
      property unpinned while the other five stay maximally coordinated.  Balance does
      not become rare, it becomes null, and the right way to see that is as a
      codimension.

      THE EXPONENT IS 6, NOT 7.  The free property is a UNIT direction moving on S^6
      rather than in R^7, and the coordinated five are all parallel, which is exactly
      comp 152's all-parallel stratum where it measured codimension 6 independently.
""")
    n, T = 6, 300000
    eps_signs = rng.integers(0, 2, size=(T, n)) * 2 - 1
    p_full = float((eps_signs.sum(1) == 0).mean())
    pred = math.comb(n, n // 2) / 2 ** n
    print(f"      mu_pm as comp 152 has it       P(balance) = {p_full:.4f}   "
          f"(C(6,3)/2^6 = {pred:.4f})")
    u = rng.normal(size=7); u /= np.linalg.norm(u)
    s5 = (rng.integers(0, 2, size=(T, n - 1)) * 2 - 1).sum(1)
    tot = s5[:, None] * u[None, :] + unit(rng, T)
    exact = int((np.linalg.norm(tot, axis=1) < 1e-12).sum())
    print(f"      one property unpinned          exact hits at 0 = {exact}  of {T}")

    def exact_P(e):
        """Cancellation-free.  The naive form subtracts two nearly equal numbers and
        loses every digit below eps ~ 3e-3, returning a negative probability."""
        h = e * e / 2.0
        return (4.0 * h ** 3 / 3.0 - h ** 4 + h ** 5 / 5.0) / (16.0 / 15.0)

    M = 4000000
    # Counted a block at a time.  Four million unit vectors in R^7 is 224 MB in one
    # array and another 224 MB once u is added, and the browser runtime this book
    # publishes to cannot be relied on for that; a 100k block is 5.6 MB.  Splitting the
    # draw does not change the stream, and a count of hits does not care how the rows
    # are grouped, so every number below is the number it was.
    EPS_LEVELS = (0.80, 0.55, 0.38, 0.26)
    near_hits = {e: 0 for e in EPS_LEVELS}
    for _i in range(0, M, 100_000):
        nb = np.linalg.norm(u[None, :] + unit(rng, min(100_000, M - _i)), axis=1)
        for _e in EPS_LEVELS:
            near_hits[_e] += int((nb < _e).sum())
    print(f"\n      {'eps':>9} {'measured':>13} {'hits':>9} {'exact':>13} {'meas/exact':>12} {'tol':>9}")
    agree = True
    for e in EPS_LEVELS:
        c = near_hits[e]; f = c / M; ex = exact_P(e)
        # Tolerance derived from the hit count, not a magic number: 5 Poisson sigma.
        # A flat 0.12 was used before and failed for about one seed in ten, because at
        # eps = 0.26 there are ~190 hits and 0.12 is only 1.65 sigma.
        tol = 5.0 / math.sqrt(max(c, 1))
        agree &= abs(f / ex - 1.0) < tol
        print(f"      {e:>9.4f} {f:>13.7f} {c:>9d} {ex:>13.7f} {f / ex:>12.3f} {tol:>9.3f}")
    print(f"      Monte Carlo matches the exact law within 5 Poisson sigma   : {agree}")
    prev, slope = None, float("nan")
    print(f"\n      the exponent, read from the exact law where it is asymptotic:")
    for e in (1e-2, 3e-3, 1e-3, 3e-4):
        ex = exact_P(e)
        if prev:
            slope = (math.log(ex) - math.log(prev[1])) / (math.log(e) - math.log(prev[0]))
            print(f"      {e:>12.5f} {ex:>16.3e} {slope:>14.4f}")
        prev = (e, ex)
    ok = abs(p_full - pred) < 0.01 and exact == 0 and agree and abs(slope - 6.0) < 0.01
    print(f"\n      31% becomes a clean codimension-6 scaling                   : {ok}")
    print("      Five coordinated properties cannot rescue balance from one that is not.")
    return ok


def part3(rng):
    rule()
    print("  PART 3.  THE REFUTATION: mu_pm satisfies (i)-(iii) and pays nothing")
    rule()
    print("""
      This is the part that withdraws the earlier version's step (iv).  mu_pm sets
      v(a) = eps_a u with ONE shared u drawn uniformly on S^6 and i.i.d. signs.  If
      step (iv) were right, this law would have to pay six parameters to place its
      conditional atoms.  It pays none, and it balances.
""")
    n, T = 6, 400000
    u = unit(rng, T)
    eps = rng.integers(0, 2, size=(T, n)) * 2 - 1
    # The checks below read only v(1) and the sum of the other five, so the full
    # (T, n, 7) array is never needed: it is 134 MB at T = 400000, and another 134 MB
    # once rotated, which the browser runtime this book publishes to cannot be relied
    # on for.  v(1) is formed directly and the cancelling sum a block of rows at a
    # time; both are the same arithmetic on the same draws.
    V0 = eps[:, 0, None] * u                      # v(1) = eps_1 u, the slice used below

    p_bal = float((np.abs(eps.sum(1)) == 0).mean())
    mean_dir = float(np.abs(V0.mean(0)).max())
    R, _ = np.linalg.qr(rng.normal(size=(7, 7)))
    cov_shift = float(np.abs(np.cov(V0.T) - np.cov((V0 @ R.T).T)).max())
    cancel = np.empty_like(V0)
    for _i in range(0, T, 50_000):
        _j = min(_i + 50_000, T)
        cancel[_i:_j] = -(eps[_i:_j, 1:, None] * u[_i:_j, None, :]).sum(1)
    on_atom = float(np.isclose(np.linalg.norm(V0 - cancel, axis=1), 0, atol=1e-9).mean())

    print(f"      (a) it BALANCES                       P = {p_bal:.4f}   "
          f"(C(6,3)/2^6 = {math.comb(6,3)/64:.4f})")
    print(f"      (b) it names NO direction             max |E[v(1)]| = {mean_dir:.5f}")
    print(f"          and is rotation invariant         cov shift     = {cov_shift:.5f}")
    print(f"      (c) its conditional law is a PURE atom, supported on {{+u, -u}}")
    print(f"          and the atom sits on the cancelling value  P = {on_atom:.4f}")
    print(f"          which equals P(balance)                      = {p_bal:.4f}")
    print(f"      (d) it pays ZERO parameters: u is drawn uniformly, not named")
    print("""
      So a law with MAXIMAL conditional atoms, naming no direction and costing nothing,
      balances 31% of the time.  Step (iv) priced an unconditional atom at a fixed
      point; steps (i)-(iii) deliver a conditional atom at a random one.  The pricing
      does not apply, and non-balance is not closed by this route.
""")
    ok = (abs(p_bal - math.comb(6, 3) / 64) < 0.01 and mean_dir < 0.01
          and cov_shift < 0.01 and abs(on_atom - p_bal) < 1e-9)
    print(f"      mu_pm satisfies (i)-(iii) at zero cost and balances         : {ok}")
    return ok


def part4(rng):
    rule()
    print("  PART 4.  Where balance IS possible, and mu_pm is not that case")
    rule()
    print("""
      The earlier version claimed comp 152's counterexample and its quantised-directions
      caveat were one phenomenon, both confining the directions to a distinguished
      finite set.  That is true of the quantised variant and FALSE of mu_pm, whose two
      directions are a uniformly random antipodal pair.  Corrected here.
""")
    axes = [(i, s) for i in range(7) for s in (1, -1)]
    print(f"      {'model':<34} {'|D|':>4} {'P(balance)':>13}  directions live on")
    quant = {}
    for n in (2, 4):
        tot = bal = 0
        for combo in itertools.product(range(14), repeat=n):
            v = np.zeros(7)
            for c in combo:
                i, sg = axes[c]; v[i] += sg
            tot += 1; bal += int(not v.any())
        quant[n] = bal / tot
        print(f"      {'quantised to +-e_1..+-e_7':<34} {n:>4} {bal/tot:>13.6f}"
              f"  14 FIXED axes")
    T = 200000
    mu = {}
    for n in (2, 4):
        e = rng.integers(0, 2, size=(T, n)) * 2 - 1
        mu[n] = float((e.sum(1) == 0).mean())
        print(f"      {'mu_pm (comp 152 part 3)':<34} {n:>4} {mu[n]:>13.6f}"
              f"  a RANDOM antipodal pair")
    cont = {}
    for n in (2, 4):
        V = unit(rng, T * n).reshape(T, n, 7)
        cont[n] = int((np.linalg.norm(V.sum(1), axis=1) < 1e-12).sum()) / T
        print(f"      {'P1 continuum, independent':<34} {n:>4} {cont[n]:>13.6f}"
              f"  all of S^6")
    print("""
      Two different mechanisms, not one.  The quantised variant balances because the
      directions are confined to fixed axes, which is a different theory.  mu_pm
      balances because the properties are CORRELATED, with no confinement and no fixed
      axes at all.  It is the second that step (iv) failed to price.
""")
    ok = (abs(quant[2] - 1/14) < 1e-9 and abs(mu[2] - 0.5) < 0.01
          and cont[2] == 0.0 and cont[4] == 0.0)
    print(f"      quantised 1/14, mu_pm 1/2, continuum 0, at |D| = 2          : {ok}")
    return ok


def main():
    rng = np.random.default_rng(20260928)
    print()
    rule()
    print("  COMPUTATION 169.  The atom criterion, and the step that does not close it")
    rule()
    print("""
  An earlier version of this computation claimed to close the non-balance condition, and
  the book removed it from the named-premise register on that claim.  The closing step
  was wrong.  It is withdrawn, the refutation is part 3, and what survives is stated at
  its true strength: balance needs TOTAL coordination, and the theory does not forbid it.
""")
    r1, r2, r3, r4 = part1(rng), part2(rng), part3(rng), part4(rng)
    print()
    rule()
    print("  ASSESSMENT")
    rule()
    for lbl, r in [("1. P(balance) equals the planted conditional atom mass", r1),
                   ("2. one unpinned property gives codimension-6 scaling", r2),
                   ("3. mu_pm satisfies (i)-(iii) at zero cost and balances", r3),
                   ("4. the quantised and correlated cases are different", r4)]:
        print(f"  {lbl:<58}: {r}")
    allok = all([r1, r2, r3, r4])
    print()
    if allok:
        print("""  RESULT: NON-BALANCE REMAINS A CONDITION.  Steps (i)-(iii) hold and sharpen comp
  152's residue considerably: balance requires EVERY property to be pinned by the rest,
  so one unpinned property anywhere destroys it, measured as a codimension-6 scaling
  that five maximally coordinated neighbours cannot rescue.  But the step that would
  have turned that into a derivation is withdrawn: it priced an unconditional atom at a
  fixed direction, while what the chain delivers is a conditional atom at a random one,
  and comp 152's mu_pm exhibits exactly that at zero parameter cost while balancing 31%
  of the time.

  WHAT WOULD CLOSE IT is an argument about CORRELATION rather than about direction: a
  reason the substrate cannot carry a shared hidden variable of mu_pm's kind.  comp
  168's selector argument is not that argument, and this computation does not have it.""")
    else:
        print("  RESULT: at least one check failed; see above.")
    print()
    return allok


if __name__ == "__main__":
    main()
