"""v2 — YIELD / BREAK. The rule the metal has to finish. v1 asked the machine to read the metal and decide. Four times over, that ended in a closed orbit: more time (ten days), more machines (the shared plate), a coarser body (the polar axes) and a new opportunity (the diameter rail) all stopped. The rail said why, in one line: a change to the body works when the rule is FORCED by it, and does nothing when it is merely PERMITTED. The diameter rail offered the centre as a short cut. Nothing in the rule could even represent "short cut" — it read density, and density cannot say that. So the machine declined, 22 times in 80 hours, and went back to its ring. So v2 removes the deciding. The machine's only actuated variable is the LATERAL STIFFNESS of the radial axis, and the metal does the rest: YIELD (compliant) the tip falls into the first old groove it meets at a shallow angle and is carried along it. It cuts nothing, because depth is set by load and does not accumulate. This is not a choice; it is capture. BREAK (stiff) the axis holds, and the tip ploughs a chord straight across whatever it crosses. There is no predictor, no density reading, no weights, no goal, and NO BOUNDARY RULE — v1's rim grain turned out to be my authorship, so there is nothing here to be the author of. The whole memory is one mode bit, one sign bit, one small integer. and the only state transition in the entire rule is the integer reaching N. Why this is worth running: **no length is written anywhere in the rule.** N counts groove crossings, which is dimensionless. The length of a BREAK chord is therefore N divided by however many grooves per millimetre the metal happens to have there, and the length of a YIELD run is N divided by however many junctions it happens to meet. Both are quantities the metal owns. So if a mesh with a spacing appears, that spacing was made by the metal, and it is not my constant wearing a disguise (which is exactly how the two rejected plans and my own first prototype died). The one angle in the rule is not free either. A drag tip stays in a groove while the groove wall can supply the sideways force to hold it, and the wall's mean slope over the half width is 2*depth/width for the groove THIS TOOL CUTS — 28.31 degrees at the specified 0.10 mm. Change the tool's width and this angle moves with it, like the load does. There is nothing here I chose. Usage: python3 yieldbreak.py --self-test python3 yieldbreak.py --hours 8 --n 3 --out ../plate/v2/n3 """ from __future__ import annotations import argparse import json import math import os import time from array import array import numpy as np import polar as P import stylus as st # --- the two constants, and where each comes from -------------------------- # The capture angle. NOT a free parameter: the mean wall slope of the groove # this same tool cuts, from stylus.py, which takes the tool's specified width # as its input. At 0.10 mm this is 28.31 degrees; at the ends of the tool's # specification (0.05, 0.15 mm) it is 26.6 and 28.9 degrees. def capture_angle_rad(width_mm=None): w_um = (width_mm if width_mm is not None else st.GROOVE_MM) * 1000.0 d_um = st.depth_for_contact_um(w_um * 0.5) return math.atan(2.0 * d_um / w_um) CHI_C = capture_angle_rad() # The field the metal keeps of itself. Finer than the density grid because the # question here is "which way does the groove under the tip run", and the tip is # 0.10 mm wide. 0.25 mm cells, 1600 x 1600. CELL_F = 0.25 NF = int(round(2.0 * P.DISC_R / CELL_F)) def set_cell(mm): """Resolution of the metal's own record. A control, not a rule parameter. The physically right value is the tool's width, 0.10 mm, since that is the distance over which a tip can feel one groove rather than two. Coarser is a simulation compromise, so any length that comes out has to be checked against this: if the pattern's spacing scales with the record's resolution, it is the resolution wearing a disguise and not the metal. """ global CELL_F, NF CELL_F = mm NF = int(round(2.0 * P.DISC_R / CELL_F)) return {"cell_mm": CELL_F, "cells": NF} FOLLOW, CROSS = 0, 1 MODE_NAME = ("FOLLOW", "CROSS") class Grain: """What the metal remembers: how much was cut in each cell, and which way. Direction is stored AXIALLY, as summed (cos 2phi, sin 2phi) weighted by length, because a groove has no arrowhead — it runs both ways at once, and averaging the angles themselves would cancel a groove against itself. """ def __init__(self): self.n = array("I", bytes(4 * NF * NF)) self.c = array("f", bytes(4 * NF * NF)) self.s = array("f", bytes(4 * NF * NF)) @staticmethod def idx(x, y): ix = int((x + P.DISC_R) / CELL_F) iy = int((y + P.DISC_R) / CELL_F) if 0 <= ix < NF and 0 <= iy < NF: return iy * NF + ix return -1 def deposit(self, x, y, c2, s2): i = self.idx(x, y) if i >= 0: self.n[i] += 1 self.c[i] += c2 self.s[i] += s2 def read(self, x, y): """(marked, axial direction, coherence) under a point. Local only.""" return self.read_cell(self.idx(x, y)) def read_cell(self, i): if i < 0 or self.n[i] == 0: return False, 0.0, 0.0 c, s, n = self.c[i], self.s[i], self.n[i] mag = math.hypot(c, s) return True, 0.5 * math.atan2(s, c), mag / n def cross_and_cut(self, x0, y0, x1, y1, psi, rule, disc): """Walk the segment cell by cell: READ each cell, then cut it. Counting crossings has to be done per CELL, never per step. The first version sampled the grain once per step and got a detector whose reading was set entirely by the ratio of the step to the cell: at 1.2 mm it stepped over grooves and undercounted in proportion to 1/step, and at 0.15 mm the probe fell inside the cell the tool was already in, so it read its own fresh mark and counted almost nothing (10 impulses in 720 m). There was no step size at which it measured the metal. Reading before depositing is what makes this honest: the cell has whatever earlier passes left in it and nothing from this one. And because the walk is spaced in millimetres rather than in steps, the number of crossings per millimetre of path no longer depends on the step at all. """ seg = math.hypot(x1 - x0, y1 - y0) if seg <= 1e-12: return 0 phi_seg = math.atan2(y1 - y0, x1 - x0) c2, s2 = math.cos(2.0 * phi_seg), math.sin(2.0 * phi_seg) k = max(1, int(seg / (CELL_F * 0.5)) + 1) events = 0 last = -1 for j in range(k + 1): u = j / k xx, yy = x0 + (x1 - x0) * u, y0 + (y1 - y0) * u disc.cut(xx, yy) i = self.idx(xx, yy) if i < 0 or i == last: continue last = i marked, phi, _ = self.read_cell(i) across = marked and axial_diff(psi, phi) > CHI_C if across and not rule.crossing: events += 1 rule.crossing = across self.n[i] += 1 self.c[i] += c2 self.s[i] += s2 return events def axial_diff(a, b): """Smallest angle between two undirected directions, in [0, pi/2].""" d = abs((a - b) % math.pi) return d if d <= math.pi * 0.5 else math.pi - d def forward_sense(phi, psi): """Which of the groove's two directions keeps the tool going forward.""" return phi if math.cos(phi - psi) >= 0.0 else phi + math.pi class Rule: """The entire rule. Angles in, an angle out. Look at the signature of `decide`: a heading, whether there is a groove underfoot and which way it runs, and the count to flip at. THERE IS NO POSITION ARGUMENT, so no length can enter the decision even by accident. That is the whole reason this rule was the one chosen out of four — any length that shows up in the finished disc has to have been made by the metal, because there is nowhere in here for me to have put one. mode, sign and count ARE the memory. `crossing` is the edge detector of the force impulse — an impulse is an event, and an event needs an edge — so it belongs to the sensor, not to the memory. """ __slots__ = ("mode", "sign", "count", "crossing") def __init__(self): self.mode, self.sign, self.count = CROSS, 1, 0 self.crossing = False def steer(self, psi, marked, phi): """Capture. In YIELD the groove takes the tool; in BREAK nothing does.""" if self.mode == FOLLOW and marked and axial_diff(psi, phi) <= CHI_C: return forward_sense(phi, psi), True return psi, False def tick(self, psi, events, n_impulse): """The counter. Events come from the metal the tool just crossed.""" self.count += events if self.count < n_impulse: return psi, None self.count = 0 if self.mode == CROSS: self.mode = FOLLOW else: self.mode = CROSS # leave the groove at right angles: the crossing that costs the # fewest millimetres per impulse, and the only turn in the rule. psi += self.sign * math.pi * 0.5 self.sign = -self.sign return psi, self.mode class Tool: """Where the machine is and what it has done. The rule is a separate object. Everything on this object is either kinematic state the mechanism needs or a tally kept for the report. None of it is read by `Rule.decide`. """ __slots__ = ("r", "th", "psi", "cut_mm", "follow_mm", "cross_mm", "impulses", "flips", "acc_r", "acc_th", "steps_r", "steps_th", "capped_steps", "centre_crossings", "rail_stops", "rail_exits", "captures", "unheld", "chord_mm", "run_mm", "chords", "runs", "coh_sum", "coh_n") def __init__(self, r, th, psi): self.r, self.th, self.psi = r, th, psi self.cut_mm = self.follow_mm = self.cross_mm = 0.0 self.impulses = self.flips = 0 self.acc_r = self.acc_th = 0.0 self.steps_r = self.steps_th = 0 self.capped_steps = 0 self.centre_crossings = 0 self.rail_stops = 0 self.rail_exits = 0 self.captures = self.unheld = 0 self.chord_mm = self.run_mm = 0.0 # length of the current stretch self.chords = [] # finished BREAK chord lengths self.runs = [] # finished YIELD follow lengths self.coh_sum = 0.0 self.coh_n = 0 def xy(self): return self.r * math.cos(self.th), self.r * math.sin(self.th) def advance(t, rule, disc, grain, ds=None): """Resolve the heading onto the two axes, cut, and record the grain. Same kinematics as polar.advance — signed lever arm, carried remainder, rotary top speed — because the mechanism has not changed. What is new is that the metal keeps the DIRECTION of what was cut, and that the rail's limit is the only thing in this file that ever turns the tool for it. """ step_mm = P.DS if ds is None else ds x0, y0 = t.xy() lever = abs(t.r) dr_want = step_mm * math.cos(t.psi - t.th) denom = t.r if lever > 1e-9 else (1e-9 if t.r >= 0.0 else -1e-9) dth_want = step_mm * math.sin(t.psi - t.th) / denom t.acc_r += dr_want t.acc_th += dth_want n_r = int(t.acc_r / P.D_RADIUS) n_th = int(t.acc_th / P.D_THETA) if P.N_TH_MAX and abs(n_th) > P.N_TH_MAX: n_th = P.N_TH_MAX if n_th > 0 else -P.N_TH_MAX t.capped_steps += 1 t.acc_th = 0.0 t.acc_r -= n_r * P.D_RADIUS t.acc_th -= n_th * P.D_THETA t.steps_r += abs(n_r) t.steps_th += abs(n_th) r_before = t.r t.r += n_r * P.D_RADIUS t.th += n_th * P.D_THETA stopped = False if t.r < P.R_LO: t.r, stopped = P.R_LO, True elif t.r > P.R_HI: t.r, stopped = P.R_HI, True if r_before * t.r < 0.0: t.centre_crossings += 1 x1, y1 = t.xy() seg = math.hypot(x1 - x0, y1 - y0) events = grain.cross_and_cut(x0, y0, x1, y1, t.psi, rule, disc) t.cut_mm += seg # The rail has run out of travel, and the tool may not leave the metal — # it never lifts, that is the whole premise. So the radial component of the # heading REVERSES: psi - th goes to pi - (psi - th), a mirror in the # tangential direction. # # Projecting onto the tangential instead (which is what I wrote first) makes # the rim an absorbing state: the tool traces the rim circle, that circle # never crosses itself at more than the capture angle, so no impulse can # ever fire and the counter can never flip the mode. The first run did # exactly that — 26508 of 75000 steps pinned at r = 195, coverage 0.0066, # one flip in half an hour. The purest possible form of v1's rim trap, with # no error term left to shake it loose. # # Reflection has no length in it, which is the point: v1's rim grain came # from EDGE_MM = 22 mm, a distance I chose, and the controlled test showed # the grain was my authorship. There is nothing to author here. if stopped: t.rail_stops += 1 t.psi = 2.0 * t.th + math.pi - t.psi # And the groove the tip was being carried by ENDS here — the metal # stops at the rim, so nothing is holding the tool any more. Without # this, YIELD has no exit from a groove that goes somewhere and comes # back: retracing crosses nothing, so the counter never advances, and # the machine oscillates along one diameter for ever. The run that # exposed it did 6 m of cutting in 20000 steps and 3 flips in 8 hours. # This is not a new parameter — it is the rail limit already in the # mechanism, read as the end of capture rather than only as a bounce. if rule.mode == FOLLOW: rule.count = 0 rule.mode = CROSS t.psi += rule.sign * math.pi * 0.5 rule.sign = -rule.sign t.rail_exits += 1 return seg, events def step(t, rule, disc, grain, n_impulse, ds=None): """Capture on what is underfoot, cut, then let the crossings drive the count. The order matters. Capture is read at the tool's position, where the groove it is following lives. The crossings are counted along the segment as it is cut, cell by cell, reading each cell before writing to it — so the tool is never counting its own fresh mark, and the count per millimetre no longer depends on the step size. The flip lands at the end of the step, which quantises WHEN it happens by at most one step but leaves HOW MANY crossings it took exact. """ # Capture is read ONE CELL AHEAD along the heading, never under the tool. # Under the tool is the mark the tool itself laid down on the previous step, # so a tip reading its own position follows its own fresh trail: at a 0.3 mm # step that locked the machine into FOLLOW for the whole run (coverage frozen # at 0.0028, 2.4M consecutive captures, coherence 1.000). One cell is the # record's own resolution, so unlike a lookahead in steps it does not change # when the step does. x, y = t.xy() marked, phi, coh = grain.read(x + CELL_F * math.cos(t.psi), y + CELL_F * math.sin(t.psi)) if marked: t.coh_sum += coh t.coh_n += 1 was = rule.mode t.psi, captured = rule.steer(t.psi, marked, phi) if was == FOLLOW: t.captures += 1 if captured else 0 t.unheld += 0 if captured else 1 seg, events = advance(t, rule, disc, grain, ds) t.impulses += events if rule.mode == FOLLOW: t.follow_mm += seg t.run_mm += seg else: t.cross_mm += seg t.chord_mm += seg t.psi, flipped = rule.tick(t.psi, events, n_impulse) if flipped is not None: t.flips += 1 if flipped == FOLLOW: t.chords.append(t.chord_mm) t.chord_mm = 0.0 else: t.runs.append(t.run_mm) t.run_mm = 0.0 return seg def self_test(): """Check the claims this rule rests on, and that it has no length in it.""" ok = True def check(name, got, want, tol=0.0): nonlocal ok good = (abs(got - want) <= tol) if isinstance(want, float) \ else (got == want) ok = ok and good print(" %-56s %12s want %10s %s" % (name, ("%.4f" % got) if isinstance(got, float) else got, ("%.4f" % want) if isinstance(want, float) else want, "ok" if good else "FAIL")) print("the capture angle comes from the tool, not from me") # atan(2d/w) with d from stylus.depth_for_contact_um(w/2): # 0.05 -> d 12.5000, 2d/w 0.500000, atan 26.5651 deg (exactly atan 1/2) # 0.10 -> d 26.9338, 2d/w 0.538675, atan 28.3102 deg # 0.15 -> d 41.3675, 2d/w 0.551567, atan 28.8797 deg check("chi_c at the specified 0.10 mm, deg", math.degrees(CHI_C), 28.3102, 1e-4) check("chi_c at 0.05 mm (spec low), deg", math.degrees(capture_angle_rad(0.05)), 26.5651, 1e-4) check("chi_c at 0.15 mm (spec high), deg", math.degrees(capture_angle_rad(0.15)), 28.8797, 1e-4) check("chi_c widens with the tool, never past 30 deg", 1 if capture_angle_rad(0.05) < CHI_C < capture_angle_rad(0.15) < math.radians(30.0) else 0, 1) check("stylus width is the one the strokes use", st.GROOVE_MM, P.GROOVE) print("axial arithmetic: a groove has no arrowhead") check("axial_diff(10, 170) deg", math.degrees(axial_diff( math.radians(10), math.radians(170))), 20.0, 1e-9) check("axial_diff(0, 90) deg", math.degrees(axial_diff( 0.0, math.radians(90))), 90.0, 1e-9) check("axial_diff is symmetric", axial_diff(1.0, 2.5), axial_diff(2.5, 1.0), 1e-15) check("forward sense keeps cos >= 0", 1 if math.cos(forward_sense(0.2, 3.0) - 3.0) >= 0 else 0, 1) print("the grain stores direction axially, so a groove reinforces itself") g = Grain() for u in range(40): # a straight run at 30 degrees a = math.radians(30.0) g.deposit(50.0 + 0.05 * u * math.cos(a), 50.0 + 0.05 * u * math.sin(a), math.cos(2 * a), math.sin(2 * a)) m, phi, coh = g.read(50.05, 50.03) check("one direction reads back", math.degrees(phi) % 180.0, 30.0, 1e-4) check("one direction is coherent", coh, 1.0, 1e-6) g2 = Grain() for a in (math.radians(30.0), math.radians(120.0)): # a crossing g2.deposit(10.0, 10.0, math.cos(2 * a), math.sin(2 * a)) m2, phi2, coh2 = g2.read(10.0, 10.0) check("two perpendicular grooves cancel to no direction", coh2, 0.0, 1e-6) print("the rule contains no length: steer/tick are angles and counts") # The rule is two methods that take angles and counts. Neither takes a # position, so this drives them with nothing but angles and event counts: # there is no geometry present for a length to hide in. seq = [math.radians(a) if a is not None else None for a in (0, 0, 90, 90, 0, None, 45, 45, 90, 0, 0, 135, None, 90, 45, 0)] def drive(n, rot=0.0): rl, psi, out = Rule(), rot, [] for i in range(160): a = seq[i % len(seq)] marked = a is not None phi = (a + rot) if marked else 0.0 psi, _cap = rl.steer(psi, marked, phi) # the events the metal would hand back: one per fresh crossing across = marked and axial_diff(psi, phi) > CHI_C ev = 1 if (across and not rl.crossing) else 0 rl.crossing = across psi, fl = rl.tick(psi, ev, n) if fl is not None: out.append((i, MODE_NAME[fl], rl.sign)) return out import inspect ps = list(inspect.signature(Rule.steer).parameters)[1:] pt = list(inspect.signature(Rule.tick).parameters)[1:] check("steer() takes only angles", ",".join(ps), "psi,marked,phi") check("tick() takes only counts and an angle", ",".join(pt), "psi,events,n_impulse") check("no argument names a position", sum(1 for q in ps + pt if q in ("x", "y", "r", "th", "pos")), 0) a3 = drive(3) for by in (math.radians(37.0), math.radians(-113.0)): rot = drive(3, rot=by) check("flip schedule invariant under a %+.0f deg rotation" % math.degrees(by), 1 if [x[0] for x in a3] == [x[0] for x in rot] else 0, 1) check("it did flip (the test is not vacuous)", 1 if len(a3) >= 6 else 0, 1) check("modes alternate strictly", 1 if all(x[1] != y[1] for x, y in zip(a3, a3[1:])) else 0, 1) check("sign alternates on every FOLLOW exit", 1 if len({s for _, m, s in a3 if m == "CROSS"}) == 2 else 0, 1) check("a bigger N flips strictly less often", 1 if len(drive(8)) < len(a3) else 0, 1) check("state carried by the rule: mode, sign, count", len(Rule.__slots__) - 1, 3) print("the crossing count is per CELL, so it cannot depend on the step") # one straight virgin run laid down, then crossed at right angles by three # different step sizes: the count must come out the same every time counts = [] for ds in (0.15, 0.3, 0.6, 1.2): g, d = Grain(), P.Disc() rl = Rule() for u in range(2000): # three vertical grooves, 4 mm apart for x0 in (-4.0, 0.0, 4.0): g.cross_and_cut(x0, -20.0 + 0.02 * u, x0, -20.0 + 0.02 * (u + 1), math.pi * 0.5, rl, d) rl2 = Rule() rl2.mode = CROSS n = 0 steps = int(round(24.0 / ds)) for j in range(steps): # cross them horizontally xa = -12.0 + 24.0 * j / steps xb = -12.0 + 24.0 * (j + 1) / steps n += g.cross_and_cut(xa, 0.0, xb, 0.0, 0.0, rl2, d) counts.append(n) check("crossings found at step 0.15/0.3/0.6/1.2 mm", "%d/%d/%d/%d" % tuple(counts), "3/3/3/3") print("\nSELF-TEST %s" % ("PASSED" if ok else "FAILED")) return ok def run(hours=8.0, n_impulse=3, rail="diameter", out_dir="../plate/v2/n3", log_every=2000, omega_max_rpm=P.OMEGA_MAX_RPM, ds=None, cell=None): if cell: set_cell(cell) P.set_rail(rail, omega_max_rpm) step_mm = P.DS if ds is None else ds steps = int(hours * 3600.0 * P.SCRIBE_MM_S / step_mm) disc = P.Disc() grain = Grain() rule = Rule() t = Tool(99.66, 0.0, math.radians(37.0)) pts = np.empty((steps + 1, 2), dtype="float32") pts[0] = t.xy() trace = [] t0 = time.time() for i in range(steps): step(t, rule, disc, grain, n_impulse, ds) pts[i + 1] = t.xy() if (i + 1) % log_every == 0: trace.append({ "step": i + 1, "machine_h": round((i + 1) * step_mm / P.SCRIBE_MM_S / 3600.0, 4), "coverage": round(disc.coverage(), 5), "mode": MODE_NAME[rule.mode], "impulses": t.impulses, "flips": t.flips, "follow_fraction": round(t.follow_mm / max(1e-9, t.cut_mm), 4), "r": round(t.r, 2), }) wall = time.time() - t0 os.makedirs(out_dir, exist_ok=True) np.save(os.path.join(out_dir, "stroke.npy"), pts) chords = [c for c in t.chords if c > 0] runs = [c for c in t.runs if c > 0] def stats(v): if not v: return {"n": 0} a = np.asarray(v) return {"n": int(a.size), "mean_mm": round(float(a.mean()), 3), "median_mm": round(float(np.median(a)), 3), "cv": round(float(a.std() / max(1e-12, a.mean())), 4), "max_mm": round(float(a.max()), 2)} summary = { "rule": "yield/break", "n_impulse": n_impulse, "step_mm": step_mm, "cell_mm": CELL_F, "capture_angle_deg": round(math.degrees(CHI_C), 3), "groove_mm": st.GROOVE_MM, "hours": hours, "steps": steps, "cut_m": round(t.cut_mm / 1000.0, 2), "coverage": round(disc.coverage(), 5), "follow_fraction": round(t.follow_mm / max(1e-9, t.cut_mm), 4), "impulses": t.impulses, "flips": t.flips, "captures": t.captures, "unheld_steps": t.unheld, "rail_stops": t.rail_stops, "rail_exits_from_follow": t.rail_exits, "centre_crossings": t.centre_crossings, "capped_steps": t.capped_steps, "mean_coherence_under_tool": round(t.coh_sum / max(1, t.coh_n), 4), "break_chords": stats(chords), "yield_runs": stats(runs), "r_end": round(t.r, 2), "wall_s": round(wall, 1), **P.set_rail(rail, omega_max_rpm), } with open(os.path.join(out_dir, "summary.json"), "w") as f: json.dump(summary, f, ensure_ascii=False, indent=1) with open(os.path.join(out_dir, "trace.json"), "w") as f: json.dump(trace, f) print(json.dumps(summary, ensure_ascii=False, indent=1)) return summary def main(): ap = argparse.ArgumentParser() ap.add_argument("--self-test", action="store_true") ap.add_argument("--hours", type=float, default=8.0) ap.add_argument("--n", type=int, default=3, help="impulses before a flip") ap.add_argument("--rail", default="diameter", choices=["radius", "diameter"]) ap.add_argument("--out", default=None) ap.add_argument("--log-every", type=int, default=2000) ap.add_argument("--cell", type=float, default=None, help="record resolution mm; control for the sensor's grid") ap.add_argument("--ds", type=float, default=None, help="step length mm; control for the sensor's lookahead") args = ap.parse_args() if args.self_test: raise SystemExit(0 if self_test() else 1) out = args.out or "../plate/v2/n%d" % args.n run(hours=args.hours, n_impulse=args.n, rail=args.rail, out_dir=out, log_every=args.log_every, ds=args.ds, cell=args.cell) if __name__ == "__main__": main()