@@ -151,6 +151,7 @@ def visual_resolution_evaluator_contract_sha256() -> str:
151151 "template_ambiguity_suspicion_score" : AMBIGUITY_SUSPICION_SCORE ,
152152 "template_scales" : list (DEFAULT_TEMPLATE_SCALES ),
153153 "ambiguity" : "unique-or-independent-retained-evidence" ,
154+ "identity_armed_template_landmark_binding" : "corroborate-and-snap" ,
154155 }
155156 return hashlib .sha256 (
156157 json .dumps (payload , sort_keys = True , separators = ("," , ":" )).encode ("utf-8" )
@@ -544,6 +545,47 @@ def _select_geometry_estimates(
544545 return (px , py ), confidence
545546
546547
548+ def _landmark_target_evidence (
549+ anchor : Anchor ,
550+ screen_png : bytes ,
551+ vision : Any ,
552+ ) -> tuple [Optional [tuple [Point , float ]], bool ]:
553+ """Return the target point established by retained landmark relations.
554+
555+ A template for an empty field can match at several horizontal offsets
556+ inside the same control. Moving an independently marked identity region
557+ by that arbitrary template offset creates a false identity mismatch. For
558+ an identity-armed target, use unique retained labels to corroborate the
559+ template and to recover the exact demonstrated action point. Ambiguous
560+ labels remain a typed refusal signal; absence remains an abstention.
561+ """
562+
563+ estimates : list [Point ] = []
564+ confidences : list [float ] = []
565+ ambiguous = False
566+ for landmark in anchor .landmarks :
567+ try :
568+ match = _find_landmark_text (vision , screen_png , landmark )
569+ except AmbiguousOcrMatchError :
570+ ambiguous = True
571+ continue
572+ if match is None :
573+ continue
574+ estimates .append (
575+ _estimate_from_landmark (
576+ landmark .relation ,
577+ (int (match .point [0 ]), int (match .point [1 ])),
578+ landmark .distance_px ,
579+ landmark .dx_px ,
580+ landmark .dy_px ,
581+ )
582+ )
583+ confidences .append (float (match .confidence ))
584+ if not estimates :
585+ return None , ambiguous
586+ return _select_geometry_estimates (anchor , estimates , confidences ), ambiguous
587+
588+
547589def resolve (
548590 anchor : Anchor ,
549591 screen_png : bytes ,
@@ -602,6 +644,44 @@ def resolve(
602644 def elapsed_ms () -> float :
603645 return (time .monotonic () - t0 ) * 1000.0
604646
647+ landmark_evidence_loaded = False
648+ landmark_evidence : Optional [tuple [Point , float ]] = None
649+ ambiguous_landmark = False
650+
651+ def identity_landmark_evidence () -> Optional [tuple [Point , float ]]:
652+ """Load landmark evidence once for this exact observed frame."""
653+
654+ nonlocal landmark_evidence_loaded , landmark_evidence , ambiguous_landmark
655+ if not landmark_evidence_loaded :
656+ landmark_evidence , ambiguous_landmark = _landmark_target_evidence (
657+ anchor , screen_png , vision
658+ )
659+ landmark_evidence_loaded = True
660+ return landmark_evidence
661+
662+ def identity_bound_template_point (
663+ candidate : Point , candidate_region : Region
664+ ) -> Optional [Point ]:
665+ """Corroborate and stabilize an identity-armed template candidate."""
666+
667+ if anchor .identifier_region is None or not anchor .landmarks :
668+ return candidate
669+ evidence = identity_landmark_evidence ()
670+ if evidence is None :
671+ return candidate
672+ landmark_point , _confidence = evidence
673+ if (
674+ math .hypot (
675+ landmark_point [0 ] - candidate [0 ],
676+ landmark_point [1 ] - candidate [1 ],
677+ )
678+ > GLOBAL_LANDMARK_TOLERANCE_PX
679+ ):
680+ return None
681+ if not _point_in_region (landmark_point , candidate_region ):
682+ return None
683+ return landmark_point
684+
605685 # Rung 0: structural (DOM / UIA) — the strongest, deterministic evidence.
606686 # Tried FIRST, and only when a structural-capable backend is injected AND
607687 # the anchor carries a recorded structural locator. On a pixel-only
@@ -649,9 +729,17 @@ def elapsed_ms() -> float:
649729 prefer_near = (anchor .region [0 ], anchor .region [1 ]),
650730 )
651731 if match is not None :
732+ bound_point = identity_bound_template_point (
733+ _scaled_click_point (anchor , tuple (match .region )),
734+ tuple (match .region ),
735+ )
736+ if bound_point is None :
737+ match = None
738+ if match is not None :
739+ assert bound_point is not None
652740 resolution = Resolution (
653741 rung = "template" ,
654- point = _scaled_click_point ( anchor , tuple ( match . region )) ,
742+ point = bound_point ,
655743 confidence = float (match .confidence ),
656744 elapsed_ms = elapsed_ms (),
657745 )
@@ -676,7 +764,15 @@ def elapsed_ms() -> float:
676764 prefer_near = (anchor .region [0 ], anchor .region [1 ]),
677765 )
678766 if match is not None :
679- point = _scaled_click_point (anchor , tuple (match .region ))
767+ bound_point = identity_bound_template_point (
768+ _scaled_click_point (anchor , tuple (match .region )),
769+ tuple (match .region ),
770+ )
771+ if bound_point is None :
772+ match = None
773+ if match is not None :
774+ assert bound_point is not None
775+ point = bound_point
680776 contradicted = _landmarks_contradict (anchor , point , screen_png , vision )
681777 if not contradicted :
682778 resolution = Resolution (
@@ -751,36 +847,9 @@ def elapsed_ms() -> float:
751847 return resolution , tuple (match .region )
752848
753849 # Rung 4: geometry from landmarks.
754- estimates : list [Point ] = []
755- confidences : list [float ] = []
756- ambiguous_landmark = False
757- for landmark in anchor .landmarks :
758- try :
759- lm_match = _find_landmark_text (vision , screen_png , landmark )
760- except AmbiguousOcrMatchError :
761- # An ambiguous landmark contributes no coordinate. Other unique
762- # landmarks remain independently usable under the existing
763- # geometry contract, regardless of declaration order.
764- ambiguous_landmark = True
765- continue
766- if lm_match is None :
767- continue
768- estimates .append (
769- _estimate_from_landmark (
770- landmark .relation ,
771- (int (lm_match .point [0 ]), int (lm_match .point [1 ])),
772- landmark .distance_px ,
773- getattr (landmark , "dx_px" , None ),
774- getattr (landmark , "dy_px" , None ),
775- )
776- )
777- confidences .append (float (lm_match .confidence ))
778- if estimates :
779- (px , py ), geometry_confidence = _select_geometry_estimates (
780- anchor ,
781- estimates ,
782- confidences ,
783- )
850+ evidence = identity_landmark_evidence ()
851+ if evidence is not None :
852+ (px , py ), geometry_confidence = evidence
784853 region = _clamp_region_of_size (
785854 (px , py ), (anchor .region [2 ], anchor .region [3 ]), viewport
786855 )
0 commit comments