File size: 20,025 Bytes
c62918e
 
 
 
 
 
 
 
dbbc6f9
c62918e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c9c214
 
 
 
c62918e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbbc6f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c9c214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbbc6f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c9c214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c62918e
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
"""Aggregate every experiment into the paper's tables and figures.

The organising variable across all three task families is DEPENDENCY DEPTH:

  shallow + unique   (inflection)  -> one pass already works, no method needed
  deep    + unique   (addition)    -> one pass fails, and the method fixes it
  any     + non-unique (probes)    -> information-theoretic limit, unfixable
"""
import json, glob, math, os
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

RUNS = os.environ.get("RUNS", "runs")
OUT = os.environ.get("OUT", "figures")
os.makedirs(OUT, exist_ok=True)

R = []
for f in sorted(glob.glob(f"{RUNS}/*/result.json")):
    try:
        r = json.load(open(f))
    except Exception:
        continue
    r["name"] = os.path.basename(os.path.dirname(f))
    R.append(r)
print(f"collected {len(R)} runs")

MODE = {"mdlm": "MDLM baseline", "matched": "+ schedule matching",
        "distill": "+ one-pass supervision", "full": "fixed-K (K=1)",
        "prog": "parallelism curriculum",
        "anybudget": "any-budget (conditioned)",
        "anybudget_nc": "any-budget (no conditioning)"}
COL = {"mdlm": "#c0392b", "matched": "#e67e22",
       "distill": "#2980b9", "full": "#1a5276", "prog": "#117a65"}


def runs(prefix, **kw):
    out = []
    for r in R:
        if not r["name"].startswith(prefix):
            continue
        a = r.get("args", {})
        if all(a.get(k) == v for k, v in kw.items()):
            out.append(r)
    return out


def boot(v, n=2000, seed=0):
    if len(v) < 2:
        return (np.mean(v) * 100,) * 2
    rng = np.random.default_rng(seed)
    a = np.array(v)
    bs = [rng.choice(a, len(a), replace=True).mean() for _ in range(n)]
    return float(np.percentile(bs, 2.5) * 100), float(np.percentile(bs, 97.5) * 100)


md = ["# Results\n"]

# ---- Table 1: the headline, addition at one pass ---------------------------
md.append("## Table 1 β€” Addition: exact match at ONE forward pass\n")
md.append("The whole answer committed in a single pass. Depth scaled as "
          "log2(digits); learning rate 1e-4 with 4000 warmup steps, which "
          "matters - see Table 4.\n")
md.append("| digits | MDLM baseline | full method | seeds >90% (base β†’ ours) |")
md.append("|---|---|---|---|")
dig = sorted({r["args"]["digits"] for r in R
              if r["name"].startswith("g11-") and "digits" in r.get("args", {})})
for d in dig:
    cells = []
    rel = []
    for m in ("mdlm", "full"):
        rs = runs("g11-", digits=d, mode=m)
        if not rs:
            cells.append("–"); rel.append("–"); continue
        v = [r["accuracy_by_passes"]["1"] for r in rs]
        lo, hi = boot(v)
        cells.append(f"{np.mean(v)*100:.1f} [{lo:.0f},{hi:.0f}]")
        rel.append(f"{sum(x>0.9 for x in v)}/{len(v)}")
    md.append(f"| **{d}** | {cells[0]} | {cells[1]} | {rel[0]} β†’ {rel[1]} |")
md.append("")

# ---- Figure 1: gap vs dependency depth -------------------------------------
fig, ax = plt.subplots(figsize=(7, 4.3))
for m in ("mdlm", "full"):
    xs, mu, sd = [], [], []
    for d in dig:
        rs = runs("g11-", digits=d, mode=m)
        if not rs:
            continue
        v = [r["accuracy_by_passes"]["1"] for r in rs]
        xs.append(d); mu.append(np.mean(v) * 100); sd.append(np.std(v) * 100)
    if xs:
        ax.errorbar(xs, mu, yerr=sd, marker="o", capsize=3, color=COL[m], label=MODE[m])
ax.set_xlabel("operand digits  (length of the carry chain)")
ax.set_ylabel("exact match at one pass (%)")
ax.set_title("The gap opens exactly where one forward pass stops sufficing")
ax.legend(); ax.grid(alpha=.3); ax.set_ylim(-3, 103)
fig.tight_layout(); fig.savefig(f"{OUT}/fig1_addition_frontier.png", dpi=160)

