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.
@@ -0,0 +1,236 @@
1
+ # Coordinate jitter and the issue catalogue — the integrator's cookbook
2
+
3
+ This is the shortest complete path from "my game has a recorder feed"
4
+ (INTEGRATION.md §1) to: **every snap of the player's unit or camera lands as
5
+ a classified record, every incident of every kind carries a footprint naming
6
+ its cause and the game's situation, and the debugger's Issues tab shows what
7
+ keeps happening and what was done about it.** Everything here is framework-
8
+ agnostic; the reference implementation is mecharoyale (three.js/WebGPU), and
9
+ each step names the file to copy from.
10
+
11
+ Two things in the package do the work; the game supplies four small answers.
12
+
13
+ | The package gives you | You tell it |
14
+ |---|---|
15
+ | `createMotionMonitor` — the jitter detector (SPEC §3.6) | two points per rendered frame, which frames are not its to judge, when the view cut on purpose, your sim's dt clamp |
16
+ | `footprintOf` + `canonicalContext` + `buildIssues` — footprints and the catalogue (SPEC §3.7) | a few facets of the player's situation, refreshed once a second |
17
+
18
+ ---
19
+
20
+ ## 1. What a jitter is, and why the naive detector fails
21
+
22
+ The recorder's hitches are about **time**: a frame that took too long. The
23
+ other thing a player feels is about **space**: the unit or the camera landing
24
+ somewhere its own motion did not predict. The detector is a one-step
25
+ constant-velocity prediction from the last two samples; the residual
26
+ `r = p − p̂` is how far the point landed off its trajectory (½·a·dt² for smooth
27
+ motion — millimetres). A residual is only a **jump** when the next frame
28
+ **reverses** it; an un-reversed residual is a change of motion (a dash, a
29
+ boom easing) and is never reported.
30
+
31
+ Three things will make a naive position track lie to you, and the design
32
+ answers each:
33
+
34
+ 1. **Rotation.** A camera on a boom swings metres in one frame when the
35
+ mouse flicks: 16° per frame on a 10 m boom is 2.9 m of travel that is
36
+ entirely intended. Do not feed a camera's raw world position and expect
37
+ silence. Feed the **pivot** the view is arranged around (the unit) — it
38
+ is rotation-invariant by construction — and feed the eye **held** on any
39
+ frame that consumed look or zoom input.
40
+ 2. **Transient offsets.** A camera shake applied around the render and
41
+ restored after it is a per-frame random offset by design. Sample **after
42
+ the render**, once the transient is gone, so what you feed is what was
43
+ drawn.
44
+ 3. **The dt clamp.** Every game clamps the delta its sim integrates with
45
+ (mecharoyale: 50 ms). Past the clamp the point moves by the clamp while
46
+ the wall clock moved further, so the prediction is wrong for the frame's
47
+ whole length — the detector must know the clamp (`longFrameMs`) to file
48
+ those as `long-frame-catch-up` (the stall is the incident) instead of
49
+ `snap`. Measured on a software rasteriser drawing a frame every 1.5 s:
50
+ without it every frame was a "snap".
51
+
52
+ ---
53
+
54
+ ## 2. Feed the detector
55
+
56
+ ```js
57
+ import { createMotionMonitor } from 'sloptimize';
58
+
59
+ const motion = createMotionMonitor({
60
+ unit: 'm', // your world unit, for evidence strings
61
+ longFrameMs: MAX_SIM_STEP_S * 1000, // YOUR sim's dt clamp — never a guess
62
+ tracks: {
63
+ unit: { floor: 0.1 }, // the view pivot
64
+ camera: { floor: 0.1, reach: 'boom', follows: 'unit' }, // the eye
65
+ },
66
+ });
67
+ ```
68
+
69
+ - `floor` is the smallest off-trajectory displacement worth a record, in
70
+ your units. Pick the smallest pop a player can see at your camera
71
+ distances and comfortably above ½·a·dt² for your fastest acceleration
72
+ (120 m/s² at 60 fps is 1.7 cm; mecharoyale uses 10 cm).
73
+ - `reach` names the scalar you sample beside the camera — its distance to
74
+ the pivot — so a jump that is a boom clamp or a zoom is explained as
75
+ `reach-change`, not a teleport.
76
+ - `follows` declares the hierarchy: when the camera jumps in the same frame
77
+ as the unit it follows, its record is filed `follows-track` (the
78
+ passenger) and does not wake anyone twice.
79
+
80
+ Once per **rendered** frame, after the render, with the same clock every
81
+ frame:
82
+
83
+ ```js
84
+ const source = viewPivot(camera.position, pivot); // who published the pivot this frame
85
+ const key = `${source}|${thirdPerson ? 'tps' : 'fp'}|${spectateTargetId}`;
86
+ if (key !== lastKey) { lastKey = key; motion.cut(); } // an intentional discontinuity
87
+
88
+ const off = paused || !continuityExpected(phase); // covered tab, boot, a cinematic that cuts
89
+ motion.sample('unit', pivot.x, pivot.y, pivot.z, now, { held: off || source === FALLBACK, phase, ctx });
90
+ motion.sample('camera', cam.x, cam.y, cam.z, now, { held: off || lookInputSinceLastSample, reach: dist(cam, pivot), phase, ctx });
91
+ ```
92
+
93
+ Rules the reference keeps, each learned the hard way:
94
+
95
+ - **Cuts are derived, not declared.** Compose a view-configuration key from
96
+ everything about the camera that is not motion — who publishes the pivot
97
+ (foot/hull/spectate), first vs third person, the spectated target — and
98
+ `cut()` whenever it changes. Mode flips, boarding, dismount, death, respawn
99
+ and spectate cycling all change it; no call site has to remember.
100
+ - **Held ≠ cut.** `held` is a frame the track must not judge (input-driven,
101
+ paused, a phase with no continuous view); the track re-seeds after it.
102
+ Both are counted in `stats()`.
103
+ - **The pivot fallback is not the unit.** When no camera published a pivot
104
+ and the camera stood in, hold the unit track — judging it would judge the
105
+ camera twice.
106
+ - **Drain beside the recorder**, same pipe, same ledger:
107
+ `post('records', [...rec.drainRecords(), ...motion.drainRecords()])`.
108
+ - **Rate limit is per track, 1/s, 200/session**, drops counted onto the next
109
+ record. A unit that teleports takes its camera with it in the same frame;
110
+ a shared gap would drop exactly the camera's explanatory record.
111
+
112
+ Reference: `packages/client/src/dev/sloptimize-motion-feed.ts` (policy +
113
+ plumbing, ~150 lines, unit-tested with a fake camera and pivot), called from
114
+ the runtime's counter site.
115
+
116
+ ### The record
117
+
118
+ ```json
119
+ { "type": "jitter", "at": "…", "track": "unit", "kind": "snap",
120
+ "jump": [-16.25, 5.55, 3.58], "units": 17.54, "travelUnits": 1.36, "speed": 33.25,
121
+ "dtMs": 41.0, "medianDtMs": 16.7, "from": [x,y,z], "to": [x,y,z],
122
+ "coincident": ["camera"], "reach": { "name": "boom", "before": 15.7, "after": 15.7 },
123
+ "classification": [{ "guess": "snap", "confidence": "high", "evidence": "…" }],
124
+ "phase": "play", "ctx": "combat=yes,hull=droyd-g,squad=duo,stance=helm,view=tps",
125
+ "footprint": { "v": 1, "id": "b5b203ba", "key": "jitter|unit|snap|play|snap|horizontal|ctx:…" } }
126
+ ```
127
+
128
+ Verdicts (closed): `snap` · `oscillation` (≥2 reversals in one burst — a
129
+ fixed-step sim drawn without interpolation, or two writers on one transform)
130
+ · `long-frame-catch-up` · `follows-track` · `reach-change`. The watcher wakes
131
+ on the first two only.
132
+
133
+ ---
134
+
135
+ ## 3. Declare the situation
136
+
137
+ Time is not part of an issue's identity; the game's **state** is. Declare a
138
+ handful of **low-cardinality** facets — categories, never positions or
139
+ counters — and hand the canonical string along:
140
+
141
+ ```js
142
+ import { canonicalContext } from 'sloptimize';
143
+
144
+ function situation() { // read ONCE A SECOND, never per frame
145
+ return {
146
+ stance: dead ? 'dead' : aboard ? (atHelm ? 'helm' : 'crew') : 'foot',
147
+ hull: aboard ? frameName(aboard) : 'none',
148
+ squad: squadSize >= 3 ? 'trio' : squadSize === 2 ? 'duo' : 'solo',
149
+ view: thirdPerson ? 'tps' : 'fp',
150
+ combat: now - lastHitAt <= 10_000 ? 'yes' : 'no',
151
+ };
152
+ }
153
+ let ctx = '';
154
+ setInterval(() => { ctx = canonicalContext(situation()); }, 1000); // "combat=no,hull=none,squad=solo,stance=foot,view=fp"
155
+
156
+ rec.frame({ …, phase, ctx }); // hitches carry it from mint
157
+ rec.usermark({ …, phase, ctx });
158
+ motion.sample(track, x, y, z, now, { …, phase, ctx });
159
+ ```
160
+
161
+ A facet that varies per frame makes every incident its own issue and turns
162
+ the catalogue back into a log. Values with `|`, `,`, `=` or whitespace are
163
+ scrubbed; keys are sorted; the string is what gets hashed.
164
+
165
+ Reference: `packages/client/src/dev/sloptimize-situation.ts` (pure rules,
166
+ every fact injected, unit-tested) wired as `context` on the runtime.
167
+
168
+ ---
169
+
170
+ ## 4. Stamp every record at post
171
+
172
+ The writer stamps the identity; every reader then agrees without
173
+ re-deriving — the watcher's `×N`, the Issues tab, a service deduping many
174
+ clients:
175
+
176
+ ```js
177
+ import { footprintOf } from 'sloptimize';
178
+
179
+ for (const r of records) {
180
+ if (r.build === undefined) r.build = buildStamp();
181
+ if (r.phase === undefined) r.phase = currentPhase();
182
+ if (navigator.webdriver && r.automated === undefined) r.automated = true; // robots never wake anyone
183
+ if (r.ctx === undefined && ctx) r.ctx = ctx; // host-built records take the current situation
184
+ const fp = footprintOf(r); if (fp) r.footprint = fp;
185
+ }
186
+ ```
187
+
188
+ What goes into a footprint key, per type, and what never does, is SPEC §3.7.
189
+ Records written before footprints existed are derived at read time, so the
190
+ catalogue reaches back over the whole ledger. Changing what enters a key is a
191
+ new `FOOTPRINT_VERSION`, never a silent reshuffle.
192
+
193
+ ---
194
+
195
+ ## 5. Read it back
196
+
197
+ - **`sloptimize issues [--fp <id>] [--from … --to …] [--all]`** — every
198
+ incident type grouped by footprint: `×N`, first/last, `last 3h ago`,
199
+ builds, worst, the last verdict, the fixes applied. `report` shows the
200
+ top five.
201
+ - **`sloptimize watch`** — every wake line ends `fp=<id> ×N`, the count
202
+ being this ledger's own (seeded from the file at arm). ×1 is new; ×40 is
203
+ the same issue again.
204
+ - **The debugger's Issues tab** — `createPanel` renders it from the same
205
+ `history()` callback the Timeline uses (`{ records, fixes }` from your
206
+ dev-gated ledger read-back); no extra host work. Rows by frequency with
207
+ `×N` and last-seen and the situation as chips; a row opens its history and
208
+ its fixes. Session rows show their `fp` when the host's incident rows
209
+ carry one (`{ …, fp, label, glyph }`).
210
+ - **Linking fixes** — `sloptimize fix propose --footprints <id>,<id> …` (and
211
+ `sloptimize fix`). The catalogue answers "which fixes were applied to this
212
+ issue" by that join; the fix's measured before/after says whether it
213
+ landed.
214
+
215
+ Host-side ranges: the in-page tab folds only the ledger tail the server
216
+ hands back (the reference serves 2 MB, a few thousand records); the CLI folds
217
+ the whole file.
218
+
219
+ ---
220
+
221
+ ## 6. Checklist
222
+
223
+ - [ ] `createMotionMonitor` with `longFrameMs` = your sim's dt clamp and a
224
+ `floor` you can defend
225
+ - [ ] `unit` = the view pivot (rotation-invariant), `camera` = the eye held
226
+ on look/zoom input, `reach` = distance to the pivot, `follows: 'unit'`
227
+ - [ ] sample after the render, same clock every frame; hold paused frames
228
+ and phases without a continuous view; cut on a derived view key
229
+ - [ ] drain beside the recorder, same post
230
+ - [ ] `context()` with ≤ 6 categorical facets; `canonicalContext` once a
231
+ second; `ctx` on `frame`, `usermark`, `sample`
232
+ - [ ] at post: `build`, `phase`, `automated`, `ctx`, `footprint`
233
+ - [ ] incident rows for the panel carry `fp` (and `label`/`glyph` for
234
+ non-millisecond rows)
235
+ - [ ] watcher armed; `sloptimize issues` in the agent's playbook; fixes
236
+ recorded with `--footprints`
@@ -0,0 +1,176 @@
1
+ # sloptimize v2 — the incident pipeline (supersedes the first attach draft)
2
+
3
+ Status: draft for approval. Amends SPEC.md. Rewritten after the operator
4
+ corrected the frame twice, and both corrections are load-bearing:
5
+
6
+ 1. *"Your job is to report data to Claude Code so it can proceed with
7
+ optimization — remember which gaps you're filling."*
8
+ 2. *"It logs incidents automatically in the background; the debugger is
9
+ optional and shows the list; all of it is fed to Claude Code."*
10
+
11
+ ---
12
+
13
+ ## 0. Mission, restated as the contract
14
+
15
+ The agent cannot watch, cannot localize, cannot verify. sloptimize is the
16
+ agent's senses and ruler — nothing more. Every feature below must resolve
17
+ to one of the three gaps, or it is scope creep:
18
+
19
+ | gap | what fills it |
20
+ |---|---|
21
+ | MEASURE — the agent will never feel a hitch | incidents recorded automatically, before anyone asks |
22
+ | ATTRIBUTE — "it stutters" is not a work item | every incident carries its classification, evidence, and (by tier) stacks/entities |
23
+ | VERIFY — an unmeasured fix is a hypothesis | exact counters, before/after windows, budgets with exit codes |
24
+
25
+ The tool decides what is true; the agent decides what to try; the human
26
+ plays. Any design that asks the human to operate instruments, or asks the
27
+ agent to trust prose, violates the contract.
28
+
29
+ ## 1. First principles: the incident
30
+
31
+ An **incident** is a frame (or run of frames) where time went somewhere
32
+ the frame needed. There are only four somewheres, each with the one
33
+ instrument that names it:
34
+
35
+ | where | field example | naming instrument | tier |
36
+ |---|---|---|---|
37
+ | main-thread JS | warm sweep, 165–305ms | sampling JS profile over the window → function names | 0 |
38
+ | GPU process | 8.4s page-load freeze (no rAF, no long task, no counter) | queue latency + creation ledger with call stacks | 0 |
39
+ | scene structure | uninstanced 200-mesh group | census / measured bisection → entity names | 1–2 |
40
+ | game logic | launch playing under the battleground | NOT an incident — a correctness bug; out of scope, said out loud | — |
41
+
42
+ Corollary: **detection is threshold math; attribution is per-somewhere.**
43
+ A pipeline that detects everything but attributes nothing (our first
44
+ field build: `long-script`, 14ms inside render, full stop) makes the
45
+ agent guess — the exact failure this product exists to end.
46
+
47
+ ## 2. The pipeline
48
+
49
+ ```
50
+ detect (always on) → classify+attribute → deliver ┬→ agent (push: wake with evidence; pull: files/CLI)
51
+ └→ human (OPTIONAL debugger: the incident list + one-line annotation)
52
+ → agent changes ONE thing → verify (counters/bench) → ledger
53
+ ```
54
+
55
+ - **Detect**: relative + absolute thresholds (2× median, 1.5× budget),
56
+ rate-limited with loud drop counts. No keypress anywhere in this stage.
57
+ - **Deliver to the agent** is the primary edge. Push: incident → agent
58
+ wakeup with the classification attached (MCP notification when
59
+ packaged; a session Monitor until then). Pull: `.sloptimize/` files +
60
+ CLI with exit codes — works headless, in CI, and after the fact.
61
+ - **Deliver to the human** is a MIRROR, not a transport: opening the
62
+ debugger shows what already shipped (list: when, how long, why;
63
+ manual keyframes starred) and offers one text line — semantics only a
64
+ human has. The mirror must never claim more than the pipe did: rows
65
+ read "sent", meaning *landed in the sink*; whether an agent session is
66
+ currently consuming the sink is not the page's claim to make.
67
+ - **Verify** closes the loop: counters compare exactly on any renderer;
68
+ timing only within its regime; verdicts land in the append-only ledger
69
+ so a reverted strategy is never retried.
70
+
71
+ ## 3. Tiers of sensing (progressive precision, none required to start)
72
+
73
+ - **Tier 0 — attach** (`sloptimize attach [--launch <url>]`): CDP;
74
+ injected recorder; rAF timing; graphics-API wraps (draws, triangles,
75
+ pipeline creations WITH `Error().stack`, uploads, queue latency);
76
+ rolling sampling profiler (~1–3% dev overhead) so every `long-script`
77
+ incident carries `topFrames`. Zero game code. Chromium-only, dev-only.
78
+ - **Tier 1 — in-page feed**: the game hands engine-true numbers
79
+ (`rec.frame(...)` at its stats site — one line) and ships the recorder
80
+ ambient with every dev session, no attach needed. Exact
81
+ insideRenderMs, spawn deltas, engine tags.
82
+ - **Tier 2 — engine/slopjs**: census, entity attribution, measured
83
+ bisection, snapshot repro of a keyframe's workload.
84
+
85
+ Every number carries its tier and regime; a tier-0 approximation never
86
+ poses as a tier-1 measurement.
87
+
88
+ ## 4. Critical review (of this spec, including against its own drafts)
89
+
90
+ **Fixed since draft 1:**
91
+ - Draw-call counting was the wrong headline; the rolling profiler is the
92
+ prize — it attributes the class of freeze (`long-script`,
93
+ unexplained) that the field deployment recorded a dozen times and
94
+ could never name.
95
+ - The human's role was over-weighted (Ctrl+F11 as a pillar). Corrected:
96
+ auto-first; the debugger is an optional mirror + annotation channel.
97
+ - "Sent to Claude Code" is now specified as *sink-landed*, because the
98
+ UI must not assert a live consumer it cannot see.
99
+
100
+ **Standing weaknesses, stated:**
101
+ - **Operator's everyday browser**: attach needs a debug port; ambient
102
+ always-on coverage of the human's normal play is tier 1's job, which
103
+ costs one line of game code. Zero-code AND ambient-for-the-human is
104
+ not achievable simultaneously; the spec stops pretending otherwise.
105
+ - **Correctness bugs** (most of what the field ticket actually fixed)
106
+ are invisible to every tier. The doctrine must route "it looks/behaves
107
+ wrong" reports away from the profiler before anyone burns a loop on it.
108
+ - **Incident flooding / identity**: a recurring root cause fires
109
+ incidents forever. Rate limits bound volume but not repetition;
110
+ clustering (same classification + same top frame ⇒ same incident id,
111
+ count incremented) is REQUIRED in v2 so the agent investigates a cause
112
+ once, not per occurrence. New in this draft; unimplemented.
113
+ - **Profiler observer effect**: 1–3% steady overhead plus GC from stack
114
+ sampling. Bounded and labeled (`profiled: true` on the window) so a
115
+ profiled p95 is never compared against an unprofiled one.
116
+ - **Attribution ceiling at tier 0**: draw calls cannot be attributed to
117
+ entities from the API (in three, every draw shares one internal call
118
+ site). Entity work items require tier ≥1. The table in §1 is honest
119
+ about which somewhere needs which tier.
120
+ - **Trust**: an injected recorder and a writable sink are spoofable by
121
+ anything local. Unchanged posture from SPEC §9 — price and expose,
122
+ don't pretend to prevent.
123
+
124
+ **Will it resolve the issues this ticket actually dealt with?**
125
+
126
+ | issue | verdict |
127
+ |---|---|
128
+ | random unattributed freezes | YES — this spec's center of mass; profile stacks name the function |
129
+ | 8.4s page-load freeze | YES (diagnosis) — queue latency + creation stacks discriminate; the fix follows what they name |
130
+ | descent-entry compile stutters | YES — creation stacks replace the bespoke attribution probe |
131
+ | warm-sweep stalls | YES — stacks ≥ hand tags |
132
+ | launch logic bugs | NO — out of scope by principle, forever |
133
+
134
+ ## 4.1 Dogfood verdict (tier 0 against the real game, zero integration)
135
+
136
+ `sloptimize attach --launch http://127.0.0.1:4477/?play&launch --headless`
137
+ against mecharoyale (1.4MB minified bundle, WebGL2 fallback on the QA
138
+ box): 35 hitches recorded, every one attributed and clustered — the
139
+ software rasterizer's real costs by name (`bufferSubData` ×7,
140
+ `getUniformBlockIndex` ×6), three.js internals (`_setupBindings`,
141
+ `update`), and one game function. Two limits surfaced and kept:
142
+
143
+ - **Minified names**: the game frame arrived as `O_e@game.min.js` — the
144
+ pipeline works, but human-readable attribution in bundled games needs
145
+ sourcemaps served with the dev build. Doctrine: turn sourcemaps on in
146
+ dev, or accept minified names as cluster keys (they are stable per
147
+ build, so clustering still holds).
148
+ - **API coverage follows the backend**: `gpu-create` records require
149
+ WebGPU; on a WebGL2-fallback page the WebGL draw wraps carry the
150
+ counters and creation stacks are absent (honestly, not silently).
151
+
152
+ ## 5. Milestones
153
+
154
+ - **M-A0 attach MVP** — **SHIPPED, exit criterion measured**: on the
155
+ zero-integration fixture the seeded freeze is attributed
156
+ `seededFreezeWork@seeded-freeze.html:5` from the written record alone
157
+ (test/attach-e2e.mjs; unit tier in test/attach.test.js).
158
+ - **M-A1 incident identity** — **SHIPPED with a measured limit**: repeats
159
+ merge into an existing cause when its identifying frame appears in the
160
+ new hitch's top-3 (fixture: 3 occurrences → 2 clusters). The measured
161
+ limit, recorded rather than papered over: V8 INLINING can erase the
162
+ leaf frame between occurrences (freeze #1 named the function, #2
163
+ arrived as its caller), so a cause can still split across an
164
+ optimization boundary. Deopt-aware matching is deferred until a real
165
+ case demands it.
166
+ - **M-A2 plugin packaging** — **SHIPPED (structure + live tier)**: the
167
+ repo IS the plugin (`.claude-plugin/plugin.json` at root, so `bin/` and
168
+ `src/` travel with any install), carrying the doctrine skill, the
169
+ silent-by-default UserPromptSubmit hook via `${CLAUDE_PLUGIN_ROOT}`,
170
+ and a dependency-free stdio MCP server (`get_report`, `check_budgets`,
171
+ `attach_start`, `attach_stop`) protocol-smoked over real JSON-RPC.
172
+ Install: `claude --plugin-dir <path-to-sloptimize>` in dev; marketplace
173
+ add once published. The exit criterion's PUSH half (agent woken by
174
+ incident with zero project files) still rides the session Monitor —
175
+ server-initiated MCP wakeups are not yet a documented host contract,
176
+ recorded here as the remaining gap rather than claimed.*