Skip to content

fix(daemon): corroborate the no-effect claim against the rebased baseline - #1622

Closed
thymikee wants to merge 2 commits into
mainfrom
claude/instrument-gesture-no-effect-corroboration
Closed

fix(daemon): corroborate the no-effect claim against the rebased baseline#1622
thymikee wants to merge 2 commits into
mainfrom
claude/instrument-gesture-no-effect-corroboration

Conversation

@thymikee

@thymikee thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member

Part of #1620 — the truncation-drift half stays open.

The defect

capturePostGestureStabilizedResult keeps a local baseline that it rebases when the capture backend changes mid-poll. Backends disagree about which nodes exist, so a cross-backend pair says nothing about the gesture — #1569 adopts the new capture as the baseline rather than concluding from it, and the verdict is computed from that comparable pair.

buildAcceptedStabilizedResult then corroborated against pending.baselineSignature: the original pre-gesture signature, the one the loop had just discarded as incomparable. So on any backend fallback the agent-facing claim re-introduced exactly the comparison #1569 exists to forbid — and haveIdenticalDiscriminatingSurfaces demands set equality, which two backends' views of one screen never satisfy.

Net effect: the gestureNoEffect warning could not fire on any screen whose capture plan falls back. That is every hostile screen — the only kind it was built for. #1600's motivating case, element-18, was a Bluesky feed; measured live on a seeded Bluesky fixture the capture falls back to private-ax and truncates at depth 56, and no no-effect gesture produced the warning across five attempts on two screens.

The fix

Corroborate against the loop's live baselineSignature. With no rebase it is pending's, so #1601's veto is unchanged in the ordinary case; after a rebase it is the comparable same-backend capture, which is the only pair either check should read.

Red evidence

The new test models what the two backends actually return for one screen — private-ax additionally reports a scrolled-away row the tree backend prunes — with every post-gesture capture identical to the others, so the gesture provably moved nothing. Against the previous line it fails with "a proven-inert gesture must still be reported after the capture backend falls back"; with the fix 21/21.

An earlier draft of that test was vacuous and is worth knowing about: it used identical node content for both backends, so it passed against the unfixed code, and the green run looked no different from a real one. The defect is precisely that the two views differ, so the fixture has to differ too.

Not fixed here

Truncation drift is the second candidate veto from #1620 and is untouched. A depth-capped tree may still fail set equality against its own baseline; whether that happens in practice needs the live re-run this fix unblocks. #1620 stays open until that is measured.

Gates

check:affected --run green on this commit: 132 files / 1063 tests, "all runnable checks passed". Daemon suite 92 files / 682 tests. typecheck / lint / format / check:layering green.

One process note: a later re-run failed on android-lifecycle (~15s timeout, the flake this repo sees under load) and my vitest | grep && git push chain pushed anyway, because a pipeline returns grep's status. Wrong chain on my part. Re-checked afterwards: that test passes 2/2 on this branch and 12/12 on an unrelated branch, and this change cannot reach Android — requiresPostGestureBaselineDistrust is Apple-only, so baselineSignature is undefined there and the corroboration returns false either way.

…line

Closes #1620.

`capturePostGestureStabilizedResult` keeps a local baseline that it REBASES
when the capture backend changes mid-poll: backends disagree about which
nodes exist, so a cross-backend pair says nothing about the gesture and
#1569 adopts the new capture as the baseline rather than concluding from
it. The verdict is then computed from that comparable pair.

`buildAcceptedStabilizedResult` corroborated against
`pending.baselineSignature` instead — the ORIGINAL pre-gesture signature,
the one the loop had just discarded as incomparable. So on any backend
fallback the agent-facing claim re-introduced exactly the comparison #1569
exists to prevent, and `haveIdenticalDiscriminatingSurfaces` demands set
equality, which two backends' views of one screen never satisfy.

The consequence was that the warning could not fire on any screen whose
capture plan falls back — i.e. every hostile screen, which is the only
kind it was built for. #1600's motivating case, element-18, was a Bluesky
feed; measured live on a seeded Bluesky fixture, the capture falls back to
private-ax and truncates at depth 56, and no no-effect gesture ever
produced the warning.

Fix: corroborate against the loop's live `baselineSignature`. When no
rebase happened it is `pending`'s, so the #1601 veto is unchanged for the
ordinary case; when a rebase happened it is the comparable same-backend
capture, which is the only pair either check should ever read.

