Repository navigation
feat(flow): count look-alike rows on native and remote surfaces - #1518
Conversation
PR #1504 halts a row pick before a write when the surface cannot count the rows that read the same (identity_uncounted). This gives the native and remote-display backends a census, so those workflows run again without failing open. - identity_census.tree_row_census: one rule for accessibility trees. Find the target's record row, read every sibling row at the same child path the way structured_text_at reads it, and report the matches with their record ids. A container that reports more rows than it exposes, or a row that isn't built yet, is incomplete. Position-only ids are dropped. - macOS: QuartzMacAXClient.row_census over AXTable/AXOutline rows and AXList items; record ids are AXIdentifier and AXURL. The backend converts row offsets to captured pixels. - Linux: AtspiLinuxClient.row_census over list items, table rows and tree items, reading rows exactly as structured_text does; record ids are AT-SPI accessible ids. - Windows: the guest agent serves /uia/identity-candidates-at (a stdlib mirror of the rule) over DataItem/ListItem/TreeItem/TableRow siblings; record ids are non-positional AutomationIds. A Grid pattern RowCount above the exposed rows, or a scrollable ItemContainer list, is incomplete. - Remote display: a pixel identity with no OCR band now counts the places the identifier crop verifies (vision.match.template_peaks plus the same pixel verifier), instead of halting as uncounted. Tests: unit tests over fake trees for each backend (identical rows halt, distinct ids pick the row, a unique row runs, positional ids, incomplete containers) and replayer-level native cases. New opt-in real-app tests: a WPF ListBox over UI Automation (must run on the new Windows CI leg) and a Swift NSTableView over AX (skips when the runner has no Accessibility access). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the macOS runner, the real-app census returned None. AX hands back a new wrapper for the same element on every read, so == never matched while the census walked from the hit element up to its row. Tree views can now define same(); the AX view uses CFEqual. A failing real-app test now prints the AX roles from the hit element up. The LIMITS.md path is now package-relative, which the docs consistency check requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Pixel census: only a clear pixel mismatch rules a place out. A verify, an abstain, no verdict, or verify turned off counts the place as a look-alike. Before this, a VLM-verified remote step read as unique. A place that cannot be cut, or a target place missing from the peaks (another render scale), is uncounted. Peaks are found at the matcher's look-alike score. - Tree census: a container with no row count is incomplete unless it is known not to scroll (macOS AXScrollArea with a vertical scroll bar, Linux scroll pane with a scroll bar, or unknown). - Windows agent: a pattern provider that raises makes the census incomplete, and so does a sibling row with no cells. - Record ids: positional_id drops ids that name a position (row-3, r3c2, row-3-col-1, item_12, absolute row numbers, or several numbers) in the tree census, the Windows agent and the browser. - CI: both real-app legs must run every test, and each outcome goes to the job summary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
positional_id treated any id with more than one number as a position, and
a bare "c<digits>" as a grid column. That dropped 100% of UUIDs,
ObjectIds, ULIDs, encounter UUIDs and composite ids like ENC2024-000123,
so rows that carried them could not be told apart.
It now splits the id into alphanumeric tokens. An id is positional when
a token is a position word with its number (row3, r3c2), when a position
word is followed by a number token (row-3, item_12, row-3-col-1), when
every token is a short number (3-1), or when its only number is the
row's index. Measured over 12 families of 1000 realistic ids each, none
is dropped; every positional example still is. The browser and the
Windows agent use the same rule, with parity tests for both.
Risk: "Sign out patient", "Log out <patient>" and "Discharge" are
consequential writes. A bare "Sign out" / "Log out", or one that names
the session ("of OpenAdapt", "now", "all devices"), stays session
chrome. step_text joins its labels with " | ", so the intent and label
of a session "Sign out" button no longer run together into
"sign out Sign".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abrichr
left a comment
There was a problem hiding this comment.
Coordinator review (AI-assisted). Approved at a08534d.
- Earlier fail-open findings, all fixed:
- The pixel census counts anything that isn't a clear mismatch as a look-alike; an uncountable place halts.
- A list with no declared row count reads incomplete unless it definitely doesn't scroll.
- Windows provider errors read incomplete.
- positional_id: the token rule drops 0 of 24,000 real ids across 12 families (UUIDs, ULIDs, encounter and visit ids, MRNs) and still drops positional ones (row-3, r3c2, item_12). Parity holds in the browser and the Windows agent.
- Write verbs: "Sign out patient", "Discharge patient" and similar are consequential; session sign-out stays non-consequential.
- Real-app CI: the Windows WPF and macOS AX legs ran with zero skips and passed, and the job fails on any skip.
…e only) Qualification fault injection, consequential delivery, effect checks and read-back, the API tier, acting by kind, settling and confirming move verbatim into mixins (107 units; verify_moved_code finds no edits). replayer.py goes from 18,300 to 10,926 lines. Left in place because open pull requests change them: identity checks (#1518, #1504), _resolve_step (#1504), postconditions and step evidence (#1516, #1514), the starting-state settle and qualification boundary (#1449), and the run and step drivers. Two methods that name Replayer stay too. Two tests patched module globals the moved code now reads from its new module, and patch them there as well: resolve (scroll readiness is in replay.acting, interstitials in replay.settling) and _DIALOG_WAIT_S (replay.acting). Checked: strict mypy on runtime/core, runtime/replay and replayer.py; package-wide mypy; both import contracts; 3,149 tests across the 146 files that import the replayer plus the real-browser e2e files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #1504, #1557 and the reviewer's control-role fix. In _apply_candidate_census, the pixel census still runs when a pixel or VLM identity has no OCR band; a teach-time control role then replaces only a cannot_count. A census that can count, pixel included, always decides. The #1557 tests for the no-census branch now make the pixel census unavailable (crop-only steps can otherwise be counted by pixels). A new test checks that the pixel census decides for a control: the crop found once verifies, found twice halts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pixel census counted every place where the identifier crop matched at 0.7 or more and wasn't a clear pixel mismatch. On OpenEMR-like section toggles, where the chrome dominates the crop and the label is small, siblings such as "Who" and "Misc" get no pixel verdict, so the "Contact" toggle would halt as ambiguous. The threshold is unchanged. A place that the pixels don't rule out is now distinguishable only when OCR reads both crops and their text differs. Unreadable text on either crop, or the same text, still counts it as a look-alike. Tests use rendered OpenEMR-like sibling tabs: "Contact" among distinct labels is unique; with unreadable labels it halts; two "Contact" tabs halt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On OpenEMR, #1518 halted step_016 as identical_candidates with 12 candidates. Its recorded identifier crop is a dark background band with one light rule and no label. It matches itself shifted, so template matching found it at every offset along the band, and OCR read no text to break the tie. vision.match.crop_is_featureless flags a flat crop, or one whose quarter-shift self-correlation is at least 0.9 on either axis (real identifier crops measured at most ~0.65; this one measures 1.0). The pixel census returns cannot_count for it instead of counting. A teach-time control then verifies, as on main, and a row or a target with no role still halts as identity_uncounted. Checked offline on six soak-final-2 frames of step_016: cannot_count, then not_a_row, verified. Tests: a blank band on a button before a save verifies; with no role it halts; crop_is_featureless on bands, flat, text and tab crops. The census tests that used the full-height-bar helper now use a text crop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Real-app check, run by the production-lifecycle session: at ee707b8, the OpenEMR representative admission case is VERIFIED (exact_record read-back, 0 model calls, 105 s engine). It was tested at d21779a68 (measure/production-lifecycle-final 8bfd7f6 = main 78ff5ad + #1516 + harness, merged with ee707b8) on a fresh stack. step_016 is pixel verified, then the census gives not_a_row; every other step through the save (step_040) is structured verified. Only the representative case ran, not the 5 fault cases. |
AI review (coordinator)Scope: the commits after the #1557 merge at head 1. Blocker: an OCR misread makes two identical rows count as different (152d69a)The new tie-break treats any difference in the squashed text as distinguishable ( To test this, I rendered a patient list with two "Smith, John" rows that have different DOBs (Arial 13 px). I ran the real
Overall, 18 of 34 tight-crop cases returned Fix. A place counts as distinguishable only when all of these hold:
Sibling tab labels measure 0.5 or less ("Contact"/"Stats" 0.5, "Contact"/"Who" 0.2). 2. Bug:
|
| control | measured |
|---|---|
| kebab menu | 0.79 |
| ellipsis | 0.79 |
| empty checkbox | 0.43 |
| radio button | 0.05 |
| empty input | 0.82 |
| button outline | 0.77 |
| text crops | at most 0.5 |
Those crops still get counted and halt when they're ambiguous. The 0.9 threshold leaves margin above real crops. The flat-window case in finding 2 is the only false positive I found.
Exact fix (verified locally)
diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py
index 48bc241f7..6bdad2cd9 100644
--- a/packages/flow/openadapt_flow/runtime/replayer.py
+++ b/packages/flow/openadapt_flow/runtime/replayer.py
@@ -736,6 +736,17 @@ class _WebFormApprovalView:
ref: Any
+
+# The pixel census's OCR tie-break: a place the pixels do not rule out is
+# distinguishable only when OCR reads both crops with every line at or above
+# this confidence, neither text contains the other, and the two texts are less
+# similar than this ratio. One label read two ways measured 0.82-0.95
+# ("Smith, John" / "Smith, S John" / "Smith, 10 lohn"); sibling tab labels
+# ("Contact" / "Stats") measure 0.5 or less.
+PIXEL_CENSUS_OCR_MIN_CONFIDENCE = 0.8
+PIXEL_CENSUS_OCR_MAX_SIMILARITY = 0.6
+
+
class Replayer:
"""Replays a Workflow against a Backend using injected vision.
@@ -15823,10 +15834,27 @@ class Replayer:
lines = self.vision.ocr(png, region=None)
except Exception:
return ""
+ # A low-confidence fragment ("S", "10", "lohn") is a misread, not
+ # evidence: the whole crop then counts as unreadable.
+ if any(
+ float(getattr(line, "confidence", 0.0) or 0.0)
+ < PIXEL_CENSUS_OCR_MIN_CONFIDENCE
+ for line in lines
+ ):
+ return ""
return identity_mod.squash(
" ".join(str(getattr(line, "text", "") or "") for line in lines)
)
+ def texts_differ(a: str, b: str) -> bool:
+ """Clearly different labels, not one label read two ways."""
+ import difflib
+
+ if a in b or b in a:
+ return False # a dropped or clipped word, not another label
+ ratio = difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()
+ return ratio < PIXEL_CENSUS_OCR_MAX_SIMILARITY
+
target_text: Optional[str] = None
count = 1
for x, y in peaks:
@@ -15847,7 +15875,7 @@ class Replayer:
if target_text is None:
target_text = crop_text(live)
other_text = crop_text(crop)
- if target_text and other_text and other_text != target_text:
+ if target_text and other_text and texts_differ(target_text, other_text):
continue
count += 1
if count > 1:
diff --git a/packages/flow/openadapt_flow/vision/match.py b/packages/flow/openadapt_flow/vision/match.py
index 2f96557a7..933d12c7d 100644
--- a/packages/flow/openadapt_flow/vision/match.py
+++ b/packages/flow/openadapt_flow/vision/match.py
@@ -333,8 +333,15 @@ def crop_is_featureless(template_png: bytes) -> bool:
def shifted_score(a: np.ndarray, b: np.ndarray) -> float:
a = a - a.mean()
b = b - b.mean()
- denom = math.sqrt(float((a * a).sum()) * float((b * b).sum()))
- return 1.0 if denom == 0 else float((a * b).sum()) / denom
+ energy_a = float((a * a).sum())
+ energy_b = float((b * b).sum())
+ if energy_a == 0 and energy_b == 0:
+ return 1.0 # both windows flat: nothing tells places apart
+ if energy_a == 0 or energy_b == 0:
+ # Content confined to one edge quarter (a short label in a wide
+ # cell): the shifted windows are not alike.
+ return 0.0
+ return float((a * b).sum()) / math.sqrt(energy_a * energy_b)
dx = max(1, width // 4)
dy = max(1, height // 4)
diff --git a/packages/flow/tests/test_native_identity_census.py b/packages/flow/tests/test_native_identity_census.py
index e3ae7facf..8d068091c 100644
--- a/packages/flow/tests/test_native_identity_census.py
+++ b/packages/flow/tests/test_native_identity_census.py
@@ -855,7 +855,7 @@ class _TabOcr:
_TABS + ["Contact"],
key=lambda name: float(np.abs(crop.astype(int) - _tab(name)).mean()),
)
- return [types.SimpleNamespace(text=label)]
+ return [types.SimpleNamespace(text=label, confidence=1.0)]
def _tab_census(tmp_path, labels: list[str], target: int, *, readable: bool = True):With this diff:
- the Smith grid has 0 of 288
uniqueresults at 13 px and 0 of 288 at 11 px. On the head commit it had 77 of 288 at 13 px. - the motivating OpenEMR "Contact" tab is still
uniquewith real RapidOCR on the rendered tabs. - two "Contact" tabs still halt.
test_native_identity_census,test_census_teach_roleandtest_native_census_real_appspass (80 passed, 6 skipped). The fake_TabOcrneedsconfidence=1.0because a line with no confidence now counts as unreadable.- ruff is clean.
Please also add a regression test: two identical rows, where the other crop's OCR returns a low-confidence fragment, should be ambiguous.
Auto-merge stays off until the fix lands.
Review of #1518 found two holes. - The pixel census's OCR tie-break treated any text difference as a different label. With real RapidOCR on two rendered "Smith, John" rows, 18 of 34 cases read unique ("Smith, 10 lohn" at JPEG q75, "Smith, S John" with a 1 px offset). Now a crop with any OCR line below 0.8 confidence reads as unreadable, and two texts differ only when neither contains the other and their similarity ratio is below 0.6 (SequenceMatcher, autojunk off). - crop_is_featureless scored 1.0 when only one shifted window was flat, so a short label at the edge of a wide cell counted as featureless. It scores 1.0 only when both windows are flat, and 0.0 when exactly one is. The fix is the reviewer's (rr-1518). With it, the rendered Smith grid is 0 of 288 unique, the OpenEMR "Contact" tab is still unique, and two Contact tabs halt. Six real soak-final-2 frames of step_016 still verify. Regression tests: a low-confidence misread and a confident near-identical reading both leave identical rows ambiguous, and an edge-label crop is not featureless. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Real-app check, run by the production-lifecycle session at the pushed head 68e79e7: the OpenEMR representative admission case is VERIFIED (exact_record read-back, 0 model calls, 105 s engine). It was tested at 0cedc0c7d (measure/production-lifecycle-final 07742b8 = main 78ff5ad + #1516 + harness, merged with 68e79e7) on a fresh stack. step_016 is pixel verified, then the census gives not_a_row, and all 20 census calls stayed verified. Only the representative case ran, not the 5 fault cases. |
Stacked on #1504 (base: its branch). Merge #1504 first; GitHub then retargets this PR to
main.Before
After #1504, a step that picks a row before a write halts with
identity_uncountedon any surface that can't count look-alike rows. That covered native Windows, macOS and Linux apps, plus pixel identities with no OCR band. Those workflows stopped instead of failing open, but they couldn't run.After
Each of those surfaces now counts the rows that read the same as the target's row. The rules from #1504 then apply: one matching row runs; several halt unless the recorded record id picks one; a row list that can't be fully enumerated halts.
/uia/identity-candidates-at)/uia/text-at-pointListViewItem-0)RowCountabove the exposed rows; a scrollable list with the ItemContainer patternAXTable/AXOutline; items ofAXListAXIdentifier,AXURLAXRowsthan exposed rows; a row whose cells aren't builtstructured_textreads themn_rowsabove the exposed rowsvision.match.template_peaks, then the ladder's pixel verifier on each place)Pixel screens that have an OCR band already used #1504's OCR census; that's unchanged.
The tree walk is one function,
identity_census.tree_row_census, used by macOS and Linux. The Windows agent is copied into the guest without this package, so it carries a mirror of the same rule.Tests
tests/test_native_identity_census.py: the generic census (two and three identical rows, distinct ids picking the row, a unique row, controls outside rows, position-only ids, a short or virtualized table, an unbuilt row,AXListitems). It also covers the Linux client over fake AT-SPI nodes, the Windows agent over fake UIA controls (including the served operation), the macOS backend's point-to-pixel conversion, and the replayer on a native backend: identical rows halt, the recorded id moves the click, a unique row runs.tests/test_native_census_real_apps.py(opt-in): a WPFListBoxread through real UI Automation, and a SwiftNSTableViewread through real AX. The new CI jobflow-native-censusruns both. The Windows leg fails if any WPF test is skipped. The macOS leg skips when the hosted runner gives no Accessibility access. On my Mac, AX returns application elements in place of windows in this session, so those tests skipped here too.tests/test_identity_evidence_capability.py: a crop-only identity now runs when the crop verifies in one place and halts when it verifies in two.Not covered
🤖 Generated with Claude Code