diff --git a/packages/contracts/package.json b/packages/contracts/package.json index ba42c2c275..d9c30ff5be 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -316,10 +316,6 @@ "types": "./src/managed-web-backend.ts", "default": "./src/managed-web-backend.ts" }, - "./moving-target-gate": { - "types": "./src/moving-target-gate.ts", - "default": "./src/moving-target-gate.ts" - }, "./navigation": { "types": "./src/navigation.ts", "default": "./src/navigation.ts" diff --git a/packages/contracts/src/moving-target-gate.test.ts b/packages/contracts/src/moving-target-gate.test.ts deleted file mode 100644 index 27f5da1d66..0000000000 --- a/packages/contracts/src/moving-target-gate.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { - MOVING_TARGET_GATE_SAMPLE_GAP_MS, - MOVING_TARGET_GATE_WINDOW_MS, - movingTargetGateDecision, -} from './moving-target-gate.ts'; - -// #3471: the two judgements the moving-target gate makes, isolated from both callers so the window -// and the stability rule have exactly one reading. Whether two captures count as one position is -// kernel's `areCapturedRectsAtSamePosition`, tested beside the band it owns -// (`packages/kernel/src/rect-center.test.ts`). The runtime half (what a press does with these -// answers) is `src/commands/interaction/runtime/moving-target-gate.ts`; the Layer-3 scenarios behind -// the ADR 0011 `targetStability` cells drive the whole press. - -// --- is a second observation owed? --- - -test('a session with no mutation clock is idle and owes nothing', () => { - // `targetShownAtRest` is deliberately the disturbing kind here: with no clock there is nothing to - // wait out, and an absent clock must not read as "a mutation just finished". - const decision = movingTargetGateDecision({ - baselineFinishedAtMs: undefined, - targetShownAtRest: false, - nowMs: 10_000, - }); - - assert.deepEqual(decision, { kind: 'idle' }); -}); - -test('a target the previous action left in place is answered without a re-observation', () => { - const decision = movingTargetGateDecision({ - baselineFinishedAtMs: 9_900, - targetShownAtRest: true, - nowMs: 10_000, - }); - - assert.deepEqual(decision, { kind: 'stationary' }); -}); - -test('the window is measured from the mutation END, so only what is LEFT of it is waited out', () => { - // The action finished 400 ms ago; a full window of waiting would be a 900 ms pause on a press - // whose surface has already been quiet for 400 ms. - const decision = movingTargetGateDecision({ - baselineFinishedAtMs: 9_600, - targetShownAtRest: false, - nowMs: 10_000, - }); - - assert.deepEqual(decision, { kind: 'recheck', waitMs: 100 }); -}); - -test('the declared window is the 500 ms transition budget, and its edge decides the branch', () => { - // The edge is exclusive: at exactly the budget the resolved point already stands, because an - // action that finished a full window ago has no claim on this press. - assert.equal(MOVING_TARGET_GATE_WINDOW_MS, 500); - const atEdge = movingTargetGateDecision({ - baselineFinishedAtMs: 10_000 - MOVING_TARGET_GATE_WINDOW_MS, - targetShownAtRest: false, - nowMs: 10_000, - }); - const inside = movingTargetGateDecision({ - baselineFinishedAtMs: 9_600, - targetShownAtRest: false, - nowMs: 10_000, - }); - const outside = movingTargetGateDecision({ - baselineFinishedAtMs: 9_400, - targetShownAtRest: false, - nowMs: 10_000, - }); - - assert.deepEqual(atEdge, { kind: 'not-recent' }); - assert.equal(inside.kind, 'recheck'); - assert.equal(outside.kind, 'not-recent'); -}); - -test('inside the window, only a tree that shows the target at rest excuses the re-observation', () => { - // One bit carries the whole screening verdict, because that is all this branch can use: absent, - // ambiguous, and moved all mean the same thing here — the session cannot show where the target - // came to rest, so it owes the look. - for (const targetShownAtRest of [false, true] as const) { - const decision = movingTargetGateDecision({ - baselineFinishedAtMs: 9_950, - targetShownAtRest, - nowMs: 10_000, - }); - - assert.equal( - decision.kind, - targetShownAtRest ? 'stationary' : 'recheck', - `targetShownAtRest ${String(targetShownAtRest)}`, - ); - } -}); - -test('the wait never drops below one sample gap, even as the window closes', () => { - // Re-observing immediately would compare two captures the same animation frame could produce, - // and agreement would then be a timing accident rather than the target having stopped. - const decision = movingTargetGateDecision({ - baselineFinishedAtMs: 9_520, - targetShownAtRest: false, - nowMs: 10_000, - }); - - assert.deepEqual(decision, { kind: 'recheck', waitMs: MOVING_TARGET_GATE_SAMPLE_GAP_MS }); -}); - -test('a clock skew ahead of the mutation does not produce a negative wait', () => { - const decision = movingTargetGateDecision({ - baselineFinishedAtMs: 10_100, - targetShownAtRest: false, - nowMs: 10_000, - }); - - assert.ok(decision.kind === 'recheck'); - assert.ok(decision.waitMs >= MOVING_TARGET_GATE_SAMPLE_GAP_MS); -}); - -// The refusal's published recovery is asserted in the runtime gate's suite, where the hint lives -// beside the refusal that publishes it (`movingTargetHint`); this module decides, it does not speak. diff --git a/packages/contracts/src/moving-target-gate.ts b/packages/contracts/src/moving-target-gate.ts deleted file mode 100644 index ee762e719c..0000000000 --- a/packages/contracts/src/moving-target-gate.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { SurfaceScopedNodes } from './interaction.ts'; - -/** - * The pure half of the moving-target gate (#3471): a press must not dispatch at a point an - * in-flight transition has already vacated. A capture is one instant's photograph of a surface that - * may still be rearranging itself, and the ADB-backed Android helper is measured reporting frames in - * flight — so a press issued right after an action that moved the UI can resolve a point the target - * has already left and report success on the vacancy. The exposure is the runtime path's own - * capture-to-dispatch distance, so the gate is backend-neutral. - * - * Two judgements decide when it is real: {@link movingTargetGateDecision} asks whether this position - * is still owed a second observation, and kernel's `areCapturedRectsAtSamePosition` asks whether the - * target has stopped — that one lives in `@agent-device/kernel/rect-center` because the post-gesture - * no-effect comparison judges the same pair of capture rects and must not hold its own band. Both - * are pure over values the caller already holds, so neither is re-derived per platform. The window is - * anchored on the mutation's END, so the gate waits out only what is LEFT of it — which is also why - * the refusal's hint names `--settle` as the caller-opted superset. - * - * What cannot be decided over values — spending the window, taking the re-observations, and the - * refusal with its reason (`INTERACTION_ERROR_REASONS.targetStillMoving`) and hint — lives in - * `src/commands/interaction/runtime/moving-target-gate.ts`, which produces all three together. - */ - -/** How long after a mutation its target positions stay owed a second observation. */ -export const MOVING_TARGET_GATE_WINDOW_MS = 500; - -/** - * How far apart the two observations must be for agreeing to mean "at rest". Below one capture - * round trip, two observations of a still-moving target agree by timing rather than by the target - * having stopped, and the gate would certify a position it never measured. The gate therefore waits - * `max(remaining window, this)` before re-observing. - */ -export const MOVING_TARGET_GATE_SAMPLE_GAP_MS = 50; - -/** - * The tree a mutation resolved against, kept on the session with the instant that mutation - * finished, so the next action can ask whether its own coordinates are still in play. - * - * A {@link SurfaceScopedNodes} rather than a whole capture: the gate compares node positions on one - * surface, and the surface scoping is what keeps an iOS system-sheet tree from ever being compared - * against app content (#2438). - */ -export type MovingTargetBaseline = Readonly<{ - /** - * When the action finished, not when it began: the gate waits out what is LEFT of the window. The - * daemon stamps it with `Date.now()`; the gate compares it against `now(runtime)`, so a runtime - * that installs a clock of its own (a scenario pinning the window) states this value in that - * clock's time too. Production installs none, so both sides read the wall clock. - */ - finishedAt: number; - /** - * The tree the action acted through: its resolution's own observation, or the settled one its - * `--settle` published as rest. - */ - tree: SurfaceScopedNodes; -}>; - -export type MovingTargetGateDecision = - /** The session holds no baseline: nothing has resolved a mutation recently enough to matter. */ - | { kind: 'idle' } - /** The last resolving mutation finished at or beyond the window ago; the resolved point stands. */ - | { kind: 'not-recent' } - /** Inside the window, but the pre-action tree already shows this node at the resolved rect. */ - | { kind: 'stationary' } - /** Inside the window and disturbed: re-observe at the end of it before dispatching. */ - | { kind: 'recheck'; waitMs: number }; - -/** - * Whether a resolved rect is still owed a second observation, from the session's mutation clock and - * its pre-action tree's verdict on the resolved node. A missing verdict (no baseline tree) can only - * mean "not shown at rest", so it rechecks. - */ -export function movingTargetGateDecision(params: { - /** When the session's last resolving mutation finished; `undefined` when the session is idle. */ - baselineFinishedAtMs: number | undefined; - /** Whether the action's own pre-action tree already shows the resolved node at this rect. */ - targetShownAtRest: boolean; - nowMs: number; -}): MovingTargetGateDecision { - if (params.baselineFinishedAtMs === undefined) return { kind: 'idle' }; - const ageMs = params.nowMs - params.baselineFinishedAtMs; - if (ageMs >= MOVING_TARGET_GATE_WINDOW_MS) return { kind: 'not-recent' }; - if (params.targetShownAtRest) return { kind: 'stationary' }; - return { - kind: 'recheck', - waitMs: Math.max(MOVING_TARGET_GATE_WINDOW_MS - ageMs, MOVING_TARGET_GATE_SAMPLE_GAP_MS), - }; -} diff --git a/packages/contracts/src/runtime-contract.ts b/packages/contracts/src/runtime-contract.ts index 4e006945d4..7ead23756b 100644 --- a/packages/contracts/src/runtime-contract.ts +++ b/packages/contracts/src/runtime-contract.ts @@ -1,6 +1,6 @@ import type { AgentDeviceBackend } from './backend.ts'; import type { ArtifactAdapter } from './artifact-adapter.ts'; -import type { MovingTargetBaseline } from './moving-target-gate.ts'; +import type { SurfaceScopedNodes } from './interaction.ts'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; export type CommandPolicy = { @@ -9,6 +9,29 @@ export type CommandPolicy = { maxImagePixels: number; }; +/** + * The tree a mutation resolved against, kept on the session with the instant that mutation + * finished, so the next action can ask whether its own coordinates are still in play. + * + * A {@link SurfaceScopedNodes} rather than a whole capture: the gate compares node positions on one + * surface, and the surface scoping is what keeps an iOS system-sheet tree from ever being compared + * against app content (#2438). + */ +export type MovingTargetBaseline = Readonly<{ + /** + * When the action finished, not when it began: the gate waits out what is LEFT of the window. The + * daemon stamps it with `Date.now()`; the gate compares it against `now(runtime)`, so a runtime + * that installs a clock of its own (a scenario pinning the window) states this value in that + * clock's time too. Production installs none, so both sides read the wall clock. + */ + finishedAt: number; + /** + * The tree the action acted through: its resolution's own observation, or the settled one its + * `--settle` published as rest. + */ + tree: SurfaceScopedNodes; +}>; + export type CommandSessionRecord = { name: string; appId?: string; diff --git a/packages/daemon/src/core/deferred-interaction-outcome.ts b/packages/daemon/src/core/deferred-interaction-outcome.ts index fd0fbbcced..0088997e2e 100644 --- a/packages/daemon/src/core/deferred-interaction-outcome.ts +++ b/packages/daemon/src/core/deferred-interaction-outcome.ts @@ -4,7 +4,7 @@ import type { PostOpenObservation } from '@agent-device/contracts/application-li import type { SnapshotCaptureAnnotations } from '@agent-device/contracts/capture'; import { isApplePlatform, isMobilePlatform } from '@agent-device/kernel/device'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; -import type { MovingTargetBaseline } from '@agent-device/contracts/moving-target-gate'; +import type { MovingTargetBaseline } from '@agent-device/contracts/runtime-contract'; import { captureAndroidFreshnessRecoveredAttempt, getActiveAndroidSnapshotFreshness, diff --git a/packages/daemon/src/core/session-state.ts b/packages/daemon/src/core/session-state.ts index 1a1f7c5194..a33d1629d7 100644 --- a/packages/daemon/src/core/session-state.ts +++ b/packages/daemon/src/core/session-state.ts @@ -8,7 +8,7 @@ import type { } from '@agent-device/contracts/perf-runtime'; import type { ScreenRecordingLiveHandle } from '@agent-device/contracts/screen-recording-runtime'; import type { SessionAction, SessionScope, SessionSurface } from '@agent-device/contracts/session'; -import type { MovingTargetBaseline as ContractsMovingTargetBaseline } from '@agent-device/contracts/moving-target-gate'; +import type { MovingTargetBaseline } from '@agent-device/contracts/runtime-contract'; import type { LeaseBackend, SessionRuntimeHints as PublicSessionRuntimeHints, @@ -122,9 +122,9 @@ export type DeferredSurface = * The tree the mutation resolved its target against, beside the instant the mutation * finished, so the next action can ask whether its own coordinates are still in play * (#3471). The shape is `MovingTargetBaseline` in - * `@agent-device/contracts/moving-target-gate`, declared beside the gate that reads it. + * `@agent-device/contracts/runtime-contract`, declared beside the session record that carries it. */ - baseline: ContractsMovingTargetBaseline; + baseline: MovingTargetBaseline; }; /** diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index ae16f53cbf..01a719f264 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -132,14 +132,7 @@ export const NEW_ENTRY_CEILINGS: Readonly> = Objec */ export const APPROVED_OVER_CEILING: Readonly< Record -> = Object.freeze({ - 'packages/maestro/src/replay-backend/replay-maestro-backend.ts': { - issue: '#3377', - reason: - 'The Maestro replay backend plugin IS the engine behind `replay --maestro`; the registry reaches it only through a function-scoped import(), and no startup hub evaluates it (cli-startup-import-closure pins that).', - owner: 'thymikee', - }, -}); +> = Object.freeze({}); /** The category is a function of the path, never a hand-written column. */ export function entryCategoryOf(entryFile: string): EntryCategory { diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index c2834535cf..5a5e4c8e2b 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -76,7 +76,6 @@ "@agent-device/contracts/local-interactor-operation-set", "@agent-device/contracts/logs-runtime-plan", "@agent-device/contracts/managed-web-backend", - "@agent-device/contracts/moving-target-gate", "@agent-device/contracts/navigation", "@agent-device/contracts/network-runtime", "@agent-device/contracts/network-runtime-plan", diff --git a/src/commands/interaction/runtime/moving-target-gate.test.ts b/src/commands/interaction/runtime/moving-target-gate.test.ts index 635bc84b78..7375487d34 100644 --- a/src/commands/interaction/runtime/moving-target-gate.test.ts +++ b/src/commands/interaction/runtime/moving-target-gate.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import type { AgentDeviceBackend } from '@agent-device/contracts/backend'; import type { PreresolvedInteractionTarget } from '@agent-device/contracts/interaction'; -import type { MovingTargetBaseline } from '@agent-device/contracts/moving-target-gate'; +import type { MovingTargetBaseline } from '@agent-device/contracts/runtime-contract'; import type { Point, SnapshotState } from '@agent-device/kernel/snapshot'; import { readNodeLocalIdentity, readNodeStructuralDenotation } from '@agent-device/ad-script'; import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; @@ -13,6 +13,11 @@ import { localCommandPolicy } from '@agent-device/contracts/command-policy'; import { createMemorySessionStore } from '../../../command-runtime/runtime-factory.ts'; import { ref, selector } from './selector-read-utils.ts'; import { createFakeClock } from './__tests__/settle-device-fixtures.ts'; +import { + MOVING_TARGET_GATE_SAMPLE_GAP_MS, + MOVING_TARGET_GATE_WINDOW_MS, + movingTargetGateDecision, +} from './moving-target-gate.ts'; // #3471, the gate's own claims: what a press pays when the session is idle, and which callers are // never asked to pay anything. The accepted re-aim and the typed refusal are proven end-to-end by @@ -448,3 +453,110 @@ test('the refused selector press publishes a wait that survives the shell', asyn }, ); }); + +// The pure decision, isolated from the press so the window has exactly one reading. Whether two +// captures count as one position is kernel's `areCapturedRectsAtSamePosition`, tested beside the +// band it owns (`packages/kernel/src/rect-center.test.ts`). + +// --- is a second observation owed? --- + +test('a session with no mutation clock is idle and owes nothing', () => { + // `targetShownAtRest` is deliberately the disturbing kind here: with no clock there is nothing to + // wait out, and an absent clock must not read as "a mutation just finished". + const decision = movingTargetGateDecision({ + baselineFinishedAtMs: undefined, + targetShownAtRest: false, + nowMs: 10_000, + }); + + assert.deepEqual(decision, { kind: 'idle' }); +}); + +test('a target the previous action left in place is answered without a re-observation', () => { + const decision = movingTargetGateDecision({ + baselineFinishedAtMs: 9_900, + targetShownAtRest: true, + nowMs: 10_000, + }); + + assert.deepEqual(decision, { kind: 'stationary' }); +}); + +test('the window is measured from the mutation END, so only what is LEFT of it is waited out', () => { + // The action finished 400 ms ago; a full window of waiting would be a 900 ms pause on a press + // whose surface has already been quiet for 400 ms. + const decision = movingTargetGateDecision({ + baselineFinishedAtMs: 9_600, + targetShownAtRest: false, + nowMs: 10_000, + }); + + assert.deepEqual(decision, { kind: 'recheck', waitMs: 100 }); +}); + +test('the declared window is the 500 ms transition budget, and its edge decides the branch', () => { + // The edge is exclusive: at exactly the budget the resolved point already stands, because an + // action that finished a full window ago has no claim on this press. + assert.equal(MOVING_TARGET_GATE_WINDOW_MS, 500); + const atEdge = movingTargetGateDecision({ + baselineFinishedAtMs: 10_000 - MOVING_TARGET_GATE_WINDOW_MS, + targetShownAtRest: false, + nowMs: 10_000, + }); + const inside = movingTargetGateDecision({ + baselineFinishedAtMs: 9_600, + targetShownAtRest: false, + nowMs: 10_000, + }); + const outside = movingTargetGateDecision({ + baselineFinishedAtMs: 9_400, + targetShownAtRest: false, + nowMs: 10_000, + }); + + assert.deepEqual(atEdge, { kind: 'not-recent' }); + assert.equal(inside.kind, 'recheck'); + assert.equal(outside.kind, 'not-recent'); +}); + +test('inside the window, only a tree that shows the target at rest excuses the re-observation', () => { + // One bit carries the whole screening verdict, because that is all this branch can use: absent, + // ambiguous, and moved all mean the same thing here — the session cannot show where the target + // came to rest, so it owes the look. + for (const targetShownAtRest of [false, true] as const) { + const decision = movingTargetGateDecision({ + baselineFinishedAtMs: 9_950, + targetShownAtRest, + nowMs: 10_000, + }); + + assert.equal( + decision.kind, + targetShownAtRest ? 'stationary' : 'recheck', + `targetShownAtRest ${String(targetShownAtRest)}`, + ); + } +}); + +test('the wait never drops below one sample gap, even as the window closes', () => { + // Re-observing immediately would compare two captures the same animation frame could produce, + // and agreement would then be a timing accident rather than the target having stopped. + const decision = movingTargetGateDecision({ + baselineFinishedAtMs: 9_520, + targetShownAtRest: false, + nowMs: 10_000, + }); + + assert.deepEqual(decision, { kind: 'recheck', waitMs: MOVING_TARGET_GATE_SAMPLE_GAP_MS }); +}); + +test('a clock skew ahead of the mutation does not produce a negative wait', () => { + const decision = movingTargetGateDecision({ + baselineFinishedAtMs: 10_100, + targetShownAtRest: false, + nowMs: 10_000, + }); + + assert.ok(decision.kind === 'recheck'); + assert.ok(decision.waitMs >= MOVING_TARGET_GATE_SAMPLE_GAP_MS); +}); diff --git a/src/commands/interaction/runtime/moving-target-gate.ts b/src/commands/interaction/runtime/moving-target-gate.ts index d10a6f92ac..48ce52b637 100644 --- a/src/commands/interaction/runtime/moving-target-gate.ts +++ b/src/commands/interaction/runtime/moving-target-gate.ts @@ -1,9 +1,3 @@ -import { - MOVING_TARGET_GATE_SAMPLE_GAP_MS, - movingTargetGateDecision, - type MovingTargetBaseline, - type MovingTargetGateDecision, -} from '@agent-device/contracts/moving-target-gate'; import { INTERACTION_ERROR_REASONS } from '@agent-device/selectors/interaction-error'; import type { InteractionTarget, @@ -18,6 +12,7 @@ import type { AgentDeviceRuntime, CommandContext, CommandSessionRecord, + MovingTargetBaseline, } from '@agent-device/contracts/runtime-contract'; import { nodeRectByIdentity } from '@agent-device/ad-script'; import { now, sleep } from '../../runtime-common.ts'; @@ -27,15 +22,29 @@ import { resolveInteractionTarget } from './resolution.ts'; import type { ResolveInteractionTargetParams } from './interaction-resolution-request.ts'; /** - * The moving-target gate (#3471): the seam that spends the window, in front of the tap and behind - * every pre-dispatch guard that already judged the target (occlusion, off-screen, keyboard), so it - * re-decides none of them. The decision and its budget live in - * `@agent-device/contracts/moving-target-gate`; what lives here is the part that can only be done - * with a runtime — taking the second and third observations, and putting the tap at the position two - * of them agree on. + * The moving-target gate (#3471): a press or click resolved from a selector or `@ref` must not + * dispatch at a point an in-flight transition has already vacated. A raw-coordinate target has no + * resolved element to re-observe, so the gate passes it through. A capture is one instant's + * photograph of a surface that may still be rearranging itself, and the ADB-backed Android helper is + * measured reporting frames in flight — so an interaction issued right after an action that moved + * the UI can resolve a point the target has already left and report success on the vacancy. The exposure is the runtime path's own capture-to-dispatch + * distance, so the gate is backend-neutral. * - * Agreement dispatches rather than refuses: a control that settled inside the window is the tap the - * caller asked for, at where it ended up. The refusal is reserved for a target the window could not + * Two judgements decide when it is real: {@link movingTargetGateDecision} asks whether this position + * is still owed a second observation, and kernel's `areCapturedRectsAtSamePosition` asks whether the + * target has stopped — that one lives in `@agent-device/kernel/rect-center` because the post-gesture + * no-effect comparison judges the same pair of capture rects and must not hold its own band. Both + * are pure over values the caller already holds, so neither is re-derived per platform. The window is + * anchored on the mutation's END, so the gate waits out only what is LEFT of it — which is also why + * the refusal's hint names `--settle` as the caller-opted superset. + * + * This is the seam that spends the window, in front of the dispatch and behind every pre-dispatch + * guard that already judged the target (occlusion, off-screen, keyboard), so it re-decides none of + * them: it takes the second and third observations, and dispatches the requested interaction at the + * position two of them agree on. + * + * Agreement dispatches rather than refuses: a control that settled inside the window is the target + * the caller asked for, at where it ended up. The refusal is reserved for a target the window could not * settle, where a coordinate would be a guess. * * Re-resolving rather than re-pointing is deliberate: the recheck spends the whole resolution path, @@ -63,6 +72,49 @@ export async function guardResolvedTargetAgainstMotion( return await resolveAtRestTarget(runtime, options, params, resolved, gate); } +/** How long after a mutation its target positions stay owed a second observation. */ +export const MOVING_TARGET_GATE_WINDOW_MS = 500; + +/** + * How far apart the two observations must be for agreeing to mean "at rest". Below one capture + * round trip, two observations of a still-moving target agree by timing rather than by the target + * having stopped, and the gate would certify a position it never measured. The gate therefore waits + * `max(remaining window, this)` before re-observing. + */ +export const MOVING_TARGET_GATE_SAMPLE_GAP_MS = 50; + +type MovingTargetGateDecision = + /** The session holds no baseline: nothing has resolved a mutation recently enough to matter. */ + | { kind: 'idle' } + /** The last resolving mutation finished at or beyond the window ago; the resolved point stands. */ + | { kind: 'not-recent' } + /** Inside the window, but the pre-action tree already shows this node at the resolved rect. */ + | { kind: 'stationary' } + /** Inside the window and disturbed: re-observe at the end of it before dispatching. */ + | { kind: 'recheck'; waitMs: number }; + +/** + * Whether a resolved rect is still owed a second observation, from the session's mutation clock and + * its pre-action tree's verdict on the resolved node. A missing verdict (no baseline tree) can only + * mean "not shown at rest", so it rechecks. + */ +export function movingTargetGateDecision(params: { + /** When the session's last resolving mutation finished; `undefined` when the session is idle. */ + baselineFinishedAtMs: number | undefined; + /** Whether the action's own pre-action tree already shows the resolved node at this rect. */ + targetShownAtRest: boolean; + nowMs: number; +}): MovingTargetGateDecision { + if (params.baselineFinishedAtMs === undefined) return { kind: 'idle' }; + const ageMs = params.nowMs - params.baselineFinishedAtMs; + if (ageMs >= MOVING_TARGET_GATE_WINDOW_MS) return { kind: 'not-recent' }; + if (params.targetShownAtRest) return { kind: 'stationary' }; + return { + kind: 'recheck', + waitMs: Math.max(MOVING_TARGET_GATE_WINDOW_MS - ageMs, MOVING_TARGET_GATE_SAMPLE_GAP_MS), + }; +} + /** A press owed a re-observation: the recheck decision, which carries the wait it owes. */ type MovingTargetRecheck = Extract;