Files
felhom.eu/documentation/tests/campaign10-evidence-2026-07-31/runner/c10growth_report.py
T
admin ac6c05bd7b Campaign 10: add monotonic-growth sampling — the half the invariants cannot see
I1-I11 are CORRECTNESS invariants: they answer 'is the system telling the truth
this cycle'. All 586 of them passed in run 1 while nothing at all watched whether
disk usage, snapshot count, log volume, fd count or RSS climbs. Accumulation is
exactly what depth was for, and it was missing from the invariant list.

Adds c10growth.py (Campaign 2's controller_rss.tsv precedent, widened to 19
metrics) sampling every 90s as a SEPARATE process, so the in-flight run 2 did not
have to be restarted. Attributes every sample to a cycle by reading the runner's
status.txt, and records NA rather than dying when the box is down during a
hard-reset or reboot atom.

c10growth_report.py turns it into Campaign 2's table shape (start/end/min/max/
slope-per-cycle) and splits verdicts by class: growth in RSS/fd/volumes/images/
restarts is a LEAK; growth in backup storage or the qcow2 is expected
accumulation, reported with a projection to cycle 45.

Caught a bug in the sampler itself on the first analysis: MENTES_USED_MB appeared
to jump 623 -> 5667 MB, which is exactly ROOT_USED_MB — when a drive is detached,
/mnt/<name> reverts to a plain directory on root and df silently reports the ROOT
filesystem. The same class of error as the agent's exactMount check, in the
measurement code. Gated on mountpoint and red-proofed both ways: a real mount
returns a number, a non-mount returns NA.
2026-08-01 17:48:04 +02:00

93 lines
3.6 KiB
Python

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