simframe 0.16.0 → 0.18.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,218 @@
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
+
25
+ /**
26
+ * How little agreement between the sensors counts as "these are different
27
+ * screens".
28
+ *
29
+ * **Measured, and the first version of this constant was justified with the
30
+ * wrong number.** It cited CLAUDE.md's "the tree and OCR agree on 0.33–0.47",
31
+ * which is a figure about *structural tokens* — a different quantity from the
32
+ * element-level fusion counted here. Printing a healthy band of 0.33–0.47 next
33
+ * to a live reading of 0.846 is how that got noticed.
34
+ *
35
+ * Sampled on `326464A4` (iPhone 17 Pro, iOS 26.5) across five real screens —
36
+ * Settings root, General, About, springboard, Reminders:
37
+ *
38
+ * 0.857 0.833 0.929 0.846 0.667 median 0.846
39
+ *
40
+ * 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
41
+ * sparsest screen, which is also the case `ENOUGH_TO_COMPARE` withholds
42
+ * judgement on. A threshold of 0.1 sits 6.7x below the observed floor: this is
43
+ * not a band inside the metric's own noise, which is the mistake the HPI_time
44
+ * gate made.
45
+ *
46
+ * What the collapse means: the tree is read live and in-process, OCR reads a
47
+ * framebuffer that can go stale without saying so. If both sensors report
48
+ * plenty and almost nothing fuses, they are looking at different screens, and
49
+ * the frame is the one that is behind. A peer hit exactly this — `sim_look`
50
+ * returned a web form from an earlier session on the device while the element
51
+ * map, taken at the same moment, correctly described the app in front of them.
52
+ */
53
+ export const DISAGREEMENT = 0.1;
54
+
55
+ /** The range observed on healthy screens, for the report to print honestly. */
56
+ export const HEALTHY_FUSION = '0.66-0.93';
57
+
58
+ /**
59
+ * Both sensors need at least this many elements before their disagreement means
60
+ * anything. A screen with one label from each cannot be said to disagree, and a
61
+ * springboard or a lock screen is legitimately sparse.
62
+ */
63
+ export const ENOUGH_TO_COMPARE = 3;
64
+
65
+ /** At or below this from both sensors, there is effectively nothing on screen. */
66
+ export const SPARSE = 2;
67
+
68
+ const seenBy = (targets, sensor) =>
69
+ targets.filter((t) => String(t.source ?? '').split('|').includes(sensor)).length;
70
+
71
+ /**
72
+ * Read everything that discriminates between the failure modes, in one pass.
73
+ *
74
+ * Every field here exists because it separates two hypotheses. Nothing is
75
+ * collected because it is interesting.
76
+ */
77
+ export async function snapshot(deviceQuery, { options } = {}) {
78
+ // **`ensureDaemon` throws on the loudest condition this module exists to
79
+ // name.** "the daemon is running and the display produced no frame in 60s" is
80
+ // reported as an exception, so the first version of this function propagated
81
+ // it and `simframe diagnose` died with a stack trace on a genuinely wedged
82
+ // device — the one moment it is worth running. Caught within an hour of
83
+ // shipping, by the device wedging.
84
+ //
85
+ // So a snapshot of a dead device is a snapshot, not an error. `classify`
86
+ // already has a verdict for it.
87
+ let device;
88
+ let state = null;
89
+ let daemonError = null;
90
+ try {
91
+ ({ device, state } = await api.ensureDaemon(deviceQuery, options));
92
+ } catch (err) {
93
+ daemonError = err.message;
94
+ device = { udid: String(deviceQuery ?? '?'), name: String(deviceQuery ?? 'unknown device') };
95
+ }
96
+ const udid = device.udid;
97
+ if (daemonError) {
98
+ return {
99
+ device: { udid, name: device.name },
100
+ readError: daemonError,
101
+ frame: null,
102
+ elements: { total: 0, ax: 0, ocr: 0, fused: 0 },
103
+ agreement: null,
104
+ frontmost: null,
105
+ };
106
+ }
107
+
108
+ let identity = null;
109
+ let readError = null;
110
+ try {
111
+ identity = await api.screenIdentity(udid, { options, confirmNovel: false });
112
+ } catch (err) {
113
+ readError = err.message;
114
+ }
115
+ const targets = identity?.entry?.targets ?? [];
116
+
117
+ // Who holds the front, by pid, which is item 169's contribution and the only
118
+ // sensor here that does not go through the display at all.
119
+ let front = null;
120
+ try {
121
+ front = await frontmost.read(udid);
122
+ } catch (err) {
123
+ front = { pid: null, title: null, error: err.message };
124
+ }
125
+
126
+ const ax = seenBy(targets, 'ax');
127
+ const ocr = seenBy(targets, 'ocr');
128
+ const fused = targets.filter((t) => {
129
+ const parts = String(t.source ?? '').split('|');
130
+ return parts.includes('ax') && parts.includes('ocr');
131
+ }).length;
132
+
133
+ return {
134
+ device: { udid, name: device.name },
135
+ readError,
136
+ frame: state
137
+ ? {
138
+ seq: state.seq ?? null,
139
+ ageMs: state.capturedAt ? Date.now() - state.capturedAt : null,
140
+ stableForMs: state.stableForMs ?? null,
141
+ size: state.width && state.height ? `${state.width}x${state.height}` : null,
142
+ hash: state.hash ? String(state.hash).slice(0, 8) : null,
143
+ }
144
+ : null,
145
+ elements: { total: targets.length, ax, ocr, fused },
146
+ // Null rather than a number when it cannot be computed, so a caller cannot
147
+ // read "0 agreement" off a screen nobody could compare.
148
+ agreement: ax > 0 && ocr > 0 ? Number((fused / Math.min(ax, ocr)).toFixed(3)) : null,
149
+ frontmost: front,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Name the state, or say it looks fine.
155
+ *
156
+ * Ordered most specific first, and each verdict says what it is evidence *of*
157
+ * rather than what to do about it — the same discipline as the escalation
158
+ * reasons, where a vocabulary that admits "other" collects a pile of "other".
159
+ */
160
+ export function classify(snap) {
161
+ if (!snap?.frame) {
162
+ return {
163
+ state: 'capture-down',
164
+ // The daemon's own words when it has them. They are more specific than
165
+ // anything derivable here — "produced no frame in 60s" distinguishes a
166
+ // live daemon over a dead display from a daemon that is not running.
167
+ detail: snap?.readError
168
+ ? `capture is not producing frames: ${snap.readError}`
169
+ : 'no frame state at all — the daemon is not producing frames',
170
+ revive: true,
171
+ };
172
+ }
173
+ const { total, ax, ocr } = snap.elements ?? { total: 0, ax: 0, ocr: 0 };
174
+ if (snap.readError) {
175
+ return { state: 'read-failed', detail: `the screen could not be read: ${snap.readError}`, revive: true };
176
+ }
177
+ if (total === 0) {
178
+ return {
179
+ state: 'nothing-readable',
180
+ detail: 'neither the accessibility tree nor OCR found a single element',
181
+ revive: true,
182
+ };
183
+ }
184
+ // The two sensors are describing different screens. The tree is read live and
185
+ // in-process; OCR reads the framebuffer. So the frame is the stale one.
186
+ if (ax >= ENOUGH_TO_COMPARE && ocr >= ENOUGH_TO_COMPARE
187
+ && snap.agreement != null && snap.agreement <= DISAGREEMENT) {
188
+ return {
189
+ state: 'stale-frame',
190
+ detail: `the tree found ${ax} element(s) and OCR found ${ocr}, and they fuse on `
191
+ + `${snap.agreement} of them (measured healthy: ${HEALTHY_FUSION}). The tree is read live, `
192
+ + 'so the framebuffer is the one that is behind — an image from this device is not safe to trust',
193
+ revive: true,
194
+ };
195
+ }
196
+ // An app holds the front, and the display is showing almost nothing. This is
197
+ // the CI signature that `device-state.mjs` recognises as "a clock and nothing
198
+ // else", now stated as an observation. It deliberately does NOT claim to know
199
+ // whether this is a lock screen, a dead surface or a crashed SpringBoard —
200
+ // that is the next question, and pretending to answer it is what put a regex
201
+ // where a measurement belongs.
202
+ if (snap.frontmost?.pid && ax <= SPARSE && ocr <= SPARSE) {
203
+ return {
204
+ state: 'not-presenting',
205
+ detail: `pid ${snap.frontmost.pid}`
206
+ + `${snap.frontmost.title ? ` (${snap.frontmost.title})` : ''} holds the front, but the `
207
+ + `display shows ${total} element(s). Something is in front of the app, or the display is `
208
+ + 'not painting it. Which of those it is, is not knowable from here',
209
+ revive: true,
210
+ };
211
+ }
212
+ return { state: 'healthy', detail: `${total} element(s), sensors agree on ${snap.agreement ?? 'n/a'}`, revive: false };
213
+ }
214
+
215
+ export async function diagnose(deviceQuery, { options } = {}) {
216
+ const snap = await snapshot(deviceQuery, { options });
217
+ return { ...snap, verdict: classify(snap) };
218
+ }