sloptimize 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/motion.js ADDED
@@ -0,0 +1,345 @@
1
+ // ============================================================
2
+ // motion.js — coordinate continuity: the jitter detector (SPEC §3.6)
3
+ // ============================================================
4
+ // The recorder's hitch math is about TIME: a frame that took too long. This
5
+ // module is about SPACE: a tracked point — the player's unit, the camera —
6
+ // that did not arrive where its own motion said it would. The operator sees
7
+ // a hitch as a stutter and a jump as a snap; the second was invisible to
8
+ // every instrument here until it was asked for in as many words ("my player
9
+ // unit/camera's coordinate suddenly jumping instead of smoothly transitioning
10
+ // at every frame").
11
+ //
12
+ // The test is a one-step prediction. With the last two samples p0, p1 at
13
+ // t0, t1, constant velocity predicts p̂ = p1 + (p1 − p0)/(t1 − t0)·(t2 − t1);
14
+ // the residual r = p2 − p̂ is how far the point landed OFF its own trajectory.
15
+ // Smooth motion has residuals of ½·a·dt² — millimetres for anything a physics
16
+ // step accelerates. A frame that took longer moves the point proportionally
17
+ // further and the prediction scales with dt, so a long frame with dt-scaled
18
+ // motion is NOT a jump.
19
+ //
20
+ // A residual alone is a velocity change (a dash starting, a camera boom
21
+ // beginning to ease back out) — legitimate. What makes it a JUMP is that the
22
+ // next frame REVERSES it: a point displaced by d that then continues at its
23
+ // old velocity is predicted d further along and lands d back. So an event is
24
+ // confirmed one frame late, on the reversal; a residual the next frame does
25
+ // not reverse is a change of motion and is not reported (stated limit: a
26
+ // snap that coincides with an equal-and-opposite velocity change reads as
27
+ // one). The reversal is compared as a VELOCITY anomaly (residual ÷ frame
28
+ // time): a jump that lands in a 400ms stall frame is pulled back in the 17ms
29
+ // frame after it by 1/24 of the distance, and in position units that is not
30
+ // a reversal at all.
31
+ //
32
+ // Consecutive events (≤3 frames apart) fold into one BURST: one event is a
33
+ // `snap`, two or more an `oscillation` — the signature of a fixed-step sim
34
+ // drawn at a higher rate without interpolation, or of two writers fighting
35
+ // over one transform. A burst posts as ONE record when it closes (or every
36
+ // 2s while it goes on), rate-limited like hitches so a storm can never be the
37
+ // hitch it reports.
38
+ //
39
+ // HELD frames: the host says when a frame is input-driven (a mouse flick
40
+ // swings a camera boom metres in one frame — intended, not a jump) or paused,
41
+ // and the track re-seeds after it. CUTS: the host says when the view changed
42
+ // on purpose (mode flip, spectate target, respawn); the track re-seeds.
43
+ // Nothing here guesses what the host meant.
44
+ //
45
+ // Pure: no DOM, no three.js; positions are numbers in the host's own unit.
46
+ // Steady path allocates nothing (per-track state is preallocated; strings and
47
+ // objects exist only when a burst closes).
48
+
49
+ const DT_RING = 64;
50
+ const EVENT_RING = 8;
51
+
52
+ /**
53
+ * @param {object} opts
54
+ * @param {Record<string, {floor:number, reach?:string, follows?:string}>} opts.tracks
55
+ * One entry per tracked point. `floor` is the smallest residual (host
56
+ * units) worth calling a jump; `reach` names the optional scalar the host
57
+ * samples beside the point (the camera's distance to its pivot) so a jump
58
+ * that is a change of that distance can say so; `follows` names the track
59
+ * this one is attached to (the camera follows the unit), so a jump shared
60
+ * with it is explained as the passenger's rather than reported twice.
61
+ * @param {number} [opts.ratio=0.25] a residual must also exceed this fraction
62
+ * of the frame's predicted travel — at speed, a pop under a quarter of one
63
+ * frame's motion is not a jump anyone sees
64
+ * @param {string} [opts.unit='u'] unit label for evidence strings
65
+ * @param {() => number} [opts.now] wall clock (ms since epoch) for `at`
66
+ * @param {number} [opts.longFrameMs=100] a frame at least this long cannot have
67
+ * its motion judged: past the host sim's dt clamp the point moves by the
68
+ * clamp while the prediction scales with the wall clock, so every residual
69
+ * in such a frame is the clamp's, not a jump's. Hosts pass their own clamp
70
+ * (mecharoyale: 50ms); the default is the hitch bar
71
+ * @param {number} [opts.burstGapFrames=3]
72
+ * @param {number} [opts.burstMaxMs=2000]
73
+ * @param {number} [opts.maxRecordsPerSession=200]
74
+ * @param {number} [opts.minRecordGapMs=1000]
75
+ */
76
+ export function createMotionMonitor(opts = {}) {
77
+ const now = opts.now ?? (() => Date.now());
78
+ const ratio = opts.ratio ?? 0.25;
79
+ const unit = opts.unit ?? 'u';
80
+ const longFrameMs = opts.longFrameMs ?? 100;
81
+ const burstGapFrames = opts.burstGapFrames ?? 3;
82
+ const burstMaxMs = opts.burstMaxMs ?? 2000;
83
+ const maxRecords = opts.maxRecordsPerSession ?? 200;
84
+ const minGapMs = opts.minRecordGapMs ?? 1000;
85
+
86
+ const tracks = new Map();
87
+ for (const [name, cfg] of Object.entries(opts.tracks ?? {})) {
88
+ if (!cfg || !(cfg.floor > 0)) throw new Error(`motion track "${name}" needs a positive floor`);
89
+ tracks.set(name, newTrack(name, cfg));
90
+ }
91
+
92
+ let records = [];
93
+ let sessionRecords = 0; // the cap is per session, across tracks
94
+ let droppedSinceLast = 0;
95
+ let totalDropped = 0;
96
+
97
+ function newTrack(name, cfg) {
98
+ return {
99
+ name, floor: cfg.floor, reachName: cfg.reach ?? null, follows: cfg.follows ?? null,
100
+ samples: 0, held: 0, cuts: 0, events: 0, bursts: 0,
101
+ // The 1/s gap is PER TRACK: a unit that teleports takes its camera with
102
+ // it in the same frame, and the camera's record is the one that says so
103
+ // (`follows-track`) — a shared gap would drop exactly that record.
104
+ lastRecordAt: -Infinity,
105
+ // The last two samples (p0 older, p1 newer) and their times.
106
+ seeds: 0, p0x: 0, p0y: 0, p0z: 0, p1x: 0, p1y: 0, p1z: 0, t0: 0, t1: 0,
107
+ reach1: NaN, // reach at p1 (NaN = not sampled)
108
+ phase: undefined,
109
+ // Frame-time ring, for the long-frame verdict (median read at event time).
110
+ dts: new Float64Array(DT_RING), dtN: 0, dtHead: 0,
111
+ // The residual awaiting its reversal verdict.
112
+ pend: { active: false, rx: 0, ry: 0, rz: 0, mag: 0, travel: 0, speed: 0, dt: 0, t: 0, wall: 0,
113
+ frame: 0, fromX: 0, fromY: 0, fromZ: 0, toX: 0, toY: 0, toZ: 0, reachBefore: NaN, reachAfter: NaN, phase: undefined, ctx: undefined },
114
+ // The open burst (folds consecutive events).
115
+ burst: { active: false, events: 0, firstWall: 0, lastWall: 0, lastFrame: 0, amplitude: 0, durationMs: 0,
116
+ first: null },
117
+ // Recent event sample-times, for cross-track coincidence.
118
+ eventT: new Float64Array(EVENT_RING), eventN: 0,
119
+ };
120
+ }
121
+
122
+ function reseed(tr, x, y, z, t, reach) {
123
+ tr.seeds = 1;
124
+ tr.p1x = x; tr.p1y = y; tr.p1z = z; tr.t1 = t;
125
+ tr.reach1 = reach;
126
+ tr.pend.active = false;
127
+ }
128
+
129
+ function pushDt(tr, dt) {
130
+ tr.dts[tr.dtHead] = dt;
131
+ tr.dtHead = (tr.dtHead + 1) % DT_RING;
132
+ if (tr.dtN < DT_RING) tr.dtN++;
133
+ }
134
+ function medianDt(tr) {
135
+ if (tr.dtN === 0) return undefined;
136
+ const s = Array.from(tr.dts.subarray(0, tr.dtN)).sort((a, b) => a - b);
137
+ return s[s.length >> 1];
138
+ }
139
+
140
+ function noteEvent(tr, p) {
141
+ tr.events++;
142
+ tr.eventT[tr.eventN % EVENT_RING] = p.t;
143
+ tr.eventN++;
144
+ const b = tr.burst;
145
+ if (!b.active) {
146
+ b.active = true; b.events = 0; b.amplitude = 0;
147
+ b.firstWall = p.wall;
148
+ b.first = { ...p };
149
+ tr.bursts++;
150
+ }
151
+ b.events++;
152
+ b.lastWall = p.wall;
153
+ b.lastFrame = p.frame;
154
+ if (p.mag > b.amplitude) b.amplitude = p.mag;
155
+ }
156
+
157
+ function hadEventAt(tr, t) {
158
+ const n = Math.min(tr.eventN, EVENT_RING);
159
+ for (let i = 0; i < n; i++) if (tr.eventT[i] === t) return true;
160
+ return false;
161
+ }
162
+
163
+ function closeBurst(tr) {
164
+ const b = tr.burst;
165
+ if (!b.active) return;
166
+ b.active = false;
167
+ const f = b.first;
168
+ b.first = null;
169
+ // Rate limits first (the recorder's own contract): silence must mean
170
+ // nothing was dropped, so drops are counted onto the NEXT record.
171
+ if (f.wall - tr.lastRecordAt < minGapMs || sessionRecords >= maxRecords) {
172
+ droppedSinceLast++; totalDropped++;
173
+ return;
174
+ }
175
+ tr.lastRecordAt = f.wall;
176
+ sessionRecords++;
177
+
178
+ const kind = b.events >= 2 ? 'oscillation' : 'snap';
179
+ const durationMs = b.lastWall - b.firstWall;
180
+ const med = medianDt(tr);
181
+ const fx = (n) => +n.toFixed(3);
182
+ const px = (n) => +n.toFixed(2);
183
+ const rec = {
184
+ type: 'jitter',
185
+ at: new Date(f.wall).toISOString(),
186
+ track: tr.name,
187
+ kind,
188
+ frame: f.frame,
189
+ jump: [fx(f.rx), fx(f.ry), fx(f.rz)],
190
+ units: fx(f.mag),
191
+ travelUnits: fx(f.travel),
192
+ speed: +f.speed.toFixed(2),
193
+ dtMs: +f.dt.toFixed(1),
194
+ from: [px(f.fromX), px(f.fromY), px(f.fromZ)],
195
+ to: [px(f.toX), px(f.toY), px(f.toZ)],
196
+ classification: [],
197
+ };
198
+ if (med !== undefined) rec.medianDtMs = +med.toFixed(1);
199
+ if (kind === 'oscillation') {
200
+ rec.frames = b.events;
201
+ rec.durationMs = +durationMs.toFixed(0);
202
+ rec.amplitude = fx(b.amplitude);
203
+ }
204
+ if (f.phase) rec.phase = f.phase;
205
+ if (f.ctx) rec.ctx = f.ctx;
206
+
207
+ // Cross-track: another point jumped in the SAME sample frame. Data on
208
+ // every record; an EXPLANATION only where the host declared the hierarchy.
209
+ const coincident = [];
210
+ for (const other of tracks.values()) if (other !== tr && hadEventAt(other, f.t)) coincident.push(other.name);
211
+ if (coincident.length) rec.coincident = coincident;
212
+ const passenger = tr.follows && coincident.includes(tr.follows);
213
+ // Reach: the host's scalar (camera→pivot distance) across the jump.
214
+ const reachKnown = tr.reachName && Number.isFinite(f.reachBefore) && Number.isFinite(f.reachAfter);
215
+ if (reachKnown) rec.reach = { name: tr.reachName, before: px(f.reachBefore), after: px(f.reachAfter) };
216
+
217
+ // Explanations rank ahead of the kind: the wake line shows one guess, and
218
+ // "the camera moved because its pivot did" is worth more than "snap".
219
+ const cls = rec.classification;
220
+ if (passenger) {
221
+ cls.push({ guess: 'follows-track', confidence: 'high',
222
+ evidence: `jumped in the same frame as ${tr.follows}, which it follows — see that record; this one is the passenger` });
223
+ }
224
+ if (reachKnown) {
225
+ const d = f.reachAfter - f.reachBefore;
226
+ if (Math.abs(d) >= 0.7 * f.mag) {
227
+ cls.push({ guess: 'reach-change', confidence: 'high',
228
+ evidence: `${tr.reachName} ${px(f.reachBefore)}→${px(f.reachAfter)}${unit} (Δ${d >= 0 ? '+' : ''}${fx(d)} ≈ the ${fx(f.mag)}${unit} jump): the point's distance to its anchor changed — a clamp or a zoom, not a teleport` });
229
+ }
230
+ }
231
+ // A frame past the sim's clamp owns every residual in it — an alternation
232
+ // across such frames is the clamp meeting a wobbling frame time, not two
233
+ // writers — so the long-frame verdict ranks ahead of the kind.
234
+ if (f.dt >= longFrameMs || (med !== undefined && f.dt >= 2 * med && f.dt >= 50)) {
235
+ const rel = med !== undefined ? ` (${(f.dt / med).toFixed(1)}× the ${med.toFixed(1)}ms median)` : '';
236
+ const shape = kind === 'oscillation' ? `${b.events} reversals starting in` : 'landed in';
237
+ cls.push({ guess: 'long-frame-catch-up', confidence: 'medium',
238
+ evidence: `${shape} a ${f.dt.toFixed(0)}ms frame${rel}, past the ${longFrameMs}ms the sim integrates against the clock: a dt clamp across a stall reads as a jump — the stall is the incident` });
239
+ } else if (kind === 'oscillation') {
240
+ cls.push({ guess: 'oscillation', confidence: 'high',
241
+ evidence: `${b.events} reversals over ${durationMs.toFixed(0)}ms, amplitude ${fx(b.amplitude)}${unit} — a fixed-step sim drawn without interpolation, or two writers fighting over one transform` });
242
+ } else {
243
+ cls.push({ guess: 'snap', confidence: 'high',
244
+ evidence: `${fx(f.mag)}${unit} off its trajectory in one ${f.dt.toFixed(1)}ms frame (expected ${fx(f.travel)}${unit} of travel at ${f.speed.toFixed(2)}${unit}/s); motion resumed from the new place` });
245
+ }
246
+ if (droppedSinceLast > 0) { rec.droppedSinceLast = droppedSinceLast; droppedSinceLast = 0; }
247
+ records.push(rec);
248
+ }
249
+
250
+ return {
251
+ /**
252
+ * One rendered frame's position for `track`, at host time `t` (ms, the
253
+ * same clock every frame — performance.now()). `meta.held` marks a frame
254
+ * whose motion is not the track's own to judge (look input, pause);
255
+ * `meta.reach` is the optional anchor distance; `meta.phase` and
256
+ * `meta.ctx` (the host's canonical situation string, SPEC §3.7) stamp the
257
+ * record. Zero-allocation unless a burst closes.
258
+ */
259
+ sample(track, x, y, z, t, meta) {
260
+ const tr = tracks.get(track);
261
+ if (!tr) throw new Error(`unknown motion track "${track}"`);
262
+ tr.samples++;
263
+ const reach = meta && typeof meta.reach === 'number' ? meta.reach : NaN;
264
+ const phase = meta ? meta.phase : undefined;
265
+ if (meta && meta.held) {
266
+ tr.held++;
267
+ closeBurst(tr);
268
+ reseed(tr, x, y, z, t, reach);
269
+ return;
270
+ }
271
+ if (tr.seeds === 0) { reseed(tr, x, y, z, t, reach); return; }
272
+ const dt2 = t - tr.t1;
273
+ if (tr.seeds === 1) {
274
+ if (dt2 <= 0) { reseed(tr, x, y, z, t, reach); return; }
275
+ pushDt(tr, dt2);
276
+ tr.p0x = tr.p1x; tr.p0y = tr.p1y; tr.p0z = tr.p1z; tr.t0 = tr.t1;
277
+ tr.p1x = x; tr.p1y = y; tr.p1z = z; tr.t1 = t; tr.reach1 = reach;
278
+ tr.seeds = 2;
279
+ return;
280
+ }
281
+ const dt1 = tr.t1 - tr.t0;
282
+ if (dt1 <= 0 || dt2 <= 0) { closeBurst(tr); reseed(tr, x, y, z, t, reach); return; }
283
+ pushDt(tr, dt2);
284
+
285
+ const vx = (tr.p1x - tr.p0x) / dt1, vy = (tr.p1y - tr.p0y) / dt1, vz = (tr.p1z - tr.p0z) / dt1;
286
+ const speed = Math.sqrt(vx * vx + vy * vy + vz * vz);
287
+ const travel = speed * dt2;
288
+ const rx = x - (tr.p1x + vx * dt2), ry = y - (tr.p1y + vy * dt2), rz = z - (tr.p1z + vz * dt2);
289
+ const mag = Math.sqrt(rx * rx + ry * ry + rz * rz);
290
+
291
+ // The verdict on the PREVIOUS residual: reversed by this one? Compared
292
+ // as velocity anomalies (residual ÷ dt), see the header.
293
+ const p = tr.pend;
294
+ if (p.active) {
295
+ const dot = p.rx * rx + p.ry * ry + p.rz * rz;
296
+ const scaled = mag * (p.dt / dt2);
297
+ // Anti-parallel (cos ≤ −0.5) and at least half the size.
298
+ const reversed = dot < 0 && scaled >= 0.5 * p.mag && dot * dot >= 0.25 * p.mag * p.mag * mag * mag;
299
+ if (reversed) noteEvent(tr, p);
300
+ p.active = false;
301
+ }
302
+ // This residual as the next candidate.
303
+ if (mag > Math.max(tr.floor, ratio * travel)) {
304
+ p.active = true;
305
+ p.rx = rx; p.ry = ry; p.rz = rz; p.mag = mag;
306
+ p.travel = travel; p.speed = speed * 1000; p.dt = dt2; p.t = t; p.wall = now();
307
+ p.frame = tr.samples;
308
+ p.fromX = tr.p1x; p.fromY = tr.p1y; p.fromZ = tr.p1z;
309
+ p.toX = x; p.toY = y; p.toZ = z;
310
+ p.reachBefore = tr.reach1; p.reachAfter = reach;
311
+ p.phase = phase;
312
+ p.ctx = meta ? meta.ctx : undefined;
313
+ }
314
+ // Burst bookkeeping: closed by silence or by age.
315
+ const b = tr.burst;
316
+ if (b.active) {
317
+ if (tr.samples - b.lastFrame > burstGapFrames + 1 || now() - b.firstWall >= burstMaxMs) closeBurst(tr);
318
+ }
319
+
320
+ tr.p0x = tr.p1x; tr.p0y = tr.p1y; tr.p0z = tr.p1z; tr.t0 = tr.t1;
321
+ tr.p1x = x; tr.p1y = y; tr.p1z = z; tr.t1 = t; tr.reach1 = reach;
322
+ },
323
+
324
+ /** The host changed the view on purpose (mode flip, spectate target,
325
+ * respawn, session boundary): the track(s) forget their trajectory. An
326
+ * open burst closes as it stands. */
327
+ cut(track) {
328
+ const list = track ? [tracks.get(track)].filter(Boolean) : [...tracks.values()];
329
+ for (const tr of list) { tr.cuts++; closeBurst(tr); tr.seeds = 0; tr.pend.active = false; }
330
+ },
331
+
332
+ /** Hand back accumulated records and clear — the host owns transport. */
333
+ drainRecords() { const r = records; records = []; return r; },
334
+
335
+ /** Counters for probes: did the instrument see anything at all? */
336
+ stats() {
337
+ const out = { records: sessionRecords, dropped: totalDropped, tracks: {} };
338
+ for (const tr of tracks.values()) {
339
+ out.tracks[tr.name] = { samples: tr.samples, held: tr.held, cuts: tr.cuts, events: tr.events, bursts: tr.bursts,
340
+ pending: tr.pend.active, burstOpen: tr.burst.active };
341
+ }
342
+ return out;
343
+ },
344
+ };
345
+ }