a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
109 lines
3.6 KiB
Python
109 lines
3.6 KiB
Python
"""Context passed to every reward scorer.
|
|
|
|
A scorer reads only what it needs from this object. The aggregator builds one
|
|
Context per matrix cell (device x scenario) and reuses it across all scorers,
|
|
so expensive work (decoding the screenshot, parsing the source tree) happens
|
|
once.
|
|
|
|
Heavy fields are lazy: the numpy image and source-file map are only loaded on
|
|
first access. Optional fields (ax_tree, runtime) are None when not collected.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from functools import cached_property
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
# iPhone OS chrome fractions (status bar / home indicator). Scorers that grade
|
|
# the app's own content area should trim these.
|
|
STATUS_BAR_FRAC = 0.055
|
|
HOME_INDICATOR_FRAC = 0.025
|
|
|
|
# Design tokens, mirrored from Sources/JCodeMobile/Theme.swift. Shared so every
|
|
# scorer agrees on "the background" / "the accent".
|
|
TOKENS = {
|
|
"background": 0x0F0F14,
|
|
"surface": 0x1A1A1F,
|
|
"surfaceElevated": 0x242429,
|
|
"mint": 0x4DD9A6,
|
|
"warning": 0xF59E0B,
|
|
"error": 0xD94D59,
|
|
}
|
|
|
|
|
|
def hex_to_rgb(h: int) -> np.ndarray:
|
|
return np.array([(h >> 16) & 0xFF, (h >> 8) & 0xFF, h & 0xFF], dtype=np.float64)
|
|
|
|
|
|
@dataclass
|
|
class Context:
|
|
screenshot: Optional[str] = None # path to a PNG
|
|
device: str = "iPhone 17"
|
|
scenario: str = "short"
|
|
scale: int = 3 # device px per point
|
|
source_root: Optional[str] = None # e.g. Sources/JCodeMobile
|
|
ax_tree_path: Optional[str] = None # optional accessibility tree JSON
|
|
runtime: Optional[dict] = None # optional perf metrics dict
|
|
meta: dict[str, Any] = field(default_factory=dict)
|
|
|
|
# --- lazy, cached derivations ----------------------------------------
|
|
@cached_property
|
|
def image(self) -> Optional[Image.Image]:
|
|
if not self.screenshot:
|
|
return None
|
|
return Image.open(self.screenshot).convert("RGB")
|
|
|
|
@cached_property
|
|
def pixels(self) -> Optional[np.ndarray]:
|
|
img = self.image
|
|
return None if img is None else np.asarray(img, dtype=np.float64)
|
|
|
|
@cached_property
|
|
def content_pixels(self) -> Optional[np.ndarray]:
|
|
"""The app content region with OS chrome trimmed top/bottom."""
|
|
arr = self.pixels
|
|
if arr is None:
|
|
return None
|
|
h = arr.shape[0]
|
|
top = int(h * STATUS_BAR_FRAC)
|
|
bot = int(h * (1 - HOME_INDICATOR_FRAC))
|
|
return arr[top:bot]
|
|
|
|
@cached_property
|
|
def content_mask(self) -> Optional[np.ndarray]:
|
|
"""Boolean mask of pixels that differ from the app background."""
|
|
arr = self.content_pixels
|
|
if arr is None:
|
|
return None
|
|
bg = hex_to_rgb(TOKENS["background"])
|
|
dist = np.linalg.norm(arr - bg, axis=2)
|
|
return dist > 18.0
|
|
|
|
@cached_property
|
|
def source_files(self) -> dict[str, str]:
|
|
"""Map of relative path -> file text for every .swift under source_root."""
|
|
if not self.source_root:
|
|
return {}
|
|
root = Path(self.source_root)
|
|
if not root.exists():
|
|
return {}
|
|
out = {}
|
|
for f in sorted(root.rglob("*.swift")):
|
|
out[str(f.relative_to(root))] = f.read_text(encoding="utf-8", errors="replace")
|
|
return out
|
|
|
|
@cached_property
|
|
def ax_tree(self) -> Optional[dict]:
|
|
if not self.ax_tree_path or not Path(self.ax_tree_path).exists():
|
|
return None
|
|
try:
|
|
return json.loads(Path(self.ax_tree_path).read_text())
|
|
except Exception:
|
|
return None
|