agentleFS
Registry

Text rule trends

skill · published by agentleFS · version 1 · approved by frdchang@gmail.com on 2026-10-12

Measure how the rules in versioned text files change over time: extract, cluster into themes, and compare periods.

Mount
scripts/analyze.py
#!/usr/bin/env python3
"""Turn item lives into trends: read each file at an age, bin by period, pool samples, map
clusters to themes, label rise/fall/steady, compare before/after a date, and count the share
of one file kind among new files.

Usage:
  python3 analyze.py --sample NAME=DIR [--sample NAME=DIR ...] --out OUT
                     [--config config.json] [--themes themes.json] [options]

Every option can also be set in --config (a JSON object whose keys are the option names with
underscores, e.g. {"age": 30, "period": "quarter", "from": "2025Q4"}). The command line wins.

Options (defaults in brackets):
  --mode cohort|snapshot  [cohort] cohort: bin files by the period they were created in, and read
                          each file as it stood --age days after creation. snapshot: for every
                          period, read every file that existed as it stood on the period's last
                          day (deleted files drop out).
  --age N                 [30] days after creation (cohort mode)
  --created first-item|first-version  [first-item] a file's creation date: the birth of its
                          earliest item, or its first commit
  --period quarter|month|year  [quarter]
  --periods P1,P2,...     [every period with data] the bins to report, e.g. 2025Q2,2025Q3
  --from P --to P         [first and last bin] the two bins compared for rise/fall
  --threshold X           [0.15] a theme rises if its share of a file's items grows by more than
                          X (relative), falls if it shrinks by more than X, else steady. A theme
                          that starts at 0 and grows is "rise". If either bin holds fewer than
                          --min-files files, every theme is "too few".
  --min-files N           [20]
  --before A:B --after C:D  date windows [A, B) and [C, D) by creation date for the before/after
                          comparison. Or --split DATE: before = every file created earlier,
                          after = every file created on DATE or later. In both modes each file in a
                          window is read at creation + --age (snapshot mode too: a window compares
                          files by when they started, not calendar periods).
  --kinds K1,K2           [every kind, most files first] file kinds to report separately, in this
                          order (also the order of the bootstrap draws)
  --share A:B             kind A's share of new files among A and B, per period
  --boot N --seed S       [3000, 3] bootstrap resamples and numpy default_rng seed
  --today YYYY-MM-DD      [the current date] files younger than --age on this day are left out
                          of the bins. Set it to make a run repeatable.

Each sample DIR holds rules.json (extract.py) and labels.json (cluster.py). The first --sample
wins when a file is in more than one (it is counted once when pooling).

  --force-themes          apply --themes even when its centroids_id differs from the labels'.
                          Without it, analyze refuses: cluster numbers from another fit name
                          different clusters, and the themes would silently be wrong.

Writes to OUT (empty bins are null, never 0):
  pooled.json   {Q, nfiles, tot, med, pf, files_with_rules, overlap}: all samples pooled; per
                period, files, mean and median items per file, and mean items per file in each
                cluster (pf[i][cluster]). Q is the list of periods, whatever the unit.
  themes.json   {q, nfiles, tot, med, from_q, to_q, threshold, groups: [{name, kind, v,
                share_from, share_to}], rest, files_with_items, items, samples: {name:
                {history_files, files, files_with_items, items}}}. share_from/share_to are
                FRACTIONS (0.181 = 18.1%). The input to tufte-charts and watercolor-data-art.
  compare.json  per sample and kind: "periods" (n, mean, median, pf per period) and "window"
                (means, medians, and a bootstrap 95% CI of the difference in means, after minus
                before; "too_few": true when a side has fewer than --min-files files).
  share.json    per sample (with --share): new files per period by kind, counted by each file's
                first commit, whether it has items or not, leaving out files first committed after
                --today; over --periods if given, else every period. "share" is in PERCENT
                (87.2 = 87.2%). Also "files" (files with a version) and "history_files".
"""
import argparse
import collections
import json
import os
import sys
from datetime import date, timedelta

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import period_end, period_of, period_range, period_start  # noqa: E402