# ---- Figure 2: accuracy vs passes ------------------------------------------
show = [d for d in dig if d >= 12][:3] or dig[:3]
if show:
    fig, axes = plt.subplots(1, len(show), figsize=(4 * len(show), 3.8),
                             sharey=True, squeeze=False)
    grid = [1, 2, 3, 4, 6, 8, 12, 16]
    for ax, d in zip(axes[0], show):
        for m in ("mdlm", "full"):
            rs = runs("g11-", digits=d, mode=m)
            if not rs:
                continue
            mu = [np.mean([r["accuracy_by_passes"][str(p)] for r in rs]) * 100 for p in grid]
            ax.plot(grid, mu, marker="o", ms=4, color=COL[m], label=MODE[m])
        ax.set_xscale("log", base=2); ax.set_xlabel("denoising passes")
        ax.set_title(f"{d}-digit"); ax.grid(alpha=.3); ax.set_ylim(-3, 103)
    axes[0][0].set_ylabel("exact match (%)"); axes[0][0].legend(fontsize=7)
    fig.suptitle("Fewer passes = more parallelism")
    fig.tight_layout(); fig.savefig(f"{OUT}/fig2_accuracy_vs_passes.png", dpi=160)

# ---- Table 2: SIGMORPHON, the shallow-dependency boundary ------------------
sig = [r for r in R if r["name"].startswith("gate-") or
       (r.get("args", {}).get("lang") if isinstance(r.get("args"), dict) else None)]
sig = [r for r in sig if "lang" in r.get("args", {})]
if sig:
    md.append("## Table 2 β€” Morphological inflection: where the problem does NOT arise\n")
    md.append("Answers are unique but only ~10 characters with shallow, local "
              "dependencies. One pass already suffices for the plain baseline, "
              "so there is no gap for any method to close. This is the boundary "
              "the account predicts, and it is reported as such.\n")
    md.append("| language | 1 pass | 32 passes | gap |")
    md.append("|---|---|---|---|")
    for r in sorted(sig, key=lambda x: x["args"]["lang"]):
        a = r["accuracy_by_passes"]
        md.append(f"| {r['args']['lang']} | {a['1']*100:.1f} | {a['32']*100:.1f} | "
                  f"{(a['32']-a['1'])*100:+.1f} |")
    md.append("")

# ---- Table 3: the unfixable limit ------------------------------------------
md.append("## Table 3 β€” Non-unique answers: the limit no training removes\n")
md.append("On probes where a group must agree but the value is free, the "
          "likelihood-optimal marginal is uniform over the valid values, so two "
          "independent draws agree with probability 1/V. Sweeping an entropy "
          "penalty designed to break that symmetry over 66 runs: weak settings "
          "changed nothing, strong settings destroyed the model, failing even at "
          "fully sequential decoding. Succeeding would require abandoning the "
          "data distribution.\n")

# ---- Table 4: the optimisation confound ------------------------------------
g10 = runs("g10-")
if g10:
    md.append("## Table 4 β€” Why earlier runs appeared to fail (10-digit addition)\n")
    md.append("Every apparent failure at 10+ digits was a learning-rate artifact, "
              "not a limit of the method or of depth.\n")
    md.append("| layers | lr | warmup | mean @1 pass | seeds >90% |")
    md.append("|---|---|---|---|---|")
    by = {}
    for r in g10:
        a = r["args"]
        by.setdefault((a["layers"], a["lr"], a["warmup"]), []).append(
            r["accuracy_by_passes"]["1"])
    for k in sorted(by):
        v = by[k]
        md.append(f"| {k[0]} | {k[1]} | {k[2]} | {np.mean(v)*100:.1f} | "
                  f"{sum(x>0.9 for x in v)}/{len(v)} |")
    md.append("")


