Skip to content

Commit 51aed14

Browse files
committed
feat: declare layer-3 divergences and schedule the differential
Layer 3 ran both engines for the first time (29504440599) and immediately found a real engine bug. Blocking the measurement instrument on repairing what it just measured inverts the dependency, so layer 3 now gets the contract layer 1 already had: every divergence is a decision on the record. Adds `knownDivergence: { reason, tracking }` to the scenario type — the layer-3 twin of FLOW_DIVERGENCES. A declared divergence keeps the run green; only UNDECLARED ones fail. Two rules stop that from rotting, both enforced mechanically rather than by prose discipline: - `tracking` is required and must be a real issue URL (run.test.ts), because a declaration with nothing behind it is how "temporarily expected" becomes permanent without anyone deciding to. - a stale declaration FAILS: if a declared-divergent scenario starts passing, the run goes red until the declaration is removed. The fix PR must delete it, and the differential then enforces the gap stays closed — the oracle is the acceptance test for its own findings. Declared: - settle-after-tap -> #1299. Our scrollUntilVisible times out finding home-open-form where Maestro 2.5.1 scrolls to it and passes. Real engine correctness bug in an advertised command, found by this differential. Blocks bug class 4's device detector until fixed. - tap-retry-if-no-change -> #1300. The invariant caught the scenario being vacuous: both engines pass but tapRetries was 0, so retryIfNoChange never ran. Needs an inert fixture control; a scenario defect, not an engine one. Proven green on both engines and enforced now: percent-swipe, optional-warned-not-failed — the latter is real device-verified warned-vs-failed parity. With declarations in place the differential is green, so the schedule goes in (cron 05:00) per #1274. A green run still prints what it is not proving.
1 parent 35842ac commit 51aed14

6 files changed

Lines changed: 157 additions & 25 deletions

File tree

.github/workflows/conformance-differential.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ name: Conformance Differential
66
# replay trace. This is the only layer that can catch settle-loop ordering, which
77
# has no reflectable upstream constant.
88
#
9-
# DISPATCH-ONLY, DELIBERATELY. This path has never executed end-to-end: nothing
10-
# else in this repo builds or installs the Expo fixture app, so the build/install
11-
# steps below are new and unproven. A nightly cron that fails at 05:00 every day
12-
# teaches us nothing and trains people to ignore it. Run it manually, watch it,
13-
# fix what breaks, and only then add:
14-
# schedule:
15-
# - cron: "0 5 * * *"
16-
# Tracked as the follow-up to #1274; layers 1-2 verify per-PR without any device.
9+
# Scheduled: this path is proven end-to-end (run 29504440599 executed both
10+
# engines against the real fixture app). Scenarios that currently diverge are
11+
# DECLARED via `knownDivergence` in differential/scenarios.ts with a tracking
12+
# issue, so the schedule stays green on known gaps and fails only on undeclared
13+
# ones — the same contract layer 1 has. A declaration that stops reproducing also
14+
# fails, so a fix cannot land while leaving the oracle blind.
1715

1816
on:
17+
schedule:
18+
- cron: "0 5 * * *"
1919
workflow_dispatch:
2020
inputs:
2121
only:

