agent-desktop/crates/core/examples/locator_benchmark.rs
Lahfir 3f322728b4
feat!: implement Playwright-grade foundation contract
Settle the Playwright-grade reliability contract in agent-desktop-core
before the Windows/Linux adapters are built, so they inherit it instead
of redesigning it. Every command now observes, waits, verifies, and
reports honestly instead of firing blindly.

Highlights: capability-supertrait split of PlatformAdapter with
not_supported() defaults; canonical role/state vocabulary with live
`is --property visible`; display enumeration (`list-displays`) and honest
`--screen` with scale factor; truthful Automation permission; `native_id`
identity spine; window-id-first resolution; serializable `LocatorQuery`
with live `find`; default-on auto-wait before every ref action; three-way
`hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name
precedence; typed `ActionStep` delivery tier; `ProcessState` and
`APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals
(`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse
modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with
independent validation and a green live e2e gate (109/0), plus a
head-vs-main performance comparison harness.

BREAKING CHANGE: default-on auto-wait changes the timing of every
previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0`
restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the
`APP_UNRESPONSIVE` code and process state in error details). FFI ABI major
is `3` (append-only struct evolution; `wait --event` is intentionally not
exposed over FFI). The legacy string clipboard API is removed in favor of
typed content. `key-down`/`key-up` fail closed until daemon-owned held input
exists. `close-app` verifies termination and the osascript fallback path is
removed. `--text` matching is subtree containment: `find --text X --first`
returns the outermost matching container.
2026-07-20 00:21:38 -07:00

283 lines
12 KiB
Rust

#[path = "locator_benchmark/adapter.rs"]
mod adapter;
#[path = "locator_benchmark/fixture.rs"]
mod fixture;
#[path = "locator_benchmark/fixture_builder.rs"]
mod fixture_builder;
#[path = "locator_benchmark/fixture_node.rs"]
mod fixture_node;
#[path = "locator_benchmark/legacy.rs"]
mod legacy;
#[path = "locator_benchmark/live.rs"]
mod live;
#[path = "locator_benchmark/scenario.rs"]
mod scenario;
#[path = "locator_benchmark/scenarios.rs"]
mod scenarios;
use crate::{
legacy::run_legacy,
live::{run_live_count, run_live_direct, run_live_find},
scenario::Scenario,
};
use serde_json::{Value, json};
use std::{error::Error, hint::black_box};
const WARMUP_RUNS: usize = 5;
const MEASURED_RUNS: usize = 31;
fn main() -> Result<(), Box<dyn Error>> {
let reports = scenarios::all()
.iter()
.map(benchmark_scenario)
.collect::<Result<Vec<_>, _>>()?;
let report = json!({
"schema_version": "1.3",
"benchmark": "locator-resolution-electron-synthetic",
"methodology": {
"warmup_runs": WARMUP_RUNS,
"measured_runs": MEASURED_RUNS,
"timing": "wall clock around fixture-to-result resolution; fixture generation excluded",
"legacy_path": "full AccessibilityNode snapshot, ref allocation, recursive matcher",
"live_direct_path": "handle-free observed tree, strict direct target selection without ref-map materialization, memoized evaluator",
"live_find_path": "CLI-compatible default find selection with selected-match-only ref materialization from the same observed tree",
"live_count_path": "CLI-compatible count selection without ref evidence or ref-map materialization",
"read_accounting": "per-path requested AX attribute slots plus action, child-label, promotion, and settable probe classes from locator stats",
"native_ipc": "not measured; native action and settable probe counts vary by role",
"scope": "deterministic synthetic Chromium/Electron accessibility fixtures; CPU and requested synthetic attributes only",
},
"scenarios": reports,
});
println!("{}", serde_json::to_string_pretty(&report)?);
Ok(())
}
fn benchmark_scenario(scenario: &Scenario) -> Result<Value, Box<dyn Error>> {
for run in 0..WARMUP_RUNS {
let fixture = scenario.frame(run);
black_box(run_legacy(fixture, &scenario.query)?);
black_box(run_live_direct(fixture, &scenario.query)?);
black_box(run_live_find(fixture, &scenario.query)?);
black_box(run_live_count(fixture, &scenario.query)?);
}
let mut legacy_ns = Vec::with_capacity(MEASURED_RUNS);
let mut direct_ns = Vec::with_capacity(MEASURED_RUNS);
let mut find_ns = Vec::with_capacity(MEASURED_RUNS);
let mut count_ns = Vec::with_capacity(MEASURED_RUNS);
let mut legacy_counts = Vec::with_capacity(MEASURED_RUNS);
let mut direct_counts = Vec::with_capacity(MEASURED_RUNS);
let mut find_counts = Vec::with_capacity(MEASURED_RUNS);
let mut count_counts = Vec::with_capacity(MEASURED_RUNS);
let mut legacy_scanned = 0_u64;
let mut direct_scanned = 0_u64;
let mut find_scanned = 0_u64;
let mut legacy_predicate_visits = 0_u64;
let mut direct_predicate_cells = 0_u64;
let mut find_predicate_cells = 0_u64;
let mut direct_dom_matches = 0_u64;
let mut find_dom_matches = 0_u64;
let mut find_ref_count = 0_usize;
let mut count_action_reads = 0_u64;
let mut direct_action_reads = 0_u64;
let mut find_action_reads = 0_u64;
let mut direct_reads = (0_u64, 0_u64, 0_u64, 0_u64, 0_u64, 0_u64);
let mut find_reads = (0_u64, 0_u64, 0_u64, 0_u64, 0_u64, 0_u64);
let mut count_reads = (0_u64, 0_u64, 0_u64, 0_u64, 0_u64, 0_u64);
let mut find_refs_reresolvable = true;
for run in 0..MEASURED_RUNS {
let fixture = scenario.frame(run);
let (legacy, direct, find, count) = match run % 4 {
0 => (
run_legacy(fixture, &scenario.query)?,
run_live_direct(fixture, &scenario.query)?,
run_live_find(fixture, &scenario.query)?,
run_live_count(fixture, &scenario.query)?,
),
1 => {
let direct = run_live_direct(fixture, &scenario.query)?;
let find = run_live_find(fixture, &scenario.query)?;
let count = run_live_count(fixture, &scenario.query)?;
let legacy = run_legacy(fixture, &scenario.query)?;
(legacy, direct, find, count)
}
2 => {
let find = run_live_find(fixture, &scenario.query)?;
let count = run_live_count(fixture, &scenario.query)?;
let legacy = run_legacy(fixture, &scenario.query)?;
let direct = run_live_direct(fixture, &scenario.query)?;
(legacy, direct, find, count)
}
_ => {
let count = run_live_count(fixture, &scenario.query)?;
let legacy = run_legacy(fixture, &scenario.query)?;
let direct = run_live_direct(fixture, &scenario.query)?;
let find = run_live_find(fixture, &scenario.query)?;
(legacy, direct, find, count)
}
};
legacy_ns.push(legacy.0);
direct_ns.push(direct.elapsed_ns);
find_ns.push(find.elapsed_ns);
count_ns.push(count.elapsed_ns);
legacy_counts.push(legacy.1);
direct_counts.push(direct.correctness.matches);
find_counts.push(find.correctness.matches);
count_counts.push(count.correctness.matches);
legacy_scanned = legacy.2;
legacy_predicate_visits = legacy.3;
direct_scanned = direct.visited;
direct_predicate_cells = direct.memo_cells;
direct_dom_matches = direct.dom_matches;
find_scanned = find.visited;
find_predicate_cells = find.memo_cells;
find_dom_matches = find.dom_matches;
find_ref_count = find.ref_count;
count_action_reads = count.action_reads;
direct_action_reads = direct.action_reads;
find_action_reads = find.action_reads;
direct_reads = read_counts(&direct);
find_reads = read_counts(&find);
count_reads = read_counts(&count);
find_refs_reresolvable &= find.correctness.selected_refs_reresolvable;
}
let expected = scenario.expected_matches;
let legacy_correct = legacy_counts.iter().all(|count| *count == expected);
let direct_correct = direct_counts.iter().all(|count| *count == expected);
let find_correct = find_counts.iter().all(|count| *count == expected);
let count_correct = count_counts.iter().all(|count| *count == expected);
let legacy_p50 = percentile(&mut legacy_ns, 50);
let legacy_p95 = percentile(&mut legacy_ns, 95);
let direct_p50 = percentile(&mut direct_ns, 50);
let direct_p95 = percentile(&mut direct_ns, 95);
let find_p50 = percentile(&mut find_ns, 50);
let find_p95 = percentile(&mut find_ns, 95);
let count_p50 = percentile(&mut count_ns, 50);
let count_p95 = percentile(&mut count_ns, 95);
let fixture = scenario.frame(0);
let legacy_attributes_requested = fixture.nodes.len() as u64 * 22;
Ok(json!({
"name": scenario.name,
"fixture": {
"frames": scenario.frames.len(),
"nodes": fixture.nodes.len(),
"roots": fixture.roots.len(),
"moving_bounds": scenario.moving_bounds_verified(),
"legacy_locator_result_retained_handles": fixture.nodes.len(),
"observed_tree_retained_handles": 0,
},
"expectation": {
"matches": expected,
"cardinality": cardinality(expected),
},
"legacy_snapshot": {
"p50_us": ns_to_us(legacy_p50),
"p95_us": ns_to_us(legacy_p95),
"observed_matches": legacy_counts[0],
"correct_all_runs": legacy_correct,
"candidate_nodes_scanned": legacy_scanned,
"predicate_node_visits": legacy_predicate_visits,
"attributes_requested": legacy_attributes_requested,
"attribute_batches": fixture.nodes.len(),
"action_reads": fixture.nodes.len(),
},
"live_arena_direct": {
"p50_us": ns_to_us(direct_p50),
"p95_us": ns_to_us(direct_p95),
"observed_matches": direct_counts[0],
"correct_all_runs": direct_correct,
"candidate_nodes_scanned": direct_scanned,
"memo_cells_evaluated": direct_predicate_cells,
"native_id_dom_matches": direct_dom_matches,
"attributes_requested": direct_reads.0,
"attribute_batches": direct_reads.1,
"child_label_reads": direct_reads.2,
"promotion_reads": direct_reads.3,
"settable_reads": direct_reads.4,
"action_reads": direct_action_reads,
"peak_handles_owned": direct_reads.5,
},
"live_find_selected_refs": {
"p50_us": ns_to_us(find_p50),
"p95_us": ns_to_us(find_p95),
"observed_matches": find_counts[0],
"correct_all_runs": find_correct,
"candidate_nodes_scanned": find_scanned,
"memo_cells_evaluated": find_predicate_cells,
"native_id_dom_matches": find_dom_matches,
"ref_count": find_ref_count,
"selected_refs_reresolvable": find_refs_reresolvable,
"attributes_requested": find_reads.0,
"attribute_batches": find_reads.1,
"child_label_reads": find_reads.2,
"promotion_reads": find_reads.3,
"settable_reads": find_reads.4,
"action_reads": find_action_reads,
"peak_handles_owned": find_reads.5,
},
"live_count_no_refmap": {
"p50_us": ns_to_us(count_p50),
"p95_us": ns_to_us(count_p95),
"observed_matches": count_counts[0],
"correct_all_runs": count_correct,
"action_reads": count_action_reads,
"ref_count": 0,
"attributes_requested": count_reads.0,
"attribute_batches": count_reads.1,
"child_label_reads": count_reads.2,
"promotion_reads": count_reads.3,
"settable_reads": count_reads.4,
"peak_handles_owned": count_reads.5,
},
"comparison": {
"p50_find_speedup": ratio(legacy_p50, find_p50),
"p95_find_speedup": ratio(legacy_p95, find_p95),
"p50_direct_target_speedup": ratio(legacy_p50, direct_p50),
"p95_direct_target_speedup": ratio(legacy_p95, direct_p95),
"p50_count_speedup": ratio(legacy_p50, count_p50),
"p95_count_speedup": ratio(legacy_p95, count_p95),
"find_correctness_delta": i8::from(find_correct) - i8::from(legacy_correct),
"direct_target_correctness_delta": i8::from(direct_correct) - i8::from(legacy_correct),
"count_correctness_delta": i8::from(count_correct) - i8::from(legacy_correct),
},
}))
}
fn read_counts(run: &live::LiveRun) -> (u64, u64, u64, u64, u64, u64) {
(
run.attributes_requested,
run.attribute_batches,
run.child_label_reads,
run.promotion_reads,
run.settable_reads,
run.peak_handles_owned,
)
}
fn percentile(values: &mut [u128], percentile: usize) -> u128 {
values.sort_unstable();
let index = (values.len() * percentile).div_ceil(100).saturating_sub(1);
values[index]
}
fn ns_to_us(nanoseconds: u128) -> f64 {
nanoseconds as f64 / 1_000.0
}
fn ratio(before: u128, after: u128) -> f64 {
if after == 0 {
return 0.0;
}
before as f64 / after as f64
}
fn cardinality(matches: usize) -> &'static str {
match matches {
0 => "zero",
1 => "one",
_ => "many",
}
}