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
148 lines
5.4 KiB
Python
148 lines
5.4 KiB
Python
"""Turn one tool call into one device action.
|
|
|
|
Every failure here is returned to the model as a sentence it can act on, not
|
|
raised. A 1.7B model asked for `index: 9` on a four-element screen has made a
|
|
recoverable mistake, and "index 9 is out of range, the screen has 4 elements
|
|
(0-3)" is the difference between the next step being right and the episode
|
|
ending. Exceptions are reserved for the device being gone.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from device import KEYCODES, Device
|
|
from ui_tree import Element
|
|
|
|
|
|
@dataclass
|
|
class ActionResult:
|
|
ok: bool
|
|
message: str
|
|
done: bool = False
|
|
summary: str = ""
|
|
action: str = ""
|
|
|
|
def as_tool_reply(self) -> str:
|
|
return self.message
|
|
|
|
|
|
def _need(args: dict, key: str, kind: type, what: str) -> tuple[object, str | None]:
|
|
if key not in args:
|
|
return None, f"missing required argument {key!r} for {what}"
|
|
value = args[key]
|
|
if kind is int:
|
|
try:
|
|
value = int(value)
|
|
except (TypeError, ValueError):
|
|
return None, f"{key!r} must be a whole number, got {args[key]!r}"
|
|
elif not isinstance(value, kind):
|
|
return None, f"{key!r} must be {kind.__name__}, got {type(value).__name__}"
|
|
return value, None
|
|
|
|
|
|
def _resolve(index: object, elements: list[Element]) -> tuple[Element | None, str | None]:
|
|
i = int(index)
|
|
if not elements:
|
|
return None, "the screen has no addressable elements; try press_key(back)"
|
|
if not 0 <= i < len(elements):
|
|
return None, (
|
|
f"index {i} is out of range; the screen has {len(elements)} "
|
|
f"elements (0-{len(elements) - 1})"
|
|
)
|
|
return elements[i], None
|
|
|
|
|
|
def execute(name: str, args: dict, elements: list[Element],
|
|
device: Device) -> ActionResult:
|
|
"""Run one tool call. Returns what to tell the model."""
|
|
args = args or {}
|
|
|
|
if name == "task_complete":
|
|
summary = str(args.get("summary", "")).strip()
|
|
return ActionResult(True, "task marked complete", done=True,
|
|
summary=summary, action="task_complete")
|
|
|
|
if name == "tap":
|
|
index, err = _need(args, "index", int, "tap")
|
|
if err:
|
|
return ActionResult(False, err, action="tap")
|
|
el, err = _resolve(index, elements)
|
|
if err:
|
|
return ActionResult(False, err, action="tap")
|
|
x, y = el.center
|
|
device.tap(x, y)
|
|
state = ""
|
|
if "checked" in el.flags:
|
|
state = f" (was {'on' if el.flags['checked'] else 'off'})"
|
|
return ActionResult(True, f'tapped [{index}] "{el.text}"{state}',
|
|
action=f"tap:{index}")
|
|
|
|
if name == "type_text":
|
|
index, err = _need(args, "index", int, "type_text")
|
|
if err:
|
|
return ActionResult(False, err, action="type_text")
|
|
text, err = _need(args, "text", str, "type_text")
|
|
if err:
|
|
return ActionResult(False, err, action="type_text")
|
|
el, err = _resolve(index, elements)
|
|
if err:
|
|
return ActionResult(False, err, action="type_text")
|
|
# Focus first: `input text` goes to whatever holds focus, which on a
|
|
# freshly dumped screen is usually nothing.
|
|
x, y = el.center
|
|
device.tap(x, y)
|
|
device.input_text(str(text))
|
|
return ActionResult(True, f'typed "{text}" into [{index}] "{el.text}"',
|
|
action=f"type:{index}")
|
|
|
|
if name == "swipe":
|
|
direction = str(args.get("direction", "")).lower()
|
|
if direction not in ("up", "down", "left", "right"):
|
|
return ActionResult(
|
|
False, "direction must be one of up, down, left, right",
|
|
action="swipe")
|
|
w, h = device.size()
|
|
cx, cy = w // 2, h // 2
|
|
dx, dy = int(w * 0.35), int(h * 0.30)
|
|
# `direction` is where the *content* should move, i.e. what the user
|
|
# means by "scroll down to see more", so the finger travels the other
|
|
# way. Stated in the tool description too, because guessing this wrong
|
|
# is invisible in the transcript.
|
|
moves = {
|
|
"down": (cx, cy + dy, cx, cy - dy),
|
|
"up": (cx, cy - dy, cx, cy + dy),
|
|
"right": (cx + dx, cy, cx - dx, cy),
|
|
"left": (cx - dx, cy, cx + dx, cy),
|
|
}
|
|
device.swipe(*moves[direction])
|
|
return ActionResult(True, f"swiped to scroll {direction}",
|
|
action=f"swipe:{direction}")
|
|
|
|
if name == "press_key":
|
|
key = str(args.get("key", "")).lower()
|
|
if key not in KEYCODES:
|
|
return ActionResult(
|
|
False, f"unknown key {key!r}; use one of {', '.join(KEYCODES)}",
|
|
action="press_key")
|
|
device.keyevent(key)
|
|
return ActionResult(True, f"pressed {key}", action=f"key:{key}")
|
|
|
|
if name == "open_app":
|
|
app = str(args.get("name", "")).strip()
|
|
if not app:
|
|
return ActionResult(False, "open_app needs a name", action="open_app")
|
|
try:
|
|
device.launch(app)
|
|
except Exception as exc: # device-specific launch failures are recoverable
|
|
return ActionResult(False, f"could not launch {app!r}: {exc}",
|
|
action="open_app")
|
|
return ActionResult(True, f"launched {app}", action=f"open:{app}")
|
|
|
|
return ActionResult(
|
|
False,
|
|
f"unknown tool {name!r}; available: tap, type_text, swipe, press_key, "
|
|
"open_app, task_complete",
|
|
action="unknown",
|
|
)
|