docs/adr/0015-direct-maestro-engine.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,17 @@ so a hand edit to a captured command or constant fails the build; the scheduled
137137
job then re-runs the harness against the pinned jars and fails on any byte difference, which is what makes
138138
"generated from upstream" enforced rather than documented. Layer 3 runs a small set of app-observable
139139
differential scenarios (settle ordering, tap retries, optional warned-vs-failed) through both engines
140-
against the real fixture app, pinning the Maestro CLI to the same version as layers 1-2; it is
141-
dispatch-only until its first supervised run, and never per-PR. The four #1217 regressions (percent
140+
against the real fixture app, pinning the Maestro CLI to the same version as layers 1-2; it is scheduled,
141+
never per-PR. Layer 3 carries the same declared-divergence contract as layer 1: a scenario that currently
142+
fails declares a `knownDivergence` with a required tracking issue, the schedule stays green on that known
143+
gap, and only undeclared divergences fail. A declaration that stops reproducing also fails, so the fix PR
144+
must remove it and the differential becomes the acceptance test for its own findings. This exists because
145+
the instrument must not be blocked on repairing what it just measured. The four #1217 regressions (percent
142146
rounding/rejection, target-swipe direction default, retry-cap semantics, settle-loop ordering) each have a
143-
dedicated fixture; bug class 4's detector is an engine-side timing invariant, unit-tested against
144-
synthetic traces, that only executes on the device path.
147+
dedicated fixture. Percent truncation's runtime half is pinned by a pure unit test rather than a device
148+
scenario, because a one-pixel delta is not app-observable. Bug class 4's detector is an engine-side timing
149+
invariant, unit-tested against synthetic traces, that executes on the device path — currently blocked by a
150+
declared `scrollUntilVisible` divergence (#1299) that the differential itself found on its first working run.
145151

146152
Documented intentional deviations are encoded as expected-divergence entries rather than silent
147153
mismatches: the hierarchy-vs-screenshot animation wait (constants match, mechanism differs), the

scripts/maestro-conformance/README.md

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,25 @@ exist on no device — so a device run against them would fail before exercising
5454
any runtime behavior, making the settle detector silently vacuous. A test
5555
enforces the separation, and the workflow hard-fails if the app is not installed.
5656

57-
**Layer 3 is dispatch-only and has never executed end-to-end.** Nothing else in
58-
this repo builds or installs the Expo fixture app, so those steps are new and
59-
unproven. Run it manually, watch it, fix what breaks, and only then add the cron
60-
— a nightly job that fails at 05:00 every day teaches nothing and trains people
61-
to ignore it.
57+
### Declared divergences (`knownDivergence`)
58+
59+
Layer 3 has the same contract as layer 1: **every divergence is a decision on the
60+
record.** When the differential catches a real engine bug, the instrument does not
61+
block on repairing what it just measured — the scenario declares it with a
62+
`knownDivergence: { reason, tracking }`, the scheduled run stays green on that
63+
known gap, and only *undeclared* divergences fail.
64+
65+
Two rules keep that from rotting, both enforced by `run.test.ts` / the runner
66+
rather than by good intentions:
67+
68+
- **`tracking` is required** and must be a real issue URL. A declared divergence
69+
with nothing behind it is how "temporarily expected" silently becomes permanent.
70+
- **A stale declaration fails.** If a declared-divergent scenario starts passing,
71+
the run goes red until the declaration is removed — so the fix PR must delete
72+
it, and the oracle then enforces that the gap stays closed. The differential is
73+
the acceptance test for its own findings.
74+
75+
A green run still prints what it is *not* proving.
6276

6377
This matters most for **bug class 4** (settle ordering), whose 200ms × 10 loop has
6478
no reflectable upstream constant, so layer 3 is its only home. Its detector is the
@@ -121,11 +135,23 @@ This resolves the pinned jars, **verifies their SHA-256 against
121135
refresh the vendored corpus flows and their `manifest.json` `sha256`s, regenerate,
122136
and reconcile any new divergences.
123137

124-
## Layer 3 (scheduled / opt-in)
138+
## Layer 3 (device)
125139

126140
```sh
127141
pnpm maestro:conformance:differential -- --platform ios --out-dir .tmp/diff
128142
```
129143

130-
Runs on the `conformance-differential` workflow (scheduled). `--dry-run`
131-
validates the scenario registry without a device.
144+
Runs scheduled on the `conformance-differential` workflow, and on demand via
145+
`workflow_dispatch`. `--dry-run` validates the scenario registry without a device.
146+
147+
The workflow builds and installs the fixture app, verifies its bundle id, pins
148+
the Maestro CLI to the same version as layers 1-2, and passes `--maestro` so the
149+
flow routes through the compat engine.
150+
151+
**Investigate locally, not through CI.** A device iteration in CI is ~40 minutes;
152+
`--only` plus a local simulator is minutes:
153+
154+
```sh
155+
pnpm test-app:install && pnpm --dir examples/test-app exec expo run:ios --configuration Release
156+
pnpm maestro:conformance:differential -- --platform ios --only settle-after-tap --trace-root .agent-device
157+
```

scripts/maestro-conformance/differential/run.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,26 @@ test('no differential scenario points at the parse-only layer-1 corpus', () => {
4646
}
4747
});
4848