def load(root, k, a, today):
    R = json.load(open(os.path.join(root, "rules.json")))
    life = R["life"]
    labels = json.load(open(os.path.join(root, "labels.json")))["labels"]
    if len(labels) != len(life):
        raise SystemExit(f"{root}: labels.json does not match rules.json")
    files = R.get("files", {})
    first, kind = {}, {}
    for r in life:
        first[r["repo"]] = min(first.get(r["repo"], "9999"), r["born"][:10])
        kind[r["repo"]] = r["kind"]
    if a.created == "first-version":
        for f in first:
            key = f[: -len(".tsv.gz")] if f.endswith(".tsv.gz") else f[: -len(".tsv")]
            if key in files:
                first[f] = files[key]["first"][:10]
    deleted = {}
    for f in first:
        key = f[: -len(".tsv.gz")] if f.endswith(".tsv.gz") else f[: -len(".tsv")]
        deleted[f] = (files.get(key, {}).get("deleted") or "9999")[:10]
    return {"first": first, "kind": kind, "life": life, "labels": labels, "deleted": deleted,
            "files": files, "nrules": len(life), "k": k, "age": a.age, "today": today}


def count_at(D, when):
    """Items alive per file on a date: when(file) -> 'YYYY-MM-DD' or None (file not counted)."""
    k = D["k"]
    cnt = {f: 0 for f in D["first"]}
    cl = {f: np.zeros(k) for f in D["first"]}
    at = {f: when(f) for f in D["first"]}
    for r, l in zip(D["life"], D["labels"]):
        t = at[r["repo"]]
        if t and r["born"][:10] <= t and (r["died"] or "9999")[:10] > t:
            cnt[r["repo"]] += 1
            cl[r["repo"]][l] += 1
    return cnt, cl


def window(s):
    a, b = s.split(":")
    return a, b


