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/index.js
CHANGED
|
@@ -9,6 +9,9 @@ import { decodePng, encodePng, scaleBitmap } from './png.js';
|
|
|
9
9
|
import {
|
|
10
10
|
REGION_COLS,
|
|
11
11
|
hexToSignature,
|
|
12
|
+
isBlackFrame,
|
|
13
|
+
maxCellDelta,
|
|
14
|
+
CELL_CHANGE,
|
|
12
15
|
regionDeltas,
|
|
13
16
|
regionMap,
|
|
14
17
|
signatureDiff,
|
|
@@ -19,6 +22,7 @@ import * as graph from './graph.js';
|
|
|
19
22
|
import * as matching from './matching.js';
|
|
20
23
|
import * as refs from './refs.js';
|
|
21
24
|
import * as screenmap from './screenmap.js';
|
|
25
|
+
import * as metrics from './metrics.js';
|
|
22
26
|
import { capabilitiesFor, resolveDevice, resize, screenshot } from './platform/index.js';
|
|
23
27
|
import * as store from './store.js';
|
|
24
28
|
|
|
@@ -314,10 +318,24 @@ function stallNote(health) {
|
|
|
314
318
|
const parts = [`capture: stalled — the display surface has been unreadable for ${Math.round(forMs / 1000)}s`];
|
|
315
319
|
if (health.reattaches) parts.push(`${health.reattaches} re-attach${health.reattaches === 1 ? '' : 'es'} did not help`);
|
|
316
320
|
if (health.reason) parts.push(String(health.reason).slice(0, 120));
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
+
// What to do about it, and this line has been wrong until now.
|
|
322
|
+
//
|
|
323
|
+
// It said "only restarting the device is known to cure it". Then the same
|
|
324
|
+
// daemon's log turned out to contain nine "capture recovered on its own"
|
|
325
|
+
// lines, and a wedge that had survived 223 port re-resolves and 6 device
|
|
326
|
+
// rebinds cleared by itself while nobody touched it. Meanwhile Apple's own
|
|
327
|
+
// `simctl io screenshot` on a wedged device returns a valid PNG whose every
|
|
328
|
+
// pixel is black, in 16 s — so the display pipeline has stopped rendering
|
|
329
|
+
// and simframe's read is an accurate report of that, not a bug in it.
|
|
330
|
+
//
|
|
331
|
+
// So: it is the simulator, it often comes back, and restarting the device
|
|
332
|
+
// also cures it. `simframe doctor` runs a screenshot probe when this is
|
|
333
|
+
// published and says which of the two faults it is.
|
|
334
|
+
parts.push(
|
|
335
|
+
"this is the simulator's display, not simframe's read of it: Apple's own screenshot path "
|
|
336
|
+
+ 'returns an all-black image on a wedged device. It frequently recovers on its own; '
|
|
337
|
+
+ 'restarting the device also cures it, and `simframe doctor` will confirm which fault this is',
|
|
338
|
+
);
|
|
321
339
|
return parts.join('; ');
|
|
322
340
|
}
|
|
323
341
|
|
|
@@ -383,6 +401,80 @@ export function resolveBaseline(state, since) {
|
|
|
383
401
|
return { kind: 'unmatched', requested: key };
|
|
384
402
|
}
|
|
385
403
|
|
|
404
|
+
/**
|
|
405
|
+
* Is the baseline describing a screen that had already finished moving?
|
|
406
|
+
*
|
|
407
|
+
* Pure, so the rule can be argued with in a test rather than only observed on
|
|
408
|
+
* a device. The full reasoning is at the call site in `waitFor`; the short
|
|
409
|
+
* version is that stillness cannot accumulate in the milliseconds between a
|
|
410
|
+
* dispatch returning and a wait beginning, so a screen that already differs
|
|
411
|
+
* from the baseline *and* has already been at rest for the whole stillness
|
|
412
|
+
* window changed for some earlier reason.
|
|
413
|
+
*/
|
|
414
|
+
/** The newest frame's region signature, which the state already carries. */
|
|
415
|
+
function currentSig(state) {
|
|
416
|
+
const h = state?.history;
|
|
417
|
+
if (!h?.length) return null;
|
|
418
|
+
const newest = h.find((x) => x.seq === state.seq) ?? h[h.length - 1];
|
|
419
|
+
return newest?.sig ?? null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The longest pause inside a transition, measured after the transition is over.
|
|
424
|
+
*
|
|
425
|
+
* This is the unbiased half of the estimator that was reverted in Phase 11, and
|
|
426
|
+
* the difference is entirely about *when* the measurement stops.
|
|
427
|
+
*
|
|
428
|
+
* The biased version accumulated the statistic from inside the wait: the
|
|
429
|
+
* longest stretch of stillness the wait itself happened to observe. Feed that
|
|
430
|
+
* back into how long the next wait runs and it eats itself — a wait that ends
|
|
431
|
+
* early never sees the pauses that come later, so the gaps read as zero, the
|
|
432
|
+
* window ratchets down, the next wait ends earlier still, and eventually a
|
|
433
|
+
* settle returns mid-transition and the graph learns a screen it never reached.
|
|
434
|
+
* That is not a theory; it corrupted this device's graph in one afternoon.
|
|
435
|
+
*
|
|
436
|
+
* This one reads the frame history *after* the fact, over a window whose end is
|
|
437
|
+
* not decided by the wait. The frames are already on disk with their timestamps
|
|
438
|
+
* and their hashes, so the true profile of a transition is recoverable as long
|
|
439
|
+
* as the history still reaches back to it.
|
|
440
|
+
*
|
|
441
|
+
* That last condition is the whole reason this returns null rather than a
|
|
442
|
+
* number: the ring is bounded, and during fast motion it holds a second or two.
|
|
443
|
+
* A partial window would produce a *shorter* gap than really occurred, which is
|
|
444
|
+
* the exact direction of the bias being removed. Reporting nothing is the only
|
|
445
|
+
* honest answer to a question the evidence cannot reach.
|
|
446
|
+
*/
|
|
447
|
+
export function longestQuietGap(history, sinceMs, untilMs = Date.now()) {
|
|
448
|
+
if (!Array.isArray(history) || history.length < 2) return null;
|
|
449
|
+
const frames = history
|
|
450
|
+
.filter((f) => Number.isFinite(f?.at) && f.at >= sinceMs && f.at <= untilMs)
|
|
451
|
+
.sort((a, b) => a.at - b.at);
|
|
452
|
+
if (frames.length < 2) return null;
|
|
453
|
+
// The history has to reach back to the action itself. One frame interval of
|
|
454
|
+
// slack, because the frame that captures the moment of the action is not
|
|
455
|
+
// required to land exactly on it.
|
|
456
|
+
const span = frames[1].at - frames[0].at;
|
|
457
|
+
if (frames[0].at > sinceMs + Math.max(250, span)) return null;
|
|
458
|
+
|
|
459
|
+
let longest = 0;
|
|
460
|
+
let lastChangeAt = frames[0].at;
|
|
461
|
+
for (let i = 1; i < frames.length; i += 1) {
|
|
462
|
+
if (frames[i].hash !== frames[i - 1].hash) {
|
|
463
|
+
longest = Math.max(longest, frames[i].at - lastChangeAt);
|
|
464
|
+
lastChangeAt = frames[i].at;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// The quiet after the last change is not a pause *inside* the transition —
|
|
468
|
+
// it is the transition being over, which is what a settle already measures.
|
|
469
|
+
return longest;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function baselineAlreadySettled({ mode, changedAtStart, stableForMs, stableMs } = {}) {
|
|
473
|
+
if (mode === 'stable' || !changedAtStart) return false;
|
|
474
|
+
if (!Number.isFinite(stableForMs) || !Number.isFinite(stableMs)) return false;
|
|
475
|
+
return stableForMs >= stableMs;
|
|
476
|
+
}
|
|
477
|
+
|
|
386
478
|
function compareToBaseline(state, baseline) {
|
|
387
479
|
if (!baseline) return null;
|
|
388
480
|
if (baseline.kind === 'history') {
|
|
@@ -473,7 +565,7 @@ function pngSize(png) {
|
|
|
473
565
|
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
474
566
|
}
|
|
475
567
|
|
|
476
|
-
export async function getState(deviceQuery, { since, options } = {}) {
|
|
568
|
+
export async function getState(deviceQuery, { since, options, inputHealth = false } = {}) {
|
|
477
569
|
const { device, state } = await ensureDaemon(deviceQuery, options);
|
|
478
570
|
return {
|
|
479
571
|
device,
|
|
@@ -482,9 +574,58 @@ export async function getState(deviceQuery, { since, options } = {}) {
|
|
|
482
574
|
map: regionMap(state.regions || [], REGION_COLS),
|
|
483
575
|
since: compareToBaseline(state, resolveBaseline(state, since)),
|
|
484
576
|
live: liveness(device.udid, state),
|
|
577
|
+
// Costs 32 integer comparisons on a signature already computed, and it is
|
|
578
|
+
// the difference between "the screen is calm" and "the display stopped
|
|
579
|
+
// rendering" — which looked identical to everything above this line.
|
|
580
|
+
black: isBlackFrame(currentSig(state)),
|
|
581
|
+
// Off by default and asked for by the state commands only. A flow step
|
|
582
|
+
// calls getState twice, and the fix for a stale session runs before every
|
|
583
|
+
// action anyway (input.ensureFreshSession) — this is the report, not the
|
|
584
|
+
// repair.
|
|
585
|
+
input: inputHealth ? await input.sessionHealth(device.udid) : undefined,
|
|
586
|
+
timing: inputHealth ? timingOfNow(device.udid, state) : undefined,
|
|
485
587
|
};
|
|
486
588
|
}
|
|
487
589
|
|
|
590
|
+
/**
|
|
591
|
+
* How long this screen has been moving, against how long it usually takes.
|
|
592
|
+
*
|
|
593
|
+
* Research §7 asks `sim_state` for this, and the point is a sentence an agent
|
|
594
|
+
* can act on: a screen 400 ms into a transition that normally takes 350 ms is
|
|
595
|
+
* fine, and the same screen four seconds in is not. Elapsed comes from the
|
|
596
|
+
* frame store's own clock; the distribution comes from the edge that was most
|
|
597
|
+
* recently traversed into this screen.
|
|
598
|
+
*
|
|
599
|
+
* Reads no frames and takes no perception pass: the layout hash and the
|
|
600
|
+
* structural hash screen memory filed under it are both already on disk.
|
|
601
|
+
*/
|
|
602
|
+
function timingOfNow(udid, state) {
|
|
603
|
+
try {
|
|
604
|
+
const near = screenmap.recallNearest(udid, state.layoutHash);
|
|
605
|
+
const edge = graph.timingInto(udid, near?.entry?.structuralHash);
|
|
606
|
+
const elapsed = Number.isFinite(state.lastChangeAt) ? Date.now() - state.lastChangeAt : null;
|
|
607
|
+
const settled = state.stableForMs >= STRUCTURAL_SETTLE_MS;
|
|
608
|
+
const slower = graph.slowerThanUsual({
|
|
609
|
+
elapsedMs: elapsed,
|
|
610
|
+
p95: edge?.p95,
|
|
611
|
+
settled,
|
|
612
|
+
kind: state.transition?.kind,
|
|
613
|
+
});
|
|
614
|
+
return {
|
|
615
|
+
edge_p50: edge?.p50 ?? null,
|
|
616
|
+
edge_p95: edge?.p95 ?? null,
|
|
617
|
+
samples: edge?.samples ?? 0,
|
|
618
|
+
elapsed_ms: elapsed,
|
|
619
|
+
stable_for_ms: state.stableForMs ?? null,
|
|
620
|
+
slower_than_usual: Boolean(slower.slower),
|
|
621
|
+
note: slower.slower ? slower.note : null,
|
|
622
|
+
};
|
|
623
|
+
} catch {
|
|
624
|
+
// Timing is commentary. A screen with no history still has a state.
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
488
629
|
/**
|
|
489
630
|
* Wait for the screen to settle (`mode: 'stable'`) or to move away from what it
|
|
490
631
|
* shows right now (`mode: 'change'`). Removes the screenshot-retry loop.
|
|
@@ -508,7 +649,7 @@ export async function waitFor(
|
|
|
508
649
|
const p = store.paths(device.udid);
|
|
509
650
|
const requested = since ?? baselineHash;
|
|
510
651
|
const resolved = resolveBaseline(first, requested);
|
|
511
|
-
|
|
652
|
+
let baselineHashValue =
|
|
512
653
|
resolved?.kind === 'history' ? resolved.entry.hash : (requested ?? first.hash);
|
|
513
654
|
const baselineResolved = resolved?.kind === 'history' || requested == null;
|
|
514
655
|
|
|
@@ -516,16 +657,93 @@ export async function waitFor(
|
|
|
516
657
|
const deadline = startedAt + timeoutMs;
|
|
517
658
|
const startSeq = first.seq;
|
|
518
659
|
let last = first;
|
|
660
|
+
/**
|
|
661
|
+
* The longest pause *inside* this transition, in ms.
|
|
662
|
+
*
|
|
663
|
+
* This is the statistic a stillness window exists to defeat: a transition
|
|
664
|
+
* that pauses for 180 ms mid-flight will be mistaken for a finished screen
|
|
665
|
+
* by any window shorter than that. Nobody was measuring it, so the window
|
|
666
|
+
* was a constant — 500 ms, chosen once, paid by every step forever.
|
|
667
|
+
*
|
|
668
|
+
* Tracked as the highest `stableForMs` observed before the screen moved
|
|
669
|
+
* again. Reported so a caller can learn it per edge; a settle that ends
|
|
670
|
+
* without a second change has no gap to report and says 0.
|
|
671
|
+
*/
|
|
672
|
+
let quietGapMs = 0;
|
|
673
|
+
let quietRun = 0;
|
|
674
|
+
/**
|
|
675
|
+
* The baseline's own signature, for changes the mean cannot see.
|
|
676
|
+
*
|
|
677
|
+
* Only when the baseline was found in the frame history — a hash we cannot
|
|
678
|
+
* place has no signature to compare against, and guessing one would be worse
|
|
679
|
+
* than not looking.
|
|
680
|
+
*/
|
|
681
|
+
const baselineSig = resolved?.kind === 'history' && resolved.entry?.sig
|
|
682
|
+
? hexToSignature(resolved.entry.sig)
|
|
683
|
+
: (requested == null ? hexToSignature(currentSig(first) ?? '') : null);
|
|
684
|
+
let smallChange = false;
|
|
685
|
+
/** Frames the display was not rendering at all. See `isBlackFrame`. */
|
|
686
|
+
let blackFrames = 0;
|
|
687
|
+
let blackSinceStart = null;
|
|
688
|
+
let lastHash = first.hash;
|
|
519
689
|
let sawChange = mode === 'stable' || first.hash !== baselineHashValue;
|
|
520
690
|
const changedAtStart = sawChange && mode !== 'stable';
|
|
521
691
|
|
|
692
|
+
/**
|
|
693
|
+
* The baseline describes a screen that has already finished moving.
|
|
694
|
+
*
|
|
695
|
+
* `since` means "the screen as it was before the action", and the whole
|
|
696
|
+
* reliability of these scripts rests on it being captured *before* rather
|
|
697
|
+
* than after — a baseline sampled afterwards is the commonest way to wait for
|
|
698
|
+
* a change that already happened. What was missing is the other end of it:
|
|
699
|
+
* time also passes between capturing the baseline and dispatching the action,
|
|
700
|
+
* and in a flow step that gap holds a `locate`, a perception pass and a
|
|
701
|
+
* settle wait — hundreds of milliseconds, not microseconds.
|
|
702
|
+
*
|
|
703
|
+
* So a transition can begin *and finish* in that gap, and then `sawChange` is
|
|
704
|
+
* true at wait start because of the previous action's animation. Measured:
|
|
705
|
+
* `tap Accessibility` returned `settled 124ms` against a 500 ms stillness
|
|
706
|
+
* window, the screen had never left the Settings root, and the graph recorded
|
|
707
|
+
* `root -> root` as a verified edge — count 11, changedOutcomes 5, flipping
|
|
708
|
+
* between the real destination and itself all day.
|
|
709
|
+
*
|
|
710
|
+
* The test is unambiguous rather than clever: the screen differs from the
|
|
711
|
+
* baseline *and has already been at rest for the full stillness window*.
|
|
712
|
+
* Stillness cannot have accumulated in the milliseconds between a dispatch
|
|
713
|
+
* returning and this call starting, so whatever changed, changed and settled
|
|
714
|
+
* before we looked, and it is not this action's doing. Re-baseline to what is
|
|
715
|
+
* actually on screen and wait for a further change — which is what the caller
|
|
716
|
+
* asked for and what a stale hash prevented.
|
|
717
|
+
*
|
|
718
|
+
* A screen that differs and is *still moving* is left alone: that is
|
|
719
|
+
* genuinely ambiguous, and after an action the usual reading is the right
|
|
720
|
+
* one.
|
|
721
|
+
*/
|
|
722
|
+
const staleBaseline = baselineAlreadySettled({
|
|
723
|
+
mode, changedAtStart, stableForMs: first.stableForMs, stableMs,
|
|
724
|
+
});
|
|
725
|
+
if (staleBaseline) {
|
|
726
|
+
baselineHashValue = first.hash;
|
|
727
|
+
sawChange = false;
|
|
728
|
+
}
|
|
729
|
+
|
|
522
730
|
const done = (satisfied, extra = {}) => ({
|
|
523
731
|
device,
|
|
524
732
|
state: last,
|
|
525
733
|
satisfied,
|
|
526
734
|
mode,
|
|
735
|
+
quietGapMs,
|
|
527
736
|
sawChange,
|
|
528
737
|
changedBeforeWait: changedAtStart,
|
|
738
|
+
// Something moved, but only in one region — a control changing state
|
|
739
|
+
// rather than a screen changing.
|
|
740
|
+
smallChange,
|
|
741
|
+
staleBaseline,
|
|
742
|
+
blackFrames,
|
|
743
|
+
// Said as an observation, never as a diagnosis: a screen can be black
|
|
744
|
+
// because the app drew black. What makes it the capture wedge is that it
|
|
745
|
+
// stays black while input is being delivered, and the caller knows that.
|
|
746
|
+
blackMs: blackSinceStart ? Date.now() - blackSinceStart : 0,
|
|
529
747
|
baselineHash: baselineHashValue,
|
|
530
748
|
baselineResolved,
|
|
531
749
|
waitedMs: Date.now() - startedAt,
|
|
@@ -541,8 +759,55 @@ export async function waitFor(
|
|
|
541
759
|
const live = liveness(device.udid, state);
|
|
542
760
|
if (!live.ok) return done(false, { stalled: true });
|
|
543
761
|
|
|
762
|
+
// A black frame is not evidence, in either direction.
|
|
763
|
+
//
|
|
764
|
+
// The capture wedge leaves every frame black while the whole capture path
|
|
765
|
+
// reports success, so before this a settle read the black screen as a
|
|
766
|
+
// change (the hash differs from anything) and then as a calm one (nothing
|
|
767
|
+
// moves), and returned `ok` for an action nobody could see the result of.
|
|
768
|
+
// It self-recovers most times, so the useful behaviour is to keep waiting
|
|
769
|
+
// rather than to conclude.
|
|
770
|
+
const black = isBlackFrame(currentSig(state));
|
|
771
|
+
if (black) {
|
|
772
|
+
blackFrames += 1;
|
|
773
|
+
blackSinceStart = blackSinceStart ?? Date.now();
|
|
774
|
+
await sleep(60);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
blackSinceStart = null;
|
|
778
|
+
|
|
544
779
|
if (!sawChange && state.hash !== baselineHashValue) sawChange = true;
|
|
545
780
|
|
|
781
|
+
// A change too small for the whole-screen mean to see.
|
|
782
|
+
//
|
|
783
|
+
// A switch flipping moves one cell of thirty-two by 0.043 and the mean by
|
|
784
|
+
// 0.0013 — a third of the threshold — so every switch, radio dot,
|
|
785
|
+
// checkbox and segment highlight was an action that "changed nothing",
|
|
786
|
+
// and `no-visible-change` is a verdict that escalates. See
|
|
787
|
+
// analyze.CELL_CHANGE for the measured gap this sits in.
|
|
788
|
+
//
|
|
789
|
+
// Deliberately feeding `sawChange` and *not* stillness: `stableForMs`
|
|
790
|
+
// stays on the mean, because a blinking text caret is a small localised
|
|
791
|
+
// change and a screen with a cursor would otherwise never settle.
|
|
792
|
+
if (!sawChange && baselineSig) {
|
|
793
|
+
const sig = currentSig(state);
|
|
794
|
+
if (sig && maxCellDelta(hexToSignature(sig), baselineSig) > CELL_CHANGE) {
|
|
795
|
+
sawChange = true;
|
|
796
|
+
smallChange = true;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// A pause that turned out not to be the end of the transition. Only
|
|
801
|
+
// pauses followed by more movement count: the quiet at the end of a
|
|
802
|
+
// settle is the answer, not a gap.
|
|
803
|
+
if (state.hash !== lastHash) {
|
|
804
|
+
if (quietRun > quietGapMs) quietGapMs = quietRun;
|
|
805
|
+
quietRun = 0;
|
|
806
|
+
lastHash = state.hash;
|
|
807
|
+
} else if (Number.isFinite(state.stableForMs)) {
|
|
808
|
+
quietRun = Math.max(quietRun, state.stableForMs);
|
|
809
|
+
}
|
|
810
|
+
|
|
546
811
|
// Some controls barely move the screen at all — a radio dot, a checkbox,
|
|
547
812
|
// a button changing state. Waiting the full timeout for a change that
|
|
548
813
|
// will never be visible turns a 100ms action into a 12s one, so give up
|
|
@@ -890,8 +1155,19 @@ export async function locate(
|
|
|
890
1155
|
const list = outcome.alternatives
|
|
891
1156
|
.map((a, i) => `[${i}] "${a.label}" (${a.x},${a.y}) ${a.region ?? 'content'} ${a.score}`)
|
|
892
1157
|
.join(', ');
|
|
893
|
-
|
|
894
|
-
|
|
1158
|
+
// Tagged, not just thrown: the reason an escalation happened is known
|
|
1159
|
+
// here and nowhere above here. See metrics.tag — it adds a property and
|
|
1160
|
+
// changes nothing else about the error.
|
|
1161
|
+
throw metrics.tag(
|
|
1162
|
+
new Error(
|
|
1163
|
+
`"${query}" matches ${outcome.alternatives.length} things on this screen — say which, or pass index: ${list}`,
|
|
1164
|
+
),
|
|
1165
|
+
'ambiguous_intent',
|
|
1166
|
+
// Present, several times over — as opposed to absent, which also tags
|
|
1167
|
+
// ambiguous_intent when the screen was one we thought we knew. A
|
|
1168
|
+
// waiting caller needs the difference: more time cannot make a thing
|
|
1169
|
+
// unique, and it can make an absent thing arrive.
|
|
1170
|
+
{ candidates: outcome.alternatives, ambiguous: true },
|
|
895
1171
|
);
|
|
896
1172
|
}
|
|
897
1173
|
if (outcome.status === 'ok') {
|
|
@@ -905,24 +1181,28 @@ export async function locate(
|
|
|
905
1181
|
// here undoes every guard above — it has no off-screen filter and no
|
|
906
1182
|
// coverage weighting, and it is what returned a scrolled-away list row for
|
|
907
1183
|
// "back". "Not found" is the correct answer.
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1184
|
+
const visible = entry.targets.filter((t) => t.label && t.y >= 0 && t.y <= points.height);
|
|
1185
|
+
const sample = visible.slice(0, 12).map((t) => t.label.slice(0, 24)).join(', ');
|
|
1186
|
+
// Which escalation this is depends on whether the screen was recognised.
|
|
1187
|
+
// Screen memory had nothing for it (`from` is one of the built values) and
|
|
1188
|
+
// the target is missing: that is not knowing the screen. On a screen
|
|
1189
|
+
// recalled from memory, the screen is known and the intent did not resolve.
|
|
1190
|
+
throw metrics.tag(
|
|
1191
|
+
new Error(`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`),
|
|
1192
|
+
from === 'memory' ? 'ambiguous_intent' : 'unknown_screen',
|
|
1193
|
+
{ candidates: visible.slice(0, 8) },
|
|
1194
|
+
);
|
|
914
1195
|
}
|
|
915
1196
|
|
|
916
1197
|
const candidates = screenmap.rank(entry, query);
|
|
917
1198
|
const target = index != null ? candidates[index] : candidates[0];
|
|
918
1199
|
if (!target) {
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`,
|
|
1200
|
+
const visible = entry.targets.filter((t) => t.label);
|
|
1201
|
+
const sample = visible.slice(0, 12).map((t) => t.label).join(', ');
|
|
1202
|
+
throw metrics.tag(
|
|
1203
|
+
new Error(`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`),
|
|
1204
|
+
from === 'memory' ? 'ambiguous_intent' : 'unknown_screen',
|
|
1205
|
+
{ candidates: visible.slice(0, 8) },
|
|
926
1206
|
);
|
|
927
1207
|
}
|
|
928
1208
|
return { device, state: current, entry, target, from, distance, settled, screens: screenmap.stats(udid).screens };
|
|
@@ -941,6 +1221,38 @@ export async function locate(
|
|
|
941
1221
|
*/
|
|
942
1222
|
export const STRUCTURAL_SETTLE_MS = 300;
|
|
943
1223
|
|
|
1224
|
+
/**
|
|
1225
|
+
* How much of the structural window is still owed, given when the last sample's
|
|
1226
|
+
* frame was captured.
|
|
1227
|
+
*
|
|
1228
|
+
* This was `sleep(300)` between the two readings, and unlike every other fixed
|
|
1229
|
+
* wait in the engine it cannot be replaced by waiting for a signal — because
|
|
1230
|
+
* there is no signal. The race it guards is a screen whose *pixels* have gone
|
|
1231
|
+
* still while its structure has not: a list whose spinner has gone and whose
|
|
1232
|
+
* rows have not landed is perfectly quiet and structurally wrong, so the settle
|
|
1233
|
+
* detector, which watches pixels, has nothing to report. Only elapsed time
|
|
1234
|
+
* separates the two readings.
|
|
1235
|
+
*
|
|
1236
|
+
* What can be fixed is that the wait was *additional*. The guarantee wanted is
|
|
1237
|
+
* 300 ms between the frames the two samples read; the code slept 300 ms after a
|
|
1238
|
+
* sample that had already spent an unbounded settle wait and a full perception
|
|
1239
|
+
* pass getting there. On a screen that took a second to go quiet the separation
|
|
1240
|
+
* was already there and the sleep bought nothing but a second of it. So credit
|
|
1241
|
+
* what has passed and wait only for the remainder — the same guarantee, and
|
|
1242
|
+
* usually none of the sleep.
|
|
1243
|
+
*
|
|
1244
|
+
* The window itself is per-screen learnable, and worth noting that its
|
|
1245
|
+
* estimator has the *opposite* feedback sign to the one that corrupted the
|
|
1246
|
+
* graph: a window too short produces disagreeing samples, which lengthens it.
|
|
1247
|
+
* Self-correcting rather than self-reinforcing. It still waits on the
|
|
1248
|
+
* perception eval harness, because "the samples agreed" is only evidence the
|
|
1249
|
+
* window was long enough if the readings themselves are trustworthy.
|
|
1250
|
+
*/
|
|
1251
|
+
export function structuralSettleOwed(capturedAt, now = Date.now(), windowMs = STRUCTURAL_SETTLE_MS) {
|
|
1252
|
+
if (!Number.isFinite(capturedAt)) return windowMs;
|
|
1253
|
+
return Math.max(0, Math.min(windowMs, windowMs - (now - capturedAt)));
|
|
1254
|
+
}
|
|
1255
|
+
|
|
944
1256
|
/**
|
|
945
1257
|
* How long identity will wait for pixels to go quiet.
|
|
946
1258
|
*
|
|
@@ -1024,7 +1336,8 @@ export async function screenIdentity(deviceQuery, { options, confirmNovel = true
|
|
|
1024
1336
|
// Nothing recognises this, or the pixels have not gone quiet. Either way, make
|
|
1025
1337
|
// it prove it is the same screen twice running before it becomes a node.
|
|
1026
1338
|
for (let i = 1; i < STRUCTURAL_SETTLE_SAMPLES; i += 1) {
|
|
1027
|
-
|
|
1339
|
+
const owed = structuralSettleOwed(identity.state?.capturedAt);
|
|
1340
|
+
if (owed > 0) await sleep(owed);
|
|
1028
1341
|
const again = await read({ fresh: true });
|
|
1029
1342
|
// Two readings agree if they are the same screen — the same test identity
|
|
1030
1343
|
// itself uses. Demanding an identical hash is a stricter question than the
|