T07: agentic realization over a stdlib browser surface
Two decisions taken with the operator: stdlib HTML driver instead of Playwright (F-0004), and a deterministic discovery runtime instead of a live model. Both sit behind interfaces so the alternatives drop in later. - html.py: stdlib DOM parse and query - agentic.py: DiscoveryRuntime (agentic arm, ignores data-td by construction) and RecordedSelectorRuntime (control arm, uses the strongest identifier the page offers) - browser.py: per-actor sessions over real HTTP, constructed per call so no actor inherits another's connection state - cost/nondeterminism metrics recorded from the first run F-0005 (CONCEPT_DRIFT): the H-001 result is a narrowing. Where test ids are preserved, discovery 9/9 and recorded selectors 9/9 - the semantic action buys nothing. Where they are dropped, discovery 2/3 and recorded 0/3. The concept model presents semantic actions as generally superior; the evidence says conditionally superior. M21 and M22 added mid-task: the deciding side of the axis was N=1. M22 (field names renamed) defeats the heuristic and is the first concrete evidence that a live model would add capability, not just cost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1629012@bnt-lap001 Assistant-Session: 78d4fb13-8a1e-474b-87a3-9b9261c49a39
This commit is contained in:
parent
925ff2dd91
commit
44faf3de8e
23 changed files with 1008 additions and 9 deletions
BIN
src/testdriver/__pycache__/agentic.cpython-312.pyc
Normal file
BIN
src/testdriver/__pycache__/agentic.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/testdriver/__pycache__/browser.cpython-312.pyc
Normal file
BIN
src/testdriver/__pycache__/browser.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/testdriver/__pycache__/html.cpython-312.pyc
Normal file
BIN
src/testdriver/__pycache__/html.cpython-312.pyc
Normal file
Binary file not shown.
244
src/testdriver/agentic.py
Normal file
244
src/testdriver/agentic.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""Actor runtimes: how a semantic action becomes a concrete interaction.
|
||||
|
||||
A runtime is handed a *goal* and a *document* and must work out the path itself.
|
||||
It is never handed a selector — a runtime given a selector has discovered
|
||||
nothing and cannot demonstrate adaptation.
|
||||
|
||||
Two runtimes live here, and the contrast between them is the H-001 experiment:
|
||||
|
||||
* `DiscoveryRuntime` — the agentic arm. Scores candidate controls on semantic
|
||||
signals (visible text, field names, form target) and **deliberately ignores
|
||||
`data-td` test ids**. If it were allowed to use them it would be a recorded
|
||||
selector wearing a different hat.
|
||||
* `RecordedSelectorRuntime` — the control arm. Captures stable test-id selectors
|
||||
against the baseline and replays them, which is the most robust form of the
|
||||
conventional approach. It is a real control, not a straw man: where test ids
|
||||
survive, it should win.
|
||||
|
||||
Cost and nondeterminism are recorded from the first run. `DiscoveryRuntime` is
|
||||
deterministic and free, so its token fields are zero — but the fields exist and
|
||||
are populated from run one, because a live model runtime fills exactly the same
|
||||
shape and the comparison is impossible to reconstruct after the fact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .html import Document, Element
|
||||
|
||||
# Vocabulary linking a semantic action to how a surface might name it. This is
|
||||
# knowledge about *intent*, not about any particular implementation — which is
|
||||
# why it lives with the runtime and survives the surface changing.
|
||||
SYNONYMS: dict[str, tuple[str, ...]] = {
|
||||
"grant_access": ("share", "give access", "grant", "invite", "add person"),
|
||||
"revoke_access": ("revoke", "withdraw access", "remove access", "unshare"),
|
||||
}
|
||||
|
||||
REQUIRED_FIELDS: dict[str, tuple[str, ...]] = {
|
||||
"grant_access": ("subject_id", "permission"),
|
||||
"revoke_access": ("subject_id",),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RealizationMetrics:
|
||||
"""The economics of one realization attempt.
|
||||
|
||||
T4 in the assessment is an existential question — if agentic realization
|
||||
costs more than asking an agent to rewrite the broken test, crystallization
|
||||
is an aesthetic preference rather than a value proposition. These fields are
|
||||
free to collect from run one and impossible to backfill.
|
||||
"""
|
||||
|
||||
runtime: str
|
||||
wall_time_ms: float = 0.0
|
||||
candidates_considered: int = 0
|
||||
attempts: int = 0
|
||||
retries: int = 0
|
||||
tokens_in: int = 0
|
||||
tokens_out: int = 0
|
||||
model: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"runtime": self.runtime,
|
||||
"wall_time_ms": round(self.wall_time_ms, 3),
|
||||
"candidates_considered": self.candidates_considered,
|
||||
"attempts": self.attempts,
|
||||
"retries": self.retries,
|
||||
"tokens_in": self.tokens_in,
|
||||
"tokens_out": self.tokens_out,
|
||||
"model": self.model,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Plan:
|
||||
"""A concrete interaction the driver can execute."""
|
||||
|
||||
method: str
|
||||
action: str
|
||||
fields: dict[str, str]
|
||||
rationale: str
|
||||
element_path: str
|
||||
metrics: RealizationMetrics
|
||||
|
||||
|
||||
class RealizationFailed(Exception):
|
||||
"""The runtime could not find a way to accomplish the goal on this surface."""
|
||||
|
||||
|
||||
class ActorRuntime(Protocol):
|
||||
name: str
|
||||
|
||||
def plan(self, document: Document, action_name: str, args: dict) -> Plan: ...
|
||||
|
||||
|
||||
# --- the agentic arm ------------------------------------------------------
|
||||
|
||||
|
||||
def _visible_text(form: Element) -> str:
|
||||
return form.full_text.lower()
|
||||
|
||||
|
||||
def _field_names(form: Element) -> set[str]:
|
||||
return {
|
||||
element.attrs.get("name", "")
|
||||
for element in form.walk()
|
||||
if element.tag in ("input", "select", "textarea")
|
||||
} - {""}
|
||||
|
||||
|
||||
class DiscoveryRuntime:
|
||||
"""Finds a control by what it means, not where it is.
|
||||
|
||||
Scores every form on three independent signals so that no single surface
|
||||
change is fatal:
|
||||
|
||||
1. the visible wording matches a synonym of the goal;
|
||||
2. the form collects the fields the goal needs;
|
||||
3. the form's target names the operation.
|
||||
|
||||
A mutation typically disturbs one signal. Requiring agreement across three is
|
||||
what lets the same semantic action survive a control moving into a modal, a
|
||||
DOM rewrite, or a rewording — while still failing loudly when the control is
|
||||
genuinely gone, which is what keeps a removed authorization check from
|
||||
reading as a recovery.
|
||||
"""
|
||||
|
||||
name = "discovery-runtime"
|
||||
|
||||
def plan(self, document: Document, action_name: str, args: dict) -> Plan:
|
||||
started = time.perf_counter()
|
||||
synonyms = SYNONYMS.get(action_name, (action_name.replace("_", " "),))
|
||||
needed = set(REQUIRED_FIELDS.get(action_name, ()))
|
||||
|
||||
scored: list[tuple[int, str, Element]] = []
|
||||
forms = document.forms()
|
||||
for form in forms:
|
||||
text = _visible_text(form)
|
||||
names = _field_names(form)
|
||||
target = form.attrs.get("action", "")
|
||||
|
||||
reasons: list[str] = []
|
||||
score = 0
|
||||
if any(word in text for word in synonyms):
|
||||
score += 3
|
||||
reasons.append("wording matches the goal")
|
||||
if needed and needed <= names:
|
||||
score += 3
|
||||
reasons.append("collects the required fields")
|
||||
verb = action_name.split("_")[0]
|
||||
if verb in target.lower():
|
||||
score += 2
|
||||
reasons.append("target names the operation")
|
||||
if score:
|
||||
scored.append((score, "; ".join(reasons), form))
|
||||
|
||||
metrics = RealizationMetrics(
|
||||
runtime=self.name,
|
||||
candidates_considered=len(forms),
|
||||
attempts=1,
|
||||
)
|
||||
if not scored:
|
||||
metrics.wall_time_ms = (time.perf_counter() - started) * 1000
|
||||
raise RealizationFailed(
|
||||
f"no control on this surface affords {action_name!r} "
|
||||
f"(considered {len(forms)} candidates)"
|
||||
)
|
||||
|
||||
scored.sort(key=lambda row: row[0], reverse=True)
|
||||
score, rationale, form = scored[0]
|
||||
|
||||
fields = {
|
||||
name: str(args[name])
|
||||
for name in _field_names(form)
|
||||
if name in args
|
||||
}
|
||||
missing = needed - set(fields)
|
||||
if missing:
|
||||
metrics.wall_time_ms = (time.perf_counter() - started) * 1000
|
||||
raise RealizationFailed(
|
||||
f"control for {action_name!r} does not accept {sorted(missing)}"
|
||||
)
|
||||
|
||||
metrics.wall_time_ms = (time.perf_counter() - started) * 1000
|
||||
return Plan(
|
||||
method=form.attrs.get("method", "post").upper(),
|
||||
action=form.attrs.get("action", ""),
|
||||
fields=fields,
|
||||
rationale=f"score {score}: {rationale}",
|
||||
element_path=form.path(),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
# --- the control arm ------------------------------------------------------
|
||||
|
||||
|
||||
class RecordedSelectorRuntime:
|
||||
"""Replays test-id selectors captured against a baseline surface.
|
||||
|
||||
The conventional approach at its strongest: stable `data-td` attributes are
|
||||
what a well-instrumented application provides and what good practice says to
|
||||
use. Where a mutation preserves them this runtime is unbeatable; where a
|
||||
rewrite drops them it has nothing left. That asymmetry is precisely what
|
||||
H-001 is asking about.
|
||||
"""
|
||||
|
||||
name = "recorded-selector-runtime"
|
||||
|
||||
def __init__(self, recordings: dict[str, str]) -> None:
|
||||
self._recordings = recordings # action_name -> data-td value of the form
|
||||
|
||||
def plan(self, document: Document, action_name: str, args: dict) -> Plan:
|
||||
started = time.perf_counter()
|
||||
recorded = self._recordings.get(action_name)
|
||||
metrics = RealizationMetrics(runtime=self.name, attempts=1)
|
||||
|
||||
candidates = [
|
||||
form for form in document.forms()
|
||||
if form.attrs.get("data-td") == recorded
|
||||
]
|
||||
metrics.candidates_considered = len(document.forms())
|
||||
metrics.wall_time_ms = (time.perf_counter() - started) * 1000
|
||||
|
||||
if recorded is None or not candidates:
|
||||
raise RealizationFailed(
|
||||
f"recorded selector [data-td={recorded!r}] matched nothing"
|
||||
)
|
||||
form = candidates[0]
|
||||
fields = {
|
||||
name: str(args[name]) for name in _field_names(form) if name in args
|
||||
}
|
||||
return Plan(
|
||||
method=form.attrs.get("method", "post").upper(),
|
||||
action=form.attrs.get("action", ""),
|
||||
fields=fields,
|
||||
rationale=f"replayed recorded selector [data-td={recorded}]",
|
||||
element_path=form.path(),
|
||||
metrics=metrics,
|
||||
)
|
||||
156
src/testdriver/browser.py
Normal file
156
src/testdriver/browser.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""A browser-surface driver over the lab's HTML UI.
|
||||
|
||||
Each actor gets its **own** session — its own base URL binding, its own
|
||||
credentials, its own cookie-equivalent. Nothing is shared between actors at
|
||||
module or class level, because a shared client is exactly how isolation breaks in
|
||||
practice and it never announces itself (F-0003).
|
||||
|
||||
The driver executes a `Plan` produced by an `ActorRuntime`. It does not decide
|
||||
what to click and it does not decide whether the outcome was correct: the first
|
||||
belongs to the runtime, the second to the observer and the oracle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .actions import SemanticAction, Surface
|
||||
from .agentic import ActorRuntime, RealizationFailed
|
||||
from .drivers import Realization, UnsupportedAction
|
||||
from .html import Document
|
||||
from .world import Actor
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Session:
|
||||
"""One actor's private connection to the surface. Never shared."""
|
||||
|
||||
base_url: str
|
||||
token: str
|
||||
|
||||
def _open(self, method: str, path: str, body: bytes | None, content_type: str):
|
||||
request = urllib.request.Request(
|
||||
urllib.parse.urljoin(self.base_url, path),
|
||||
data=body,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
**({"Content-Type": content_type} if body else {}),
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
return response.status, response.read().decode()
|
||||
except urllib.error.HTTPError as error:
|
||||
return error.code, error.read().decode()
|
||||
|
||||
def get(self, path: str) -> tuple[int, str]:
|
||||
return self._open("GET", path, None, "")
|
||||
|
||||
def post_form(self, path: str, fields: dict[str, str]) -> tuple[int, str]:
|
||||
encoded = urllib.parse.urlencode(fields).encode()
|
||||
return self._open("POST", path, encoded, "application/x-www-form-urlencoded")
|
||||
|
||||
def post_json(self, path: str, payload: dict[str, Any]) -> tuple[int, str]:
|
||||
return self._open("POST", path, json.dumps(payload).encode(), "application/json")
|
||||
|
||||
|
||||
class BrowserDriver:
|
||||
"""Realizes semantic actions by finding and using controls on an HTML page."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
tokens: dict[str, str],
|
||||
runtime: ActorRuntime,
|
||||
resource_id: str,
|
||||
) -> None:
|
||||
self._base_url = base_url
|
||||
self._tokens = tokens
|
||||
self._runtime = runtime
|
||||
self._resource_id = resource_id
|
||||
self.surface = Surface(
|
||||
id="browser", kind="html", description="lab browser UI"
|
||||
)
|
||||
|
||||
def _session_for(self, actor: Actor) -> Session:
|
||||
# Constructed per call: no actor can inherit another's connection state.
|
||||
return Session(self._base_url, self._tokens[actor.id])
|
||||
|
||||
def _view_path(self) -> str:
|
||||
return f"/resources/{self._resource_id}/view"
|
||||
|
||||
def realize(self, actor: Actor, action: SemanticAction) -> Realization:
|
||||
action.check_surface(self.surface.id)
|
||||
session = self._session_for(actor)
|
||||
|
||||
status, body = session.get(self._view_path())
|
||||
if status != 200:
|
||||
# A surface the actor cannot even load is not an adaptation problem.
|
||||
return Realization(
|
||||
self.surface.id,
|
||||
{"action": action.name, "stage": "navigate", "status": status},
|
||||
raised=f"NavigationFailed: {status}",
|
||||
)
|
||||
|
||||
document = Document.parse(body)
|
||||
args = {"resource_id": self._resource_id, **dict(action.args)}
|
||||
|
||||
try:
|
||||
plan = self._runtime.plan(document, action.name, args)
|
||||
except RealizationFailed as failure:
|
||||
return Realization(
|
||||
self.surface.id,
|
||||
{
|
||||
"action": action.name,
|
||||
"stage": "discovery",
|
||||
"runtime": getattr(self._runtime, "name", "?"),
|
||||
"page_bytes": len(body),
|
||||
},
|
||||
raised=f"RealizationFailed: {failure}",
|
||||
)
|
||||
|
||||
mechanics: dict[str, Any] = {
|
||||
"action": action.describe(),
|
||||
"stage": "submit",
|
||||
"runtime": plan.metrics.runtime,
|
||||
"rationale": plan.rationale,
|
||||
"element_path": plan.element_path,
|
||||
"target": plan.action,
|
||||
"fields": sorted(plan.fields),
|
||||
"metrics": plan.metrics.as_dict(),
|
||||
"actor": actor.id,
|
||||
}
|
||||
|
||||
status, response = session.post_form(plan.action, plan.fields)
|
||||
mechanics["status"] = status
|
||||
if status >= 400:
|
||||
return Realization(
|
||||
self.surface.id, mechanics, raised=f"Refused: {status} {response[:120]}"
|
||||
)
|
||||
return Realization(self.surface.id, mechanics)
|
||||
|
||||
|
||||
def record_baseline_selectors(html: str) -> dict[str, str]:
|
||||
"""Capture the control arm's selectors from a baseline page.
|
||||
|
||||
Deliberately generous: it takes the strongest identifier the page offers.
|
||||
A weak control arm would make H-001 trivially true and worthless.
|
||||
"""
|
||||
document = Document.parse(html)
|
||||
recordings: dict[str, str] = {}
|
||||
for form in document.forms():
|
||||
test_id = form.attrs.get("data-td", "")
|
||||
target = form.attrs.get("action", "")
|
||||
if not test_id:
|
||||
continue
|
||||
if target.endswith("/grant"):
|
||||
recordings["grant_access"] = test_id
|
||||
elif target.endswith("/revoke"):
|
||||
recordings["revoke_access"] = test_id
|
||||
return recordings
|
||||
108
src/testdriver/html.py
Normal file
108
src/testdriver/html.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""A minimal DOM for the browser surface — stdlib only.
|
||||
|
||||
Not a browser. It parses server-rendered HTML into a queryable tree so that a
|
||||
driver can *look for* a control rather than being told where one is. That
|
||||
distinction is the whole point: a driver handed a selector has not discovered
|
||||
anything, and cannot demonstrate adaptation.
|
||||
|
||||
Chosen over Playwright deliberately (see F-0004): the lab's surface is
|
||||
server-rendered forms with no JavaScript, so a browser engine would add a
|
||||
dependency, a licence question and several hundred megabytes of binaries without
|
||||
changing what H-001 and H-002 can be measured against. `Driver` is a Protocol, so
|
||||
a Playwright implementation can sit alongside this one when a surface needs it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from html.parser import HTMLParser
|
||||
from typing import Iterator
|
||||
|
||||
VOID_ELEMENTS = frozenset(
|
||||
{"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"link", "meta", "param", "source", "track", "wbr"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Element:
|
||||
tag: str
|
||||
attrs: dict[str, str] = field(default_factory=dict)
|
||||
children: list["Element"] = field(default_factory=list)
|
||||
text: str = ""
|
||||
parent: "Element | None" = field(default=None, repr=False)
|
||||
|
||||
def walk(self) -> Iterator["Element"]:
|
||||
yield self
|
||||
for child in self.children:
|
||||
yield from child.walk()
|
||||
|
||||
@property
|
||||
def full_text(self) -> str:
|
||||
return " ".join(
|
||||
part for part in (
|
||||
[self.text] + [c.full_text for c in self.children]
|
||||
) if part
|
||||
).strip()
|
||||
|
||||
def ancestors(self) -> Iterator["Element"]:
|
||||
node = self.parent
|
||||
while node is not None:
|
||||
yield node
|
||||
node = node.parent
|
||||
|
||||
def path(self) -> str:
|
||||
"""A human-readable location, for evidence. Never used to find anything."""
|
||||
parts = [self.tag] + [a.tag for a in self.ancestors()]
|
||||
return "/".join(reversed(parts))
|
||||
|
||||
|
||||
class _Builder(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = Element("#document")
|
||||
self._stack = [self.root]
|
||||
|
||||
def handle_starttag(self, tag: str, attrs) -> None:
|
||||
element = Element(tag, {k: (v or "") for k, v in attrs}, parent=self._stack[-1])
|
||||
self._stack[-1].children.append(element)
|
||||
if tag not in VOID_ELEMENTS:
|
||||
self._stack.append(element)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs) -> None:
|
||||
element = Element(tag, {k: (v or "") for k, v in attrs}, parent=self._stack[-1])
|
||||
self._stack[-1].children.append(element)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
for index in range(len(self._stack) - 1, 0, -1):
|
||||
if self._stack[index].tag == tag:
|
||||
del self._stack[index:]
|
||||
return
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
stripped = data.strip()
|
||||
if stripped:
|
||||
self._stack[-1].text = (self._stack[-1].text + " " + stripped).strip()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Document:
|
||||
root: Element
|
||||
source: str
|
||||
|
||||
@classmethod
|
||||
def parse(cls, html: str) -> "Document":
|
||||
builder = _Builder()
|
||||
builder.feed(html)
|
||||
builder.close()
|
||||
return cls(builder.root, html)
|
||||
|
||||
def elements(self, *tags: str) -> list[Element]:
|
||||
wanted = set(tags)
|
||||
return [
|
||||
element for element in self.root.walk()
|
||||
if element.tag != "#document" and (not wanted or element.tag in wanted)
|
||||
]
|
||||
|
||||
def forms(self) -> list[Element]:
|
||||
return self.elements("form")
|
||||
Loading…
Add table
Add a link
Reference in a new issue