def main():
    pre = argparse.ArgumentParser(add_help=False)
    pre.add_argument("--config")
    c, _ = pre.parse_known_args()
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--config")
    ap.add_argument("--sample", action="append", required=True, help="NAME=DIR, repeatable")
    ap.add_argument("--themes")
    ap.add_argument("--out", required=True)
    ap.add_argument("--mode", default="cohort", choices=["cohort", "snapshot"])
    ap.add_argument("--today", default=date.today().isoformat())
    ap.add_argument("--age", type=int, default=30)
    ap.add_argument("--created", default="first-item", choices=["first-item", "first-version"])
    ap.add_argument("--period", default="quarter", choices=["quarter", "month", "year"])
    ap.add_argument("--periods")
    ap.add_argument("--from", dest="from_", metavar="P")
    ap.add_argument("--to", metavar="P")
    ap.add_argument("--threshold", type=float, default=0.15)
    ap.add_argument("--min-files", type=int, default=20)
    ap.add_argument("--before")
    ap.add_argument("--after")
    ap.add_argument("--split")
    ap.add_argument("--kinds")
    ap.add_argument("--share")
    ap.add_argument("--boot", type=int, default=3000)
    ap.add_argument("--seed", type=int, default=3)
    ap.add_argument("--force-themes", action="store_true", help="apply the themes file even if its centroids_id differs from the labels'")
    if c.config:
        cfg = json.load(open(c.config))
        cfg = {("from_" if k == "from" else k.replace("-", "_")): (",".join(v) if isinstance(v, list) else v)
               for k, v in cfg.items() if not k.startswith("_")}
        ap.set_defaults(**cfg)
    a = ap.parse_args()

    os.makedirs(a.out, exist_ok=True)
    today = date.fromisoformat(a.today)
    samples = [(s.split("=", 1)[0], s.split("=", 1)[1]) for s in a.sample]
    for name, root in samples:
        for f, step in (("rules.json", "extract.py"), ("labels.json", "cluster.py fit or assign")):
            if not os.path.exists(os.path.join(root, f)):
                raise SystemExit(f"{root}/{f} is missing: run {step} on this sample first")
    labs = {name: json.load(open(os.path.join(root, "labels.json"))) for name, root in samples}
    k_lab = max(l["k"] for l in labs.values())
    ids = {name: l.get("centroids_id") for name, l in labs.items()}
    if len(set(ids.values())) > 1:
        msg = "samples were labelled against different centroids: " + ", ".join(f"{n} {i}" for n, i in ids.items())
        if not a.force_themes:
            raise SystemExit(msg + ". Assign every sample to the same centroids file (cluster.py assign), or pass --force-themes.")
        print("warning: " + msg)
    if a.themes:
        cfg = json.load(open(a.themes))
        K = max(k_lab, 1 + max(c for t in cfg["themes"] for c in t["clusters"]))
        theme_list = cfg["themes"]
        want, have = cfg.get("centroids_id"), next(iter(ids.values()))
        if want and have and want != have:
            msg = (f"{a.themes} names the clusters of centroids {want}, but the labels come from {have}. "
                   "Cluster numbers from another fit mean different things, so the themes would be wrong")
            if not a.force_themes:
                raise SystemExit(msg + ". Assign the samples to the centroids the themes file was written for "
                                 "(cluster.py assign --centroids ...), or write a themes file for this fit. "
                                 "--force-themes overrides.")
            print("warning: " + msg)
        elif not want:
            print(f"note: {a.themes} has no centroids_id, so it cannot be checked against the labels")
    else:
        K = k_lab
        theme_list = [{"name": f"cluster {c}", "clusters": [c]} for c in range(K)]
    data = {name: load(root, K, a, today) for name, root in samples}
    per = lambda d: period_of(d, a.period)

    # cohort: each file at creation + age; snapshot: each file at the end of every period
    for D in data.values():
        if a.mode == "cohort":
            when = lambda f, D=D: (date.fromisoformat(D["first"][f]) + timedelta(days=a.age)).isoformat()
            D["cnt"], D["cl"] = count_at(D, when)
            D["ok"] = {f for f, d in D["first"].items() if date.fromisoformat(d) + timedelta(days=a.age) <= today}

    if a.periods:
        Q = a.periods.split(",")
    else:
        firsts = [d for D in data.values() for d in D["first"].values()]
        if a.mode == "snapshot":  # periods that have ended by --today
            p = per(today.isoformat())
            if period_end(p) > today:
                p = per((period_start(p) - timedelta(days=1)).isoformat())
            last = p
        else:
            ok = [d for D in data.values() for f, d in D["first"].items() if f in D["ok"]]
            if not ok:
                raise SystemExit(f"no file is {a.age} days old by {a.today}")
            last = per(max(ok))
        Q = period_range(per(min(firsts)), last, a.period)

    def bin_files(D, q):
        """(files, counts, cluster counts) for one sample in one period."""
        if a.mode == "cohort":
            fs = [f for f in D["first"] if per(D["first"][f]) == q and f in D["ok"]]
            return fs, D["cnt"], D["cl"]
        end = period_end(q).isoformat()
        fs = [f for f in D["first"] if D["first"][f] <= end and D["deleted"][f] > end]
        cnt, cl = count_at(D, lambda f: end)
        return fs, cnt, cl

    # ---- per sample, per kind: periods and the before/after windows
    rng = np.random.default_rng(a.seed)

    def ci(x, y):
        bs = [rng.choice(y, len(y)).mean() - rng.choice(x, len(x)).mean() for _ in range(a.boot)]
        return [round(float(v), 1) for v in np.percentile(bs, [2.5, 97.5])]

    if a.split:
        b0, b1, a0, a1 = "0000-00-00", a.split, a.split, "9999-99-99"   # open-ended; printed as "before/from DATE"
    elif a.before and a.after:
        (b0, b1), (a0, a1) = window(a.before), window(a.after)
    else:
        b0 = b1 = a0 = a1 = None
    if a.kinds:
        KINDS = a.kinds.split(",")
    else:
        kc = collections.Counter(D["kind"][f] for D in data.values() for f in D["first"])
        KINDS = sorted(kc, key=lambda x: (-kc[x], x))
    if a.split:
        wb, wa = f"created before {a.split}", f"created {a.split} or later"
    else:
        wb, wa = f"{b0}..{b1}", f"{a0}..{a1}"
    compare = {"Q": Q, "today": a.today, "mode": a.mode, "age_days": a.age, "before": [None, a.split] if a.split else [b0, b1], "after": [a.split, None] if a.split else [a0, a1]}
    for name, _ in samples:
        D = data[name]
        res = {"files_with_rules": len(D["first"]), "rules": D["nrules"]}
        print(f"== {name}: {len(D['first'])} files with items, {D['nrules']} items")
        for kd in KINDS:
            rows = []
            for q in Q:
                allf, cnt, cl = bin_files(D, q)
                fs = [f for f in allf if D["kind"][f] == kd]
                n = len(fs)
                rows.append({"n": n,
                             "tot": sum(cnt[f] for f in fs) / n if n else None,
                             "med": float(np.median([cnt[f] for f in fs])) if n else None,
                             "pf": (sum((cl[f] for f in fs), np.zeros(K)) / n).tolist() if n else None})
            w = {}
            if b0 is not None:
                if a.mode == "cohort":
                    cnt = D["cnt"]
                else:  # snapshot mode still compares files by creation, each read at creation + age
                    cnt, _ = count_at(D, lambda f: (date.fromisoformat(D["first"][f]) + timedelta(days=a.age)).isoformat())
                x = np.array([cnt[f] for f in D["first"] if D["kind"][f] == kd and b0 <= D["first"][f] < b1])
                y = np.array([cnt[f] for f in D["first"] if D["kind"][f] == kd and a0 <= D["first"][f] < a1])
                w = {"n_before": len(x), "n_after": len(y)}
                if len(x) and len(y):
                    w.update(mean_before=float(x.mean()), mean_after=float(y.mean()),
                             median_before=float(np.median(x)), median_after=float(np.median(y)),
                             diff=round(float(y.mean() - x.mean()), 1), ci95=ci(x, y))
                    print(f"  {kd} {wb} -> {wa}: n {len(x)} -> {len(y)}, mean {x.mean():.1f} -> {y.mean():.1f}, "
                          f"median {np.median(x)} -> {np.median(y)}, diff {w['diff']:+.1f} CI {w['ci95']}")
                if min(len(x), len(y)) < a.min_files:
                    w["too_few"] = True
                    print(f"  note: fewer than {a.min_files} files on one side of the {kd} window; too few to tell")
            res[kd] = {"periods": rows, "window": w}
        compare[name] = res
    json.dump(compare, open(os.path.join(a.out, "compare.json"), "w"), indent=1)

    # ---- pooled across samples, a file counted once (first sample wins)
    owner = {}
    for name, _ in samples:
        for f in data[name]["first"]:
            owner.setdefault(f, name)
    overlap = sum(len(data[n]["first"]) for n, _ in samples) - len(owner)
    pooled = {"Q": Q, "nfiles": [], "tot": [], "med": [], "pf": [], "files_with_rules": len(owner), "overlap": overlap}
    for q in Q:
        vals, cls = [], []
        for name, _ in samples:
            D = data[name]
            fs, cnt, cl = bin_files(D, q)
            for f in fs:
                if owner[f] == name:
                    vals.append(cnt[f])
                    cls.append(cl[f])
        n = len(vals)
        pooled["nfiles"].append(n)
        pooled["tot"].append(sum(vals) / n if n else None)
        pooled["med"].append(float(np.median(vals)) if n else None)
        pooled["pf"].append((sum(cls, np.zeros(K)) / n).tolist() if n else None)
    json.dump(pooled, open(os.path.join(a.out, "pooled.json"), "w"))
    print(f"== pooled ({a.mode}): {len(owner)} files with items ({overlap} in more than one sample)")
    for i, q in enumerate(Q):
        if pooled["nfiles"][i]:
            print(f"  {q}: files {pooled['nfiles'][i]:5d}  mean {pooled['tot'][i]:5.2f}  median {pooled['med'][i]}")
        else:
            print(f"  {q}: no files")

    # ---- themes
    qf, qt = a.from_ or Q[0], a.to or Q[-1]
    i0, i1 = Q.index(qf), Q.index(qt)
    few = min(pooled["nfiles"][i0], pooled["nfiles"][i1]) < a.min_files
    named, groups = set(), []
    for t in theme_list:
        v = [sum(pf[c] for c in t["clusters"]) if pf is not None else None for pf in pooled["pf"]]
        t0, t1 = pooled["tot"][i0] or 0.0, pooled["tot"][i1] or 0.0
        # an empty compared bin has no share at all: null, never 0
        s0 = v[i0] / t0 if pooled["nfiles"][i0] and t0 else (0.0 if pooled["nfiles"][i0] else None)
        s1 = v[i1] / t1 if pooled["nfiles"][i1] and t1 else (0.0 if pooled["nfiles"][i1] else None)
        if few or s0 is None or s1 is None:
            kind = "too few"
        elif s0 == 0:
            kind = "rise" if s1 > 0 else "steady"
        else:
            change = s1 / s0 - 1
            kind = "rise" if change > a.threshold else "fall" if change < -a.threshold else "steady"
        groups.append({"name": t["name"], "kind": kind, "v": v, "share_from": s0, "share_to": s1})
        named |= set(t["clusters"])
    rest = [pooled["tot"][i] - sum(g["v"][i] for g in groups) if pooled["nfiles"][i] else None for i in range(len(Q))]
    samples_info = {}
    for name, root in samples:
        R_ = json.load(open(os.path.join(root, "rules.json")))
        samples_info[name] = {"history_files": R_.get("nhistory", R_["nfiles"]), "files": R_["nfiles"],
                              "files_with_items": len(data[name]["first"]), "items": data[name]["nrules"]}
    themes = {"q": Q, "nfiles": pooled["nfiles"], "tot": pooled["tot"], "med": pooled["med"],
              "files_with_items": len(owner), "items": sum(d["nrules"] for d in data.values()), "samples": samples_info,
              "from_q": qf, "to_q": qt, "threshold": a.threshold, "groups": groups, "rest": rest}
    json.dump(themes, open(os.path.join(a.out, "themes.json"), "w"), indent=1)
    print(f"== themes, share of a file's items {qf} -> {qt}" + (f" (too few files: fewer than {a.min_files} in a compared bin)" if few else ""))
    for g in groups[::-1]:
        pct = lambda x: "    – " if x is None else f"{x * 100:5.1f}%"
        print(f"  {g['kind']:7} {g['name'][:42]:42} {pct(g['share_from'])} -> {pct(g['share_to'])}")
    if len(named) < K:
        print(f"  ({K - len(named)} clusters in no theme; their items are in 'rest')")

    # ---- share of kind A among new files of kinds A and B
    if a.share:
        A, B = a.share.split(":")
        share = {}
        for name, root in samples:
            files = json.load(open(os.path.join(root, "rules.json"))).get("files")
            if files is None:
                raise SystemExit(f"{root}/rules.json has no 'files'; run extract.py from this skill")
            fc = {k: (v["kind"], v["first"]) for k, v in files.items() if v["first"][:10] <= a.today}
            qs = Q if a.periods else sorted({per(d) for _, d in fc.values()})
            c = collections.Counter((kd, per(d)) for kd, d in fc.values())
            row = {"Q": qs, "files": len(fc), "history_files": samples_info[name]["history_files"]}
            for kd in dict.fromkeys(KINDS + [A, B]):
                row[kd] = [c[(kd, q)] for q in qs]
            row["share_of"], row["share_vs"] = A, B
            row["share"] = [round(100 * c[(A, q)] / (c[(A, q)] + c[(B, q)]), 1) if c[(A, q)] + c[(B, q)] else None for q in qs]
            share[name] = row
            print(f"== {name}: {A} share of new {A} + {B} files: " + ", ".join(f"{q} {s}%" if s is not None else f"{q} –" for q, s in zip(qs, row["share"])))
        json.dump(share, open(os.path.join(a.out, "share.json"), "w"), indent=1)


if __name__ == "__main__":
    main()