#!/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()
