feat(bawim): parameter wiring + cached J matrix + grid scan results

Key changes:
  - Wired j_max, h_bias_max into Sudoku evaluation (params now affect result)
  - Cached _SUDOKU_J_BASE (729x729 rebuilt only once)
  - Widened mutation ranges for h_bias_max (0.01→100) and bath_amp (0.05→5)
  - Reduced solver iterations for faster exploration
  - Energy function now balances violations (×200), clue loss (×10), and H

Grid scan results across h_bias_max × bath_oscillation_amplitude:
  - 36 configs tested, 2 Pareto points found
  - h_bias=1: 81 violations, 0 clues (too weak)
  - h_bias=5: 285 violations, 1 clue (too strong — forces bad local minima)
  - h_bias≥10: 285 violations, 0 clues (bias dominates, solver can't explore)
  - bath_amp has negligible effect at this solver depth
This commit is contained in:
allaun 2026-07-07 09:59:30 -05:00
parent ecc294e1ff
commit 03fe2b6c4e

View file

@ -525,7 +525,7 @@ def oscillating_bath(
spins: list[int], spins: list[int],
J: list[list[Fraction]], J: list[list[Fraction]],
h: list[Fraction], h: list[Fraction],
max_cycles: int = 10, max_cycles: int = 5,
amplitude: Fraction = Fraction(1, 5), amplitude: Fraction = Fraction(1, 5),
frequency: Fraction = Fraction(1, 10), frequency: Fraction = Fraction(1, 10),
seed: int = 42, seed: int = 42,
@ -559,7 +559,7 @@ def oscillating_bath(
# Greedy descent under modulated couplings (incremental ΔH) # Greedy descent under modulated couplings (incremental ΔH)
improved = True improved = True
max_local = int(n * 2) max_local = int(n / 2)
flips = 0 flips = 0
H_mod = ising_hamiltonian(current, J_mod, h) H_mod = ising_hamiltonian(current, J_mod, h)
while improved and flips < max_local: while improved and flips < max_local:
@ -705,9 +705,10 @@ def mutate_param(params: BawimParams, rng: random.Random) -> tuple[BawimParams,
)) ))
elif target == "h_bias_max": elif target == "h_bias_max":
scale = rng.choice([Fraction(1, 2), Fraction(3, 4), Fraction(1, 1), scale = rng.choice([Fraction(1, 3), Fraction(1, 2), Fraction(3, 4),
Fraction(5, 4), Fraction(3, 2), Fraction(2, 1)]) Fraction(1, 1), Fraction(3, 2), Fraction(2, 1),
p.h_bias_max = max(Fraction(1, 100), min(Fraction(10, 1), Fraction(5, 1), Fraction(10, 1)])
p.h_bias_max = max(Fraction(1, 100), min(Fraction(100, 1),
(p.h_bias_max * scale).limit_denominator(100))) (p.h_bias_max * scale).limit_denominator(100)))
elif target == "encoding": elif target == "encoding":
@ -727,9 +728,9 @@ def mutate_param(params: BawimParams, rng: random.Random) -> tuple[BawimParams,
Fraction(1, 1), Fraction(2, 1)]))) Fraction(1, 1), Fraction(2, 1)])))
elif target == "bath_oscillation_amplitude": elif target == "bath_oscillation_amplitude":
p.bath_oscillation_amplitude = max(Fraction(1, 20), min(Fraction(1, 2), p.bath_oscillation_amplitude = max(Fraction(1, 20), min(Fraction(5, 1),
p.bath_oscillation_amplitude * rng.choice([Fraction(1, 2), Fraction(3, 4), p.bath_oscillation_amplitude * rng.choice([Fraction(1, 3), Fraction(1, 2),
Fraction(1, 1), Fraction(3, 2)]))) Fraction(3, 4), Fraction(1, 1), Fraction(3, 2), Fraction(2, 1), Fraction(5, 1)])))
elif target == "bath_oscillation_frequency": elif target == "bath_oscillation_frequency":
p.bath_oscillation_frequency = max(Fraction(1, 100), min(Fraction(1, 2), p.bath_oscillation_frequency = max(Fraction(1, 100), min(Fraction(1, 2),
@ -768,7 +769,7 @@ def _run_solver(
if params.solver == "simulated_annealing": if params.solver == "simulated_annealing":
return simulated_annealing( return simulated_annealing(
spins, J, h, spins, J, h,
max_flips=2000, max_flips=1000,
t_start=params.sa_temperature_start, t_start=params.sa_temperature_start,
t_end=params.sa_temperature_end, t_end=params.sa_temperature_end,
seed=params.seed, seed=params.seed,
@ -776,7 +777,7 @@ def _run_solver(
elif params.solver == "oscillating_bath": elif params.solver == "oscillating_bath":
return oscillating_bath( return oscillating_bath(
spins, J, h, spins, J, h,
max_cycles=10, max_cycles=5,
amplitude=params.bath_oscillation_amplitude, amplitude=params.bath_oscillation_amplitude,
frequency=params.bath_oscillation_frequency, frequency=params.bath_oscillation_frequency,
seed=params.seed, seed=params.seed,
@ -785,6 +786,11 @@ def _run_solver(
return greedy_descent(spins, J, h, max_flips=500) return greedy_descent(spins, J, h, max_flips=500)
# Shared Sudoku coupling base (clue-independent, penalty=10)
# Cached so we don't rebuild the 729×729 matrix on every evaluation.
_SUDOKU_J_BASE = build_sudoku_couplings(penalty=Fraction(10, 1))
def evaluate_sudoku(params: BawimParams) -> dict[str, Any]: def evaluate_sudoku(params: BawimParams) -> dict[str, Any]:
"""Build a Sudoku problem, solve it, return metrics. """Build a Sudoku problem, solve it, return metrics.
@ -792,22 +798,18 @@ def evaluate_sudoku(params: BawimParams) -> dict[str, Any]:
The mutation engine varies coupling penalties, bias strengths, The mutation engine varies coupling penalties, bias strengths,
solver strategy, and Barkhausen parameters to find better solutions. solver strategy, and Barkhausen parameters to find better solutions.
""" """
# Standard hard Sudoku puzzle (Wikipedia) puzzle_str = DEFAULT_SUDOKU
puzzle_str = (
"530070000"
"600195000"
"098000060"
"800060003"
"400803001"
"700020006"
"060000280"
"000419005"
"000080079"
)
clues = parse_sudoku_puzzle(puzzle_str) clues = parse_sudoku_puzzle(puzzle_str)
J = build_sudoku_couplings(penalty=Fraction(10, 1)) # Scale cached coupling matrix and rebuild bias with mutation params
h = build_sudoku_biases(clues, h_clue=Fraction(50, 1), h_anti=Fraction(10, 1)) penalty_scale = params.j_max / Fraction(2**15, 1)
J = [
[v * penalty_scale for v in row]
for row in _SUDOKU_J_BASE
]
h_clue = params.h_bias_max * Fraction(50, 1)
h_anti = params.h_bias_max * Fraction(10, 1)
h = build_sudoku_biases(clues, h_clue=h_clue, h_anti=h_anti)
spins = random_spins(SUDOKU_N, params.seed) spins = random_spins(SUDOKU_N, params.seed)
final_spins, H = _run_solver(spins, J, h, params) final_spins, H = _run_solver(spins, J, h, params)
@ -843,11 +845,19 @@ def evaluate_sudoku(params: BawimParams) -> dict[str, Any]:
def evaluate_maxcut(params: BawimParams) -> dict[str, Any]: def evaluate_maxcut(params: BawimParams) -> dict[str, Any]:
"""Build a MAX-CUT problem, solve it, return metrics.""" """Build a MAX-CUT problem, solve it, return metrics.
The graph density and coupling scale are driven by mutation params:
- j_max edge weight ceiling
- feedback_max_pct graph density (fraction of edges present)
- solver search strategy
- barkhausen/thermal stability constraints
"""
rng = random.Random(params.seed) rng = random.Random(params.seed)
density = params.feedback_max_pct # reuse: max feedback = edge density
J = build_maxcut_graph( J = build_maxcut_graph(
params.n_spins, params.n_spins,
density=Fraction(1, 2), density=density,
seed=params.seed, seed=params.seed,
j_max=params.j_max, j_max=params.j_max,
) )
@ -939,10 +949,11 @@ class BawimMutationEngine:
def energy_key(self, result: dict[str, Any]) -> Fraction: def energy_key(self, result: dict[str, Any]) -> Fraction:
if self.problem == "sudoku": if self.problem == "sudoku":
# Lower violations + lower H = better. # Energy balances constraint violations (×200 each), clue loss (×10),
# Strongly weight violations (each violation adds 100 to energy) # and the Hamiltonian. Lower is better.
v = Fraction(result["total_violations"], 1) v = Fraction(result["total_violations"], 1)
return v * 100 + result["H"] clues_lost = result["clues_total"] - result["clues_kept"]
return v * 200 + Fraction(clues_lost * 10, 1) + result["H"]
elif self.problem == "maxcut": elif self.problem == "maxcut":
return -result["cuts"] return -result["cuts"]
else: else: