From b82669f8dc046695d2b46c0df85e1460576d1638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 23 Aug 2026 19:19:55 +0200 Subject: [PATCH] perf(android): bound occlusion coverage memory --- .../android/__tests__/rect-coverage.test.ts | 128 ++++++++++++++++++ .../__tests__/snapshot-presentation.test.ts | 68 +++++----- src/platforms/android/rect-coverage.ts | 113 ++++++++++++++++ .../android/ui-hierarchy-visibility.ts | 76 +---------- 4 files changed, 276 insertions(+), 109 deletions(-) create mode 100644 src/platforms/android/__tests__/rect-coverage.test.ts create mode 100644 src/platforms/android/rect-coverage.ts diff --git a/src/platforms/android/__tests__/rect-coverage.test.ts b/src/platforms/android/__tests__/rect-coverage.test.ts new file mode 100644 index 000000000..0088eb7ca --- /dev/null +++ b/src/platforms/android/__tests__/rect-coverage.test.ts @@ -0,0 +1,128 @@ +import type { Rect } from '@agent-device/kernel/snapshot'; +import fc from 'fast-check'; +import { expect, test } from 'vitest'; +import { PROPERTY_RUNS } from '../../../__tests__/test-utils/property-arbitraries.ts'; +import { unionCoverage } from '../rect-coverage.ts'; +import { createAndroidSnapshotPresentationBudget } from '../snapshot-presentation.ts'; + +const smallRect = fc.record({ + x: fc.integer({ min: -4, max: 20 }), + y: fc.integer({ min: -4, max: 20 }), + width: fc.integer({ min: 0, max: 6 }), + height: fc.integer({ min: 0, max: 6 }), +}); + +test('matches an exact unit-cell oracle for integer rectangles', () => { + fc.assert( + fc.property( + fc.array(smallRect, { maxLength: 6 }), + fc.array(smallRect, { maxLength: 6 }), + (covering, covered) => { + const coverage = unionCoverage(covering, covered); + expect(coverage).toBe(bruteForceCoverage(covering, covered)); + expect(coverage).toBeGreaterThanOrEqual(0); + expect(coverage).toBeLessThanOrEqual(1); + expect(unionCoverage([...covering].reverse(), [...covered].reverse())).toBe(coverage); + expect(unionCoverage([...covering, ...covering], [...covered, ...covered])).toBe(coverage); + }, + ), + { numRuns: PROPERTY_RUNS }, + ); +}); + +test('uses edges from both sets when measuring partial coverage', () => { + expect( + unionCoverage([{ x: 0, y: 0, width: 5, height: 10 }], [{ x: 0, y: 0, width: 10, height: 10 }]), + ).toBe(0.5); +}); + +test('handles numeric edge ordering, adjacency, duplicates, and negative coordinates', () => { + expect( + unionCoverage( + [{ x: 9, y: -10, width: 91, height: 10 }], + [{ x: 9, y: -10, width: 91, height: 10 }], + ), + ).toBe(1); + expect( + unionCoverage( + [ + { x: -10, y: 0, width: 10, height: 10 }, + { x: 10, y: 0, width: 10, height: 10 }, + ], + [{ x: 0, y: 0, width: 10, height: 10 }], + ), + ).toBe(0); + expect( + unionCoverage( + [{ x: -10, y: -10, width: 10, height: 10 }], + [ + { x: -10, y: -10, width: 10, height: 10 }, + { x: -10, y: -10, width: 10, height: 10 }, + ], + ), + ).toBe(1); +}); + +test('ignores degenerate rectangles and keeps the ratio within its exact threshold', () => { + expect( + unionCoverage( + [ + { x: 0, y: 0, width: 90, height: 1 }, + { x: 0, y: 0, width: 0, height: 100 }, + ], + [{ x: 0, y: 0, width: 100, height: 1 }], + ), + ).toBe(0.9); + expect( + unionCoverage([{ x: 0, y: 0, width: 89, height: 1 }], [{ x: 0, y: 0, width: 100, height: 1 }]), + ).toBe(0.89); +}); + +test('charges deterministic work and rejects overlapping hostile inputs', () => { + const covering = Array.from({ length: 100 }, () => ({ x: 0, y: 0, width: 100, height: 100 })); + const covered = Array.from({ length: 100 }, () => ({ x: 0, y: 0, width: 100, height: 100 })); + const first = createAndroidSnapshotPresentationBudget( + { deadlineAtMs: Number.POSITIVE_INFINITY }, + 10_000, + ); + const second = createAndroidSnapshotPresentationBudget( + { deadlineAtMs: Number.POSITIVE_INFINITY }, + 10_000, + ); + + expect(unionCoverage(covering, covered, first)).toBe(1); + expect(unionCoverage(covering, covered, second)).toBe(1); + expect(first.workUnitCount).toBe(1001); + expect(first.workUnitCount).toBe(second.workUnitCount); + + const constrained = createAndroidSnapshotPresentationBudget( + { deadlineAtMs: Number.POSITIVE_INFINITY, maxWorkUnits: 900 }, + 10_000, + ); + expect(() => unionCoverage(covering, covered, constrained)).toThrow( + 'Android snapshot presentation exceeded its linear work budget', + ); +}); + +function bruteForceCoverage(covering: Rect[], covered: Rect[]): number { + const coveringCells = cellsOf(covering); + const coveredCells = cellsOf(covered); + if (coveredCells.size === 0) return 0; + let overlap = 0; + for (const cell of coveredCells) { + if (coveringCells.has(cell)) overlap += 1; + } + return overlap / coveredCells.size; +} + +function cellsOf(rects: Rect[]): Set { + const cells = new Set(); + for (const rect of rects) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + cells.add(`${x},${y}`); + } + } + } + return cells; +} diff --git a/src/platforms/android/__tests__/snapshot-presentation.test.ts b/src/platforms/android/__tests__/snapshot-presentation.test.ts index 01b78e36b..a0998169b 100644 --- a/src/platforms/android/__tests__/snapshot-presentation.test.ts +++ b/src/platforms/android/__tests__/snapshot-presentation.test.ts @@ -209,36 +209,35 @@ test('hostile nested Android presentation stays under a deterministic linear wor ); }); -test('broad Android presentation rejects an oversized descendant footprint budget', () => { +test('broad sibling footprints resolve coverage inside the deterministic budget', () => { const siblingCount = 24; - const buttons = (offset: number) => + const buttons = (labelPrefix: string) => Array.from({ length: siblingCount }, (_, index) => { - const x = offset + index * 16; + const x = index * 16; const y = 16 + index * 16; - return ``; + return ``; }).join(''); const tree = parseUiHierarchyTree( ` - ${buttons(0)} - ${buttons(0)} + ${buttons('covered')} + ${buttons('covering')} `, ); - assert.throws( - () => - buildUiHierarchySnapshot(tree, undefined, { - androidPresentation: { - deadlineAtMs: Number.POSITIVE_INFINITY, - maxWorkUnits: 1024, - }, - }), - (error: unknown) => { - assert.equal(isAndroidSnapshotPresentationFailure(error), true); - assert(error instanceof AndroidSnapshotPresentationFailure); - assert.equal(error.details.phase, 'complexity'); - assert(error.details.workUnits > 1024); - return true; + const built = buildUiHierarchySnapshot(tree, undefined, { + androidPresentation: { + deadlineAtMs: Number.POSITIVE_INFINITY, + maxWorkUnits: 1024, }, + }); + + assert.equal( + built.nodes.some((node) => node.label?.startsWith('covered-')), + false, + ); + assert.equal( + built.nodes.filter((node) => node.label?.startsWith('covering-')).length, + siblingCount, ); }); @@ -253,6 +252,7 @@ test('hostile equal-order same-rect siblings charge the broad candidate scan', ( `${siblings}`, ); + // Equal drawing orders skip coverage calculation, so this isolates the sibling candidate scan. assert.throws( () => buildUiHierarchySnapshot(tree, undefined, { @@ -287,7 +287,7 @@ test('default budget admits a bounded flat hierarchy', () => { assert.equal(built.nodes.length, 101); }); -test('one-axis footprint indexing remains inside the deterministic work budget', () => { +test('dense one-axis footprints resolve coverage inside the deterministic budget', () => { const childCount = 80; const labels = Array.from( { length: childCount }, @@ -301,19 +301,19 @@ test('one-axis footprint indexing remains inside the deterministic work budget', `, ); - assert.throws( - () => - buildUiHierarchySnapshot(tree, undefined, { - androidPresentation: { - deadlineAtMs: Number.POSITIVE_INFINITY, - maxWorkUnits: 1650, - }, - }), - (error: unknown) => { - assert.equal(isAndroidSnapshotPresentationFailure(error), true); - assert(error instanceof AndroidSnapshotPresentationFailure); - assert.equal(error.details.phase, 'complexity'); - return true; + const built = buildUiHierarchySnapshot(tree, undefined, { + androidPresentation: { + deadlineAtMs: Number.POSITIVE_INFINITY, + maxWorkUnits: 1650, }, + }); + + assert.equal( + built.nodes.some((node) => node.label?.startsWith('Label ')), + false, + ); + assert.equal( + built.nodes.some((node) => node.label === 'Cover'), + true, ); }); diff --git a/src/platforms/android/rect-coverage.ts b/src/platforms/android/rect-coverage.ts new file mode 100644 index 000000000..dc73cbd59 --- /dev/null +++ b/src/platforms/android/rect-coverage.ts @@ -0,0 +1,113 @@ +import { isPositiveFiniteRect } from '@agent-device/kernel/rect'; +import type { Rect } from '@agent-device/kernel/snapshot'; +import type { AndroidSnapshotPresentationBudget } from './snapshot-presentation.ts'; + +type Interval = { start: number; end: number }; +type SweepRect = Rect & { endX: number }; + +/** Fraction of the covered rects' union that lies under the covering rects' union. */ +export function unionCoverage( + coveringRects: readonly Rect[], + coveredRects: readonly Rect[], + presentationBudget?: AndroidSnapshotPresentationBudget, +): number { + presentationBudget?.consume(coveringRects.length + coveredRects.length); + const covered = sweepRects(coveredRects); + if (covered.length === 0) return 0; + + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + for (const rect of covered) { + minX = Math.min(minX, rect.x); + maxX = Math.max(maxX, rect.endX); + } + const covering = sweepRects(coveringRects).filter((rect) => rect.x < maxX && rect.endX > minX); + if (covering.length === 0) return 0; + + const xEdges = [ + ...covered.flatMap((rect) => [rect.x, rect.endX]), + ...covering.flatMap((rect) => [Math.max(rect.x, minX), Math.min(rect.endX, maxX)]), + ]; + presentationBudget?.consume(covered.length + covering.length + xEdges.length); + covered.sort(compareRectsByX); + covering.sort(compareRectsByX); + const xs = [...new Set(xEdges)].sort((left, right) => left - right); + + let activeCovered: SweepRect[] = []; + let activeCovering: SweepRect[] = []; + let nextCovered = 0; + let nextCovering = 0; + let coveredArea = 0; + let overlapArea = 0; + + for (let index = 0; index < xs.length - 1; index += 1) { + const x = xs[index]!; + const nextX = xs[index + 1]!; + + activeCovered = activeCovered.filter((rect) => rect.endX > x); + activeCovering = activeCovering.filter((rect) => rect.endX > x); + while (nextCovered < covered.length && covered[nextCovered]!.x <= x) { + activeCovered.push(covered[nextCovered]!); + nextCovered += 1; + } + while (nextCovering < covering.length && covering[nextCovering]!.x <= x) { + activeCovering.push(covering[nextCovering]!); + nextCovering += 1; + } + + presentationBudget?.consume(1 + activeCovered.length + activeCovering.length); + const coveredY = mergeYIntervals(activeCovered); + const coveringY = mergeYIntervals(activeCovering); + const width = nextX - x; + coveredArea += width * totalLength(coveredY); + overlapArea += width * intersectionLength(coveredY, coveringY); + } + + return coveredArea <= 0 ? 0 : overlapArea / coveredArea; +} + +function sweepRects(rects: readonly Rect[]): SweepRect[] { + return rects.filter(isPositiveFiniteRect).map((rect) => ({ ...rect, endX: rect.x + rect.width })); +} + +function compareRectsByX(left: SweepRect, right: SweepRect): number { + return left.x - right.x || left.endX - right.endX; +} + +function mergeYIntervals(rects: readonly Rect[]): Interval[] { + const intervals = rects + .map((rect) => ({ start: rect.y, end: rect.y + rect.height })) + .sort((left, right) => left.start - right.start || left.end - right.end); + const merged: Interval[] = []; + for (const interval of intervals) { + const previous = merged.at(-1); + if (!previous || interval.start > previous.end) { + merged.push({ ...interval }); + } else { + previous.end = Math.max(previous.end, interval.end); + } + } + return merged; +} + +function totalLength(intervals: readonly Interval[]): number { + return intervals.reduce((total, interval) => total + interval.end - interval.start, 0); +} + +function intersectionLength(left: readonly Interval[], right: readonly Interval[]): number { + let total = 0; + let leftIndex = 0; + let rightIndex = 0; + while (leftIndex < left.length && rightIndex < right.length) { + const leftInterval = left[leftIndex]!; + const rightInterval = right[rightIndex]!; + total += Math.max( + 0, + Math.min(leftInterval.end, rightInterval.end) - + Math.max(leftInterval.start, rightInterval.start), + ); + if (leftInterval.end <= rightInterval.end) leftIndex += 1; + else rightIndex += 1; + } + return total; +} diff --git a/src/platforms/android/ui-hierarchy-visibility.ts b/src/platforms/android/ui-hierarchy-visibility.ts index 6136b07ac..cb3a4b4f2 100644 --- a/src/platforms/android/ui-hierarchy-visibility.ts +++ b/src/platforms/android/ui-hierarchy-visibility.ts @@ -1,5 +1,6 @@ import type { Rect } from '@agent-device/kernel/snapshot'; import type { AndroidSnapshotPresentationBudget } from './snapshot-presentation.ts'; +import { unionCoverage } from './rect-coverage.ts'; import { hasMeaningfulLabel, hasPositiveRect, @@ -157,81 +158,6 @@ function paintsOwnBox(node: AndroidNode, hidden: ReadonlySet): bool ); } -/** Fraction of the covered rects' union that lies under the covering rects' union. */ -function unionCoverage( - coveringRects: Rect[], - coveredRects: Rect[], - presentationBudget?: AndroidSnapshotPresentationBudget, -): number { - const xs = compressedEdges([...coveringRects, ...coveredRects], (rect) => [ - rect.x, - rect.x + rect.width, - ]); - const ys = compressedEdges([...coveringRects, ...coveredRects], (rect) => [ - rect.y, - rect.y + rect.height, - ]); - const xIndex = createEdgeIndex(xs, presentationBudget); - const yIndex = createEdgeIndex(ys, presentationBudget); - const covering = markCells(coveringRects, xIndex, yIndex, presentationBudget); - const covered = markCells(coveredRects, xIndex, yIndex, presentationBudget); - const rows = ys.length - 1; - const columns = xs.length - 1; - presentationBudget?.consume(columns * rows); - let coveredArea = 0; - let overlapArea = 0; - for (let column = 0; column < columns; column += 1) { - presentationBudget?.check('work'); - const width = xs[column + 1]! - xs[column]!; - for (let row = 0; row < rows; row += 1) { - const cell = column * rows + row; - if (!covered[cell]) continue; - const area = width * (ys[row + 1]! - ys[row]!); - coveredArea += area; - if (covering[cell]) overlapArea += area; - } - } - return coveredArea <= 0 ? 0 : overlapArea / coveredArea; -} - -function compressedEdges(rects: Rect[], edgesOf: (rect: Rect) => [number, number]): number[] { - return [...new Set(rects.flatMap(edgesOf))].sort((left, right) => left - right); -} - -function createEdgeIndex( - edges: number[], - presentationBudget?: AndroidSnapshotPresentationBudget, -): ReadonlyMap { - presentationBudget?.consume(edges.length); - return new Map(edges.map((edge, index) => [edge, index])); -} - -function markCells( - rects: Rect[], - xIndex: ReadonlyMap, - yIndex: ReadonlyMap, - presentationBudget?: AndroidSnapshotPresentationBudget, -): Uint8Array { - const rows = yIndex.size - 1; - const columns = xIndex.size - 1; - const cellCount = columns * rows; - presentationBudget?.consume(cellCount); - const cells = new Uint8Array(cellCount); - for (const rect of rects) { - presentationBudget?.check('work'); - const firstColumn = xIndex.get(rect.x)!; - const lastColumn = xIndex.get(rect.x + rect.width)!; - const firstRow = yIndex.get(rect.y)!; - const lastRow = yIndex.get(rect.y + rect.height)!; - for (let column = firstColumn; column < lastColumn; column += 1) { - presentationBudget?.check('work'); - presentationBudget?.consume(lastRow - firstRow); - cells.fill(1, column * rows + firstRow, column * rows + lastRow); - } - } - return cells; -} - /** * A childless sibling that only presents: an RN screen-level testID, or a label drawn inside a * higher sibling's box (Telegram's `+` over the country-code EditText). Geometry cannot tell a