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/docs/SPEC.md ADDED
@@ -0,0 +1,845 @@
1
+ # sloptimize — founding specification
2
+
3
+ Status: **v0 draft, pre-implementation.** This is the buildable contract
4
+ derived from the strategy work in the slopjs repo
5
+ (`docs/performance-strategy.md`, branch `ticket-ebf515b9`), which carries the
6
+ full evidence base and citations. Where this document and that one disagree,
7
+ this one wins — it is newer and more specific.
8
+
9
+ The one-sentence product: **give a coding agent the three verbs it measurably
10
+ cannot perform alone — measure, attribute, verify — against a running
11
+ three.js game, and make the loop terminate.**
12
+
13
+ ---
14
+
15
+ ## 0. Design principles (the spec in miniature)
16
+
17
+ Every decision below derives from six principles. When a future feature
18
+ request conflicts with one, the principle wins or this section gets amended
19
+ first.
20
+
21
+ 1. **The agent can never watch, so the game must remember.** Token cost and
22
+ loop latency make live observation impossible at any price. All evidence
23
+ is recorded before anyone asks, to disk, in schemas sized for a model's
24
+ context.
25
+ 2. **Verdicts, not dashboards.** Every agent-facing surface is
26
+ non-interactive, machine-readable, and exit-code-meaningful. A number that
27
+ requires a human glance to interpret is dead weight.
28
+ 3. **Attribution over aggregation.** A global counter ("940 draw calls") is
29
+ the starting point, never the answer. Every metric must resolve toward a
30
+ `track()` entity id, and through it toward a construction site in source.
31
+ 4. **Never conclude past the evidence.** Truth grades on every measurement,
32
+ noise floors on every comparison, "no detectable change" as a first-class
33
+ result, and stated limits instead of degraded guesses.
34
+ 5. **The ruler is not the agent's to hold.** Measurement runs outside the
35
+ agent's write path; what cannot be prevented (a local agent can edit
36
+ anything) is made expensive and visible instead.
37
+ 6. **Own the protocol, borrow everything else.** three.js counts
38
+ (`renderer.info`), stats-gl times the GPU, Chromium runs the frames,
39
+ Chrome DevTools profiles functions, Spector captures draw state. sloptimize
40
+ owns identity-attached measurement and the loop — nothing else.
41
+
42
+ ---
43
+
44
+ ## 1. Architecture
45
+
46
+ Five components, one product:
47
+
48
+ ```
49
+ game (browser) ──┐
50
+ │ in-page runtime: recorder, census, attribution
51
+ │ (src/, injected or imported; reads @slopjs/inspector ctx)
52
+
53
+ vite plugin ────► .sloptimize/ on disk ◄──── CLI (bin/sloptimize.mjs)
54
+ (vite/plugin.js) the agent's reading room bench, check, attribute, doctor
55
+
56
+ │ MCP server (mcp/) — live tools against the paused world
57
+ └ launched Chromium (bench only, optional)
58
+ ```
59
+
60
+ - **In-page runtime** — samples frames, maintains the ring buffer, detects
61
+ hitches, walks the scene for the census, executes bisection while paused.
62
+ - **Vite plugin** (`sloptimize/vite`) — dev-only (`apply: 'serve'`), exposes
63
+ `/__sloptimize/*` endpoints, lands payloads in `.sloptimize/`. Composes
64
+ with `@slopjs/inspector/vite`; requires it in v0 (see §2).
65
+ - **CLI** — `sloptimize check|bench|attribute|report|doctor`. The commands an
66
+ agent runs in its shell; also the hook's substrate.
67
+ - **MCP server** — optional, stdio, for the live paused-world tier.
68
+ - **Files** — the primary agent interface. File-first, tools-second, same as
69
+ slopjs: a hook or a `Read` needs no protocol.
70
+
71
+ ### 1.1 Directory layout on disk (in the game project)
72
+
73
+ ```
74
+ .sloptimize/
75
+ profile.json rolling summary: current medians, p95, counters, regime
76
+ perf.jsonl hitch records, append-only
77
+ census.json per-entity cost census, from the last walk
78
+ bench/<name>.json one bench run, by snapshot name + timestamp
79
+ bench-history.jsonl the ledger: every bench comparison ever, append-only
80
+ goldens/<name>.png correctness-gate reference frames (gitignored)
81
+ budgets: in the game's source, via tune('perf.budget.*') — see §7
82
+ ```
83
+
84
+ `perf.jsonl` and `bench-history.jsonl` are **not** gitignored (they are the
85
+ audit trail); `goldens/` and `bench/` are.
86
+
87
+ ---
88
+
89
+ ## 2. Platform contract with `@slopjs/inspector`
90
+
91
+ sloptimize peer-depends on the inspector and consumes only public or
92
+ to-be-published surfaces. v0 requires the inspector's vite plugin to be
93
+ present (it supplies the pause, the IDs, the bridge security model). The
94
+ **platform asks** — small additions the inspector must grow, to be proposed
95
+ as PRs there, listed here so the dependency is explicit:
96
+
97
+ | ask | why sloptimize needs it |
98
+ |---|---|
99
+ | `onFrame(cb)` hook on the render interception (`src/discover.js` wrap) | the recorder's sampling point; called once per `renderer.render` with `{ renderer, scene, camera, timestamp }`. Without it we would re-wrap `render` and fight the inspector's own wrapper. |
100
+ | camera state included in `snapshot.capture()` / restored by `restore()` | identical workload requires identical view; today snapshots omit the camera. |
101
+ | a documented `captureFrame()` that works while paused | the correctness gate's golden frames; the inspector already solves `preserveDrawingBuffer` timing — expose it. |
102
+ | read access to the tracked registry (`all()`, `idOf`, `pathOf` — already exported) | census attribution to entity ids. Already public; listed for completeness. |
103
+ | `applyOp` visibility ops honored while paused with a forced re-render | measured bisection = toggle, re-render, read `renderer.info`. |
104
+
105
+ Until an ask lands upstream, the corresponding sloptimize feature ships
106
+ degraded and says so in `sloptimize doctor` (principle 4) — it does not
107
+ monkey-patch around the inspector.
108
+
109
+ ---
110
+
111
+ ## 3. The flight recorder
112
+
113
+ ### 3.1 Sampling
114
+
115
+ - Source: the `onFrame` hook (§2). Per frame, read `performance.now()` delta
116
+ between render calls **and** the wall time spent inside the render call
117
+ (wrap start/end), plus `renderer.info.render.{calls,triangles,points,lines}`,
118
+ `renderer.info.memory.{geometries,textures}`, `renderer.info.programs.length`.
119
+ - Cost budget: **≤ 0.2ms per frame, zero allocations on the steady path**
120
+ (pre-allocated ring slots; strings only at flush time). The recorder must
121
+ never be the hitch it reports.
122
+ - Ring buffer: 600 frames (~10s at 60fps) of full samples, in memory, always
123
+ on while the plugin is active — panel open or closed, paused or running.
124
+ Pause state is recorded per sample (`paused: true` frames are excluded from
125
+ budget checks but kept for continuity).
126
+ - Flush: every 2s (aligned with the inspector's refresh throttle), write
127
+ `.sloptimize/profile.json` — the rolling summary, not the ring.
128
+ - Per-frame strings the host may pass and the recorder stamps at mint:
129
+ `phase` (the host's moment axis — menu/boot/launch/match…) and `ctx` (the
130
+ host's canonical situation, §3.7). Both are strings the host caches and
131
+ hands along; the recorder allocates nothing for them.
132
+
133
+ ### 3.2 `profile.json` (rolling summary)
134
+
135
+ ```json
136
+ {
137
+ "at": "2026-08-23T12:00:00.000Z",
138
+ "regime": "hardware", // "hardware" | "software" | "unknown" — see §6.4
139
+ "window": { "frames": 600, "seconds": 10.4 },
140
+ "frame": { "medianMs": 8.1, "p95Ms": 15.2, "insideRenderMs": 4.2, "fps": 60 },
141
+ "render": { "calls": 940, "triangles": 1200000, "points": 0, "lines": 12 },
142
+ "memory": { "geometries": 312, "textures": 48, "programs": 17 },
143
+ "budgets": { "checked": 3, "breached": ["perf.budget.draw_calls"] },
144
+ "paused": false
145
+ }
146
+ ```
147
+
148
+ A field the runtime cannot measure is **absent**, never `0` (the inspector's
149
+ em-dash rule, inherited as JSON absence).
150
+
151
+ ### 3.3 Hitch detection and `perf.jsonl`
152
+
153
+ A hitch is a non-paused frame whose delta exceeds
154
+ `max(2 × rolling median, budget frame ms × 1.5)` — relative and absolute
155
+ guards together, so a 30fps-by-design game does not report every frame. Per
156
+ hitch, append one record:
157
+
158
+ ```json
159
+ {
160
+ "type": "hitch",
161
+ "at": "2026-08-23T12:00:01.100Z",
162
+ "frame": 8412,
163
+ "frameMs": 41.0,
164
+ "medianMs": 8.1,
165
+ "insideRenderMs": 6.0,
166
+ "delta": {
167
+ "calls": +12, "triangles": +48000, "programs": +3, "textures": +2,
168
+ "geometries": 0
169
+ },
170
+ "world": {
171
+ "spawned": ["enemy_17", "enemy_18", "auto:mesh_1f2a"],
172
+ "removed": [],
173
+ "camera": { "position": [4.1, 12.0, -30.2], "quaternion": [0,0.7,0,0.7] },
174
+ "inputsHeld": ["KeyW", "Mouse0"]
175
+ },
176
+ "classification": {
177
+ "guess": "shader-compile",
178
+ "confidence": "medium",
179
+ "evidence": "programs +3 in the hitch frame"
180
+ },
181
+ "snapshotRef": "hitch-8412" // present iff auto-snapshot succeeded, §5
182
+ }
183
+ ```
184
+
185
+ Classification vocabulary (closed set, extensible only by spec change):
186
+ `shader-compile` (programs delta > 0), `texture-upload` (textures delta > 0,
187
+ programs 0), `spawn-burst` (spawned length above threshold),
188
+ `gc-or-upload-by-elimination` (no counter moved), `long-render`
189
+ (insideRenderMs dominates), `long-script` (frame delta dominates,
190
+ insideRenderMs small). Multiple guesses allowed, ranked. `confidence` is
191
+ `low | medium | high` and the `evidence` string is mandatory — a guess
192
+ without its reason is banned by principle 4.
193
+
194
+ Rate limit: at most 1 record per second and 500 per session; when the limit
195
+ truncates, the *last* record of the session says how many were dropped
196
+ (silence must mean nothing was dropped — the inspector's selection-block
197
+ rule, inherited).
198
+
199
+ ### 3.5 Usermarks — the human's half of hitch detection
200
+
201
+ The automatic threshold cannot see "it feels wrong": steady-but-low fps,
202
+ micro-stutter under the hitch bar, jitter that only a hand on the mouse
203
+ notices. So the recorder exposes one more verb:
204
+
205
+ - `usermark({ windowMs = 5000, note, inputsHeld, world })` — freeze the
206
+ trailing window of the ring into one appended record: window frame count,
207
+ median/p95 over the window, and the **five worst frames ranked**, each with
208
+ its counter deltas and the same closed-vocabulary classification an
209
+ automatic hitch gets.
210
+ - The host binds it to a chord (the reference integration uses
211
+ **Ctrl+F11**) and shows a one-line confirmation. The press is the
212
+ human's timestamp; the ring is the evidence; the record is the labeled
213
+ training example an agent can read cold.
214
+ - Usermarks share `perf.jsonl` (type `"usermark"`), bypass the 1/s hitch
215
+ rate limit (a human press is already rate-limited by a human), and count
216
+ against the 500/session cap.
217
+
218
+ This section exists because the product's first field deployment asked for
219
+ exactly this, in the operator's words: "whenever a bottleneck or jitter is
220
+ detected, I can press a unique shortcut which will save the keyframe and use
221
+ all data recorded from the last 5 seconds to identify the bottleneck."
222
+
223
+ ### 3.4 What the recorder does not do
224
+
225
+ No JS stack sampling (DevTools' job), no per-frame heap snapshots (cost), no
226
+ network waterfall. `performance.memory` (Chromium-only) is sampled at 1Hz
227
+ solely to support the `gc` classification, and its absence downgrades that
228
+ guess's confidence — it never blocks a record.
229
+
230
+ ### 3.6 Coordinate continuity — jitter records
231
+
232
+ Hitches are about TIME. The second thing an operator feels is about SPACE:
233
+ "my player unit/camera's coordinate suddenly jumping instead of smoothly
234
+ transitioning at every frame". So the runtime ships a second detector,
235
+ `createMotionMonitor`, fed once per rendered frame with the world position
236
+ of each TRACK the host cares about — the reference integration feeds two:
237
+
238
+ - `unit` — the point the view is arranged around (the host's view pivot:
239
+ the eye on foot, the hull aboard, the spectated subject). Rotation-
240
+ invariant by construction, so a mouse flick cannot move it.
241
+ - `camera` — the eye itself, `follows: 'unit'`, with `reach` = its distance
242
+ to the pivot (the boom length), and HELD on every frame that carried look
243
+ or zoom input (a flick swings a boom metres in one frame — intended).
244
+
245
+ The test is a one-step constant-velocity prediction from the previous two
246
+ samples; the residual `r = p − p̂` is how far the point landed off its own
247
+ trajectory (½·a·dt² for smooth motion — millimetres). A residual is a
248
+ CANDIDATE when `|r| > max(floor, ratio × predicted travel)`; it becomes an
249
+ EVENT only when the next frame REVERSES it (compared as velocity anomalies,
250
+ so a jump landing in a 400ms stall frame is still confirmed by the 17ms
251
+ frame after it). A residual that is not reversed is a change of motion —
252
+ a dash, a boom easing back out — and is never reported. Consecutive events
253
+ (≤3 frames apart) fold into one burst: one event is a `snap`, two or more
254
+ an `oscillation`; the burst posts as ONE record when it closes (or every
255
+ 2s while it continues).
256
+
257
+ ```json
258
+ {
259
+ "type": "jitter",
260
+ "at": "2026-09-01T19:17:02.130Z",
261
+ "track": "unit",
262
+ "kind": "snap", // "snap" | "oscillation"
263
+ "frame": 8412,
264
+ "jump": [0.6, 0.3, 0], // the off-trajectory displacement, host units
265
+ "units": 0.671,
266
+ "travelUnits": 0.133, // what constant velocity predicted for this frame
267
+ "speed": 8.0, // host units per second before the jump
268
+ "dtMs": 16.7, "medianDtMs": 16.7,
269
+ "from": [12.1, 3.0, -4.2], "to": [12.83, 3.3, -4.2],
270
+ "frames": 41, "durationMs": 683, "amplitude": 0.3, // oscillation only
271
+ "coincident": ["camera"], // other tracks that jumped in the same frame
272
+ "reach": { "name": "boom", "before": 10.0, "after": 9.1 }, // if the host samples one
273
+ "classification": [ { "guess": "snap", "confidence": "high", "evidence": "…" } ],
274
+ "phase": "play", "build": "v1788289826333"
275
+ }
276
+ ```
277
+
278
+ Classification vocabulary (closed): `snap`, `oscillation`,
279
+ `long-frame-catch-up` (the jump landed in a frame ≥ the host's `longFrameMs`
280
+ — its sim's dt clamp, default the 100ms hitch bar — or ≥2× the median and
281
+ ≥50ms: past the clamp the point moves by the clamp while the prediction
282
+ scales with the clock, so the residual is the stall's, not a jump's),
283
+ `follows-track` (the track jumped in the same frame as the one it declares
284
+ it follows — the passenger, not the cause), `reach-change` (the point's
285
+ distance to its anchor changed by ≈ the jump — a boom clamp or a zoom, not
286
+ a teleport). Explanations rank ahead of the kind. Every guess carries
287
+ evidence.
288
+
289
+ Host contract: the host says which frames are HELD (input-driven, paused)
290
+ and when the view was CUT on purpose (camera mode flip, spectate target
291
+ change, respawn, session boundary) — the detector never guesses intent.
292
+ Rate limits: one record per second PER TRACK (a unit that teleports takes
293
+ its camera with it in the same frame, and the camera's record is the one
294
+ that says so), 200 per session, drops counted onto the next record.
295
+
296
+ Stated limits: a pure rotation pop (a yaw snap) moves no coordinate and is
297
+ not detected; a snap that coincides with an equal-and-opposite velocity
298
+ change reads as a change of motion; the camera is judged only on frames
299
+ without look input, so a pop under a moving mouse is missed (the unit
300
+ track still sees it if the pivot moved).
301
+
302
+ ### 3.7 Footprints, situation, and the issue catalogue
303
+
304
+ A ledger line is an OCCURRENCE. Two lines a day apart, on two builds, on
305
+ two players' machines, are very often the same thing happening again, and
306
+ a log that cannot say so cannot answer "how often does this happen" or
307
+ "what did we do about it". So every incident record carries a
308
+ **footprint**: the identity of its cause, apart from its occurrence.
309
+
310
+ ```json
311
+ "footprint": { "v": 1, "id": "a3f92c1d", "key": "jitter|unit|snap|play|snap|horizontal|ctx:hull=elong-x,squad=duo,stance=helm" }
312
+ ```
313
+
314
+ - `key` is readable and is exactly what was hashed: the record type, the
315
+ phase, the closed-vocabulary verdict, whatever identifies the SITE (a
316
+ hitch's minted `material@object` pairs, sorted and deduped; a jitter's
317
+ track, kind and dominant axis — vertical vs horizontal; a warm's tag and
318
+ kind), and the host's situation (below). Never the timestamp, the frame
319
+ number, the exact milliseconds or metres, the build, the machine.
320
+ - `id` is FNV-1a 32 of `v<version>:<key>`, 8 hex characters — a dedupe key
321
+ over thousands of causes, stable across runtimes, no dependency.
322
+ - `v` versions the derivation. A change to what enters a key is a new
323
+ version, never a silent reshuffle of old ids; a reader re-derives a
324
+ record stamped with an older version and keeps one stamped with the
325
+ current one (the writer's word stands).
326
+
327
+ **Situation.** Time is not a facet of the cause; the game's STATE is. A
328
+ hitch at the helm of a heavy machine with a copilot aboard in a firefight
329
+ is not the same issue as the same hitch on foot in an empty lobby, and no
330
+ profiler can know which facets matter for which game. So the host declares
331
+ them: `context()` returns a few LOW-CARDINALITY facets — categories, never
332
+ positions or counters — the runtime canonicalises them once a second
333
+ (`canonicalContext`: keys sorted, `k=v` pairs joined by `,`, separators
334
+ scrubbed) and hands the string to the recorder per frame (`frame({ ctx })`,
335
+ `usermark({ ctx })`, the motion monitor's `meta.ctx`), which stamps it on
336
+ the record as `ctx`; host-built records take the current one at post. The
337
+ reference integration's facets: `stance` (foot|helm|crew|dead), `hull`
338
+ (the frame's name), `squad` (solo|duo|trio), `view` (fp|tps), `combat`
339
+ (yes|no, a ten-second window after any hit).
340
+
341
+ **The catalogue.** `buildIssues(records, { fixes, from, to, now })` folds a
342
+ ledger by footprint: one row per id with `count`, `first`, `last`,
343
+ `lastAgoMs`, `builds` seen, `worst` (with its unit), the last verdict's
344
+ evidence, the parsed situation facets, and `fixes` — every fix record whose
345
+ `footprints` names the id. Robots' records (`automated: true`) are left out
346
+ unless asked for. A fix names what it addresses:
347
+ `sloptimize fix propose --footprints a3f92c1d,… --title "…"` (also on
348
+ `sloptimize fix`); the catalogue answers "which fixes were applied to this
349
+ issue" by that join, and the before/after windows of each say whether it
350
+ landed.
351
+
352
+ Surfaces: `sloptimize issues` (the catalogue; `--fp <id>` for one issue's
353
+ history), `sloptimize report` (the top five), every `watch` wake line
354
+ (`fp=a3f92c1d ×7` — the count is the ledger's, seeded at arm), and the
355
+ debugger's **Issues** tab: every incident type grouped by footprint, most
356
+ frequent first, with `×N` and `last 3h ago`; picking a row opens its
357
+ history — key, first and last seen, builds, worst, last verdict, and the
358
+ fixes applied to it, or the exact command that would record one.
359
+
360
+ Cloud path: the footprint is computed by the writer, so a service that
361
+ ingests many clients' records dedupes on `footprint.id` from day one; the
362
+ fold is the same code.
363
+
364
+ ---
365
+
366
+ ## 4. Census and attribution
367
+
368
+ ### 4.1 Static census (`census.json`)
369
+
370
+ Produced by a full scene walk on demand (`sloptimize attribute --static`, an
371
+ MCP call, or the panel) and after every `inspector check`-style scan; never
372
+ per-frame. Per tracked entity (and per auto-tracked node, flagged):
373
+
374
+ ```json
375
+ {
376
+ "at": "2026-08-23T12:00:00.000Z",
377
+ "totals": { "calls": null, "meshes": 1240, "triangles": 1200000,
378
+ "uniqueMaterials": 41, "uniqueGeometries": 220,
379
+ "textureBytesEstimate": 182000000 },
380
+ "entities": [
381
+ {
382
+ "id": "gltf_town",
383
+ "meshes": 611,
384
+ "triangles": 604000,
385
+ "uniqueMaterials": 9,
386
+ "uniqueGeometries": 34,
387
+ "sharedGeometryGroups": [
388
+ { "geometry": "auto:geo_a91c", "material": "auto:mat_2210",
389
+ "count": 500, "instanced": false }
390
+ ],
391
+ "textureBytesEstimate": 96000000,
392
+ "castShadow": 611,
393
+ "visible": true,
394
+ "persistent": true
395
+ }
396
+ ],
397
+ "hints": [
398
+ {
399
+ "kind": "instancing-candidate",
400
+ "entity": "gltf_town",
401
+ "detail": "500 meshes share one geometry+material and are not instanced",
402
+ "estimate": { "callsBefore": 940, "callsAfter": 441 },
403
+ "fix": "merge into one InstancedMesh at the construction site of gltf_town"
404
+ }
405
+ ]
406
+ }
407
+ ```
408
+
409
+ `totals.calls` is `null` in the static census — draw calls are a runtime
410
+ fact, and the census does not pretend to know it (principle 4); `profile.json`
411
+ carries the measured number.
412
+
413
+ Hint vocabulary (closed set): `instancing-candidate`,
414
+ `material-dedup-candidate` (N materials with identical parameters),
415
+ `shadow-caster-light` (shadow-casting point/spot lights, with their map
416
+ cost), `undisposed-suspect` (geometries/textures count monotonically growing
417
+ across censuses), `oversized-texture` (dimension threshold),
418
+ `uncapped-pixel-ratio`. Texture bytes are labeled `Estimate` in the field
419
+ name because that is what they are (width×height×format guess, no VRAM
420
+ introspection in WebGL).
421
+
422
+ ### 4.2 Measured bisection (`attribute_cost`)
423
+
424
+ Protocol, executed by the in-page runtime while **paused** (requires the
425
+ inspector's pause; refuses to run otherwise with a structured error):
426
+
427
+ 1. Render K frames (default 5), record baseline `renderer.info` counters and
428
+ median inside-render ms.
429
+ 2. For each candidate (default: top 20 census entities by triangles, or an
430
+ explicit id list): set the entity's root `visible = false` via the
431
+ inspector's own `applyOp`, render K frames, record, restore visibility.
432
+ 3. Emit per-entity deltas: `calls`, `triangles`, `insideRenderMs` (the last
433
+ marked `noisy: true` when below the measured per-run spread).
434
+ 4. Restore the world exactly (ops applied through the same `applyOp` path
435
+ means undo history stays coherent; the run ends with a verification that
436
+ post-state === pre-state, and reports if not).
437
+
438
+ Output ranks entities by measured contribution and carries the caveat as
439
+ data, not prose: `"sumsToBaseline": false` — hiding a shadow caster changes
440
+ shadow-map cost, batching state shifts; this is a **ranking instrument, not
441
+ an accounting identity**, and every consumer (skill, panel) must render that
442
+ caveat.
443
+
444
+ ### 4.3 Attribution to source
445
+
446
+ Every census/bisection row carries the entity id; ids resolve to construction
447
+ sites through the inspector's existing writeback tooling (`locate`). The
448
+ hint's `fix` string names the site when `locate` finds one, and says
449
+ `site: unknown` when it does not — auto-tracked (`auto:`) entities always
450
+ say so, with the standing advice to `track()` them.
451
+
452
+ ---
453
+
454
+ ## 5. Hitch reproduction
455
+
456
+ On a hitch, the recorder requests `snapshot.capture()` (with camera, per the
457
+ §2 ask) for the frame `k = 30` frames before the spike, using the snapshot
458
+ system's HMR-carry machinery, and saves it as `hitch-<frame>`. The record's
459
+ `snapshotRef` names it. Reproduction is then:
460
+
461
+ ```
462
+ sloptimize repro hitch-8412 # CLI wrapper, or the MCP equivalent:
463
+ snapshot.load('hitch-8412'); clock.step(35); # step across the hitch, reading counters
464
+ ```
465
+
466
+ **Scope, stated as data:** the repro record carries
467
+ `"fidelity": "workload"` — snapshots hold tracked transforms, visibility,
468
+ tunables, camera; not physics internals, mixer time, RNG, or spawn queues. A
469
+ restore *looks* identical and *evolves* differently. This is strong for
470
+ draw/material/fill workloads and weak for simulation-CPU spikes, and the
471
+ skill says exactly that. Trajectory-fidelity replay (seeded RNG, fixed
472
+ timestep, keyframe chains) is **out of scope for v1** and enters the spec
473
+ only if workload repro is demonstrated insufficient on real cases.
474
+
475
+ ---
476
+
477
+ ## 6. The bench
478
+
479
+ ### 6.1 Contract
480
+
481
+ ```
482
+ sloptimize bench --snapshot town-square [--frames 600] [--launch] [--json]
483
+ sloptimize bench --compare A B [--json] # or --compare last
484
+ ```
485
+
486
+ A bench run: restore the named snapshot (camera included), pin `clock` to a
487
+ fixed 1/60s tick, run N frames, discard the first 120 (warmup: shader
488
+ compiles, JIT), record per-frame deltas and counters, repeat the whole run R
489
+ times (default 3), report per-metric median and p95 across runs plus the
490
+ inter-run spread — the **noise floor**.
491
+
492
+ ### 6.2 Output (`bench/<name>.json`)
493
+
494
+ ```json
495
+ {
496
+ "name": "town-square",
497
+ "at": "2026-08-23T12:05:00.000Z",
498
+ "regime": "hardware",
499
+ "environment": { "launched": true, "headless": "new", "gpu": "ANGLE (Apple M2)",
500
+ "configHash": "sha256:9f2c…" },
501
+ "frames": 600, "runs": 3,
502
+ "frame": { "medianMs": 9.1, "p95Ms": 14.8, "noiseFloorMs": 0.9 },
503
+ "render": { "calls": 24, "triangles": 1200000, "programs": 14 },
504
+ "grade": { "counters": "exact", "timing": "hardware" }
505
+ }
506
+ ```
507
+
508
+ `configHash` covers snapshot content, frame count, runs, viewport, and
509
+ pixelRatio — two reports compare only if hashes match, and `--compare`
510
+ refuses otherwise (exit 3).
511
+
512
+ ### 6.3 Comparison verdicts
513
+
514
+ `--compare` emits exactly one verdict per metric and one overall:
515
+
516
+ - `improved` / `regressed`: the delta exceeds the pooled noise floor.
517
+ - `no-detectable-change`: inside the noise floor. **This is a result**, not a
518
+ failure, and the skill maps it to "revert".
519
+ - Counters compare exactly (no noise floor — they are deterministic).
520
+ - Overall verdict additionally reports **distance-to-budget** for every
521
+ breached budget: `"frame p95 14.8ms; budget 16.7ms: inside"` — because
522
+ agents satisfice, and "improved" without "are we there" invites stopping
523
+ early.
524
+
525
+ Exit codes: `0` improved-or-inside-budgets, `1` regressed, `2`
526
+ no-detectable-change, `3` incomparable, `4` environment failure (no browser,
527
+ no snapshot). The codes are API; the skill depends on them.
528
+
529
+ ### 6.4 Truth grades and regimes
530
+
531
+ - `counters` grade — draw calls, triangles, programs, census: **exact on any
532
+ renderer including SwiftShader.** CI-safe.
533
+ - `timing` grade — frame ms, inside-render ms, GPU ms: meaningful only in
534
+ `regime: "hardware"`. Regime detection: `WEBGL_debug_renderer_info`
535
+ unmasked renderer string matched against software rasterizers (SwiftShader,
536
+ llvmpipe); unknown → `"unknown"` and timing is reported but flagged. A
537
+ timing comparison across regimes is refused (exit 3). **An unlabeled
538
+ number is a lie waiting for a model to believe it.**
539
+ - GPU ms (optional): via `stats-gl`'s timer-query mechanism when the optional
540
+ peer is installed and the extension exists — Chromium-only, and reported as
541
+ absent elsewhere, never estimated.
542
+
543
+ ### 6.5 The launched browser (`--launch`)
544
+
545
+ - Default: bench against the already-open dev tab through the plugin bridge
546
+ (the developer's real GPU, zero new dependencies).
547
+ - `--launch`: spawn Chromium for an unattended run — required for `/loop`
548
+ and CI. Binary discovery order: `SLOPTIMIZE_BROWSER` env var →
549
+ `playwright-core`'s managed Chromium if the optional peer is installed →
550
+ well-known system paths (the inspector's `verify-zero-config.mjs` CDP
551
+ approach, productized). No browser found → exit 4 with the install hint.
552
+ - A launched browser uses a fresh profile, fixed 1280×720 viewport,
553
+ `--disable-extensions`, pinned `devicePixelRatio: 1` — reproducibility
554
+ beats realism for verdicts.
555
+ - The whole launcher is **Chromium-only by design**, stated in `doctor`.
556
+
557
+ ### 6.6 The ledger (`bench-history.jsonl`)
558
+
559
+ Every `--compare` appends one line, machine-written only:
560
+
561
+ ```json
562
+ {
563
+ "at": "2026-08-23T12:06:00.000Z",
564
+ "snapshot": "town-square",
565
+ "change": { "ref": "git:1a2b3c4", "description": "instance gltf_town rocks" },
566
+ "before": { "calls": 940, "p95Ms": 21.4 },
567
+ "after": { "calls": 24, "p95Ms": 9.1 },
568
+ "verdict": "improved",
569
+ "budgets": { "perf.budget.draw_calls": "inside", "perf.budget.frame_ms_p95": "inside" },
570
+ "configHash": "sha256:9f2c…"
571
+ }
572
+ ```
573
+
574
+ The ledger is the loop's memory (§8): an iteration reads it before acting,
575
+ so a reverted strategy is never retried and progress is auditable after the
576
+ fact. It is append-only by contract; the CLI has no delete/edit verb.
577
+
578
+ ---
579
+
580
+ ## 7. Budgets and `sloptimize check`
581
+
582
+ Budgets are declared in game source through the inspector's own `tune()`:
583
+
584
+ ```js
585
+ tune('perf.budget.draw_calls', 300, { min: 1, max: 10000 });
586
+ tune('perf.budget.frame_ms_p95', 16.7, { min: 1, max: 100 });
587
+ tune('perf.budget.triangles', 2_000_000, { min: 1, max: 100_000_000 });
588
+ ```
589
+
590
+ Rationale: budgets live in **reviewed source** (a diff to a budget is loud in
591
+ a PR — the anti-gaming posture, §9), are visible in the inspector's Tunables
592
+ panel like any other tunable, and need no new config format.
593
+
594
+ ```
595
+ sloptimize check # reads profile.json (or runs a bench with --bench)
596
+ budgets: 3 checked, 1 breached
597
+ perf.budget.draw_calls 940 / 300 ← over by 3.1×
598
+ exit 1
599
+ ```
600
+
601
+ Exit `0` when all budgets pass, `1` on any breach, `4` when no measurement
602
+ exists to check against (never a silent pass). `--counters-only` restricts to
603
+ the exact grade for CI on GPU-less machines. In a project with no
604
+ `perf.budget.*` tunables, `check` reports "no budgets declared" and exits 0
605
+ with a warning — budgets are opt-in, but their absence is said out loud.
606
+
607
+ ---
608
+
609
+ ## 8. Agent integration — the whole point
610
+
611
+ ### 8.1 Surfaces, in order of preference
612
+
613
+ 1. **Files** (`.sloptimize/*`) — read with the agent's own Read tool. No
614
+ protocol, no server needed. The primary interface.
615
+ 2. **CLI** — every command supports `--json` and meaningful exits. What the
616
+ agent runs in Bash, what hooks wrap, what CI calls.
617
+ 3. **Hook** — `sloptimize hook-status` prints a ≤5-line block for
618
+ `UserPromptSubmit`: current regime, p95 vs budget, breached budgets, last
619
+ hitch classification. Installed alongside the inspector's selection hook;
620
+ silent (exit 0, no output) when nothing is running or nothing is breached
621
+ — ambient perf the way selection is ambient, and only when it matters.
622
+ 4. **MCP** (optional) — the live tier: `get_profile`, `get_census`,
623
+ `attribute_cost`, `run_bench`, `get_ledger`, `repro_hitch`. Same
624
+ structured-error-never-hang contract as the inspector's server.
625
+
626
+ ### 8.1.1 The push channel — auto mode
627
+
628
+ The surfaces above are pull: the agent reads when it acts or when a prompt
629
+ hook fires. The first field deployment closed the loop the other direction
630
+ — "auto identifies bottleneck, sends signal to claude code, I just play":
631
+
632
+ - The RECORDER is already the auto-detector (§3.3); no keypress is involved
633
+ in a hitch record existing.
634
+ - A session-side WATCHER (Claude Code's Monitor primitive, a 20s poll over
635
+ `perf.jsonl` with a byte cursor) turns each new meaningful record — an
636
+ auto hitch ≥100ms, any usermark, a coordinate jitter (§3.6) — into an
637
+ agent wake event carrying the classification line. The agent starts the
638
+ §8.2 playbook unprompted.
639
+ - `perf.jsonl`'s record schema is therefore a PUSH CONTRACT, not just an
640
+ audit trail: fields added to records surface directly in wake events.
641
+
642
+ Usermarks (§3.5) become the OPTIONAL manual channel of the same pipe:
643
+ auto mode covers everything over the threshold; Ctrl+F11 covers what only a
644
+ human can feel below it. Both arrive the same way and neither requires the
645
+ operator to type anything.
646
+
647
+ ### 8.2 Doctrine (SKILL.md + CLAUDE.md fragment)
648
+
649
+ Ships in-package (`skills/sloptimize/SKILL.md`) and is scaffolded into
650
+ projects the same way the inspector's skill is. The rules it must contain,
651
+ verbatim in spirit:
652
+
653
+ 1. **Never claim a performance fix without a bench comparison.** A fix
654
+ without a `bench --compare` verdict is a hypothesis.
655
+ 2. The playbook: read `perf.jsonl` → classify → `attribute` → **one change**
656
+ → `bench --compare` → report the verdict *and* distance-to-budget.
657
+ 3. Map verdicts mechanically: improved → commit; no-detectable-change →
658
+ revert; regressed → revert. No exceptions without the human.
659
+ 4. Read the ledger before optimizing — do not retry a reverted strategy.
660
+ 5. Trust grades: never quote a `timing` number from a `software` regime;
661
+ never present census estimates as measurements.
662
+ 6. Escalation seams: CPU-bound in script → Chrome DevTools MCP trace (which
663
+ function); need per-draw GL state → Spector capture (which command).
664
+ sloptimize answers *which entity, which frame, which workload* — only.
665
+
666
+ ### 8.3 The `/loop` iteration contract
667
+
668
+ The self-iteration mode this product exists for. One iteration:
669
+
670
+ ```
671
+ 1. sloptimize check --json → all inside? stop: done.
672
+ 2. read .sloptimize/bench-history.jsonl (ledger) + perf.jsonl + census.json
673
+ 3. pick ONE change, excluding strategies the ledger shows reverted
674
+ 4. apply the change (ordinary code edit)
675
+ 5. sloptimize bench --snapshot <canonical> --launch --compare last --json
676
+ 6. correctness gate (§8.4): fails → revert, record, count a strike
677
+ 7. verdict improved → commit (message carries the ledger line)
678
+ otherwise → revert
679
+ 8. stop conditions: budgets pass | 3 consecutive no-detectable-change |
680
+ 2 correctness strikes | iteration cap reached. Else loop.
681
+ ```
682
+
683
+ Every step is a command or a file read; no step requires judgment about
684
+ *whether* to proceed — only step 3 requires intelligence, which is the
685
+ division of labor: **the tool decides what is true; the agent decides what
686
+ to try.** The canonical bench snapshot is chosen by the human once (a
687
+ representative heavy scene) and named in the skill — the agent must not
688
+ invent its own benchmark workload (LM-generated perf tests miss the real
689
+ regression roughly half the time in the literature).
690
+
691
+ ### 8.4 The correctness gate
692
+
693
+ `sloptimize gate --snapshot <name>`: restore snapshot, `clock.step(1)`,
694
+ capture the frame, perceptual-diff (SSIM, threshold configurable, default
695
+ 0.98) against `goldens/<name>.png`. Exit 0 pass / 1 fail / 4 no golden.
696
+ Goldens are created **only** by an explicit `sloptimize gate --record`,
697
+ which the doctrine reserves for the human (or a human-approved step); the
698
+ skill forbids the agent from re-recording a golden to make a failure pass,
699
+ and a re-recorded golden is a loud artifact in any review of `.sloptimize/`
700
+ timestamps. The gate complements — never replaces — the project's own tests.
701
+
702
+ ### 8.5 The timeline and the fix ledger — showing the work
703
+
704
+ The ledger already holds the whole history of a deployment (every line
705
+ self-sufficient: `build`, `phase`, timestamp), so "has sloptimize actually
706
+ made the game faster" must be answerable by FOLDING it, never by a number
707
+ someone typed. Three surfaces, one fold (`src/history.js`, pure, shared by
708
+ node and the page):
709
+
710
+ - **Timeline** — `perf.jsonl` in equal time buckets: frame p95 and median,
711
+ draw calls / triangles / programs (medians of the heartbeats, which carry
712
+ the counters for this reason), hitch count and worst frame per bucket,
713
+ and the build that ran. Plus one measured window per build. CLI:
714
+ `sloptimize history [--json] [--buckets N]`; MCP: `get_history`.
715
+ - **Fix ledger** — `.sloptimize/fixes.jsonl`, append-only, one record per
716
+ verified fix: `title`, `issue`, `solution`, `commit`, `files`, `at`, and
717
+ `before`/`after` — each a **measured window** of the ledger (the previous
718
+ build vs the latest build with evidence by default; or any build name or
719
+ `<ISO>..<ISO>` range) carrying the window summary and a p95 series. CLI:
720
+ `sloptimize fix --title … [--issue … --solution … --commit … --before …
721
+ --after …]`; MCP: `record_fix`. The doctrine's last step.
722
+ - **The in-game debugger** (`src/panel.js`, `createPanel`) — the human's
723
+ Ctrl+F12 modal, four tabs: **Current Session** (this tab's incidents, each
724
+ with its footprint id, + the note box), **Issues** (§3.7: every incident
725
+ type grouped by footprint, `×N`, last seen, the situation as chips; a row
726
+ opens its history and the fixes applied), **Optimizations** (the strips
727
+ above on one time axis, build boundaries marked, a shared crosshair, and
728
+ one card per fix: date, commit, was → now, before/after sparklines and
729
+ deltas, merge/reject for proposals), **Settings** (the fix loop's
730
+ automation level). The host serves the ledger back over a dev-gated GET
731
+ (the reference: `/api/sloptimize/ledger`, the 2MB tail of `perf.jsonl` +
732
+ `fixes.jsonl`); the page folds it, so the Issues tab covers that tail and
733
+ the CLI covers the whole file.
734
+
735
+ Honesty rules, inherited: a bucket with no evidence draws a gap, never a
736
+ zero; a spike past a strip's ceiling (1.5 × p90) is drawn at the ceiling
737
+ with a caret and read out at its real value; one series per strip, never a
738
+ dual axis; every metric shown is lower-is-better and the delta is colored
739
+ by direction only.
740
+
741
+ ---
742
+
743
+ ## 9. Anti-gaming posture
744
+
745
+ A local agent with write access can edit budgets, goldens, this package, or
746
+ the ledger file itself. That cannot be prevented and this spec does not
747
+ pretend otherwise. It can be priced and exposed:
748
+
749
+ - Budgets in reviewed source (§7): moving a goalpost is a visible diff.
750
+ - The ledger is append-only by contract and machine-written; the CLI offers
751
+ no edit verb, and each entry carries the `configHash` — a comparison
752
+ against a mutated config self-identifies.
753
+ - Goldens regenerate only via the explicit human-reserved command (§8.4).
754
+ - Bench numbers are produced by this package's harness, not by
755
+ agent-authored probe code; the skill forbids hand-rolled measurement
756
+ scripts when the harness exists.
757
+ - The final backstop is unchanged from all agent work: a human reads the PR,
758
+ where the ledger lines in commit messages make the claimed trajectory
759
+ checkable in minutes.
760
+
761
+ ---
762
+
763
+ ## 10. Stated limits (what sloptimize will say it cannot do)
764
+
765
+ Printed by `sloptimize doctor`, documented in the README, never silently
766
+ degraded:
767
+
768
+ - **No per-draw GPU timing.** WebGL's timer extension allows one non-nested
769
+ query scope, results arrive frames late, and pipelined draws do not sum to
770
+ frame time. GPU ms is frame-level, Chromium-only, via stats-gl, or absent.
771
+ - **Fill-rate/overdraw attribution is heuristic only** (pixel-ratio
772
+ experiment: fps recovers when resolution drops → fill-bound), and the
773
+ verdict says "heuristic".
774
+ - **Costs from bisection do not sum** (§4.2) — ranking, not accounting.
775
+ - **Timing from software renderers is flagged and never compared** (§6.4).
776
+ - **Workload repro, not trajectory repro** (§5).
777
+ - **Simulation-CPU spikes attribute poorly** — the classification will say
778
+ `long-script` and the escalation seam points at Chrome DevTools MCP.
779
+ - **EffectComposer/custom-pipeline hosts** inherit the inspector's
780
+ interception gap; the recorder degrades to rAF-delta-only sampling there
781
+ and `doctor` names it.
782
+ - **Multiplayer** is out of scope for the same reason it is out of scope for
783
+ the inspector's pause: you cannot pause or restore the server.
784
+
785
+ ---
786
+
787
+ ## 11. Dependency policy
788
+
789
+ Hard runtime dependencies: **none** (the slopjs posture). Peers:
790
+ `@slopjs/inspector` (required), `three` (required),
791
+ `playwright-core` (optional — managed browser for `--launch`),
792
+ `stats-gl` (optional — GPU ms). Dev-only tooling may use whatever it needs.
793
+ The vite plugin activates only under `serve`; a production build contains
794
+ nothing of this package.
795
+
796
+ ---
797
+
798
+ ## 12. Milestones
799
+
800
+ - **M0 — recorder.** In-page runtime + vite plugin + `profile.json` +
801
+ `perf.jsonl` + hitch classification + `sloptimize doctor`. The platform
802
+ ask: `onFrame`. *Exit criterion: a hitch that happened before the panel was
803
+ ever opened is explained from the file alone.*
804
+ - **M1 — census + hints.** `census.json`, hint vocabulary, `sloptimize
805
+ attribute --static`, id→site resolution via `locate`. *Exit: the
806
+ instancing-candidate hint names a real site in a real vibe-coded game.*
807
+ - **M2 — budgets + check + hook.** `tune('perf.budget.*')` convention,
808
+ `sloptimize check` exit codes, `hook-status`. *Exit: CI fails a PR that
809
+ doubles draw calls, on a GPU-less runner, counters-only.*
810
+ - **M3 — bench + ledger + gate.** Snapshot-pinned bench, `--launch`,
811
+ compare verdicts, noise floors, `bench-history.jsonl`, the SSIM gate.
812
+ Platform asks: snapshot camera, `captureFrame`. *Exit: the same fix
813
+ benched twice yields the same verdict; a no-op change yields
814
+ no-detectable-change.*
815
+ - **M4 — bisection + MCP + doctrine.** `attribute_cost`, the MCP server, the
816
+ skill, the `/loop` contract end to end. *Exit: a `/loop` session takes a
817
+ seeded 2,000-draw-call scene inside budgets unattended, with a clean
818
+ ledger and zero gate failures.*
819
+
820
+ M0 is independently shippable and useful; each milestone is a publishable
821
+ release.
822
+
823
+ ---
824
+
825
+ ## 13. Open questions (tracked, not blocking M0)
826
+
827
+ 1. Should `hook-status` and the inspector's selection hook merge into one
828
+ block to save prompt tokens, or stay separable products? (Leaning:
829
+ separable, one line each.)
830
+ 2. Census texture-byte estimation for compressed formats (KTX2/basis) —
831
+ estimate decoded or on-disk size? (Leaning: decoded, labeled.)
832
+ 3. Does the bench restore path need `loadLayout` awareness for scenes built
833
+ from layout files, or does snapshot restore subsume it? (Needs a real
834
+ test scene.)
835
+ 4. WebGPU (`WebGPURenderer`): `renderer.info` parity is partial and the
836
+ interception point differs. Deferred until the inspector takes a position
837
+ on WebGPU; tracked here so it is not forgotten.
838
+ 5. Whether `attribute_cost` should also bisect **lights** (visibility
839
+ toggling a light changes compiled program count — measurable, but slow).
840
+
841
+ ---
842
+
843
+ *Derived from: the slopjs performance strategy (`docs/performance-strategy.md`
844
+ in the slopjs repo — audit of the inspector's current perf surface, two
845
+ research sweeps with citations, and the spin-out decision). August 2026.*