""" 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()