spinor-lib / demos /api.py
SoulFireMage's picture
Claude Fable 5.1
Prepare demos for public hosting on a Hugging Face Docker Space
dee8943
Raw History Blame Contribute Delete
15.2 kB
"""JSON-friendly handlers that drive the three.js demos with ``spinor_lib``.
Every handler takes a dict of plain JSON values and returns a dict of plain
JSON values. Real tensors become nested lists; complex tensors become nested
lists of ``[re, im]`` pairs. The demos never re-implement any of the maths:
all rotations, boosts, Bloch vectors, partial traces and gradient steps are
computed here by the library and shipped to the browser as numbers.
"""
from __future__ import annotations
import math
import uuid
from collections import OrderedDict
from typing import Callable, Dict
import torch
from spinor_lib import (
QuaternionRotation,
Spinor,
bloch_vector,
concurrence,
embed_operator,
entanglement_entropy,
expectation,
fidelity,
from_bloch_vector,
generate_boost,
generate_su2_rotation,
inner_product,
kron,
pauli,
purity,
quaternion_to_su2,
rapidity_from_velocity,
reduced_density_matrix,
rotate,
sl2c_to_lorentz,
su2_to_quaternion,
su2_to_so3,
tensor_product,
weyl_current,
)
REAL = torch.float64
COMPLEX = torch.complex128
HANDLERS: Dict[str, Callable[[dict], dict]] = {}
def handler(name: str):
def register(fn):
HANDLERS[name] = fn
return fn
return register
# ------------------------------------------------------------ serialisation
def r2j(t: torch.Tensor):
"""Real tensor -> nested lists."""
return t.detach().cpu().tolist()
def c2j(t: torch.Tensor):
"""Complex tensor -> nested lists of [re, im]."""
return torch.stack([t.real, t.imag], dim=-1).detach().cpu().tolist()
def _int(p: dict, key: str, default: int, lo: int, hi: int) -> int:
"""An integer parameter clamped to [lo, hi], so a public server cannot be
asked for a million stars or a hundred-thousand-sample sweep."""
try:
v = int(p.get(key, default))
except (TypeError, ValueError):
v = default
return max(lo, min(hi, v))
def _vec3(v, default):
v = torch.as_tensor(v if v is not None else default, dtype=REAL)
if v.shape != (3,):
raise ValueError(f"expected a 3-vector, got shape {tuple(v.shape)}")
if float(v.norm()) < 1e-9:
raise ValueError("vector must be non-zero")
return v
def _state_from_params(p: dict) -> Spinor:
"""A single normalised spinor from either Bloch coordinates or components."""
if p.get("spinor") is not None:
comps = torch.tensor(p["spinor"], dtype=REAL) # [2, 2] as [re, im]
return Spinor(torch.complex(comps[:, 0], comps[:, 1])).normalize()
return from_bloch_vector(_vec3(p.get("bloch"), [0, 0, 1]), dtype=COMPLEX)
# ------------------------------------------- demo 1: Bloch sphere & double cover
@handler("rotation_sweep")
def rotation_sweep(p: dict) -> dict:
"""Rotate one state through angles 0..max_angle about an axis.
Returns everything the browser needs to scrub the angle without further
requests: the Bloch vector, the raw components, the overlap with the
starting state (which reaches -1 at 2π and +1 at 4π), the unit quaternion
and the SO(3) matrix of every sample.
"""
axis = _vec3(p.get("axis"), [0, 0, 1])
max_angle = float(p.get("max_angle", 4 * math.pi))
n = _int(p, "samples", 721, 2, 2001)
psi0 = _state_from_params(p)
angles = torch.linspace(0, max_angle, n, dtype=REAL)
R = generate_su2_rotation(axis, angles) # [n, 2, 2]
psi = rotate(psi0, R) # [n, 2]
return {
"angles": r2j(angles),
"axis": r2j(axis / axis.norm()),
"bloch": r2j(bloch_vector(psi)),
"components": c2j(psi.data),
"overlap": c2j(inner_product(psi0, psi)),
"quaternion": r2j(su2_to_quaternion(R)),
"so3": r2j(su2_to_so3(R)),
"matrix": c2j(R),
}
@handler("precession_sweep")
def precession_sweep(p: dict) -> dict:
"""Larmor precession of a cloud of random spins about a field axis.
The time evolution exp(-i B·σ t/2) of a spin-1/2 in a field is exactly an
SU(2) rotation about B by angle ∝ t, so a whole batch is one broadcast
call to ``generate_su2_rotation``.
"""
axis = _vec3(p.get("axis"), [0, 0, 1])
n_spins = _int(p, "n_spins", 150, 1, 500)
n = _int(p, "samples", 241, 2, 721)
max_angle = float(p.get("max_angle", 4 * math.pi))
g = torch.Generator().manual_seed(int(p.get("seed", 0)))
psi0 = Spinor(torch.randn(n_spins, 2, dtype=COMPLEX, generator=g)).normalize()
angles = torch.linspace(0, max_angle, n, dtype=REAL)
R = generate_su2_rotation(axis, angles)[:, None] # [n, 1, 2, 2]
psi = rotate(psi0, R) # [n, n_spins, 2]
b = bloch_vector(psi)
return {
"angles": r2j(angles),
"axis": r2j(axis / axis.norm()),
"bloch": r2j(b),
"mean_spin": r2j(b.mean(dim=1)),
}
# ------------------------------------------------------ demo 2: the belt trick
@handler("belt")
def belt(p: dict) -> dict:
"""A belt twisted by ``turns`` full turns, deformed by a homotopy parameter.
The belt's orientation along its length is a path R(s) in SO(3). Lifted to
SU(2) the 2π twist is a path from I to -I (an open arc) while the 4π twist
is a closed loop. Sliding the lifted path along great circles of S³
towards a fixed rotation gives a family of paths P_t(s); left-multiplying
by P_t(0)⁻¹ pins the near end of the belt to the identity. For 4π the far
end stays put for every t and the twist disappears; for 2π the far end is
forced to tumble, which is exactly why one twist cannot be undone.
``slerp_su2`` is deliberately *not* used: it takes the shortest arc in
SO(3) and so discards the sign of the SU(2) element, which here is the
whole point. The interpolation is done on unit quaternions instead,
through the library's ``su2_to_quaternion`` / ``quaternion_to_su2``.
"""
turns = float(p.get("turns", 2))
t = float(p.get("t", 0.0))
n = _int(p, "segments", 96, 4, 400)
s = torch.linspace(0, 1, n, dtype=REAL)
loop = generate_su2_rotation([0, 0, 1], 2 * math.pi * turns * s) # [n, 2, 2]
anchor = generate_su2_rotation([1, 0, 0], math.pi, dtype=COMPLEX) # contract towards this
q_loop, q_anchor = su2_to_quaternion(loop), su2_to_quaternion(anchor)
lam = t * math.pi / 2 # the two are orthogonal in R⁴, so this is an exact slerp
P = quaternion_to_su2(math.cos(lam) * q_loop + math.sin(lam) * q_anchor) # [n, 2, 2]
R = P[0].conj().mT @ P # pin the near end: R(0) = I
M = su2_to_so3(R) # [n, 3, 3]
# Centreline: integrate the belt's local "along" direction (its z axis).
tangent = M[:, :, 2] # M @ ẑ
length = float(p.get("length", 3.0))
centre = torch.cumsum(tangent, dim=0) * (length / n)
centre = centre - centre[0]
end_q = su2_to_quaternion(R[-1])
return {
"s": r2j(s),
"so3": r2j(M),
"centre": r2j(centre),
"quaternion": r2j(su2_to_quaternion(R)),
"lifted_path": r2j(su2_to_quaternion(P)), # the path in S³ before pinning
"end_so3": r2j(M[-1]),
"end_angle": float(2 * torch.arccos(end_q[0].abs().clamp(max=1))),
}
# --------------------------------------- demo 3: relativistic aberration
def _star_field(n: int, seed: int):
g = torch.Generator().manual_seed(seed)
d = torch.randn(n, 3, dtype=REAL, generator=g)
d = d / d.norm(dim=-1, keepdim=True)
mag = torch.rand(n, dtype=REAL, generator=g)
return d, mag
def _sky_grid(n_lat=5, n_lon=12, samples=73):
"""Latitude and longitude circles of the sky as polylines, shape [k, samples, 3]."""
u = torch.linspace(0, 2 * math.pi, samples, dtype=REAL)
lines = []
for i in range(1, n_lat + 1):
th = math.pi * i / (n_lat + 1)
lines.append(torch.stack([math.sin(th) * torch.cos(u), math.sin(th) * torch.sin(u),
torch.full_like(u, math.cos(th))], -1))
v = torch.linspace(0, math.pi, samples, dtype=REAL)
for j in range(n_lon):
ph = 2 * math.pi * j / n_lon
lines.append(torch.stack([torch.sin(v) * math.cos(ph), torch.sin(v) * math.sin(ph),
torch.cos(v)], -1))
return torch.stack(lines)
def _aberrate(directions: torch.Tensor, A: torch.Tensor):
"""Transform sky directions (unit vectors pointing *at* the sources).
A photon arriving from direction n̂ has null wave-vector k = (1, -n̂). The
right-handed spinor with Bloch vector -n̂ has Weyl current exactly k, so
boosting the spinor and reading its current back gives Λk, from which the
new direction and the Doppler factor follow.
"""
psi = from_bloch_vector(-directions, dtype=COMPLEX)
k = weyl_current(rotate(psi, A), "right") # [..., 4] = (D, -D n̂')
doppler = k[..., 0]
new_dir = -k[..., 1:] / doppler[..., None]
return new_dir, doppler
@handler("aberration")
def aberration(p: dict) -> dict:
"""The celestial sphere seen by an observer moving at speed β.
Boosts are elements of SL(2,C) acting on Weyl spinors, and SL(2,C) acts
on the sky as a Möbius transformation: circles stay circles, stars bunch
up ahead of you and blueshift there.
"""
beta = float(p.get("beta", 0.0))
beta = max(-0.999, min(0.999, beta))
direction = _vec3(p.get("direction"), [0, 0, 1])
n_stars = _int(p, "n_stars", 1500, 1, 5000)
seed = int(p.get("seed", 1))
eta = rapidity_from_velocity(torch.tensor(beta, dtype=REAL))
# Λ(η) carries rest -> velocity β·n, i.e. maps the moving frame's
# coordinates into ours; the observer's own view needs the inverse boost.
A = generate_boost(direction, -eta, handedness="right", dtype=COMPLEX)
stars, mag = _star_field(n_stars, seed)
new_dir, doppler = _aberrate(stars, A)
grid = _sky_grid()
grid_new, _ = _aberrate(grid, A)
return {
"beta": beta,
"gamma": float(torch.cosh(eta)),
"rapidity": float(eta),
"direction": r2j(direction / direction.norm()),
"stars": r2j(stars),
"magnitude": r2j(mag),
"aberrated": r2j(new_dir),
"doppler": r2j(doppler),
"grid": r2j(grid),
"grid_aberrated": r2j(grid_new),
"sl2c": c2j(A),
"lorentz": r2j(sl2c_to_lorentz(A, "right")),
}
# ---------------------------------------------- demo 4: two-qubit entanglement
CNOT = torch.tensor(
[[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0]], dtype=COMPLEX
)
@handler("two_qubit")
def two_qubit(p: dict) -> dict:
"""cos θ|00⟩ + sin θ|11⟩ followed by local rotations on each qubit.
The reduced density matrices come from a partial trace; their Bloch
vectors shrink inside the ball as the concurrence grows. Local rotations
move the arrows but cannot change concurrence, entropy or purity.
"""
theta = float(p.get("theta", 0.0))
up = Spinor(torch.tensor([1, 0], dtype=COMPLEX))
psi = tensor_product(up, up)
ry = generate_su2_rotation([0, 1, 0], 2 * theta, dtype=COMPLEX)
psi = rotate(psi, embed_operator(ry, (2, 2), target=0))
psi = rotate(psi, CNOT)
for target in (0, 1):
loc = p.get(f"local{target}") or {}
angle = float(loc.get("angle", 0.0))
if abs(angle) > 1e-12:
R = generate_su2_rotation(_vec3(loc.get("axis"), [0, 0, 1]), angle, dtype=COMPLEX)
psi = rotate(psi, embed_operator(R, (2, 2), target=target))
rho0 = reduced_density_matrix(psi, (2, 2), keep=0)
rho1 = reduced_density_matrix(psi, (2, 2), keep=1)
sig = pauli(dtype=COMPLEX)
corr = torch.stack(
[torch.stack([expectation(psi, kron(sig[i], sig[j])) for j in range(3)]) for i in range(3)]
)
return {
"amplitudes": c2j(psi.data),
"probabilities": r2j(psi.data.abs() ** 2),
"bloch0": r2j(bloch_vector(rho0)),
"bloch1": r2j(bloch_vector(rho1)),
"purity0": float(purity(rho0)),
"purity1": float(purity(rho1)),
"concurrence": float(concurrence(psi)),
"entropy_bits": float(entanglement_entropy(psi, (2, 2), keep=0, base=2)),
"correlation": r2j(corr),
"rho0": c2j(rho0),
}
# ------------------------------------------ demo 5: learning a rotation live
class _Session:
def __init__(self, p: dict):
seed = int(p.get("seed", 0))
g = torch.Generator().manual_seed(seed)
n = _int(p, "n_points", 64, 4, 512)
noise = float(p.get("noise", 0.02))
lr = float(p.get("lr", 0.05))
axis = torch.randn(3, generator=g)
angle = torch.rand(1, generator=g).item() * 1.6 * math.pi + 0.4 * math.pi
self.target = generate_su2_rotation(axis, torch.tensor(angle))
self.x = Spinor(torch.randn(n, 2, dtype=torch.complex64, generator=g)).normalize()
y = rotate(self.x, self.target)
y = Spinor(y.data + noise * torch.randn(y.data.shape, dtype=y.dtype, generator=g))
self.y = y.normalize()
self.model = QuaternionRotation()
self.opt = torch.optim.Adam(self.model.parameters(), lr=lr)
self.step = 0
with torch.no_grad():
self.loss = (1 - fidelity(self.model(self.x), self.y)).mean().item()
def train(self, steps: int):
for _ in range(steps):
self.opt.zero_grad()
loss = (1 - fidelity(self.model(self.x), self.y)).mean()
loss.backward()
self.opt.step()
self.step += 1
self.loss = loss.item()
def snapshot(self) -> dict:
with torch.no_grad():
R = self.model.matrix
pred = self.model(self.x)
err = (su2_to_so3(R) - su2_to_so3(self.target)).abs().max().item()
grad = self.model.quaternion.grad
return {
"step": self.step,
"loss": self.loss,
"quaternion": r2j(su2_to_quaternion(R)),
"so3": r2j(su2_to_so3(R)),
"predicted": r2j(bloch_vector(pred)),
"so3_error": err,
"grad_norm": float(grad.norm()) if grad is not None else 0.0,
}
_SESSIONS: "OrderedDict[str, _Session]" = OrderedDict()
_MAX_SESSIONS = 16
@handler("learn_reset")
def learn_reset(p: dict) -> dict:
"""Start a fresh ``QuaternionRotation`` fit against a hidden rotation."""
sid = uuid.uuid4().hex
sess = _Session(p)
_SESSIONS[sid] = sess
while len(_SESSIONS) > _MAX_SESSIONS:
_SESSIONS.popitem(last=False)
with torch.no_grad():
return {
"session": sid,
"inputs": r2j(bloch_vector(sess.x)),
"targets": r2j(bloch_vector(sess.y)),
"target_quaternion": r2j(su2_to_quaternion(sess.target)),
"target_so3": r2j(su2_to_so3(sess.target)),
**sess.snapshot(),
}
@handler("learn_step")
def learn_step(p: dict) -> dict:
"""Run a few optimiser steps and report the new state."""
sess = _SESSIONS.get(str(p.get("session", "")))
if sess is None:
raise KeyError("unknown or expired session; call learn_reset")
sess.train(_int(p, "steps", 1, 1, 200))
return sess.snapshot()