simframe 0.17.0 → 0.19.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/wedge.js ADDED
@@ -0,0 +1,370 @@
1
+ // What is this device actually doing right now?
2
+ //
3
+ // `doctor` answers "can this machine capture". This answers a different
4
+ // question, and it is the one item 173 has never been able to answer: when a
5
+ // device stops presenting the app, *which* of several failures is it?
6
+ //
7
+ // The reason this exists is that the evidence has been arriving as a
8
+ // consequence rather than an observation. `scripts/device-state.mjs` recognises
9
+ // a wedge from the shape of a tour's failure text — "two labels, one of them a
10
+ // clock, on a still screen" — and names it "a launched app never came to the
11
+ // front". That classification is useful for deciding whether to revive, and it
12
+ // is not a diagnosis: it cannot tell a dead framebuffer from a device that is
13
+ // genuinely showing a lock screen from an app that never fronted. Three
14
+ // hypotheses, one symptom, and a regex standing where a measurement should be.
15
+ //
16
+ // So the facts get gathered at the moment of the wedge instead of inferred
17
+ // afterwards, and the classifier over them is a **pure function** with fixtures.
18
+ // That part is deliberate: `device-state.mjs` records that "two runtime bugs in
19
+ // this project came from logic that was correct and had never executed",
20
+ // because the only thing exercising it was a hosted runner at minute fifteen of
21
+ // a job. Nothing here needs a device to be tested.
22
+ import * as api from './index.js';
23
+ import * as frontmost from './frontmost.js';
24
+ import * as input from './input.js';
25
+ import { launchApp, restartDevice } from './platform/index.js';
26
+
27
+ /**
28
+ * How little agreement between the sensors counts as "these are different
29
+ * screens".
30
+ *
31
+ * **Measured, and the first version of this constant was justified with the
32
+ * wrong number.** It cited CLAUDE.md's "the tree and OCR agree on 0.33–0.47",
33
+ * which is a figure about *structural tokens* — a different quantity from the
34
+ * element-level fusion counted here. Printing a healthy band of 0.33–0.47 next
35
+ * to a live reading of 0.846 is how that got noticed.
36
+ *
37
+ * Sampled on `326464A4` (iPhone 17 Pro, iOS 26.5) across five real screens —
38
+ * Settings root, General, About, springboard, Reminders:
39
+ *
40
+ * 0.857 0.833 0.929 0.846 0.667 median 0.846
41
+ *
42
+ * So healthy element fusion is **0.67-0.93** (stated as 0.66 so a reading at the floor does not print as below it), and the floor belongs to the
43
+ * sparsest screen, which is also the case `ENOUGH_TO_COMPARE` withholds
44
+ * judgement on. A threshold of 0.1 sits 6.7x below the observed floor: this is
45
+ * not a band inside the metric's own noise, which is the mistake the HPI_time
46
+ * gate made.
47
+ *
48
+ * What the collapse means: the tree is read live and in-process, OCR reads a
49
+ * framebuffer that can go stale without saying so. If both sensors report
50
+ * plenty and almost nothing fuses, they are looking at different screens, and
51
+ * the frame is the one that is behind. A peer hit exactly this — `sim_look`
52
+ * returned a web form from an earlier session on the device while the element
53
+ * map, taken at the same moment, correctly described the app in front of them.
54
+ */
55
+ export const DISAGREEMENT = 0.1;
56
+
57
+ /**
58
+ * The range observed on healthy screens, for the report to print honestly.
59
+ *
60
+ * **Widened 2026-09-18, because the first sample was too narrow and the output
61
+ * was contradicting itself.** A field reporter watched `diagnose` print
62
+ * `healthy` twice at **0.567** and **0.615** on the same line as "measured
63
+ * healthy 0.66-0.93", and was right to call that out: a verdict and the band
64
+ * printed beside it should not disagree.
65
+ *
66
+ * Neither number was wrong. The verdict is correct — the threshold that decides
67
+ * it is `DISAGREEMENT`, 0.1, and 0.567 is nowhere near it. The *band* was the
68
+ * wrong thing: it was sampled on five Apple system screens (Settings, General,
69
+ * About, springboard, Reminders), which are uniformly well-labelled, and a real
70
+ * third-party app fuses lower because more of its content is OCR-only. Same
71
+ * afternoon on this device, on system screens: 0.7, 0.846, 0.857 — consistent
72
+ * with the original sample. The 0.567 and 0.615 come from an app.
73
+ *
74
+ * So it is stated as what it is: a range observed across screens, with the floor
75
+ * from an app rather than from Apple's own UI.
76
+ *
77
+ * **And then it was widened once and immediately contradicted again**: the next
78
+ * device read after that change returned **0.471** on a Settings screen with 34
79
+ * elements, 25 by tree and 17 by OCR. Which settles what this constant is for.
80
+ * It is not a health standard — fusion tracks how much of a screen is OCR-only,
81
+ * that is a property of the app, and any fixed band will be below some real
82
+ * screen. `diagnose` no longer prints it on the healthy path for that reason.
83
+ * It survives in the `stale-frame` message, where a reading of 0.03 wants the
84
+ * contrast, and in `docs/BENCHMARKS.md` as evidence rather than a threshold.
85
+ *
86
+ * The number that decides anything is `DISAGREEMENT`, and it is 4.7x below even
87
+ * the lowest healthy reading yet seen.
88
+ */
89
+ export const HEALTHY_FUSION = '0.47-0.93';
90
+
91
+ /**
92
+ * Both sensors need at least this many elements before their disagreement means
93
+ * anything. A screen with one label from each cannot be said to disagree, and a
94
+ * springboard or a lock screen is legitimately sparse.
95
+ */
96
+ export const ENOUGH_TO_COMPARE = 3;
97
+
98
+ /** At or below this from both sensors, there is effectively nothing on screen. */
99
+ export const SPARSE = 2;
100
+
101
+ const seenBy = (targets, sensor) =>
102
+ targets.filter((t) => String(t.source ?? '').split('|').includes(sensor)).length;
103
+
104
+ /**
105
+ * Read everything that discriminates between the failure modes, in one pass.
106
+ *
107
+ * Every field here exists because it separates two hypotheses. Nothing is
108
+ * collected because it is interesting.
109
+ */
110
+ export async function snapshot(deviceQuery, { options } = {}) {
111
+ // **`ensureDaemon` throws on the loudest condition this module exists to
112
+ // name.** "the daemon is running and the display produced no frame in 60s" is
113
+ // reported as an exception, so the first version of this function propagated
114
+ // it and `simframe diagnose` died with a stack trace on a genuinely wedged
115
+ // device — the one moment it is worth running. Caught within an hour of
116
+ // shipping, by the device wedging.
117
+ //
118
+ // So a snapshot of a dead device is a snapshot, not an error. `classify`
119
+ // already has a verdict for it.
120
+ let device;
121
+ let state = null;
122
+ let daemonError = null;
123
+ try {
124
+ ({ device, state } = await api.ensureDaemon(deviceQuery, options));
125
+ } catch (err) {
126
+ daemonError = err.message;
127
+ device = { udid: String(deviceQuery ?? '?'), name: String(deviceQuery ?? 'unknown device') };
128
+ }
129
+ const udid = device.udid;
130
+ if (daemonError) {
131
+ return {
132
+ device: { udid, name: device.name },
133
+ readError: daemonError,
134
+ frame: null,
135
+ elements: { total: 0, ax: 0, ocr: 0, fused: 0 },
136
+ agreement: null,
137
+ frontmost: null,
138
+ };
139
+ }
140
+
141
+ let identity = null;
142
+ let readError = null;
143
+ try {
144
+ identity = await api.screenIdentity(udid, { options, confirmNovel: false });
145
+ } catch (err) {
146
+ readError = err.message;
147
+ }
148
+ const targets = identity?.entry?.targets ?? [];
149
+
150
+ // Who holds the front, by pid, which is item 169's contribution and the only
151
+ // sensor here that does not go through the display at all.
152
+ let front = null;
153
+ try {
154
+ front = await frontmost.read(udid);
155
+ } catch (err) {
156
+ front = { pid: null, title: null, error: err.message };
157
+ }
158
+
159
+ const ax = seenBy(targets, 'ax');
160
+ const ocr = seenBy(targets, 'ocr');
161
+ const fused = targets.filter((t) => {
162
+ const parts = String(t.source ?? '').split('|');
163
+ return parts.includes('ax') && parts.includes('ocr');
164
+ }).length;
165
+
166
+ return {
167
+ device: { udid, name: device.name },
168
+ readError,
169
+ frame: state
170
+ ? {
171
+ seq: state.seq ?? null,
172
+ ageMs: state.capturedAt ? Date.now() - state.capturedAt : null,
173
+ stableForMs: state.stableForMs ?? null,
174
+ size: state.width && state.height ? `${state.width}x${state.height}` : null,
175
+ hash: state.hash ? String(state.hash).slice(0, 8) : null,
176
+ }
177
+ : null,
178
+ elements: { total: targets.length, ax, ocr, fused },
179
+ // Null rather than a number when it cannot be computed, so a caller cannot
180
+ // read "0 agreement" off a screen nobody could compare.
181
+ agreement: ax > 0 && ocr > 0 ? Number((fused / Math.min(ax, ocr)).toFixed(3)) : null,
182
+ frontmost: front,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Name the state, or say it looks fine.
188
+ *
189
+ * Ordered most specific first, and each verdict says what it is evidence *of*
190
+ * rather than what to do about it — the same discipline as the escalation
191
+ * reasons, where a vocabulary that admits "other" collects a pile of "other".
192
+ */
193
+ export function classify(snap) {
194
+ if (!snap?.frame) {
195
+ return {
196
+ state: 'capture-down',
197
+ // The daemon's own words when it has them. They are more specific than
198
+ // anything derivable here — "produced no frame in 60s" distinguishes a
199
+ // live daemon over a dead display from a daemon that is not running.
200
+ detail: snap?.readError
201
+ ? `capture is not producing frames: ${snap.readError}`
202
+ : 'no frame state at all — the daemon is not producing frames',
203
+ revive: true,
204
+ };
205
+ }
206
+ const { total, ax, ocr } = snap.elements ?? { total: 0, ax: 0, ocr: 0 };
207
+ if (snap.readError) {
208
+ return { state: 'read-failed', detail: `the screen could not be read: ${snap.readError}`, revive: true };
209
+ }
210
+ if (total === 0) {
211
+ return {
212
+ state: 'nothing-readable',
213
+ detail: 'neither the accessibility tree nor OCR found a single element',
214
+ revive: true,
215
+ };
216
+ }
217
+ // The two sensors are describing different screens. The tree is read live and
218
+ // in-process; OCR reads the framebuffer. So the frame is the stale one.
219
+ if (ax >= ENOUGH_TO_COMPARE && ocr >= ENOUGH_TO_COMPARE
220
+ && snap.agreement != null && snap.agreement <= DISAGREEMENT) {
221
+ return {
222
+ state: 'stale-frame',
223
+ detail: `the tree found ${ax} element(s) and OCR found ${ocr}, and they fuse on `
224
+ + `${snap.agreement} of them (measured healthy: ${HEALTHY_FUSION}). The tree is read live, `
225
+ + 'so the framebuffer is the one that is behind — an image from this device is not safe to trust',
226
+ revive: true,
227
+ };
228
+ }
229
+ // One sensor is reading and the other is silent.
230
+ //
231
+ // **Found within an hour of shipping this classifier, by it calling a device
232
+ // `healthy` while the accessibility tree returned nothing at all** — 0 by
233
+ // tree, 20 by OCR. `agreement` is null unless both sensors report something,
234
+ // so the disagreement check above cannot fire on this, and nothing else was
235
+ // looking. A classifier blind to a whole sensor being dead is worse than no
236
+ // classifier, because it answers.
237
+ //
238
+ // It is not cosmetic. CLAUDE.md's perception order makes the tree
239
+ // authoritative when present, so a silent tree means every intent resolves
240
+ // against OCR alone — the reading quality a field report called "materially
241
+ // less reliable" on web content, applied to the whole device, with nothing
242
+ // saying so.
243
+ if (total >= ENOUGH_TO_COMPARE && (ax === 0 || ocr === 0)) {
244
+ const treeDead = ax === 0;
245
+ return {
246
+ state: treeDead ? 'tree-silent' : 'ocr-silent',
247
+ detail: `${treeDead ? 'OCR' : 'the accessibility tree'} found ${Math.max(ax, ocr)} element(s)`
248
+ + ` and ${treeDead ? 'the accessibility tree' : 'OCR'} found none.`
249
+ + (treeDead
250
+ ? ' The tree is authoritative when present, so every intent is now resolving against'
251
+ + ' OCR alone — materially less reliable, and nothing else says so.'
252
+ : ' Frames are not being read, so anything that needs pixels is unavailable.'),
253
+ revive: true,
254
+ };
255
+ }
256
+ // An app holds the front, and the display is showing almost nothing. This is
257
+ // the CI signature that `device-state.mjs` recognises as "a clock and nothing
258
+ // else", now stated as an observation. It deliberately does NOT claim to know
259
+ // whether this is a lock screen, a dead surface or a crashed SpringBoard —
260
+ // that is the next question, and pretending to answer it is what put a regex
261
+ // where a measurement belongs.
262
+ if (snap.frontmost?.pid && ax <= SPARSE && ocr <= SPARSE) {
263
+ return {
264
+ state: 'not-presenting',
265
+ detail: `pid ${snap.frontmost.pid}`
266
+ + `${snap.frontmost.title ? ` (${snap.frontmost.title})` : ''} holds the front, but the `
267
+ + `display shows ${total} element(s). Something is in front of the app, or the display is `
268
+ + 'not painting it. Which of those it is, is not knowable from here',
269
+ revive: true,
270
+ };
271
+ }
272
+ return { state: 'healthy', detail: `${total} element(s), sensors agree on ${snap.agreement ?? 'n/a'}`, revive: false };
273
+ }
274
+
275
+ /**
276
+ * States that mean the device cannot be driven at all, as opposed to states
277
+ * that mean it is degraded and should be said out loud.
278
+ *
279
+ * The distinction earns its place in `revive`. Demanding `healthy` there turned
280
+ * a **transient** `tree-silent` into a hard failure and a non-zero exit — and it
281
+ * is transient: observed surviving a full revive, then clearing after any app
282
+ * launch, with the springboard reading 13 tree elements again afterwards. A
283
+ * device whose tree is briefly silent still taps, still reads by OCR, and still
284
+ * recovers. Failing it is the false-refusal shape of item 175.
285
+ */
286
+ export const UNUSABLE = new Set(['capture-down', 'nothing-readable', 'read-failed']);
287
+
288
+ export async function diagnose(deviceQuery, { options } = {}) {
289
+ const snap = await snapshot(deviceQuery, { options });
290
+ return { ...snap, verdict: classify(snap) };
291
+ }
292
+
293
+
294
+ /**
295
+ * How hard a revive looks before calling a device unhealthy.
296
+ *
297
+ * A device seconds out of a boot is still settling, and one reading would make
298
+ * that a false alarm — the failure shape of item 175.
299
+ */
300
+ export const REVIVE_HEALTH_POLLS = 5;
301
+ export const REVIVE_HEALTH_WAIT_MS = 2000;
302
+
303
+ /**
304
+ * What a revive launches to bring a silent accessibility tree back, and the one
305
+ * platform it makes sense on.
306
+ *
307
+ * Settings, because it is on every iOS simulator and opening it changes nothing
308
+ * a caller could be relying on. The app is incidental — what clears the tree is
309
+ * that *something* launched. Android is excluded by name rather than by
310
+ * accident: it has no accessibility tree at all and says so, so `tree-silent`
311
+ * there is the backend's normal state and not a fault.
312
+ */
313
+ export const REVIVE_LAUNCH = { ios: 'com.apple.Preferences' };
314
+
315
+ /**
316
+ * Power-cycle a device and say what state it came back in.
317
+ *
318
+ * The order is load bearing and was learned by hand: stop the daemon, shut the
319
+ * device down, boot it and *wait for the boot to finish*, start capture, rebuild
320
+ * the HID session. Any other order leaves a daemon holding a dead device.
321
+ *
322
+ * Lifted out of `cli.js` on 2026-09-18 so the bench can use it between passes.
323
+ * That is not tidying: `bench-hpi` runs `passes x runs` consecutively with no
324
+ * recovery in between, which on this device is 30 runs against a tolerance of
325
+ * roughly 8-10, so a three-pass suite has never been physically completable and
326
+ * the gate has never produced a hosted-runner reading. DEFERRED 173 listed a
327
+ * revive between passes as worth trying and nothing had tried it.
328
+ *
329
+ * `onStep` is called with each step's outcome so a CLI can print it live and a
330
+ * script can stay quiet.
331
+ */
332
+ export async function revive(deviceQuery, { options, onStep = () => {}, device } = {}) {
333
+ const dev = device ?? { udid: String(deviceQuery), name: String(deviceQuery), platform: 'ios' };
334
+ const steps = [];
335
+ const did = async (what, fn) => {
336
+ try {
337
+ await fn();
338
+ steps.push({ step: what, ok: true });
339
+ onStep({ step: what, ok: true });
340
+ } catch (err) {
341
+ steps.push({ step: what, ok: false, error: err.message });
342
+ onStep({ step: what, ok: false, error: err.message });
343
+ }
344
+ };
345
+ // Forced: the point of this is that the device is wedged, so something is
346
+ // certainly still holding it.
347
+ await did('stopped the daemon', async () => { api.stopDaemon(dev.udid, { force: true }); });
348
+ await did('restarted the device, and waited for the boot to finish', () => restartDevice(dev.udid));
349
+ await did('started capture', () => api.ensureDaemon(dev.udid));
350
+ await did('rebuilt the HID session', () => input.resetSession(dev.udid));
351
+
352
+ let verdict = null;
353
+ for (let attempt = 0; attempt < REVIVE_HEALTH_POLLS; attempt += 1) {
354
+ verdict = classify(await snapshot(dev.udid, { options }).catch(() => null));
355
+ if (verdict?.state === 'healthy') break;
356
+ if (attempt < REVIVE_HEALTH_POLLS - 1) await new Promise((r) => setTimeout(r, REVIVE_HEALTH_WAIT_MS));
357
+ }
358
+ // A silent tree does not heal with time and does heal with a launch — six
359
+ // reads over 60s at 0 elements, then 14 immediately after one launch.
360
+ const bundle = REVIVE_LAUNCH[dev.platform];
361
+ if (verdict?.state === 'tree-silent' && bundle) {
362
+ await did('launched an app, which is what brings a silent tree back', async () => {
363
+ await launchApp(dev.udid, bundle, { args: [], env: {} });
364
+ await new Promise((r) => setTimeout(r, REVIVE_HEALTH_WAIT_MS));
365
+ });
366
+ verdict = classify(await snapshot(dev.udid, { options }).catch(() => null)) ?? verdict;
367
+ }
368
+ const state = verdict?.state ?? 'read-failed';
369
+ return { device: dev, steps, verdict, state, usable: !UNUSABLE.has(state), healthy: state === 'healthy' };
370
+ }