humanish 0.129.0 → 0.131.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/CHANGELOG.md +27 -0
- package/dist/actors/codex/restricted-participant.d.ts +2 -2
- package/dist/actors/codex/restricted-participant.js +3 -3
- package/dist/actors/computer-use/loop/actions.d.ts +17 -2
- package/dist/actors/computer-use/loop/actions.js +24 -5
- package/dist/actors/computer-use/loop/backstop.d.ts +9 -1
- package/dist/actors/computer-use/loop/backstop.js +4 -3
- package/dist/actors/computer-use/loop/clock.d.ts +4 -4
- package/dist/actors/computer-use/loop/clock.js +5 -5
- package/dist/actors/computer-use/loop/observation.d.ts +21 -2
- package/dist/actors/computer-use/loop/observation.js +50 -8
- package/dist/actors/computer-use/loop/trace.js +5 -1
- package/dist/actors/computer-use/loop/types.d.ts +30 -19
- package/dist/actors/computer-use/loop/watch.d.ts +42 -0
- package/dist/actors/computer-use/loop/watch.js +260 -0
- package/dist/actors/computer-use/loop.js +18 -4
- package/dist/actors/computer-use/wait.d.ts +36 -18
- package/dist/actors/computer-use/wait.js +51 -22
- package/dist/actors/contract.d.ts +34 -0
- package/dist/actors/local-agent/cli.js +5 -0
- package/dist/actors/stop-cause.d.ts +1 -1
- package/dist/analysis/automatic.js +0 -1
- package/dist/analysis/evidence-sources.d.ts +44 -7
- package/dist/analysis/evidence-sources.js +40 -6
- package/dist/analysis/evidence.js +96 -122
- package/dist/analysis/service.js +12 -6
- package/dist/analysis/source.d.ts +28 -0
- package/dist/analysis/source.js +118 -0
- package/dist/evidence/frame-change.d.ts +39 -0
- package/dist/evidence/frame-change.js +293 -0
- package/dist/feedback/export.js +0 -1
- package/dist/observer/data.d.ts +2 -2
- package/dist/observer-app.html +1 -1
- package/dist/run/bundle-shape.d.ts +2 -2
- package/dist/run/bundle.d.ts +8 -1
- package/dist/run/detail.js +4 -1
- package/dist/run/participant-records.d.ts +1 -1
- package/dist/run/stream-shape.d.ts +2 -2
- package/dist/run/stream-trace.d.ts +21 -7
- package/dist/run/stream-trace.js +42 -8
- package/dist/run/streams.d.ts +18 -0
- package/dist/study/bundle-estimate.d.ts +4 -1
- package/dist/study/bundle-estimate.js +40 -15
- package/dist/study/plan-participants.d.ts +1 -1
- package/dist/study/types.d.ts +11 -7
- package/dist/study/warnings.js +1 -1
- package/dist/verify/verify.js +28 -21
- package/docs/architecture/actor-contract.md +8 -0
- package/docs/architecture/browser-control.md +11 -9
- package/docs/contracts/run-bundle.md +2 -2
- package/docs/contracts/schemas.md +48 -8
- package/docs/contracts/study-analysis.md +13 -1
- package/package.json +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// How analysis reads a run's recorded source: run.json, then each participant's trace one at a time,
|
|
2
|
+
// and the refusals it gives for either.
|
|
3
|
+
import { isCommsReceivingEvidence } from "../comms/receiving-evidence.js";
|
|
4
|
+
import { isReadRunBundleSchema } from "../run/bundle.js";
|
|
5
|
+
import { runIdOf } from "../run/paths.js";
|
|
6
|
+
import { reduceRunTraces, StreamTraceRefusedError, StreamTraceTooLargeError, } from "../run/stream-trace.js";
|
|
7
|
+
import { isRecord } from "../run/type-guards.js";
|
|
8
|
+
import { EVIDENCE_LIMITS } from "./analysis-limits.js";
|
|
9
|
+
/**
|
|
10
|
+
* A refusal of the run's recorded source. Its message is the code, as every analysis error's is,
|
|
11
|
+
* and `reason` says which file analysis did not read and why, for the person who asked.
|
|
12
|
+
*/
|
|
13
|
+
export class AnalysisSourceRefusedError extends Error {
|
|
14
|
+
reason;
|
|
15
|
+
constructor(code, reason) {
|
|
16
|
+
super(code);
|
|
17
|
+
this.reason = reason;
|
|
18
|
+
this.name = "AnalysisSourceRefusedError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const bytes = (count) => `${count.toLocaleString("en-US")} bytes`;
|
|
22
|
+
const limit = (max) => `${bytes(max)} (${(max / (1024 * 1024)).toLocaleString("en-US", { maximumFractionDigits: 1 })} MiB)`;
|
|
23
|
+
const runFileInvalid = (reason) => new AnalysisSourceRefusedError("ANALYSIS_SOURCE_INVALID", `Analysis did not read run.json: ${reason}`);
|
|
24
|
+
const traceInvalid = (streamId, reason) => new AnalysisSourceRefusedError("ANALYSIS_SOURCE_INVALID", `Analysis did not read participant ${streamId}'s trace: ${reason}`);
|
|
25
|
+
/** The refusal of a run.json of `size` bytes, more than the `max` analysis reads of it. */
|
|
26
|
+
export function runFileTooLarge(size, max) {
|
|
27
|
+
return new AnalysisSourceRefusedError("ANALYSIS_SOURCE_TOO_LARGE", `Analysis did not read run.json: it holds ${bytes(size)}, more than the ${limit(max)} analysis reads of it. run.json grows with each participant and the study's mission; split the participants across studies to keep it under the limit.`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* run.json's bundle, when it is a run bundle of the prepared run that analysis reads: at most
|
|
31
|
+
* EVIDENCE_LIMITS.sourceBytes, at most EVIDENCE_LIMITS.participants participants, and each
|
|
32
|
+
* participant and event with the fields analysis reads.
|
|
33
|
+
*/
|
|
34
|
+
export function parseAnalysisSource(prepared, bytes) {
|
|
35
|
+
if (bytes.length > EVIDENCE_LIMITS.sourceBytes)
|
|
36
|
+
throw runFileTooLarge(bytes.length, EVIDENCE_LIMITS.sourceBytes);
|
|
37
|
+
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
38
|
+
if (!isRecord(value) || !isReadRunBundleSchema(value.schema))
|
|
39
|
+
throw runFileInvalid("it is not a run bundle of a schema this version of humanish reads.");
|
|
40
|
+
if (value.runId !== runIdOf(prepared))
|
|
41
|
+
throw runFileInvalid(`it records another run than ${runIdOf(prepared)}, the run in its folder.`);
|
|
42
|
+
if (!Array.isArray(value.streams) || !Array.isArray(value.events))
|
|
43
|
+
throw runFileInvalid("it has no participants or events list.");
|
|
44
|
+
if (value.streams.length > EVIDENCE_LIMITS.participants)
|
|
45
|
+
throw runFileInvalid(`it lists ${value.streams.length} participants, more than the ${EVIDENCE_LIMITS.participants} analysis reads; split the participants across studies.`);
|
|
46
|
+
if (value.commsReceiving !== undefined && !isCommsReceivingEvidence(value.commsReceiving))
|
|
47
|
+
throw runFileInvalid("its email receiving evidence holds a field or value analysis does not read.");
|
|
48
|
+
const ids = new Set();
|
|
49
|
+
for (const [index, stream] of value.streams.entries()) {
|
|
50
|
+
if (!isRecord(stream) ||
|
|
51
|
+
typeof stream.id !== "string" ||
|
|
52
|
+
stream.id.length === 0 ||
|
|
53
|
+
stream.id.length > 256 ||
|
|
54
|
+
ids.has(stream.id) ||
|
|
55
|
+
typeof stream.label !== "string" ||
|
|
56
|
+
typeof stream.status !== "string") {
|
|
57
|
+
throw runFileInvalid(`participant ${index + 1} needs an id of 1 to 256 characters no other participant has, a label and a status.`);
|
|
58
|
+
}
|
|
59
|
+
ids.add(stream.id);
|
|
60
|
+
if (stream.actor !== undefined && !isRecord(stream.actor))
|
|
61
|
+
throw runFileInvalid(`participant ${stream.id}'s actor is not an object.`);
|
|
62
|
+
}
|
|
63
|
+
for (const [index, event] of value.events.entries()) {
|
|
64
|
+
if (!isRecord(event) ||
|
|
65
|
+
typeof event.id !== "string" ||
|
|
66
|
+
typeof event.message !== "string" ||
|
|
67
|
+
typeof event.type !== "string")
|
|
68
|
+
throw runFileInvalid(`event ${index + 1} needs an id, a type and a message.`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* What `keep` takes from each stream of run.json's bundle with the stream's trace loaded, in stream
|
|
74
|
+
* order. Each trace file is read up to RUN_ARTIFACT_MAX_BYTES, as the loader, verify and the
|
|
75
|
+
* Observer read it, and is dropped once `keep` returns, so analysis holds one trace at a time
|
|
76
|
+
* whatever the number of participants. run.json's digest, which the analysis record binds, binds
|
|
77
|
+
* each trace's sha256. A trace whose index records more than the limit is refused as
|
|
78
|
+
* ANALYSIS_SOURCE_TOO_LARGE, and any other trace the loader refuses as ANALYSIS_SOURCE_INVALID.
|
|
79
|
+
*/
|
|
80
|
+
export async function readParticipantTraces(prepared, bundle, keep) {
|
|
81
|
+
try {
|
|
82
|
+
return await reduceRunTraces(prepared, bundle, (stream, index) => {
|
|
83
|
+
validateTraceItems(stream);
|
|
84
|
+
return keep(stream, index);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// The loader's refusal names the participant, the file, why, and what to do.
|
|
89
|
+
if (error instanceof StreamTraceRefusedError)
|
|
90
|
+
throw new AnalysisSourceRefusedError(error instanceof StreamTraceTooLargeError
|
|
91
|
+
? "ANALYSIS_SOURCE_TOO_LARGE"
|
|
92
|
+
: "ANALYSIS_SOURCE_INVALID", error.message);
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function validateTraceItems(stream) {
|
|
97
|
+
const actor = stream.actor;
|
|
98
|
+
if (actor === undefined)
|
|
99
|
+
return;
|
|
100
|
+
if (!isRecord(actor) || !Array.isArray(actor.items))
|
|
101
|
+
throw traceInvalid(stream.id, "it has no items list.");
|
|
102
|
+
// The place each id was first seen. A refusal names places, since an id is trace text.
|
|
103
|
+
const firstPlace = new Map();
|
|
104
|
+
for (const [index, item] of actor.items.entries()) {
|
|
105
|
+
if (!isRecord(item) ||
|
|
106
|
+
typeof item.id !== "string" ||
|
|
107
|
+
item.id.length === 0 ||
|
|
108
|
+
item.id.length > 256 ||
|
|
109
|
+
typeof item.kind !== "string" ||
|
|
110
|
+
typeof item.title !== "string" ||
|
|
111
|
+
(item.text !== undefined && typeof item.text !== "string"))
|
|
112
|
+
throw traceInvalid(stream.id, `item ${index + 1} needs an id of 1 to 256 characters, a kind and a title, and a text that is a string if it has one.`);
|
|
113
|
+
const first = firstPlace.get(item.id);
|
|
114
|
+
if (first !== undefined)
|
|
115
|
+
throw traceInvalid(stream.id, `items ${first + 1} and ${index + 1} share an id, and analysis cites each item by its id.`);
|
|
116
|
+
firstPlace.set(item.id, index);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** A rectangle of the frame, in pixels from its top-left corner. */
|
|
2
|
+
export interface FrameRegion {
|
|
3
|
+
readonly x: number;
|
|
4
|
+
readonly y: number;
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly height: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* What one look found, compared with the frame the participant last saw. `settled`: a region of
|
|
10
|
+
* change held still long enough to wake the participant; `area` bounds it, and `small` is true
|
|
11
|
+
* when it is at most LARGE_REGION_TILES tiles, which the participant's hint names. `changing`: tiles moved
|
|
12
|
+
* since the previous look and nothing settled; `lookSoon` when more than LARGE_REGION_TILES moved,
|
|
13
|
+
* so a large change is confirmed by a quick look. `unchanged`: nothing moved and nothing settled.
|
|
14
|
+
* `held` on the last two bounds a small region that held still past the hold and is held back by a
|
|
15
|
+
* ticking tile or a large moving region near it.
|
|
16
|
+
*/
|
|
17
|
+
export type FrameVerdict = {
|
|
18
|
+
readonly verdict: "unchanged";
|
|
19
|
+
readonly held?: FrameRegion;
|
|
20
|
+
} | {
|
|
21
|
+
readonly verdict: "changing";
|
|
22
|
+
readonly lookSoon: boolean;
|
|
23
|
+
readonly held?: FrameRegion;
|
|
24
|
+
} | {
|
|
25
|
+
readonly verdict: "settled";
|
|
26
|
+
readonly area: FrameRegion;
|
|
27
|
+
readonly small: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Compare later looks at one screen with `reference`, the frame the participant last saw. Call
|
|
31
|
+
* `next` once per look, in order, with the look's time. Each tile that differs from the reference
|
|
32
|
+
* keeps the time it last moved, so a moving tile (a spinner) never joins a still region beside
|
|
33
|
+
* it. Nothing is masked: a large change that stops changing always settles, and a small one does
|
|
34
|
+
* unless a tile near it keeps ticking. Stillness is sampled at looks, so a blink seen in the same
|
|
35
|
+
* phase at every look reads as still.
|
|
36
|
+
*/
|
|
37
|
+
export declare function watchFrames(reference: Buffer): {
|
|
38
|
+
next(frame: Buffer, atMs: number): FrameVerdict;
|
|
39
|
+
};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { PNG } from "pngjs";
|
|
2
|
+
// Whether a screen changed while a participant waited, and whether the change has stopped
|
|
3
|
+
// changing. The loop's watch (src/actors/computer-use/loop/watch.ts) asks after each look.
|
|
4
|
+
//
|
|
5
|
+
// The constants were chosen on 323 idle screenshot pairs from 203 retained hosted-desktop runs.
|
|
6
|
+
// Regions that changed on their own were the panel clock (2 to 6 tiles, every 60 s), a text caret
|
|
7
|
+
// (2 tiles, about every 0.5 s), Chrome's tab spinner (2 to 4 tiles) and one landing page's
|
|
8
|
+
// rotating tagline (10 to 36 tiles, median 18). Page loads, navigations and app updates changed
|
|
9
|
+
// 94 to 2,598 tiles. A moved list selection changed 35 tiles and a re-rendered
|
|
10
|
+
// block of timestamps 8 to 12, both missed by the frame hash in frame-signature.ts.
|
|
11
|
+
//
|
|
12
|
+
// The rule for small regions was chosen on the wave 9 look corpus: 3 pages recorded for 40 minutes
|
|
13
|
+
// each on hosted desktops, replayed over 84 to 168 look timelines a page. There a small region
|
|
14
|
+
// that held still woke the participant falsely beside a panel clock, a relative timestamp
|
|
15
|
+
// ("Opened 3 minutes ago", whose digit ticks each minute and whose tail shifts when the text
|
|
16
|
+
// grows) and a moving chart. With the conditions below it woke none falsely and missed no event.
|
|
17
|
+
/** The side of a square tile, in pixels: one tile is the unit of change. */
|
|
18
|
+
const TILE_PX = 16;
|
|
19
|
+
/**
|
|
20
|
+
* A pixel changed when one of its channels differs by more than this (0 to 255). On the wave 9
|
|
21
|
+
* corpus 32 added a false chart wake, and 48 split a new row's light fill from its text, so a row
|
|
22
|
+
* read as small regions and was seen 69.9 s late.
|
|
23
|
+
*/
|
|
24
|
+
const PIXEL_TOLERANCE = 24;
|
|
25
|
+
/**
|
|
26
|
+
* A tile changed when at least this many of its pixels changed. One or two pixels are an
|
|
27
|
+
* antialiased edge: a spinner edge 2 pixels over the tolerance woke a 900 s watch in wave 7 live
|
|
28
|
+
* run 3c. On the wave 9 corpus every tile of its status and export labels changed 10 to 69 pixels,
|
|
29
|
+
* and its new rows (509 and 545 tiles) lost 7 or 8 tiles at 3. 1 and 3 gave the same wakes there.
|
|
30
|
+
*/
|
|
31
|
+
const TILE_MIN_PIXELS = 3;
|
|
32
|
+
/**
|
|
33
|
+
* A still region of more than this many tiles wakes the participant at once: it is above every
|
|
34
|
+
* self-changing region measured except 11 of 110 tagline swaps, and below every page change. On
|
|
35
|
+
* the wave 9 corpus the status label (30 tiles) and new rows were large, and the export label (8
|
|
36
|
+
* tiles) small.
|
|
37
|
+
*/
|
|
38
|
+
const LARGE_REGION_TILES = 24;
|
|
39
|
+
/**
|
|
40
|
+
* A still region of at most LARGE_REGION_TILES tiles wakes the participant once every tile in it
|
|
41
|
+
* has held still for more than this, and no tile within NEAR_TILES of it ticked or moved with a
|
|
42
|
+
* region of more than MOVING_REGION_TILES tiles in this time. Minute updates (a clock's minutes
|
|
43
|
+
* digit, "2 min ago") move again within it. On the wave 9 corpus a one-time 8-tile text change was
|
|
44
|
+
* seen 72.4 s after it landed (median, 5 s looks).
|
|
45
|
+
*/
|
|
46
|
+
const SMALL_REGION_HOLD_MS = 65_000;
|
|
47
|
+
/**
|
|
48
|
+
* How near, in tiles, a ticking tile or a large moving region holds a small region back. The
|
|
49
|
+
* relative timestamp's tail ("1 minute ago" to "2 minutes ago") sat 4 tiles from its digit in
|
|
50
|
+
* wave 7 live run 2a and on the wave 9 corpus, where a distance of 1 left 4 tail wakes in 168
|
|
51
|
+
* timelines and 4 left none.
|
|
52
|
+
*/
|
|
53
|
+
const NEAR_TILES = 4;
|
|
54
|
+
/**
|
|
55
|
+
* A tile ticks when its last move came at least this long after the move before it, or when it
|
|
56
|
+
* has moved once. A minute counter ticks. A spinner tile that moves every few looks does not, so a
|
|
57
|
+
* label beside a spinner still wakes the participant, at 90 s on the wave 9 synthetic screens.
|
|
58
|
+
*/
|
|
59
|
+
const TICK_GAP_MS = 30_000;
|
|
60
|
+
/**
|
|
61
|
+
* A tile that moves with a connected moving region of more than this many tiles (a chart, a video)
|
|
62
|
+
* holds a small region within NEAR_TILES back. A chart's tiles move every few looks, so they never
|
|
63
|
+
* tick. On the wave 9 corpus, without this condition a chart's tiles woke 3 watches falsely in its
|
|
64
|
+
* page's 40 minutes, and 259 in its 84 replayed timelines; with it, none. It was not tuned apart
|
|
65
|
+
* from LARGE_REGION_TILES and has the same value. A spinner of 1 to 4 tiles stays under it.
|
|
66
|
+
*/
|
|
67
|
+
const MOVING_REGION_TILES = 24;
|
|
68
|
+
const UNCHANGED = { verdict: "unchanged" };
|
|
69
|
+
function decode(frame) {
|
|
70
|
+
try {
|
|
71
|
+
const image = PNG.sync.read(frame);
|
|
72
|
+
return image.width > 0 && image.height > 0 ? image : undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Compare later looks at one screen with `reference`, the frame the participant last saw. Call
|
|
80
|
+
* `next` once per look, in order, with the look's time. Each tile that differs from the reference
|
|
81
|
+
* keeps the time it last moved, so a moving tile (a spinner) never joins a still region beside
|
|
82
|
+
* it. Nothing is masked: a large change that stops changing always settles, and a small one does
|
|
83
|
+
* unless a tile near it keeps ticking. Stillness is sampled at looks, so a blink seen in the same
|
|
84
|
+
* phase at every look reads as still.
|
|
85
|
+
*/
|
|
86
|
+
export function watchFrames(reference) {
|
|
87
|
+
const base = decode(reference);
|
|
88
|
+
// Each tile that differs from the reference, with its pixels at the look it last moved at. Only
|
|
89
|
+
// these are kept from the previous look, so a watch holds one decoded frame and not two.
|
|
90
|
+
const differing = new Map();
|
|
91
|
+
let moves;
|
|
92
|
+
return {
|
|
93
|
+
next(frame, atMs) {
|
|
94
|
+
const current = decode(frame);
|
|
95
|
+
// A frame that cannot be decoded is compared by its bytes; a frame of another size is a new
|
|
96
|
+
// screen, one change over the whole frame.
|
|
97
|
+
if (base === undefined || current === undefined)
|
|
98
|
+
return frame.equals(reference) ? UNCHANGED : wholeFrame(current ?? base);
|
|
99
|
+
if (current.width !== base.width || current.height !== base.height)
|
|
100
|
+
return wholeFrame(current);
|
|
101
|
+
moves ??= tileMoves(base);
|
|
102
|
+
return compare(base, current, atMs, differing, moves);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function tileMoves(image) {
|
|
107
|
+
const columns = Math.ceil(image.width / TILE_PX);
|
|
108
|
+
const rows = Math.ceil(image.height / TILE_PX);
|
|
109
|
+
const never = () => new Float64Array(columns * rows).fill(Number.NaN);
|
|
110
|
+
return { columns, rows, moved: never(), movedBefore: never(), movedLarge: never() };
|
|
111
|
+
}
|
|
112
|
+
function wholeFrame(image) {
|
|
113
|
+
return {
|
|
114
|
+
verdict: "settled",
|
|
115
|
+
area: { x: 0, y: 0, width: image?.width ?? 0, height: image?.height ?? 0 },
|
|
116
|
+
small: false,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function compare(base, current, atMs, differing, moves) {
|
|
120
|
+
const { columns, rows } = moves;
|
|
121
|
+
const still = new Set();
|
|
122
|
+
const movedNow = new Set();
|
|
123
|
+
let moved = 0;
|
|
124
|
+
const move = (tile) => {
|
|
125
|
+
moves.movedBefore[tile] = moves.moved[tile];
|
|
126
|
+
moves.moved[tile] = atMs;
|
|
127
|
+
movedNow.add(tile);
|
|
128
|
+
};
|
|
129
|
+
for (let row = 0; row < rows; row += 1)
|
|
130
|
+
for (let column = 0; column < columns; column += 1) {
|
|
131
|
+
const tile = row * columns + column;
|
|
132
|
+
const before = differing.get(tile);
|
|
133
|
+
if (!framesDiffer(base, current, column, row)) {
|
|
134
|
+
if (before === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
differing.delete(tile);
|
|
137
|
+
// A move back to the reference is a move: a ticking digit can tick back to its old shape.
|
|
138
|
+
if (tileDiffers(before, current, column, row))
|
|
139
|
+
move(tile);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// A tile that starts differing has moved, whatever the previous look's pixels were.
|
|
143
|
+
if (before === undefined || tileDiffers(before, current, column, row)) {
|
|
144
|
+
differing.set(tile, tilePixels(current, column, row));
|
|
145
|
+
move(tile);
|
|
146
|
+
moved += 1;
|
|
147
|
+
}
|
|
148
|
+
else
|
|
149
|
+
still.add(tile);
|
|
150
|
+
}
|
|
151
|
+
for (const region of connectedRegions(movedNow, columns))
|
|
152
|
+
if (region.length > MOVING_REGION_TILES)
|
|
153
|
+
for (const tile of region)
|
|
154
|
+
moves.movedLarge[tile] = atMs;
|
|
155
|
+
const regions = connectedRegions(still, columns);
|
|
156
|
+
const area = (region) => areaOf(region, columns, base.width, base.height);
|
|
157
|
+
const large = largest(regions.filter((region) => region.length > LARGE_REGION_TILES));
|
|
158
|
+
if (large !== undefined)
|
|
159
|
+
return { verdict: "settled", area: area(large), small: false };
|
|
160
|
+
const heldSmall = regions.filter((region) => region.every((tile) => atMs - moves.moved[tile] > SMALL_REGION_HOLD_MS));
|
|
161
|
+
const settled = largest(heldSmall.filter((region) => !heldBack(region, moves, atMs)));
|
|
162
|
+
if (settled !== undefined)
|
|
163
|
+
return { verdict: "settled", area: area(settled), small: true };
|
|
164
|
+
const waiting = largest(heldSmall);
|
|
165
|
+
const held = waiting === undefined ? {} : { held: area(waiting) };
|
|
166
|
+
if (moved > 0)
|
|
167
|
+
return { verdict: "changing", lookSoon: moved > LARGE_REGION_TILES, ...held };
|
|
168
|
+
return { verdict: "unchanged", ...held };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether a tile within NEAR_TILES of the region, outside it, ticked or moved with a large region
|
|
172
|
+
* within the hold.
|
|
173
|
+
*/
|
|
174
|
+
function heldBack(region, moves, atMs) {
|
|
175
|
+
const inRegion = new Set(region);
|
|
176
|
+
// NaN, a tile that never moved, is never recent.
|
|
177
|
+
const recent = (ms) => atMs - ms <= SMALL_REGION_HOLD_MS;
|
|
178
|
+
for (const tile of region)
|
|
179
|
+
for (let dy = -NEAR_TILES; dy <= NEAR_TILES; dy += 1)
|
|
180
|
+
for (let dx = -NEAR_TILES; dx <= NEAR_TILES; dx += 1) {
|
|
181
|
+
const column = (tile % moves.columns) + dx;
|
|
182
|
+
const row = Math.floor(tile / moves.columns) + dy;
|
|
183
|
+
if (column < 0 || column >= moves.columns || row < 0 || row >= moves.rows)
|
|
184
|
+
continue;
|
|
185
|
+
const near = row * moves.columns + column;
|
|
186
|
+
const moved = moves.moved[near];
|
|
187
|
+
if (inRegion.has(near) || !recent(moved))
|
|
188
|
+
continue;
|
|
189
|
+
const before = moves.movedBefore[near];
|
|
190
|
+
if (Number.isNaN(before) ||
|
|
191
|
+
moved - before >= TICK_GAP_MS ||
|
|
192
|
+
recent(moves.movedLarge[near]))
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
/** Whether at least TILE_MIN_PIXELS pixels of one tile differ between two frames of one size. */
|
|
198
|
+
function framesDiffer(a, b, column, row) {
|
|
199
|
+
const { width, height } = a;
|
|
200
|
+
const xEnd = Math.min(width, (column + 1) * TILE_PX);
|
|
201
|
+
const yEnd = Math.min(height, (row + 1) * TILE_PX);
|
|
202
|
+
let changed = 0;
|
|
203
|
+
for (let y = row * TILE_PX; y < yEnd; y += 1)
|
|
204
|
+
for (let x = column * TILE_PX; x < xEnd; x += 1) {
|
|
205
|
+
const at = (y * width + x) * 4;
|
|
206
|
+
if ((Math.abs(a.data[at] - b.data[at]) > PIXEL_TOLERANCE ||
|
|
207
|
+
Math.abs(a.data[at + 1] - b.data[at + 1]) > PIXEL_TOLERANCE ||
|
|
208
|
+
Math.abs(a.data[at + 2] - b.data[at + 2]) > PIXEL_TOLERANCE) &&
|
|
209
|
+
++changed >= TILE_MIN_PIXELS)
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
/** A copy of one tile's RGBA pixels, row by row. */
|
|
215
|
+
function tilePixels(image, column, row) {
|
|
216
|
+
const xStart = column * TILE_PX;
|
|
217
|
+
const xEnd = Math.min(image.width, xStart + TILE_PX);
|
|
218
|
+
const yEnd = Math.min(image.height, (row + 1) * TILE_PX);
|
|
219
|
+
const pixels = new Uint8Array((xEnd - xStart) * (yEnd - row * TILE_PX) * 4);
|
|
220
|
+
let to = 0;
|
|
221
|
+
for (let y = row * TILE_PX; y < yEnd; y += 1) {
|
|
222
|
+
const from = (y * image.width + xStart) * 4;
|
|
223
|
+
pixels.set(image.data.subarray(from, from + (xEnd - xStart) * 4), to);
|
|
224
|
+
to += (xEnd - xStart) * 4;
|
|
225
|
+
}
|
|
226
|
+
return pixels;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether at least TILE_MIN_PIXELS pixels of one tile of `image` differ from `pixels`, that tile's
|
|
230
|
+
* earlier pixels.
|
|
231
|
+
*/
|
|
232
|
+
function tileDiffers(pixels, image, column, row) {
|
|
233
|
+
const xStart = column * TILE_PX;
|
|
234
|
+
const xEnd = Math.min(image.width, xStart + TILE_PX);
|
|
235
|
+
const yEnd = Math.min(image.height, (row + 1) * TILE_PX);
|
|
236
|
+
const { data } = image;
|
|
237
|
+
let at = 0;
|
|
238
|
+
let changed = 0;
|
|
239
|
+
for (let y = row * TILE_PX; y < yEnd; y += 1)
|
|
240
|
+
for (let x = xStart; x < xEnd; x += 1, at += 4) {
|
|
241
|
+
const from = (y * image.width + x) * 4;
|
|
242
|
+
if ((Math.abs(pixels[at] - data[from]) > PIXEL_TOLERANCE ||
|
|
243
|
+
Math.abs(pixels[at + 1] - data[from + 1]) > PIXEL_TOLERANCE ||
|
|
244
|
+
Math.abs(pixels[at + 2] - data[from + 2]) > PIXEL_TOLERANCE) &&
|
|
245
|
+
++changed >= TILE_MIN_PIXELS)
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
/** Tiles grouped with every tile of the set next to them, diagonals included. */
|
|
251
|
+
function connectedRegions(tiles, columns) {
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const regions = [];
|
|
254
|
+
for (const start of tiles) {
|
|
255
|
+
if (seen.has(start))
|
|
256
|
+
continue;
|
|
257
|
+
seen.add(start);
|
|
258
|
+
const region = [];
|
|
259
|
+
const queue = [start];
|
|
260
|
+
for (let tile = queue.pop(); tile !== undefined; tile = queue.pop()) {
|
|
261
|
+
region.push(tile);
|
|
262
|
+
const column = tile % columns;
|
|
263
|
+
for (const dy of [-1, 0, 1])
|
|
264
|
+
for (const dx of [-1, 0, 1]) {
|
|
265
|
+
const neighbourColumn = column + dx;
|
|
266
|
+
const neighbour = tile + dy * columns + dx;
|
|
267
|
+
if (neighbourColumn < 0 || neighbourColumn >= columns)
|
|
268
|
+
continue;
|
|
269
|
+
if (!tiles.has(neighbour) || seen.has(neighbour))
|
|
270
|
+
continue;
|
|
271
|
+
seen.add(neighbour);
|
|
272
|
+
queue.push(neighbour);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
regions.push(region);
|
|
276
|
+
}
|
|
277
|
+
return regions;
|
|
278
|
+
}
|
|
279
|
+
function largest(regions) {
|
|
280
|
+
return regions.reduce((best, region) => (best === undefined || region.length > best.length ? region : best), undefined);
|
|
281
|
+
}
|
|
282
|
+
function areaOf(tiles, columns, width, height) {
|
|
283
|
+
const columnsOf = tiles.map((tile) => tile % columns);
|
|
284
|
+
const rowsOf = tiles.map((tile) => Math.floor(tile / columns));
|
|
285
|
+
const x = Math.min(...columnsOf) * TILE_PX;
|
|
286
|
+
const y = Math.min(...rowsOf) * TILE_PX;
|
|
287
|
+
return {
|
|
288
|
+
x,
|
|
289
|
+
y,
|
|
290
|
+
width: Math.min(width, (Math.max(...columnsOf) + 1) * TILE_PX) - x,
|
|
291
|
+
height: Math.min(height, (Math.max(...rowsOf) + 1) * TILE_PX) - y,
|
|
292
|
+
};
|
|
293
|
+
}
|
package/dist/feedback/export.js
CHANGED
|
@@ -17,7 +17,6 @@ import { buildObserverData, OBSERVER_DATA_SCHEMA, observerDataFromSaved, } from
|
|
|
17
17
|
import { resolveRunPath } from "../run/locate.js";
|
|
18
18
|
import { loadRunTraces, StreamTraceRefusedError } from "../run/stream-trace.js";
|
|
19
19
|
import { verifyRun } from "../verify/verify.js";
|
|
20
|
-
import {} from "../run/bundle.js";
|
|
21
20
|
import { exportRedactedBundle } from "./export-bundle.js";
|
|
22
21
|
import { loadAnalysis } from "../analysis/load.js";
|
|
23
22
|
import { readNotesForSharing } from "../run/note-files.js";
|
package/dist/observer/data.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ActorEnding } from "../actors/stop-cause.js";
|
|
2
2
|
import type { RunBundle, RunCostSummary, RunEvent, RunSimulation } from "../run/bundle.js";
|
|
3
3
|
import { type RunDisplay } from "../run/display.js";
|
|
4
|
-
import type { RunStream, RunStreamKind } from "../run/streams.js";
|
|
4
|
+
import type { RunStream, RunStreamKind, StoredRunStream } from "../run/streams.js";
|
|
5
5
|
/**
|
|
6
6
|
* The Observer's data. From v2 observer-data.json leaves out each participant's items, and from v3
|
|
7
7
|
* each participant's per-request lists (REQUEST_LISTS in src/run/stream-trace.ts), which no part
|
|
@@ -96,7 +96,7 @@ export interface ObserverData {
|
|
|
96
96
|
artifactRoot: string;
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
-
export interface ObserverStream extends
|
|
99
|
+
export interface ObserverStream extends StoredRunStream {
|
|
100
100
|
/** Only the attached server may grant provider-origin access for a live desktop iframe. */
|
|
101
101
|
embed?: NonNullable<RunStream["embed"]> & {
|
|
102
102
|
runtimeDesktop?: true;
|