feat(ModularBot): 6 guns, pattern matcher, melee modules, adversarial bots

- New guns: guess-factor (GF histogram), pattern-matcher (movement tape replay)
- New modules: minimum-risk melee movement, spinning melee radar
- New test bots: PatternMover, RandomMover, WaveSurfer
- Fixed: FeedbackEvent now carries actualX/actualY for proper GF learning
- Fixed: TM gun warmup gating + directional residuals
- Fixed: circular gun integrated formula + multi-bin omega cache
- Fixed: oscillator wall-bounce lockout
- Fixed: phantom meteor perpendicular body orientation
- 6/6 battle wins across all enemy types
This commit is contained in:
2026-09-20 00:59:53 +02:00
parent 254c7dc997
commit 1ed7797cb6
184 changed files with 80149 additions and 21 deletions
@@ -0,0 +1,652 @@
"""
Alternative learning methods backtest — binary input, online learning, no gradients.
Stdlib only: csv, math, random, collections.
Baselines: linear extrapolation MAE≈12.24, WiSARD K=12 MAE≈9.93
"""
import csv
import math
import random
import os
import collections
CSV_PATH = os.path.join(os.path.dirname(__file__), "../data/target_battle_1_decimal.csv")
OUT_PATH = os.path.join(os.path.dirname(__file__), "alternatives_results.txt")
POWER_LEVELS = [0.10, 0.42, 0.74, 1.07, 1.39, 1.71, 2.03, 2.36, 2.68, 3.00]
POWER_STRS = ["p0.10","p0.42","p0.74","p1.07","p1.39","p1.71","p2.03","p2.36","p2.68","p3.00"]
MAX_DIST = 1414.0
HIT_THRESH = 18.0 # px
REP_POWER = 1.07
REP_PS = "p1.07"
# ── bit encoding (matches backtest_wisard.py) ─────────────────────────────────
def _to_gray(v):
return v ^ (v >> 1)
def int_to_bits(value, nbits):
g = _to_gray(int(value))
return [(g >> (nbits - 1 - i)) & 1 for i in range(nbits)]
FIELD_DEFS = [
("bearing_sin", 8), ("bearing_cos", 8), ("distance", 7), ("velocity", 5),
("heading_sin", 8), ("heading_cos", 8), ("enemy_x", 7), ("enemy_y", 7),
("enemy_energy", 11),
]
BITS_PER_FRAME = sum(b for _, b in FIELD_DEFS) # 69
def encode_frame(row, prefix):
bits = []
for fname, nbits in FIELD_DEFS:
bits += int_to_bits(row[prefix + fname], nbits)
return bits
def build_input(row):
bits = []
for i in range(4):
bits += encode_frame(row, f"f{i}_")
return bits # 276 bits
# ── geometry helpers ──────────────────────────────────────────────────────────
def bullet_speed(power): return 20.0 - 3.0 * power
def flight_ticks(dist_enc, power): return dist_enc / 99.0 * MAX_DIST / bullet_speed(power)
def euclid(ax, ay, bx, by): return math.sqrt((ax - bx)**2 + (ay - by)**2)
def predict_linear(row, t):
x0, y0 = row["f0_enemy_x"], row["f0_enemy_y"]
x1, y1 = row["f1_enemy_x"], row["f1_enemy_y"]
return x0 + (x0 - x1) * t, y0 + (y0 - y1) * t
def stats(errors, label=None):
if not errors: return {}
n = len(errors)
mae = sum(errors) / n
rmse = math.sqrt(sum(e*e for e in errors) / n)
med = sorted(errors)[n // 2]
hit = sum(1 for e in errors if e <= HIT_THRESH) / n * 100
return {"mae": mae, "rmse": rmse, "median": med, "hit": hit, "n": n}
# ── CSV loader ────────────────────────────────────────────────────────────────
def load_csv():
with open(CSV_PATH) as f:
rows = []
for row in csv.DictReader(f):
try:
rows.append({k: float(v) for k, v in row.items()})
except ValueError:
pass
return rows
# ── Generic online backtest harness ──────────────────────────────────────────
def run_method(rows, predict_fn, learn_fn):
"""
predict_fn(bits) -> (cx, cy) correction on top of linear
learn_fn(bits, rx, ry) update on true residual
Returns: (all_errors_by_ps, first50_p107, last50_p107)
"""
errors = {ps: [] for ps in POWER_STRS}
first50, last50 = [], []
n = len(rows)
for i, row in enumerate(rows):
bits = build_input(row)
cx, cy = predict_fn(bits)
for ps, power in zip(POWER_STRS, POWER_LEVELS):
t = flight_ticks(row["f0_distance"], power)
lx, ly = predict_linear(row, t)
e = euclid(lx + cx, ly + cy, row[f"{ps}_enemy_x"], row[f"{ps}_enemy_y"])
errors[ps].append(e)
if ps == REP_PS:
if i < 50: first50.append(e)
if i >= n - 50: last50.append(e)
# learn residual at representative power
t_r = flight_ticks(row["f0_distance"], REP_POWER)
lx_r, ly_r = predict_linear(row, t_r)
cx2, cy2 = predict_fn(bits) # use same prediction (already called above)
rx = row[f"{REP_PS}_enemy_x"] - (lx_r + cx)
ry = row[f"{REP_PS}_enemy_y"] - (ly_r + cy)
learn_fn(bits, rx, ry)
return errors, first50, last50
# ════════════════════════════════════════════════════════════════════════════════
# 1. Echo State Network (Reservoir Computing)
# Fixed sparse random reservoir, delta-rule readout only.
# ════════════════════════════════════════════════════════════════════════════════
class EchoStateNet:
def __init__(self, n_in=276, n_res=512, sparsity=0.1, seed=7):
rng = random.Random(seed)
self.n_res = n_res
# sparse binary reservoir weights: ±1 with prob sparsity, else 0
self.W_res = [[0.0]*n_res for _ in range(n_res)]
for i in range(n_res):
for j in range(n_res):
if rng.random() < sparsity:
self.W_res[i][j] = 1.0 if rng.random() < 0.5 else -1.0
# scale spectral radius to ~0.9 (approx: divide by expected density*n)
scale = sparsity * n_res * 0.9
if scale > 0:
for i in range(n_res):
for j in range(n_res):
self.W_res[i][j] /= scale
# input projection: random binary ±1
self.W_in = []
for i in range(n_res):
col = rng.randint(0, n_in - 1) # random input index
sign = 1 if rng.random() < 0.5 else -1
self.W_in.append((col, sign))
# reservoir state
self.state = [0.0] * n_res
# readout weights (x and y)
self.w_x = [0.0] * n_res
self.w_y = [0.0] * n_res
self.lr = 0.01
def _step(self, bits):
new_state = [0.0] * self.n_res
for i in range(self.n_res):
col, sign = self.W_in[i]
s = sign * bits[col]
for j in range(self.n_res):
s += self.W_res[i][j] * self.state[j]
new_state[i] = math.tanh(s)
self.state = new_state
def predict(self, bits):
self._step(bits)
cx = sum(self.w_x[i] * self.state[i] for i in range(self.n_res))
cy = sum(self.w_y[i] * self.state[i] for i in range(self.n_res))
return cx, cy
def learn(self, bits, rx, ry):
# delta rule on readout (reservoir already stepped in predict)
for i in range(self.n_res):
self.w_x[i] += self.lr * rx * self.state[i]
self.w_y[i] += self.lr * ry * self.state[i]
# ════════════════════════════════════════════════════════════════════════════════
# 2. Kanerva Sparse Distributed Memory (SDM)
# Hard addresses = random 276-bit patterns. Read/write by Hamming proximity.
# ════════════════════════════════════════════════════════════════════════════════
class KanervaSDM:
def __init__(self, n_addr=1000, n_bits=276, radius=90, seed=13):
rng = random.Random(seed)
self.n_bits = n_bits
self.radius = radius
# random hard addresses as integers (stored as bit lists for fast Hamming)
self.hard_addr = []
for _ in range(n_addr):
addr = [rng.randint(0, 1) for _ in range(n_bits)]
self.hard_addr.append(addr)
# storage cells: sum_x, sum_y, count
self.cells = [[0.0, 0.0, 0] for _ in range(n_addr)]
def _hamming(self, bits, addr):
return sum(b != a for b, a in zip(bits, addr))
def _near(self, bits):
return [i for i, addr in enumerate(self.hard_addr)
if self._hamming(bits, addr) <= self.radius]
def predict(self, bits):
near = self._near(bits)
hits = [(self.cells[i][0], self.cells[i][1], self.cells[i][2])
for i in near if self.cells[i][2] > 0]
if not hits:
return 0.0, 0.0
cx = sum(sx/c for sx, sy, c in hits) / len(hits)
cy = sum(sy/c for sx, sy, c in hits) / len(hits)
return cx, cy
def learn(self, bits, rx, ry):
for i in self._near(bits):
self.cells[i][0] += rx
self.cells[i][1] += ry
self.cells[i][2] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 3. N-gram Markov on discretized patterns
# Hash 4 chunks of bits → pattern ID, build pattern→correction table.
# ════════════════════════════════════════════════════════════════════════════════
class NGramMarkov:
def __init__(self, chunk_size=69, n_chunks=4):
self.chunk_size = chunk_size
self.n_chunks = n_chunks
# table: pattern_key -> [sum_x, sum_y, count]
self.table = {}
def _key(self, bits):
chunks = []
for c in range(self.n_chunks):
start = c * self.chunk_size
end = min(start + self.chunk_size, len(bits))
val = 0
for b in bits[start:end]:
val = (val << 1) | b
chunks.append(val)
return tuple(chunks)
def predict(self, bits):
key = self._key(bits)
e = self.table.get(key)
if e is None or e[2] == 0:
return 0.0, 0.0
return e[0] / e[2], e[1] / e[2]
def learn(self, bits, rx, ry):
key = self._key(bits)
if key not in self.table:
self.table[key] = [0.0, 0.0, 0]
self.table[key][0] += rx
self.table[key][1] += ry
self.table[key][2] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 4. Bloom Filter Predictor
# Multiple hash functions → slots, store/average corrections.
# ════════════════════════════════════════════════════════════════════════════════
class BloomPredictor:
def __init__(self, n_slots=4096, n_hashes=8, seed=99):
rng = random.Random(seed)
self.n_slots = n_slots
self.n_hashes = n_hashes
# random hash seeds
self.seeds = [rng.randint(1, 2**31) for _ in range(n_hashes)]
self.slots_x = [0.0] * n_slots
self.slots_y = [0.0] * n_slots
self.counts = [0] * n_slots
def _hash(self, bits, seed):
h = seed
for i, b in enumerate(bits):
if b:
h ^= (i * 2654435761 + seed) & 0xFFFFFFFF
return h % self.n_slots
def _addrs(self, bits):
return [self._hash(bits, s) for s in self.seeds]
def predict(self, bits):
addrs = self._addrs(bits)
hits = [(self.slots_x[a], self.slots_y[a], self.counts[a])
for a in addrs if self.counts[a] > 0]
if not hits:
return 0.0, 0.0
cx = sum(sx/c for sx, sy, c in hits) / len(hits)
cy = sum(sy/c for sx, sy, c in hits) / len(hits)
return cx, cy
def learn(self, bits, rx, ry):
for a in self._addrs(bits):
self.slots_x[a] += rx
self.slots_y[a] += ry
self.counts[a] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 5. Hyperdimensional Computing (HDC)
# Random projection to 10000-dim binary HV, bundled class prototypes.
# Discretize correction into 8 angle classes × 4 magnitude classes = 32 classes.
# ════════════════════════════════════════════════════════════════════════════════
class HDC:
def __init__(self, n_in=276, n_hd=10000, n_classes=32, seed=17):
rng = random.Random(seed)
self.n_hd = n_hd
self.n_classes = n_classes
# random projection matrix: for each HD dim, pick a random input bit index
self.proj = [rng.randint(0, n_in - 1) for _ in range(n_hd)]
# random XOR masks per input position to break locality
self.masks = [rng.getrandbits(n_hd) for _ in range(n_in)]
# class prototypes: sum of HVs (as int for fast popcount via XOR)
self.proto_sum = [0] * (n_hd * n_classes) # flattened int sums
self.proto_count = [0] * n_classes
# store accumulated corrections per class
self.class_cx = [0.0] * n_classes
self.class_cy = [0.0] * n_classes
def _encode(self, bits):
# Build HD vector as integer (1 bit per position)
# Use random projection: hv[i] = bits[proj[i]] XOR random noise
hv = 0
for i, idx in enumerate(self.proj):
if bits[idx]:
hv |= (1 << i)
return hv
def _hamming_hv(self, hv_a, hv_b):
# Hamming distance between two n_hd-bit integers
return bin(hv_a ^ hv_b).count('1')
def _discretize(self, rx, ry):
# 8 angle classes × 4 magnitude classes
angle = math.atan2(ry, rx) # -pi..pi
angle_cls = int((angle + math.pi) / (2 * math.pi) * 8) % 8
mag = math.sqrt(rx*rx + ry*ry)
mag_cls = min(int(mag / 20), 3) # 0-19, 20-39, 40-59, 60+
return angle_cls * 4 + mag_cls
def predict(self, bits):
hv = self._encode(bits)
best_cls = -1
best_dist = self.n_hd + 1
for c in range(self.n_classes):
if self.proto_count[c] == 0:
continue
# proto is stored as sum; threshold at count/2 to binarize
count = self.proto_count[c]
# approx distance: count bits where sum > count/2 differs from hv
# ponytail: O(n_hd) loop; acceptable for 10k bits and ~100 samples
dist = 0
base = c * self.n_hd
for i in range(self.n_hd):
proto_bit = 1 if self.proto_sum[base + i] > count / 2 else 0
hv_bit = (hv >> i) & 1
if proto_bit != hv_bit:
dist += 1
if dist < best_dist:
best_dist = dist
best_cls = c
if best_cls < 0 or self.proto_count[best_cls] == 0:
return 0.0, 0.0
c = self.proto_count[best_cls]
return self.class_cx[best_cls] / c, self.class_cy[best_cls] / c
def learn(self, bits, rx, ry):
hv = self._encode(bits)
cls = self._discretize(rx, ry)
self.proto_count[cls] += 1
self.class_cx[cls] += rx
self.class_cy[cls] += ry
base = cls * self.n_hd
for i in range(self.n_hd):
self.proto_sum[base + i] += (hv >> i) & 1
# ════════════════════════════════════════════════════════════════════════════════
# 6. Random Subspace Ensemble
# Multiple WiSARD-lite predictors, each seeing random 50-bit subset.
# ════════════════════════════════════════════════════════════════════════════════
class RandomSubspaceEnsemble:
def __init__(self, n_estimators=20, subset_size=50, k=5, n_bits=276, seed=31):
rng = random.Random(seed)
self.estimators = []
for _ in range(n_estimators):
indices = random.sample(range(n_bits), subset_size)
tables = {} # addr -> [sum_x, sum_y, count]
self.estimators.append((indices, tables, k))
def _addresses(self, bits, indices, k):
addrs = []
for start in range(0, len(indices), k):
chunk = indices[start:start+k]
addr = 0
for idx in chunk:
addr = (addr << 1) | bits[idx]
addrs.append((start // k, addr))
return addrs
def predict(self, bits):
cx_sum = cy_sum = weight = 0.0
for indices, tables, k in self.estimators:
addrs = self._addresses(bits, indices, k)
est_cx = est_cy = 0.0
hit = 0
for node, addr in addrs:
key = (node, addr)
e = tables.get(key)
if e and e[2] > 0:
est_cx += e[0] / e[2]
est_cy += e[1] / e[2]
hit += 1
if hit > 0:
cx_sum += est_cx / hit
cy_sum += est_cy / hit
weight += 1
if weight == 0:
return 0.0, 0.0
return cx_sum / weight, cy_sum / weight
def learn(self, bits, rx, ry):
for indices, tables, k in self.estimators:
for node, addr in self._addresses(bits, indices, k):
key = (node, addr)
if key not in tables:
tables[key] = [0.0, 0.0, 0]
tables[key][0] += rx
tables[key][1] += ry
tables[key][2] += 1
# ════════════════════════════════════════════════════════════════════════════════
# 7. WiSARD + Eligibility Traces (temporal credit assignment)
# WiSARD stores eligibility traces per LUT entry.
# At learn time, also update recently-visited entries with decayed credit.
# ════════════════════════════════════════════════════════════════════════════════
class WiSARDEligibility:
"""WiSARD with eligibility trace — past addresses get partial credit."""
def __init__(self, n_bits=276, k=12, trace_len=5, decay=0.7, seed=42):
self.k = k
n_pad = math.ceil(n_bits / k) * k
self.n_nodes = n_pad // k
rng = random.Random(seed)
indices = list(range(n_bits)) + [0] * (n_pad - n_bits)
self.perm = indices[:]
rng.shuffle(self.perm)
self.tables = [{} for _ in range(self.n_nodes)]
self.trace_len = trace_len
self.decay = decay
# history: deque of (addrs_list, weight)
self.history = collections.deque()
def _addresses(self, bits):
addrs = []
for node in range(self.n_nodes):
addr = 0
for bit_idx in range(self.k):
perm_idx = node * self.k + bit_idx
b = bits[self.perm[perm_idx]] if perm_idx < len(bits) else 0
addr = (addr << 1) | b
addrs.append(addr)
return addrs
def predict(self, bits):
addrs = self._addresses(bits)
cx_sum = cy_sum = 0.0
count = 0
for node, addr in enumerate(addrs):
e = self.tables[node].get(addr)
if e and e[2] > 0:
cx_sum += e[0] / e[2]
cy_sum += e[1] / e[2]
count += 1
return (cx_sum / count, cy_sum / count) if count else (0.0, 0.0)
def learn(self, bits, rx, ry):
addrs = self._addresses(bits)
# current step with full credit
self.history.appendleft((addrs, 1.0))
if len(self.history) > self.trace_len:
self.history.pop()
# update all traces
for past_addrs, weight in self.history:
for node, addr in enumerate(past_addrs):
e = self.tables[node].get(addr)
if e is None:
self.tables[node][addr] = [rx * weight, ry * weight, 1]
else:
e[0] += rx * weight
e[1] += ry * weight
e[2] += 1
# decay weights for next round
self.history = collections.deque(
[(a, w * self.decay) for a, w in self.history]
)
# ════════════════════════════════════════════════════════════════════════════════
# Main
# ════════════════════════════════════════════════════════════════════════════════
def fmt_row(name, s_all, s_first, s_last, p1_mae):
mae = s_all.get("mae", float("nan"))
hit = s_all.get("hit", float("nan"))
f50 = s_first.get("mae", float("nan")) if s_first else float("nan")
l50 = s_last.get("mae", float("nan")) if s_last else float("nan")
delta = mae - p1_mae
return (f"{name:<34} {mae:>7.2f} {hit:>7.1f}% {f50:>8.2f} {l50:>8.2f} {delta:>+8.2f}")
def run_and_report(rows, name, predict_fn, learn_fn):
print(f" running {name}...")
errors, first50, last50 = run_method(rows, predict_fn, learn_fn)
s_all = stats(errors[REP_PS])
s_f = stats(first50)
s_l = stats(last50)
return errors, s_all, s_f, s_l
def main():
rows = load_csv()
lines = []
w = lines.append
w("=" * 80)
w("ALTERNATIVE LEARNING METHODS BACKTEST")
w(f"Dataset: {len(rows)} rows | Rep power: {REP_PS} | Hit threshold: {HIT_THRESH}px")
w("Baselines: linear MAE≈12.24 | WiSARD K=12 MAE≈9.93")
w("=" * 80)
# ── baseline: pure linear ──────────────────────────────────────────────────
lin_errs = []
for row in rows:
t = flight_ticks(row["f0_distance"], REP_POWER)
lx, ly = predict_linear(row, t)
lin_errs.append(euclid(lx, ly, row[f"{REP_PS}_enemy_x"], row[f"{REP_PS}_enemy_y"]))
p1_mae = stats(lin_errs)["mae"]
p1_hit = stats(lin_errs)["hit"]
p1_f50 = stats(lin_errs[:50])["mae"]
p1_l50 = stats(lin_errs[-50:])["mae"]
w(f"\n{'Method':<34} {'MAE':>7} {'Hit%':>8} {'F50 MAE':>8} {'L50 MAE':>8} {'vs Lin':>8}")
w("-" * 80)
w(f"{'Linear (baseline)':<34} {p1_mae:>7.2f} {p1_hit:>7.1f}% {p1_f50:>8.2f} {p1_l50:>8.2f} {'---':>8}")
w(f"{'WiSARD K=12 (reference)':<34} {'9.93':>7} {'~55%':>8} {'---':>8} {'---':>8} {'-2.31':>8}")
results = []
# ── 1. Echo State Network ─────────────────────────────────────────────────
esn = EchoStateNet(n_in=276, n_res=512, sparsity=0.05, seed=7)
_last_state = [None]
def esn_predict(bits):
cx, cy = esn.predict(bits)
return cx, cy
def esn_learn(bits, rx, ry):
esn.learn(bits, rx, ry)
e, sa, sf, sl = run_and_report(rows, "1. Echo State Network", esn_predict, esn_learn)
results.append(("1. Echo State Net (res=512)", sa, sf, sl))
w(fmt_row("1. Echo State Net (res=512)", sa, sf, sl, p1_mae))
# ── 2. Kanerva SDM ───────────────────────────────────────────────────────
sdm = KanervaSDM(n_addr=2000, n_bits=276, radius=100, seed=13)
e, sa, sf, sl = run_and_report(rows, "2. Kanerva SDM", sdm.predict, sdm.learn)
results.append(("2. Kanerva SDM (addr=2000)", sa, sf, sl))
w(fmt_row("2. Kanerva SDM (addr=2000)", sa, sf, sl, p1_mae))
# ── 3. N-gram Markov ─────────────────────────────────────────────────────
ng = NGramMarkov(chunk_size=69, n_chunks=4)
e, sa, sf, sl = run_and_report(rows, "3. N-gram Markov", ng.predict, ng.learn)
results.append(("3. N-gram Markov (4×69 chunks)", sa, sf, sl))
w(fmt_row("3. N-gram Markov (4×69 chunks)", sa, sf, sl, p1_mae))
# ── 4. Bloom Filter Predictor ─────────────────────────────────────────────
bloom = BloomPredictor(n_slots=8192, n_hashes=12, seed=99)
e, sa, sf, sl = run_and_report(rows, "4. Bloom Filter", bloom.predict, bloom.learn)
results.append(("4. Bloom Filter (8192 slots)", sa, sf, sl))
w(fmt_row("4. Bloom Filter (8192 slots)", sa, sf, sl, p1_mae))
# ── 5. HDC ───────────────────────────────────────────────────────────────
print(" running 5. HDC (slow, n_hd=2000)...")
hdc = HDC(n_in=276, n_hd=2000, n_classes=32, seed=17)
hdc_errors = {ps: [] for ps in POWER_STRS}
hdc_first50, hdc_last50 = [], []
n = len(rows)
for i, row in enumerate(rows):
bits = build_input(row)
cx, cy = hdc.predict(bits)
for ps, power in zip(POWER_STRS, POWER_LEVELS):
t = flight_ticks(row["f0_distance"], power)
lx, ly = predict_linear(row, t)
e_val = euclid(lx + cx, ly + cy, row[f"{ps}_enemy_x"], row[f"{ps}_enemy_y"])
hdc_errors[ps].append(e_val)
if ps == REP_PS:
if i < 50: hdc_first50.append(e_val)
if i >= n - 50: hdc_last50.append(e_val)
t_r = flight_ticks(row["f0_distance"], REP_POWER)
lx_r, ly_r = predict_linear(row, t_r)
rx = row[f"{REP_PS}_enemy_x"] - (lx_r + cx)
ry = row[f"{REP_PS}_enemy_y"] - (ly_r + cy)
hdc.learn(bits, rx, ry)
sa = stats(hdc_errors[REP_PS])
sf = stats(hdc_first50)
sl = stats(hdc_last50)
results.append(("5. HDC (n_hd=2000, 32 cls)", sa, sf, sl))
w(fmt_row("5. HDC (n_hd=2000, 32 cls)", sa, sf, sl, p1_mae))
# ── 6. Random Subspace Ensemble ───────────────────────────────────────────
rse = RandomSubspaceEnsemble(n_estimators=30, subset_size=50, k=5, n_bits=276, seed=31)
e, sa, sf, sl = run_and_report(rows, "6. Random Subspace Ensemble", rse.predict, rse.learn)
results.append(("6. RandSubspace (30×50bits)", sa, sf, sl))
w(fmt_row("6. RandSubspace (30×50bits)", sa, sf, sl, p1_mae))
# ── 7. WiSARD + Eligibility Traces ───────────────────────────────────────
wet = WiSARDEligibility(n_bits=276, k=12, trace_len=5, decay=0.7, seed=42)
e, sa, sf, sl = run_and_report(rows, "7. WiSARD+Eligibility", wet.predict, wet.learn)
results.append(("7. WiSARD+Elig (k=12,tr=5)", sa, sf, sl))
w(fmt_row("7. WiSARD+Elig (k=12,tr=5)", sa, sf, sl, p1_mae))
# ── Summary ───────────────────────────────────────────────────────────────
w("\n" + "=" * 80)
w("RANKING by MAE (lower is better, rep power p1.07)")
w("=" * 80)
ranked = sorted(results, key=lambda x: x[1].get("mae", 9999))
w(f"\n{'Rank':<5} {'Method':<34} {'MAE':>7} {'Hit%':>8} {'F50':>8} {'L50':>8}")
w("-" * 70)
for rank, (name, sa, sf, sl) in enumerate(ranked, 1):
mae = sa.get("mae", float("nan"))
hit = sa.get("hit", float("nan"))
f50 = sf.get("mae", float("nan")) if sf else float("nan")
l50 = sl.get("mae", float("nan")) if sl else float("nan")
w(f"{rank:<5} {name:<34} {mae:>7.2f} {hit:>7.1f}% {f50:>8.2f} {l50:>8.2f}")
w(f"\n Linear baseline: MAE={p1_mae:.2f} Hit={p1_hit:.1f}%")
w(f" WiSARD K=12 ref: MAE=9.93 Hit=~55%")
w("\nNotes:")
w(" F50/L50 = MAE on first/last 50 samples (learning speed proxy).")
w(" Hit% = fraction within 18px (Robocode bullet half-width).")
w(" All methods: online, binary input, no gradients, no supervised labels.")
w(" Learning signal: residual correction after linear extrapolation at p1.07.")
report = "\n".join(lines)
print("\n" + report)
with open(OUT_PATH, "w") as f:
f.write(report + "\n")
print(f"\n[saved to {OUT_PATH}]")
if __name__ == "__main__":
main()