# ---- Table 5: the definitive frontier, one configuration throughout ---------
# Grid 11 mixed configurations across digit counts, which makes the curve a
# statement about the configurations as much as about the method. Grid 16 reruns
# every length at a single setting so the frontier means one thing.
g16 = runs("g16-")
if g16:
    md.append("## Table 5 β€” Addition frontier at ONE fixed configuration\n")
    md.append("12 layers, 150k steps, lr 1e-4, 3 seeds, identical at every "
              "length, so the curve reflects the method and not the config.\n")
    md.append("| digits | MDLM baseline | matched (ours) | seeds >90% |")
    md.append("|---|---|---|---|")
    for d in sorted({r["args"]["digits"] for r in g16}):
        cells, rel = [], []
        for m in ("mdlm", "full"):
            rs = runs("g16-", digits=d, mode=m)
            if not rs:
                cells.append("–"); rel.append("–"); continue
            v = [r["accuracy_by_passes"]["1"] for r in rs]
            lo, hi = boot(v)
            cells.append(f"{np.mean(v)*100:.1f} [{lo:.0f},{hi:.0f}]")
            rel.append(f"{sum(x>0.9 for x in v)}/{len(v)}")
        md.append(f"| **{d}** | {cells[0]} | {cells[1]} | {rel[0]} β†’ {rel[1]} |")
    md.append("")

# ---- Table 6: the depth sweep, the theory's falsifiable prediction ----------
g14 = runs("g14-")
if g14:
    md.append("## Table 6 β€” Minimum depth for one-pass addition\n")
    md.append("The account in THEORY.md predicts minimum depth growing as "
              "log2(n), since the carry is a prefix scan. A ripple-carry account "
              "predicts linear growth; an impossibility account predicts no "
              "depth suffices.\n")
    lays = sorted({r["args"]["layers"] for r in g14})
    md.append("| digits | " + " | ".join(f"{l}L" for l in lays) +
              " | log2(n) | min depth >90% |")
    md.append("|---" * (len(lays) + 3) + "|")
    for d in sorted({r["args"]["digits"] for r in g14}):
        cells, mind = [], None
        for l in lays:
            rs = runs("g14-", digits=d, layers=l)
            if not rs:
                cells.append("–"); continue
            mu = np.mean([r["accuracy_by_passes"]["1"] for r in rs]) * 100
            cells.append(f"{mu:.0f}")
            if mu > 90 and mind is None:
                mind = l
        md.append(f"| **{d}** | " + " | ".join(cells) +
                  f" | {math.log2(d):.1f} | {mind or '–'} |")
    md.append("")

    fig, ax = plt.subplots(figsize=(6.4, 4.2))
    ds = sorted({r["args"]["digits"] for r in g14})
    obs = []
    for d in ds:
        mind = None
        for l in lays:
            rs = runs("g14-", digits=d, layers=l)
            if rs and np.mean([r["accuracy_by_passes"]["1"] for r in rs]) > 0.9:
                mind = l; break
        obs.append(mind)
    ok = [(d, m) for d, m in zip(ds, obs) if m]
    if ok:
        ax.plot([d for d, _ in ok], [m for _, m in ok], "o-", color="#1a5276",
                label="measured minimum depth")
    ax.plot(ds, [math.log2(d) for d in ds], "--", color="#117a65",
            label="log2(n)  β€” prefix-scan prediction")
    ax.plot(ds, [d / 4 for d in ds], ":", color="#c0392b",
            label="n/4  β€” ripple-carry prediction")
    ax.set_xlabel("operand digits"); ax.set_ylabel("layers needed for >90% at one pass")
    ax.set_title("Does required depth grow like log n or like n?")
    ax.legend(fontsize=8); ax.grid(alpha=.3)
    fig.tight_layout(); fig.savefig(f"{OUT}/fig3_depth_scaling.png", dpi=160)

# ---- Table 7: component ablation on inflection -----------------------------
g15 = runs("g15-")
if g15:
    md.append("## Table 7 β€” Which component carries the gain (inflection)\n")
    modes = ["mdlm", "matched", "distill", "full"]
    langs = sorted({r["args"]["lang"] for r in g15})
    md.append("| language | " + " | ".join(modes) + " | (gap 1β†’32) |")
    md.append("|---" * (len(modes) + 2) + "|")
    for L in langs:
        cells, gaps = [], []
        for m in modes:
            rs = runs("g15-", lang=L, mode=m)
            if not rs:
                cells.append("–"); gaps.append("–"); continue
            one = np.mean([r["accuracy_by_passes"]["1"] for r in rs]) * 100
            many = np.mean([r["accuracy_by_passes"]["32"] for r in rs]) * 100
            cells.append(f"{one:.1f}"); gaps.append(f"{many-one:+.1f}")
        md.append(f"| {L} | " + " | ".join(cells) + " | " + " / ".join(gaps) + " |")
    md.append("")