49+
// A declared divergence without an issue behind it is how "temporarily expected"
50+
// becomes permanent without anyone deciding to. Layer 1 requires `unsupported`
51+
// on every we-reject entry for the same reason; enforce the twin here, because
52+
// the lesson of this whole arc is that prose discipline does not survive contact
53+
// with a two-day debugging session.
54+
test('every knownDivergence carries a tracking issue', () => {
55+
const problems: string[] = [];
56+
for (const scenario of DIFFERENTIAL_SCENARIOS) {
57+
const declared = scenario.knownDivergence;
58+
if (!declared) continue;
59+
if (!/^https:\/\/github\.com\/.+\/issues\/\d+$/.test(declared.tracking ?? '')) {
60+
problems.push(`${scenario.id}: knownDivergence.tracking must be a GitHub issue URL`);
61+
}
62+
if (!declared.reason || declared.reason.length < 20) {
63+
problems.push(`${scenario.id}: knownDivergence.reason must explain what it blocks`);
64+
}
65+
}
66+
assert.deepEqual(problems, [], problems.join('\n'));
67+
});
68+
4969
test('every device flow targets the fixture app the workflow installs', () => {
5070
for (const scenario of DIFFERENTIAL_SCENARIOS) {
5171
const body = fs.readFileSync(path.join(CONFORMANCE_DIR, scenario.flow), 'utf8');

scripts/maestro-conformance/differential/run.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ export type ScenarioReport = {
9090
outcomeDiverged: boolean;
9191
/** Engine-side invariants over agent-device's own timing trace. */
9292
invariants: InvariantResult[];
93+
/**
94+
* ok — behaved as expected.
95+
* failed — an UNDECLARED divergence. The signal this exists for.
96+
* known-divergence — failed exactly as declared; tracked, so the run stays green.
97+
* stale-declaration — passed while still declared divergent: remove the
98+
* declaration (the gap is closed). Fails, so a fix cannot
99+
* land while leaving the oracle blind to a regression.
100+
*/
101+
status: 'ok' | 'failed' | 'known-divergence' | 'stale-declaration';
102+
tracking?: string;
93103
failed: boolean;
94104
};
95105

@@ -138,6 +148,20 @@ function runScenario(scenario: DifferentialScenario, options: RunnerOptions): Sc
138148
? evaluateInvariants(trace ? readTrace(trace) : [], scenario.engineInvariants)
139149
: [];
140150
const invariantFailed = invariants.some((result) => result.status !== 'held');
151+
const misbehaved = outcomeDiverged || invariantFailed;
152+
153+
// A declared divergence is an expected, tracked gap: it keeps the run green so
154+
// the oracle is not blocked on the engine bug it just found. But a declaration
155+
// that no longer reproduces FAILS — the fix PR has to delete it, which is what
156+
// turns the differential into the acceptance test for its own findings.
157+
const declared = scenario.knownDivergence;
158+
const status = declared
159+
? misbehaved
160+
? ('known-divergence' as const)
161+
: ('stale-declaration' as const)
162+
: misbehaved
163+
? ('failed' as const)
164+
: ('ok' as const);
141165

142166
return {
143167
id: scenario.id,
@@ -146,7 +170,9 @@ function runScenario(scenario: DifferentialScenario, options: RunnerOptions): Sc
146170
agentDevice,
147171
outcomeDiverged,
148172
invariants,
149-
failed: outcomeDiverged || invariantFailed,
173+
status,
174+
...(declared ? { tracking: declared.tracking } : {}),
175+
failed: status === 'failed' || status === 'stale-declaration',
150176
};
151177
}
152178

@@ -158,11 +184,15 @@ function main(argv: readonly string[]): void {
158184
if (options.dryRun) {
159185
for (const scenario of scenarios) {
160186
const invariants = scenario.engineInvariants?.length ?? 0;
187+
const declared = scenario.knownDivergence
188+
? `\tdeclared-divergence=${scenario.knownDivergence.tracking}`
189+
: '';
161190
console.log(
162-
`${scenario.id}\t${scenario.flow}\texpect=${scenario.expect}\tinvariants=${invariants}`,
191+
`${scenario.id}\t${scenario.flow}\texpect=${scenario.expect}\tinvariants=${invariants}${declared}`,
163192
);
164193
}
165-
console.log(`\n${scenarios.length} scenario(s) validated.`);
194+
const known = scenarios.filter((scenario) => scenario.knownDivergence).length;
195+
console.log(`\n${scenarios.length} scenario(s) validated, ${known} declared divergence(s).`);
166196
return;
167197
}
168198

@@ -176,13 +206,26 @@ function main(argv: readonly string[]): void {
176206
}
177207

178208
for (const report of reports) {
179-
const status = report.failed ? 'FAILED' : 'ok';
180209
console.log(
181-
`${status.padEnd(7)} ${report.id} maestro=${report.maestro.outcome} agent-device=${report.agentDevice.outcome}`,
210+
`${report.status.padEnd(17)} ${report.id} maestro=${report.maestro.outcome} agent-device=${report.agentDevice.outcome}`,
182211
);
183212
for (const result of report.invariants) {
184213
console.log(` invariant ${result.status}: ${result.detail}`);
185214
}
215+
if (report.status === 'known-divergence') {
216+
console.log(` declared divergence, tracked: ${report.tracking}`);
217+
}
218+
if (report.status === 'stale-declaration') {
219+
console.log(
220+
` passed while declared divergent — remove knownDivergence (${report.tracking}) so this stays enforced`,
221+
);
222+
}
223+
}
224+
225+
// Keep declared gaps visible: a green run must still say what it is not proving.
226+
const known = reports.filter((report) => report.status === 'known-divergence');
227+
if (known.length > 0) {
228+
console.log(`\n${known.length} declared divergence(s), not enforced: ${known.map((r) => r.id).join(', ')}`);
186229
}
187230

188231
const failed = reports.filter((report) => report.failed);

scripts/maestro-conformance/differential/scenarios.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,27 @@ export const DIFFERENTIAL_APP_ID = 'com.callstack.agentdevicelab';
2525

2626
export type DifferentialOutcome = 'pass' | 'fail';
2727

28+
/**
29+
* A divergence this scenario is currently EXPECTED to exhibit.
30+
*
31+
* This is the layer-3 twin of layer 1's FLOW_DIVERGENCES, and it exists for the
32+
* same reason: the oracle's contract is that every divergence is a decision on
33+
* the record, never a silent one. When the differential catches a real engine
34+
* bug, the instrument must not be blocked on repairing what it just measured —
35+
* declare it, file it, and let the scheduled run stay green on the known gap
36+
* while still failing on anything undeclared.
37+
*
38+
* `tracking` is REQUIRED and enforced by run.test.ts. A declared divergence with
39+
* no issue behind it is exactly how "temporarily expected" becomes permanent
40+
* without anyone deciding to make it so.
41+
*/
42+
export type KnownDivergence = {
43+
/** Why this scenario currently fails, and what it blocks. */
44+
reason: string;
45+
/** Issue tracking the fix. Required — see above. */
46+
tracking: string;
47+
};
48+
2849
export type DifferentialScenario = {
2950
id: string;
3051
/** The #1217 bug class this scenario guards, when applicable. */
@@ -39,6 +60,12 @@ export type DifferentialScenario = {
3960
engineInvariants?: Invariant[];
4061
/** What a divergence would indicate. */
4162
divergenceMeans: string;
63+
/**
64+
* Declared, tracked failure. The run stays green while this holds — but it
65+
* FAILS if the scenario starts passing, so the fix PR must remove the
66+
* declaration and the oracle enforces that the gap stays closed.
67+
*/
68+
knownDivergence?: KnownDivergence;
4269
};
4370

4471
export const DIFFERENTIAL_SCENARIOS: DifferentialScenario[] = [
@@ -61,6 +88,11 @@ export const DIFFERENTIAL_SCENARIOS: DifferentialScenario[] = [
6188
],
6289
divergenceMeans:
6390
'agent-device settled in a different order than upstream (sleep-before vs sleep-after capture) or never latches within the shared budget.',
91+
knownDivergence: {
92+
reason:
93+
'Caught by this differential on its first working run: our scrollUntilVisible times out finding home-open-form where Maestro 2.5.1 scrolls to it and passes (maestro=pass, agent-device=fail). The flow cannot reach its tapOn step, so bug class 4 has no working device detector until that engine bug is fixed. Declared rather than chased inline — the instrument does not block on repairing what it just measured.',
94+
tracking: 'https://github.com/callstack/agent-device/issues/1299',
95+
},
6496
},
6597
{
6698
id: 'percent-swipe',
@@ -97,6 +129,11 @@ export const DIFFERENTIAL_SCENARIOS: DifferentialScenario[] = [
97129
},
98130
],
99131
divergenceMeans: 'agent-device fails a tap upstream completes (or vice versa) under retry-if-no-change.',
132+
knownDivergence: {
133+
reason:
134+
'The invariant caught this scenario being vacuous: both engines pass, but tapRetries was 0, so retryIfNoChange never ran. Tapping the fixture app title does not hold the hierarchy signature still (its ScreenTitle carries a dynamic cart badge), so the no-change path is never forced. Needs an inert fixture control — a defect in the scenario, not the engine.',
135+
tracking: 'https://github.com/callstack/agent-device/issues/1300',
136+
},
100137
},
101138
{
102139
id: 'optional-warned-not-failed',

0 commit comments

Comments
 (0)