"""ONLY SURPRISE BENDS THE LINE — 驚きだけが線を曲げる One stroke. A diamond stylus on a stainless plate. The tool never lifts and nothing can be taken back. The line is not a record of thinking. It is the thinking. At every step the machine predicts what the metal ahead will be like, then finds out. Where the prediction holds it goes straight. Where the metal contradicts it, it turns — and the size of the turn is the size of the error. So the drawing is a map of where the machine was wrong. Straight passages are the places it understood. The dense writhing knots are the places it did not. And because a scratch cannot be removed, every error it makes becomes part of the surface that its later self has to read. It has to keep thinking inside the consequences of having been wrong. The predictor learns online, from its own trace, with no target but the metal. Nobody supplies a goal. There is no fitness. There is only a surface, a prediction, and the difference between them. """ from __future__ import annotations import argparse import json import math import os # --- the plate ------------------------------------------------------------ PLATE_W, PLATE_H = 420.0, 297.0 # mm, A3 stainless GROOVE = 0.10 # mm, diamond drag tip SCRIBE_MM_S = 25.0 # mm/s, tool down CELL = 1.0 # mm, density grid MARGIN = 6.0 # mm, the stylus stays on the sheet # --- the walk ------------------------------------------------------------- DS = 0.6 # mm per step LOOKAHEAD = 7.0 # mm, how far ahead it reads PROBE_SPREAD = 0.62 # rad, angle of the left / right feelers SENSE_R = 3 # cells, radius of a density reading GAIN_GRADIENT = 0.052 # how strongly the gradient steers GAIN_SURPRISE = 0.95 # how strongly error amplifies the turn GAIN_EDGE = 0.30 # how hard it refuses to leave the sheet LEARN_RATE = 0.055 # online update of the predictor TURN_CLAMP = 0.30 # rad per step NW, NH = int(PLATE_W / CELL), int(PLATE_H / CELL) class Metal: """The surface. Append-only: cells only ever get deeper.""" def __init__(self): self.g = bytearray(NW * NH) self.touched = 0 def cut(self, x, y): cx, cy = int(x / CELL), int(y / CELL) if 0 <= cx < NW and 0 <= cy < NH: i = cy * NW + cx v = self.g[i] if v == 0: self.touched += 1 if v < 255: self.g[i] = v + 1 def density(self, x, y, r=SENSE_R): """How marked the metal is around a point, in 0..1.""" cx, cy = int(x / CELL), int(y / CELL) tot = 0 n = 0 for dy in range(-r, r + 1): yy = cy + dy if yy < 0 or yy >= NH: continue base = yy * NW for dx in range(-r, r + 1): xx = cx + dx if xx < 0 or xx >= NW: continue v = self.g[base + xx] tot += 1 if v else 0 n += 1 return tot / n if n else 0.0 def coverage(self): return self.touched / (NW * NH) def run(hours=8.0, out_dir="../plate", seed_heading=0.6, log_every=2000): metal = Metal() x, y = PLATE_W * 0.5, PLATE_H * 0.5 th = seed_heading # the predictor: density_ahead ~ w . features. It starts knowing nothing. w = [0.0] * 6 bias = 0.0 budget_s = hours * 3600.0 steps = int(budget_s * SCRIBE_MM_S / DS) pts = [(x, y)] trace = [] # periodic samples of the machine's inner state surprise_run = 0.0 # exponential average of |error| turn_prev = 0.0 cut_mm = 0.0 straight_mm = 0.0 # distance travelled while barely turning max_surprise = 0.0 surprise_sum = 0.0 for step in range(steps): # ---- what the machine expects to find ahead ---- ax, ay = x + LOOKAHEAD * math.cos(th), y + LOOKAHEAD * math.sin(th) lx = x + LOOKAHEAD * math.cos(th - PROBE_SPREAD) ly = y + LOOKAHEAD * math.sin(th - PROBE_SPREAD) rx = x + LOOKAHEAD * math.cos(th + PROBE_SPREAD) ry = y + LOOKAHEAD * math.sin(th + PROBE_SPREAD) d_here = metal.density(x, y) d_left = metal.density(lx, ly) d_right = metal.density(rx, ry) cov = metal.coverage() feats = [d_here, d_left, d_right, abs(turn_prev), cov, surprise_run] pred = bias + sum(wi * fi for wi, fi in zip(w, feats)) # ---- what the metal actually is ---- actual = metal.density(ax, ay) err = actual - pred ae = abs(err) # ---- learn from being wrong, and only from that ---- for i, fi in enumerate(feats): w[i] += LEARN_RATE * err * fi bias += LEARN_RATE * err * 0.25 surprise_run = 0.98 * surprise_run + 0.02 * ae surprise_sum += ae if ae > max_surprise: max_surprise = ae # ---- turn ---- # direction from the gradient: lean toward the metal that is less cut. # magnitude from the error: understood ground is crossed in a straight # line, and only a failed prediction bends the stroke. drive = math.tanh((d_left - d_right) * 6.0) turn = (GAIN_GRADIENT + GAIN_SURPRISE * ae) * drive # the edge of the sheet is the edge of what can be thought ex = min(x - MARGIN, PLATE_W - MARGIN - x) ey = min(y - MARGIN, PLATE_H - MARGIN - y) edge = min(ex, ey) if edge < 26.0: cx, cy = PLATE_W * 0.5, PLATE_H * 0.5 to_c = math.atan2(cy - y, cx - x) diff = math.atan2(math.sin(to_c - th), math.cos(to_c - th)) turn += GAIN_EDGE * diff * (1.0 - max(0.0, edge) / 26.0) turn = max(-TURN_CLAMP, min(TURN_CLAMP, turn)) th += turn turn_prev = turn # ---- cut ---- nx, ny = x + DS * math.cos(th), y + DS * math.sin(th) nx = min(PLATE_W - 1.0, max(1.0, nx)) ny = min(PLATE_H - 1.0, max(1.0, ny)) # rasterise the segment so the surface it reads is the surface it cut seg = math.hypot(nx - x, ny - y) k = max(1, int(seg / (CELL * 0.5))) for j in range(1, k + 1): t = j / k metal.cut(x + (nx - x) * t, y + (ny - y) * t) cut_mm += seg if abs(turn) < 0.02: straight_mm += seg x, y = nx, ny pts.append((x, y)) if step % log_every == 0: trace.append({ "step": step, "mm": round(cut_mm, 1), "machine_h": round(cut_mm / SCRIBE_MM_S / 3600.0, 4), "coverage": round(metal.coverage(), 5), "surprise": round(ae, 5), "surprise_avg": round(surprise_run, 5), "turn": round(turn, 5), "d_here": round(d_here, 4), "pred": round(pred, 4), "actual": round(actual, 4), "w": [round(v, 4) for v in w], }) out = os.path.abspath(out_dir) os.makedirs(out, exist_ok=True) # thin the stroke for storage without changing its geometry meaningfully thin = [pts[0]] for p in pts[1:]: q = thin[-1] if (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 >= 0.09: thin.append(p) with open(os.path.join(out, "stroke.json"), "w") as f: json.dump({"pts": [[round(px, 2), round(py, 2)] for (px, py) in thin]}, f) with open(os.path.join(out, "trace.json"), "w") as f: json.dump(trace, f) summary = { "hours": hours, "steps": steps, "cut_mm": round(cut_mm, 1), "cut_m": round(cut_mm / 1000.0, 2), "machine_hours": round(cut_mm / SCRIBE_MM_S / 3600.0, 3), "coverage": round(metal.coverage(), 5), "points_stored": len(thin), "straight_mm": round(straight_mm, 1), "straight_fraction": round(straight_mm / max(1e-9, cut_mm), 4), "mean_surprise": round(surprise_sum / max(1, steps), 5), "max_surprise": round(max_surprise, 5), "final_weights": [round(v, 4) for v in w], "final_bias": round(bias, 4), "plate_mm": [PLATE_W, PLATE_H], "groove_mm": GROOVE, } with open(os.path.join(out, "summary.json"), "w") as f: json.dump(summary, f, ensure_ascii=False, indent=1) print(json.dumps(summary, ensure_ascii=False)) return summary if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--hours", type=float, default=8.0) ap.add_argument("--out", default="../plate") args = ap.parse_args() run(hours=args.hours, out_dir=args.out)