Red evidence: the new test models what two backends actually return for
one screen — private-ax additionally reports a scrolled-away row the tree
backend prunes — with every post-gesture capture identical to the others,
so the gesture provably moved nothing. Against the previous line it fails
("a proven-inert gesture must still be reported after the capture backend
falls back"); with the fix, 21/21.

An earlier draft of that test used identical node content for both
backends and passed against the unfixed code — vacuous, since the whole
defect is that the two views differ. Recorded here because the green run
looked identical either way.
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.97 MB 1.97 MB -6 B
JS gzip 631.7 kB 631.7 kB +3 B
npm tarball 761.6 kB 761.6 kB +5 B
npm unpacked 2.67 MB 2.67 MB -6 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.6 ms 26.2 ms -0.4 ms
CLI --help 63.3 ms 63.8 ms +0.6 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/internal/daemon.js -6 B +3 B

@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Not ready — one P1 correctness issue and one scope issue remain:

  1. markPostGestureStabilization records baselineSignature: [] when there is no pre-gesture snapshot. Since [] is truthy, the first capture with a defined backend rebases that empty baseline to the post-gesture signature. This PR then corroborates later quiet captures against that invented baseline and can emit gestureNoEffect despite having no before-state evidence. Rebase only a non-empty original baseline (baselineSignature?.length) and add a fake-timer regression: no session.snapshot, steady private-AX captures => no warning.

  2. The body says Closes #1620, but its “Not fixed here” section explicitly leaves truncation drift unresolved and says gestureNoEffect warning is unreachable on hostile-capture screens (the ones it targets) #1620 must remain open. Change this to Part of #1620.

The intended same-backend rebase fix and red-before test are otherwise sound. Most CI is green; iOS Smoke is still in progress. Do not apply ready-for-human until these findings are addressed.

Review P1 on #1622, and a false-positive path the previous commit
introduced.

`markPostGestureStabilization` records `baselineSignature: []` when the
session has no pre-gesture snapshot. `decidePostGestureStabilityVerdict`
already guards that correctly (`!baselineSignature?.length` -> 'trust'),
but the backend-rebase branch tested plain truthiness, and `[]` is truthy.
So the first capture from a different backend replaced "no before-state"
with a post-gesture signature — inventing a baseline. That was harmless
while corroboration read `pending.baselineSignature` (still `[]`, and
`haveIdenticalDiscriminatingSurfaces` returns false on an empty side), but
the previous commit pointed corroboration at the loop's live baseline, so
the invented one became reachable: the loop could agree with itself and
report a gesture inert with nothing to compare against.

Rebase only a non-empty baseline, matching the guard the verdict already
uses.

Red evidence: with no `session.snapshot` and steady private-AX captures,
restoring the truthiness test fails the new case ("a no-effect claim needs
a real pre-gesture baseline, never one the loop invented for itself");
22/22 with `?.length`.
@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Both addressed on 408a020c.

P1 — correct, and it was a false-positive path this PR introduced. markPostGestureStabilization records baselineSignature: [] with no pre-gesture snapshot; decidePostGestureStabilityVerdict already guards that properly (!baselineSignature?.length'trust'), but the rebase branch tested plain truthiness and [] is truthy. So the first differing-backend capture replaced "no before-state" with a post-gesture signature.

That was inert while corroboration read pending.baselineSignature — still [], and haveIdenticalDiscriminatingSurfaces returns false on an empty side. My first commit pointed corroboration at the loop's live baseline, which made the invented one reachable: the loop could agree with itself and report a gesture inert with nothing to compare against. Exactly the false positive #1601's gate exists to stop, reintroduced through the back door.

Now baselineSignature?.length, matching the guard the verdict already uses.

Red evidence, as requested — no session.snapshot, steady private-AX captures, fake timers: restoring the truthiness test fails the new case ("a no-effect claim needs a real pre-gesture baseline, never one the loop invented for itself"); 22/22 with ?.length.

Scope — fixed. Body now reads Part of #1620 — the truncation-drift half stays open. You were right that Closes contradicted the PR's own "not fixed here" section.

Gates: typecheck / lint / format clean, 22/22 on the touched suite. check:affected red only on android-lifecycle (~15s timeout), which passes 12/12 in isolation with exit 0 — the load flake this repo sees, and unreachable from this change since requiresPostGestureBaselineDistrust is Apple-only.

🤖 Addressed by Claude Code

@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Attempted the post-fix live check at exact head 408a020c (CLI + daemon built from this branch, isolated --state-dir, explicit --platform ios --udid, seeded Bluesky fixture sim). I could not demonstrate the fix live, and the reason is that this screen cannot exercise it.

The capture reports:

{"state":"recovered","backend":"private-ax",
 "reason":"timed out while preparing tree snapshot on the XCTest main thread",
 "reasonCode":"capture-failed","effectiveDepth":56}

Tree capture times out on the first attempt here, so every capture in the session — pre-gesture baseline included — is already private-ax. baselineBackend === current.backend, the rebase branch is never entered, and the line this PR changes is never reached. A green run on this screen would have proved nothing, and I am not presenting it as evidence.

Two further facts from the same run, both narrowing #1620:

  1. No backend flip is available on a fully-hostile screen. The flip needs a screen that starts on tree and degrades mid-sequence. Bluesky degrades before the first capture, so it is the wrong fixture for this specific proof despite being the right one for iOS: scroll/fling/pan silently no-op on some UITableViews while reporting success #1600's original symptom.
  2. No post_gesture_* diagnostic appears in the session request logs for a swipe — not the rebase, not the settle, not the stabilization timeout. Either the loop is not entering on this path or its diagnostics are below the persisted level. I did not resolve which, and it is a prerequisite for any live proof here: without a post-gesture phase in the log there is nothing to read a verdict from.

So the current evidence for this change is the unit level, where it is genuinely red-before-green on both findings: the backend-flip case fails against the old corroboration line, and the empty-baseline case fails against the old truthiness test.

Your call on how to weigh that. If a recorded live flip is required before merge, the honest next step is (2) above — make the stabilization verdict observable on the gesture path first, then stage a screen that degrades mid-sequence. That is a separate change from this one, and I would rather do it under #1620 than grow this PR.

Leaving as draft.

🤖 Addressed by Claude Code

@thymikee

thymikee commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 408a020c. Both prior findings are resolved: the empty-baseline path now gates on baselineSignature?.length with a non-vacuous red-before regression, and the PR correctly says Part of #1620 while leaving truncation drift open. The original backend-flip regression also remains meaningful. All CI is green and the branch is mergeable.

Still not ready-for-human under the repository device-evidence rule: the exact-head live attempt did not exercise the changed tree→private-AX mid-poll rebase path (the hostile screen started on private-AX), and no post_gesture_* diagnostic proved the production stabilization branch ran. Please capture exact-head simulator evidence where a tree baseline degrades mid-stabilization, a genuinely inert gesture emits gestureNoEffect, and a following command succeeds; add/stage sufficient phase diagnostics if the route is otherwise unobservable.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Thermo-nuclear code quality review

The diagnosis of the defect is right, the writeup is exemplary, and the P1 catch (empty-baseline rebase) is real. But I can't approve this as-is: the corrected corroboration is vacuous after a rebase, and it now produces a confirmed false positive — the no-effect warning fires on a successful scroll whenever the capture backend flips. Runnable counterexample below; it passes on main and fails on this branch.

Blocker: post-rebase, the claim no longer measures the gesture

After the loop rebases, baselineSignature is a post-gesture capture. Corroborating the quiet capture against it compares the settled screen with itself, so haveIdenticalDiscriminatingSurfaces holds for any screen that goes quiet after a flip — inert gesture or successful one. The pre-gesture evidence was (correctly, per #1569) discarded at the rebase, which means "did the gesture move the screen" is undecidable from that point on. The PR's framing conflates two different comparabilities: the rebased pair is comparable for stability, not for gesture effect. #1569's own words — "a cross-backend pair says nothing about the gesture" — apply equally to a post-gesture-only pair.

Falsification test, using the codebase's own successful-scroll fixture (whose comment in post-gesture-stabilization-fixtures.ts says this shape "must never surface as an agent-facing no-effect claim"), plus the backend flip from this PR's headline test:

test('a successful scroll that flips the capture backend must not claim no-effect', async () => {
  vi.useFakeTimers();
  const session = makeSession('ios');
  // Pre-gesture baseline from the TREE backend: rows 1-2.
  session.snapshot = makeSnapshotState(chromeWithListSnapshot(['row-1', 'row-2']).nodes, {
    snapshotQuality: { state: 'healthy', backend: 'tree' },
  });
  markPostGestureStabilization(session, 'scroll', ['down']);

  // Post-gesture captures from PRIVATE-AX: rows 3-4 — the scroll worked and
  // the screen has settled, so every capture is identical to the others.
  const capture = vi.fn(async () =>
    makeSnapshotState(chromeWithListSnapshot(['row-3', 'row-4']).nodes, {
      snapshotQuality: { state: 'recovered', backend: 'private-ax' },
    }),
  );

  const resultPromise = withDiagnosticsScope({}, async () =>
    capturePostGestureStabilizedResult({ session, capture, readSnapshot: (s) => s }),
  );
  await vi.advanceTimersByTimeAsync(10_000);
  const result = await resultPromise;

  assert.equal(result.gestureNoEffect, undefined,
    'the scroll swapped every list cell — a no-effect claim here is a false positive');
});

Measured: fails on this branch (gestureNoEffect: { action: 'scroll', positionals: ['down'] }), passes on main. All 22 tests in the PR's file pass alongside it, so the hole is untested.

Trace on this branch: quiet match at ~800ms → flip detected → rebase to rows-3-4 → next quiet matches classify 'unchanged' against the rebased baseline → distrust until the 3.5s cap → accept-stale → corroboration compares rows-3-4 with rows-3-4 → claim fires. The agent is told "scroll down produced no visible change: the tree still matches its pre-gesture state" — both halves false; nothing was ever compared to the pre-gesture state.

The headline test can't see this because its "the gesture provably moved nothing" premise is fixture decoration: after the flip the code reads the tree baseline's backend only, never its content. Replace the pre-gesture nodes with anything at all and that test still passes — which is exactly the vacuity the PR description warns about in its own earlier test draft, one layer up.

Severity: by the PR's own model, hostile screens flip on every gesture (that's why all five live attempts were vetoed pre-fix). Under that regime the false positive isn't an edge case — every successful scroll on a Bluesky-class screen pays the 3.5s distrust budget and then tells the agent it did nothing, steering it into raw-swipe workarounds against a working gesture. That's arguably worse than #1600's 40 wasted re-scrolls, because it corrupts the agent's model of actions that work.

The code-judo remedy is smaller than the current diff. An honest claim requires an un-rebased baseline — and when no rebase happened, the live baseline is pending's, so the entire threading of baselineSignature into buildAcceptedStabilizedResult disappears:

let baselineRebased = false;            // set true in the rebase branch
...
const corroborated =
  verdict === 'accept-stale' &&
  !baselineRebased &&
  haveIdenticalDiscriminatingSurfaces(pending.baselineSignature ?? [], current.signature);

Yes, this reverts the headline behavior — deliberately: the claim is undecidable post-flip, and no restructuring changes that. If #1620's element-18 warning must fire on flip screens, the sound path is to make the flip rare, not the claim reckless: if the capture-plan penalty were sticky per session/screen, the next gesture's pre-gesture snapshot would already be private-ax, the pair comparable, and the warning honest from gesture 2 onward. Related open question worth answering before any variant ships: under the stated model the flip should only occur on the first gesture after a penalty arms (afterwards session.snapshot is private-ax too) — yet all five live attempts were vetoed. Something makes the tree-baseline/private-ax-capture mismatch recur per command (penalty decay?), and that same something sets the false-positive rate.

P1: the ?.length guard patches, at the consumption site, an ambiguity created at the mark site

The new six-line comment justifying ?.length over truthiness is the tell (house rule: a paragraph justifying a workaround means the fix belongs elsewhere). The ambiguity — "no baseline" having two representations, undefined and [] — is created by markPostGestureStabilization storing an empty signature for a missing pre-gesture snapshot. Its sibling markPendingInteractionOutcome already refuses exactly this at the source (if (preSignature.length === 0) return;). Do the same here (omit baselineSignature/baselineBackend when the signature is empty): the rebase guard reverts to plain truthiness, the comment disappears, and the invented-baseline state becomes unrepresentable instead of guarded.

Validated locally: with that variant, the full stabilization + verdict suites pass 29/29 — the only test needing a change is markPostGestureStabilization tolerates a missing pre-gesture snapshot on iOS, whose assertion flips from deepEqual(..., []) to equal(..., undefined), which is the better contract to pin anyway. (decidePostGestureStabilityVerdict keeps its ?.length — a rebased baseline can still legitimately be empty.)

P2: the trap that caused this bug is still armed

buildAcceptedStabilizedResult now has two baselines in scope — the live parameter and the stale pending.baselineSignature the bug came from reading — with only a doc comment stopping the next editor from reaching for the wrong one again. If the remedy above is taken this resolves itself (the parameter goes away). If not, narrow the parameter to Pick<PostGestureStabilization, 'action' | 'positionals'> so pending.baselineSignature is a type error inside the function; the call site doesn't change. Same instinct for the loop: baselineSignature/baselineBackend are two loose lets that must be updated in tandem — one baseline: { signature, backend } object makes the rebase atomic.

P3: test-file size

post-gesture-stabilization.test.ts goes 559 → 655 against the repo's 500-line tripwire that this file's own header cites (the #1563 split). Pre-existing breach, but +96 deepens it; if claim/corroboration coverage keeps growing, split it out (e.g. post-gesture-no-effect-claim.test.ts) rather than growing this file again.

What's good here

The stale-read diagnosis is correct and well-evidenced; the no-baseline P1 test is a genuine catch worth keeping verbatim; and the writeup's honesty about the vacuous test draft and the broken vitest | grep && git push chain is the standard PR descriptions should be held to. The problem isn't the craftsmanship — it's that the corrected comparison quietly changed what it measures.


Review artifacts: falsification test run against both this branch (fails: claim fires) and main (passes); P1-remedy variant run against the full stabilization + verdict suites (29/29).

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Re-review at exact head 408a020c: confirmed the new P1 independently. A focused counterexample with pre-gesture tree rows 1–2 and stable post-gesture private-AX rows 3–4 fails on this head: 22 tests pass and the new negative case fails because gestureNoEffect is emitted for a successful scroll. After a backend flip, the loop’s rebased signature is post-gesture evidence; comparing the later quiet capture to it proves stability only, never no-effect relative to the discarded pre-gesture state.

Required: continue settling if desired but fail closed on the no-effect claim after any rebase; add the successful-scroll/backend-flip regression and prove it red on this head. Also remove the ambiguous dual-baseline shape: omit baseline fields when no pre-gesture signature exists, and use a tagged/atomic baseline state (or equally unambiguous API) so only genuine pre-gesture evidence can authorize the warning.

All CI being green does not clear this production false claim, and the prior live run never exercised a backend rebase. Not ready; do not apply ready-for-human.

@thymikee

thymikee commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

Closing this — the premise is wrong, and #1633 states why.

Rebasing onto d919876c put me in front of the refactored post-gesture-stability.ts, which carries both behaviours I called defects and documents the first as intentional:

corroboration (surfacesIdentical) against the ORIGINAL pre-gesture baseline — deliberately NOT a mid-loop rebased one, so a backend flip can never launder a cross-backend pair into a no-effect claim.

That is correct and this PR is not. The rebase assigns baselineSignature = current.signature, and current is a post-gesture capture. So after a rebase the local baseline is no longer before-state, and corroborating against it compares two post-gesture captures — which proves the screen went quiet, not that the gesture did nothing. Pointing corroboration at that value, which is exactly what this PR does, manufactures a no-effect claim with no before-state evidence behind it.

The asymmetry I filed as a bug is the design:

  • the verdict reads the local, possibly-rebased baseline — it drives more polling and must not conclude from a cross-backend pair;
  • the claim reads pending.baselineSignature — it needs pre-gesture evidence, and if a flip destroyed comparability the honest output is silence.

@thymikee's P1 here was the same defect in its most visible form: with no pre-gesture snapshot the rebase invents a baseline out of a post-gesture capture. My ?.length patch fixed only that instance. The general case — any rebase, empty baseline or not — has identical shape, which I should have seen when the empty case was pointed out rather than patching the symptom.

Consequence for #1620: the first of the two candidate vetoes is not a veto, it is the feature. A backend flip should suppress the claim. I will correct the issue to reflect that, leaving truncation drift as the only open question, and note that the observed unreachability on Bluesky is partly just correct behaviour — that screen never has a comparable pre-gesture baseline because tree capture fails before the first snapshot.

I will also correct the comment I left on #1615, which repeated this wrong reading.

No code from this branch should land. Closing rather than reworking.

🤖 Addressed by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant