Skip to content

test(cu): measure a rendering change against recorded trajectories, and name what a model got stuck on - #1884

Merged
hqhq1025 merged 3 commits into
apache:mainfrom
hqhq1025:pr/cu-eval-harness
Aug 3, 2026
Merged

test(cu): measure a rendering change against recorded trajectories, and name what a model got stuck on#1884
hqhq1025 merged 3 commits into
apache:mainfrom
hqhq1025:pr/cu-eval-harness

Conversation

@hqhq1025

@hqhq1025 hqhq1025 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Three tools that exist because looking at a Computer Use run and forming an opinion about it does not work.

cu-prune-eval.mjs — try a rendering change offline before a real machine sees it

Replays recorded observations through the real renderer and reports what a pruning rule would have hidden.

It imports packages/runtime/dist/computer-use-observation-text.js rather than carrying its own copy of the rendering. Twice before, an evaluator with its own copy reached the opposite conclusion from the shipped renderer.

Two negative controls ship with it — a rule that hides nothing, a rule that hides everything — so a result in between means the measurement actually ran.

This is how the naive pruning rule was rejected: it read as an obvious win, and it hid 1,023 elements a model could have acted on.

cu-trace-analyse.mjs — count shapes, not success rates

shape what it means
repeated the same call re-sent after reading the reply — the reply gave it nothing new
thrash one action tried in three or more argument shapes — guessing the schema, not reading the screen
dead end the same action re-sent after a refusal — the refusal did not say where to go instead
blind acting with no observation
abandoned the turn ends on a refusal

Each is measurable evidence that the tool surface is hard to use, rather than an opinion about a model.

cu-ax-oracle.swift — an independent witness

A model sees the screen through the tool and reports through the same tool. On that one path, a click that never landed and a report saying it did are indistinguishable. This reads Accessibility directly, in its own process, so there is a second path to check against.

Verification

node scripts/cu-prune-eval.test.mjs — 8/8, run locally against current main. These are plain scripts with no workspace build dependency beyond packages/runtime/dist.

@hqhq1025
hqhq1025 force-pushed the pr/cu-eval-harness branch from 1ee9014 to e4e32c0 Compare August 3, 2026 06:08
@hqhq1025

hqhq1025 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Review findings addressed. Two of the three tools leave this branch, and the reason is the dependency ordering, so I will start there.

On the dependency ordering

cu-prune-eval.mjs and its test import packages/runtime/dist/computer-use-observation-text.js. I checked: that module exists on neither main, nor this branch, nor pr/cu-model-surface, nor any other open pull request. It is only on the unmerged feat/maka-cu-sole-executor. So the evaluator cannot be imported, which means the entire safety net the file's own header says it needs — the byte-for-byte re-render comparison, the round trip, the negative control — cannot execute. Wiring it into CI as the finding asks would only make CI red. Verified locally: node scripts/cu-prune-eval.test.mjs fails with ERR_MODULE_NOT_FOUND before a single test runs.

Rather than ship 1,013 lines that cannot run behind a comment saying so, I have taken both files off this branch. They are preserved in this branch's parent commit e4e32c0 and will come back with the change that lands the renderer. That is the only honest option here: I cannot bring the renderer into a test-harness pull request without duplicating exactly the mistake this file is written around, which is an evaluator carrying its own copy of the policy it evaluates.

cu-ax-oracle.swift moves to pr/cu-desktop-harness. It had no consumer on this branch; both of its callers are over there, and shipping a witness on one branch while the harness that depends on it sits on another is precisely why that harness was skipping every scenario and exiting 0. Stacking the two was the other option and it is worse, because it would block the desktop harness on this branch's unrelated renderer dependency. The two oracle findings — depth truncation and AXIsProcessTrusted — are fixed there.

What is left here is cu-trace-analyse.mjs, which is self-contained, and it now has a test.

Finding 13, confirmed and fixed

blind was documented as a mutating action with no observation in front of it and implemented as a search of every preceding call for any observe. After the first observe in a run nothing could be flagged again. It is now no live observation of this target: a mutation retires the tree it acted on unless its own result carried a replacement, and an observation of one window says nothing about another. On a trajectory of one observe followed by four clicks the count goes from 0 to 3.

abandoned was documented as within one call of a refusal and implemented as the last call was a refusal.

