SilverSight/python/bawim_mutation_engine.py
allaun cdc7d24464 feat(python): BAWIM mutation engine — exact Fraction mutation loop
Sketch of a mutation engine for the Bulk Acoustic Wave Ising Machine
(arXiv 2607.02112). Architecture:

  §1  Mutable parameter budget (n_spins, J_bits, feedback %, encoding)
  §2  Core BAWIM equations as exact Fraction arithmetic (Eq 1-5)
  §3  Spin configuration (random + flip)
  §4  Coupling matrix generators (MAX-CUT, NPP)
  §5  Greedy descent solver (swap target)
  §6  Mutation operators (one param per round)
  §7  Evaluation (MAX-CUT, NPP)
  §8  Mutation engine loop with SA acceptance
  §9  CLI

No float in compute path.  Quantize to Q16_16 at output boundary only.
2026-07-07 09:35:44 -05:00

572 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
BAWIM Mutation Engine — explore the coupling/encoding design space
Maps the Bulk Acoustic Wave Ising Machine (arXiv 2607.02112) equations
onto the SilverSight Q16 fraction system and provides a mutation loop
that systematically varies parameters to improve solution quality.
Papers equations:
Eq 1 — Ising Hamiltonian H = -∑ J_ij s_i s_j - ∑ h_i s_i
Eq 2 — Feedback coupling c_i = ∑ J_ij s_j
Eq 3 — MAX-CUT score Cuts = -½∑ J_ij - ½H
Eq 4 — NPP (set form) E(A,B) = |∑A a_i - ∑B a_i|
Eq 5 — NPP (spin form) E(s) = |∑ a_i s_i|
Architecture:
Mutation round → vary one parameter → evaluate → accept/reject → next round
All arithmetic is exact Fraction (q16_fraction). Quantize only at output.
Usage:
python3 python/bawim_mutation_engine.py --rounds 100 --problem maxcut
python3 python/bawim_mutation_engine.py --rounds 100 --problem npp
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import random
import sys
import time
from dataclasses import dataclass, field
from fractions import Fraction
from itertools import combinations
from typing import Any, Callable, Optional
# ── Import Q16 fraction system (exact rational, no float in compute path) ──
sys.path.insert(0, "python")
from q16_fraction import Q16, q16_mul, q16_div, q16_add, q16_sub, to_raw, from_raw
from q16_fraction import from_float as q16_from_float
# ═══════════════════════════════════════════════════════════════════════════
# §1 PARAMETER BUDGET (mutable)
# ═══════════════════════════════════════════════════════════════════════════
# BAWIM hardware constraints (Eq.2 context: 15-bit J_ij, 5-30% amplitude)
BAWIM_DEFAULTS = {
"n_spins": 20, # N
"j_resolution_bits": 15, # J_ij bit depth (paper: 15-bit)
"j_max": Fraction(2**15, 1), # max coupling value
"feedback_min_pct": Fraction(5, 100), # 5% of RF carrier
"feedback_max_pct": Fraction(30, 100), # 30% of RF carrier
"h_bias_max": Fraction(1, 10), # max local bias
"thermal_stability": Fraction(780, 1), # BAWIM deg/°C (paper: 780)
"encoding": "one_hot", # one_hot | binary | hybrid
}
@dataclass
class BawimParams:
"""Mutable parameter set for the BAWIM system.
Each field can be mutated independently. The mutation engine
varies one field per round and evaluates the impact.
"""
n_spins: int = BAWIM_DEFAULTS["n_spins"]
j_resolution_bits: int = BAWIM_DEFAULTS["j_resolution_bits"]
j_max: Fraction = BAWIM_DEFAULTS["j_max"]
feedback_min_pct: Fraction = BAWIM_DEFAULTS["feedback_min_pct"]
feedback_max_pct: Fraction = BAWIM_DEFAULTS["feedback_max_pct"]
h_bias_max: Fraction = BAWIM_DEFAULTS["h_bias_max"]
thermal_stability: Fraction = BAWIM_DEFAULTS["thermal_stability"]
encoding: str = BAWIM_DEFAULTS["encoding"]
seed: int = 42
def clone(self) -> BawimParams:
return BawimParams(
n_spins=self.n_spins,
j_resolution_bits=self.j_resolution_bits,
j_max=self.j_max,
feedback_min_pct=self.feedback_min_pct,
feedback_max_pct=self.feedback_max_pct,
h_bias_max=self.h_bias_max,
thermal_stability=self.thermal_stability,
encoding=self.encoding,
seed=self.seed,
)
def fingerprint(self) -> str:
"""Deterministic hash for reproducibility tracking."""
raw = json.dumps({
"n_spins": self.n_spins,
"j_res_bits": self.j_resolution_bits,
"j_max": str(self.j_max),
"fb_min": str(self.feedback_min_pct),
"fb_max": str(self.feedback_max_pct),
"h_max": str(self.h_bias_max),
"thermal": str(self.thermal_stability),
"encoding": self.encoding,
"seed": self.seed,
}, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode()).hexdigest()[:16]
# ═══════════════════════════════════════════════════════════════════════════
# §2 CORE BAWIM EQUATIONS (exact Fraction arithmetic)
# ═══════════════════════════════════════════════════════════════════════════
def ising_hamiltonian(
spins: list[int],
J: list[list[Fraction]],
h: list[Fraction],
) -> Fraction:
"""Eq 1: H = -∑ J_ij s_i s_j - ∑ h_i s_i
All arithmetic in exact Fraction. No float, no truncation.
"""
H = Fraction(0, 1)
n = len(spins)
for i in range(n):
for j in range(i + 1, n):
H -= J[i][j] * spins[i] * spins[j]
for i in range(n):
H -= h[i] * spins[i]
return H
def feedback_coupling(
spins: list[int],
J: list[list[Fraction]],
i: int,
) -> Fraction:
"""Eq 2: c_i = ∑ J_ij s_j
The coupling pulse injected back into spin i.
Amplitude should stay within feedback_min_pctfeedback_max_pct
of the circulating RF signal to avoid chaotization.
"""
return sum(J[i][j] * spins[j] for j in range(len(spins)))
def maxcut_score(
spins: list[int],
J: list[list[Fraction]],
H: Optional[Fraction] = None,
) -> Fraction:
"""Eq 3: Cuts = -½∑ J_ij - ½H
Lower Hamiltonian → more cuts.
"""
if H is None:
H = ising_hamiltonian(spins, J, [Fraction(0, 1)] * len(spins))
sum_J = sum(J[i][j] for i in range(len(spins)) for j in range(i + 1, len(spins)))
return -sum_J / 2 - H / 2
def npp_spin_form(
spins: list[int],
values: list[Fraction],
) -> Fraction:
"""Eq 5: E(s) = |∑ a_i s_i|
s_i = +1 → subset A; s_i = -1 → subset B.
Perfect partition when E = 0 (total even) or E = 1 (total odd).
"""
total = sum(values[i] * spins[i] for i in range(len(spins)))
return abs(total)
# ═══════════════════════════════════════════════════════════════════════════
# §3 SPIN CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════════
def random_spins(n: int, seed: int) -> list[int]:
rng = random.Random(seed)
return [1 if rng.random() < 0.5 else -1 for _ in range(n)]
def flip_spin(spins: list[int], idx: int) -> list[int]:
s = list(spins)
s[idx] = -s[idx]
return s
# ═══════════════════════════════════════════════════════════════════════════
# §4 COUPLING MATRIX GENERATORS (mutation targets)
# ═══════════════════════════════════════════════════════════════════════════
def build_maxcut_graph(
n: int,
density: Fraction,
seed: int,
j_max: Fraction,
) -> list[list[Fraction]]:
"""Generate a MAX-CUT graph coupling matrix.
density is the fraction of edges present.
Each edge gets a random coupling in [0, j_max].
"""
rng = random.Random(seed)
J = [[Fraction(0, 1) for _ in range(n)] for _ in range(n)]
threshold = float(density)
for i in range(n):
for j in range(i + 1, n):
if rng.random() < threshold:
val = rng.randint(1, int(j_max))
J[i][j] = Fraction(val, 1)
J[j][i] = J[i][j]
return J
def build_npp_matrix(
values: list[Fraction],
) -> tuple[list[list[Fraction]], list[Fraction]]:
"""Build coupling matrix for number partitioning.
J_ij = a_i * a_j (Mattis spin glass form)
h = 0
"""
n = len(values)
J = [[Fraction(0, 1) for _ in range(n)] for _ in range(n)]
for i in range(n):
for j in range(i + 1, n):
val = values[i] * values[j]
J[i][j] = val
J[j][i] = val
h = [Fraction(0, 1)] * n
return J, h
# ═══════════════════════════════════════════════════════════════════════════
# §5 SOLVER (greedy descent — mutation target)
# ═══════════════════════════════════════════════════════════════════════════
def greedy_descent(
spins: list[int],
J: list[list[Fraction]],
h: list[Fraction],
max_flips: int = 100,
) -> tuple[list[int], Fraction]:
"""Greedy energy minimization by single-spin flips.
Each flip that lowers H is accepted. Stops when no flip improves.
— MUTATION TARGET: replace with simulated annealing, QAOA, etc.
"""
current = list(spins)
H = ising_hamiltonian(current, J, h)
improved = True
n = len(current)
while improved and max_flips > 0:
improved = False
for i in range(n):
candidate = flip_spin(current, i)
H_candidate = ising_hamiltonian(candidate, J, h)
if H_candidate < H:
current = candidate
H = H_candidate
improved = True
max_flips -= 1
break
return current, H
# ═══════════════════════════════════════════════════════════════════════════
# §6 MUTATION OPERATORS
# ═══════════════════════════════════════════════════════════════════════════
@dataclass
class Mutation:
"""A single mutation: which parameter changed, from what to what."""
target: str # parameter name
old_value: Any
new_value: Any
delta_energy: Optional[Fraction] = None # improvement (negative = better)
MUTATION_TARGETS = [
# n_spins is excluded: changing N changes the graph, making
# cross-round energy comparisons meaningless.
"j_resolution_bits",
"j_max",
"feedback_min_pct",
"feedback_max_pct",
"h_bias_max",
"encoding",
"solver", # swap solver strategy
]
def mutate_param(params: BawimParams, rng: random.Random) -> tuple[BawimParams, Mutation]:
"""Apply one random mutation to a parameter.
Returns (new_params, description_of_mutation).
"""
p = params.clone()
target = rng.choice(MUTATION_TARGETS)
old_val = getattr(p, target, None)
if target == "n_spins":
delta = rng.choice([-5, -3, -1, 1, 3, 5])
new = max(4, min(200, p.n_spins + delta))
p.n_spins = new
elif target == "j_resolution_bits":
delta = rng.choice([-2, -1, 1, 2])
new = max(4, min(20, p.j_resolution_bits + delta))
p.j_resolution_bits = new
p.j_max = Fraction(2**new, 1)
elif target == "j_max":
scale = rng.choice([Fraction(1, 2), Fraction(3, 4), Fraction(1, 1),
Fraction(5, 4), Fraction(3, 2), Fraction(2, 1)])
p.j_max = (p.j_max * scale).limit_denominator(2**p.j_resolution_bits)
elif target == "feedback_min_pct":
step = Fraction(1, 100)
p.feedback_min_pct = max(Fraction(1, 100), min(
Fraction(49, 100),
p.feedback_min_pct + rng.choice([-step, step]) * rng.randint(1, 5)
))
elif target == "feedback_max_pct":
step = Fraction(1, 100)
p.feedback_max_pct = max(p.feedback_min_pct + step, min(
Fraction(95, 100),
p.feedback_max_pct + rng.choice([-step, step]) * rng.randint(1, 5)
))
elif target == "h_bias_max":
scale = rng.choice([Fraction(1, 2), Fraction(3, 4), Fraction(1, 1),
Fraction(3, 2), Fraction(2, 1)])
p.h_bias_max = max(Fraction(1, 100), min(Fraction(10, 1),
(p.h_bias_max * scale).limit_denominator(100)))
elif target == "encoding":
p.encoding = rng.choice(["one_hot", "binary", "hybrid"])
elif target == "solver":
# Future: swap between greedy_descent, simulated_annealing, QAOA
pass
new_val = getattr(p, target, None)
return p, Mutation(target=target, old_value=old_val, new_value=new_val)
# ═══════════════════════════════════════════════════════════════════════════
# §7 EVALUATION
# ═══════════════════════════════════════════════════════════════════════════
def evaluate_maxcut(params: BawimParams) -> dict[str, Any]:
"""Build a MAX-CUT problem, solve it, return metrics."""
rng = random.Random(params.seed)
J = build_maxcut_graph(
params.n_spins,
density=Fraction(1, 2),
seed=params.seed,
j_max=params.j_max,
)
h = [Fraction(0, 1)] * params.n_spins
spins = random_spins(params.n_spins, params.seed)
final_spins, H = greedy_descent(spins, J, h)
cuts = maxcut_score(final_spins, J, H)
return {
"problem": "maxcut",
"n": params.n_spins,
"H": H,
"cuts": cuts,
"cuts_float": float(cuts),
"spins": final_spins,
}
def evaluate_npp(params: BawimParams) -> dict[str, Any]:
"""Build a number partitioning problem, solve it, return metrics."""
rng = random.Random(params.seed)
values = [Fraction(rng.randint(1, 1000), 1) for _ in range(params.n_spins)]
J, h = build_npp_matrix(values)
spins = random_spins(params.n_spins, params.seed + 1)
final_spins, H = greedy_descent(spins, J, h)
E = npp_spin_form(final_spins, values)
return {
"problem": "npp",
"n": params.n_spins,
"E": E,
"E_float": float(E),
"H": H,
"spins": final_spins,
}
# ═══════════════════════════════════════════════════════════════════════════
# §8 MUTATION ENGINE
# ═══════════════════════════════════════════════════════════════════════════
@dataclass
class MutationRound:
"""Record of one mutation + evaluation."""
round: int
params: BawimParams
mutation: Mutation
result: dict[str, Any]
accepted: bool
class BawimMutationEngine:
"""Run mutation rounds to improve BAWIM solution quality.
Each round:
1. Clone current params
2. Mutate one parameter
3. Evaluate on the problem
4. Accept if energy improved (or with probability for exploration)
5. Record the round
"""
def __init__(
self,
problem: str = "maxcut",
initial_params: Optional[BawimParams] = None,
temperature: Fraction = Fraction(1, 10),
):
self.problem = problem
self.params = initial_params or BawimParams()
self.temperature = temperature
self.history: list[MutationRound] = []
self.best_result: Optional[dict[str, Any]] = None
self.best_energy: Optional[Fraction] = None
self.rng = random.Random(self.params.seed)
def evaluate(self, params: BawimParams) -> dict[str, Any]:
if self.problem == "maxcut":
return evaluate_maxcut(params)
elif self.problem == "npp":
return evaluate_npp(params)
else:
raise ValueError(f"Unknown problem: {self.problem}")
def energy_key(self, result: dict[str, Any]) -> Fraction:
if self.problem == "maxcut":
return -result["cuts"] # more cuts = better (lower energy)
else:
return result["E"] # lower E = better
def step(self) -> MutationRound:
new_params, mutation = mutate_param(self.params, self.rng)
result = self.evaluate(new_params)
new_energy = self.energy_key(result)
accepted = False
if self.best_energy is None:
accepted = True
elif new_energy < self.best_energy:
accepted = True # strict improvement
else:
# Probabilistic acceptance (simulated annealing style)
delta = new_energy - self.best_energy
if self.temperature > 0:
# Convert Fraction to float for exp (I/O boundary — display only)
p = math.exp(-float(delta) / float(self.temperature))
accepted = self.rng.random() < p
mutation.delta_energy = (
new_energy - self.best_energy if self.best_energy is not None else Fraction(0, 1)
)
if accepted:
self.params = new_params
self.best_result = result
self.best_energy = new_energy
record = MutationRound(
round=len(self.history),
params=new_params,
mutation=mutation,
result=result,
accepted=accepted,
)
self.history.append(record)
return record
def run(self, rounds: int, verbose: bool = True) -> list[MutationRound]:
for r in range(rounds):
record = self.step()
if verbose and (r < 10 or r % 10 == 0 or record.accepted):
delta = record.mutation.delta_energy
sign = "" if record.accepted else ""
energy = float(self.energy_key(record.result))
print(
f" [{r:4d}] {sign} "
f"{record.mutation.target:20s} "
f"Δ={float(delta):+.6f} "
f"E={energy:.6f}"
)
return self.history
def summary(self) -> dict[str, Any]:
best = self.best_result or {}
accepted = sum(1 for h in self.history if h.accepted)
return {
"problem": self.problem,
"rounds": len(self.history),
"accepted": accepted,
"accept_rate": accepted / max(1, len(self.history)),
"best_energy": float(self.best_energy) if self.best_energy else None,
"best_params": {
"n_spins": self.params.n_spins,
"j_resolution_bits": self.params.j_resolution_bits,
"j_max": str(self.params.j_max),
"feedback_range": f"{self.params.feedback_min_pct}{self.params.feedback_max_pct}",
"encoding": self.params.encoding,
},
"best_result": {
k: v for k, v in best.items() if k != "spins"
} if best else {},
"fingerprint": self.params.fingerprint(),
}
# ═══════════════════════════════════════════════════════════════════════════
# §9 CLI
# ═══════════════════════════════════════════════════════════════════════════
def main() -> int:
parser = argparse.ArgumentParser(
description="BAWIM Mutation Engine — explore coupling/encoding design space"
)
parser.add_argument("--rounds", type=int, default=50, help="mutation rounds")
parser.add_argument("--problem", choices=["maxcut", "npp"], default="maxcut")
parser.add_argument("--n-spins", type=int, default=20)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--temperature", type=float, default=0.1,
help="SA acceptance temperature (0 = greedy)")
parser.add_argument("--json", action="store_true", help="output final summary as JSON")
args = parser.parse_args()
params = BawimParams(n_spins=args.n_spins, seed=args.seed)
engine = BawimMutationEngine(
problem=args.problem,
initial_params=params,
temperature=Fraction(args.temperature).limit_denominator(100),
)
print(f"BAWIM Mutation Engine — {args.problem.upper()}")
print(f" Initial params: n={params.n_spins}, J_bits={params.j_resolution_bits}")
print(f" Temperature: {args.temperature}")
print(f" Running {args.rounds} rounds...\n")
start = time.time()
engine.run(args.rounds)
elapsed = time.time() - start
summary = engine.summary()
print(f"\n--- Summary ({elapsed:.1f}s) ---")
print(f" Problem: {summary['problem']}")
print(f" Rounds: {summary['rounds']} ({summary['accepted']} accepted)")
print(f" Accept rate: {summary['accept_rate']:.2f}")
if summary['best_energy'] is not None:
print(f" Best energy: {summary['best_energy']:.6f}")
print(f" Best params: {summary['best_params']}")
print(f" Fingerprint: {summary['fingerprint']}")
if args.json:
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())