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.
This commit is contained in:
Sylpheed port agent
2026-09-02 18:34:06 +00:00
parent 88d7656f50
commit 359f0d8bff
3 changed files with 205 additions and 0 deletions

View File

@@ -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.

View File

@@ -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

147
tools/port/fit-trajectory Executable file
View File

@@ -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())