"""A testbed for v2: one stroke, a permanent medium, and rules that might make a pattern larger than they are. WHAT v1 LEARNED THAT THIS IS BUILT ON v1 asked the machine to read OCCUPANCY — what fraction of nearby metal has been touched at all. That quantity dies. Once a neighbourhood is marked it reads 1 everywhere and carries nothing, and because the medium is permanent it never recovers. Every v1 run ended in a closed orbit. But v1 also measured, almost by accident, a quantity that did NOT die. On the 80-hour disc the outer ring was 100% cut — occupancy pinned at 1, no information left — and yet the axial order parameter of the groove DIRECTIONS there was -0.918. Direction still carried structure where amount carried none. So this testbed's medium stores two things per cell: n[y,x] how many times the tip has crossed it C[y,x], S[y,x] the axial direction field: sums of cos(2 phi), sin(2 phi) Grooves are AXIAL — a groove cut heading 30 degrees is the same groove as one cut heading 210 — so directions live on the doubled angle, and the resultant length |(C,S)| / n is an order parameter: 0 if the local grooves point every way, 1 if they all agree. AND THE OTHER THING v1 ESTABLISHED, which turns out to be the engine Depth is set by the spring load, so a second pass along an existing groove removes NO METAL. In v1 that was a limitation. Here it is the mechanism: re-tracing an old groove is measurably fruitless, so a machine that can tell new metal from old has a reason to leave a path it has been following — without any need for the medium to forget. That is what stops a follow-the-groove rule collapsing into one trunk traced forever, which is exactly how v1 died. Usage: python3 stroke.py --rule vein --steps 400000 python3 stroke.py --rule vein --steps 400000 --null follow # ablations python3 stroke.py --list """ from __future__ import annotations import argparse import json import math import os import numpy as np # --- the sheet ------------------------------------------------------------- SIZE_MM = 400.0 CELL = 0.5 # mm per cell; the groove is 0.062 mm so a cell is NX = NY = int(SIZE_MM / CELL) # coarser than one groove, as in v1 DS = 0.6 # mm per step, as in v1 MARGIN = 8.0 # mm # --- what the tip can feel ------------------------------------------------- SENSE_MM = 1.2 # radius of the direction reading. DELIBERATELY SMALL: # the whole claim is that the pattern is far larger LOOK_MM = 2.4 # how far ahead it reads class Sheet: """Permanent. Cells only ever gain passes; nothing decays, nothing erases.""" def __init__(self): self.n = np.zeros((NY, NX), dtype=np.int32) self.C = np.zeros((NY, NX), dtype=np.float32) self.S = np.zeros((NY, NX), dtype=np.float32) self.touched = 0 def cut(self, x, y, phi): cx, cy = int(x / CELL), int(y / CELL) if not (0 <= cx < NX and 0 <= cy < NY): return False virgin = self.n[cy, cx] == 0 if virgin: self.touched += 1 self.n[cy, cx] += 1 self.C[cy, cx] += math.cos(2.0 * phi) self.S[cy, cx] += math.sin(2.0 * phi) return virgin def _box(self, x, y, mm): k = max(1, int(mm / CELL)) cx, cy = int(x / CELL), int(y / CELL) x0, x1 = max(0, cx - k), min(NX, cx + k + 1) y0, y1 = max(0, cy - k), min(NY, cy + k + 1) return (slice(y0, y1), slice(x0, x1)) def read(self, x, y, mm=SENSE_MM): """The local groove field: how much, which way, how much they agree. Returns (occupancy, mean_axis, alignment). occupancy is v1's dying quantity, kept only so the ablations can use it. alignment is the resultant length of the doubled angles and does NOT die when the sheet fills: it dies only if the grooves there genuinely point every way. """ sl = self._box(x, y, mm) n = self.n[sl] tot = int(n.sum()) if tot == 0: return 0.0, 0.0, 0.0 occ = float((n > 0).mean()) c = float(self.C[sl].sum()) / tot s = float(self.S[sl].sum()) / tot align = math.hypot(c, s) axis = 0.5 * math.atan2(s, c) return occ, axis, align def coverage(self): return self.touched / float(NX * NY) # --- rules ----------------------------------------------------------------- def angle_to_axis(heading, axis): """Signed turn that aligns the heading with an AXIS (mod pi), and |chi|.""" d = math.atan2(math.sin(heading - axis), math.cos(heading - axis)) if d > math.pi / 2: d -= math.pi elif d < -math.pi / 2: d += math.pi return -d, abs(d) # turn, |chi| class Vein: """Follow a groove met shallowly; cross it when met steeply; leave when the following stops cutting metal. Three lines, all local: 1. ANASTOMOSIS. If the grooves nearby agree (alignment > A_MIN) and the tip meets them at a shallow angle (|chi| < CHI_C), steer onto that axis. This is not a metaphor for slime mould, it is what a drag stylus physically does: met shallowly, the tip falls into the groove and tracks it. 2. SATIATION. Keep an exponential average of how often a step cuts VIRGIN metal. Riding an old groove cuts none, so this average falls, and when it does the rule adds a turn AWAY from the local axis. This is the term that v1 lacked, and it needs no decay in the medium: it is the machine noticing that it is doing no work. 3. EXPLORE. Otherwise steer toward whichever side is less marked, as v1 did. The length scale is set by (2) and is a prediction, not a fit: the tip stays on a trunk for about TAU steps, so trunks should be about TAU * DS mm long, and the mesh should be of that order — with TAU = 60 and DS = 0.6 that is 36 mm on a 400 mm sheet, against a 1.2 mm sensing radius. A ratio of 30. """ A_MIN = 0.35 # how much the grooves must agree to be worth following CHI_C = math.radians(32.0) # shallow enough to fall in K_FOLLOW = 0.55 # how hard it steers onto the axis K_LEAVE = 0.85 # how hard satiation pushes it off K_EXPLORE = 0.09 TAU = 60.0 # steps; sets the trunk length and so the mesh SAT_LO = 0.18 # below this fraction of virgin steps, leave name = "vein" def __init__(self, null=None): self.null = null self.virgin_run = 1.0 def feed(self, was_virgin): a = 1.0 / self.TAU self.virgin_run = (1.0 - a) * self.virgin_run + a * (1.0 if was_virgin else 0.0) def turn(self, sheet, x, y, heading): occ, axis, align = sheet.read(x, y) lx = x + LOOK_MM * math.cos(heading - 0.6) ly = y + LOOK_MM * math.sin(heading - 0.6) rx = x + LOOK_MM * math.cos(heading + 0.6) ry = y + LOOK_MM * math.sin(heading + 0.6) oc_l = sheet.read(lx, ly)[0] oc_r = sheet.read(rx, ry)[0] t = 0.0 # 3. explore: lean toward the less marked side if self.null != "explore": t += self.K_EXPLORE * math.tanh((oc_l - oc_r) * 4.0) to_axis, chi = angle_to_axis(heading, axis) coherent = align > self.A_MIN and occ > 0.05 # 1. follow if self.null != "follow" and coherent and chi < self.CHI_C: t += self.K_FOLLOW * to_axis # 2. leave, when following has stopped cutting anything if self.null != "satiation" and coherent and self.virgin_run < self.SAT_LO: starve = (self.SAT_LO - self.virgin_run) / self.SAT_LO t -= self.K_LEAVE * starve * to_axis return t class Occupancy: """v1's rule, reduced to its essentials, as the baseline that is known to die.""" name = "occupancy" K = 0.10 def __init__(self, null=None): self.null = null def feed(self, was_virgin): pass def turn(self, sheet, x, y, heading): lx = x + LOOK_MM * math.cos(heading - 0.6) ly = y + LOOK_MM * math.sin(heading - 0.6) rx = x + LOOK_MM * math.cos(heading + 0.6) ry = y + LOOK_MM * math.sin(heading + 0.6) return self.K * math.tanh((sheet.read(lx, ly)[0] - sheet.read(rx, ry)[0]) * 6.0) RULES = {"vein": Vein, "occupancy": Occupancy} # --- the run --------------------------------------------------------------- def trunk_stats(n): """Is the path CONCENTRATED into trunks, or spread like felt? The vein pattern lives in the pass count, not in the binary mask: both a network and a felt end up covering most of the sheet, so (n>0) cannot tell them apart. What differs is how unevenly the passes pile up. Returns the share of all passes falling in the busiest tenth of marked cells, and the Gini coefficient over marked cells. A felt sits near 0.1 / 0.0; a network puts a large share into a small part of the sheet. """ v = n[n > 0].astype(np.float64) if v.size == 0: return 0.0, 0.0, 0.0 v.sort() top = v[int(len(v) * 0.9):] share = float(top.sum() / v.sum()) cum = np.cumsum(v) / v.sum() gini = float(1.0 - 2.0 * cum.mean() + 1.0 / len(v)) return share, gini, float(v.max() / v.mean()) def pass_corr(n, cell_mm=CELL): """Autocorrelation length and first side lobe of the PASS COUNT field. Run on log(1+n) so a few very busy cells cannot dominate. The side lobe, if there is one, is the trunk spacing — the number this whole design is betting on. """ a = np.log1p(n.astype(np.float64)) a -= a.mean() F = np.fft.rfft2(a) ac = np.fft.fftshift(np.fft.irfft2(F * np.conj(F), s=a.shape)) ac /= ac.max() ny, nx = ac.shape r = np.hypot((np.arange(ny) - ny // 2)[:, None], (np.arange(nx) - nx // 2)[None, :]) * cell_mm nb, hi = 400, min(ny, nx) // 2 * cell_mm ib = np.clip((r / hi * nb).astype(int), 0, nb - 1) cnt = np.maximum(np.bincount(ib.ravel(), minlength=nb), 1) prof = np.bincount(ib.ravel(), weights=ac.ravel(), minlength=nb) / cnt rad = (np.arange(nb) + 0.5) / nb * hi fz = None for i in range(1, nb): if prof[i - 1] > 0 >= prof[i]: f = prof[i - 1] / (prof[i - 1] - prof[i] + 1e-30) fz = float(rad[i - 1] + f * (rad[i] - rad[i - 1])); break lobe = None if fz: j0 = int(fz / hi * nb) + 2 seg = prof[j0:int(nb * 0.5)] if len(seg) > 4: k = int(np.argmax(seg)) if seg[k] > 0.005 and 0 < k < len(seg) - 1: lobe = float(rad[j0 + k]) return fz, lobe def run(rule_name, steps, null=None, seed_heading=0.6, turn_clamp=0.30, until_coverage=None): sheet = Sheet() rule = RULES[rule_name](null=null) x = y = SIZE_MM * 0.5 h = seed_heading P = np.empty((steps + 1, 2), dtype=np.float32) P[0] = (x, y) trace = [] virgin_steps = 0 for i in range(steps): t = rule.turn(sheet, x, y, h) # the sheet has edges; lean off them as v1 did room = min(x - MARGIN, SIZE_MM - MARGIN - x, y - MARGIN, SIZE_MM - MARGIN - y) if room < 20.0: to_c = math.atan2(SIZE_MM * 0.5 - y, SIZE_MM * 0.5 - x) d = math.atan2(math.sin(to_c - h), math.cos(to_c - h)) t += 0.30 * d * (1.0 - max(0.0, room) / 20.0) t = max(-turn_clamp, min(turn_clamp, t)) h += t nx_ = min(SIZE_MM - 1.0, max(1.0, x + DS * math.cos(h))) ny_ = min(SIZE_MM - 1.0, max(1.0, y + DS * math.sin(h))) # rasterise so what it reads is what it cut k = max(1, int(math.hypot(nx_ - x, ny_ - y) / (CELL * 0.5))) v = False for j in range(1, k + 1): f = j / k v |= sheet.cut(x + (nx_ - x) * f, y + (ny_ - y) * f, h) rule.feed(v) virgin_steps += 1 if v else 0 x, y = nx_, ny_ P[i + 1] = (x, y) if until_coverage is not None and sheet.coverage() >= until_coverage: P = P[:i + 2] steps = i + 1 break if i % 2000 == 0: occ, axis, align = sheet.read(x, y) trace.append({"step": i, "coverage": round(sheet.coverage(), 5), "occ": round(occ, 4), "align": round(align, 4), "turn": round(t, 5), "virgin_run": round(getattr(rule, "virgin_run", -1.0), 4)}) return sheet, P, trace, virgin_steps, steps # --- did anything emerge? -------------------------------------------------- def correlation_length(mask, cell_mm=CELL): """First zero crossing of the radial autocorrelation, in mm. This replaces a radial power-spectrum PEAK, which does not work here: the spectrum of a filled scribble decays monotonically, so its argmax always lands in the lowest allowed bin and reports the size of the sheet no matter what the pattern is. Every rule and every ablation returned the same 103.71 mm, which is how the mistake showed up. The autocorrelation's first zero is a real length: it is where the deposit stops resembling itself. For a mesh it is of the order of the mesh spacing. Also returns the first SIDE LOBE position if there is one, since a genuine periodic mesh puts a bump there and a random scribble does not. """ a = mask.astype(np.float64) a -= a.mean() F = np.fft.rfft2(a) ac = np.fft.irfft2(F * np.conj(F), s=a.shape) ac = np.fft.fftshift(ac) ac /= ac.max() ny, nx = ac.shape yy = (np.arange(ny) - ny // 2)[:, None] xx = (np.arange(nx) - nx // 2)[None, :] r = np.hypot(yy, xx) * cell_mm nb = 400 hi = min(ny, nx) // 2 * cell_mm ib = np.clip((r / hi * nb).astype(int), 0, nb - 1) prof = np.bincount(ib.ravel(), weights=ac.ravel(), minlength=nb) cnt = np.bincount(ib.ravel(), minlength=nb) prof = prof / np.maximum(cnt, 1) rad = (np.arange(nb) + 0.5) / nb * hi first_zero = None for i in range(1, nb): if prof[i - 1] > 0 >= prof[i]: f = prof[i - 1] / (prof[i - 1] - prof[i] + 1e-30) first_zero = float(rad[i - 1] + f * (rad[i] - rad[i - 1])) break # a side lobe: the largest local maximum beyond the first zero lobe = None if first_zero is not None: j0 = int(first_zero / hi * nb) + 2 seg = prof[j0:int(nb * 0.6)] if len(seg) > 4: k = int(np.argmax(seg)) if seg[k] > 0.008 and 0 < k < len(seg) - 1: lobe = float(rad[j0 + k]) return first_zero, lobe, prof, rad def spectrum_peak(mask, cell_mm=CELL): """Kept only so the old call site still works; see correlation_length.""" a = mask.astype(np.float64) a -= a.mean() w = np.hanning(a.shape[0])[:, None] * np.hanning(a.shape[1])[None, :] F = np.abs(np.fft.rfft2(a * w)) ** 2 ny, nx = a.shape fy = np.fft.fftfreq(ny, d=cell_mm)[:, None] fx = np.fft.rfftfreq(nx, d=cell_mm)[None, :] fr = np.hypot(fy, fx) nb = 220 hi = float(fr.max()) ib = np.clip((fr / hi * nb).astype(int), 0, nb - 1) p = np.bincount(ib.ravel(), weights=F.ravel(), minlength=nb) c = np.bincount(ib.ravel(), minlength=nb) p = p / np.maximum(c, 1) edges = (np.arange(nb) + 0.5) / nb * hi lo = edges > 1.0 / (SIZE_MM * 0.5) # ignore the sheet-sized mode if not lo.any(): return None, None j = int(np.argmax(np.where(lo, p, -np.inf))) f = edges[j] return (1.0 / f if f > 0 else None), p def gap_sizes(mask): """Sizes of the un-marked regions, in mm^2 — the cells of any mesh.""" from collections import deque free = ~mask seen = np.zeros_like(free) out = [] for sy in range(0, free.shape[0]): row = np.nonzero(free[sy] & ~seen[sy])[0] for sx in row: if seen[sy, sx]: continue q = deque([(sy, sx)]) seen[sy, sx] = True n = 0 while q: cy, cx = q.popleft() n += 1 for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): yy, xx = cy + dy, cx + dx if 0 <= yy < free.shape[0] and 0 <= xx < free.shape[1] \ and free[yy, xx] and not seen[yy, xx]: seen[yy, xx] = True q.append((yy, xx)) out.append(n * CELL * CELL) return np.array(sorted(out, reverse=True)) def orbit_score(P): """How stuck is it. 1 means the last tenth wanders as much as the whole run; near 0 means it has settled into a closed orbit, which is how every v1 run ended.""" tail = P[-len(P) // 10:] r_all = float(np.sqrt(((P - P.mean(0)) ** 2).sum(1).mean())) r_end = float(np.sqrt(((tail - tail.mean(0)) ** 2).sum(1).mean())) return r_end / max(r_all, 1e-9) def report(sheet, P, trace, virgin_steps, steps, label): mask = sheet.n > 0 corr, lobe, _, _ = correlation_length(mask) share, gini, peak_ratio = trunk_stats(sheet.n) pfz, plobe = pass_corr(sheet.n) gaps = gap_sizes(mask) big = gaps[gaps > 4.0] aligns = [t["align"] for t in trace] occs = [t["occ"] for t in trace] half = len(trace) // 2 out = { "label": label, "steps": steps, "cut_m": round(steps * DS / 1000.0, 2), "coverage": round(sheet.coverage(), 5), "max_passes": int(sheet.n.max()), "virgin_step_fraction": round(virgin_steps / steps, 4), "orbit_score": round(orbit_score(P), 4), "corr_length_mm": round(corr, 2) if corr else None, "side_lobe_mm": round(lobe, 2) if lobe else None, "sense_radius_mm": SENSE_MM, "scale_separation": round(corr / SENSE_MM, 2) if corr else None, "busiest_tenth_share": round(share, 4), "gini_over_marked": round(gini, 4), "max_over_mean_passes": round(peak_ratio, 1), "pass_corr_mm": round(pfz, 2) if pfz else None, "trunk_spacing_mm": round(plobe, 2) if plobe else None, "gap_count_over_4mm2": int(len(big)), "gap_median_mm2": round(float(np.median(big)), 2) if len(big) else 0.0, "gap_largest_mm2": round(float(big[0]), 1) if len(big) else 0.0, "align_first_half": round(float(np.mean(aligns[:half])), 4), "align_last_half": round(float(np.mean(aligns[half:])), 4), "occ_first_half": round(float(np.mean(occs[:half])), 4), "occ_last_half": round(float(np.mean(occs[half:])), 4), } return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--rule", default="vein", choices=sorted(RULES)) ap.add_argument("--steps", type=int, default=400000) ap.add_argument("--null", default=None, choices=["follow", "satiation", "explore"], help="ablate one ingredient; the pattern must not survive it") ap.add_argument("--out", default=None) ap.add_argument("--until-coverage", type=float, default=None, help="stop at this coverage so runs can be compared fairly") ap.add_argument("--list", action="store_true") args = ap.parse_args() if args.list: print("rules:", ", ".join(sorted(RULES))) print("nulls: follow, satiation, explore") return label = args.rule + ("" if not args.null else " (no %s)" % args.null) sheet, P, trace, vs, used = run(args.rule, args.steps, args.null, until_coverage=args.until_coverage) rep = report(sheet, P, trace, vs, used, label) print(json.dumps(rep, ensure_ascii=False)) if args.out: out = os.path.abspath(args.out) os.makedirs(out, exist_ok=True) np.save(os.path.join(out, "passes.npy"), sheet.n) np.save(os.path.join(out, "stroke.npy"), P) with open(os.path.join(out, "report.json"), "w") as f: json.dump({"report": rep, "trace": trace}, f, ensure_ascii=False, indent=1) if __name__ == "__main__": main()