Skip to content

feat(flow): count look-alike rows on native and remote surfaces - #1518

Merged
abrichr merged 8 commits into
mainfrom
feat/native-identity-census
Oct 25, 2026
Merged

abrichr merged 8 commits into
mainfrom
feat/native-identity-census

Conversation

@abrichr

@abrichr abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member

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_uncounted on 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.

Surface Rows counted Record id Incomplete (halts)
Windows (UIA agent, /uia/identity-candidates-at) DataItem, ListItem, TreeItem and TableRow siblings, read at the same child index as /uia/text-at-point AutomationId, unless it only encodes the row's position (ListViewItem-0) Grid pattern RowCount above the exposed rows; a scrollable list with the ItemContainer pattern
macOS (AX) rows of AXTable / AXOutline; items of AXList AXIdentifier, AXURL more AXRows than exposed rows; a row whose cells aren't built
Linux (AT-SPI) sibling list items, table rows, tree items, read exactly as structured_text reads them AT-SPI accessible id Table interface n_rows above the exposed rows
Pixel only, no OCR band the places on the frame where the identifier crop verifies (vision.match.template_peaks, then the ladder's pixel verifier on each place) none 16 or more candidate places

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, AXList items). 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 WPF ListBox read through real UI Automation, and a Swift NSTableView read through real AX. The new CI job flow-native-census runs 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.
  • Locally (2 workers): ruff, mypy (including strict), and 98 targeted test files, 3,291 passed.

Not covered

  • Linux: GTK 3 list boxes (one child per row) and tree views (cells without a row) give no row identity under the current structured reading, so identity there rests on OCR. No real-app Linux test yet.
  • macOS real-app: the test only proves something on a runner with Accessibility access. If the CI leg skips, the macOS census is covered by fake-tree tests only.

🤖 Generated with Claude Code

abrichr and others added 4 commits October 24, 2026 20:02
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 abrichr left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Land together with #1504 (#1518 contains it), after #1467.

abrichr added a commit that referenced this pull request Oct 25, 2026
…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>
Base automatically changed from claude/affectionate-cerf-161abd to main October 25, 2026 05:40
abrichr and others added 3 commits October 25, 2026 06:28
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>
@abrichr

abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

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.

@abrichr
abrichr enabled auto-merge October 25, 2026 13:05
@abrichr
abrichr disabled auto-merge October 25, 2026 13:18
@abrichr

abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

AI review (coordinator)

Scope: the commits after the #1557 merge at head ee707b80d (33bc0ac, 152d69a, ee707b8). I've turned auto-merge off because of finding 1, which lets the census click the wrong row.

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 (other_text != target_text). It doesn't check OCR confidence or how close the texts are. Two renders of the same label often read slightly differently.

To test this, I rendered a patient list with two "Smith, John" rows that have different DOBs (Arial 13 px). I ran the real Replayer._pixel_census with real RapidOCR and pixel_verify_enabled=True. I counted a case only when the target's own pixel tier returned verified, so the census actually runs. A sample of results with a tight identifier crop (the size of a web field rect or an operator-marked box):

live frame target crop OCR other "Smith, John" crop OCR census
JPEG q75 (lossy remote display), no offset Smith, John Smith, 10(0.71) lohn(0.87) unique
zebra rows, 1 px offset Smith, John Smith, S(0.77) John unique
no JPEG, 1 px vertical offset Smith, John 19(0.65) Smith, John unique

Overall, 18 of 34 tight-crop cases returned unique while two identical rows were on screen. The ambiguous halt then never fires, and the resolver can click either Smith before the save. Padded crops (1 to 4 px) were not affected in this grid. Every misread had a fragment below 0.9 confidence, and every pair of reads had a similarity ratio of 0.82 or more.

Fix. A place counts as distinguishable only when all of these hold:

  • every OCR line in both crops has confidence of at least 0.8;
  • neither text contains the other;
  • difflib ratio < 0.6.

Sibling tab labels measure 0.5 or less ("Contact"/"Stats" 0.5, "Contact"/"Who" 0.2).

2. Bug: crop_is_featureless returns 1.0 when one shifted window is flat (ee707b8)

shifted_score returns 1.0 when denom == 0. That includes the case where only one of the two shifted windows is flat. So a crop whose content sits entirely in one edge quarter counts as "featureless". Examples: a short left-aligned label in a wide cell, such as "Li, Al" in a 200 px name cell, or "17" in a 120 px cell. I measured both as featureless=True.

The pixel census then returns cannot_count:

  • for a row or a target with no role, the run halts (identity_uncounted). This is a false halt.
  • for a teach-time control, the target verifies with no count at all.

Fix. Return 1.0 only when both windows are flat, and 0.0 when exactly one is.

3. Featureless crop plus control role (question 2): acceptable, not a regression

A featureless crop returns cannot_count, and a control then gets not_a_row. That is exactly main's behaviour for every control (main has no pixel census), so this change doesn't widen anything compared with main. It narrows only the stricter pre-ee707 state of this PR, and it's consistent with #1557's rule. The remaining exposure is a per-row button whose crop really is self-similar, such as a row of identical icons spaced at a quarter of the width (measured 1.0).

Common per-row controls aren't flagged:

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 unique results 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 unique with real RapidOCR on the rendered tabs.
  • two "Contact" tabs still halt.
  • test_native_identity_census, test_census_teach_role and test_native_census_real_apps pass (80 passed, 6 skipped). The fake _TabOcr needs confidence=1.0 because 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>
@abrichr

abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

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.

@abrichr
abrichr enabled auto-merge October 25, 2026 13:25
@abrichr
abrichr merged commit 3b1413f into main Oct 25, 2026
44 checks passed
@abrichr
abrichr deleted the feat/native-identity-census branch October 25, 2026 13:43
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