"""FIVE KINDS OF UNREADABLE — 読めない五つ A rotating disc. A linear rail through the centre. One diamond point that touches the metal and never lifts. Why the mechanism matters ------------------------- On the A3 plate every place was the same place. The disc is not like that, and the difference is not decorative — it comes out of the axes. The rotary axis moves in microsteps of 2pi/3200. A single microstep carries the tool r*dtheta in the tangential direction: 0.383 mm at the rim, 0.020 mm at r=10. The groove is 0.10 mm wide, so there is a radius where the mechanism starts being able to place the tool more finely than the tool itself is wide: r_fine = groove / dtheta = 50.9 mm Inside that circle, shape survives. Outside it, any detail finer than a third of a millimetre is quantised away before it reaches the metal. That inner disc is 6.8% of the area. It is the only place where the machine can be precise, and it is small — which is what makes what it puts there a legible decision. The radial axis is a 1 um lead screw, precise everywhere. So the disc is not merely coarse-to-fine; it is ANISOTROPIC. At the rim the machine can still be exact radially while being blunt tangentially. Its handwriting is forced to change with where it stands, using the same rule throughout. And theta wraps. On the flat plate the line met its own past by accident. Here it comes back to the same angle every revolution, necessarily. Prediction finally has something to be about. The five variations ------------------- Each asks the same two questions — what intelligence is, and what a world that a human cannot read would be like — and answers them differently. They share the base rule imported from scribe.py: read the metal, predict it, learn only from the error, and turn by the size of the error. There is no goal, no reward and no fitness anywhere. descent Sensing scales with r. Coarse and wide at the rim, fine and narrow at the centre. UNREADABLE BY SCALE: the record is finer than the eye at exactly the places the machine cared most. phase The prediction is about the metal one revolution ahead, at the angle the machine will return to. Verification arrives a whole turn later. UNREADABLE BY DELAY: nothing on the surface says when a prediction was made or which mark answered it. nesting The machine keeps its own recent turn sequence as a motif and replays it at whatever scale its current radius affords. UNREADABLE BY SELF-SIMILARITY: part and whole are the same gesture, so no crop tells you which magnification you are at. imaginary Weights, features and error are complex. Only the real part reaches the stylus; the imaginary part only ever trains. UNREADABLE IN PRINCIPLE: half the thinking cannot touch metal. search Six candidate directions are evaluated each step and the one the model is most wrong about is taken. UNREADABLE BY ERASURE: the surface holds the decisions and none of the alternatives. Usage: python3 polar.py --mode descent --hours 8 --out ../plate/polar/descent """ from __future__ import annotations import argparse import json import math import os import time # the rule of motion's constants come from the flat work so the two cannot # silently drift apart. Only the body the rule runs in has changed. from scribe import (GROOVE, SCRIBE_MM_S, DS, PROBE_SPREAD, GAIN_GRADIENT, GAIN_SURPRISE, GAIN_EDGE, LEARN_RATE, TURN_CLAMP) MODES = ("descent", "phase", "nesting", "imaginary", "search") # --- the disc ------------------------------------------------------------- DISC_R = 200.0 # mm, a 400 mm stainless disc R_MIN, R_MAX = 5.0, 195.0 # mm, where the tool is allowed to be CELL = 0.5 # mm, isotropic density grid; metal is isotropic NW = NH = int(2.0 * DISC_R / CELL) # 800 x 800 SENSE_MM = 2.5 # mm, radius of a density reading LOOKAHEAD_MM = 7.0 # mm, how far ahead it reads EDGE_MM = 22.0 # mm, how close to a limit before it leans away # --- the axes ------------------------------------------------------------- D_THETA = 2.0 * math.pi / 3200.0 # rad, one microstep of the rotary axis D_RADIUS = 0.001 # mm, one microstep of the radial axis R_FINE = GROOVE / D_THETA # mm, inside here the mechanism beats the tool # --- variation-specific --------------------------------------------------- SENSE_SCALE_MIN = 0.35 # descent: floor on the sensing radius, mm MOTIF_LEN = 256 # nesting: how long a remembered gesture is MOTIF_GAIN = 0.55 # nesting: how strongly it is replayed N_CANDIDATES = 6 # search: directions weighed per step CAND_SPREAD = 0.9 # search: rad, half-width of the fan class Disc: """The surface. Append-only: cells only ever get deeper. Cartesian and isotropic on purpose. The mechanism is polar, the metal is not, and conflating the two would build the result into the measurement. """ def __init__(self): self.g = bytearray(NW * NH) self.touched = 0 # only cells inside the annulus can ever be cut; that is the denominator n = 0 for iy in range(NH): y = (iy + 0.5) * CELL - DISC_R for ix in range(NW): x = (ix + 0.5) * CELL - DISC_R d2 = x * x + y * y if R_MIN * R_MIN <= d2 <= R_MAX * R_MAX: n += 1 self.reachable = n def cut(self, x, y): cx = int((x + DISC_R) / CELL) cy = int((y + DISC_R) / 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, mm=SENSE_MM): """How marked the metal is around a point, in 0..1.""" k = max(1, int(mm / CELL)) cx = int((x + DISC_R) / CELL) cy = int((y + DISC_R) / CELL) tot = 0 n = 0 for dy in range(-k, k + 1): yy = cy + dy if yy < 0 or yy >= NH: continue base = yy * NW for dx in range(-k, k + 1): xx = cx + dx if xx < 0 or xx >= NW: continue tot += 1 if self.g[base + xx] else 0 n += 1 return tot / n if n else 0.0 def coverage(self): return self.touched / max(1, self.reachable) class Head: """Where the tool is, where it is going, and what it expects. Position is polar because the mechanism is polar: r on the rail, th on the rotary axis. Heading psi is a global angle — the direction the point is travelling across the metal. """ __slots__ = ("r", "th", "psi", "w", "bias", "wi", "biasi", "surprise_run", "turn_prev", "cut_mm", "straight_mm", "surprise_sum", "max_surprise", "acc_r", "acc_th", "quantised_mm", "coarse_sum", "steps_r", "steps_th") def __init__(self, r, th, psi, nfeat=6): self.r, self.th, self.psi = r, th, psi self.w = [0.0] * nfeat self.bias = 0.0 self.wi = [0.0] * nfeat # imaginary part of the weights self.biasi = 0.0 self.surprise_run = 0.0 self.turn_prev = 0.0 self.cut_mm = self.straight_mm = 0.0 self.surprise_sum = 0.0 self.max_surprise = 0.0 self.acc_r = self.acc_th = 0.0 # sub-microstep remainder, carried over self.quantised_mm = 0.0 # summed tangential lag, mm self.coarse_sum = 0.0 # summed r*dtheta/groove, for its mean self.steps_r = self.steps_th = 0 def xy(self): return self.r * math.cos(self.th), self.r * math.sin(self.th) def probe_xy(h, ahead_mm, offset=0.0): """A point ahead of the tool, in millimetres, along its heading.""" x, y = h.xy() a = h.psi + offset return x + ahead_mm * math.cos(a), y + ahead_mm * math.sin(a) def sense_radius(h, mode): """How wide a patch the machine reads. Fixed for every variation but 'descent', where it is tied to the radius — the mechanism can only resolve r*dtheta out here, so reading finer than that would be reading noise it can never act on. """ if mode != "descent": return SENSE_MM, LOOKAHEAD_MM f = h.r / R_MAX return (max(SENSE_SCALE_MIN, SENSE_MM * f), max(SENSE_SCALE_MIN * 2.8, LOOKAHEAD_MM * f)) def advance(h, disc, turn): """Turn, then cut — through the axes, which is where shape gets lost. The heading is what the rule asked for. What the metal receives is whole microsteps of each axis, with the remainder carried to the next step so the quantisation costs the machine detail and not direction. At the rim that removes anything finer than 0.38 mm tangentially; near the centre it removes almost nothing. Same rule, different handwriting, because of where it is standing. """ turn = max(-TURN_CLAMP, min(TURN_CLAMP, turn)) h.psi += turn h.turn_prev = turn x0, y0 = h.xy() # what the rule wants, resolved onto the two axes dr_want = DS * math.cos(h.psi - h.th) dth_want = DS * math.sin(h.psi - h.th) / max(1e-9, h.r) h.acc_r += dr_want h.acc_th += dth_want n_r = int(h.acc_r / D_RADIUS) n_th = int(h.acc_th / D_THETA) h.acc_r -= n_r * D_RADIUS h.acc_th -= n_th * D_THETA h.steps_r += abs(n_r) h.steps_th += abs(n_th) # how far the tool is from where the rule asked it to be, this instant. # The remainder is carried, so direction is preserved and it is only fine # shape that is lost — this number is the size of that loss, not a drift. h.quantised_mm += abs(h.acc_th) * h.r h.coarse_sum += h.r * D_THETA / GROOVE h.r += n_r * D_RADIUS h.th += n_th * D_THETA if h.r < R_MIN: h.r = R_MIN elif h.r > R_MAX: h.r = R_MAX x1, y1 = h.xy() seg = math.hypot(x1 - x0, y1 - y0) k = max(1, int(seg / (CELL * 0.5))) for j in range(1, k + 1): t = j / k disc.cut(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) h.cut_mm += seg if abs(turn) < 0.02: h.straight_mm += seg return seg def edge_lean(h): """The rail has ends. Lean off them, the way the sheet's edge worked.""" room = min(h.r - R_MIN, R_MAX - h.r) if room >= EDGE_MM: return 0.0 # aim along the mid-radius circle rather than at a point, so the disc does # not acquire a centre the rule was never given tangential = h.th + math.pi * 0.5 inward = h.th + math.pi if h.r > (R_MIN + R_MAX) * 0.5 else h.th want = math.atan2(0.6 * math.sin(tangential) + math.sin(inward), 0.6 * math.cos(tangential) + math.cos(inward)) diff = math.atan2(math.sin(want - h.psi), math.cos(want - h.psi)) return GAIN_EDGE * diff * (1.0 - max(0.0, room) / EDGE_MM) def base_error(h, disc, mode, ext): """Read, predict, find out, learn. Returns the readings and |error|. This is the rule the flat work established, unchanged in substance: the prediction is about the density of the metal ahead, learning happens only from the error, and nothing else is optimised. What each variation changes is what counts as 'ahead' or as 'the error'. """ sm, la = sense_radius(h, mode) x, y = h.xy() ax, ay = probe_xy(h, la) lx, ly = probe_xy(h, la, -PROBE_SPREAD) rx, ry = probe_xy(h, la, +PROBE_SPREAD) d_here = disc.density(x, y, sm) d_left = disc.density(lx, ly, sm) d_right = disc.density(rx, ry, sm) cov = disc.coverage() # how coarse this radius is, in tool widths — the one thing the machine is # told about its own body grain = min(1.0, h.r * D_THETA / GROOVE) feats = [d_here, d_left, d_right, abs(h.turn_prev), cov, grain] pred = h.bias + sum(wi * fi for wi, fi in zip(h.w, feats)) if mode == "phase": # the claim is about the metal one revolution from here, at the angle # this machine is about to come back to. It is filed and left alone. b = int((h.th / (2.0 * math.pi) % 1.0) * MOTIF_LEN) % MOTIF_LEN due = ext["due"] actual = disc.density(x, y, sm) # what that angle turned out to be claim = due[b] due[b] = pred if claim is None: err = 0.0 # first visit: nothing was promised else: err = actual - claim else: actual = disc.density(ax, ay, sm) err = actual - pred ae = abs(err) if mode == "imaginary": # complex features: value and its change. Complex weights. The update # is the complex LMS step. Only Re(error) will steer; Im(error) exists # solely to shape the weights, and can never reach the metal. prev = ext["prev_feats"] erri = actual - (h.biasi + sum(wi * fi for wi, fi in zip(h.wi, feats))) for i, fi in enumerate(feats): fim = fi - prev[i] h.w[i] += LEARN_RATE * (err * fi + erri * fim) h.wi[i] += LEARN_RATE * (erri * fi - err * fim) h.bias += LEARN_RATE * err * 0.25 h.biasi += LEARN_RATE * erri * 0.25 ext["prev_feats"] = feats ext["imag_sum"] += abs(erri) ext["imag_n"] += 1 else: for i, fi in enumerate(feats): h.w[i] += LEARN_RATE * err * fi h.bias += LEARN_RATE * err * 0.25 h.surprise_run = 0.98 * h.surprise_run + 0.02 * ae h.surprise_sum += ae if ae > h.max_surprise: h.max_surprise = ae return d_left, d_right, pred, actual, ae, grain def step(h, disc, mode, ext): """One step, for one variation.""" if mode == "search": # weigh six directions and take the one the model is most wrong about. # Nothing is optimised toward a goal; the machine is simply steering # into its own ignorance. Only the taken direction is ever cut. sm, la = sense_radius(h, mode) cov = disc.coverage() grain = min(1.0, h.r * D_THETA / GROOVE) x, y = h.xy() d_here = disc.density(x, y, sm) # the prediction must not be allowed to see what it is predicting, so # the features are the same for every candidate and only the outcome # differs. Feeding each candidate's own density into its features would # let the model reproduce the answer and the error would vanish. feats = [d_here, d_here, d_here, abs(h.turn_prev), cov, grain] pred = h.bias + sum(wi * fi for wi, fi in zip(h.w, feats)) best = None for c in range(N_CANDIDATES): off = -CAND_SPREAD + 2.0 * CAND_SPREAD * c / (N_CANDIDATES - 1.0) cx, cy = probe_xy(h, la, off) a = disc.density(cx, cy, sm) e = abs(a - pred) if best is None or e > best[0]: best = (e, off, a) ae, off, actual = best for i, fi in enumerate(feats): h.w[i] += LEARN_RATE * (actual - pred) * fi h.bias += LEARN_RATE * (actual - pred) * 0.25 h.surprise_run = 0.98 * h.surprise_run + 0.02 * ae h.surprise_sum += ae if ae > h.max_surprise: h.max_surprise = ae ext["rejected"] += N_CANDIDATES - 1 turn = off * (GAIN_GRADIENT / max(1e-9, CAND_SPREAD) + GAIN_SURPRISE * ae) turn += edge_lean(h) seg = advance(h, disc, turn) return ae, grain, seg d_left, d_right, pred, actual, ae, grain = base_error(h, disc, mode, ext) # direction from the gradient: lean toward the metal that is less cut. # magnitude from the error: understood ground is crossed straight. drive = math.tanh((d_left - d_right) * 6.0) turn = (GAIN_GRADIENT + GAIN_SURPRISE * ae) * drive if mode == "nesting": # replay the remembered gesture, scaled by how much shape this radius # can actually hold. A motif learned in the fine centre comes out # blunted at the rim, and the rim's blunt version comes back sharp when # the machine returns inward: the same intention at two magnifications. motif = ext["motif"] i = ext["motif_i"] turn += MOTIF_GAIN * motif[i % MOTIF_LEN] * (1.0 - 0.5 * grain) ext["motif_i"] = i + 1 if ext["recording"] == 0 and ae > 2.2 * h.surprise_run and h.surprise_run > 0: # surprised enough to be worth remembering: start overwriting. Do # not re-trigger mid-recording, or the motif is continuously rewritten # and stops being a memory of anything. ext["recording"] = MOTIF_LEN if ext["recording"] > 0: motif[(MOTIF_LEN - ext["recording"]) % MOTIF_LEN] = h.turn_prev ext["recording"] -= 1 ext["rewrites"] += 1 turn += edge_lean(h) seg = advance(h, disc, turn) return ae, grain, seg def new_ext(mode): return { "due": [None] * MOTIF_LEN, "prev_feats": [0.0] * 6, "imag_sum": 0.0, "imag_n": 0, "motif": [0.0] * MOTIF_LEN, "motif_i": 0, "recording": 0, "rewrites": 0, "rejected": 0, } def run(mode, hours=8.0, out_dir="../plate/polar", log_every=2000): assert mode in MODES, mode disc = Disc() h = Head(100.0, 0.0, math.pi * 0.5 + 0.6) ext = new_ext(mode) steps = int(hours * 3600.0 * SCRIBE_MM_S / DS) pts = [h.xy()] trace = [] r_hist = [] fine_mm = 0.0 # distance cut inside the precise inner disc t0 = time.time() for st in range(steps): ae, grain, seg = step(h, disc, mode, ext) pts.append(h.xy()) if h.r < R_FINE: fine_mm += seg if st % log_every == 0: trace.append({ "step": st, "mm": round(h.cut_mm, 1), "r": round(h.r, 2), "coverage": round(disc.coverage(), 5), "surprise": round(ae, 5), "surprise_avg": round(h.surprise_run, 5), "grain": round(grain, 4), "turn": round(h.turn_prev, 5), }) r_hist.append(round(h.r, 2)) out = os.path.abspath(out_dir) os.makedirs(out, exist_ok=True) thin = [pts[0]] for p in pts[1:]: q = thin[-1] if (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 >= 0.0025: thin.append(p) with open(os.path.join(out, "stroke.json"), "w") as f: json.dump({"pts": [[round(a, 3), round(b, 3)] for a, b in thin]}, f) with open(os.path.join(out, "trace.json"), "w") as f: json.dump(trace, f) rs = [t["r"] for t in trace] summary = { "mode": mode, "hours": hours, "steps": steps, "cut_m": round(h.cut_mm / 1000.0, 2), "coverage": round(disc.coverage(), 5), "points_stored": len(thin), "straight_fraction": round(h.straight_mm / max(1e-9, h.cut_mm), 4), "mean_surprise": round(h.surprise_sum / max(1, steps), 6), "max_surprise": round(h.max_surprise, 5), "surprise_first_eighth": round( sum(t["surprise"] for t in trace[:len(trace) // 8]) / max(1, len(trace) // 8), 6), "surprise_last_eighth": round( sum(t["surprise"] for t in trace[-len(trace) // 8:]) / max(1, len(trace) // 8), 6), "r_start": rs[0] if rs else None, "r_end": rs[-1] if rs else None, "r_mean": round(sum(rs) / max(1, len(rs)), 2), "r_min_seen": min(rs) if rs else None, "r_max_seen": max(rs) if rs else None, "fine_fraction": round(fine_mm / max(1e-9, h.cut_mm), 4), "tangential_lag_mm": round(h.quantised_mm, 1), "tangential_lag_fraction": round(h.quantised_mm / max(1e-9, h.cut_mm), 5), "mean_coarseness_in_tool_widths": round(h.coarse_sum / max(1, steps), 3), "steps_rotary": h.steps_th, "steps_radial": h.steps_r, "r_fine_mm": round(R_FINE, 2), "disc_mm": [R_MIN, R_MAX], "groove_mm": GROOVE, "final_weights": [round(v, 4) for v in h.w], "wall_s": round(time.time() - t0, 1), } if mode == "imaginary": summary["imaginary_error_mean"] = round( ext["imag_sum"] / max(1, ext["imag_n"]), 6) summary["imaginary_weights"] = [round(v, 4) for v in h.wi] if mode == "search": summary["candidates_rejected"] = ext["rejected"] summary["candidates_per_step"] = N_CANDIDATES if mode == "nesting": summary["motif_rewrites"] = ext["rewrites"] summary["motif_len"] = MOTIF_LEN 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("--mode", required=True, choices=MODES) ap.add_argument("--hours", type=float, default=8.0) ap.add_argument("--out", default=None) args = ap.parse_args() run(args.mode, hours=args.hours, out_dir=args.out or ("../plate/polar/" + args.mode))