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:
tegwick 2026-08-22 23:50:29 +02:00
parent 925ff2dd91
commit 44faf3de8e
23 changed files with 1008 additions and 9 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

244
src/testdriver/agentic.py Normal file
View 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
View 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
View 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")