Refusal detection hung on one regex over rendered prose. It now reads CuDebugRecord.error, the executor's own code written beside the result rather than inside it, so a rewording cannot zero it. The regex stays as a second source for older journals. A result that reads like a failure and yields no code from either is counted as unclassified and printed loudly rather than passed over as a success.

Wired into test:scripts, so it runs on every change under scripts/ and in the full test run. 17 tests, each shape asserted twice — once on a trajectory that must trip it, once on a neighbour that must not.

One thing added beyond the findings: a corpus in which no file carried a single call now exits 2. Nothing on main writes MAKA_CU_DEBUG_LOG, so that is the state this reports today, and it must not read as no problems found.

Negative controls

Each fix was reverted in place and the test rerun.

blind reverted to any preceding observe: 2 failures — one observation does not excuse every click that follows it, an observation of one window says nothing about another. Restored: 17/17.

abandoned reverted to the last call was a refusal: 1 failure — a turn that gave up one call after a refusal is abandoned. Restored: 17/17.

Structural refusal field dropped so only the prose regex remains: 1 failure — a refusal is read from the executor field, not from how it was worded. Restored: 17/17.

unclassified forced to false: 1 failure — a failure with no code anywhere is counted, not passed over. Restored: 17/17.

An action's own fresh observation ignored, so the counter over-flags: 1 failure — an action that hands back a tree leaves the next one sighted. This is the control in the other direction, against a metric that only ever goes up. Restored: 17/17.

Empty corpus: node scripts/cu-trace-analyse.mjs on an empty trace prints "no trajectory in any of these files carried a single Computer Use call" and exits 2.

npm run test:scripts green. npx biome check clean on the touched files. node scripts/check-console.mjs passes.

@hqhq1025

hqhq1025 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

All five findings held up under measurement, and all five are fixed. The measurements are below, taken against the code as it stood.

carriesObservation tested for observation_id= — a shell-style form with zero occurrences in this repository. The renderer emits JSON: observationText in packages/runtime/src/computer-use-tools.ts writes {"observation_id":"obs-1",...}, and persistedObservationText writes the same key. So the flag was false for every real observation. Measured: carriesObservation() === false, and a correct run of four clicks that each returned a fresh observation reported BLIND 3 where the test asserted 0. blindCalls reads that flag to decide whether an action handed back a replacement tree, so the counter was flagging the product working correctly. It now matches the JSON key, and the test asserts both branches of the real renderer — the fresh-observation form and the "Observation consumed; call observe before the next action" form.

The MUTATING and OBSERVING regexes were a second copy of the wire enum and had drifted off it entirely, exactly as described. Matching neither list: left_click, right_click, left_mouse_down, left_mouse_up, left_click_drag, mouse_move, cursor_position, type, key, hold_key, wait, zoom. In the regexes and not on the wire: click, type_text, drag, scroll_element, element_sequence, window_action, launch_app, wait_for_text, wait_for_text_gone. Measured: twenty coordinate clicks with no observation at all reported BLIND 0, because left_click was neither observing nor mutating and the walk stepped over every one of them.

Rather than correct the regexes, the vocabulary now comes from the product. @maka/core gains CU_TOOL_ACTION_TYPES — one list of every action the tool accepts — plus the observing/mutating partition and three predicates. The runtime's wire enum and its two lease predicates are built from that list instead of restating it, which is a straight de-duplication: the observing set is exactly the old requiresObservationLease chain and the mutating set is exactly the old requiresActionLease chain. The analyser imports the same list. An action added to the tool is now classified here the day it lands, and an action nobody has heard of is refused rather than counted. Runtime tests: 2703 pass, 0 fail. Core: 0 fail.

classify defaulted a record it could not read to {} and '', and the only "nothing to analyse" guard was calls === 0. Measured: twenty records shaped {kind:'call', arguments:{…}, result:'failed: dispatch_refused'} reported {"calls":20,"refusals":0,"blind":0,"actions":["?"]} and exit 0 — a clean bill of health for a corpus it had not read one field of. Every field is now read strictly: a call line with no arguments object, no action name, an action not on the wire, or no result of any kind is returned with the reason attached, printed, and it makes the run exit 3. An unparseable line is counted rather than dropped, and a file that cannot be opened is distinguished from a file that is empty.

