simframe 0.7.2 → 0.9.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/README.md +21 -4
- package/flows/hpi-suite.json +68 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +49 -1
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +7 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +11 -0
- package/native/simframed/Sources/SimframeCore/CaptureRecovery.swift +48 -0
- package/native/simframed/Sources/simframed/main.swift +128 -73
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +40 -0
- package/package.json +2 -1
- package/scripts/bench-hpi.mjs +254 -0
- package/scripts/check-package.mjs +7 -0
- package/scripts/ci-memory.mjs +15 -2
- package/src/actions.js +184 -4
- package/src/baseline.js +333 -0
- package/src/cli.js +336 -2
- package/src/daemon.js +9 -0
- package/src/fingerprint.js +7 -1
- package/src/graph.js +162 -1
- package/src/index.js +118 -20
- package/src/input.js +111 -1
- package/src/intent.js +11 -2
- package/src/matching.js +81 -2
- package/src/mcp.js +14 -1
- package/src/metrics.js +499 -0
- package/src/navigate.js +44 -7
- package/src/platform/android.js +27 -0
- package/src/platform/index.js +3 -0
- package/src/platform/ios.js +63 -0
- package/src/screenmap.js +36 -14
- package/src/view.js +4 -3
package/src/baseline.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// The human half of the Human Parity Index.
|
|
2
|
+
//
|
|
3
|
+
// HPI is a ratio and the human is its denominator, so nothing in this series
|
|
4
|
+
// produces a number until a person has performed the same flows on the same
|
|
5
|
+
// simulator. This module records those runs and reduces them to a committed
|
|
6
|
+
// median + IQR.
|
|
7
|
+
//
|
|
8
|
+
// One assumption in the research does not survive contact: §1 says human
|
|
9
|
+
// baseline collection is "essentially free" because HID events are already
|
|
10
|
+
// logged. That is true of the events simframe *injects*. A person tapping the
|
|
11
|
+
// Simulator window produces no HID log simframe can read — there is no such
|
|
12
|
+
// log on the host side. So the two numbers come from different places and are
|
|
13
|
+
// labelled accordingly:
|
|
14
|
+
//
|
|
15
|
+
// wall time measured, from an explicit start and an explicit stop
|
|
16
|
+
// step count derived from screen transitions in the frame history, which
|
|
17
|
+
// is an *estimate* in both directions and is recorded as
|
|
18
|
+
// `source: screen-transitions` rather than as taps
|
|
19
|
+
//
|
|
20
|
+
// That second point was filed here as a "lower bound" and the first real
|
|
21
|
+
// recording disproved it within the hour: a 4-tap Settings flow produced a
|
|
22
|
+
// median of 3 transitions (two taps merged inside one window) and a 2-tap
|
|
23
|
+
// Contacts flow produced 3 (one tap launched an app, whose launch animation
|
|
24
|
+
// and whose content arrived more than a window apart). It is neither an upper
|
|
25
|
+
// nor a lower bound. Nothing numeric rests on it — `min_steps` comes from the
|
|
26
|
+
// flow definition and `step_ratio` uses that — so it stays as a shape-of-the-run
|
|
27
|
+
// signal, correctly labelled.
|
|
28
|
+
//
|
|
29
|
+
// `min_steps` therefore comes from the authored flow definition, never from a
|
|
30
|
+
// human run. What the human run is authoritative about is time.
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import * as api from './index.js';
|
|
35
|
+
import * as input from './input.js';
|
|
36
|
+
import { launchApp, terminateApp } from './platform/index.js';
|
|
37
|
+
import * as metrics from './metrics.js';
|
|
38
|
+
import * as store from './store.js';
|
|
39
|
+
|
|
40
|
+
const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
/**
|
|
42
|
+
* The flow suite is a runtime input, not a test fixture: `simframe baseline`
|
|
43
|
+
* reads it. It lived under test/ for exactly one commit, which would have
|
|
44
|
+
* shipped a package whose new command died with ENOENT — `files` does not
|
|
45
|
+
* include test/. scripts/check-package.mjs now requires it.
|
|
46
|
+
*/
|
|
47
|
+
export const SUITE_FILE = path.join(PKG_ROOT, 'flows', 'hpi-suite.json');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where committed human baselines live.
|
|
51
|
+
*
|
|
52
|
+
* In a checkout that is docs/research/human-baselines, because the phase
|
|
53
|
+
* commits them and CI reads them from there. An installed package has no
|
|
54
|
+
* docs/, so it keeps its own under ~/.simframe rather than writing inside
|
|
55
|
+
* node_modules or pretending the directory exists.
|
|
56
|
+
*/
|
|
57
|
+
export function baselineDir() {
|
|
58
|
+
const repo = path.join(PKG_ROOT, 'docs', 'research', 'human-baselines');
|
|
59
|
+
return fs.existsSync(path.dirname(repo)) ? repo : path.join(store.ROOT, 'human-baselines');
|
|
60
|
+
}
|
|
61
|
+
export const BASELINE_DIR = baselineDir();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A screen change, not a pixel change. `MINOR_CHANGE` is a clock digit; a
|
|
65
|
+
* human tapping a row moves the whole screen, which is what MAJOR_CHANGE
|
|
66
|
+
* measures.
|
|
67
|
+
*/
|
|
68
|
+
export const CHANGE_THRESHOLD = api.MAJOR_CHANGE;
|
|
69
|
+
/**
|
|
70
|
+
* Frames closer together than this belong to the same transition.
|
|
71
|
+
*
|
|
72
|
+
* A push animation is ~300 ms of continuously changing frames and must count
|
|
73
|
+
* as one step. Human inter-tap intervals are an order of magnitude longer —
|
|
74
|
+
* research §1 puts a deliberate tester near a second — so the two do not
|
|
75
|
+
* overlap at 400 ms. Two taps genuinely inside one window read as one step,
|
|
76
|
+
* which is why this produces a lower bound and says so.
|
|
77
|
+
*/
|
|
78
|
+
export const TRANSITION_GAP_MS = 400;
|
|
79
|
+
|
|
80
|
+
export function loadSuite(file = SUITE_FILE) {
|
|
81
|
+
const suite = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
82
|
+
if (!Array.isArray(suite) || !suite.length) throw new Error(`${file}: not a flow suite`);
|
|
83
|
+
return suite;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function flowFrom(suite, name) {
|
|
87
|
+
const flow = suite.find((f) => f.name === name);
|
|
88
|
+
if (!flow) {
|
|
89
|
+
throw new Error(`no flow "${name}" in the suite. known: ${suite.map((f) => f.name).join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
return flow;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Group a frame history into transitions.
|
|
96
|
+
*
|
|
97
|
+
* Pure, so the grouping rule is testable without a simulator — which matters,
|
|
98
|
+
* because this is the rule that decides what a "step" was.
|
|
99
|
+
*/
|
|
100
|
+
export function transitionsIn(history, { from = 0, to = Infinity, threshold = CHANGE_THRESHOLD, gapMs = TRANSITION_GAP_MS } = {}) {
|
|
101
|
+
const changed = (history ?? [])
|
|
102
|
+
.filter((h) => h && h.at >= from && h.at <= to && Number(h.diff) > threshold)
|
|
103
|
+
.sort((a, b) => a.at - b.at);
|
|
104
|
+
const groups = [];
|
|
105
|
+
for (const h of changed) {
|
|
106
|
+
const last = groups[groups.length - 1];
|
|
107
|
+
if (last && h.at - last.endedAt <= gapMs) {
|
|
108
|
+
last.endedAt = h.at;
|
|
109
|
+
last.frames += 1;
|
|
110
|
+
last.peakDiff = Math.max(last.peakDiff, Number(h.diff));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
groups.push({ at: h.at, endedAt: h.at, frames: 1, peakDiff: Number(h.diff) });
|
|
114
|
+
}
|
|
115
|
+
return groups;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Gaps between the starts of consecutive transitions. */
|
|
119
|
+
export function intervalsBetween(transitions) {
|
|
120
|
+
return transitions.slice(1).map((t, i) => t.at - transitions[i].at);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Turn one recorded run into a record.
|
|
125
|
+
*
|
|
126
|
+
* `history_complete` is not decoration. The daemon keeps 90 s of frame
|
|
127
|
+
* history, so a run longer than that has transitions the log can no longer
|
|
128
|
+
* see, and its step count would be wrong while looking fine. A run that
|
|
129
|
+
* cannot be counted says so instead.
|
|
130
|
+
*/
|
|
131
|
+
export function runFrom({ flow, startedAt, endedAt, history, oldestHistoryAt = null }) {
|
|
132
|
+
const transitions = transitionsIn(history, { from: startedAt, to: endedAt });
|
|
133
|
+
const complete = oldestHistoryAt == null ? null : oldestHistoryAt <= startedAt;
|
|
134
|
+
return {
|
|
135
|
+
flow,
|
|
136
|
+
recorded_at: new Date(startedAt).toISOString(),
|
|
137
|
+
wall_time_ms: endedAt - startedAt,
|
|
138
|
+
steps_observed: transitions.length,
|
|
139
|
+
steps_source: 'screen-transitions',
|
|
140
|
+
interaction_intervals_ms: intervalsBetween(transitions),
|
|
141
|
+
transitions: transitions.map((t) => ({ at_ms: t.at - startedAt, frames: t.frames, peak_diff: Number(t.peakDiff.toFixed(4)) })),
|
|
142
|
+
history_complete: complete,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Put the device where the flow starts: the app not running, on the home
|
|
148
|
+
* screen.
|
|
149
|
+
*
|
|
150
|
+
* iOS restores an app to the screen you left it on, so without this the second
|
|
151
|
+
* recorded run of a Settings flow starts on its own destination and takes no
|
|
152
|
+
* time at all. It is deliberately not expressed as flow steps — terminating an
|
|
153
|
+
* app that is not running throws, and a reset is not a step anybody is timing.
|
|
154
|
+
*/
|
|
155
|
+
export async function resetFor(udid, flow) {
|
|
156
|
+
const reset = flow.reset ?? {};
|
|
157
|
+
const failures = [];
|
|
158
|
+
// Put the app back on its own root screen before killing it.
|
|
159
|
+
//
|
|
160
|
+
// iOS restores an app to the screen you left it on, and terminating it does
|
|
161
|
+
// not clear that: `settings-larger-text` failed 3 of 4 runs with
|
|
162
|
+
// `unexpected-screen` because Settings reopened on Larger Text — its own
|
|
163
|
+
// destination — so step 1 tapped "Accessibility" on a screen that has no
|
|
164
|
+
// such row. A human hits this too: one of the five recorded human runs
|
|
165
|
+
// measured 2.2 s with zero transitions, and it is the run that had to be
|
|
166
|
+
// excluded.
|
|
167
|
+
//
|
|
168
|
+
// Navigating back is reset work and is never timed, so it costs the
|
|
169
|
+
// measurement nothing and buys every run the same starting screen. Bounded,
|
|
170
|
+
// and it gives up quietly: a reset that cannot reach root reports it, and a
|
|
171
|
+
// failing run says more than a reset that loops.
|
|
172
|
+
if (reset.rootMarker && reset.launch) {
|
|
173
|
+
try {
|
|
174
|
+
await launchApp(udid, reset.launch, { terminateFirst: true });
|
|
175
|
+
for (let attempt = 0; attempt <= (reset.maxBack ?? 4); attempt += 1) {
|
|
176
|
+
await api.waitFor(udid, { mode: 'stable', stableMs: 350, timeoutMs: 3000 });
|
|
177
|
+
try {
|
|
178
|
+
await api.locate(udid, reset.rootMarker, { refresh: attempt > 0 });
|
|
179
|
+
break;
|
|
180
|
+
} catch {
|
|
181
|
+
const back = await api.locate(udid, 'back', { refresh: true });
|
|
182
|
+
await input.tapPoint(udid, back.target.x, back.target.y);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
failures.push(`could not return ${reset.launch} to its root screen: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const bundle of reset.terminate ?? []) {
|
|
190
|
+
try {
|
|
191
|
+
await terminateApp(udid, bundle);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
// Not running is the expected case, and it is indistinguishable here
|
|
194
|
+
// from a real failure. Both are reported rather than swallowed.
|
|
195
|
+
failures.push(`${bundle}: ${err.message}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (reset.home !== false) await input.pressButton(udid, 'home');
|
|
199
|
+
return { failures };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const runsFile = (udid, flow) => path.join(metrics.paths(udid).baselines, `${encodeURIComponent(flow)}.jsonl`);
|
|
203
|
+
|
|
204
|
+
export const recordRun = (udid, flow, run) => metrics.appendJsonl(runsFile(udid, flow), run);
|
|
205
|
+
export const readRuns = (udid, flow) => metrics.readJsonl(runsFile(udid, flow));
|
|
206
|
+
|
|
207
|
+
/** How few runs is not a baseline. §1 recommends N≥5; below three there is no IQR worth printing. */
|
|
208
|
+
/**
|
|
209
|
+
* Take a run out of the baseline without taking it out of the record.
|
|
210
|
+
*
|
|
211
|
+
* A wedged device produced four unusable runs the first time this was used on
|
|
212
|
+
* a real person: two slow ones, one that recorded 2.2 s and zero transitions,
|
|
213
|
+
* and one mid-recovery. Deleting them would have been the obvious move and the
|
|
214
|
+
* wrong one — a measurement log that gets edited when the numbers are
|
|
215
|
+
* inconvenient is not evidence. So the runs stay, carrying why they do not
|
|
216
|
+
* count, and `summarizeRuns` skips them. The alternative, a `--last=5` flag on
|
|
217
|
+
* summarize, was rejected: it puts the exclusion in the command that happened
|
|
218
|
+
* to be typed once rather than in the data, and the next person to summarize
|
|
219
|
+
* gets a different answer with no way to know it.
|
|
220
|
+
*/
|
|
221
|
+
export function markExcluded(runs, { keepLast, reason, at = Date.now() } = {}) {
|
|
222
|
+
if (!Number.isFinite(keepLast) || keepLast < 1) throw new Error('keepLast must be a positive number of runs');
|
|
223
|
+
const cut = Math.max(0, runs.length - keepLast);
|
|
224
|
+
return runs.map((run, i) => {
|
|
225
|
+
if (i >= cut || run.excluded) return run;
|
|
226
|
+
return { ...run, excluded: { reason: reason ?? 'unspecified', at: new Date(at).toISOString() } };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function excludeRuns(udid, flow, { keepLast, reason } = {}) {
|
|
231
|
+
const runs = readRuns(udid, flow);
|
|
232
|
+
const marked = markExcluded(runs, { keepLast, reason });
|
|
233
|
+
const file = runsFile(udid, flow);
|
|
234
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
235
|
+
store.writeAtomic(file, marked.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
|
236
|
+
return { file, total: marked.length, excluded: marked.filter((r) => r.excluded).length };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const MIN_RUNS = 3;
|
|
240
|
+
export const WANT_RUNS = 5;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Median and IQR over recorded runs.
|
|
244
|
+
*
|
|
245
|
+
* Refuses under three, per the phase prompt: a median of two numbers is one of
|
|
246
|
+
* them and an IQR of two is the range, and publishing either as a baseline
|
|
247
|
+
* invites a comparison it cannot support.
|
|
248
|
+
*/
|
|
249
|
+
export function summarizeRuns(flow, runs, { minSteps = null, device = null } = {}) {
|
|
250
|
+
const excluded = runs.filter((r) => r?.excluded);
|
|
251
|
+
const usable = runs.filter((r) => Number.isFinite(r?.wall_time_ms) && !r.excluded);
|
|
252
|
+
if (usable.length < MIN_RUNS) {
|
|
253
|
+
return { ok: false, reason: 'too-few-runs', runs: usable.length, need: MIN_RUNS };
|
|
254
|
+
}
|
|
255
|
+
const incomplete = usable.filter((r) => r.history_complete === false).length;
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
summary: {
|
|
259
|
+
flow,
|
|
260
|
+
device,
|
|
261
|
+
generated_at: new Date().toISOString(),
|
|
262
|
+
runs: usable.length,
|
|
263
|
+
// The number HPI divides by. Everything else here is context for it.
|
|
264
|
+
wall_time_ms: metrics.quartiles(usable.map((r) => r.wall_time_ms)),
|
|
265
|
+
steps_observed: metrics.quartiles(usable.map((r) => r.steps_observed)),
|
|
266
|
+
steps_source: 'screen-transitions',
|
|
267
|
+
min_steps: minSteps,
|
|
268
|
+
interaction_intervals_ms: metrics.quartiles(usable.flatMap((r) => r.interaction_intervals_ms ?? [])),
|
|
269
|
+
runs_with_incomplete_history: incomplete,
|
|
270
|
+
// Named in the committed baseline, not just in a shell history. A
|
|
271
|
+
// baseline that silently rests on a subset is a baseline nobody can
|
|
272
|
+
// check.
|
|
273
|
+
runs_recorded: runs.length,
|
|
274
|
+
runs_excluded: excluded.map((r) => ({ recorded_at: r.recorded_at, wall_time_ms: r.wall_time_ms, reason: r.excluded.reason })),
|
|
275
|
+
note: 'wall_time_ms is measured, from an explicit start and stop. steps_observed counts screen transitions and is an estimate of taps in BOTH directions — taps within 400ms merge into one, and a single tap that launches an app can produce two or three. Use min_steps, which comes from the flow definition. There is no host-readable HID log for human input, which is why the two numbers come from different places.',
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function writeSummary(summary, { dir = BASELINE_DIR } = {}) {
|
|
281
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
282
|
+
const file = path.join(dir, `${encodeURIComponent(summary.flow)}.json`);
|
|
283
|
+
store.writeAtomic(file, `${JSON.stringify(summary, null, 2)}\n`);
|
|
284
|
+
return file;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Every committed human baseline, keyed by flow name. */
|
|
288
|
+
export function readBaselines({ dir = BASELINE_DIR } = {}) {
|
|
289
|
+
let names;
|
|
290
|
+
try {
|
|
291
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
292
|
+
} catch {
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
const out = {};
|
|
296
|
+
for (const name of names) {
|
|
297
|
+
const body = store.readJson(path.join(dir, name));
|
|
298
|
+
if (body?.flow) out[body.flow] = body;
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Record one human run.
|
|
305
|
+
*
|
|
306
|
+
* `waitForStop` is injected rather than reading stdin here, so the thing that
|
|
307
|
+
* decides when the run ended is the CLI's problem and this stays testable.
|
|
308
|
+
* The frame history is read after the stop, which is the only reason this
|
|
309
|
+
* works at all: the daemon is already writing every frame down, so a human
|
|
310
|
+
* run needs no new capture path — only two timestamps and the honesty about
|
|
311
|
+
* what sits between them.
|
|
312
|
+
*/
|
|
313
|
+
export async function recordHumanRun(deviceQuery, flowName, { waitForStop, suite = loadSuite(), options } = {}) {
|
|
314
|
+
const flow = flowFrom(suite, flowName);
|
|
315
|
+
const { device } = await api.ensureDaemon(deviceQuery, options);
|
|
316
|
+
const udid = device.udid;
|
|
317
|
+
|
|
318
|
+
const startedAt = Date.now();
|
|
319
|
+
await waitForStop({ flow, device });
|
|
320
|
+
const endedAt = Date.now();
|
|
321
|
+
|
|
322
|
+
const state = store.readJson(store.paths(udid).state);
|
|
323
|
+
const history = state?.history ?? [];
|
|
324
|
+
const run = runFrom({
|
|
325
|
+
flow: flowName,
|
|
326
|
+
startedAt,
|
|
327
|
+
endedAt,
|
|
328
|
+
history,
|
|
329
|
+
oldestHistoryAt: history.length ? Math.min(...history.map((h) => h.at)) : null,
|
|
330
|
+
});
|
|
331
|
+
recordRun(udid, flowName, run);
|
|
332
|
+
return { device, flow, run, runs: readRuns(udid, flowName).length };
|
|
333
|
+
}
|