# ---- Table 8: text8, where the two fixes have to compose -------------------
t8 = [r for r in R if r["name"].startswith("t8-")]
if t8:
    md.append("## Table 8 β€” text8: quality against compute\n")
    md.append("Generative perplexity under a from-scratch autoregressive "
              "character evaluator, against the number of function evaluations "
              "actually used. Entropy and distinct-4-gram rate are reported "
              "beside it because generative perplexity alone is gamed by "
              "degenerate repetition; the real-text reference row is the "
              "target, not zero.\n")
    ref = t8[0].get("reference", {})
    md.append(f"Real text8 reference β€” ppl {ref.get('gen_ppl', float('nan')):.2f}, "
              f"entropy {ref.get('entropy', float('nan')):.3f}, "
              f"distinct-4 {ref.get('distinct4', float('nan')):.3f}\n")
    md.append("| training | decoding | NFE | gen ppl | entropy | distinct-4 | valid BPC |")
    md.append("|---|---|---|---|---|---|---|")
    by = {}
    for r in t8:
        by.setdefault(r["args"]["mode"], []).append(r)
    for mode in ("mdlm", "matched", "full"):
        for r in by.get(mode, [])[:1]:
            for key, row in sorted(r.get("decode", {}).items()):
                if row.get("gen_ppl") is None:
                    continue
                md.append(f"| {mode} | {key} | {row['nfe']:.1f} | "
                          f"{row['gen_ppl']:.2f} | {row['entropy']:.3f} | "
                          f"{row['distinct4']:.3f} | {r.get('valid_bpc', float('nan')):.4f} |")
    md.append("")

    # quality/compute frontier: the plot the claim lives or dies on
    fig, ax = plt.subplots(figsize=(6.8, 4.4))
    style = {"mdlm": ("#c0392b", "uniform-t training"),
             "matched": ("#e67e22", "matched training"),
             "full": ("#1a5276", "matched + high-ratio")}
    for mode, rs in by.items():
        c, lbl = style.get(mode, ("#555", mode))
        for fam, mk, ls in (("fixedK_", "o", "-"), ("entbudget_", "s", "--")):
            pts = []
            for r in rs:
                for k, row in r.get("decode", {}).items():
                    if k.startswith(fam) and row.get("gen_ppl"):
                        pts.append((row["nfe"], row["gen_ppl"]))
            if pts:
                pts.sort()
                ax.plot([p[0] for p in pts], [p[1] for p in pts], marker=mk,
                        ms=4, ls=ls, color=c, alpha=.85,
                        label=f"{lbl}, {'fixed-K' if fam[0]=='f' else 'entropy budget'}")
    if ref.get("gen_ppl"):
        ax.axhline(ref["gen_ppl"], color="k", lw=1, ls=":", label="real text8")
    ax.set_xscale("log", base=2); ax.set_yscale("log")
    ax.set_xlabel("forward passes (NFE)"); ax.set_ylabel("generative perplexity")
    ax.set_title("Quality against compute: do the two fixes compose?")
    ax.legend(fontsize=7); ax.grid(alpha=.3)
    fig.tight_layout(); fig.savefig(f"{OUT}/fig4_text8_quality_vs_compute.png", dpi=160)

# ---- Table 9b: does budget conditioning remove the crossover? --------------
g18 = runs("g18-")
if g18:
    md.append("## Table 9b β€” Any-budget conditioning vs a fixed budget\n")
    md.append("20-digit addition, one configuration. A fixed-K method wins at the "
              "budget it was trained for and loses away from it; the question is "
              "whether conditioning on the budget removes that trade.\n")
    md.append("| training | @1 pass | @sequential | spread |")
    md.append("|---|---|---|---|")
    rows = {}
    for r in g18 + runs("g13-", digits=20):
        rows.setdefault(r["args"]["mode"], []).append(r)
    for mode in ("mdlm", "full", "anybudget_nc", "anybudget"):
        rs = rows.get(mode)
        if not rs:
            continue
        one = np.mean([r["accuracy_by_passes"]["1"] for r in rs]) * 100
        keys = [int(k) for k in rs[0]["accuracy_by_passes"]]
        seq = np.mean([r["accuracy_by_passes"][str(max(keys))] for r in rs]) * 100
        md.append(f"| {MODE.get(mode, mode)} | {one:.1f} | {seq:.1f} | "
                  f"{abs(one-seq):.1f} |")
    md.append("")

