zu-patterns 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ """zu-patterns — the policy-prior / move-ordering layer over the Action Surface.
2
+
3
+ The ``Pattern`` port itself lives in zu-core (``zu_core.ports.Pattern``); this
4
+ package ships the built-in patterns, the recognizer pass, the reversible-vs-
5
+ committing classifier, and the offline guided search over the Phase-1 FSM.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .recognizer import Recognition, recognize
11
+ from .reversibility import (
12
+ DEFAULT_PRIORS,
13
+ ActionPrior,
14
+ Commitment,
15
+ Signal,
16
+ classify_action,
17
+ )
18
+ from .search import (
19
+ Candidate,
20
+ MpcDecision,
21
+ MpcOutcome,
22
+ Plan,
23
+ PlanStep,
24
+ fsm_from_events,
25
+ fsm_from_shadow,
26
+ fsm_from_shadow_events,
27
+ live_mpc_step,
28
+ merge_transition_models,
29
+ mpc_run,
30
+ plan,
31
+ )
32
+
33
+ __all__ = [
34
+ "Recognition",
35
+ "recognize",
36
+ "Commitment",
37
+ "Signal",
38
+ "ActionPrior",
39
+ "DEFAULT_PRIORS",
40
+ "classify_action",
41
+ "Plan",
42
+ "PlanStep",
43
+ "fsm_from_events",
44
+ "plan",
45
+ "live_mpc_step",
46
+ "mpc_run",
47
+ "Candidate",
48
+ "MpcDecision",
49
+ "MpcOutcome",
50
+ "fsm_from_shadow",
51
+ "fsm_from_shadow_events",
52
+ "merge_transition_models",
53
+ ]
zu_patterns/_match.py ADDED
@@ -0,0 +1,80 @@
1
+ """Shared, purely-structural matching helpers for the built-in patterns.
2
+
3
+ These are deterministic predicates over a core ``SurfaceView``'s affordances —
4
+ roles, normalized labels, states. No model, no site constants: a pattern derives
5
+ its ``label_hint`` from the surface it matched, never from a hardcoded magic
6
+ string. The token lists below (``user``/``password``/``search``/``accept`` …) are
7
+ GENERIC, language-of-the-archetype vocabulary — the same way an accessibility
8
+ checker knows ``button`` means "actionable" — not site-specific keys.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Iterable
14
+
15
+ from zu_core.surface import SurfaceAffordance, SurfaceView
16
+
17
+
18
+ def norm(s: str) -> str:
19
+ """Lowercase, collapse whitespace — the canonical form for label matching."""
20
+ return " ".join(s.lower().split())
21
+
22
+
23
+ def label_has(aff: SurfaceAffordance, tokens: Iterable[str]) -> bool:
24
+ """True iff the affordance's normalized label contains any token."""
25
+ lbl = norm(aff.label)
26
+ return any(t in lbl for t in tokens)
27
+
28
+
29
+ def has_state(aff: SurfaceAffordance, *states: str) -> bool:
30
+ sset = {norm(s) for s in aff.states}
31
+ return any(norm(s) in sset for s in states)
32
+
33
+
34
+ def of_role(surface: SurfaceView, *roles: str) -> list[SurfaceAffordance]:
35
+ rset = {r.lower() for r in roles}
36
+ return [a for a in surface.affordances if a.role.lower() in rset]
37
+
38
+
39
+ def first(
40
+ surface: SurfaceView,
41
+ *,
42
+ roles: Iterable[str] = (),
43
+ tokens: Iterable[str] = (),
44
+ states: Iterable[str] = (),
45
+ ) -> SurfaceAffordance | None:
46
+ """The first affordance matching ALL of the supplied predicates (any omitted
47
+ predicate is satisfied vacuously)."""
48
+ rset = {r.lower() for r in roles}
49
+ tlist = list(tokens)
50
+ slist = list(states)
51
+ for a in surface.affordances:
52
+ if rset and a.role.lower() not in rset:
53
+ continue
54
+ if tlist and not label_has(a, tlist):
55
+ continue
56
+ if slist and not has_state(a, *slist):
57
+ continue
58
+ return a
59
+ return None
60
+
61
+
62
+ def context_has(surface: SurfaceView, tokens: Iterable[str]) -> bool:
63
+ blob = norm(" ".join(surface.context))
64
+ return any(t in blob for t in tokens)
65
+
66
+
67
+ # Generic archetype vocabularies (NOT site constants).
68
+ USER_TOKENS = ("user", "email", "e-mail", "login", "username", "account name")
69
+ PASSWORD_TOKENS = ("password", "passcode", "pass word")
70
+ SUBMIT_TOKENS = ("sign in", "log in", "login", "submit", "continue", "next", "go")
71
+ SEARCH_TOKENS = ("search", "find", "query", "look up")
72
+ ACCEPT_TOKENS = ("accept", "agree", "allow", "got it", "ok", "i accept", "consent")
73
+ REJECT_TOKENS = ("reject", "decline", "deny", "refuse", "no thanks")
74
+ CLOSE_TOKENS = ("close", "dismiss", "×", "x")
75
+ CONFIRM_TOKENS = ("confirm", "yes", "ok", "proceed", "continue")
76
+ NEXT_TOKENS = ("next", "next page", ">", "more", "load more")
77
+ PREV_TOKENS = ("prev", "previous", "back", "<")
78
+ CART_TOKENS = ("add to cart", "add to bag", "add to basket")
79
+ CHECKOUT_TOKENS = ("checkout", "check out")
80
+ PLACE_ORDER_TOKENS = ("place order", "buy now", "pay", "complete purchase", "confirm order")
@@ -0,0 +1,62 @@
1
+ """autocomplete — a textbox/combobox (expanded / controls a listbox of options).
2
+
3
+ Script: fill partial, pick option. Typing + picking is REVERSIBLE (it fills a
4
+ field). Success: the option fills the field.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from zu_core.invariants import Invariant
10
+ from zu_core.ports import PatternStep, RecognitionResult
11
+ from zu_core.surface import SurfaceView
12
+
13
+ from . import _match as m
14
+ from .rail import surface_shows
15
+
16
+ _EXPAND_STATES = ("expanded", "haspopup", "autocomplete")
17
+
18
+
19
+ class Autocomplete:
20
+ name = "autocomplete"
21
+ archetype = "autocomplete"
22
+
23
+ def recognize(self, surface: SurfaceView) -> RecognitionResult | None:
24
+ box = next(
25
+ (
26
+ a
27
+ for a in surface.affordances
28
+ if a.role.lower() in {"combobox", "textbox", "searchbox"}
29
+ and m.has_state(a, *_EXPAND_STATES)
30
+ ),
31
+ None,
32
+ )
33
+ if box is None:
34
+ return None
35
+ options = m.of_role(surface, "option")
36
+ # An expanded combobox with options present is the strong case.
37
+ confidence = 0.85 if options else 0.62
38
+ script = [PatternStep(op="fill", role=box.role, label_hint=m.norm(box.label), note="type")]
39
+ handles = [box.handle]
40
+ if options:
41
+ opt = options[0]
42
+ script.append(
43
+ PatternStep(op="select", role="option", label_hint=m.norm(opt.label), note="pick")
44
+ )
45
+ handles.append(opt.handle)
46
+ return RecognitionResult(
47
+ archetype=self.archetype,
48
+ confidence=confidence,
49
+ matched_handles=tuple(handles),
50
+ script=tuple(script),
51
+ detail="autocomplete",
52
+ )
53
+
54
+ def success_invariants(self, result: RecognitionResult) -> list[Invariant]:
55
+ handle = result.matched_handles[0] if result.matched_handles else None
56
+ # Done = the field EVENTUALLY present (filled) by the deadline.
57
+ return [surface_shows(self.archetype, "option_filled", handle=handle, liveness=True)]
58
+
59
+ def failure_invariants(self, result: RecognitionResult) -> list[Invariant]:
60
+ # Failure CONTEXT = a "no results" empty-state appears. Safety shape:
61
+ # THROUGHOUT NOT contains(no results) — fires the instant it lands.
62
+ return [surface_shows(self.archetype, "no_options", label="no results", negate=True)]
@@ -0,0 +1,102 @@
1
+ """cart_checkout — the canonical IRREVERSIBLE-BOUNDARY pattern.
2
+
3
+ Recognizes a button cluster {add to cart, checkout, place order, pay} alongside
4
+ line-item context. The proposed script STOPS BEFORE the committing step: it adds
5
+ to cart / proceeds to checkout, but the place-order/pay step is classified
6
+ COMMITTING — the live-search and rail commit boundary. This is the discipline
7
+ made into a pattern: search may explore up to the boundary, never auto-cross it.
8
+ Success: an order-confirmation surface. The place-order step never auto-executes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from zu_core.invariants import Invariant
14
+ from zu_core.ports import PatternStep, RecognitionResult
15
+ from zu_core.surface import SurfaceView
16
+
17
+ from . import _match as m
18
+ from .rail import surface_shows
19
+ from .reversibility import ActionPrior, Commitment
20
+
21
+ _LINE_ITEM_CONTEXT = ("cart", "basket", "bag", "subtotal", "order summary", "line item", "qty")
22
+ _CONFIRM_CONTEXT = ("order confirmed", "thank you for your order", "order number", "confirmation")
23
+
24
+
25
+ class CartCheckout:
26
+ name = "cart_checkout"
27
+ archetype = "cart_checkout"
28
+
29
+ def recognize(self, surface: SurfaceView) -> RecognitionResult | None:
30
+ add = m.first(surface, roles=("button",), tokens=m.CART_TOKENS)
31
+ checkout = m.first(surface, roles=("button", "link"), tokens=m.CHECKOUT_TOKENS)
32
+ place = m.first(surface, roles=("button",), tokens=m.PLACE_ORDER_TOKENS)
33
+ if add is None and checkout is None and place is None:
34
+ return None
35
+ line_ctx = m.context_has(surface, _LINE_ITEM_CONTEXT)
36
+ # Confidence: a cart/checkout button WITH line-item context is strong.
37
+ present = [x for x in (add, checkout, place) if x is not None]
38
+ confidence = 0.85 if (line_ctx and present) else 0.6
39
+ # The proposed script advances toward — but STOPS BEFORE — the committing
40
+ # place-order/pay step. We propose the safe step (add / go to checkout) and
41
+ # mark the committing step with an ``expect`` (a boundary marker), never a
42
+ # ``submit`` the search would auto-cross.
43
+ script: list[PatternStep] = []
44
+ handles: list[str] = []
45
+ safe = add or checkout
46
+ if safe is not None:
47
+ script.append(
48
+ PatternStep(op="click", role=safe.role, label_hint=m.norm(safe.label), note="proceed")
49
+ )
50
+ handles.append(safe.handle)
51
+ if place is not None:
52
+ # The boundary: marked, not proposed for execution.
53
+ script.append(
54
+ PatternStep(
55
+ op="expect",
56
+ role="button",
57
+ label_hint=m.norm(place.label),
58
+ note="COMMIT BOUNDARY: place-order/pay is committing — do not auto-cross",
59
+ )
60
+ )
61
+ handles.append(place.handle)
62
+ return RecognitionResult(
63
+ archetype=self.archetype,
64
+ confidence=confidence,
65
+ matched_handles=tuple(handles),
66
+ script=tuple(script),
67
+ detail="cart/checkout (irreversible boundary)",
68
+ )
69
+
70
+ def success_invariants(self, result: RecognitionResult) -> list[Invariant]:
71
+ # Done = an order-confirmation surface EVENTUALLY appears (by the deadline).
72
+ # A committed-but-never-confirmed run violates this liveness at the deadline.
73
+ return [
74
+ surface_shows(self.archetype, "order_confirmed", label="order confirmed", liveness=True)
75
+ ]
76
+
77
+ def failure_invariants(self, result: RecognitionResult) -> list[Invariant]:
78
+ # Failure CONTEXT = a payment/checkout error appears. Safety shape:
79
+ # THROUGHOUT NOT contains(error) — fires the instant the error lands.
80
+ return [surface_shows(self.archetype, "checkout_error", label="error", negate=True)]
81
+
82
+ # The reversibility prior this pattern CONTRIBUTES: its place-order/pay step is
83
+ # COMMITTING. A planner/classifier passes this into ``classify_action`` so the
84
+ # boundary is declared by the pattern, not hardcoded into the core classifier.
85
+ @staticmethod
86
+ def commit_prior() -> ActionPrior:
87
+ def _is_place_order(facts: dict) -> bool:
88
+ note = str(facts.get("note", "")).lower()
89
+ op = str(facts.get("op", "")).lower()
90
+ label = str(facts.get("label_hint", "")).lower()
91
+ return (
92
+ op in {"place_order", "pay", "purchase", "checkout"}
93
+ or "commit boundary" in note
94
+ or any(tok in label for tok in m.PLACE_ORDER_TOKENS)
95
+ )
96
+
97
+ return ActionPrior(
98
+ name="cart_checkout.place_order",
99
+ matcher=_is_place_order,
100
+ commitment=Commitment.COMMITTING,
101
+ weight=2.0,
102
+ )
@@ -0,0 +1,71 @@
1
+ """cookie_banner — a consent/cookie banner with an accept/reject button cluster.
2
+
3
+ Recognized when the surface's context mentions cookies/consent (or an
4
+ alert/region carries it) and the affordances are dominated by accept/agree/reject
5
+ buttons with no other task affordances. Dismissing is idempotent ⇒ REVERSIBLE.
6
+ Success: the accept button is GONE from the next surface (a negated
7
+ SURFACE_CONTAINS). Failure: the banner persists.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from zu_core.invariants import Invariant
13
+ from zu_core.ports import PatternStep, RecognitionResult
14
+ from zu_core.surface import SurfaceView
15
+
16
+ from . import _match as m
17
+ from .rail import surface_shows
18
+
19
+ _CONSENT_CONTEXT = ("cookie", "consent", "gdpr", "privacy", "tracking")
20
+
21
+
22
+ class CookieBanner:
23
+ name = "cookie_banner"
24
+ archetype = "cookie_banner"
25
+
26
+ def recognize(self, surface: SurfaceView) -> RecognitionResult | None:
27
+ accept = m.first(surface, roles=("button",), tokens=m.ACCEPT_TOKENS)
28
+ if accept is None:
29
+ return None
30
+ buttons = m.of_role(surface, "button")
31
+ consent_ctx = m.context_has(surface, _CONSENT_CONTEXT)
32
+ consent_label = any(m.label_has(b, _CONSENT_CONTEXT) for b in buttons)
33
+ if not (consent_ctx or consent_label):
34
+ return None
35
+ # Confidence: a small banner (few affordances) dominated by accept/reject
36
+ # is a strong match; a page with many other affordances is weaker.
37
+ non_consent = [
38
+ a
39
+ for a in surface.affordances
40
+ if a.handle != accept.handle
41
+ and not m.label_has(a, m.ACCEPT_TOKENS + m.REJECT_TOKENS + m.CLOSE_TOKENS)
42
+ ]
43
+ confidence = 0.9 if len(non_consent) <= 1 else 0.65
44
+ if consent_ctx and consent_label:
45
+ confidence = min(1.0, confidence + 0.05)
46
+ return RecognitionResult(
47
+ archetype=self.archetype,
48
+ confidence=confidence,
49
+ matched_handles=(accept.handle,),
50
+ script=(
51
+ PatternStep(
52
+ op="click", role="button", label_hint=m.norm(accept.label), note="accept"
53
+ ),
54
+ ),
55
+ detail="consent banner",
56
+ )
57
+
58
+ def success_invariants(self, result: RecognitionResult) -> list[Invariant]:
59
+ handle = result.matched_handles[0] if result.matched_handles else None
60
+ # Done = the accept button is EVENTUALLY gone (a liveness-of-ABSENCE check:
61
+ # the banner is present pre-dismiss, so it must not fire until the deadline;
62
+ # if the button never goes away by the deadline, that liveness VIOLATES —
63
+ # which IS the "banner persists" failure, captured without a redundant
64
+ # positive must-contain-THROUGHOUT invariant).
65
+ return [surface_shows(self.archetype, "dismissed", handle=handle, negate=True, liveness=True)]
66
+
67
+ def failure_invariants(self, result: RecognitionResult) -> list[Invariant]:
68
+ # Failure CONTEXT = a consent-wall error appears. Safety shape: THROUGHOUT
69
+ # NOT contains(error) — fires the instant it lands. (The "banner persists"
70
+ # mode is the success liveness deadline-violation, not duplicated here.)
71
+ return [surface_shows(self.archetype, "consent_error", label="error", negate=True)]
@@ -0,0 +1,87 @@
1
+ """login_form — a username/email textbox + a password textbox + a submit button.
2
+
3
+ Script (a PROPOSAL, never auto-run): fill user, fill password, click submit.
4
+ Submit is COMMITTING (a form POST). Success: a post-submit surface shows an
5
+ account/logout/profile affordance. Failure: an error alert/status appears.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from zu_core import events as ev
11
+ from zu_core.invariants import Invariant
12
+ from zu_core.ports import PatternStep, RecognitionResult
13
+ from zu_core.surface import SurfaceView
14
+
15
+ from . import _match as m
16
+ from .rail import surface_shows
17
+
18
+ _ACCOUNT_TOKENS = ("logout", "log out", "sign out", "account", "profile", "my account")
19
+ _ERROR_TOKENS = ("invalid", "incorrect", "error", "wrong password", "failed", "try again")
20
+
21
+
22
+ class LoginForm:
23
+ name = "login_form"
24
+ archetype = "login_form"
25
+
26
+ def recognize(self, surface: SurfaceView) -> RecognitionResult | None:
27
+ user = m.first(surface, roles=("textbox", "searchbox"), tokens=m.USER_TOKENS)
28
+ # A password field is a textbox carrying a 'password' state, or labelled so.
29
+ pw = next(
30
+ (
31
+ a
32
+ for a in m.of_role(surface, "textbox")
33
+ if m.has_state(a, "password") or m.label_has(a, m.PASSWORD_TOKENS)
34
+ ),
35
+ None,
36
+ )
37
+ submit = m.first(surface, roles=("button",), tokens=m.SUBMIT_TOKENS)
38
+ if user is None or pw is None:
39
+ return None
40
+ # Confidence rises with a submit button present and a password state.
41
+ confidence = 0.7
42
+ if submit is not None:
43
+ confidence += 0.15
44
+ if m.has_state(pw, "password"):
45
+ confidence += 0.1
46
+ confidence = min(1.0, confidence)
47
+ handles = tuple(h for h in (user.handle, pw.handle, submit.handle if submit else None) if h)
48
+ script = [
49
+ PatternStep(op="fill", role="textbox", label_hint=m.norm(user.label), note="username"),
50
+ PatternStep(op="fill", role="textbox", label_hint=m.norm(pw.label), note="password"),
51
+ ]
52
+ if submit is not None:
53
+ script.append(
54
+ PatternStep(
55
+ op="submit", role="button", label_hint=m.norm(submit.label), note="commit"
56
+ )
57
+ )
58
+ return RecognitionResult(
59
+ archetype=self.archetype,
60
+ confidence=confidence,
61
+ matched_handles=handles,
62
+ script=tuple(script),
63
+ detail="login form",
64
+ )
65
+
66
+ def success_invariants(self, result: RecognitionResult) -> list[Invariant]:
67
+ # Done = a post-submit surface EVENTUALLY shows an account/logout
68
+ # affordance (a liveness-by-deadline postcondition: absent until the submit
69
+ # completes, so it must NOT fire on the pre-submit login surface).
70
+ return [
71
+ surface_shows(self.archetype, "reached_account", label=tok, liveness=True)
72
+ for tok in ("Logout", "Sign out", "Account")
73
+ ][:1]
74
+
75
+ def failure_invariants(self, result: RecognitionResult) -> list[Invariant]:
76
+ # Failure CONTEXT = an error alert/status appeared. Correct safety shape:
77
+ # "THROUGHOUT: NOT contains(error)" — the Monitor fires the instant an
78
+ # error context lands; the pre-interaction surface (no error) satisfies it.
79
+ return [
80
+ surface_shows(
81
+ self.archetype,
82
+ "error_alert",
83
+ label="error",
84
+ event_type=ev.SURFACE_CAPTURED,
85
+ negate=True,
86
+ )
87
+ ]
@@ -0,0 +1,64 @@
1
+ """modal_dialog — an alertdialog/dialog trapping focus with a close/confirm.
2
+
3
+ Reversible when the proposed step is a close/dismiss; COMMITTING when it is a
4
+ confirm/proceed (a confirm may commit an action). Success: the dialog is gone.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from zu_core.invariants import Invariant
10
+ from zu_core.ports import PatternStep, RecognitionResult
11
+ from zu_core.surface import SurfaceView
12
+
13
+ from . import _match as m
14
+ from .rail import surface_shows
15
+
16
+ _DIALOG_CONTEXT = ("dialog", "modal", "are you sure", "please confirm")
17
+
18
+
19
+ class ModalDialog:
20
+ name = "modal_dialog"
21
+ archetype = "modal_dialog"
22
+
23
+ def recognize(self, surface: SurfaceView) -> RecognitionResult | None:
24
+ # A dialog surfaces as a close button and/or a confirm button, with
25
+ # dialog-ish context. (Cookie banners are their own pattern and rank
26
+ # higher via consent vocabulary; this is the generic modal.)
27
+ close = m.first(surface, roles=("button",), tokens=m.CLOSE_TOKENS)
28
+ confirm = m.first(surface, roles=("button",), tokens=m.CONFIRM_TOKENS)
29
+ if close is None and confirm is None:
30
+ return None
31
+ dialogish = m.context_has(surface, _DIALOG_CONTEXT)
32
+ # Avoid stealing the cookie-banner case: if it reads as consent, defer.
33
+ if m.context_has(surface, ("cookie", "consent")):
34
+ return None
35
+ if not dialogish and close is None:
36
+ return None
37
+ chosen = close or confirm
38
+ assert chosen is not None
39
+ # Prefer the reversible close step in the proposed script.
40
+ op = "click" if chosen is close else "confirm"
41
+ confidence = 0.8 if dialogish else 0.6
42
+ return RecognitionResult(
43
+ archetype=self.archetype,
44
+ confidence=confidence,
45
+ matched_handles=(chosen.handle,),
46
+ script=(
47
+ PatternStep(op=op, role="button", label_hint=m.norm(chosen.label), note="dismiss"),
48
+ ),
49
+ detail="modal dialog",
50
+ )
51
+
52
+ def success_invariants(self, result: RecognitionResult) -> list[Invariant]:
53
+ handle = result.matched_handles[0] if result.matched_handles else None
54
+ # Done = the dialog is EVENTUALLY gone (liveness-of-ABSENCE: the dialog is
55
+ # present pre-dismiss, so it must not fire until the deadline; a dialog that
56
+ # never closes by the deadline VIOLATES this liveness — the "persists"
57
+ # failure, captured without a redundant positive must-contain-THROUGHOUT).
58
+ return [surface_shows(self.archetype, "dismissed", handle=handle, negate=True, liveness=True)]
59
+
60
+ def failure_invariants(self, result: RecognitionResult) -> list[Invariant]:
61
+ # Failure CONTEXT = an error appears inside/after the dialog. Safety shape:
62
+ # THROUGHOUT NOT contains(error) — fires the instant it lands. (The
63
+ # "persists" mode is the success liveness deadline-violation, not duplicated.)
64
+ return [surface_shows(self.archetype, "dialog_error", label="error", negate=True)]
@@ -0,0 +1,52 @@
1
+ """paginated_list — a list/listbox plus a next/prev/page-N link cluster.
2
+
3
+ Script: click next. Navigation ⇒ REVERSIBLE. Success: the list refreshes / the
4
+ page context advances.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from zu_core.invariants import Invariant
10
+ from zu_core.ports import PatternStep, RecognitionResult
11
+ from zu_core.surface import SurfaceView
12
+
13
+ from . import _match as m
14
+ from .rail import surface_shows
15
+
16
+
17
+ class PaginatedList:
18
+ name = "paginated_list"
19
+ archetype = "paginated_list"
20
+
21
+ def recognize(self, surface: SurfaceView) -> RecognitionResult | None:
22
+ has_list = bool(m.of_role(surface, "list", "listbox", "table", "grid"))
23
+ # A 'next' affordance is a link or a button labelled next/more.
24
+ nxt = m.first(surface, roles=("link", "button"), tokens=m.NEXT_TOKENS)
25
+ prev = m.first(surface, roles=("link", "button"), tokens=m.PREV_TOKENS)
26
+ if nxt is None and prev is None:
27
+ return None
28
+ # Pagination is strongest with a list AND a next control.
29
+ confidence = 0.8 if (has_list and nxt is not None) else 0.6
30
+ target = nxt or prev
31
+ assert target is not None
32
+ return RecognitionResult(
33
+ archetype=self.archetype,
34
+ confidence=confidence,
35
+ matched_handles=(target.handle,),
36
+ script=(
37
+ PatternStep(
38
+ op="click", role=target.role, label_hint=m.norm(target.label), note="paginate"
39
+ ),
40
+ ),
41
+ detail="paginated list",
42
+ )
43
+
44
+ def success_invariants(self, result: RecognitionResult) -> list[Invariant]:
45
+ # Done = a fresh list surface EVENTUALLY appears (a results/list affordance
46
+ # present by the deadline). Liveness, not THROUGHOUT.
47
+ return [surface_shows(self.archetype, "page_advanced", label="results", liveness=True)]
48
+
49
+ def failure_invariants(self, result: RecognitionResult) -> list[Invariant]:
50
+ # Failure CONTEXT = an error/empty-state banner appears (navigation broke).
51
+ # Safety shape: THROUGHOUT NOT contains(error) — fires when it lands.
52
+ return [surface_shows(self.archetype, "page_error", label="error", negate=True)]
zu_patterns/rail.py ADDED
@@ -0,0 +1,86 @@
1
+ """Pattern → rail helpers — success/failure criteria as declarable Invariants.
2
+
3
+ A pattern's predicted "done" state and its known failure modes are expressed as
4
+ ``zu_core.invariants.Invariant``s, which compile (via ``compile_spec``) to
5
+ Monitors the loop's ZU-RAIL-5 checkpoint runs. This is FULL reuse of the §1
6
+ machinery — no new monitor type. A breach yields ``MonitorVerdict(VIOLATION)`` →
7
+ the existing escalation path, which is the ZU-RAIL-9 guarantee: a recognized
8
+ pattern's prediction is VERIFIED, never trusted as ground truth.
9
+
10
+ The Monitor names are namespaced ``pattern.<archetype>.<criterion>`` so audits
11
+ read cleanly.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from zu_core import events as ev
17
+ from zu_core.invariants import Invariant, InvariantKind, Predicate, PredicateKind
18
+
19
+
20
+ def _name(archetype: str, criterion: str) -> str:
21
+ return f"pattern.{archetype}.{criterion}"
22
+
23
+
24
+ def surface_shows(
25
+ archetype: str,
26
+ criterion: str,
27
+ *,
28
+ label: str | None = None,
29
+ handle: str | None = None,
30
+ recognized_archetype: str | None = None,
31
+ event_type: str = ev.SURFACE_CAPTURED,
32
+ negate: bool = False,
33
+ liveness: bool = False,
34
+ deadline: str | None = None,
35
+ ) -> Invariant:
36
+ """An Invariant over a surface event — the seam a Pattern's success/failure
37
+ criterion compiles to (ZU-RAIL-9).
38
+
39
+ Exactly one of ``label`` / ``handle`` / ``recognized_archetype`` is the token
40
+ SURFACE_CONTAINS folds the event log for. ``negate=True`` asserts ABSENCE (the
41
+ natural shape for "the banner is gone").
42
+
43
+ Two semantics, chosen by ``liveness``:
44
+
45
+ * ``liveness=True`` — a SUCCESS / POSTCONDITION criterion: an
46
+ ``EVENTUALLY``-by-deadline property. The predicted post-state is, by
47
+ definition, ABSENT until the interaction completes, so it must NOT be a
48
+ violation that early/pre-interaction surfaces lack it. The Monitor stays
49
+ inert until the post-state appears (then satisfied forever) OR the
50
+ ``deadline`` event arrives without it (then, and only then, VIOLATION).
51
+ ``deadline`` is the deadline event TYPE; ``None`` ⇒ any terminal event
52
+ (``TASK_TERMINAL``/``TASK_COMPLETED``) marking the interaction/run complete.
53
+ For a non-negated success token we also require the token to ACTUALLY appear
54
+ (``require_present``) so "no surface ever showed it" correctly violates at
55
+ the deadline rather than passing vacuously.
56
+
57
+ * ``liveness=False`` (default) — a SAFETY criterion: ``THROUGHOUT``. The
58
+ correct shape for a FAILURE CONTEXT is ``negate=True`` ("throughout: NOT
59
+ contains(error-context)") so the Monitor fires the instant the failure
60
+ context appears, and the pre-interaction state (where the context is absent)
61
+ satisfies it. Do NOT model a failure as a positive must-contain-THROUGHOUT —
62
+ that wrongly fires on every normal surface lacking the token.
63
+ """
64
+ params: dict = {"event_type": event_type, "negate": negate}
65
+ if recognized_archetype is not None:
66
+ params["archetype"] = recognized_archetype
67
+ elif handle is not None:
68
+ params["handle"] = handle
69
+ elif label is not None:
70
+ params["label"] = label
71
+ if liveness:
72
+ # A non-negated liveness token must genuinely appear by the deadline;
73
+ # a negated one (a state that must become ABSENT) needs no evidence floor.
74
+ if not negate:
75
+ params["require_present"] = True
76
+ return Invariant(
77
+ name=_name(archetype, criterion),
78
+ kind=InvariantKind.EVENTUALLY,
79
+ predicate=Predicate(kind=PredicateKind.SURFACE_CONTAINS, params=params),
80
+ applies_to=deadline,
81
+ )
82
+ return Invariant(
83
+ name=_name(archetype, criterion),
84
+ kind=InvariantKind.THROUGHOUT,
85
+ predicate=Predicate(kind=PredicateKind.SURFACE_CONTAINS, params=params),
86
+ )