simframe 0.8.0 → 0.10.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 +31 -3
- 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/check-private.mjs +143 -0
- package/scripts/eval-perception.mjs +248 -0
- package/src/actions.js +333 -14
- package/src/analyze.js +70 -0
- package/src/baseline.js +333 -0
- package/src/cli.js +410 -4
- package/src/daemon.js +9 -0
- package/src/fingerprint.js +7 -1
- package/src/graph.js +262 -1
- package/src/index.js +335 -22
- package/src/input.js +155 -1
- package/src/intent.js +11 -2
- package/src/matching.js +136 -4
- package/src/mcp.js +14 -1
- package/src/metrics.js +596 -0
- package/src/navigate.js +47 -7
- package/src/platform/android.js +16 -1
- package/src/platform/index.js +3 -0
- package/src/platform/ios.js +40 -0
- package/src/screenmap.js +55 -14
- package/src/view.js +65 -3
package/src/graph.js
CHANGED
|
@@ -9,11 +9,75 @@ import path from 'node:path';
|
|
|
9
9
|
import { hashDistance } from './analyze.js';
|
|
10
10
|
import { informative } from './refs.js';
|
|
11
11
|
import * as fingerprint from './fingerprint.js';
|
|
12
|
+
import * as metrics from './metrics.js';
|
|
12
13
|
import * as matching from './matching.js';
|
|
13
14
|
import * as store from './store.js';
|
|
14
15
|
|
|
15
16
|
const GRAPH_VERSION = 3;
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* How many observed settle durations an edge remembers. Research §7.
|
|
20
|
+
*
|
|
21
|
+
* Fifty is a window, not a history: an app that got faster after an update
|
|
22
|
+
* should stop being waited for at its old speed, and a mean over everything
|
|
23
|
+
* ever observed never forgets.
|
|
24
|
+
*/
|
|
25
|
+
export const TIMING_WINDOW = 50;
|
|
26
|
+
/** Below this many samples an edge has no distribution worth trusting. */
|
|
27
|
+
export const COLD_SAMPLES = 5;
|
|
28
|
+
/**
|
|
29
|
+
* What a cold edge waits: exactly what every step waited before Phase 11.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately unchanged, so the first traversal of an edge behaves as it
|
|
32
|
+
* always did and only a *measured* edge gets a tighter bound. A conservative
|
|
33
|
+
* default that is also the historical default cannot make anything worse.
|
|
34
|
+
*/
|
|
35
|
+
export const COLD_TIMEOUT_MS = 8000;
|
|
36
|
+
/**
|
|
37
|
+
* The hard cap on waiting, from research §7: Nielsen's attention limit. Past
|
|
38
|
+
* ten seconds a person has stopped believing the screen is coming, and so
|
|
39
|
+
* should the agent — it escalates instead.
|
|
40
|
+
*/
|
|
41
|
+
export const HARD_CAP_MS = 10_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How long to wait for a transition that has been measured.
|
|
45
|
+
*
|
|
46
|
+
* p95 plus a margin, where the margin is the larger of 150 ms and a fifth of
|
|
47
|
+
* p95. The floor matters for fast edges: a tab switch with a p95 of 90 ms
|
|
48
|
+
* would otherwise get a 108 ms budget, and one slow frame would call a
|
|
49
|
+
* perfectly ordinary transition a timeout.
|
|
50
|
+
*/
|
|
51
|
+
export function adaptiveTimeout({ p95, samples } = {}) {
|
|
52
|
+
if (!Number.isFinite(p95) || !Number.isFinite(samples) || samples < COLD_SAMPLES) {
|
|
53
|
+
return { timeoutMs: COLD_TIMEOUT_MS, cold: true, reason: `fewer than ${COLD_SAMPLES} samples` };
|
|
54
|
+
}
|
|
55
|
+
const margin = Math.max(150, Math.round(p95 * 0.2));
|
|
56
|
+
return { timeoutMs: Math.min(HARD_CAP_MS, p95 + margin), cold: false, reason: null, margin };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is this transition taking longer than this edge usually does?
|
|
61
|
+
*
|
|
62
|
+
* Two different answers hide behind a slow transition, and §7 asks for both:
|
|
63
|
+
* a screen that is *working* (a spinner, a load) should be waited for up to
|
|
64
|
+
* the hard cap, while a screen that is doing nothing visible has already
|
|
65
|
+
* given its answer. The classifier's `loading` kind is what separates them.
|
|
66
|
+
*/
|
|
67
|
+
export function slowerThanUsual({ elapsedMs, p95, settled, kind } = {}) {
|
|
68
|
+
if (settled || !Number.isFinite(elapsedMs) || !Number.isFinite(p95)) return { slower: false };
|
|
69
|
+
if (elapsedMs <= p95) return { slower: false };
|
|
70
|
+
const working = kind === 'loading';
|
|
71
|
+
return {
|
|
72
|
+
slower: true,
|
|
73
|
+
working,
|
|
74
|
+
keepWaiting: working && elapsedMs < HARD_CAP_MS,
|
|
75
|
+
note: working
|
|
76
|
+
? `slower than usual (${elapsedMs}ms against a p95 of ${p95}ms) and still loading`
|
|
77
|
+
: `slower than usual (${elapsedMs}ms against a p95 of ${p95}ms) with nothing visibly happening`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
17
81
|
/**
|
|
18
82
|
* Which fingerprint produced the hashes in these files.
|
|
19
83
|
*
|
|
@@ -276,7 +340,193 @@ export function findScreen(udid, query) {
|
|
|
276
340
|
}
|
|
277
341
|
|
|
278
342
|
/** Remember that doing `action` on `from` led to `to`. */
|
|
279
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Add one observed settle duration to an edge's rolling window.
|
|
345
|
+
*
|
|
346
|
+
* Kept on the edge rather than in a separate store because it is a property of
|
|
347
|
+
* this transition on this screen — the same tap costs 90 ms on a tab bar and
|
|
348
|
+
* 2.4 s on a screen that fetches — and because the graph is already persisted,
|
|
349
|
+
* versioned and pruned.
|
|
350
|
+
*/
|
|
351
|
+
function noteSettle(edge, settleMs, quietGapMs, focusMs) {
|
|
352
|
+
if (Number.isFinite(settleMs) && settleMs >= 0) {
|
|
353
|
+
edge.settles = [...(edge.settles ?? []), Math.round(settleMs)].slice(-TIMING_WINDOW);
|
|
354
|
+
}
|
|
355
|
+
// Recorded even when zero: "this transition never paused" is exactly the
|
|
356
|
+
// observation that lets the next one stop waiting 500ms to find out.
|
|
357
|
+
if (Number.isFinite(quietGapMs) && quietGapMs >= 0) {
|
|
358
|
+
edge.quietGaps = [...(edge.quietGaps ?? []), Math.round(quietGapMs)].slice(-TIMING_WINDOW);
|
|
359
|
+
}
|
|
360
|
+
// How long the *field* took to take focus, which is a different duration from
|
|
361
|
+
// how long the step took: it is measured between the tap and the keyboard,
|
|
362
|
+
// inside a step whose settle is measured after the typing. One edge, two
|
|
363
|
+
// waits, so two distributions.
|
|
364
|
+
if (Number.isFinite(focusMs) && focusMs >= 0) {
|
|
365
|
+
edge.focuses = [...(edge.focuses ?? []), Math.round(focusMs)].slice(-TIMING_WINDOW);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Record the unbiased pause statistic for an edge already written.
|
|
371
|
+
*
|
|
372
|
+
* Separate from `record` because it arrives later on purpose. The biased
|
|
373
|
+
* `quietGaps` are gathered from inside the wait and are therefore bounded by
|
|
374
|
+
* when the wait chose to stop; `trueGaps` are read off the frame history once
|
|
375
|
+
* the transition is definitely over, which is one step later. So the edge has
|
|
376
|
+
* to be found again rather than passed along.
|
|
377
|
+
*
|
|
378
|
+
* Nothing reads `trueGaps` yet, and that is deliberate. Phase 11 built the
|
|
379
|
+
* learned stillness window on the biased statistic, it corrupted the graph
|
|
380
|
+
* inside an afternoon, and the lesson taken was not "use a better estimator" —
|
|
381
|
+
* it was that a number gets to *act* only after it has been watched for a while
|
|
382
|
+
* doing nothing. This is the watching.
|
|
383
|
+
*/
|
|
384
|
+
export function noteTrueGap(udid, screen, step, trueGapMs) {
|
|
385
|
+
if (!Number.isFinite(trueGapMs) || trueGapMs < 0) return null;
|
|
386
|
+
const node = screen?.hash ? nearestScreen(udid, screen)?.node : null;
|
|
387
|
+
if (!node) return null;
|
|
388
|
+
const edge = node.edges?.find((e) => e.action === actionSignature(step));
|
|
389
|
+
if (!edge) return null;
|
|
390
|
+
edge.trueGaps = [...(edge.trueGaps ?? []), Math.round(trueGapMs)].slice(-TIMING_WINDOW);
|
|
391
|
+
save(udid, node);
|
|
392
|
+
return edge;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* How long a screen must hold still on this edge before it is finished.
|
|
397
|
+
*
|
|
398
|
+
* Derived from the longest pause ever seen *inside* this transition, plus a
|
|
399
|
+
* margin, and never longer than the caller's own default — this can only make
|
|
400
|
+
* a wait shorter, never longer, which is what keeps a learned number from
|
|
401
|
+
* becoming a new way to hang.
|
|
402
|
+
*
|
|
403
|
+
* The floor is 150 ms because a settle also needs at least one fresh frame to
|
|
404
|
+
* judge, and the capture loop's own idle interval is the limit on how fast an
|
|
405
|
+
* answer can arrive.
|
|
406
|
+
*/
|
|
407
|
+
export const STILLNESS_FLOOR_MS = 150;
|
|
408
|
+
|
|
409
|
+
export function stillnessFor({ gapSamples, gapP95 } = {}, fallbackMs) {
|
|
410
|
+
if (!Number.isFinite(gapP95) || !Number.isFinite(gapSamples) || gapSamples < COLD_SAMPLES) {
|
|
411
|
+
return { stillnessMs: fallbackMs, cold: true };
|
|
412
|
+
}
|
|
413
|
+
const margin = Math.max(100, Math.round(gapP95 * 0.5));
|
|
414
|
+
return {
|
|
415
|
+
stillnessMs: Math.max(STILLNESS_FLOOR_MS, Math.min(fallbackMs, gapP95 + margin)),
|
|
416
|
+
cold: false,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* How long to wait for a tapped field to take focus.
|
|
422
|
+
*
|
|
423
|
+
* The three numbers this replaces were the last genuinely fixed waits on the
|
|
424
|
+
* action path: 250 ms of stillness, a 900 ms reaction window, a 3 s timeout.
|
|
425
|
+
* What makes them different from the step budget is the shape of the failure.
|
|
426
|
+
* A step budget that is too short reports `no-visible-change` and the flow can
|
|
427
|
+
* see it. A focus wait that is too short types into a field that does not have
|
|
428
|
+
* focus yet, `typeText` succeeds because input has no feedback channel, and the
|
|
429
|
+
* step reports that it typed — the worst shape a failure can take, and the bug
|
|
430
|
+
* this helper was written to fix in the first place.
|
|
431
|
+
*
|
|
432
|
+
* So this one is asymmetric on purpose: **a learned window may only lengthen
|
|
433
|
+
* the wait**, never shorten it. p95 of what this field has actually cost, when
|
|
434
|
+
* that is longer than 3 s, is a field that was being typed into too early and
|
|
435
|
+
* now is not. Where it is shorter, the measurement is discarded rather than
|
|
436
|
+
* banked as a saving — 5% of a distribution is one silent wrong type in twenty
|
|
437
|
+
* runs, and there is no amount of median wall time worth that.
|
|
438
|
+
*
|
|
439
|
+
* The one shortening is not a learned number at all, it is positive evidence:
|
|
440
|
+
* if the keyboard was already up before the tap, this tap moves a caret. There
|
|
441
|
+
* is no keyboard animation to wait for, so the reaction window collapses to the
|
|
442
|
+
* stillness window instead of paying 900 ms to watch a screen that was never
|
|
443
|
+
* going to move. `beforeScreen` already carries `keyboard`, so the evidence is
|
|
444
|
+
* free — it is the same perception pass the step was going to run anyway.
|
|
445
|
+
*/
|
|
446
|
+
export function focusPlan(stats, { reactionMs, timeoutMs, stillnessMs, keyboardUp } = {}) {
|
|
447
|
+
if (keyboardUp) {
|
|
448
|
+
return {
|
|
449
|
+
reactionMs: stillnessMs ?? reactionMs,
|
|
450
|
+
timeoutMs,
|
|
451
|
+
cold: false,
|
|
452
|
+
from: 'the keyboard was already up, so this tap moves a caret',
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const { focusP50, focusP95, focusSamples } = stats ?? {};
|
|
456
|
+
if (!Number.isFinite(focusP95) || !Number.isFinite(focusSamples) || focusSamples < COLD_SAMPLES) {
|
|
457
|
+
return { reactionMs, timeoutMs, cold: true, from: `fewer than ${COLD_SAMPLES} focus samples` };
|
|
458
|
+
}
|
|
459
|
+
const margin = Math.max(150, Math.round(focusP95 * 0.2));
|
|
460
|
+
const learnedTimeout = Math.min(HARD_CAP_MS, focusP95 + margin);
|
|
461
|
+
const learnedReaction = Math.min(learnedTimeout, focusP50 + margin);
|
|
462
|
+
return {
|
|
463
|
+
// max, not min. See above: only ever longer.
|
|
464
|
+
reactionMs: Math.max(reactionMs, learnedReaction),
|
|
465
|
+
timeoutMs: Math.max(timeoutMs, learnedTimeout),
|
|
466
|
+
cold: false,
|
|
467
|
+
from: `p95 ${focusP95}ms over ${focusSamples} focus samples`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** What this edge's observed settle durations say, or that it has none. */
|
|
472
|
+
export function timingOf(edge) {
|
|
473
|
+
const samples = edge?.settles ?? [];
|
|
474
|
+
const gaps = edge?.quietGaps ?? [];
|
|
475
|
+
const focuses = edge?.focuses ?? [];
|
|
476
|
+
return {
|
|
477
|
+
samples: samples.length,
|
|
478
|
+
p50: metrics.percentile(samples, 50),
|
|
479
|
+
p95: metrics.percentile(samples, 95),
|
|
480
|
+
gapSamples: gaps.length,
|
|
481
|
+
gapP95: metrics.percentile(gaps, 95),
|
|
482
|
+
// The same statistic measured after the transition rather than during it.
|
|
483
|
+
// Reported side by side so the size of the bias is visible rather than
|
|
484
|
+
// argued about — see `index.longestQuietGap`.
|
|
485
|
+
trueGapSamples: (edge?.trueGaps ?? []).length,
|
|
486
|
+
trueGapP95: metrics.percentile(edge?.trueGaps ?? [], 95),
|
|
487
|
+
focusSamples: focuses.length,
|
|
488
|
+
focusP50: metrics.percentile(focuses, 50),
|
|
489
|
+
focusP95: metrics.percentile(focuses, 95),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* How long to wait for `step` on `screen`, from what it has cost before.
|
|
495
|
+
*
|
|
496
|
+
* Returns the cold default when this screen or this action has not been
|
|
497
|
+
* measured, and says which — a timeout nobody can explain is how a fixed sleep
|
|
498
|
+
* gets reintroduced as a constant with a comment.
|
|
499
|
+
*/
|
|
500
|
+
export function timingFor(udid, screen, step) {
|
|
501
|
+
const node = screen?.hash ? nearestScreen(udid, screen)?.node : null;
|
|
502
|
+
const edge = node?.edges?.find((e) => e.action === actionSignature(step));
|
|
503
|
+
const stats = timingOf(edge);
|
|
504
|
+
return { ...stats, ...adaptiveTimeout(stats), known: Boolean(edge) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* What getting *to* this screen has cost before.
|
|
509
|
+
*
|
|
510
|
+
* `sim_state` is asked "what is on screen and is it done moving", with no
|
|
511
|
+
* action in hand, so there is no outgoing edge to consult. The useful answer
|
|
512
|
+
* is the inbound one: the edge most recently traversed into this screen is how
|
|
513
|
+
* we got here, and its distribution is what "slower than usual" means right
|
|
514
|
+
* now. Most recently seen rather than most travelled — a screen reachable two
|
|
515
|
+
* ways is being timed against the way it was just reached.
|
|
516
|
+
*/
|
|
517
|
+
export function timingInto(udid, hash) {
|
|
518
|
+
if (!hash) return null;
|
|
519
|
+
let best = null;
|
|
520
|
+
for (const node of allNodes(udid)) {
|
|
521
|
+
for (const edge of node.edges ?? []) {
|
|
522
|
+
if (edge.to !== hash) continue;
|
|
523
|
+
if (!best || (edge.lastSeen ?? 0) > (best.lastSeen ?? 0)) best = edge;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return best ? { ...timingOf(best), action: best.action, kind: best.kind ?? null } : null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function record(udid, { from, action, to, kind, settleMs, quietGapMs, focusMs }) {
|
|
280
530
|
const fromKey = typeof from === 'string' ? { hash: from } : from;
|
|
281
531
|
const toHash = typeof to === 'string' ? to : to?.hash;
|
|
282
532
|
if (!fromKey?.hash || !toHash) return null;
|
|
@@ -343,6 +593,7 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
343
593
|
save(udid, target);
|
|
344
594
|
existing.count += 1;
|
|
345
595
|
existing.lastSeen = Date.now();
|
|
596
|
+
noteSettle(existing, settleMs, quietGapMs, focusMs);
|
|
346
597
|
save(udid, node);
|
|
347
598
|
return node;
|
|
348
599
|
}
|
|
@@ -354,6 +605,13 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
354
605
|
existing.kind = kind ?? existing.kind;
|
|
355
606
|
existing.count += 1;
|
|
356
607
|
existing.lastSeen = Date.now();
|
|
608
|
+
// Every argument, and it is worth saying why this line once passed one.
|
|
609
|
+
// `quietGapMs` was dropped here — on the *main* path, the one nearly every
|
|
610
|
+
// recorded edge takes — so the pause statistic only ever accumulated on a
|
|
611
|
+
// brand-new edge and on the variant branch. The window that reads it looked
|
|
612
|
+
// permanently cold, which is a measurement quietly not being taken rather
|
|
613
|
+
// than a wrong number, and those are the ones nothing complains about.
|
|
614
|
+
noteSettle(existing, settleMs, quietGapMs, focusMs);
|
|
357
615
|
} else {
|
|
358
616
|
node.edges.push({
|
|
359
617
|
action: signature,
|
|
@@ -364,6 +622,9 @@ export function record(udid, { from, action, to, kind }) {
|
|
|
364
622
|
kind,
|
|
365
623
|
count: 1,
|
|
366
624
|
lastSeen: Date.now(),
|
|
625
|
+
settles: Number.isFinite(settleMs) && settleMs >= 0 ? [Math.round(settleMs)] : [],
|
|
626
|
+
quietGaps: Number.isFinite(quietGapMs) && quietGapMs >= 0 ? [Math.round(quietGapMs)] : [],
|
|
627
|
+
focuses: Number.isFinite(focusMs) && focusMs >= 0 ? [Math.round(focusMs)] : [],
|
|
367
628
|
});
|
|
368
629
|
}
|
|
369
630
|
save(udid, node);
|