signature() was key-order sensitive. Measured: three byte-identical calls emitted with the keys in three orders reported repeated: 0, thrash: ['click_element×3 shapes'] — verbatim repetition diagnosed as schema-guessing, which is the confusion the file's header claims to have fixed. It now sorts keys at every depth, and the test asserts both directions: three orderings of the same call are one repeat and no thrash, three genuinely different calls are still three shapes.

On the fifth point, you are right and it is now said where it is read rather than only in the tail of main(). MAKA_CU_DEBUG_LOG and the CuDebugRecord shape have no producer anywhere — not on main, not on any open branch except these harness scripts, which only pass the variable through. The header says so, classify says so, and the exit-3 message says so, because the likeliest way this file will next be wrong is that the first writer to land disagrees with the shape read here.

The negative control, since for a harness that is the deliverable. Reverting each fix one at a time in a scratch copy, and running the same test file against it:

carriesObservation back to the observation_id= form
exit 1, 2 failing
a result carrying a fresh tree is recognised by protocol, not by prose
an action that hands back a tree leaves the next one sighted

vocabulary back to the two hand-written regexes
exit 1, 1 failing
twenty coordinate clicks with nothing observed are twenty blind calls

classify back to defaulting a record it cannot read to {} and ""
exit 1, 3 failing
the observing and mutating vocabularies are the product enum, not a copy
a record in a shape this file does not know is refused, not counted as fine
a call with an action but no result at all is refused

signature back to key-order-sensitive JSON.stringify
exit 1, 2 failing
the order the keys were serialised in is not part of it either
nesting does not reintroduce key-order sensitivity

parseJournal back to dropping unreadable lines silently
exit 1, 1 failing
but the unreadable line is counted, not forgotten

Each fix has assertions that go red without it, and no fix is covered only by assertions that another fix would also break. Unmodified: 26 pass, 0 fail.

End to end, on a corpus it can read:

$ node scripts/cu-trace-analyse.mjs good.trace.jsonl
=== good.trace.jsonl
3 calls, 0 refused
actions: observe click_element
REPEATED — the same call sent again after reading the reply:
observe {"action":"observe","app":"com.apple.calculator","window_id":2}
across 1 runs: 3 calls, 0 refused (0%), 1 repeated, 0 dead ends
EXIT=0

and on one it cannot:

$ node scripts/cu-trace-analyse.mjs alien.trace.jsonl
=== alien.trace.jsonl
20 calls, 0 refused
actions: ?
NOT A COMPUTER USE CALL — 40 record(s) this analyser cannot classify.
Every number in this block is computed over the rest:
no rawArgs/modelFacingArgs object
no resultText, resultModelText or error field
REFUSING TO REPORT ON THIS CORPUS: 0 file(s) unreadable, 0 line(s) not JSON,
40 record(s) not a Computer Use call. ...
EXIT=3

$ node scripts/cu-trace-analyse.mjs absent.trace.jsonl
COULD NOT READ — ENOENT: no such file or directory
EXIT=3

Scope change, decided while this was in progress: scripts/cu-ax-oracle.swift moves here from the desktop-harness branch, so the analyser and the independent witness ship as one reviewable change. It walks the accessibility tree in its own process through the system framework, which is the only way to check an observation without asking the thing that produced it. It carries two fixes and one addition.

A depth cut used to prune the subtree and leave "truncated": false, and every consumer of that field makes negative assertions off it. Real output, same app, same role filter, only the depth changed:

$ swift scripts/cu-ax-oracle.swift com.apple.finder --depth 1 --role AXNothing
{"deepest_cut":2,"element_count":0,"elements":[],"max_depth":1,
"truncated":true,"truncated_by_budget":false,"truncated_by_depth":true,...}
EXIT=0

$ swift scripts/cu-ax-oracle.swift com.apple.finder --depth 12 --role AXNothing
{"deepest_cut":0,"element_count":0,"elements":[],"max_depth":12,
"truncated":false,"truncated_by_budget":false,"truncated_by_depth":false,...}
EXIT=0

Two empty element lists; one of them says it is empty because the walk stopped, and one says it is empty because the world is.

Without Accessibility permission every attribute read fails silently, and the oracle would have emitted a well-formed {"window_count":0,"element_count":0,"truncated":false} — indistinguishable from an application with nothing in it. It now checks AXIsProcessTrusted() and says so. Negative control, with the guard forced to fire so that no TCC grant had to be touched:

