From 359f0d8bff9f5e56236def7882fbee62b9ab80d2 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Wed, 2 Sep 2026 18:34:06 +0000 Subject: [PATCH] port: fit-trajectory -- position carries the clock, alpha carries almost nothing Answering the human's 'what is holding you up': two agents spent three exchanges on ALPHA while the answer sat in a POSITION series neither of us compared to anything. The arithmetic that should have been step one: the Decoder's quoted sweep centre -1.690 read as NDC is -441.6 px, and the declared position at leaf t=0 is -439.5 px. A 2 px agreement on a 2160 px travel -- so the game's sweep begins travelling at leaf t~0, the same as the port. That CONTRADICTS my own earlier framing on this page, which blamed ~135 units on the leaf clock. If the game's leaf clock also starts at 0 then F6 is a visibility question, not a clock question. Flagged rather than rewritten: it rests on two numbers relayed in a message, which is what should be read from the repo instead. The tool: solves x(frame) ~= declared(t0 + rate*frame) for both parameters and reports the RESIDUAL, which says whether the model was right at all. Selftest runs both directions -- recovers a known clock to 0.09 px and rejects a wrong-shape series at 81.9 px against a 20 px bar. In check-all. --- docs/port/f6-what-starts-the-sweep.md | 57 ++++++++++ tools/port/check-all | 1 + tools/port/fit-trajectory | 147 ++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100755 tools/port/fit-trajectory diff --git a/docs/port/f6-what-starts-the-sweep.md b/docs/port/f6-what-starts-the-sweep.md index 19d9afe7..a397279f 100644 --- a/docs/port/f6-what-starts-the-sweep.md +++ b/docs/port/f6-what-starts-the-sweep.md @@ -193,3 +193,60 @@ been burned before β€” and the port would be changing a renderer rule on it. `screen_view.gd` keeps its current behaviour until the row is identified by travel direction. **This is not scepticism about the conclusion**, which the port's own flagged limit predicted would fall this way; it is about which row. + +--- + +# πŸ“Œ What was actually holding this up: we both picked the wrong observable + +The human asked what was blocking us and whether the approach needed revising. +**It did, and the diagnosis is short: two agents spent three exchanges on ALPHA +while the answer sat in a POSITION series neither of us compared to anything.** + +## The arithmetic that should have been step one + +The Decoder's capture reports the sweep's x-centre running `βˆ’1.690 β†’ +0.500`. +Read as NDC (`x_ndc = 2Β·x_px/W βˆ’ 1`, **an assumption, stated as one**): + +| | ndc | centre px | implied leaf t | +|---|---|---|---| +| first sample | βˆ’1.690 | βˆ’441.6 | **βˆ’0.5** | +| last sample | +0.500 | +960.0 | 349.9 | +| **declared at leaf t=0** | **βˆ’1.687** | **βˆ’439.5** | β€” | + +**A 2 px agreement on a 2 160 px travel.** So the game's sweep begins travelling +at leaf tβ‰ˆ0, from off-screen left β€” **the same as the port.** + +πŸ”΄ **That contradicts my own earlier framing on this page**, which attributed +~135 units of the earliness to "the leaf clock starting at title t=0 with no +offset". If the game's leaf clock also starts at 0, that is not a defect and F6 +is a **visibility** question β€” alpha, or draw order, or something not yet named β€” +rather than a clock question. I am flagging it rather than rewriting the section: +this rests on two numbers relayed in a message, which is exactly the thing that +should be read from the repository instead. + +## Why alpha was the wrong tool, stated generally + +| | alpha | position | +|---|---|---| +| dynamic range | 8 bits, quantised | **2 160 px** | +| shape | non-monotone, ramps and holds | **monotone** | +| failure mode that bit us | a 14-sample tail out of 1754 looks like signal | a wrong shape raises the residual | +| yields the clock? | no | **origin AND rate together** | + +**When something moves, its position carries the clock and its alpha carries +almost nothing.** Neither of us reached for a trajectory comparison because +neither of us had one. + +## So: `tools/port/fit-trajectory` + +Solves `x_measured(frame) β‰ˆ declared(t0 + rateΒ·frame)` for the pair, and reports +the **residual**, which is the part that matters: it says whether the model was +right at all, where a value-at-an-instant never can. + +Its `--selftest` runs both directions β€” recovers a known clock from a synthesised +series to 0.09 px, and **rejects** a wrong-shape series at 81.9 px against a 20 px +bar β€” because a fit that cannot fail is a curve-fitter, not a measurement. Wired +into `check-all`. + +⚠️ It fits a **constant** rate. A stalling guest clock or uneven capture drops +raise the residual rather than being absorbed, which is deliberate. diff --git a/tools/port/check-all b/tools/port/check-all index 8050e866..007bb095 100755 --- a/tools/port/check-all +++ b/tools/port/check-all @@ -149,6 +149,7 @@ step decisions-index must-pass tools/port/index-decisions --check # monorepo move and the `export/` rename. Only that class fails; a citation that # is merely on a peer's unmerged branch is reported, because the fix is a merge # and nobody in this container can make it. +step trajectory-fit must-pass tools/port/fit-trajectory --selftest step doc-citations must-pass tools/port/check-citations step citations-control must-pass tools/port/check-citations --selftest # A refuted claim asserted outside its correction is a lie the corpus tells a diff --git a/tools/port/fit-trajectory b/tools/port/fit-trajectory new file mode 100755 index 00000000..26f6ffe7 --- /dev/null +++ b/tools/port/fit-trajectory @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Align a MEASURED trajectory against a DECLARED one, and solve for the clock. + + tools/port/fit-trajectory SCREEN.json ELEMENT measured.tsv + tools/port/fit-trajectory --selftest + +πŸ”΄ WHY THIS EXISTS. F6 asked "when does the title sweep start?" Both agents spent +three exchanges on ALPHA -- a bound, a refutation, a downgrade -- and the answer +was sitting in a POSITION series nobody compared to anything. + +Alpha is the weakest observable we have: 8 bits, quantised, and the argument that +collapsed did so on a 14-sample tail out of 1754, where a vertex-grouping slip +looks exactly like a signal. The sweep's POSITION travels 2 160 px, monotonically, +and is immune to every one of those failure modes. + +πŸ“Œ The generalisation, which is the reusable part: **when something moves, its +POSITION carries the clock and its ALPHA carries almost nothing.** A trajectory +fit yields the clock ORIGIN and the RATE together, and its residual says whether +the model was right at all -- which a value-at-an-instant never does. This is +`TEMPORAL-VERIFICATION.md`'s "align by content" made into arithmetic. + +WHAT IT DOES NOT DO. It fits t = t0 + rate * frame, i.e. a constant rate. If the +guest's clock stalls or the capture drops frames unevenly, the residual rises and +the tool says so rather than absorbing it -- that is the point of reporting RMS +rather than just the parameters. +""" +import json +import sys + + +def declared_track(path, element_id): + """[(t, x)] for an element, preferring its leaf -- the leaf is what moves.""" + screen = json.load(open(path)) + + def find(elements): + for el in elements: + if el.get("id") == element_id: + return el + for sub in ("leaf", "focus"): + inner = el.get(sub, {}).get("elements", []) + for fe in inner: + if fe.get("id") == element_id: + return fe + return None + + el = find(screen["elements"]) + if el is None: + raise SystemExit("no element %r in %s" % (element_id, path)) + track = [(float(k["t"]), float(k["pos"][0])) + for k in el.get("keyframes", []) if k.get("pos")] + if len(track) < 2: + raise SystemExit("%s declares no positional keyframes" % element_id) + return sorted(track) + + +def at(track, t): + if t <= track[0][0]: + return track[0][1] + if t >= track[-1][0]: + return track[-1][1] + for (t0, x0), (t1, x1) in zip(track, track[1:]): + if t0 <= t <= t1: + return x0 + (x1 - x0) * (t - t0) / (t1 - t0) + return track[-1][1] + + +def fit(track, series): + """Solve x_measured(frame) ~= declared(t0 + rate*frame). Coarse then refine. + + A grid rather than a gradient because the declared track is piecewise linear + and its corners make the residual non-smooth -- a gradient walks into one and + reports a corner as an optimum. + """ + span = track[-1][0] - track[0][0] + frames = [f for f, _ in series] + width = max(frames) - min(frames) or 1.0 + best = None + lo_r, hi_r, lo_t, hi_t = 1e-4, 20.0 * span / width, -span, span + for _ in range(4): + for i in range(60): + rate = lo_r + (hi_r - lo_r) * i / 59.0 + for j in range(60): + t0 = lo_t + (hi_t - lo_t) * j / 59.0 + err = sum((x - at(track, t0 + rate * f)) ** 2 for f, x in series) + if best is None or err < best[0]: + best = (err, t0, rate) + _, t0, rate = best + dr, dt = (hi_r - lo_r) / 20.0, (hi_t - lo_t) / 20.0 + lo_r, hi_r, lo_t, hi_t = rate - dr, rate + dr, t0 - dt, t0 + dt + err, t0, rate = best + return t0, rate, (err / len(series)) ** 0.5 + + +def main(): + if "--selftest" in sys.argv: + # πŸ”΄ A FIT THAT CANNOT FAIL IS A CURVE-FITTER, NOT A MEASUREMENT. + # Two directions: a series SYNTHESISED from the track with a known clock + # must be recovered, and a series that is not this element's motion at + # all must produce a large residual rather than a confident wrong clock. + track = [(0.0, -639.0), (150.0, -39.0), (540.0, 1521.0)] + true_t0, true_rate = 37.0, 0.31 + good = [(f, at(track, true_t0 + true_rate * f)) for f in range(0, 900, 7)] + t0, rate, rms = fit(track, good) + ok_recover = abs(t0 - true_t0) < 1.0 and abs(rate - true_rate) < 0.01 and rms < 1.0 + print("selftest recover : t0=%.2f (true %.2f) rate=%.4f (true %.4f) rms=%.3f px -> %s" + % (t0, true_t0, rate, true_rate, rms, "ok" if ok_recover else "πŸ”΄ BROKEN")) + # The negative: a quadratic sweep is NOT this piecewise-linear travel. + bad = [(f, -639.0 + 0.0027 * f * f) for f in range(0, 900, 7)] + _, _, rms_bad = fit(track, bad) + ok_reject = rms_bad > 20.0 + print("selftest reject : wrong-shape series rms=%.1f px (needs >20) -> %s" + % (rms_bad, "ok" if ok_reject else "πŸ”΄ BROKEN -- it fits anything")) + return 0 if (ok_recover and ok_reject) else 2 + + if len(sys.argv) < 4: + raise SystemExit(__doc__) + track = declared_track(sys.argv[1], sys.argv[2]) + series = [] + for line in open(sys.argv[3]): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.replace(",", " ").split() + series.append((float(parts[0]), float(parts[1]))) + if len(series) < 3: + raise SystemExit("need at least 3 measured samples") + t0, rate, rms = fit(track, series) + print("declared track : %s t=%.0f..%.0f x=%.0f..%.0f" + % (sys.argv[2], track[0][0], track[-1][0], track[0][1], track[-1][1])) + print("measured : %d samples, frames %.0f..%.0f" + % (len(series), series[0][0], series[-1][0])) + print() + print(" clock origin t0 = %+.2f units at frame 0" % t0) + print(" clock rate = %.4f units per frame" % rate) + print(" residual (RMS) = %.2f px over a %.0f px travel" + % (rms, track[-1][1] - track[0][1])) + print() + if rms > 20.0: + print("πŸ”΄ residual is large -- a constant-rate model does not describe this") + print(" series, so t0 and rate above are a best fit to the wrong shape.") + return 1 + print("the measured motion IS this declared track, on that clock") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())