androidctl 0.1.0__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.
Files changed (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -0,0 +1,284 @@
1
+ """Internal ref repair decisions and diagnostic conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+ from androidctld.artifacts.models import ScreenArtifacts
10
+ from androidctld.artifacts.screen_lookup import lookup_source_screen_artifact
11
+ from androidctld.commands.results import screen_summary
12
+ from androidctld.errors import DaemonError, DaemonErrorCode
13
+ from androidctld.refs.models import RefBinding, RefRepairSourceSignature
14
+ from androidctld.refs.service import (
15
+ repair_source_signature_to_current_snapshot,
16
+ source_signature_from_artifact_payload,
17
+ source_signature_from_binding,
18
+ )
19
+ from androidctld.runtime.models import WorkspaceRuntime
20
+ from androidctld.runtime.screen_state import current_compiled_screen
21
+ from androidctld.schema.base import dump_api_model
22
+ from androidctld.semantics.public_models import PublicScreen
23
+
24
+
25
+ class RepairDecisionStatus(str, Enum):
26
+ RESOLVED = "resolved"
27
+ LIVE_REF_MISSING = "live_ref_missing"
28
+ SOURCE_UNAVAILABLE = "source_unavailable"
29
+ INVALID_ARTIFACT = "invalid_artifact"
30
+ REPAIR_FAILED = "repair_failed"
31
+
32
+
33
+ _DIAGNOSTIC_SOURCE_ARTIFACT_STATUSES = {
34
+ RepairDecisionStatus.SOURCE_UNAVAILABLE,
35
+ RepairDecisionStatus.INVALID_ARTIFACT,
36
+ RepairDecisionStatus.REPAIR_FAILED,
37
+ }
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class RepairDecision:
42
+ ref: str | None
43
+ source_screen_id: str | None
44
+ status: RepairDecisionStatus
45
+ binding: RefBinding | None = None
46
+ source_signature: RefRepairSourceSignature | None = None
47
+
48
+ @property
49
+ def is_resolved(self) -> bool:
50
+ return self.status == RepairDecisionStatus.RESOLVED
51
+
52
+ @property
53
+ def diagnostic_source_artifact_status(self) -> str | None:
54
+ if self.status not in _DIAGNOSTIC_SOURCE_ARTIFACT_STATUSES:
55
+ return None
56
+ return self.status.value
57
+
58
+
59
+ def resolved_repair_decision(
60
+ *,
61
+ ref: str,
62
+ source_screen_id: str,
63
+ binding: RefBinding,
64
+ source_signature: RefRepairSourceSignature | None = None,
65
+ ) -> RepairDecision:
66
+ return RepairDecision(
67
+ ref=ref,
68
+ source_screen_id=source_screen_id,
69
+ status=RepairDecisionStatus.RESOLVED,
70
+ binding=binding,
71
+ source_signature=source_signature,
72
+ )
73
+
74
+
75
+ def resolved_source_signature_decision(
76
+ *,
77
+ ref: str,
78
+ source_screen_id: str,
79
+ source_signature: RefRepairSourceSignature,
80
+ ) -> RepairDecision:
81
+ return RepairDecision(
82
+ ref=ref,
83
+ source_screen_id=source_screen_id,
84
+ status=RepairDecisionStatus.RESOLVED,
85
+ source_signature=source_signature,
86
+ )
87
+
88
+
89
+ def failed_repair_decision(
90
+ *,
91
+ ref: str | None,
92
+ source_screen_id: str | None,
93
+ ) -> RepairDecision:
94
+ return RepairDecision(
95
+ ref=ref,
96
+ source_screen_id=source_screen_id,
97
+ status=RepairDecisionStatus.REPAIR_FAILED,
98
+ )
99
+
100
+
101
+ def resolve_ref_decision(
102
+ session: WorkspaceRuntime,
103
+ ref: str,
104
+ source_screen_id: str,
105
+ ) -> RepairDecision:
106
+ source_decision = resolve_source_binding_decision(
107
+ session,
108
+ ref,
109
+ source_screen_id,
110
+ )
111
+ if not source_decision.is_resolved:
112
+ return source_decision
113
+ if source_screen_id == session.current_screen_id:
114
+ return source_decision
115
+ source_signature = source_decision.source_signature
116
+ if source_signature is None:
117
+ raise RuntimeError("resolved source binding is missing its signature")
118
+ return repair_source_signature_decision(
119
+ session,
120
+ source_signature,
121
+ source_screen_id=source_screen_id,
122
+ )
123
+
124
+
125
+ def resolve_source_binding_decision(
126
+ session: WorkspaceRuntime,
127
+ ref: str,
128
+ source_screen_id: str,
129
+ ) -> RepairDecision:
130
+ if source_screen_id == session.current_screen_id:
131
+ binding = session.ref_registry.get(ref)
132
+ if binding is None:
133
+ return RepairDecision(
134
+ ref=ref,
135
+ source_screen_id=source_screen_id,
136
+ status=RepairDecisionStatus.LIVE_REF_MISSING,
137
+ )
138
+ return resolved_repair_decision(
139
+ ref=ref,
140
+ source_screen_id=source_screen_id,
141
+ binding=binding,
142
+ source_signature=source_signature_from_binding(binding),
143
+ )
144
+
145
+ return load_source_artifact_binding_decision(session, ref, source_screen_id)
146
+
147
+
148
+ def load_source_artifact_binding_decision(
149
+ session: WorkspaceRuntime,
150
+ ref: str,
151
+ source_screen_id: str,
152
+ ) -> RepairDecision:
153
+ source_screen_lookup = lookup_source_screen_artifact(session, source_screen_id)
154
+ if source_screen_lookup.status == "not_found":
155
+ return RepairDecision(
156
+ ref=ref,
157
+ source_screen_id=source_screen_id,
158
+ status=RepairDecisionStatus.SOURCE_UNAVAILABLE,
159
+ )
160
+ if source_screen_lookup.status == "invalid_artifact":
161
+ return RepairDecision(
162
+ ref=ref,
163
+ source_screen_id=source_screen_id,
164
+ status=RepairDecisionStatus.INVALID_ARTIFACT,
165
+ )
166
+ payload = source_screen_lookup.payload
167
+ if payload is None or payload.screen_id != source_screen_id:
168
+ return RepairDecision(
169
+ ref=ref,
170
+ source_screen_id=source_screen_id,
171
+ status=RepairDecisionStatus.INVALID_ARTIFACT,
172
+ )
173
+ binding_payload = payload.repair_bindings.get(ref)
174
+ if binding_payload is None:
175
+ return RepairDecision(
176
+ ref=ref,
177
+ source_screen_id=source_screen_id,
178
+ status=RepairDecisionStatus.INVALID_ARTIFACT,
179
+ )
180
+ return resolved_source_signature_decision(
181
+ ref=ref,
182
+ source_screen_id=source_screen_id,
183
+ source_signature=source_signature_from_artifact_payload(ref, binding_payload),
184
+ )
185
+
186
+
187
+ def repair_source_signature_decision(
188
+ session: WorkspaceRuntime,
189
+ source_signature: RefRepairSourceSignature,
190
+ *,
191
+ source_screen_id: str,
192
+ ) -> RepairDecision:
193
+ compiled_screen = current_compiled_screen(session)
194
+ if compiled_screen is None or session.latest_snapshot is None:
195
+ return failed_repair_decision(
196
+ ref=source_signature.ref,
197
+ source_screen_id=source_screen_id,
198
+ )
199
+ repaired_binding = repair_source_signature_to_current_snapshot(
200
+ source_signature,
201
+ compiled_screen=compiled_screen,
202
+ snapshot_id=session.latest_snapshot.snapshot_id,
203
+ )
204
+ if repaired_binding is None:
205
+ return failed_repair_decision(
206
+ ref=source_signature.ref,
207
+ source_screen_id=source_screen_id,
208
+ )
209
+ return resolved_repair_decision(
210
+ ref=source_signature.ref,
211
+ source_screen_id=source_screen_id,
212
+ binding=repaired_binding,
213
+ )
214
+
215
+
216
+ def ref_repair_error(
217
+ decision: RepairDecision,
218
+ public_screen: PublicScreen | None = None,
219
+ artifacts: ScreenArtifacts | None = None,
220
+ ) -> DaemonError:
221
+ if decision.status == RepairDecisionStatus.LIVE_REF_MISSING:
222
+ return DaemonError(
223
+ code=DaemonErrorCode.REF_RESOLUTION_FAILED,
224
+ message="ref does not exist on the current screen",
225
+ retryable=False,
226
+ details={"ref": decision.ref},
227
+ http_status=200,
228
+ )
229
+ return ref_stale_error(
230
+ decision.ref,
231
+ public_screen=public_screen,
232
+ artifacts=artifacts,
233
+ source_screen_id=decision.source_screen_id,
234
+ source_artifact_status=decision.diagnostic_source_artifact_status,
235
+ )
236
+
237
+
238
+ def ref_stale_error(
239
+ ref: str | None,
240
+ public_screen: PublicScreen | None = None,
241
+ artifacts: ScreenArtifacts | None = None,
242
+ *,
243
+ source_screen_id: str | None = None,
244
+ source_artifact_status: str | RepairDecisionStatus | None = None,
245
+ ) -> DaemonError:
246
+ artifact_payload = artifacts or ScreenArtifacts(screen_json=None)
247
+ normalized_status = _normalize_source_artifact_status(source_artifact_status)
248
+ details: dict[str, Any] = {
249
+ "ref": ref,
250
+ "screen": (
251
+ screen_summary(
252
+ public_screen,
253
+ artifact_payload,
254
+ )
255
+ if public_screen is not None
256
+ else None
257
+ ),
258
+ "artifacts": dump_api_model(artifact_payload),
259
+ }
260
+ if source_screen_id is not None:
261
+ details["sourceScreenId"] = source_screen_id
262
+ if normalized_status is not None:
263
+ details["sourceArtifactStatus"] = normalized_status
264
+ return DaemonError(
265
+ code=DaemonErrorCode.REF_STALE,
266
+ message="ref could not be repaired",
267
+ retryable=normalized_status in (None, RepairDecisionStatus.REPAIR_FAILED.value),
268
+ details=details,
269
+ http_status=200,
270
+ )
271
+
272
+
273
+ def _normalize_source_artifact_status(
274
+ source_artifact_status: str | RepairDecisionStatus | None,
275
+ ) -> str | None:
276
+ if source_artifact_status is None:
277
+ return None
278
+ if isinstance(source_artifact_status, RepairDecisionStatus):
279
+ return source_artifact_status.value
280
+ if source_artifact_status in {
281
+ status.value for status in _DIAGNOSTIC_SOURCE_ARTIFACT_STATUSES
282
+ }:
283
+ return source_artifact_status
284
+ raise ValueError(f"unknown sourceArtifactStatus: {source_artifact_status}")
@@ -0,0 +1,422 @@
1
+ """Ref registry reconciliation and repair helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Sequence
7
+ from copy import deepcopy
8
+ from dataclasses import dataclass
9
+
10
+ from androidctld.artifacts.screen_payloads import RepairBindingPayload
11
+ from androidctld.refs.models import (
12
+ NodeHandle,
13
+ RefBinding,
14
+ RefFingerprint,
15
+ RefRegistry,
16
+ RefRepairSourceSignature,
17
+ SemanticProfile,
18
+ )
19
+ from androidctld.runtime_policy import NON_NUMERIC_REF_SORT_BUCKET
20
+ from androidctld.semantics.compiler import CompiledScreen, SemanticNode
21
+ from androidctld.text_equivalence import canonical_text_key
22
+
23
+ HIGH_CONFIDENCE_BUCKET = 2
24
+ MEDIUM_CONFIDENCE_BUCKET = 1
25
+ STRONG_BOUNDS_DISTANCE = 8
26
+ GAP_THRESHOLD_ANCHORED = 2
27
+ GAP_THRESHOLD_CONTEXTUAL = 3
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RepairEvidence:
32
+ label_match: bool
33
+ resource_match: bool
34
+ class_match: bool
35
+ parent_match: bool
36
+ sibling_overlap: int
37
+ state_overlap: int
38
+ actions_overlap: int
39
+ bounds_distance: int
40
+
41
+ @property
42
+ def identity_anchor_count(self) -> int:
43
+ return int(self.label_match) + int(self.resource_match)
44
+
45
+ @property
46
+ def semantic_signal_count(self) -> int:
47
+ return (
48
+ int(self.sibling_overlap > 0)
49
+ + int(self.state_overlap > 0)
50
+ + int(self.actions_overlap > 0)
51
+ + int(self.strong_bounds_match)
52
+ )
53
+
54
+ @property
55
+ def corroboration_count(self) -> int:
56
+ return (
57
+ int(self.class_match)
58
+ + int(self.parent_match)
59
+ + int(self.sibling_overlap > 0)
60
+ + int(self.state_overlap > 0)
61
+ + int(self.actions_overlap > 0)
62
+ + int(self.strong_bounds_match)
63
+ )
64
+
65
+ @property
66
+ def strong_bounds_match(self) -> bool:
67
+ return self.bounds_distance <= STRONG_BOUNDS_DISTANCE
68
+
69
+ @property
70
+ def contextual_anchor(self) -> bool:
71
+ return self.class_match and self.parent_match and self.semantic_signal_count > 0
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class RepairConfidence:
76
+ bucket: int
77
+ score: int
78
+ evidence: RepairEvidence
79
+
80
+ @property
81
+ def sort_key(self) -> tuple[int, int, int, int, int, int, int, int]:
82
+ return (
83
+ self.bucket,
84
+ self.score,
85
+ self.evidence.identity_anchor_count,
86
+ self.evidence.corroboration_count,
87
+ self.evidence.sibling_overlap,
88
+ self.evidence.state_overlap,
89
+ self.evidence.actions_overlap,
90
+ -self.evidence.bounds_distance,
91
+ )
92
+
93
+ @property
94
+ def is_high_confidence(self) -> bool:
95
+ return self.bucket >= HIGH_CONFIDENCE_BUCKET
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class RefReconcileResult:
100
+ registry: RefRegistry
101
+ compiled_screen: CompiledScreen
102
+
103
+
104
+ class RefRegistryBuilder:
105
+ def finalize_compiled_screen(
106
+ self,
107
+ *,
108
+ compiled_screen: CompiledScreen,
109
+ snapshot_id: int,
110
+ previous_registry: RefRegistry | None,
111
+ ) -> RefReconcileResult:
112
+ finalized_compiled_screen = deepcopy(compiled_screen)
113
+ registry = self.reconcile(
114
+ compiled_screen=finalized_compiled_screen,
115
+ snapshot_id=snapshot_id,
116
+ previous_registry=previous_registry,
117
+ )
118
+ return RefReconcileResult(
119
+ registry=registry,
120
+ compiled_screen=finalized_compiled_screen,
121
+ )
122
+
123
+ def reconcile(
124
+ self,
125
+ compiled_screen: CompiledScreen,
126
+ snapshot_id: int,
127
+ previous_registry: RefRegistry | None,
128
+ ) -> RefRegistry:
129
+ clear_candidate_refs(compiled_screen)
130
+ registry = RefRegistry()
131
+ candidates = list(compiled_screen.ref_candidates())
132
+ remaining_candidates = list(candidates)
133
+ used_refs = set()
134
+
135
+ if previous_registry is not None:
136
+ for binding in sorted(
137
+ previous_registry.bindings.values(),
138
+ key=lambda item: ref_sort_key(item.ref),
139
+ ):
140
+ match = best_candidate_for_binding(binding, remaining_candidates)
141
+ if match is None:
142
+ continue
143
+ candidate, _ = match
144
+ candidate.ref = binding.ref
145
+ used_refs.add(binding.ref)
146
+ registry.bindings[binding.ref] = binding_for_candidate(
147
+ ref=binding.ref,
148
+ candidate=candidate,
149
+ snapshot_id=snapshot_id,
150
+ reused=True,
151
+ )
152
+ remaining_candidates.remove(candidate)
153
+
154
+ next_index = 1
155
+ for candidate in candidates:
156
+ if candidate.ref:
157
+ continue
158
+ while f"n{next_index}" in used_refs:
159
+ next_index += 1
160
+ ref = f"n{next_index}"
161
+ candidate.ref = ref
162
+ used_refs.add(ref)
163
+ registry.bindings[ref] = binding_for_candidate(
164
+ ref=ref,
165
+ candidate=candidate,
166
+ snapshot_id=snapshot_id,
167
+ reused=False,
168
+ )
169
+ return registry
170
+
171
+
172
+ def clear_candidate_refs(compiled_screen: CompiledScreen) -> None:
173
+ for candidate in compiled_screen.ref_candidates():
174
+ candidate.ref = ""
175
+
176
+
177
+ def binding_for_candidate(
178
+ ref: str,
179
+ candidate: SemanticNode,
180
+ snapshot_id: int,
181
+ reused: bool,
182
+ ) -> RefBinding:
183
+ return RefBinding(
184
+ ref=ref,
185
+ handle=NodeHandle(
186
+ snapshot_id=snapshot_id,
187
+ rid=candidate.raw_rid,
188
+ ),
189
+ fingerprint=fingerprint_for_candidate(candidate),
190
+ semantic_profile=SemanticProfile(
191
+ state=tuple(candidate.state),
192
+ actions=tuple(candidate.actions),
193
+ ),
194
+ reused=reused,
195
+ )
196
+
197
+
198
+ def fingerprint_for_candidate(candidate: SemanticNode) -> RefFingerprint:
199
+ return RefFingerprint(
200
+ role=candidate.role,
201
+ normalized_label=canonical_text_key(candidate.label),
202
+ resource_id=canonical_text_key(candidate.resource_id),
203
+ class_name=canonical_text_key(candidate.class_name),
204
+ parent_role=canonical_text_key(candidate.parent_role),
205
+ parent_label=canonical_text_key(candidate.parent_label),
206
+ sibling_labels=tuple(
207
+ canonical_text_key(label)
208
+ for label in candidate.sibling_labels
209
+ if canonical_text_key(label)
210
+ ),
211
+ relative_bounds=candidate.relative_bounds,
212
+ )
213
+
214
+
215
+ def best_candidate_for_binding(
216
+ binding: RefBinding, candidates: Sequence[SemanticNode]
217
+ ) -> tuple[SemanticNode, RepairConfidence] | None:
218
+ return best_candidate_for_source_signature(
219
+ source_signature_from_binding(binding),
220
+ candidates,
221
+ )
222
+
223
+
224
+ def best_candidate_for_source_signature(
225
+ source: RefRepairSourceSignature,
226
+ candidates: Sequence[SemanticNode],
227
+ ) -> tuple[SemanticNode, RepairConfidence] | None:
228
+ scored: list[tuple[RepairConfidence, str, SemanticNode]] = []
229
+ for candidate in candidates:
230
+ confidence = candidate_match_confidence(source, candidate)
231
+ if confidence is None:
232
+ continue
233
+ scored.append((confidence, candidate.raw_rid, candidate))
234
+ if not scored:
235
+ return None
236
+ scored.sort(key=lambda item: (item[0].sort_key, item[1]), reverse=True)
237
+ best_confidence, _, best_candidate = scored[0]
238
+ if not best_confidence.is_high_confidence:
239
+ return None
240
+ if len(scored) > 1 and repair_gap_too_small(best_confidence, scored[1][0]):
241
+ return None
242
+ return best_candidate, best_confidence
243
+
244
+
245
+ def candidate_match_confidence(
246
+ source: RefRepairSourceSignature, candidate: SemanticNode
247
+ ) -> RepairConfidence | None:
248
+ fingerprint = source.fingerprint
249
+ candidate_fingerprint = fingerprint_for_candidate(candidate)
250
+ if candidate_fingerprint.role != fingerprint.role:
251
+ return None
252
+
253
+ label_match = (
254
+ candidate_fingerprint.normalized_label == fingerprint.normalized_label
255
+ and bool(fingerprint.normalized_label)
256
+ )
257
+ resource_match = (
258
+ candidate_fingerprint.resource_id == fingerprint.resource_id
259
+ and bool(fingerprint.resource_id)
260
+ )
261
+ class_match = candidate_fingerprint.class_name == fingerprint.class_name and bool(
262
+ fingerprint.class_name
263
+ )
264
+ parent_match = (
265
+ candidate_fingerprint.parent_role == fingerprint.parent_role
266
+ and candidate_fingerprint.parent_label == fingerprint.parent_label
267
+ and bool(fingerprint.parent_role or fingerprint.parent_label)
268
+ )
269
+ sibling_overlap = len(
270
+ set(candidate_fingerprint.sibling_labels).intersection(
271
+ fingerprint.sibling_labels
272
+ )
273
+ )
274
+ bounds_distance = bounds_distance_score(
275
+ candidate_fingerprint.relative_bounds, fingerprint.relative_bounds
276
+ )
277
+ state_overlap = set_overlap(
278
+ source.state,
279
+ candidate.state,
280
+ )
281
+ actions_overlap = set_overlap(
282
+ source.actions,
283
+ candidate.actions,
284
+ )
285
+ evidence = RepairEvidence(
286
+ label_match=label_match,
287
+ resource_match=resource_match,
288
+ class_match=class_match,
289
+ parent_match=parent_match,
290
+ sibling_overlap=sibling_overlap,
291
+ state_overlap=state_overlap,
292
+ actions_overlap=actions_overlap,
293
+ bounds_distance=bounds_distance,
294
+ )
295
+ if not (evidence.identity_anchor_count or evidence.contextual_anchor):
296
+ return None
297
+
298
+ bucket = confidence_bucket(evidence)
299
+ if bucket is None:
300
+ return None
301
+ return RepairConfidence(
302
+ bucket=bucket,
303
+ score=confidence_score(evidence),
304
+ evidence=evidence,
305
+ )
306
+
307
+
308
+ def source_signature_from_binding(binding: RefBinding) -> RefRepairSourceSignature:
309
+ return RefRepairSourceSignature(
310
+ ref=binding.ref,
311
+ fingerprint=binding.fingerprint,
312
+ state=binding.semantic_profile.state,
313
+ actions=binding.semantic_profile.actions,
314
+ )
315
+
316
+
317
+ def source_signature_from_artifact_payload(
318
+ ref: str,
319
+ payload: RepairBindingPayload,
320
+ ) -> RefRepairSourceSignature:
321
+ return RefRepairSourceSignature(
322
+ ref=ref,
323
+ fingerprint=RefFingerprint(
324
+ role=payload.fingerprint.role,
325
+ normalized_label=payload.fingerprint.normalized_label,
326
+ resource_id=payload.fingerprint.resource_id,
327
+ class_name=payload.fingerprint.class_name,
328
+ parent_role=payload.fingerprint.parent_role,
329
+ parent_label=payload.fingerprint.parent_label,
330
+ sibling_labels=payload.fingerprint.sibling_labels,
331
+ relative_bounds=payload.fingerprint.relative_bounds,
332
+ ),
333
+ state=payload.semantic_profile.state,
334
+ actions=payload.semantic_profile.actions,
335
+ )
336
+
337
+
338
+ def repair_source_signature_to_current_snapshot(
339
+ source: RefRepairSourceSignature,
340
+ *,
341
+ compiled_screen: CompiledScreen,
342
+ snapshot_id: int,
343
+ ) -> RefBinding | None:
344
+ match = best_candidate_for_source_signature(
345
+ source,
346
+ compiled_screen.ref_candidates(),
347
+ )
348
+ if match is None:
349
+ return None
350
+ candidate, _ = match
351
+ return binding_for_candidate(
352
+ ref=source.ref,
353
+ candidate=candidate,
354
+ snapshot_id=snapshot_id,
355
+ reused=True,
356
+ )
357
+
358
+
359
+ def bounds_distance_score(
360
+ left: tuple[int, int, int, int], right: tuple[int, int, int, int]
361
+ ) -> int:
362
+ return sum(abs(a - b) for a, b in zip(left, right, strict=True))
363
+
364
+
365
+ def set_overlap(left: Sequence[str], right: Sequence[str]) -> int:
366
+ normalized_left = {
367
+ canonical_text_key(value) for value in left if canonical_text_key(value)
368
+ }
369
+ normalized_right = {
370
+ canonical_text_key(value) for value in right if canonical_text_key(value)
371
+ }
372
+ return len(normalized_left.intersection(normalized_right))
373
+
374
+
375
+ def confidence_bucket(evidence: RepairEvidence) -> int | None:
376
+ if evidence.identity_anchor_count >= 2:
377
+ return HIGH_CONFIDENCE_BUCKET
378
+ if evidence.identity_anchor_count == 1:
379
+ if evidence.semantic_signal_count >= 1 or evidence.corroboration_count >= 2:
380
+ return HIGH_CONFIDENCE_BUCKET
381
+ return MEDIUM_CONFIDENCE_BUCKET
382
+ if evidence.contextual_anchor:
383
+ if evidence.semantic_signal_count >= 2 and evidence.corroboration_count >= 4:
384
+ return HIGH_CONFIDENCE_BUCKET
385
+ return MEDIUM_CONFIDENCE_BUCKET
386
+ return None
387
+
388
+
389
+ def confidence_score(evidence: RepairEvidence) -> int:
390
+ return (
391
+ evidence.identity_anchor_count * 8
392
+ + int(evidence.class_match) * 3
393
+ + int(evidence.parent_match) * 3
394
+ + min(evidence.sibling_overlap, 2) * 2
395
+ + min(evidence.state_overlap, 2) * 2
396
+ + min(evidence.actions_overlap, 2) * 2
397
+ + int(evidence.strong_bounds_match) * 2
398
+ )
399
+
400
+
401
+ def repair_gap_too_small(
402
+ best_confidence: RepairConfidence,
403
+ runner_up: RepairConfidence,
404
+ ) -> bool:
405
+ if runner_up.bucket != best_confidence.bucket:
406
+ return False
407
+ gap_threshold = (
408
+ GAP_THRESHOLD_ANCHORED
409
+ if best_confidence.evidence.identity_anchor_count > 0
410
+ else GAP_THRESHOLD_CONTEXTUAL
411
+ )
412
+ return (best_confidence.score - runner_up.score) < gap_threshold
413
+
414
+
415
+ _REF_RE = re.compile(r"^n(\d+)$")
416
+
417
+
418
+ def ref_sort_key(ref: str) -> tuple[int, str]:
419
+ match = _REF_RE.match(ref)
420
+ if match is None:
421
+ return (NON_NUMERIC_REF_SORT_BUCKET, ref)
422
+ return (int(match.group(1)), ref)
@@ -0,0 +1 @@
1
+ """Daemon-owned public rendering helpers."""