Turns a branch of accumulated edge/CPU work into something someone else can
read, reproduce and continue.
README.md states what the fork is for (multimodal models on edge CPUs rather
than servers), the headline memory and speed results with their costs, and why
the numbers are worth reading -- five retracted conclusions, all measurement
errors rather than coding errors. Upstream's README is preserved at
docs/README.vllm-omni.md and NOTICE records the Apache-2.0 derivation and the
files this fork changes.
docs/edge/:
methodology.md the measurement discipline, which is the most transferable
thing here: what each of the five retractions was, and the
rule it left behind. Includes the two traps specific to this
stack -- vLLM's AOT compile cache keying on config so two
arms sharing an artifact load the wrong graph, and allocated
not being resident until mincore(2) says so.
results.md every measured figure with its spread and baseline, and an
explicit note that the four memory levers overlap and must
not be added.
cpu-4bit.md the two 4-bit paths, the packed layout as measured rather
than assumed, why the path choice is a config field, and the
GPTQ output-head trap that invalidated a whole round of W4A8
numbers.
harness.md how to run a comparison, and the two arguments that matter
more than they look (--context-len, --prompt-len), each of
which has produced a wrong conclusion by being left at its
default.
ROADMAP.md orders by whether the verification method is correct rather than by
expected size, lists what is done with results, what is next with expected
gains and how each would be verified, and -- at equal length -- what was
considered and rejected, including the CPU megakernel with the measurements
that rule it out for now.
benchmarks/edge_harness/ brings the tooling into the repo so the numbers are
reproducible: the grid runner, the single-run benchmarks, the memory ledger,
the fidelity metric, the Android agent suite, the quantizers, and the tests
that pin the harness's judgement. 82 tests pass from the new location.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
147 lines
5.4 KiB
Python
147 lines
5.4 KiB
Python
"""Turn a uiautomator dump into what a text-only agent can act on.
|
|
|
|
Spark-X2.5 never sees a screenshot, so the whole agent hinges on this file:
|
|
a screen has to arrive as a short, stable, indexed list of elements, and an
|
|
index the model returns has to map back to a point on the glass.
|
|
|
|
Two decisions shape the format, both forced by the model rather than by taste:
|
|
|
|
* **Filter hard.** A real settings screen dumps 150+ nodes, most of them
|
|
layout containers. At 56 KiB/token of KV cache and a 1.7B model that both
|
|
costs context and buries the answer, so only nodes that are interactive or
|
|
carry text survive.
|
|
* **Index, do not coordinate.** The model is asked for `index: 3`, never for
|
|
pixels -- a text model has no way to know where 3 is on the glass, and
|
|
coordinates in the transcript would be a source of silent nonsense. The
|
|
mapping back to a centre point happens here, where the bounds are known.
|
|
|
|
The rendered form is the one the scenario fixtures were written in, so the
|
|
decision benchmark and the live agent observe identically.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
from dataclasses import dataclass, field
|
|
|
|
_BOUNDS = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]")
|
|
|
|
# Attributes worth showing when they are true, in the order a reader wants
|
|
# them. `checked` earns its place: the one clearly wrong action in the earlier
|
|
# measurements was tapping a Wi-Fi switch that was already on, which is only
|
|
# avoidable if the state is on the screen the model sees.
|
|
_FLAGS = ("clickable", "checked", "selected", "scrollable", "focused")
|
|
|
|
|
|
@dataclass
|
|
class Element:
|
|
index: int
|
|
cls: str
|
|
text: str
|
|
resource_id: str = ""
|
|
package: str = ""
|
|
bounds: tuple[int, int, int, int] = (0, 0, 0, 0)
|
|
flags: dict[str, bool] = field(default_factory=dict)
|
|
editable: bool = False
|
|
|
|
@property
|
|
def center(self) -> tuple[int, int]:
|
|
x1, y1, x2, y2 = self.bounds
|
|
return (x1 + x2) // 2, (y1 + y2) // 2
|
|
|
|
@property
|
|
def short_class(self) -> str:
|
|
return self.cls.rsplit(".", 1)[-1] if self.cls else "View"
|
|
|
|
def render(self) -> str:
|
|
parts = [f"[{self.index}]", self.short_class, f'"{self.text}"']
|
|
for flag in _FLAGS:
|
|
if flag in self.flags:
|
|
parts.append(f"{flag}={str(self.flags[flag]).lower()}")
|
|
return " ".join(parts)
|
|
|
|
|
|
def _truthy(node: ET.Element, name: str) -> bool:
|
|
return node.get(name, "false") == "true"
|
|
|
|
|
|
def _label(node: ET.Element) -> str:
|
|
"""What to call this element, preferring what a person would read."""
|
|
for attr in ("text", "content-desc"):
|
|
value = (node.get(attr) or "").strip()
|
|
if value:
|
|
return value.replace("\n", " ")
|
|
# A bare resource id is a poor label but far better than an empty string
|
|
# when the element is the only way forward (unlabelled icon buttons).
|
|
rid = node.get("resource-id") or ""
|
|
return rid.rsplit("/", 1)[-1] if rid else ""
|
|
|
|
|
|
def _interesting(node: ET.Element) -> bool:
|
|
if any(_truthy(node, a) for a in ("clickable", "checkable", "scrollable",
|
|
"long-clickable", "focusable")):
|
|
return True
|
|
return bool(_label(node)) and not _truthy(node, "password")
|
|
|
|
|
|
def parse_bounds(raw: str | None) -> tuple[int, int, int, int]:
|
|
m = _BOUNDS.search(raw or "")
|
|
if not m:
|
|
return (0, 0, 0, 0)
|
|
return tuple(int(g) for g in m.groups()) # type: ignore[return-value]
|
|
|
|
|
|
def parse_ui_dump(xml_text: str, *, max_elements: int = 60) -> list[Element]:
|
|
"""uiautomator XML -> the elements worth showing, in reading order."""
|
|
root = ET.fromstring(xml_text)
|
|
elements: list[Element] = []
|
|
for node in root.iter("node"):
|
|
if not _interesting(node):
|
|
continue
|
|
label = _label(node)
|
|
clickable = _truthy(node, "clickable")
|
|
# An unlabelled, non-interactive node tells the model nothing it can
|
|
# use and costs it context.
|
|
if not label and not clickable and not _truthy(node, "checkable"):
|
|
continue
|
|
flags = {"clickable": clickable}
|
|
if _truthy(node, "checkable"):
|
|
flags["checked"] = _truthy(node, "checked")
|
|
if _truthy(node, "selected"):
|
|
flags["selected"] = True
|
|
if _truthy(node, "scrollable"):
|
|
flags["scrollable"] = True
|
|
if _truthy(node, "focused"):
|
|
flags["focused"] = True
|
|
elements.append(
|
|
Element(
|
|
index=len(elements),
|
|
cls=node.get("class", ""),
|
|
text=label,
|
|
resource_id=node.get("resource-id", ""),
|
|
package=node.get("package", ""),
|
|
bounds=parse_bounds(node.get("bounds")),
|
|
flags=flags,
|
|
editable="EditText" in (node.get("class") or ""),
|
|
)
|
|
)
|
|
if len(elements) >= max_elements:
|
|
break
|
|
return elements
|
|
|
|
|
|
def render_tree(elements: list[Element]) -> str:
|
|
"""The exact text the model is shown."""
|
|
return "\n".join(e.render() for e in elements)
|
|
|
|
|
|
def screen_signature(elements: list[Element]) -> str:
|
|
"""A cheap identity for a screen, to notice when an action changed nothing.
|
|
|
|
Labels and flags only: coordinates shift with animations and scroll offsets,
|
|
and treating those as a new screen would hide a stuck agent.
|
|
"""
|
|
return "|".join(f"{e.short_class}:{e.text}:{e.flags.get('checked', '')}"
|
|
for e in elements)
|