Files
JMR-devandClaude Opus 4.8 55f1f59e3d refactor(scripts): fold cold-fetch pause-hook A/B into device-testing harness (#405)
Bakes the proven 2026-07-06 cold-vs-warm pause-hook flow into
scripts/device-testing/ as a first-class, reproducible `cold-fetch-ab`
scenario, upstreaming the scratchpad driver.

- fetchgate.py: FETCH_GATE pause/resume/query helpers through the guarded
  adb wrapper, with ordered-broadcast read-back parsing (paused=[...]).
- scenarios.cold_fetch_ab: pre-arm halt -> detect sign-in (sync all
  breadcrumb) -> confirm halt (prefetch skipped) -> wait for header sync ->
  measure cold opens -> resume -> measure warm opens. ALWAYS resumes on exit
  (finally), even on error -- never leaves fetch paused.
- report.render_cold_fetch_ab: gate summary, cold/warm tables, cold-vs-warm
  delta, connect=0ms reuse proof, throttle signature.
- Portability (subsumes #392): file-based uiautomator dump (not /dev/tty),
  UTF-8 adb decode + PYTHONUTF8/console I/O, openMessage-breadcrumb readiness,
  row-selection hardening (skip non-message rows).

The pause hook is debug-build-only (#393/#395), so the scenario needs a debug
APK. Automated validation: mocked unittest coverage (adb/breadcrumbs/gate) for
the helpers and the A/B scenario incl. restore-on-error, plus a --dry-run path
exercised end-to-end through perf_harness.main. A full on-device run is a
follow-up. Dev-tooling only; no app/src changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 05:07:11 -05:00

344 lines
12 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""report.py -- aggregate scenario samples and render ``timing-tables.md``.
Pure and testable: given the samples a scenario collected, it produces the same
per-scenario markdown tables + aggregates as the hand-written ``timing-tables.md`` from the
manual run. No device or I/O dependency beyond writing the final file.
"""
from __future__ import annotations
import statistics
from dataclasses import dataclass
from typing import List, Optional, Sequence
from breadcrumbs import OpenSample
# The floor below which adb uiautomator-dump timings cannot resolve an in-app transition
# (see perf_summary.md: nav/back were dominated by the ~2.5-3 s dump latency).
UIAUTOMATOR_LATENCY_FLOOR_MS = 3000
# A cold IMAP ``work`` time at/above this reads as a server-side throttle signature (the manual
# Gmail run saw 26-72 s of work vs Outlook's sub-second control).
THROTTLE_WORK_MS = 10000
# --------------------------------------------------------------------------- #
# Stats + markdown helpers
# --------------------------------------------------------------------------- #
@dataclass
class Aggregate:
n: int
mean: Optional[float]
median: Optional[float]
minimum: Optional[int]
maximum: Optional[int]
def aggregate(values: Sequence[float]) -> Aggregate:
"""Summarise a list of numbers; safe on an empty list."""
vals = [v for v in values if v is not None]
if not vals:
return Aggregate(0, None, None, None, None)
return Aggregate(
n=len(vals),
mean=statistics.fmean(vals),
median=statistics.median(vals),
minimum=min(vals),
maximum=max(vals),
)
def md_table(headers: Sequence[str], rows: Sequence[Sequence[object]]) -> str:
"""Render a GitHub-flavoured markdown table."""
head = "| " + " | ".join(str(h) for h in headers) + " |"
sep = "| " + " | ".join("---" for _ in headers) + " |"
body = [
"| " + " | ".join("" if c is None else str(c) for c in row) + " |" for row in rows
]
return "\n".join([head, sep, *body])
def _ms(value: Optional[float]) -> str:
return "" if value is None else f"{round(value)}"
# --------------------------------------------------------------------------- #
# Cold open
# --------------------------------------------------------------------------- #
@dataclass
class ColdOpenSample:
run: int
total_time_ms: Optional[int]
wait_time_ms: Optional[int]
note: str = ""
def render_cold_open(samples: Sequence[ColdOpenSample]) -> str:
rows: List[Sequence[object]] = []
for s in samples:
rows.append([s.run, _ms(s.total_time_ms), _ms(s.wait_time_ms), s.note])
total_agg = aggregate([s.total_time_ms for s in samples if s.total_time_ms is not None])
wait_agg = aggregate([s.wait_time_ms for s in samples if s.wait_time_ms is not None])
rows.append(
[
"**mean**",
f"**{_ms(total_agg.mean)}**",
f"**{_ms(wait_agg.mean)}**",
f"median {_ms(total_agg.median)} / {_ms(wait_agg.median)}",
]
)
table = md_table(["Run", "TotalTime (ms)", "WaitTime (ms)", "note"], rows)
return (
"## Cold open (am start -W, cache cleared each run)\n\n"
+ table
+ "\n\nColdest run is the first post-clear launch (class-load/JIT); steady state is lower.\n"
)
# --------------------------------------------------------------------------- #
# Message open (reader body-load)
# --------------------------------------------------------------------------- #
@dataclass
class ReaderOpenRow:
"""A flattened, render-ready reader-open row (mirrors a timing-tables.md line)."""
index: int
label: str
account_ref: str = ""
cached: bool = False
took_ms: Optional[int] = None
reader_ready_ms: Optional[int] = None
connect_ms: Optional[int] = None
work_ms: Optional[int] = None
select_ms: Optional[int] = None
body_ms: Optional[int] = None
flag_ms: Optional[int] = None
live: Optional[int] = None
rfc822_bytes: Optional[int] = None
body_kb_per_s: Optional[float] = None
skipped: bool = False
reason: str = ""
@classmethod
def from_open_sample(
cls, sample: OpenSample, index: int, label: str
) -> "ReaderOpenRow":
bf = sample.body_fetch
op = sample.body_fetch_op
return cls(
index=index,
label=label,
account_ref=sample.account_ref,
cached=sample.cached,
took_ms=sample.took_ms,
reader_ready_ms=sample.reader_ready.took_ms if sample.reader_ready else None,
connect_ms=op.connect_ms if op else None,
work_ms=op.work_ms if op else None,
select_ms=bf.select_ms if bf else None,
body_ms=bf.body_ms if bf else None,
flag_ms=bf.flag_ms if bf else None,
live=op.live if op else None,
rfc822_bytes=bf.rfc822_bytes if bf else None,
body_kb_per_s=sample.body_kb_per_s,
)
_OPEN_HEADERS = [
"#",
"message",
"cached",
"rfc822 B",
"openMessage took",
"reader ready",
"connect",
"work",
"select",
"body-dl",
"flag",
"live",
"body KB/s",
]
def _open_row_cells(row: ReaderOpenRow) -> Sequence[object]:
if row.skipped:
return [
row.index,
row.label,
"-",
f"SKIPPED: {row.reason}",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
kbps = "" if row.body_kb_per_s is None else f"{row.body_kb_per_s:.1f}"
return [
row.index,
row.label,
"yes" if row.cached else "no",
row.rfc822_bytes if row.rfc822_bytes is not None else "",
_fmt_ms(row.took_ms),
_fmt_ms(row.reader_ready_ms),
_ms(row.connect_ms),
_ms(row.work_ms),
_ms(row.select_ms),
_ms(row.body_ms),
_ms(row.flag_ms),
_ms(row.live),
kbps,
]
def _fmt_ms(value: Optional[int]) -> str:
return "" if value is None else f"{value} ms"
def render_message_open(title: str, rows: Sequence[ReaderOpenRow]) -> str:
table = md_table(_OPEN_HEADERS, [_open_row_cells(r) for r in rows])
uncached = [r for r in rows if not r.skipped and not r.cached and r.took_ms is not None]
agg = aggregate([r.took_ms for r in uncached])
lines = [f"## {title}", "", table, ""]
if agg.n:
lines.append(
f"Uncached opens: n={agg.n}, openMessage median "
f"{_ms(agg.median)} ms, mean {_ms(agg.mean)} ms, "
f"range {agg.minimum}-{agg.maximum} ms."
)
skipped = [r for r in rows if r.skipped]
if skipped:
lines.append(f"Skipped samples: {len(skipped)} (see rows above).")
return "\n".join(lines) + "\n"
# --------------------------------------------------------------------------- #
# Back navigation
# --------------------------------------------------------------------------- #
@dataclass
class BackNavSample:
index: int
back_ms: Optional[int]
note: str = ""
def render_back_nav(samples: Sequence[BackNavSample]) -> str:
rows = [[s.index, _fmt_ms(s.back_ms), s.note] for s in samples]
agg = aggregate([s.back_ms for s in samples if s.back_ms is not None])
table = md_table(["#", "back (reader->mailbox)", "note"], rows)
caveat = (
f"\n\n**Caveat:** these are dominated by the ~{UIAUTOMATOR_LATENCY_FLOOR_MS} ms "
"uiautomator-dump latency floor; true in-app back is sub-second and not precisely "
"measurable via adb UI polling under load (see perf_summary.md)."
)
summary = "" if not agg.n else f"\n\nBack: n={agg.n}, mean {_ms(agg.mean)} ms, median {_ms(agg.median)} ms."
return "## Reader -> mailbox (back)\n\n" + table + summary + caveat + "\n"
# --------------------------------------------------------------------------- #
# Cold-fetch pause-hook A/B (issue #405)
# --------------------------------------------------------------------------- #
def _gate_data(state) -> str:
"""The read-back payload of a fetchgate ``BroadcastResult`` (duck-typed), for display."""
if state is None:
return "(none)"
data = getattr(state, "data", None)
return data if data else "(no read-back)"
def _uncached_took(rows: Sequence["ReaderOpenRow"]) -> List[int]:
return [r.took_ms for r in rows if not r.skipped and r.took_ms is not None]
def render_gate_summary(results: dict) -> str:
"""Render the FETCH_GATE control summary: pre-arm, sign-in, halt-confirm, restore."""
accounts = results.get("sign_in_accounts")
sign_in = f"yes ({accounts} account(s))" if accounts is not None else "no / not detected"
lines = [
"## Fetch-gate A/B control",
"",
f"- Pre-armed halt read-back: `{_gate_data(results.get('gate_prearm'))}`",
f"- Sign-in detected (sync all breadcrumb): {sign_in}",
"- Halt confirmed (prefetch-skipped breadcrumb): "
+ ("yes" if results.get("gate_confirmed") else "no"),
"- Header sync ready: " + ("yes" if results.get("header_ready") else "no"),
f"- Gate restored/cleared read-back: `{_gate_data(results.get('gate_restored'))}`",
"",
"> The pause hook is **debug-build-only** (#393/#395): the scenario needs a debug APK.",
"",
]
return "\n".join(lines)
def render_cold_warm_comparison(
cold: Sequence["ReaderOpenRow"], warm: Sequence["ReaderOpenRow"]
) -> str:
"""Render the cold-vs-warm delta, the connect=0ms reuse proof, and any throttle signature."""
cold_agg = aggregate(_uncached_took(cold))
warm_agg = aggregate(_uncached_took(warm))
lines = ["## Cold vs warm (A/B)", ""]
lines.append(
f"- Cold median openMessage: {_ms(cold_agg.median)} ms (n={cold_agg.n})"
)
lines.append(
f"- Warm median openMessage: {_ms(warm_agg.median)} ms (n={warm_agg.n})"
)
if cold_agg.median and warm_agg.median:
lines.append(
f"- Speedup (cold/warm median): {cold_agg.median / warm_agg.median:.1f}x"
)
# connect=0ms connection-reuse proof (from the cold opens' ImapPerf op).
connects = [r.connect_ms for r in cold if not r.skipped and r.connect_ms is not None]
if connects:
zero = [c for c in connects if c == 0]
proof = "reuse active" if zero else "no reuse observed"
lines.append(
f"- Connection reuse: connect=0ms on {len(zero)}/{len(connects)} "
f"cold opens ({proof})"
)
# Throttle signature -- a very high cold IMAP work time.
works = [r.work_ms for r in cold if not r.skipped and r.work_ms is not None]
if works:
peak = max(works)
note = " -- server-side throttle signature" if peak >= THROTTLE_WORK_MS else ""
lines.append(f"- Peak cold IMAP work: {peak} ms{note}")
lines.append("")
return "\n".join(lines)
def render_cold_fetch_ab(results: dict) -> str:
"""Assemble the whole cold-fetch A/B section: control summary, cold/warm tables, A/B delta."""
cold = results.get("cold", [])
warm = results.get("warm", [])
parts = [
render_gate_summary(results),
render_message_open("Cold opens (fetch-gate paused, uncached bodies)", cold),
render_message_open("Warm opens (gate resumed, cached re-open)", warm),
render_cold_warm_comparison(cold, warm),
]
return "\n".join(parts)
# --------------------------------------------------------------------------- #
# Document assembly
# --------------------------------------------------------------------------- #
def render_header(metadata: dict) -> str:
lines = ["# LibreMail device perf run", ""]
for key, value in metadata.items():
lines.append(f"- **{key}:** {value}")
lines.append("")
return "\n".join(lines)
def build_document(metadata: dict, sections: Sequence[str]) -> str:
parts = [render_header(metadata), *sections]
return "\n".join(parts).rstrip() + "\n"