CIS490/tools/plot_envelope.py
Maximus Gorog 970698af83 Synthetic envelope demo: phase-driven load mimic + plotter
End-to-end pipeline now produces a labeled envelope from a single command.
Drives the orchestrator through an 8-phase XMRig-shaped schedule and
renders a 3-panel envelope (CPU%, RSS, IO write rate) with phase bands
sourced from labels.jsonl. Real telemetry, simulated load — validates the
collection + labeling shape before a real VM is involved.

Components:
- tools/load_mimic.py        phase-driven load generator. Reads phase
                             commands on stdin; CPU/IO behavior matches
                             the named phase (clean=idle, armed=light burst,
                             infecting=disk burst+CPU, infected_running=
                             CPU saturation+stratum-shaped writes,
                             dormant=quieter than clean).
- tools/run_envelope_demo.py spawns load_mimic, drives EpisodeRunner with
                             a default 85s schedule that includes the
                             classic infected_running → dormant → re-entry
                             pattern.
- tools/plot_envelope.py     reads telemetry + labels from an episode dir,
                             writes envelope.png with colored phase bands.

orchestrator: EpisodeRunner now takes an optional phase_schedule and an
on_phase callback. Walks the schedule emitting one label per transition.
Backwards-compatible — existing single-phase tests still green.

Doc fix (user pushback): README + architecture + threat-model no longer
imply the Pi5 is the deployment target. Pi5's actual role here is the
WireGuard-side collector for episode tarballs. Deployment target is
generic ("constrained Linux device"). The "gateway observer" concept
remains a deployment pattern, decoupled from the Pi5's collector role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:53:20 -06:00

130 lines
3.7 KiB
Python

"""Plot a single episode's envelope.
Reads ``telemetry-proc.jsonl`` and ``labels.jsonl`` from an episode directory
and renders a 3-panel PNG: CPU%, RSS, IO write rate, with phase bands
underneath. Saves to ``<episode_dir>/envelope.png``.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
PHASE_COLORS = {
"clean": "#9bd09b",
"armed": "#f0d27a",
"infecting": "#ec9b58",
"infected_running": "#d05757",
"dormant": "#6f8ad6",
"reverting": "#bbbbbb",
}
def _load_jsonl(path: Path) -> list[dict]:
return [json.loads(l) for l in path.read_text().splitlines() if l.strip()]
def main() -> int:
parser = argparse.ArgumentParser(prog="plot_envelope")
parser.add_argument("episode_dir", type=Path)
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args()
d: Path = args.episode_dir
if not d.exists():
print(f"no such directory: {d}", file=sys.stderr)
return 2
proc_rows = _load_jsonl(d / "telemetry-proc.jsonl")
labels = _load_jsonl(d / "labels.jsonl")
if not proc_rows:
print("no proc telemetry rows found", file=sys.stderr)
return 2
t = [r["t_mono_ns"] / 1e9 for r in proc_rows]
cpu_jiffies = [r["cpu_user_jiffies"] + r["cpu_sys_jiffies"] for r in proc_rows]
rss_mib = [r["rss_bytes"] / (1024 * 1024) for r in proc_rows]
io_w = [r["io_write_bytes"] or 0 for r in proc_rows]
clk_tck = os.sysconf("SC_CLK_TCK")
cpu_pct: list[float] = [0.0]
io_kb_s: list[float] = [0.0]
for i in range(1, len(proc_rows)):
dt = t[i] - t[i - 1]
if dt <= 0:
cpu_pct.append(0.0)
io_kb_s.append(0.0)
continue
d_jiffies = cpu_jiffies[i] - cpu_jiffies[i - 1]
cpu_pct.append(100.0 * (d_jiffies / clk_tck) / dt)
io_kb_s.append(((io_w[i] - io_w[i - 1]) / 1024.0) / dt)
end_t = t[-1] if t else 0.0
spans: list[tuple[float, float, str]] = []
for i, lbl in enumerate(labels):
start = lbl["t_mono_ns"] / 1e9
end = labels[i + 1]["t_mono_ns"] / 1e9 if i + 1 < len(labels) else end_t
spans.append((start, end, lbl["phase"]))
fig, axes = plt.subplots(3, 1, figsize=(13, 8), sharex=True)
axes[0].plot(t, cpu_pct, color="#222222", linewidth=1.0)
axes[0].set_ylabel("CPU %")
axes[0].set_ylim(-3, 110)
axes[0].grid(alpha=0.25)
axes[1].plot(t, rss_mib, color="#222222", linewidth=1.0)
axes[1].set_ylabel("RSS (MiB)")
axes[1].grid(alpha=0.25)
axes[2].plot(t, io_kb_s, color="#222222", linewidth=1.0)
axes[2].set_ylabel("IO write (KiB/s)")
axes[2].set_xlabel("time (s)")
axes[2].grid(alpha=0.25)
for ax in axes:
for start, end, phase in spans:
ax.axvspan(
start, end,
color=PHASE_COLORS.get(phase, "#cccccc"),
alpha=0.30,
linewidth=0,
)
legend_handles = [
Patch(facecolor=PHASE_COLORS[p], alpha=0.5, label=p)
for p in PHASE_COLORS
if any(s[2] == p for s in spans)
]
axes[0].legend(
handles=legend_handles,
loc="upper right",
ncols=len(legend_handles),
fontsize=9,
framealpha=0.85,
)
fig.suptitle(
f"Episode {d.name} — envelope ({len(proc_rows)} samples, {end_t:.1f}s)"
)
fig.tight_layout()
out = args.out or (d / "envelope.png")
fig.savefig(out, dpi=120)
print(f"wrote {out}")
return 0
if __name__ == "__main__":
sys.exit(main())