# ---- Table 9: failed to parallelise, or failed to learn? -------------------
sc = os.path.join(OUT, "seq_check_g13.json")
if os.path.exists(sc):
    rows = json.load(open(sc))
    md.append("## Table 9 β€” Did the baseline fail to parallelise, or fail to learn?\n")
    md.append("A 20-digit answer occupies 21 slots, so the standard 16-pass grid "
              "is not a sequential decode. Re-decoded at one token per pass, the "
              "two explanations separate.\n")
    md.append("| digits | mode | @1 pass | fully sequential | reading |")
    md.append("|---|---|---|---|---|")
    agg = {}
    for r in rows:
        agg.setdefault((r["digits"], r["mode"]), []).append(r)
    for k in sorted(agg):
        v = agg[k]
        one = np.mean([x["acc_1pass"] for x in v]) * 100
        seq = np.mean([x["acc_sequential"] for x in v]) * 100
        read = ("never learned the task" if seq < 5 else
                "learned it, cannot parallelise" if one < seq - 5 else
                "learned it and parallelises")
        md.append(f"| {k[0]} | {k[1]} | {one:.1f} | {seq:.1f} | {read} |")
    md.append("")


# ---- Table 10: graphs, where the ceiling is known exactly -------------------
gr1 = [r for r in R if r["name"].startswith("gr1-")]
if gr1:
    md.append("## Table 10 β€” Structured graphs: measured against an exact ceiling\n")
    md.append("V* is the one-pass validity achievable from the TRUE marginals, so it "
              "bounds any model that reproduces the data distribution. Where the "
              "interval contains V* the residual failure is information-theoretic "
              "and untrainable; where it falls short, the shortfall is trainable.\n")
    md.append("| family | TC (bits) | V* | achieved @1 pass | reading |")
    md.append("|---|---|---|---|---|")
    by = {}
    for r in gr1:
        by.setdefault(r["args"]["family"], []).append(r)
    for fam in sorted(by, key=lambda f: -by[f][0]["exact"]["total_correlation_bits"]):
        rs = [r for r in by[fam] if r["args"]["mode"] == "mdlm"]
        if not rs:
            continue
        ex = rs[0]["exact"]
        v = np.mean([r["decode"]["fixedK_1"]["validity"] for r in rs]) * 100
        V = ex["one_pass_ceiling"] * 100
        read = "at the ceiling β€” untrainable" if v > 0.8 * V else "below ceiling β€” see Table 11"
        md.append(f"| {fam} | {ex['total_correlation_bits']:.2f} | {V:.2f}% | "
                  f"{v:.2f}% | {read} |")
    md.append("")

# ---- Table 11: the expressivity result -------------------------------------
gr2 = [r for r in R if r["name"].startswith("gr2-")]
if gr2:
    md.append("## Table 11 β€” One-pass decoding needs ABSOLUTE position information\n")
    md.append("At t = 1 every input token is [MASK], so the sequence is constant and a "
              "relative encoding gives every position the same output. Position-dependent "
              "marginals are then inexpressible at any training budget. Matching is the "
              "control: its marginals are identical at every slot, so absolute "
              "information should buy nothing there.\n")
    md.append("| family | marginals | encoding | @1 pass | % of V* |")
    md.append("|---|---|---|---|---|")
    by = {}
    for r in gr2:
        by.setdefault((r["args"]["family"], r["args"]["pe"]), []).append(r)
    eff = {}
    for fam, marg in (("bipartite", "**vary**"), ("matching", "constant")):
        for pe in ("rope", "ape", "sin"):
            rs = by.get((fam, pe))
            if not rs:
                continue
            v = np.mean([r["decode"]["fixedK_1"]["validity"] for r in rs])
            V = rs[0]["exact"]["one_pass_ceiling"]
            eff[(fam, pe)] = v
            md.append(f"| {fam} | {marg} | {pe} | {v*100:.2f}% | {v/V*100:.1f}% |")
    md.append("")
    for fam in ("bipartite", "matching"):
        if (fam, "rope") in eff and (fam, "ape") in eff:
            delta = (eff[(fam, "ape")] - eff[(fam, "rope")]) * 100
            md.append(f"- absolute βˆ’ relative on **{fam}**: **{delta:+.3f}** points")
    md.append("")

open(f"{OUT}/summary.md", "w").write("\n".join(md) + "\n")
print("\n".join(md))
print(f"\nwrote figures + summary to {OUT}")