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/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
- export function record(udid, { from, action, to, kind }) {
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);