Files
MapLibreParticleLab/tools/particle_android_validation/verify_step113_fast_path.py
tei 1072816740
Some checks failed
validate-scripts / validate-scripts (push) Has been cancelled
test: add STEP-119 RCRR boundary variants and audit
2026-10-11 17:42:26 +09:00

283 lines
13 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""STEP-113 on-device PBF Fast Path verifier. Does not build, install or alter APKs."""
import argparse
import base64
import datetime
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[2]
ARTIFACTS = ROOT / "artifacts/maplibre-particle/android-validation"
APK = ARTIFACTS / "MapLibreAndroidTestApp-validation-opengl-debug-x86_64.apk"
BUILD_INFO = ARTIFACTS / "build-info.txt"
PACKAGE = "org.maplibre.android.testapp"
ACTIVITY = f"{PACKAGE}/{PACKAGE}.activity.particle.ParticleCoreLayerValidationActivity"
SCENARIOS = ("STATIC_RC", "PBF63", "PARTICLE_OFF", "PARTIAL_RCRR", "SOURCE_CHANGE", "STATIC_RC_NO_TIDE",
"RCRR_C_NARROW_NO_TIDE", "RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE")
NO_TIDE_SCENARIOS = {"STATIC_RC_NO_TIDE", "RCRR_C_NARROW_NO_TIDE",
"RCRR_C_WIDE_NO_TIDE", "RCRR_R_DYNAMIC_NO_TIDE"}
LOG_ENTRY = re.compile(r"^[A-Z]/STEP113Gate\(\s*(\d+)\):\s*(\{.+\})\s*$")
def call(args, *, check=True):
result = subprocess.run(args, text=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=False)
if check and result.returncode:
raise RuntimeError(f"command returned {result.returncode}: {' '.join(args)}\n"
f"{result.stderr[-1200:]}\n{result.stdout[-1200:]}")
return result.stdout.strip()
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1048576), b""):
h.update(block)
return h.hexdigest()
def inspect(apk, adb, serial):
if not apk.is_file() or not BUILD_INFO.is_file():
raise RuntimeError("缺少 Validation APK/build-info.txt,必须先由用户手工运行标准构建脚本")
info = dict(line.split("=", 1) for line in BUILD_INFO.read_text(encoding="utf-8").splitlines()
if "=" in line)
source_head = call(["git", "-C", str(ROOT), "rev-parse", "HEAD"])
tracked_changes = call(["git", "-C", str(ROOT), "status", "--porcelain", "--untracked-files=no"])
if tracked_changes:
raise RuntimeError("SOURCE_NOT_LOCKED: tracked源码有未提交变更;RDC须先独立审计并scoped提交,用户再构建最新HEAD")
if info.get("commit") != source_head:
raise RuntimeError(f"STALE_APK: build-info commit={info.get('commit')}, "
f"current HEAD={source_head}; 禁止用旧APK验收本轮修复")
local_sha = sha256(apk)
if local_sha != info.get("sha256"):
raise RuntimeError(f"APK_SHA_MISMATCH: actual={local_sha}, build-info={info.get('sha256')}")
if info.get("abi") != "x86_64":
raise RuntimeError(f"unexpected ABI: {info.get('abi')}")
device = lambda *a: call([adb, "-s", serial, *a])
abi = device("shell", "getprop", "ro.product.cpu.abi")
if abi != "x86_64":
raise RuntimeError(f"DEVICE_ABI_MISMATCH: {abi}")
paths = device("shell", "pm", "path", PACKAGE).splitlines()
if not paths or not paths[0].startswith("package:"):
raise RuntimeError(f"TEST_APP_NOT_INSTALLED: {PACKAGE}")
remote = paths[0].split("package:", 1)[1].strip()
installed_sha = device("shell", "sha256sum", remote).split()[0]
if installed_sha != local_sha:
raise RuntimeError(f"STALE_INSTALLED_APK: installed={installed_sha} local={local_sha}; "
"用户须核实后手工 adb install -r,不能用旧版本验收")
return info, local_sha, source_head, device
def extract(logcat, pid, expected):
chunks = {}
for line in logcat.splitlines():
found = LOG_ENTRY.match(line.strip())
if not found or int(found.group(1)) != pid:
continue
try:
item = json.loads(found.group(2))
if item.get("encoding") != "base64-utf8" or item.get("kind") not in ("summary", "blocked"):
continue
ident = (item["exportId"], item["kind"])
record = chunks.setdefault(ident, {"count": item["chunkCount"], "items": {}})
if record["count"] == item["chunkCount"]:
record["items"][int(item["chunkIndex"])] = item["payload"]
except (KeyError, ValueError, TypeError):
continue
result = []
for (ident, kind), record in chunks.items():
if len(record["items"]) != record["count"]:
continue
try:
packed = "".join(record["items"][i] for i in range(record["count"]))
sample = json.loads(base64.b64decode(packed, validate=True).decode("utf-8"))
if kind == "blocked":
sample["blockedReason"] = sample.get("reason") or "SCENE_BLOCKED"
sample["sceneReady"] = False
# Keep revoked/blocked summaries as negative evidence too; an
# earlier Fast counter must not conceal a later Annotation guard.
if sample.get("scenario") == expected:
result.append((int(ident.split("-", 1)[0]), sample))
except (KeyError, ValueError, TypeError, UnicodeError):
continue
return [x[1] for x in sorted(result, key=lambda item: item[0])]
def rcrr_segments_valid(sample, scenario):
"""Verify exact R/C boundaries in the new isolated, 64-layer PBF Test App cases."""
retained = sample.get("retained", [])
if len(retained) != 3:
return False
r0, r2, r3 = retained
if (r0.get("begin") != 0 or r2.get("end") != r3.get("begin")
or r3.get("end") != 64 or r3.get("begin") != 63):
return False
if scenario == "RCRR_C_NARROW_NO_TIDE":
return r0.get("end") == 31 and r2.get("begin") == 33
if scenario == "RCRR_C_WIDE_NO_TIDE":
return r0.get("end") == 2 and r2.get("begin") == 38
if scenario == "RCRR_R_DYNAMIC_NO_TIDE":
return r0.get("end") == 33 and r2.get("begin") == 38
return False
def scene_parity_valid(sample, scenario):
"""Fail closed on the *declared* base style, not just a loose layer count.
Original five STEP-113 scenarios require all 63 original base layers.
STEP-118 explicitly names a user-approved test-copy exclusion of the
orphaned tide-spots layer: 63 remote originals -> 62 copied basics, plus
the two known weather layers (and no empty Annotation point layer).
It is not equivalent to the unmodified original 63-layer test.
"""
if not sample.get("planValid"):
return False
if scenario not in NO_TIDE_SCENARIOS:
return sample.get("basePreserved") is True and not sample.get("tideLayerExcluded", False)
return (
sample.get("basePreserved") is False
and sample.get("tideLayerExcluded") is True
and sample.get("originalBaseLayerCount") == 63
and sample.get("effectiveBaseLayerCount") == 62
and sample.get("excludedLayerId") == "tide-spots"
and sample.get("excludedLayerSource") == "tide"
and sample.get("excludedLayerCount") == 1
and sample.get("actualLayers") == 64
and sample.get("commands", {}).get("emptyBuiltinAnnotationLayerRemoved") is True
and sample.get("commands", {}).get("annotationSourcePreserved") is True
and all(source.get("sourceId") != "tide" for source in sample.get("sources", []))
and sample.get("actualLayers") == 64
and (
(scenario == "STATIC_RC_NO_TIDE" and
len(sample.get("retained", [])) == 1 and
sample["retained"][0].get("begin") == 0 and
sample["retained"][0].get("end") == 62)
or
(scenario != "STATIC_RC_NO_TIDE" and
rcrr_segments_valid(sample, scenario))
)
)
def run():
parser = argparse.ArgumentParser(description="Check actual STEP-113 PBF on-device Fast frames")
parser.add_argument("--serial", default="192.168.200.139:5555")
parser.add_argument("--adb", default=str(Path.home() / "Library/Android/sdk/platform-tools/adb"))
parser.add_argument("--apk", default=str(APK))
parser.add_argument("--scenario", choices=SCENARIOS, default="STATIC_RC")
parser.add_argument("--seconds", type=int, default=75)
parser.add_argument("--preflight-only", action="store_true")
args = parser.parse_args()
if args.seconds < 75 and not args.preflight_only:
parser.error("原STEP-113场景至少75秒;摘要与完整120帧导出仍须分别验收")
if not Path(args.adb).is_file():
raise RuntimeError(f"adb not found: {args.adb}")
info, local_sha, head, device = inspect(Path(args.apk), args.adb, args.serial)
print(f"APK_SOURCE_HEAD={head}")
print(f"APK_SHA256={local_sha}")
print(f"DEVICE={args.serial} ABI=x86_64 INSTALLED_SHA_MATCH=True")
if args.preflight_only:
print("PREFLIGHT_PASS(仅证明同版本APK已安装,未测试Fast)")
return 0
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
output = ARTIFACTS / f"step113-retest-{stamp}" / args.scenario.lower()
output.mkdir(parents=True, exist_ok=False)
command = ["shell", "am", "start", "-S", "-n", ACTIVITY,
"--es", "step113_scenario", args.scenario]
print(f"START_SCENARIO={args.scenario}")
output.joinpath("launch.txt").write_text(device(*command) + "\n", encoding="utf-8")
process_id = None
for _ in range(12):
time.sleep(1)
pid_text = call([args.adb, "-s", args.serial, "shell", "pidof", PACKAGE], check=False)
if pid_text.strip().split():
process_id = int(pid_text.strip().split()[0])
break
if process_id is None:
raise RuntimeError("APP_PROCESS_NOT_RUNNING after launch")
print(f"APP_PID={process_id}")
# PID acquisition can take less than 12 seconds. Never subtract the
# maximum poll duration from the actual scenario observation period.
observation_started = time.monotonic()
time.sleep(args.seconds)
observation_elapsed = time.monotonic() - observation_started
raw_log = device("logcat", "-d", "-v", "brief", "-s", "STEP113Gate:I", "*:S")
output.joinpath("logcat.txt").write_text(raw_log + "\n", encoding="utf-8")
all_samples = extract(raw_log, process_id, args.scenario)
samples = [s for s in all_samples if s.get("sceneReady") and not s.get("blockedReason")]
if not samples:
status = "SCENE_BLOCKED_OR_REVOKED" if any(s.get("blockedReason") for s in all_samples) else "NO_READY_SUMMARY"
fast_delta = full_delta = 0
else:
start, end = samples[0], samples[-1]
fast_delta = end["fastRendered"] - start["fastRendered"]
full_delta = end["fullRendered"] - start["fullRendered"]
if len(samples) < 2:
status = "INSUFFICIENT_SAMPLES"
elif any(s.get("blockedReason") for s in all_samples):
status = "SCENE_BLOCKED_OR_REVOKED"
elif any((s.get("sceneGeneration"), s.get("lifecycle")) !=
(start.get("sceneGeneration"), start.get("lifecycle")) for s in samples):
status = "SUMMARY_LIFECYCLE_CHANGED"
elif any(not scene_parity_valid(s, args.scenario) for s in samples):
status = "INVALID_PLAN_OR_BASE_PARITY"
elif any(len(s.get("gateFailureCounts", [])) != 24 for s in samples):
status = "INCOMPLETE_GATE_SUMMARY"
elif any(s["decisionMismatch"] != 0 for s in samples):
status = "DECISION_MISMATCH"
elif any(b["fastRendered"] < a["fastRendered"] or b["fullRendered"] < a["fullRendered"]
for a, b in zip(samples, samples[1:])):
status = "COUNTER_RESET"
elif fast_delta > 0:
status = "FAST_OBSERVED"
else:
status = "NO_FAST_OBSERVED"
report = {
"schema": "step113-fast-path-retest",
"sourceCommit": head,
"apkSha256": local_sha,
"installedShaMatched": True,
"device": args.serial,
"appPid": process_id,
"scenario": args.scenario,
"sourceReport": (
"STEP-118 user-approved TEST-APP-ONLY 62-layer derivative; "
"original 63-layer PBF Style remains unmodified; not STEP-114 smoke"
if args.scenario in NO_TIDE_SCENARIOS
else "STEP-113 original 63-layer PBF test scene; not STEP-114 smoke"
),
"durationSecondsRequested": args.seconds,
"durationSecondsObservedAfterPid": observation_elapsed,
"completeSummarySamples": len(samples),
"blockedSummarySamples": [s for s in all_samples if s.get("blockedReason")],
"status": status,
"fastDelta": fast_delta,
"fullDelta": full_delta,
"initialSummary": samples[0] if samples else None,
"finalSummary": samples[-1] if samples else None,
}
output.joinpath("result.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
print(f"STATUS={status} FastDelta={fast_delta} FullDelta={full_delta} Samples={len(samples)}")
print(f"EVIDENCE={output / 'result.json'}")
# Fail closed: no Fast observed is a meaningful negative result, not PASS.
if status == "FAST_OBSERVED":
print("FAST_OBSERVED仅表示真实Fast计数增长;缓存/像素/安全回退仍须逐帧验收。")
return 0
return 2
if __name__ == "__main__":
try:
sys.exit(run())
except (OSError, RuntimeError, ValueError, KeyError) as exc:
print(f"STEP113_RETEST_BLOCKED: {exc}", file=sys.stderr)
sys.exit(3)