#!/usr/bin/env python3 """Campaign 10 — turn growth.tsv into a verdict per metric (Campaign 2's table shape). The question is NOT "did it grow" — a backup soak SHOULD grow backup storage. It is "does anything grow WITHOUT BOUND, per cycle, in a way 45 cycles would not survive". So each metric gets: start, end, min, max, per-cycle slope (least squares over cycle number), and a verdict that distinguishes expected accumulation from a leak. """ import collections, os, sys STATE = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/tests/campaign10-evidence-2026-07-31/state" SRC = os.path.join(STATE, "growth.tsv") # metrics where monotonic growth is a LEAK, vs where it is expected accumulation LEAK_IF_GROWING = { "RSS_CTRL": "controller memory", "RSS_AGENT_KB": "agent memory", "FD_CTRL": "controller file descriptors", "FD_AGENT": "agent file descriptors", "N_VOLUMES": "docker volumes (redeploys must not orphan)", "N_IMAGES": "docker images", "N_CONTAINERS": "running containers", "CTRL_RESTARTS": "controller restart count", } EXPECTED_GROWTH = { "BACKUPS_MB": "recovery units", "MENTES_USED_MB": "backup target", "VZDUMP_N": "whole-guest dumps", "VMDISK_MB": "qcow2 on the host", "LOGS_MB": "docker logs", "JOURNAL_MB": "guest journal", "AGENT_JOURNAL_MB": "agent journal", "ROOT_USED_MB": "guest root fs", "ADATOK_USED_MB": "data drive", "PGVOL_MB": "postgres volume", "NVME_AVAIL_MB": "host free space (falls)", } def tofloat(v): v = (v or "").strip().rstrip("MG") try: return float(v) except Exception: return None def main(): series = collections.defaultdict(list) # metric -> [(cycle, value)] if not os.path.exists(SRC): print("no growth.tsv yet"); return for line in open(SRC): f = line.rstrip("\n").split("\t") if len(f) != 5 or f[0] == "epoch": continue _, _, cy, metric, val = f v = tofloat(val) try: c = int(cy) except Exception: continue if v is not None: series[metric].append((c, v)) print("%-18s %10s %10s %10s %10s %12s %s" % ("metric", "start", "end", "min", "max", "slope/cycle", "verdict")) print("-" * 104) concerns = [] for m in sorted(series): pts = series[m] if len(pts) < 3: continue xs = [p[0] for p in pts]; ys = [p[1] for p in pts] n = len(xs); mx = sum(xs) / n; my = sum(ys) / n den = sum((x - mx) ** 2 for x in xs) slope = (sum((xs[i] - mx) * (ys[i] - my) for i in range(n)) / den) if den else 0.0 start, end, lo, hi = ys[0], ys[-1], min(ys), max(ys) span = max(xs) - min(xs) # projection to cycle 45 from the last observed point proj = end + slope * max(0, 45 - max(xs)) if m in LEAK_IF_GROWING: rel = (slope * max(span, 1)) / (abs(start) or 1) verdict = "LEAK-SUSPECT" if (slope > 0 and rel > 0.25) else "stable — no leak" if verdict.startswith("LEAK"): concerns.append((m, slope, end, proj)) else: label = EXPECTED_GROWTH.get(m, "") verdict = "expected accumulation (%s); proj@c45=%.0f" % (label, proj) print("%-18s %10.1f %10.1f %10.1f %10.1f %12.2f %s" % (m, start, end, lo, hi, slope, verdict)) print() if concerns: print("CONCERNS (growth where growth is a leak):") for m, s, e, p in concerns: print(" %s slope=%.2f/cycle now=%.1f projected@c45=%.1f" % (m, s, e, p)) else: print("No leak-class metric grew materially.") if __name__ == "__main__": main()