diff --git a/baseline/experiments/mnist_mlp3_tangent_rg/README.md b/baseline/experiments/mnist_mlp3_tangent_rg/README.md
index f288200..f2d3254 100644
--- a/baseline/experiments/mnist_mlp3_tangent_rg/README.md
+++ b/baseline/experiments/mnist_mlp3_tangent_rg/README.md
@@ -868,3 +868,19 @@ preserves the empirical distribution, fitted alpha, selected xmin, and KS
distance while preventing deterministic coordinate copies from inflating the
effective sample size. Outputs default to
`/private/tmp/rg-mnist-mlp3-short100-jacobians-reduced`.
+
+The reduced runner also persists every fitted spectral observation to
+`jacobian_spectra.csv`; one row contains the amplitude, squared Gram
+eigenvalue, physical observation unit, and represented uniform multiplicity.
+After the reduced run completes, build the complete static comparison report:
+
+```bash
+bash baseline/experiments/mnist_mlp3_tangent_rg/scripts/build_short100_jacobian_report.sh
+open /private/tmp/rg-mnist-mlp3-short100-jacobians-reduced/report/index.html
+```
+
+The report combines MuonClip-RMS and AdamW Jacobian alpha trajectories, KS and
+tail-support diagnostics, raw and `fix_fingers=clip_xmax` WeightWatcher
+controls, train/test accuracy and loss, alpha-versus-test-accuracy plots,
+selected spectral CCDF galleries, analysis-ready CSVs, and a browsable HTML
+index. Report generation does not recompute any Jacobian.
diff --git a/baseline/experiments/mnist_mlp3_tangent_rg/scripts/build_short100_jacobian_report.py b/baseline/experiments/mnist_mlp3_tangent_rg/scripts/build_short100_jacobian_report.py
new file mode 100755
index 0000000..d7923c5
--- /dev/null
+++ b/baseline/experiments/mnist_mlp3_tangent_rg/scripts/build_short100_jacobian_report.py
@@ -0,0 +1,448 @@
+#!/usr/bin/env python3
+"""Build a complete, static report from the reduced Jacobian run.
+
+No Jacobians are computed here. The program joins the saved Jacobian spectra
+and fits to the baseline WeightWatcher and performance tables, then writes all
+comparison figures, analysis-ready CSVs, and a browsable HTML index.
+"""
+
+from __future__ import annotations
+
+import argparse
+from html import escape
+import json
+import logging
+from pathlib import Path
+import sys
+from typing import Iterable
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+
+
+SUITE = "mnist_mlp3_tangent_rg_v1_muonclip_short100_10seed"
+OPTIMIZERS = ("muonclip_rms", "adamw")
+LAYERS = ("fc1.weight", "fc2.weight", "fc3.weight")
+COLORS = {"muonclip_rms": "#0072B2", "adamw": "#D55E00"}
+OPTIMIZER_LABELS = {"muonclip_rms": "MuonClip-RMS", "adamw": "AdamW"}
+METHOD_LABELS = {
+ "centered_log_singular_radial_pullback": "Centered log-singular radial",
+ "ecs_grassmann_cartan_cover_full_row_shell_pullback": "ECS full-row shell",
+ "ecs_grassmann_cartan_cover_detx_shell_pullback": "ECS detX shell",
+}
+METHOD_STYLES = {
+ "centered_log_singular_radial_pullback": ("-", "o"),
+ "ecs_grassmann_cartan_cover_full_row_shell_pullback": ("--", "s"),
+ "ecs_grassmann_cartan_cover_detx_shell_pullback": (":", "^")
+}
+
+
+def configure_logging(report_root: Path) -> logging.Logger:
+ report_root.mkdir(parents=True, exist_ok=True)
+ logger = logging.getLogger("short100_jacobian_report")
+ logger.handlers.clear()
+ logger.setLevel(logging.INFO)
+ formatter = logging.Formatter("%(asctime)s %(levelname)-8s %(message)s")
+ for stream in (sys.stdout, report_root / "report.log"):
+ handler = (
+ logging.StreamHandler(stream)
+ if hasattr(stream, "write")
+ else logging.FileHandler(stream, mode="w")
+ )
+ handler.setFormatter(formatter)
+ logger.addHandler(handler)
+ return logger
+
+
+def require_csv(path: Path) -> pd.DataFrame:
+ if not path.is_file():
+ raise FileNotFoundError(f"required data table is missing: {path}")
+ frame = pd.read_csv(path)
+ if frame.empty:
+ raise RuntimeError(f"required data table is empty: {path}")
+ return frame
+
+
+def bool_series(values: pd.Series) -> pd.Series:
+ if values.dtype == bool:
+ return values
+ return values.astype(str).str.strip().str.lower().isin({"1", "true", "yes"})
+
+
+def save_figure(
+ fig: plt.Figure, path: Path, *, bottom: float = 0.0, top: float = 1.0
+) -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fig.tight_layout(rect=(0.0, bottom, 1.0, top))
+ fig.savefig(path, dpi=190, bbox_inches="tight", facecolor="white")
+ plt.close(fig)
+ return path
+
+
+def shared_legend(fig: plt.Figure, axes: Iterable[plt.Axes], *, columns: int = 3) -> None:
+ unique: dict[str, object] = {}
+ for axis in axes:
+ handles, labels = axis.get_legend_handles_labels()
+ for handle, label in zip(handles, labels):
+ unique.setdefault(label, handle)
+ fig.legend(
+ unique.values(), unique.keys(), loc="lower center", ncol=columns,
+ fontsize=8, frameon=False, bbox_to_anchor=(0.5, 0.005),
+ )
+
+
+def curve_style(optimizer: str, method: str) -> dict[str, object]:
+ linestyle, marker = METHOD_STYLES.get(method, ("-", "o"))
+ return {
+ "color": COLORS[optimizer],
+ "linestyle": linestyle,
+ "marker": marker,
+ "markersize": 4.0,
+ "linewidth": 1.9,
+ }
+
+
+def plot_jacobian_metric(
+ fits: pd.DataFrame,
+ metric: str,
+ ylabel: str,
+ title: str,
+ path: Path,
+ *,
+ reference: float | None = None,
+) -> Path:
+ fig, axes = plt.subplots(1, 3, figsize=(17.0, 4.8), sharex=True)
+ for axis, layer in zip(axes, LAYERS):
+ subset = fits[fits["layer"].astype(str).eq(layer)]
+ for (optimizer, method), curve in subset.groupby(["optimizer", "method"]):
+ curve = curve.sort_values("epoch")
+ axis.plot(
+ curve["epoch"], curve[metric],
+ label=f"{OPTIMIZER_LABELS.get(optimizer, optimizer)} — "
+ f"{METHOD_LABELS.get(method, method)}",
+ **curve_style(str(optimizer), str(method)),
+ )
+ if reference is not None:
+ axis.axhline(reference, color="black", linestyle=(0, (1, 2)), linewidth=1.2)
+ axis.set(title=layer.replace(".weight", ""), xlabel="epoch", ylabel=ylabel)
+ axis.grid(True, alpha=0.25)
+ shared_legend(fig, axes, columns=3)
+ fig.suptitle(title)
+ return save_figure(fig, path, bottom=0.16, top=0.94)
+
+
+def plot_fit_quality(fits: pd.DataFrame, path: Path) -> Path:
+ fig, axes = plt.subplots(2, 3, figsize=(17.0, 8.2), sharex=True)
+ for column, layer in enumerate(LAYERS):
+ subset = fits[fits["layer"].astype(str).eq(layer)]
+ for (optimizer, method), curve in subset.groupby(["optimizer", "method"]):
+ curve = curve.sort_values("epoch")
+ style = curve_style(str(optimizer), str(method))
+ label = (
+ f"{OPTIMIZER_LABELS.get(optimizer, optimizer)} — "
+ f"{METHOD_LABELS.get(method, method)}"
+ )
+ axes[0, column].plot(curve["epoch"], curve["ks_D"], label=label, **style)
+ axes[1, column].plot(
+ curve["epoch"], curve["tail_decades"], label=label, **style
+ )
+ axes[0, column].set(title=layer.replace(".weight", ""), ylabel="KS D")
+ axes[1, column].set(xlabel="epoch", ylabel="tail decades")
+ for axis in axes[:, column]:
+ axis.grid(True, alpha=0.25)
+ shared_legend(fig, axes.ravel(), columns=3)
+ fig.suptitle("Jacobian power-law fit quality")
+ return save_figure(fig, path, bottom=0.12, top=0.95)
+
+
+def plot_weightwatcher(weightwatcher: pd.DataFrame, path: Path) -> Path:
+ frame = weightwatcher.copy()
+ frame["fit_ok_bool"] = bool_series(frame["fit_ok"])
+ frame = frame[
+ frame["layer"].astype(str).isin(LAYERS)
+ & frame["fit_variant"].astype(str).isin({"raw", "clip_xmax"})
+ & frame["fit_ok_bool"]
+ & pd.to_numeric(frame["epoch"], errors="coerce").gt(0)
+ ]
+ fig, axes = plt.subplots(1, 3, figsize=(17.0, 4.8), sharex=True)
+ variant_style = {"raw": ("--", "o"), "clip_xmax": ("-", "s")}
+ for axis, layer in zip(axes, LAYERS):
+ subset = frame[frame["layer"].astype(str).eq(layer)]
+ for (optimizer, variant), curve in subset.groupby(["optimizer", "fit_variant"]):
+ curve = curve.sort_values("epoch")
+ linestyle, marker = variant_style[str(variant)]
+ axis.plot(
+ curve["epoch"], curve["alpha"],
+ color=COLORS[str(optimizer)], linestyle=linestyle, marker=marker,
+ markersize=3.5, linewidth=1.8,
+ label=f"{OPTIMIZER_LABELS[str(optimizer)]} — {variant}",
+ )
+ axis.axhline(2.0, color="black", linestyle=(0, (1, 2)), linewidth=1.2)
+ axis.set(title=layer.replace(".weight", ""), xlabel="epoch", ylabel="WW alpha")
+ axis.grid(True, alpha=0.25)
+ shared_legend(fig, axes, columns=4)
+ fig.suptitle("WeightWatcher controls: raw and fix_fingers=clip_xmax")
+ return save_figure(fig, path, bottom=0.13, top=0.94)
+
+
+def plot_performance(performance: pd.DataFrame, path: Path) -> Path:
+ metrics = (
+ ("train_accuracy", "Train accuracy", True),
+ ("test_accuracy", "Test accuracy", True),
+ ("train_loss", "Train loss", False),
+ ("test_loss", "Test loss", False),
+ )
+ fig, axes = plt.subplots(2, 2, figsize=(13.5, 8.8), sharex=True)
+ for axis, (metric, title, bounded) in zip(axes.ravel(), metrics):
+ for optimizer, curve in performance.groupby("optimizer"):
+ curve = curve.sort_values("epoch")
+ axis.plot(
+ curve["epoch"], curve[metric], color=COLORS[str(optimizer)],
+ linewidth=2.0, label=OPTIMIZER_LABELS[str(optimizer)],
+ )
+ axis.set(title=title, xlabel="epoch", ylabel=metric.replace("_", " "))
+ if bounded:
+ values = pd.to_numeric(performance[metric], errors="coerce")
+ low = max(0.0, float(values.min()) - 0.01)
+ high = min(1.001, float(values.max()) + 0.005)
+ axis.set_ylim(low, high)
+ axis.grid(True, alpha=0.25)
+ axis.legend(frameon=False)
+ fig.suptitle("MuonClip-RMS versus AdamW performance — seed 101")
+ return save_figure(fig, path)
+
+
+def empirical_ccdf(values: Iterable[float]) -> tuple[np.ndarray, np.ndarray]:
+ sample = np.sort(np.asarray(tuple(values), dtype=float))
+ sample = sample[np.isfinite(sample) & (sample > 0)]
+ return sample, np.arange(sample.size, 0, -1, dtype=float) / sample.size
+
+
+def plot_spectral_galleries(spectra: pd.DataFrame, figure_root: Path) -> list[Path]:
+ outputs: list[Path] = []
+ available_epochs = sorted(pd.to_numeric(spectra["epoch"], errors="coerce").dropna().astype(int).unique())
+ requested = [epoch for epoch in (10, 50, 100) if epoch in available_epochs]
+ for optimizer in OPTIMIZERS:
+ for layer in LAYERS:
+ fig, axes = plt.subplots(1, len(requested), figsize=(5.2 * len(requested), 4.5))
+ axes = np.atleast_1d(axes)
+ subset = spectra[
+ spectra["optimizer"].astype(str).eq(optimizer)
+ & spectra["layer"].astype(str).eq(layer)
+ ]
+ for axis, epoch in zip(axes, requested):
+ state = subset[pd.to_numeric(subset["epoch"], errors="coerce").eq(epoch)]
+ for method, modes in state.groupby("method"):
+ x, y = empirical_ccdf(modes["gram_eigenvalue"])
+ linestyle, marker = METHOD_STYLES.get(str(method), ("-", "o"))
+ axis.step(
+ x, y, where="post", linestyle=linestyle, linewidth=1.7,
+ label=METHOD_LABELS.get(str(method), str(method)),
+ )
+ axis.set_xscale("log")
+ axis.set_yscale("log")
+ axis.set(title=f"epoch {epoch}", xlabel="Jacobian Gram eigenvalue", ylabel="CCDF")
+ axis.grid(True, alpha=0.25)
+ axis.legend(fontsize=7, frameon=False)
+ fig.suptitle(f"{OPTIMIZER_LABELS[optimizer]} — {layer} spectral evolution")
+ output = figure_root / "spectral_galleries" / f"{optimizer}_{layer.replace('.', '_')}.png"
+ outputs.append(save_figure(fig, output))
+ return outputs
+
+
+def plot_alpha_performance_relationship(
+ fits: pd.DataFrame, performance: pd.DataFrame, path: Path
+) -> Path:
+ joined = fits.merge(
+ performance[["optimizer", "seed", "epoch", "test_accuracy", "test_loss"]],
+ on=["optimizer", "seed", "epoch"], how="inner", validate="many_to_one",
+ )
+ fig, axes = plt.subplots(1, 3, figsize=(17.0, 4.8))
+ for axis, layer in zip(axes, LAYERS):
+ subset = joined[joined["layer"].astype(str).eq(layer)]
+ for (optimizer, method), points in subset.groupby(["optimizer", "method"]):
+ linestyle, marker = METHOD_STYLES.get(str(method), ("-", "o"))
+ axis.scatter(
+ points["alpha"], points["test_accuracy"],
+ color=COLORS[str(optimizer)], marker=marker, s=35, alpha=0.8,
+ label=f"{OPTIMIZER_LABELS[str(optimizer)]} — "
+ f"{METHOD_LABELS.get(str(method), str(method))}",
+ )
+ axis.axvline(2.0, color="black", linestyle=(0, (1, 2)), linewidth=1.2)
+ axis.set(title=layer.replace(".weight", ""), xlabel="Jacobian energy alpha", ylabel="test accuracy")
+ axis.grid(True, alpha=0.25)
+ shared_legend(fig, axes, columns=3)
+ fig.suptitle("Jacobian alpha versus held-out test accuracy")
+ return save_figure(fig, path, bottom=0.16, top=0.94)
+
+
+def relative(path: Path, root: Path) -> str:
+ return str(path.resolve().relative_to(root.resolve()))
+
+
+def build_html(
+ report_root: Path,
+ figures: list[Path],
+ tables: list[Path],
+ primary: pd.DataFrame,
+ inventory: dict[str, int],
+) -> Path:
+ final = primary.sort_values("epoch").groupby(
+ ["optimizer", "layer", "method"], as_index=False
+ ).tail(1)
+ final_table = final[
+ ["optimizer", "epoch", "layer", "method", "alpha", "ks_D", "n_tail", "tail_decades", "fit_ok"]
+ ].to_html(index=False, float_format=lambda value: f"{value:.5g}")
+ figure_html = "\n".join(
+ f'{escape(path.stem.replace("_", " ").title())}
'
+ f''
+ f'
MuonClip-RMS versus AdamW, seed 101, epochs 10–100. ECS curves use one physical retained-core amplitude per uniformly repeated shell group. The complete mode-level data, fit tables, WeightWatcher controls, and performance data are linked below.
+