$ swift /tmp/oracle-untrusted.swift com.apple.finder
{"error":"not_permitted","detail":"the process running this oracle has no
Accessibility permission, so every attribute read would fail and every tree
would read as empty"}
EXIT=1

$ swift scripts/cu-ax-oracle.swift com.apple.finder --role AXNothing
{"active":false,"bundle_id":"com.apple.finder","element_count":0,...}
EXIT=0

The addition: every state the oracle cannot see through — screen_locked, not_permitted, not_running — now exits 1 instead of 0. It printed those as JSON and exited 0, so cu-ax-oracle.swift X > witness.json && assert ... walked on with a file saying the tree is empty because the oracle had been blindfolded. That is the same defect class as everything above.

$ swift scripts/cu-ax-oracle.swift com.example.nope
{"bundle_id":"com.example.nope","error":"not_running"}
EXIT=1

Gates: npx biome check clean on all five touched files, node scripts/check-console.mjs OK, npm run test:scripts 49 pass 0 fail, @maka/core 0 fail, @maka/runtime 2703 pass 0 fail.

…nd name what a model got stuck on

Two tools and a witness, all of which exist because looking at a Computer Use
run and forming an opinion about it does not work.

`cu-prune-eval.mjs` replays recorded observations through the real renderer and
reports what a pruning rule would have hidden. It imports
`computer-use-observation-text.js` rather than carrying its own copy —
twice before, an evaluator with its own copy of the rendering reached the
opposite conclusion from the shipped one. Two negative controls ship with it:
a rule that hides nothing and a rule that hides everything, so a result in
between means the measurement ran.

That is how the naive pruning rule was rejected. It read as an obvious win and
it hid 1,023 elements a model could have acted on.

`cu-trace-analyse.mjs` reads a run's tool calls and counts five shapes rather
than success rates:

| shape | what it means |
|---|---|
| repeated | the same call re-sent after reading the reply — the reply said nothing new |
| thrash | one action tried in three argument shapes — guessing the schema, not reading the screen |
| dead end | the same action re-sent after a refusal — the refusal did not say where to go |
| blind | acting with no observation |
| abandoned | the turn ends on a refusal |

Each is measurable evidence that the tool surface is hard to use, rather than
an opinion about a model.

`cu-ax-oracle.swift` reads Accessibility directly, in its own process. A model
sees the screen through the tool and reports through the same tool, so a click
that did not land and a report saying it did are indistinguishable on that one
path. The witness is the second path.
Review found three of the five shapes this file counts could not report a
non-zero number, and one of them could go to zero without anyone noticing.

`blind` was documented as "a mutating action with no observation in front of
it" and implemented as a search of every preceding call for any observe at
all. After the first observe in a run nothing could be flagged again: a
trajectory that looked once and then fired twenty clicks reported BLIND 0. It
is now "no live observation of this target" — a mutation retires the tree it
acted on unless its own result carried a replacement, and an observation of one
window says nothing about another.

`abandoned` was documented as "the turn ended within one call of a refusal" and
implemented as "the last call was a refusal", which misses the commonest shape
of giving up: a refusal, one more attempt, and then nothing.

Refusal detection hung on `/failed:\s*([a-z_]+)/` over rendered prose, so a
wording change in how the executor renders an error would have zeroed every
refusal count, every dead end and the whole failure-by-action table while the
report still read like a clean run. It now reads `CuDebugRecord.error`, which
is the executor's own code written beside the result rather than inside it. The
regex stays as a second source for older journals, and a result that reads like
a failure but yields no code from either is counted as `unclassified` and
printed loudly rather than passed over as a success.

A corpus in which no file carried a single call now exits 2. Nothing on main
writes `MAKA_CU_DEBUG_LOG` yet, so that is the state this reports today, and it
must not read as "no problems found".

There was no test file at all. There is one now, wired into `test:scripts` so
it runs on every change under `scripts/`. Each shape is asserted twice — once
on a trajectory that must trip it, once on a neighbour that must not — because
a counter that only ever goes up is not a measurement.

Two files leave this branch.

`cu-prune-eval.mjs` and its test import
`packages/runtime/dist/computer-use-observation-text.js`, which does not exist
on main, on this branch, or on any open pull request. The evaluator cannot be
imported, so its safety net — the byte-for-byte re-render comparison, the round
trip, the negative control — cannot run, and wiring it into CI would only make
CI red. It is held for the change that lands the renderer.

`cu-ax-oracle.swift` moves to the desktop-harness branch, where its callers
are. It had no consumer here, and shipping a witness on one branch and the
harness that depends on it on another is how that harness came to skip every
scenario and exit 0.
The analyser was written against a Computer Use surface this repository does
not have, and every disagreement failed in the direction that reports a clean
run.

`carriesObservation` looked for `observation_id=`. The renderer emits JSON:
`observationText` in packages/runtime/src/computer-use-tools.ts writes
`{"observation_id":"obs-1",...}`, and the `=` form has zero occurrences on
main. So the flag was false for every real observation, and `blindCalls` reads
that flag to decide whether an action handed back a replacement tree. Measured
against real renderer output, a correct run of four clicks that each returned
a fresh observation reported BLIND 3. The counter was flagging the product
working.

The MUTATING and OBSERVING regexes were a second copy of the wire enum, and
they had drifted off it entirely. Matching neither: left_click, right_click,
left_mouse_down, left_mouse_up, left_click_drag, mouse_move, cursor_position,
type, key, hold_key, wait, zoom. In the regexes and not on the wire: click,
type_text, drag, scroll_element, element_sequence, window_action, launch_app,
wait_for_text, wait_for_text_gone. A trajectory of twenty coordinate clicks
with no observation at all measured BLIND 0, because `left_click` was neither
observing nor mutating and the walk stepped over every one of them.

So the vocabulary now comes from the product. `@maka/core` gains one list of
the wire actions and the observing/mutating partition, the runtime's wire enum
and its two lease predicates are built from that list instead of restating it,
and the analyser imports it. Adding an action to the tool now classifies it
here the day it lands, and an action nobody has heard of is refused rather
than counted.

`classify` defaulted a record it could not read to `{}` and `''`, and the only
guard was `calls === 0`. Twenty records shaped `{kind:'call', arguments:{…},
result:'…'}` — arguments under the wrong key, result under the wrong key,
action not on the wire — reported `{"calls":20,"refusals":0,"blind":0,
"actions":["?"]}` and exit 0: a clean bill of health for a corpus it had not
read one field of. Every field is now read strictly, an unparseable line is
counted rather than dropped, a file that cannot be opened is distinguished
from a file that is empty, and any of the three makes the run exit 3.

That guard matters more than it looks, because nothing writes this journal.
`MAKA_CU_DEBUG_LOG` and the `CuDebugRecord` shape have no producer anywhere in
this repository — not on main, not on any open branch. The record shape read
here has therefore never been checked against a writer, and the first writer
to land will disagree with it somehow. The header says so, and the disagreement
now exits non-zero instead of printing zeroes.

`signature()` was key-order sensitive, so three byte-identical calls emitted
with the keys in three orders reported `repeated: 0, thrash: click_element×3
shapes` — verbatim repetition diagnosed as schema-guessing, which is the exact
confusion the file's header claims to have fixed.

The test file's fixtures were the same guesswork: `observation_id=obs_1` and
calls with no result at all. They are now the renderer's own output, both
branches of it — the fresh-observation form and the "Observation consumed"
form. Each fix is negative-controlled: reverting it one at a time turns
exactly the assertions that cover it red, and nothing else.

Also brings scripts/cu-ax-oracle.swift over from the desktop-harness branch,
so the analyser and the independent witness ship together. It walks the
accessibility tree in its own process through the system framework, which is
the only way to check an observation without asking the thing that produced it.
Two states it used to report as an ordinary empty world: a depth cut left
`truncated` false, and every consumer of this file makes negative assertions
off that field; and without Accessibility permission every attribute read fails
silently, which produced a well-formed `{"window_count":0,"element_count":0}`.
Both are now distinct, and every state the oracle cannot see through exits 1,
so a caller cannot walk on with a file that says the tree is empty because the
oracle was blindfolded.
@hqhq1025
hqhq1025 force-pushed the pr/cu-eval-harness branch from 1ce25d9 to d2065b8 Compare August 3, 2026 15:35
@hqhq1025
hqhq1025 merged commit 352ed59 into apache:main Aug 3, 2026
11 checks passed
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