ml_meta_perf.report

Turning a finished study into a written report, without a human in the loop.

The whole argument of this project is that the fitted equation can be read. That claim would be worth very little if reading it still required an expert to sit down with the weights and narrate what they mean -- the narration would then be the product, and the equation merely its raw material. So every statement this module makes is derived from the fitted object by arithmetic that is written down here, and the same equation always produces the same report.

Two ideas carry the analysis.

Standardised weights rank the terms. The target is centred but never scaled during fitting, so a standardised weight is already in MCC units: it is how far predicted MCC moves when that term moves by one standard deviation of itself. That makes beta directly comparable across terms whose raw units have nothing to do with each other, and it is the number used to decide which terms are major.

Effects state what the terms are worth on this data. A large weight on a term that barely varies is not important, so effect -- the swing in predicted MCC across the middle 80% of a term's observed range -- is reported next to every weight. The two disagree often enough to be worth printing together.

Feature-level guidance is not re-derived here. It comes from ml_meta_perf.practices, which measures direction empirically rather than reading signs off weights, because a feature appearing in two terms or inside a denominator has no single sign to read.

Study chapter: 6. Best practices against the equation -- the rationale, in prose, with the figures.

   1"""Turning a finished study into a written report, without a human in the loop.
   2
   3The whole argument of this project is that the fitted equation can be *read*. That claim
   4would be worth very little if reading it still required an expert to sit down with the
   5weights and narrate what they mean -- the narration would then be the product, and the
   6equation merely its raw material. So every statement this module makes is derived from
   7the fitted object by arithmetic that is written down here, and the same equation always
   8produces the same report.
   9
  10Two ideas carry the analysis.
  11
  12*Standardised weights rank the terms.* The target is centred but never scaled during
  13fitting, so a standardised weight is already in MCC units: it is how far predicted MCC
  14moves when that term moves by one standard deviation of itself. That makes ``beta``
  15directly comparable across terms whose raw units have nothing to do with each other, and
  16it is the number used to decide which terms are major.
  17
  18*Effects state what the terms are worth on this data.* A large weight on a term that
  19barely varies is not important, so ``effect`` -- the swing in predicted MCC across the
  20middle 80% of a term's observed range -- is reported next to every weight. The two
  21disagree often enough to be worth printing together.
  22
  23Feature-level guidance is not re-derived here. It comes from `ml_meta_perf.practices`, which
  24measures direction empirically rather than reading signs off weights, because a feature
  25appearing in two terms or inside a denominator has no single sign to read.
  26
  27Study chapter: [6. Best practices against the equation][study-chapter] -- the rationale, in
  28prose, with the figures.
  29
  30[study-chapter]: https://github.com/mariolpantunes/ml-meta-perf/blob/main/assets/docs/06-practices.md
  31"""
  32
  33from __future__ import annotations
  34
  35from dataclasses import fields
  36from pathlib import Path
  37
  38import numpy as np
  39import polars as pl
  40
  41from ml_meta_perf.attribution import classify, contributions
  42from ml_meta_perf.config import Configuration, SelectionConfig, load_config
  43from ml_meta_perf.data import (
  44    ALL_FEATURES,
  45    DATASET_COLUMN,
  46    DATASET_FEATURES,
  47    FEATURE_GLOSSARY,
  48    MODEL_COLUMN,
  49    MODEL_FEATURES,
  50    corpus_summary,
  51    missing_cells,
  52    target_summary,
  53)
  54from ml_meta_perf.experiment import EquationReport, Report
  55from ml_meta_perf.guidance import as_table as as_guidance_table
  56from ml_meta_perf.guidance import assess, equation_coverage, equation_evidence
  57from ml_meta_perf.guidance import render as render_guidance
  58from ml_meta_perf.model import Equation, direction
  59from ml_meta_perf.practices import render as render_practices
  60from ml_meta_perf.selection import floor_curve, knee_lengths, plateau_start, smoothed
  61from ml_meta_perf.stats import spearman
  62from ml_meta_perf.terms import TRANSFORMS, Atom, Term
  63
  64#: A term is *major* when it falls inside the smallest group of terms whose standardised
  65#: weights account for this share of the total weight mass. Set at four fifths because
  66#: that is where the tail of near-zero weights begins on this data, not because 80% has
  67#: any special status -- ``term_importance`` reports the cumulative share for every term
  68#: so a reader can draw the line somewhere else.
  69MAJOR_MASS = 0.8
  70
  71#: How many terms get written out as sentences. A flat equation can flag most of its
  72#: terms as major, and twenty near-identical paragraphs is not a readable analysis --
  73#: the table carries the rest.
  74SENTENCE_LIMIT = 8
  75
  76#: The grammar's operations, in the order ``operation_usage`` reports them.
  77OPERATIONS = ("atom", "ratio", "product", "sum_ratio", "ratio_of_sums")
  78
  79#: How many raw features each operation names. Used to tell an operation the search
  80#: *declined* from one the arity cap never offered it.
  81OPERATION_ARITY = {"atom": 1, "ratio": 2, "product": 2, "sum_ratio": 3, "ratio_of_sums": 4}
  82
  83
  84def term_importance(
  85    equation: Equation,
  86    columns: dict[str, np.ndarray],
  87    dataset_features: tuple[str, ...],
  88    model_features: tuple[str, ...],
  89    stability: pl.DataFrame | None = None,
  90) -> pl.DataFrame:
  91    """One row per term, ordered by standardised weight, with the major ones flagged.
  92
  93    ``beta`` is the standardised weight: the MCC the term contributes per standard
  94    deviation of itself. ``effect`` is its 10th-to-90th-percentile contribution swing on
  95    the real data. ``share`` is ``|beta|`` as a fraction of the total, and ``cumulative``
  96    its running sum down the ranking, so ``major`` marks the leading terms that together
  97    reach `MAJOR_MASS`.
  98
  99    Ranking on ``beta`` rather than on the raw weight is not cosmetic. Raw weights carry
 100    the units of whatever the term computes, so a term over instance counts and a term
 101    over class entropy have weights that cannot be compared at all; sorting on them would
 102    rank the terms by the size of their units.
 103    """
 104    if not equation.terms:
 105        return pl.DataFrame(
 106            schema={
 107                "rank": pl.Int64,
 108                "term": pl.String,
 109                "group": pl.String,
 110                "features": pl.String,
 111                "weight": pl.Float64,
 112                "beta": pl.Float64,
 113                "effect": pl.Float64,
 114                "share": pl.Float64,
 115                "cumulative": pl.Float64,
 116                "major": pl.Boolean,
 117                "stability": pl.Float64,
 118                "direction": pl.String,
 119            }
 120        )
 121
 122    matrix = contributions(equation, columns)
 123    frequency = (
 124        dict(zip(stability["term"].to_list(), stability["frequency"].to_list(), strict=True))
 125        if stability is not None and stability.height
 126        else {}
 127    )
 128
 129    betas = np.abs(np.asarray(equation.standardized_weights, dtype=np.float64))
 130    total = float(betas.sum())
 131    order = np.argsort(-betas)
 132
 133    rows: list[dict[str, object]] = []
 134    running = 0.0
 135    for position, index in enumerate(order, start=1):
 136        beta = float(equation.standardized_weights[index])
 137        share = float(betas[index] / total) if total > 0.0 else 0.0
 138        running += share
 139        low, high = np.percentile(matrix[:, index], [10.0, 90.0])
 140        term = equation.terms[index]
 141        rows.append(
 142            {
 143                "rank": position,
 144                "term": term.name,
 145                "group": classify(term.features, dataset_features, model_features),
 146                "features": ", ".join(term.features),
 147                "weight": float(equation.weights[index]),
 148                "beta": beta,
 149                "effect": float(high - low),
 150                "share": share,
 151                "cumulative": running,
 152                # The term that crosses the threshold is kept, so the flagged group always
 153                # accounts for at least MAJOR_MASS rather than just short of it.
 154                "major": bool(running - share < MAJOR_MASS),
 155                "stability": float(frequency.get(term.name, float("nan"))),
 156                "direction": direction(beta),
 157            }
 158        )
 159    return pl.DataFrame(rows)
 160
 161
 162def term_sentences(importance: pl.DataFrame, limit: int | None = None) -> list[str]:
 163    """A written line per major term, generated from its row of `term_importance`.
 164
 165    The sentence says three things, in the order a reader needs them: what the term does
 166    to MCC, how much of the equation's weight mass it carries, and how often the folds
 167    agreed it belonged. Nothing is inferred beyond the numbers in the row -- in
 168    particular no claim is made about the *features* inside the term, because a feature's
 169    direction depends on the other terms it appears in and is `ml_meta_perf.practices`' job.
 170    """
 171    if importance.height == 0:
 172        return []
 173    table = importance.filter(pl.col("major")) if "major" in importance.columns else importance
 174    if limit is not None:
 175        table = table.head(limit)
 176
 177    lines: list[str] = []
 178    for row in table.iter_rows(named=True):
 179        beta = float(row["beta"])
 180        stability = float(row["stability"])
 181        agreement = (
 182            "selected in every fold"
 183            if stability >= 0.999
 184            else f"selected in {stability:.0%} of folds"
 185            if not np.isnan(stability)
 186            else "fold agreement not measured"
 187        )
 188        lines.append(
 189            f"`{row['term']}` ({row['group']}) {row['direction']}: one standard deviation of "
 190            f"this term is worth {beta:+.3f} MCC, it moves predicted MCC by {float(row['effect']):.3f} "
 191            f"across the middle 80% of its observed range, it carries {float(row['share']):.1%} of the "
 192            f"equation's weight mass, and it was {agreement}."
 193        )
 194    return lines
 195
 196
 197def group_sentences(shares: pl.DataFrame) -> list[str]:
 198    """A written line per feature group, generated from the share table."""
 199    readings = {
 200        "dataset": "terms over dataset meta-features alone (how hard is this data)",
 201        "model": "terms over model meta-features alone (how capable is this model)",
 202        "mixed": "terms mixing dataset and model features (which model suits which data)",
 203    }
 204    lines: list[str] = []
 205    for row in shares.iter_rows(named=True):
 206        group = str(row["group"])
 207        count = int(row["n_terms"])
 208        lines.append(
 209            f"{readings.get(group, group)}: {count} term{'' if count == 1 else 's'}, "
 210            f"{float(row['share']):.1%} of the equation's output variance."
 211        )
 212    return lines
 213
 214
 215def coverage(importance: pl.DataFrame) -> dict[str, float | int]:
 216    """How concentrated the equation is: how few terms carry how much of it.
 217
 218    ``effective_terms`` is the inverse Simpson index of the weight shares,
 219    $1 / \\sum_i s_i^2$. It answers "how many equally-weighted terms would behave like
 220    this equation": it equals the term count when every term carries the same weight, and
 221    falls toward 1 as one term takes over. It is reported because the headline count and
 222    the major count both depend on where a threshold is drawn, and this does not.
 223    """
 224    if importance.height == 0:
 225        return {
 226            "n_terms": 0,
 227            "n_major": 0,
 228            "major_share": 0.0,
 229            "top_share": 0.0,
 230            "effective_terms": 0.0,
 231        }
 232    major = importance.filter(pl.col("major"))
 233    shares = importance["share"].to_numpy()
 234    return {
 235        "n_terms": importance.height,
 236        "n_major": major.height,
 237        "major_share": float(major["share"].sum()),
 238        "top_share": float(importance["share"][0]),
 239        "effective_terms": float(1.0 / np.sum(shares**2)),
 240    }
 241
 242
 243def unstable_majors(importance: pl.DataFrame, threshold: float = 0.5) -> pl.DataFrame:
 244    """Major terms the folds did not agree on, which are the ones not to build advice on.
 245
 246    A large standardised weight and a low selection frequency together mean the term is
 247    doing a lot of work for *this* training set and would be replaced by something else
 248    on another. That combination is invisible in the equation as printed -- it looks like
 249    any other large coefficient -- so it is extracted rather than left to be noticed.
 250    """
 251    if importance.height == 0 or "stability" not in importance.columns:
 252        return importance
 253    return importance.filter(
 254        pl.col("major") & pl.col("stability").is_not_nan() & (pl.col("stability") < threshold)
 255    ).select("rank", "term", "group", "beta", "share", "stability")
 256
 257
 258def _atoms(term: Term | Atom) -> list[Atom]:
 259    """Every `Atom` inside a term, however deeply nested."""
 260    if isinstance(term, Atom):
 261        return [term]
 262    collected: list[Atom] = []
 263    for operand in term.operands:
 264        collected.extend(_atoms(operand))
 265    return collected
 266
 267
 268def feature_usage(
 269    equation: Equation,
 270    importance: pl.DataFrame,
 271    available: tuple[str, ...],
 272) -> pl.DataFrame:
 273    """Which raw features the equation reached for, how often, and under what transforms.
 274
 275    A second way to read a flat equation. When no single term dominates, the question
 276    "which term matters" has no useful answer, but "which of the features on offer did the
 277    search actually use, and how did it have to bend them" still does -- and it is asked of
 278    the vocabulary rather than of the weights, so a spread of weights does not blunt it.
 279
 280    ``share`` sums the standardised-weight mass of every term a feature appears in. A
 281    feature in two terms is credited both, so the column does not sum to 1: it answers
 282    "how much of the equation touches this feature", not "how much does this feature own".
 283    ``unused`` features are reported too, since a feature the search declined to use after
 284    seeing every transform of it is a finding about the meta-data.
 285    """
 286    mass = dict(zip(importance["term"].to_list(), importance["share"].to_list(), strict=True))
 287    rows: list[dict[str, object]] = []
 288    for feature in available:
 289        indices = [index for index, term in enumerate(equation.terms) if feature in term.features]
 290        transforms = sorted(
 291            {atom.transform for index in indices for atom in _atoms(equation.terms[index]) if atom.feature == feature}
 292        )
 293        operations = sorted({equation.terms[index].operation for index in indices})
 294        rows.append(
 295            {
 296                "feature": feature,
 297                "meaning": FEATURE_GLOSSARY.get(feature, feature),
 298                "n_terms": len(indices),
 299                "share": float(sum(mass.get(equation.terms[index].name, 0.0) for index in indices)),
 300                "transforms": ", ".join(transforms),
 301                "operations": ", ".join(operations),
 302            }
 303        )
 304    return pl.DataFrame(rows).sort("share", descending=True)
 305
 306
 307def operation_usage(
 308    equation: Equation,
 309    importance: pl.DataFrame,
 310    max_arity: int | None = None,
 311) -> pl.DataFrame:
 312    """Which grammar operations and transforms earned their place in the equation.
 313
 314    The vocabulary offers five operations and five transforms; the search is free to
 315    ignore any of them, and what it declined is as much a result as what it chose --
 316    every unused entry is a shape the data turned out not to need.
 317
 318    That reading only holds for entries the search could actually have used, so
 319    ``max_arity`` marks the rest ``offered = no``. Reporting ``ratio_of_sums`` as
 320    "declined" when a three-feature cap kept it out of the library would be reading a
 321    configuration choice as a finding about the data.
 322    """
 323    mass = dict(zip(importance["term"].to_list(), importance["share"].to_list(), strict=True))
 324    rows: list[dict[str, object]] = []
 325    for kind, names in (("operation", OPERATIONS), ("transform", TRANSFORMS)):
 326        for name in names:
 327            if kind == "operation":
 328                indices = [index for index, term in enumerate(equation.terms) if term.operation == name]
 329                offered = max_arity is None or OPERATION_ARITY[name] <= max_arity
 330            else:
 331                indices = [
 332                    index
 333                    for index, term in enumerate(equation.terms)
 334                    if any(atom.transform == name for atom in _atoms(term))
 335                ]
 336                offered = True
 337            rows.append(
 338                {
 339                    "kind": kind,
 340                    "name": name,
 341                    "offered": offered,
 342                    "n_terms": len(indices),
 343                    "share": float(sum(mass.get(equation.terms[index].name, 0.0) for index in indices)),
 344                }
 345            )
 346    return pl.DataFrame(rows)
 347
 348
 349def _shared_features(equation: Equation, members: list[int]) -> list[str]:
 350    """Features appearing in at least half of a block's terms, commonest first.
 351
 352    What names a block. The terms were grouped on how their contributions move, not on
 353    what they contain, so this is a genuine question rather than a restatement of the
 354    grouping -- and when a block has no common feature, the empty answer is worth seeing
 355    too.
 356    """
 357    counts: dict[str, int] = {}
 358    for index in members:
 359        for feature in set(equation.terms[index].features):
 360            counts[feature] = counts.get(feature, 0) + 1
 361    # Strict majority. Half would let a two-term block claim a feature only one of its
 362    # terms names, which is the opposite of shared.
 363    threshold = len(members) // 2 + 1
 364    return [
 365        feature for feature, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) if count >= threshold
 366    ]
 367
 368
 369def term_groups(
 370    equation: Equation,
 371    columns: dict[str, np.ndarray],
 372    importance: pl.DataFrame,
 373    *,
 374    threshold: float = 0.5,
 375) -> pl.DataFrame:
 376    """Terms whose contributions move together, read as one block.
 377
 378    An additive equation invites reading term by term, and that works when one or two
 379    weights dominate. When they do not -- and here they do not -- the honest unit of
 380    explanation is larger than a term and smaller than the whole equation. Terms are
 381    grouped by the correlation of their per-row contributions, so a group is a set of terms
 382    that rise and fall together across the meta-dataset and can be described in one
 383    sentence.
 384
 385    Grouping on contributions rather than on shared features is deliberate. Two terms can
 386    share no feature and still track each other, and two terms over the same feature can
 387    move independently once their transforms differ; what a reader needs to know is whether
 388    they say the same thing about a row.
 389
 390    Correlation is signed and taken on the contribution, so a term entering with a negative
 391    weight joins the group it actually agrees with rather than the one it merely shares
 392    features with.
 393    """
 394    if not equation.terms:
 395        return pl.DataFrame(
 396            schema={
 397                "group": pl.Int64,
 398                "n_terms": pl.Int64,
 399                "share": pl.Float64,
 400                "effect": pl.Float64,
 401                "direction": pl.String,
 402                "shared": pl.String,
 403                "terms": pl.String,
 404            }
 405        )
 406
 407    matrix = contributions(equation, columns)
 408    centered = matrix - matrix.mean(axis=0)
 409    scale = np.sqrt((centered**2).sum(axis=0))
 410    scale = np.where(scale < 1e-12, 1.0, scale)
 411    correlation = (centered / scale).T @ (centered / scale)
 412
 413    mass = dict(zip(importance["term"].to_list(), importance["share"].to_list(), strict=True))
 414    # Single-link agglomeration: a term joins a group when it tracks *any* member, which is
 415    # the right rule here because a chain of terms that each track the next is one story
 416    # told in several pieces, not several stories.
 417    assignment = list(range(len(equation.terms)))
 418    for i in range(len(equation.terms)):
 419        for j in range(i + 1, len(equation.terms)):
 420            if correlation[i, j] >= threshold:
 421                merged, absorbed = sorted((assignment[i], assignment[j]))
 422                assignment = [merged if label == absorbed else label for label in assignment]
 423
 424    rows: list[dict[str, object]] = []
 425    for label in sorted(set(assignment)):
 426        members = [index for index, value in enumerate(assignment) if value == label]
 427        total = matrix[:, members].sum(axis=1)
 428        low, high = np.percentile(total, [10.0, 90.0])
 429        swing = float(high - low)
 430        # The group's own sign: whether its combined contribution rises with itself, which
 431        # is what a one-sentence description of the block has to state.
 432        signed = float(np.sum([equation.standardized_weights[index] for index in members]))
 433        rows.append(
 434            {
 435                "group": len(rows) + 1,
 436                "n_terms": len(members),
 437                "share": float(sum(mass.get(equation.terms[index].name, 0.0) for index in members)),
 438                "effect": swing,
 439                "direction": direction(signed),
 440                "shared": ", ".join(_shared_features(equation, members)),
 441                "terms": " ; ".join(equation.terms[index].name for index in members),
 442            }
 443        )
 444    return pl.DataFrame(rows).sort("share", descending=True).with_columns(pl.int_range(1, pl.len() + 1).alias("group"))
 445
 446
 447def marginal_versus_conditional(
 448    practices: pl.DataFrame,
 449    columns: dict[str, np.ndarray],
 450    truth: np.ndarray,
 451) -> pl.DataFrame:
 452    """Each practice's direction against the feature's own correlation with MCC.
 453
 454    A practice states what the *equation* does as a feature rises, with every other term
 455    present. A marginal correlation states what the feature does alone. The two are
 456    different quantities and on this data they mostly disagree, which is what conditioning
 457    does rather than a defect: a marginal correlation mixes a feature's effect with
 458    everything it travels with, while inside the equation the terms carrying those
 459    companions are already there.
 460
 461    It is worth printing because the marginal view is the one a reader will accidentally
 462    check against -- plot the feature against MCC and be puzzled -- so the disagreement is
 463    better stated than discovered.
 464    """
 465    if practices.height == 0 or "direction" not in practices.columns:
 466        return pl.DataFrame(
 467            schema={
 468                "feature": pl.String,
 469                "meaning": pl.String,
 470                "marginal": pl.Float64,
 471                "conditional": pl.Float64,
 472                "practice_says": pl.String,
 473                "agrees": pl.Boolean,
 474            }
 475        )
 476    rows: list[dict[str, object]] = []
 477    for row in practices.iter_rows(named=True):
 478        feature = str(row["feature"])
 479        marginal = spearman(columns[feature], truth)
 480        conditional = float(row["direction"])
 481        rows.append(
 482            {
 483                "feature": feature,
 484                "meaning": str(row.get("meaning", feature)),
 485                "marginal": marginal,
 486                "conditional": conditional,
 487                "practice_says": "higher" if conditional > 0.0 else "lower",
 488                "agrees": bool(np.sign(marginal) == np.sign(conditional)),
 489            }
 490        )
 491    return pl.DataFrame(rows)
 492
 493
 494_PROTOCOL_LABELS = (
 495    ("loo_cell", "DHO"),
 496    ("loo_dataset", "LODO"),
 497    ("loo_model", "LOMO"),
 498    ("in_sample", "IS"),
 499    ("loo-cell: both held out", "DHO"),
 500    ("loo-dataset", "LODO"),
 501    ("loo-model", "LOMO"),
 502    ("doubly-held-out", "DHO"),
 503    ("doubly held out", "DHO"),
 504    ("leave-one-dataset-out", "LODO"),
 505    ("leave-one-model-out", "LOMO"),
 506    ("in-sample", "IS"),
 507)
 508
 509
 510def _published_protocol_label(value: object) -> str:
 511    """Translate stable internal protocol names at the publication boundary."""
 512    text = str(value)
 513    for internal, published in _PROTOCOL_LABELS:
 514        text = text.replace(internal, published)
 515    return text
 516
 517
 518def _table(frame: pl.DataFrame, float_format: str = "{:.4f}") -> str:
 519    """A polars frame as a GitHub-flavoured markdown table.
 520
 521    Result files keep the stable internal protocol keys. Published tables use the same
 522    concise labels as the figures: IS, LODO, LOMO, and DHO.
 523    """
 524    if frame.height == 0:
 525        return "_(empty)_"
 526
 527    def cell(value: object) -> str:
 528        # "no" rather than an empty cell: a blank reads as a missing measurement, which
 529        # is a different claim from a measured disagreement.
 530        if isinstance(value, bool):
 531            return "yes" if value else "no"
 532        if value is None:
 533            return ""
 534        if isinstance(value, float):
 535            return "" if np.isnan(value) else float_format.format(value)
 536        return _published_protocol_label(value).replace("|", r"\|")
 537
 538    header = "| " + " | ".join(_published_protocol_label(column) for column in frame.columns) + " |"
 539    rule = "|" + "|".join("---" for _ in frame.columns) + "|"
 540    body = ["| " + " | ".join(cell(value) for value in row) + " |" for row in frame.iter_rows()]
 541    return "\n".join([header, rule, *body])
 542
 543
 544def single_prediction(
 545    equation: Equation,
 546    columns: dict[str, np.ndarray],
 547    truth: np.ndarray,
 548    frame: pl.DataFrame,
 549    *,
 550    limit: int = 6,
 551) -> str:
 552    """One row's prediction broken into its terms, as a printed block.
 553
 554    The interpretability payoff in its most direct form: a reader can add the column up and
 555    get the prediction back, which is a thing no opaque regressor lets anyone do.
 556
 557    **The row is chosen by a rule, not picked.** The equation's median absolute error is a
 558    typical prediction rather than its best case, and choosing it arithmetically is what keeps
 559    this example from being a flattering one somebody selected once. The chapter this lands in
 560    is the chapter arguing that the analysis is generated rather than authored; it carried a
 561    hand-written breakdown until 2026-09-07, and every figure in it had gone stale -- the
 562    intercept it printed was 1.4787 against the equation's actual 1.3586.
 563
 564    Ties are broken by taking the first such row in corpus order, so the choice is a function
 565    of the data and the equation alone.
 566    """
 567    predicted = equation.predict(columns)
 568    error = np.abs(truth - predicted)
 569    # The median error itself, not the row at the middle of the sorted order: with an even
 570    # row count those differ, and `argmin` against the median value picks an actual row.
 571    row = int(np.argmin(np.abs(error - float(np.median(error)))))
 572
 573    shares = contributions(equation, columns)[row]
 574    ordered = sorted(zip(shares, equation.terms, strict=True), key=lambda pair: -abs(pair[0]))
 575    total = equation.intercept + float(shares.sum())
 576
 577    lines = [
 578        f"dataset  : {frame[DATASET_COLUMN][row]}",
 579        f"model    : {frame[MODEL_COLUMN][row]}",
 580        f"actual   : {truth[row]:+.4f}",
 581        f"predicted: {predicted[row]:+.4f}",
 582        "",
 583        "contribution breakdown:",
 584        f"    {equation.intercept:+.4f}   intercept",
 585    ]
 586    for value, term in ordered[:limit]:
 587        lines.append(f"    {value:+.4f}   {term.name}")
 588    if len(ordered) > limit:
 589        remainder = sum(value for value, _ in ordered[limit:])
 590        lines.append(f"    {remainder:+.4f}   the remaining {len(ordered) - limit} terms")
 591    lines.append(f"  = {total:+.4f}   sum")
 592    # `Equation.predict` clips to the range MCC can take at all, so on a row where the linear
 593    # form runs past the end of that range the column does not add up to the number printed
 594    # above it. Saying so is the honest form: a breakdown a reader cannot add up, with no
 595    # explanation of why, is worse than no breakdown.
 596    if abs(total - predicted[row]) > 5e-5:
 597        lines.append(f"    clipped to {predicted[row]:+.4f}, the range MCC can take")
 598    return "```\n" + "\n".join(lines) + "\n```"
 599
 600
 601#: Only E1 is constant within its identity group. E2's model-mean reference is descriptive
 602#: because Processing Units Number can vary within a model, so no ceiling ratio is shown.
 603CEILING_ROWS = {"E1": "E1 reference: true dataset means"}
 604
 605
 606def _headline(report: Report) -> pl.DataFrame:
 607    """The equations, with a ceiling ratio only for dataset-constant E1.
 608
 609    The one table the study can be summarised by. It exists because the summary page kept a
 610    hand-written copy of these numbers and had no way of noticing when they moved -- the same
 611    failure the generated chapter sections were introduced to remove, reappearing one level
 612    up. **The ``reached`` column is the point of the table**, not the R2 column: E1's
 613    structural maximum is a per-dataset constant, so its score and E3's score are not
 614    comparable as achievements, and printing the fraction of each equation's own ceiling
 615    beside them is what stops a reader making that comparison anyway.
 616    """
 617    ceilings = dict(zip(report.comparison["equation"].to_list(), report.comparison["r2"].to_list(), strict=True))
 618    rows: list[dict[str, object]] = []
 619    listed = [("E1", report.e1), ("E2", report.e2), ("E3-Valid", report.e3)]
 620    if report.e3_capability is not report.e3:
 621        listed.append(("E3-MAX", report.e3_capability))
 622    for name, equation in listed:
 623        ceiling = ceilings.get(CEILING_ROWS.get(name, ""))
 624        in_sample = float(equation.in_sample["r2"])
 625        rows.append(
 626            {
 627                "equation": name,
 628                "features": {"E1": "dataset", "E2": "model"}.get(name, "both"),
 629                "terms": equation.equation.n_terms,
 630                "IS R2": in_sample,
 631                "LODO R2": float(equation.cross_validated["loo_dataset"]["r2"]),
 632                "LOMO R2": float(equation.cross_validated["loo_model"]["r2"]),
 633                "DHO R2": float(equation.cross_validated["loo_cell"]["r2"]),
 634                "own ceiling": float(ceiling) if ceiling is not None else float("nan"),
 635                "reached": in_sample / float(ceiling) if ceiling else float("nan"),
 636            }
 637        )
 638    return pl.DataFrame(rows)
 639
 640
 641def _scores(label: str, scores: dict[str, float | int]) -> str:
 642    return (
 643        f"| {label} | {float(scores['r2']):.4f} | {float(scores['mae']):.4f} | "
 644        f"{float(scores['rmse']):.4f} | {int(scores['n'])} |"
 645    )
 646
 647
 648def _crater_note(report: Report, selection: SelectionConfig) -> str:
 649    """The deepest single-length crater on the worst-protocol curve, and what smoothing does to it.
 650
 651    The length rule reads the floor through a running median, and the argument for that is
 652    only convincing next to a length where one fold dragged the curve down. Which length that
 653    is moves whenever the configuration does, so it is found here rather than named in prose.
 654
 655    Deepest is measured against the neighbouring lengths, not against the curve's own mean: a
 656    crater is a local collapse, and a length at the end of a declining run is not one.
 657    """
 658    curve = report.e3.curve
 659    if curve.height < 3:
 660        return ""
 661    floor = floor_curve(curve)
 662    lengths = curve["n_terms"].to_numpy()
 663    neighbours = (floor[:-2] + floor[2:]) / 2.0
 664    index = int(np.argmax(neighbours - floor[1:-1])) + 1
 665    depth = float(neighbours[index - 1] - floor[index])
 666    if depth <= 0.0:
 667        return ""
 668    smooth = smoothed(floor, selection.smoothing)
 669    return (
 670        f"**Why the curve is smoothed before a length is chosen.** The deepest crater on this "
 671        f"curve is at **{int(lengths[index])} terms**, where the worst protocol reads "
 672        f"{float(floor[index]):.3f}, {depth:.3f} below the neighbouring lengths. The running median "
 673        f"of {selection.smoothing} lengths reads {float(smooth[index]):.3f} there. A crater like this "
 674        "is not a property of the length -- it is one held-out dataset sitting outside the convex "
 675        "hull of the other nineteen in term space, where a linear equation extrapolates without "
 676        "limit and `validate._clip_to_training` pins the fold to its training floor -- and the "
 677        "same holds for a single lucky length. Neither should choose the published length.\n"
 678    )
 679
 680
 681def _shared_terms_note(report: Report) -> str:
 682    """How many of each control's terms E3-Valid uses exactly, and how many of E3's are mixed."""
 683    published = {term.name for term in report.e3.equation.terms}
 684    rows = []
 685    for label, control in (("E2 → E3", report.e2), ("E1 → E3", report.e1)):
 686        names = [term.name for term in control.equation.terms]
 687        shared = [name for name in names if name in published]
 688        rows.append(
 689            f"| {label} | **{len(shared)} of {len(names)}** | "
 690            + ("; ".join(f"`{name}`" for name in shared) or "—")
 691            + " |"
 692        )
 693    mixed = sum(
 694        1 for term in report.e3.equation.terms if classify(term.features, DATASET_FEATURES, MODEL_FEATURES) == "mixed"
 695    )
 696    return (
 697        "How much of each control survives into E3-Valid, counting terms shared *exactly*:\n\n"
 698        "| | shared with E3 | which |\n|---|---|---|\n"
 699        + "\n".join(rows)
 700        + f"\n\n{mixed} of E3-Valid's {report.e3.equation.n_terms} terms mix dataset and model "
 701        "features, and a mixed term is available to neither control by construction.\n"
 702    )
 703
 704
 705def _optimism_note(report: Report) -> str:
 706    """The fixed-form LODO against LODO with the form re-chosen per fold, and the gap."""
 707    if report.optimism.height < 2:
 708        return ""
 709    reported, nested = (float(value) for value in report.optimism["r2"].to_list()[:2])
 710    return (
 711        "Every reported held-out number fixes the equation's form -- chosen once, on all observed "
 712        "rows -- and refits only its weights in each fold. The form was therefore chosen with "
 713        "the held-out dataset in view. The second row repeats the *selection* inside every "
 714        f"LODO fold, at E3-Valid's {report.e3.n_terms} terms and configuration, and scores those "
 715        "predictions instead:\n\n"
 716        + _table(report.optimism.select("LODO", "r2", "mae", "spearman"))
 717        + f"\n\n**LODO R² differs by {reported - nested:.3f}** between the globally selected form "
 718        f"({reported:.3f}) and within-fold term reselection ({nested:.3f}). This is a partial "
 719        "selection-sensitivity diagnostic: global configuration, requested length, and library "
 720        "construction remain shared. Neither row independently assesses the complete discovery "
 721        "procedure. The fixed-form scores are conditional on selection.\n"
 722    )
 723
 724
 725def _saturated_note(report: Report) -> str:
 726    """Read the saturated fit against the published equation, in one generated sentence.
 727
 728    The comparison is the point, and stating it in prose beside the table is how it stops
 729    being two numbers a reader has to subtract. Both sides come from this run, so the
 730    sentence cannot drift from the table above it.
 731    """
 732    if report.saturated.height == 0:
 733        return ""
 734    row = report.saturated.to_dicts()[0]
 735    published = float(report.e3.cross_validated["loo_dataset"]["r2"])
 736    return (
 737        f"**The solver is not the hard part; the sample size is.** All "
 738        f"{int(row['terms'])} terms at once fit better under IS than the published equation "
 739        f"({float(row['r2_in_sample']):.4f} against "
 740        f"{float(report.e3.in_sample['r2']):.4f}) and transfer at "
 741        f"{float(row['r2_loo_dataset_clipped']):.4f} under LODO, against the "
 742        f"published equation's {published:.4f}. The unclipped figure — "
 743        f"{float(row['r2_loo_dataset_unclipped']):.1f} — is what the fit does when a held-out "
 744        "dataset falls outside the convex hull of the other nineteen and nothing bounds the "
 745        "extrapolation. A design this much wider than 20 held-out groups can support has "
 746        "nothing to constrain it, which is what selection is for.\n"
 747    )
 748
 749
 750def _coverage_note(report: Report, columns: dict[str, np.ndarray]) -> str:
 751    """How far the equation reaches into the catalogue, and how to read a disagreement.
 752
 753    Two counts a reader will otherwise conflate: the verdict tally above is drawn from corpus
 754    statistics that any study with this data could compute, while this is about the terms. And
 755    one reading that has to be spelled out, because the table looks self-contradictory without
 756    it -- a feature in a denominator enters inverted, so the same feature can carry opposite
 757    signs in two terms and mean one thing.
 758    """
 759    counts = equation_coverage(report, columns)
 760    if not counts["pairs"]:
 761        return ""
 762    lines = [
 763        f"**Read this against the verdict tally above, not as part of it.** Of the "
 764        f"{counts['practices']} practices, {counts['with_feature_claims']} make a claim about a "
 765        "quantity the equation contains; the rest are about a protocol, a metric, or a family "
 766        "of learners, and pairing one of those with a coefficient would be inventing a "
 767        f"connection. Those are carried by **{counts['carrying_terms']} of the equation's "
 768        f"{counts['terms']} terms**, giving {counts['pairs']} directed (practice, term) "
 769        f"pairings: **{counts['agree']} come out the way the practice predicts and "
 770        f"{counts['disagree']} do not**"
 771        + (
 772            f", with {counts['undirected']} pairing too weak to state a direction for and "
 773            f"{counts['unselected']} claim resting on a feature the search never took.\n"
 774            if counts["undirected"] or counts["unselected"]
 775            else ".\n"
 776        ),
 777    ]
 778    if counts["disagree"] and counts["disagree_in_denominator"] == counts["disagree"]:
 779        lines.append(
 780            f"\n**Every one of the {counts['disagree']} disagreements is the same feature "
 781            "entering as a *denominator*, and that is arithmetic rather than conflict.** A "
 782            "negatively-weighted ratio contributes more as its denominator grows, so a term "
 783            "of the form `dataset property / capacity` must rise with capacity. Read the "
 784            "`position` column across those rows and the equation is saying one coherent "
 785            "thing: it has **no marginal statement about capacity at all**, only statements "
 786            "about capacity *relative to* something the dataset demands. Where capacity is a "
 787            "numerator instead — measured against the class count rather than against a "
 788            "difficulty — it lowers predicted MCC, which is the practice's own claim.\n"
 789            "\n**So the mismatch is in how the expectation was written down, not in what the "
 790            "equation says.** A practice recommending that capacity be *matched to the "
 791            "problem* is a conditional claim; encoding it as `capacity lowers MCC` is a "
 792            "marginal one, and a marginal expectation cannot match a term that only ever "
 793            "speaks conditionally. This is the clearest case in the study of why the equation "
 794            "is worth reading term by term: no per-feature summary, and no opaque model, can "
 795            "distinguish 'more capacity is better' from 'more capacity per unit of difficulty "
 796            "is better'.\n"
 797        )
 798    else:
 799        lines.append(
 800            "\n**Read the `position` column before calling a row a disagreement.** A feature "
 801            "in a denominator enters the term inverted, so a negatively-weighted ratio "
 802            "contributes more as that feature rises. The same feature carrying opposite signs "
 803            "in two terms is usually one statement about a ratio rather than two conflicting "
 804            "ones about a quantity.\n"
 805        )
 806    lines.append(
 807        "\n``beta`` is the strength and ``effect`` is what the term is worth on this data; "
 808        "**agreement in sign with a negligible effect is agreement without evidence**, which "
 809        "is why the two are printed together. ``rho`` is the measured association the "
 810        "direction comes from, and a pairing below the same floor the per-feature statements "
 811        "use is reported as having no direction rather than being given a sign it cannot "
 812        "support.\n"
 813    )
 814    return "\n".join(lines)
 815
 816
 817def _identity_note(report: Report) -> str:
 818    """What the identity ceiling is worth, and whether the study's own test calls it real.
 819
 820    A difference of two pooled R2 values over twenty folds is not a measurement -- that has
 821    produced three wrong conclusions on this project -- so the rungs are paired against the
 822    uncorrected equation and the verdict comes from the test rather than from the size of the
 823    gap. The two halves of the paired test are reported separately where they disagree,
 824    because they answer different questions: the sign test asks whether the correction wins
 825    *consistently*, the bootstrap whether the mean gain survives a different draw of datasets.
 826    """
 827    if report.identity.height < 3:
 828        return ""
 829    rows = report.identity.to_dicts()
 830    base, level, slope = rows[0], rows[1], rows[2]
 831    total = float(slope["r2_loo_dataset"]) - float(base["r2_loo_dataset"])
 832    step = float(level["r2_loo_dataset"]) - float(base["r2_loo_dataset"])
 833
 834    lines = [
 835        f"**The gap is {total:.3f} of LODO R²**, of which a per-model *level* "
 836        f"recovers {step:.3f} and the level-plus-slope form the remaining {total - step:.3f}.\n",
 837        "**Whether that is real is a paired question**, so each rung is compared with the "
 838        "uncorrected equation dataset by dataset, on absolute error, over the twenty held-out "
 839        "folds. The two rungs come back differently, and the difference is the finding:\n",
 840    ]
 841    if level["verdict"] == "tie":
 842        lines.append(
 843            f"* a per-model **level** is a **tie** -- it wins on {int(level['wins'])} of the "
 844            "twenty folds and its interval spans zero. A constant shift per model, which is "
 845            "what a level is, adds nothing the equation does not already have.\n"
 846        )
 847    if slope["verdict"] == "real":
 848        lines.append(
 849            f"* a per-model **slope** is **not** a tie: the bootstrap interval "
 850            f"[{float(slope['ci_low']):+.4f}, {float(slope['ci_high']):+.4f}] lies entirely "
 851            f"above zero, on {int(slope['wins'])} winning folds of twenty. Read it with the "
 852            f"sign test beside it, which at p = {float(slope['sign_p']):.3f} does **not** reach "
 853            "significance -- so the gain is carried by its size on the folds it wins rather "
 854            "than by winning nearly all of them. That is weaker evidence than the interval "
 855            "alone suggests, and stronger than a tie.\n"
 856        )
 857        lines.append(
 858            "**So the question this chapter was written to close is not closed.** The half of "
 859            "the correction that survives is the one that lets a model's advantage depend on "
 860            "the data -- exactly what a *capability* descriptor would have to do, and exactly "
 861            "what none of the descriptors this corpus records does. The mixed terms were "
 862            "supposed to absorb that interaction and have absorbed only part of it.\n"
 863        )
 864    else:
 865        lines.append(
 866            "* neither rung survives pairing, so on this corpus perfect model identity adds "
 867            "nothing measurable to the published equation and the model side is as well "
 868            "described as free per-model numbers could make it.\n"
 869        )
 870    lines.append(
 871        "This correction tests information carried by model histories. Its outcome does not "
 872        "establish which alternative descriptors or categorical encodings would transfer to "
 873        "unseen learners. Encoding comparisons remain conditional on form selection.\n"
 874    )
 875    return "\n".join(lines)
 876
 877
 878def _opaque_note(report: Report) -> str:
 879    """Read the opaque table across its protocols, and against the equation on each.
 880
 881    The columns are ordered by how much the estimator was allowed to see, and reading them
 882    left to right is the argument. The last one is the comparison that matters: under
 883    Under DHO neither side has the dataset or the model, so it is the only protocol on
 884    which an opaque regressor and the published equation are denied the same things.
 885    """
 886    if report.opaque.height == 0 or "r2_loo_cell" not in report.opaque.columns:
 887        return ""
 888    rows = report.opaque.to_dicts()
 889    best_fit = max(rows, key=lambda row: float(row["r2_in_sample"]))
 890    best_cell = max(rows, key=lambda row: float(row["r2_loo_cell"]))
 891    return (
 892        f"**Read the {best_fit['model']} row across.** It fits this meta-data at R² "
 893        f"{float(best_fit['r2_in_sample']):.4f}; holding out a whole model leaves it at "
 894        f"{float(best_fit['r2_loo_model']):.4f}; holding out a whole dataset drops it to "
 895        f"{float(best_fit['r2_loo_dataset']):.4f}; and with **both** held out it reaches "
 896        f"{float(best_fit['r2_loo_cell']):.4f}. The published equation is at "
 897        f"{float(report.e3.in_sample['r2']):.4f} and "
 898        f"{float(report.e3.cross_validated['loo_dataset']['r2']):.4f} in the corresponding "
 899        "IS and LODO settings.\n"
 900        "\nThese are fixed-setting comparisons across grouped coefficient-fit partitions. "
 901        "Lower scores after dataset exclusion show sensitivity to the available task histories; "
 902        "they do not by themselves identify the mechanism of a regressor's errors.\n"
 903        f"\n**Under joint exclusion the best reference R² is "
 904        f"{float(best_cell['r2_loo_cell']):.4f}** ({best_cell['model']}). Per-model historical "
 905        "baselines are unavailable under DHO because the target learner has no fitting rows. "
 906        "Regression, ranking, and threshold outcomes must be read separately.\n"
 907        "\nRidgeCV selects its penalty within fitting partitions. The tree ensembles retain "
 908        "documented study-development settings without fresh tuning on the revised corpus. "
 909        "Their raw features and prior selection budgets differ from the globally selected "
 910        "symbolic form; these comparisons do not independently establish superiority of "
 911        "the complete discovery procedure.\n"
 912    )
 913
 914
 915def _length_note(report: Report, selection: SelectionConfig) -> str:
 916    """Explain the length rule with this run's knees, and the paired sensitivity table."""
 917    if report.length_choice.height == 0:
 918        return ""
 919    knees = knee_lengths(report.e3.curve, selection.smoothing)
 920    listed = ", ".join(str(knee) for knee in knees) if knees else "none"
 921    _, how = plateau_start(
 922        report.e3.curve, delta=selection.delta, window=selection.window, smoothing=selection.smoothing
 923    )
 924    reached = (
 925        "It is a knee: the curve stops improving right after it."
 926        if how == "knee"
 927        else "None of the knees is followed by a plateau -- the curve keeps gaining past each -- so "
 928        "the plateau starts at the smoothed maximum."
 929    )
 930    lines = [
 931        f"E3-Valid has **{report.e3.n_terms} terms**. The rule, `selection.plateau_knee`, is "
 932        "the same for E1, E2 and E3-Valid. It reads the worst R² over IS, LODO, LOMO and DHO at "
 933        f"every length, smoothed by a running median of {selection.smoothing} lengths; "
 934        "multi-Kneedle (`kneeliverse`) proposes the lengths where that curve's Pareto front "
 935        f"bends -- here **{listed}** -- and the rule takes the first of them, or the smoothed "
 936        f"maximum, after which the smoothed curve gains at most {selection.delta:g} over the next "
 937        f"{selection.window} lengths: the shortest equation that has stopped improving. {reached}\n",
 938        _crater_note(report, selection),
 939        "The following paired analysis compares every length against E3-Valid, dataset by "
 940        "dataset; it is a sensitivity analysis rather than an additional selector:\n",
 941        _table(report.length_choice) + "\n",
 942    ]
 943    return "\n".join(lines)
 944
 945
 946def _form_stability(report: EquationReport) -> float:
 947    """Mean share of LODO folds that re-select the equation's own terms."""
 948    if report.stability is None or not report.equation.terms:
 949        return float("nan")
 950    frequency = dict(zip(report.stability["term"].to_list(), report.stability["frequency"].to_list(), strict=True))
 951    return float(np.mean([frequency.get(term.name, 0.0) for term in report.equation.terms]))
 952
 953
 954def _capability_note(report: Report) -> str:
 955    """The configured capability/length comparison at the raw floor maximum."""
 956    capability = report.e3_capability
 957    if not capability.equation.terms:
 958        return ""
 959    published = float(report.e3.in_sample["r2"])
 960    reached = float(capability.in_sample["r2"])
 961    lines = [
 962        f"The published equation uses arity {report.e3.arity}. The same configuration under arity "
 963        f"{capability.arity}, at the raw maximum of its worst-protocol curve (E3-MAX), reaches "
 964        f"{len(capability.equation.terms)} terms at R² {reached:.4f} under IS:\n",
 965        "| | terms | IS | LODO | LOMO | DHO | form stability |",
 966        "|---|---|---|---|---|---|---|",
 967        f"| E3-Valid (arity {report.e3.arity}) | {len(report.e3.equation.terms)} | {published:.4f} | "
 968        f"{float(report.e3.cross_validated['loo_dataset']['r2']):.4f} | "
 969        f"{float(report.e3.cross_validated['loo_model']['r2']):.4f} | "
 970        f"{float(report.e3.cross_validated['loo_cell']['r2']):.4f} | {_form_stability(report.e3):.2f} |",
 971        f"| E3-MAX (arity {capability.arity}) | {len(capability.equation.terms)} | {reached:.4f} | "
 972        f"{float(capability.cross_validated['loo_dataset']['r2']):.4f} | "
 973        f"{float(capability.cross_validated['loo_model']['r2']):.4f} | "
 974        f"{float(capability.cross_validated['loo_cell']['r2']):.4f} | {_form_stability(capability):.2f} |",
 975        "",
 976        "This is a **capability measurement, not a recommendation**. It answers the question "
 977        "the published equation cannot answer about itself — whether the additive form is out "
 978        f"of room or whether this equation is short of it — and the answer is that {reached - published:+.4f} "
 979        "of IS R² separates the equations under the configured arities and length rules. Form stability "
 980        "is the mean share of LODO folds that re-select an equation's own terms when selection "
 981        "is re-run without the held-out dataset.\n",
 982    ]
 983    return "\n".join(lines)
 984
 985
 986def _reach_note(report: Report) -> str:
 987    """The grammar's implied ceiling ladder, and where the equation lands on it.
 988
 989    Everything here is arithmetic over `analysis.grammar_ceiling` and the fitted R2. The
 990    point of the section is that the three rungs are computable *before* any search runs, so
 991    the equation's score can be read against an expectation rather than against nothing.
 992    """
 993    if report.ceiling.height == 0:
 994        return ""
 995    ladder = report.ceiling.to_dicts()[0]
 996    raw = float(ladder["r2_raw_additive"])
 997    best = float(ladder["r2_best_per_feature"])
 998    single = float(ladder["r2_all_single_feature"])
 999    fitted = float(report.e3.in_sample["r2"])
1000    n_single = int(float(ladder["n_single_feature_terms"]))
1001    n_raw = int(report.reach.filter(pl.col("r2_raw").is_finite()).height)
1002    n_terms = len(report.e3.equation.terms)
1003
1004    strongest = report.reach.head(1).to_dicts()[0] if report.reach.height else None
1005    lines = [
1006        "Three levels of what the vocabulary can explain, each a least-squares fit over the "
1007        "library and each computable before the search runs. They bound a *sum of "
1008        "per-feature functions*, which is a different question from the additive mean-based reference "
1009        "above: that one bounds a per-dataset value plus a per-model value.\n",
1010        "| level | terms | R² |",
1011        "|---|---|---|",
1012        f"| every raw term admitted by the grammar | {n_raw} | {raw:.4f} |",
1013        f"| the best single-feature term per feature | {len(ALL_FEATURES)} | {best:.4f} |",
1014        f"| every single-feature term at once | {n_single} | {single:.4f} |",
1015        f"| **the fitted equation (E3-Valid)** | **{n_terms}** | **{fitted:.4f}** |",
1016        "",
1017    ]
1018    if strongest is not None:
1019        lines.append(
1020            f"No individual feature carries much: the strongest is `{strongest['feature']}` at "
1021            f"R² {float(strongest['r2_best']):.3f}, so any accuracy beyond that is combination "
1022            "rather than a single dominant driver. Taking one best admissible term per "
1023            f"feature is worth {best - raw:+.3f} over the admissible raw-term fit.\n"
1024        )
1025    verdict = (
1026        f"E3-Valid reaches {fitted:.4f} with {n_terms} terms, **above** the {single:.4f} that all "
1027        f"{n_single} single-feature terms reach together. An equation cannot pass that level "
1028        "by describing features one at a time, so the excess is what the cross-feature terms "
1029        "buy — the same conclusion the additive mean-based reference reaches, by an independent route."
1030        if fitted > single
1031        else f"E3-Valid reaches {fitted:.4f} with {n_terms} terms against the {single:.4f} available "
1032        f"from all {n_single} single-feature terms, so on this configuration its accuracy is "
1033        "still within what per-feature description alone could explain."
1034    )
1035    lines.append(verdict + "\n")
1036    return "\n".join(lines)
1037
1038
1039def _protocol_note(report: Report) -> str:
1040    """What the equation is worth as more of the problem becomes unfamiliar.
1041
1042    Built from the two decision tables, which now carry the protocol of every row. The point
1043    of the section is that the strictest protocol -- both the dataset and the model out of the
1044    training set -- is the one the practitioner's question actually needs, and that the trivial
1045    baselines cannot be computed under it at all.
1046    """
1047    ranking = report.ranking_baselines
1048    decision = report.decision_baselines
1049    if ranking.height == 0 or decision.height == 0:
1050        return ""
1051
1052    order = [
1053        ("in-sample", "IS", "nothing held out"),
1054        ("loo-dataset", "LODO", "the dataset unseen, the model known"),
1055        ("loo-model", "LOMO", "the model unseen, the dataset known"),
1056        ("loo-cell", "DHO", "**both unseen**"),
1057    ]
1058    rank_rows = {str(row["predictor"]): row for row in ranking.to_dicts()}
1059    mid = sorted({float(value) for value in decision["threshold"]})
1060    threshold = mid[len(mid) // 2]
1061    dec_rows = {str(row["predictor"]): row for row in decision.filter(pl.col("threshold") == threshold).to_dicts()}
1062
1063    lines = [
1064        "The ranking and the go/no-go decision are reported with **both the dataset and the "
1065        "model of every cell held out of the fit**. Neither single-group protocol answers the "
1066        "question those tasks pose: LODO has seen the learner on the other "
1067        "nineteen problems, and LOMO has seen the dataset. A recommendation is "
1068        "asked about a pair that has not been run.\n",
1069        f"| Evaluation protocol | AP | MRR | hit@1 | regret | F1 @ {threshold:g} | MCC @ {threshold:g} |",
1070        "|---|---|---|---|---|---|---|",
1071    ]
1072    for key, label, shown in order:
1073        name = next((n for n in rank_rows if key in n), None)
1074        dname = next((n for n in dec_rows if key in n), None)
1075        if name is None or dname is None:
1076            continue
1077        r, d = rank_rows[name], dec_rows[dname]
1078        emphasis = "**" if key == "loo-cell" else ""
1079        lines.append(
1080            f"| {emphasis}{label}{emphasis} — {shown} | {float(r['ap']):.3f} | {float(r['mrr']):.3f} | "
1081            f"{float(r['hit_at_1']):.2f} | {float(r['regret']):.3f} | "
1082            f"{float(d['f1']):.3f} | {float(d['mcc']):.3f} |"
1083        )
1084    lines.append("")
1085
1086    baselines = [str(name) for name in rank_rows if "per-model" in name]
1087    if baselines:
1088        best = max(baselines, key=lambda n: float(rank_rows[n]["ap"]))
1089        lines.append(
1090            "The per-model mean and median predictors are reported below under "
1091            "LODO, the only held-out protocol in which the test model still "
1092            "has training rows. **Under the strictest protocol they cannot be computed at "
1093            "all**: a model held out of every fold has no rows to average, so "
1094            f'"how well does this model usually do" has no value. The best of them reaches AP '
1095            f"{float(rank_rows[best]['ap']):.3f} and F1 {float(dec_rows[best]['f1']):.3f} while "
1096            "being shown the model identity the strictest row of the equation is denied.\n"
1097        )
1098    return "\n".join(lines)
1099
1100
1101def _ranking_verdict(baselines: pl.DataFrame) -> str:
1102    """State the ranking comparison from the paired test, never from the means.
1103
1104    The means over twenty datasets are the trap this study has fallen into three times.
1105    hit@1 moves only in steps of 0.05 on twenty folds, so one dataset flipping its top pick
1106    shifts it further than the gaps in the table; and average precision differences of this
1107    size sit well inside the bootstrap interval. The sentence is therefore built from
1108    `ap_vs_e3_significant`, not from which row has the larger mean.
1109    """
1110    if "ap_vs_e3_significant" not in baselines.columns:
1111        return ""
1112    rivals = [row for row in baselines.to_dicts() if row.get("ap_vs_e3_significant") is not None]
1113    if not rivals:
1114        return ""
1115    beaten = [row for row in rivals if row["ap_vs_e3_significant"]]
1116    names = ", ".join(_published_protocol_label(row["predictor"]) for row in rivals)
1117    if beaten:
1118        return (
1119            "Paired over the datasets, the equation differs significantly from: "
1120            + ", ".join(_published_protocol_label(row["predictor"]) for row in beaten)
1121            + ". The remaining comparisons are ties.\n"
1122        )
1123    return (
1124        f"**None of these differences survives a paired test.** Against {names} the sign test "
1125        "and the bootstrap interval over datasets both include zero, so on ranking the equation "
1126        "is indistinguishable from ordering the models by how well they usually do. Read the "
1127        "means in the table above as ties, not as a ranking of the predictors — including where "
1128        "a baseline's mean is the larger one.\n"
1129    )
1130
1131
1132def _baseline_centre_note(baselines: pl.DataFrame) -> str:
1133    """Which centre is the harder baseline, per metric, derived rather than asserted.
1134
1135    The mean minimises squared error and the median minimises absolute error, so the fair
1136    opponent differs by metric. Rather than state that as a claim, the sentence is built from
1137    the table: for each metric, the best mean row is compared with the best median row.
1138    """
1139    rows = {str(row["baseline"]): row for row in baselines.to_dicts()}
1140    pairs = [(name, name.replace(" mean ", " median ")) for name in rows if " mean " in name]
1141    if not pairs:
1142        return ""
1143
1144    def best(names: list[str], metric: str, lower_is_better: bool) -> tuple[str, float]:
1145        scored = [(name, float(rows[name][metric])) for name in names]
1146        return min(scored, key=lambda item: item[1]) if lower_is_better else max(scored, key=lambda item: item[1])
1147
1148    means = [mean for mean, _ in pairs]
1149    medians = [median for _, median in pairs if median in rows]
1150    if not medians:
1151        return ""
1152
1153    lines = [
1154        "Every trivial predictor appears at its mean and at its median, because the metrics "
1155        "disagree about which is the honest opponent: the mean minimises squared error and "
1156        "the median minimises absolute error, so an MAE quoted against a mean baseline is "
1157        "quoted against a predictor that is not minimising the metric it is judged on. "
1158        "Reading the strongest baseline of each kind, per metric:\n",
1159        "| metric | strongest mean baseline | strongest median baseline | harder |",
1160        "|---|---|---|---|",
1161    ]
1162    for metric, lower_is_better in (("r2", False), ("mae", True), ("smape", True)):
1163        mean_name, mean_value = best(means, metric, lower_is_better)
1164        median_name, median_value = best(medians, metric, lower_is_better)
1165        median_wins = median_value < mean_value if lower_is_better else median_value > mean_value
1166        lines.append(
1167            f"| {metric.upper()} | {_published_protocol_label(mean_name)} ({mean_value:.4f}) | "
1168            f"{_published_protocol_label(median_name)} ({median_value:.4f}) | "
1169            f"**{'median' if median_wins else 'mean'}** |"
1170        )
1171    return "\n".join(lines) + "\n"
1172
1173
1174def _configuration(config: Configuration, selection: SelectionConfig | None) -> str:
1175    rows = [{"setting": f"search.{field.name}", "value": str(getattr(config, field.name))} for field in fields(config)]
1176    if selection is not None:
1177        rows += [
1178            {"setting": f"selection.{field.name}", "value": str(getattr(selection, field.name))}
1179            for field in fields(selection)
1180        ]
1181    return _table(pl.DataFrame(rows))
1182
1183
1184def render(
1185    report: Report,
1186    columns: dict[str, np.ndarray],
1187    truth: np.ndarray,
1188    dataset_features: tuple[str, ...],
1189    model_features: tuple[str, ...],
1190    *,
1191    frame: pl.DataFrame | None = None,
1192    config: Configuration | None = None,
1193    selection: SelectionConfig | None = None,
1194    source: str | None = None,
1195) -> str:
1196    """The full study as a markdown report.
1197
1198    Every table and every sentence is computed from ``report``. The prose that is fixed
1199    -- section headings and the explanations of what each table means -- is fixed because
1200    it describes the *method*, which does not change between runs; anything describing
1201    the *result* is generated.
1202    """
1203    rule = selection or load_config().selection
1204    importance = term_importance(report.e3.equation, columns, dataset_features, model_features, report.e3.stability)
1205    concentration = coverage(importance)
1206    equation = report.e3.equation
1207    # Counted from the corpus rather than written into the prose below it. A generated
1208    # sentence carrying a literal "20 datasets" is the same staleness the generated sections
1209    # exist to prevent, one level further in.
1210    datasets = int(frame[DATASET_COLUMN].n_unique()) if frame is not None else 0
1211
1212    parts: list[str] = []
1213    parts.append("# ml-meta-perf — fitted equation and extracted practices\n")
1214    parts.append(
1215        "Generated by `ml_meta_perf.report.render`. Every number, table and sentence below is "
1216        "computed from the fitted equation and the validation run; nothing is written by "
1217        "hand at report time.\n"
1218    )
1219
1220    parts.append("## 1. The configuration this run used\n")
1221    if source:
1222        parts.append(f"Meta-dataset: `{source}`\n")
1223    if config is not None:
1224        parts.append("Every hyperparameter below is read from `config/study.json`; E1, E2 and E3 share all of them.\n")
1225        parts.append(_configuration(config, selection) + "\n")
1226
1227    parts.append("## 1b. The corpus\n")
1228    parts.append(
1229        "What the meta-dataset is, computed from the file the run was fitted on rather than "
1230        "transcribed into prose beside it.\n"
1231    )
1232    if frame is not None:
1233        parts.append(_table(corpus_summary(frame)) + "\n")
1234        parts.append(
1235            "**The absent cells are not missing at random.** Every dataset short of models is "
1236            "one of the smallest in the corpus, which is what the instance counts show:\n"
1237        )
1238        parts.append(_table(missing_cells(frame), float_format="{:.0f}") + "\n")
1239        parts.append("And how MCC is distributed over those rows:\n")
1240        parts.append(_table(target_summary(frame)) + "\n")
1241        parts.append(
1242            "**The table counts observations exactly at 0 or 1, and those below "
1243            "zero.** That is "
1244            "what makes MAE rather than SMAPE the reported error: SMAPE divides by "
1245            "`|truth| + |prediction|`, so every row at exactly zero contributes the full 200% "
1246            "unless the prediction is exactly zero too, and the metric ends up dominated by "
1247            "the rows the equation is already known to handle worst.\n"
1248        )
1249        parts.append(
1250            "Two things these tables cannot say, both of which bound every number in the study. "
1251            "Each row is the **best of five seeds**, not their mean, so the target is "
1252            "optimistic and subject to seed variation that is not modelled separately; and "
1253            "tuning uses a sample **capped at 100,000 rows**, while later performance evaluation "
1254            "uses available source data with dataset/model-specific limits. `nr_inst` is the "
1255            "source dataset's size rather than necessarily the training set's. These are properties of the "
1256            "corpus builder upstream, and are audited against it in the prose above.\n"
1257        )
1258    else:
1259        parts.append("_(not summarised: the meta-dataset was not supplied to the renderer)_\n")
1260
1261    parts.append("## 1c. The headline\n")
1262    parts.append(
1263        "The one table the study is summarised by, so that the summary cannot drift from "
1264        f"the chapters. Every row is scored on the same {int(report.e3.in_sample['n'])} rows "
1265        "under the same protocols. E1, E2, and E3-Valid share the retained base configuration; "
1266        "E3-MAX takes the raw floor maximum at the configured capability arity; the current "
1267        "primary and capability arities both equal three.\n"
1268    )
1269    parts.append(_table(_headline(report)) + "\n")
1270    parts.append(
1271        "**Do not read these R² values as achievements against each other.** They share a "
1272        "scale but not a ceiling: E1 sees only dataset features, every row of a dataset "
1273        "shares one feature vector, and so E1 can predict nothing but a per-dataset "
1274        "constant. The `true dataset means` row gives E1's structural maximum. "
1275        "The last column gives only E1's fraction of that "
1276        "bound. E2's model-mean reference is descriptive because Processing Units Number "
1277        "can vary with dataset shape; no ceiling fraction is reported for E2.\n"
1278    )
1279    parts.append(
1280        "**E2, E3-Valid, and E3-MAX have blank ceiling cells.** Their recorded predictors are "
1281        "not constant within the corresponding identity groups. The comparison table in "
1282        "chapter 5 also shows the additive mean-based reference. On the incomplete grid that "
1283        "reference is not a jointly fitted least-squares additive bound; mixed terms can "
1284        "represent interactions beyond marginal group effects.\n"
1285    )
1286
1287    parts.append("## 2. The equation\n")
1288    parts.append(
1289        f"E3-Valid uses **{equation.n_terms} terms** over dataset and model meta-features, "
1290        "simplified and refitted after pruning, so it evaluates exactly as printed.\n"
1291    )
1292    parts.append("```\n" + str(equation) + "\n```\n")
1293    parts.append("LaTeX:\n")
1294    parts.append("```latex\n" + equation.to_latex() + "\n```\n")
1295
1296    parts.append("## 3. How well it does\n")
1297    parts.append("| protocol | R² | MAE | RMSE | n |")
1298    parts.append("|---|---|---|---|---|")
1299    parts.append(_scores("IS", report.e3.in_sample))
1300    protocol_labels = {"loo_dataset": "LODO", "loo_model": "LOMO", "loo_cell": "DHO"}
1301    for label, scores in report.e3.cross_validated.items():
1302        parts.append(_scores(protocol_labels.get(label, label.replace("_", "-")), scores))
1303    parts.append("")
1304    parts.append(
1305        "Cross-validated rows hold out a whole dataset or a whole model, so the equation "
1306        "is scored on a group it has never seen. That is the number that matters, and it "
1307        "is well below the IS value at this sample size.\n"
1308    )
1309    parts.append("Against the baselines and descriptive group-mean references:\n")
1310    parts.append(_table(report.comparison) + "\n")
1311
1312    parts.append("### The trivial predictors, at both centres\n")
1313    parts.append(_table(report.baselines) + "\n")
1314    parts.append(_baseline_centre_note(report.baselines))
1315
1316    parts.append("## 3c. How far the form could reach\n")
1317    parts.append(
1318        "The following library fits and length comparison describe representational reach "
1319        "under the recorded corpus and settings. They are not independent performance "
1320        "estimates or universal ceilings.\n"
1321    )
1322    parts.append("### How far the additive form reaches\n")
1323    parts.append(_capability_note(report))
1324
1325    parts.append("### What the vocabulary could reach, before any search\n")
1326    parts.append(_reach_note(report))
1327
1328    parts.append("### Where the variance is, before any equation\n")
1329    parts.append("Variance of MCC explained by group identity alone, with no equation involved:\n")
1330    parts.append(_table(report.decomposition) + "\n")
1331
1332    parts.append("### How fast interaction pays\n")
1333    parts.append(
1334        "The additive form cannot represent dataset-by-model interaction beyond what its mixed "
1335        "terms reach. The ladder below adds interaction components to an oracle that is "
1336        "handed the true group means, so it measures the ceiling rather than any equation:\n"
1337    )
1338    parts.append(_table(report.oracles) + "\n")
1339    parts.append(
1340        "That is a ceiling, not a score. Whether the equation reaches any of it is a separate "
1341        "question, and the answer is that it reaches some: below, `alignment` is the squared "
1342        "correlation between the equation's own interaction residual and the leading components "
1343        "of the oracle's, over observed cells. `leading_share` is how much of the interaction "
1344        "variance those components carry, and `interaction_share` how much of MCC's variance is "
1345        "interaction at all.\n"
1346    )
1347    parts.append(_table(report.interaction) + "\n")
1348
1349    parts.append("## 3b. Why a subset rather than every term\n")
1350    parts.append(
1351        "The control for the whole selection stage. If handing every candidate term to "
1352        "unpenalised least squares in one go transferred well, the beam search and the length "
1353        "rule would be machinery in search of a problem.\n"
1354    )
1355    parts.append(_table(report.saturated) + "\n")
1356    parts.append(_saturated_note(report))
1357
1358    parts.append("## 4. Equation analysis\n")
1359    parts.append(
1360        f"The equation has {concentration['n_terms']} terms, of which "
1361        f"**{concentration['n_major']}** carry {float(concentration['major_share']):.0%} of the "
1362        f"standardised weight mass; the single largest carries "
1363        f"{float(concentration['top_share']):.1%}, and the weights behave like "
1364        f"**{float(concentration['effective_terms']):.1f} equally-weighted terms** "
1365        "(inverse Simpson index of the shares).\n"
1366    )
1367    flatness = float(concentration["effective_terms"]) / max(int(concentration["n_terms"]), 1)
1368    parts.append(
1369        "That last number is the one to read for concentration, because it does not depend "
1370        "on where a threshold is drawn. At "
1371        f"{flatness:.0%} of the term count the equation is "
1372        + (
1373            "**flat**: no single term dominates. That is a statement about the *unit of "
1374            "explanation*, not about the quality of the equation — MCC here is inferred by "
1375            "a set of terms acting together rather than by one or two that could be quoted "
1376            "on their own. Three readings follow, and the sections below give each one: "
1377            "read the terms in the blocks that move together, read which features the "
1378            "search reached for, and read which operations it needed to apply to them.\n"
1379            if flatness > 0.6
1380            else "**concentrated**: a minority of terms does most of the work, and reading "
1381            "those few is close to reading the whole equation.\n"
1382        )
1383    )
1384    parts.append(
1385        "`beta` is the standardised weight — the MCC contributed per standard deviation of "
1386        "the term, which is what makes terms in unrelated units comparable. `effect` is the "
1387        "swing in predicted MCC across the middle 80% of the term's observed range. "
1388        "`stability` is the fraction of LODO folds that selected the term.\n"
1389    )
1390    parts.append(_table(importance) + "\n")
1391
1392    shown = min(SENTENCE_LIMIT, int(concentration["n_major"]))
1393    parts.append(f"### The {shown} largest terms, in words\n")
1394    if int(concentration["n_major"]) > shown:
1395        parts.append(
1396            f"({int(concentration['n_major'])} terms are flagged major; the leading {shown} are "
1397            "written out, and the table above carries the rest.)\n"
1398        )
1399    for index, sentence in enumerate(term_sentences(importance, limit=SENTENCE_LIMIT), start=1):
1400        parts.append(f"{index}. {sentence}")
1401    parts.append("")
1402
1403    unstable = unstable_majors(importance)
1404    parts.append("### Large terms the folds disagreed on\n")
1405    if unstable.height == 0:
1406        parts.append("None: every major term was selected by at least half of the LODO folds.\n")
1407    else:
1408        parts.append(
1409            f"{unstable.height} of the major terms were selected by fewer than half of the "
1410            "folds. A large weight and a low selection frequency together mean the term is "
1411            "doing its work for *this* training set and would be replaced by something else "
1412            "on another, which the equation as printed does not show. **Do not build "
1413            "guidance on these.**\n"
1414        )
1415        parts.append(_table(unstable) + "\n")
1416
1417    blocks = term_groups(report.e3.equation, columns, importance)
1418    parts.append("### Reading the terms in blocks\n")
1419    parts.append(
1420        "An additive form invites reading one term at a time, and that works when one or "
1421        "two weights dominate. When they do not, the honest unit is larger than a term and "
1422        "smaller than the equation: terms whose per-row contributions move together say the "
1423        "same thing about a row and can be read as one block. Grouping is on the "
1424        "contributions rather than on shared features, because two terms can share no "
1425        "feature and still track each other.\n"
1426    )
1427    parts.append(
1428        f"{blocks.height} blocks over {equation.n_terms} terms"
1429        + (
1430            f", the largest holding {int(blocks['n_terms'][0])} terms and "
1431            f"{float(blocks['share'][0]):.0%} of the weight mass.\n"
1432            if blocks.height
1433            else ".\n"
1434        )
1435    )
1436    parts.append(_table(blocks) + "\n")
1437
1438    parts.append("### Which features the search reached for\n")
1439    usage = feature_usage(report.e3.equation, importance, dataset_features + model_features)
1440    used = usage.filter(pl.col("n_terms") > 0)
1441    parts.append(
1442        f"**{used.height} of {usage.height}** available meta-features appear in the "
1443        "equation. `share` sums the weight mass of every term a feature appears in, so a "
1444        "feature in two terms is credited both and the column does not sum to 1 — it "
1445        "answers how much of the equation touches this feature, not how much it owns. A "
1446        "feature the search declined to use after seeing every transform of it is itself a "
1447        "result.\n"
1448    )
1449    parts.append(_table(usage) + "\n")
1450
1451    parts.append("### Which operations the equation needed\n")
1452    operations = operation_usage(report.e3.equation, importance, config.max_arity if config is not None else None)
1453    parts.append(
1454        "The vocabulary offers five operations and five transforms and the search is free "
1455        "to ignore any of them, so a row that was offered and went unused is a shape this "
1456        "data turned out not to need. Rows marked `offered = no` were kept out of the "
1457        "library by the arity cap and say nothing about the data:\n"
1458    )
1459    parts.append(_table(operations) + "\n")
1460
1461    parts.append("### Where the equation's variance comes from\n")
1462    for sentence in group_sentences(report.shares):
1463        parts.append(f"- {sentence}")
1464    parts.append("")
1465    parts.append(_table(report.shares) + "\n")
1466
1467    parts.append("## 4b. The ceiling on model descriptors\n")
1468    parts.append(
1469        "Under LODO every model appears in every training fold, so the "
1470        "equation's residual can be averaged per model on the training rows and applied to "
1471        "the held-out dataset using fitting histories. These model-identity corrections are "
1472        "conditional diagnostic references, not universal ceilings on better descriptors. "
1473        "They are unavailable for a learner whose entire history is excluded.\n"
1474    )
1475    parts.append(_table(report.identity) + "\n")
1476    parts.append(_identity_note(report))
1477
1478    parts.append("## 5. Best practices\n")
1479    parts.append(
1480        "A best practice is general, transferable advice that already circulates in the "
1481        "field — not a property of this equation. So the practices below are taken from the "
1482        "literature and this study is used to *weigh* them: each verdict, and the numbers "
1483        "inside it, are computed from this run by `ml_meta_perf.guidance`, against a stated "
1484        "threshold, so other data can overturn any of them.\n"
1485    )
1486    if frame is not None:
1487        verdicts = assess(frame, report)
1488        parts.append(render_guidance(verdicts) + "\n")
1489        parts.append("At a glance:\n")
1490        parts.append(_table(as_guidance_table(verdicts).select("practice", "verdict", "magnitude")) + "\n")
1491    else:
1492        parts.append("_(not assessed: the meta-dataset was not supplied to the renderer)_\n")
1493
1494    parts.append("### Each practice against the equation's own terms\n")
1495    parts.append(
1496        "The verdicts above are drawn from corpus averages -- family means, variance shares, "
1497        "paired tests -- which any study with this corpus could compute. This table asks the "
1498        "stronger question, and the one an interpretability-first study is uniquely able to "
1499        "ask: **which of the equation's terms carries this practice, with what strength and "
1500        "which sign?**\n"
1501    )
1502    if frame is not None:
1503        parts.append(_table(equation_evidence(report, columns)) + "\n")
1504        parts.append(
1505            "**One row per (practice, term) pair**, because the equation is a sum of terms and "
1506            "a term is the unit a practice can be held against. `expected` is what the "
1507            "practice predicts as the feature rises; `direction` is what *that term's own "
1508            "contribution* does, measured on the data rather than read off the weight sign -- "
1509            "which would be wrong the moment the feature sits in a denominator, and several "
1510            "here do. `not selected` marks a claim resting on a feature the search never took, "
1511            "so the equation is silent on it rather than supporting it.\n"
1512        )
1513        parts.append(_coverage_note(report, columns))
1514    else:
1515        parts.append("_(not checked: the meta-dataset was not supplied to the renderer)_\n")
1516
1517    parts.append("## 5b. The measurements underneath\n")
1518    parts.append(
1519        "What the fitted equation says about each raw feature it uses, kept only when the "
1520        "feature moves predicted MCC enough to matter, does so monotonically enough for a "
1521        "sentence to be true of it, and does so through terms that survived most folds. "
1522        "Directions are measured on the data rather than read off weight signs, because a "
1523        "feature can appear in several terms and inside denominators.\n"
1524    )
1525    parts.append(
1526        f"**These are associations across {datasets} datasets, not causal claims, and not practices "
1527        "on their own** — a statement about a meta-feature column is a measurement. Section "
1528        "5 is where they become advice, by supporting or failing to support something a "
1529        "practitioner could already have been told.\n"
1530    )
1531    parts.append(render_practices(report.practices) + "\n")
1532    parts.append("Evidence:\n")
1533    parts.append(
1534        _table(
1535            report.practices.select("feature", "meaning", "n_terms", "direction", "effect", "stability", "confidence")
1536            if report.practices.height
1537            else report.practices
1538        )
1539        + "\n"
1540    )
1541
1542    parts.append("### These are conditional statements, not marginal ones\n")
1543    conditional = marginal_versus_conditional(report.practices, columns, truth)
1544    agree = int(conditional["agrees"].sum()) if conditional.height else 0
1545    parts.append(
1546        "A practice states what the *equation* does as a feature rises, with every other "
1547        "term present. A marginal correlation states what the feature does alone. They are "
1548        f"different quantities, and here **{agree} of {conditional.height} agree** on the "
1549        "sign:\n"
1550    )
1551    parts.append(_table(conditional) + "\n")
1552    parts.append(
1553        "Disagreement is what conditioning does, not a defect. A marginal correlation mixes "
1554        "a feature's effect with everything it travels with; inside the equation the terms "
1555        "carrying those companions are already present, so what is left for this feature is "
1556        "what it adds beyond them. The practical consequence: **these statements describe "
1557        "what to expect once the other factors are accounted for, not what a scatter plot of "
1558        "that one feature will show** — and the scatter plot is what a reader will "
1559        "accidentally check against.\n"
1560    )
1561
1562    parts.append("## 5c. Reading a single prediction\n")
1563    parts.append(
1564        "The same equation on one row, term by term. The column adds up, and a reader can "
1565        "check that it does, which is the interpretability payoff in its most direct form "
1566        "-- and the row is "
1567        "chosen arithmetically, at the equation's **median absolute error**, so the example "
1568        "is a typical prediction rather than a flattering one somebody picked.\n"
1569    )
1570    if frame is not None:
1571        parts.append(single_prediction(equation, columns, truth, frame) + "\n")
1572    else:
1573        parts.append("_(not shown: the meta-dataset was not supplied to the renderer)_\n")
1574
1575    parts.append("## 6. Acting on it\n")
1576    parts.append(
1577        "R² is the wrong question for a practitioner, who asks whether a model will work on "
1578        "some data rather than what its MCC will be to three decimals. Thresholding both "
1579        "the prediction and the truth turns the equation into a go/no-go rule, scored here "
1580        "on held-out datasets:\n"
1581    )
1582    parts.append(_table(report.decision) + "\n")
1583    parts.append(
1584        "`majority` is the floor any such rule has to clear. The harder comparison is a "
1585        'predictor that answers "how well does this model usually do", thresholded the same '
1586        "way — at both centres, for the reason the error metrics report both:\n"
1587    )
1588    parts.append(_table(report.decision_baselines) + "\n")
1589
1590    parts.append(_protocol_note(report))
1591
1592    parts.append("Ranking models within a held-out dataset:\n")
1593    regret = float(np.mean(report.selection["regret"].to_numpy()))
1594    parts.append(
1595        f"- mean top-1 regret **{regret:.3f}** MCC — what you give up by taking the model the equation ranks first\n"
1596    )
1597    parts.append(_table(report.ranking_baselines) + "\n")
1598    parts.append(_ranking_verdict(report.ranking_baselines))
1599
1600    parts.append("## 6b. What an opaque model reaches, and does not\n")
1601    parts.append(
1602        "The other side of the trade, priced. Three standard regressors on all eighteen "
1603        "raw corpus columns, under the same protocols, with the same clip to the training "
1604        "fold's range that every reported number uses.\n"
1605    )
1606    parts.append(_table(report.opaque) + "\n")
1607    parts.append(_opaque_note(report))
1608
1609    parts.append("## 7. Why a random split is not a protocol\n")
1610    parts.append(
1611        "The same equation under three splits. A random k-fold puts rows of one dataset on "
1612        "both sides of the fold, so it does not test transfer to an unseen dataset. The "
1613        "difference between that row and the grouped splits measures how much this potential "
1614        "leakage changes the score; here random k-fold and LODO are essentially "
1615        "identical:\n"
1616    )
1617    parts.append(_table(report.leakage) + "\n")
1618
1619    parts.append("## 8. Equation length\n")
1620    parts.append(_length_note(report, rule))
1621    parts.append("The full curve reports all four protocols at every length; the length rule reads their minimum:\n")
1622    parts.append(_table(report.e3.curve) + "\n")
1623
1624    parts.append("## 8b. How much of the transfer is the form's selection\n")
1625    parts.append(_optimism_note(report))
1626    parts.append("## 9. The dataset-only and model-only controls\n")
1627    parts.append(
1628        "E1 sees dataset meta-features only, so it can predict just one value per dataset; "
1629        "E2 sees model meta-features only. Together they show how much of MCC each half of "
1630        "the meta-data explains on its own.\n"
1631    )
1632    parts.append(f"**E1** ({report.e1.equation.n_terms} terms):\n")
1633    parts.append("```\n" + str(report.e1.equation) + "\n```\n")
1634    parts.append(f"**E2** ({report.e2.equation.n_terms} terms):\n")
1635    parts.append("```\n" + str(report.e2.equation) + "\n```\n")
1636    parts.append(_shared_terms_note(report))
1637
1638    if report.e3_capability is report.e3:
1639        parts.append("### E3-Valid and E3-MAX coincide\n")
1640        parts.append(
1641            f"The length rule and the capability rule select the same arity-{report.e3.arity}, "
1642            f"{report.e3.equation.n_terms}-term equation. The full "
1643            "equation above therefore serves both roles; repeating it here would create a "
1644            "duplicate result.\n"
1645        )
1646    else:
1647        parts.append("### The capability variant, in full\n")
1648        parts.append(
1649            "The same feature sets under the most capable searched grammar. This variant is "
1650            "reported so that the published equation's accuracy can be read against what the "
1651            "wider additive form can do, rather than only against oracles and baselines.\n"
1652        )
1653        capability_scores = report.e3_capability.cross_validated
1654        parts.append(
1655            f"It reaches **{float(report.e3_capability.in_sample['r2']):.4f}** under IS against "
1656            f"the published equation's {float(report.e3.in_sample['r2']):.4f}, and "
1657            f"**{float(capability_scores['loo_dataset']['r2']):.4f}** under LODO "
1658            f"against {float(report.e3.cross_validated['loo_dataset']['r2']):.4f}.\n"
1659        )
1660        parts.append(f"**E3-MAX** ({report.e3_capability.equation.n_terms} terms):\n")
1661        parts.append("```\n" + str(report.e3_capability.equation) + "\n```\n")
1662        parts.append("LaTeX:\n")
1663        parts.append("```latex\n" + report.e3_capability.equation.to_latex() + "\n```\n")
1664
1665    return "\n".join(parts)
1666
1667
1668#: Which generated section belongs in which chapter, by the ``## N. Title`` heading `render`
1669#: gives it. The study has six chapters and its results belong *in* them: an earlier layout
1670#: put every generated table in a separate chapter 10 and had the written chapters point at
1671#: it, which kept the numbers from going stale and left the results dispersed across two
1672#: places a reader had to hold at once.
1673CHAPTER_SECTIONS: dict[str, tuple[str, ...]] = {
1674    "index.md": ("1c. The headline",),
1675    "01-dataset.md": ("1b. The corpus",),
1676    # Ownership, one topic to one chapter. The length rule lives with the selection procedure
1677    # that applies it, the ceilings with the equation they bound, the protocols with the
1678    # evaluation. Each generated section has one chapter owner.
1679    "03-term-selection.md": (
1680        "1. The configuration this run used",
1681        "3b. Why a subset rather than every term",
1682        "8. Equation length",
1683        "8b. How much of the transfer is the form's selection",
1684    ),
1685    "04-equation.md": (
1686        "2. The equation",
1687        "3c. How far the form could reach",
1688        "4. Equation analysis",
1689        "4b. The ceiling on model descriptors",
1690        "9. The dataset-only and model-only controls",
1691    ),
1692    "05-evaluation.md": (
1693        "3. How well it does",
1694        "6. Acting on it",
1695        "6b. What an opaque model reaches, and does not",
1696        "7. Why a random split is not a protocol",
1697    ),
1698    "06-practices.md": ("5. Best practices", "5b. The measurements underneath", "5c. Reading a single prediction"),
1699}
1700
1701#: The markers a generated block sits between. Everything between them is replaced on every
1702#: run; everything outside them is written by hand and never touched.
1703BEGIN, END = "<!-- generated: do not edit below -->", "<!-- end generated -->"
1704#: The generated section the README carries between its markers.
1705README_SECTION = "1c. The headline"
1706
1707
1708def sections(text: str) -> dict[str, str]:
1709    """Split a rendered report into its ``## N. Title`` sections, keyed by that title."""
1710    found: dict[str, str] = {}
1711    title, body = None, []
1712    for line in text.split("\n"):
1713        if line.startswith("## "):
1714            if title is not None:
1715                found[title] = "\n".join(body).strip()
1716            title, body = line[3:].strip(), []
1717        elif title is not None:
1718            body.append(line)
1719    if title is not None:
1720        found[title] = "\n".join(body).strip()
1721    return found
1722
1723
1724def _demote(body: str) -> str:
1725    """Push a section's own subheadings down one level, so it nests under a chapter heading."""
1726    return "\n".join(("#" + line) if line.startswith("###") else line for line in body.split("\n"))
1727
1728
1729def splice(page: str, block: str) -> str:
1730    """Replace the generated block in ``page``, or insert one if it has none.
1731
1732    A new block goes *before* the chapter's closing limitations section, so a chapter reads
1733    method, then results, then caveats -- the order a paper is read in. Replacing an existing
1734    block leaves it wherever it already is, so the position survives a hand edit.
1735    """
1736    stamped = f"{BEGIN}\n\n{block.strip()}\n\n{END}"
1737    if BEGIN in page and END in page:
1738        head, rest = page.split(BEGIN, 1)
1739        _, tail = rest.split(END, 1)
1740        return head + stamped + tail
1741    for line in page.split("\n"):
1742        if line.startswith("## Limitations"):
1743            head, tail = page.split(line, 1)
1744            return head.rstrip() + "\n\n" + stamped + "\n\n" + line + tail
1745    return page.rstrip() + "\n\n" + stamped + "\n"
1746
1747
1748def write_into_chapters(
1749    report: Report,
1750    columns: dict[str, np.ndarray],
1751    truth: np.ndarray,
1752    dataset_features: tuple[str, ...],
1753    model_features: tuple[str, ...],
1754    docs: str | Path,
1755    *,
1756    frame: pl.DataFrame | None = None,
1757    config: Configuration | None = None,
1758    selection: SelectionConfig | None = None,
1759    source: str | None = None,
1760    readme: str | Path | None = None,
1761) -> list[Path]:
1762    """Write each generated section into the chapter it belongs to.
1763
1764    The results live in the chapters that discuss them rather than in a separate generated
1765    chapter, and they are still generated rather than narrated: everything between `BEGIN` and
1766    `END` is replaced on every run, and everything outside is hand-written prose that the
1767    pipeline never touches. A chapter therefore cannot carry a stale table, which is the
1768    property the separate report chapter existed to guarantee.
1769    """
1770    text = render(
1771        report,
1772        columns,
1773        truth,
1774        dataset_features,
1775        model_features,
1776        frame=frame,
1777        config=config,
1778        selection=selection,
1779        source=source,
1780    )
1781    available = sections(text)
1782    folder = Path(docs)
1783    written: list[Path] = []
1784    for filename, titles in CHAPTER_SECTIONS.items():
1785        page = folder / filename
1786        if not page.is_file():
1787            continue
1788        blocks: list[str] = []
1789        for title in titles:
1790            body = available.get(title)
1791            if body:
1792                # Drop the "N. " ordinal: the chapter supplies the position, and a generated
1793                # heading numbered against the report would contradict the chapter it sits in.
1794                heading = title.split(". ", 1)[-1]
1795                blocks.append(f"## {heading}\n\n{_demote(body)}")
1796        if blocks:
1797            content = page.read_text(encoding="utf-8")
1798            page.write_text(
1799                splice(content, "\n\n".join(blocks)),
1800                encoding="utf-8",
1801            )
1802            written.append(page)
1803    # The README carries the headline too, so the first numbers a reader sees are generated
1804    # like every other number. It must already hold the markers: a README without them is
1805    # left alone rather than given a block in a place nobody chose.
1806    if readme is not None and Path(readme).is_file():
1807        page = Path(readme)
1808        content = page.read_text(encoding="utf-8")
1809        body = available.get(README_SECTION)
1810        if body and BEGIN in content and END in content:
1811            heading = README_SECTION.split(". ", 1)[-1]
1812            page.write_text(splice(content, f"## {heading}\n\n{_demote(body)}"), encoding="utf-8")
1813            written.append(page)
1814    return written
1815
1816
1817def glossary() -> pl.DataFrame:
1818    """The feature glossary as a table, for appending to a report or a paper.
1819
1820    Restricted to `ALL_FEATURES`, which is the generated corpus schema and exactly the set
1821    of columns the equations may draw on.
1822    """
1823    return pl.DataFrame([{"feature": name, "meaning": FEATURE_GLOSSARY[name]} for name in ALL_FEATURES])
MAJOR_MASS = 0.8
SENTENCE_LIMIT = 8
OPERATIONS = ('atom', 'ratio', 'product', 'sum_ratio', 'ratio_of_sums')
OPERATION_ARITY = {'atom': 1, 'ratio': 2, 'product': 2, 'sum_ratio': 3, 'ratio_of_sums': 4}
def term_importance( equation: ml_meta_perf.model.Equation, columns: dict[str, numpy.ndarray], dataset_features: tuple[str, ...], model_features: tuple[str, ...], stability: polars.dataframe.frame.DataFrame | None = None) -> polars.dataframe.frame.DataFrame:
 85def term_importance(
 86    equation: Equation,
 87    columns: dict[str, np.ndarray],
 88    dataset_features: tuple[str, ...],
 89    model_features: tuple[str, ...],
 90    stability: pl.DataFrame | None = None,
 91) -> pl.DataFrame:
 92    """One row per term, ordered by standardised weight, with the major ones flagged.
 93
 94    ``beta`` is the standardised weight: the MCC the term contributes per standard
 95    deviation of itself. ``effect`` is its 10th-to-90th-percentile contribution swing on
 96    the real data. ``share`` is ``|beta|`` as a fraction of the total, and ``cumulative``
 97    its running sum down the ranking, so ``major`` marks the leading terms that together
 98    reach `MAJOR_MASS`.
 99
100    Ranking on ``beta`` rather than on the raw weight is not cosmetic. Raw weights carry
101    the units of whatever the term computes, so a term over instance counts and a term
102    over class entropy have weights that cannot be compared at all; sorting on them would
103    rank the terms by the size of their units.
104    """
105    if not equation.terms:
106        return pl.DataFrame(
107            schema={
108                "rank": pl.Int64,
109                "term": pl.String,
110                "group": pl.String,
111                "features": pl.String,
112                "weight": pl.Float64,
113                "beta": pl.Float64,
114                "effect": pl.Float64,
115                "share": pl.Float64,
116                "cumulative": pl.Float64,
117                "major": pl.Boolean,
118                "stability": pl.Float64,
119                "direction": pl.String,
120            }
121        )
122
123    matrix = contributions(equation, columns)
124    frequency = (
125        dict(zip(stability["term"].to_list(), stability["frequency"].to_list(), strict=True))
126        if stability is not None and stability.height
127        else {}
128    )
129
130    betas = np.abs(np.asarray(equation.standardized_weights, dtype=np.float64))
131    total = float(betas.sum())
132    order = np.argsort(-betas)
133
134    rows: list[dict[str, object]] = []
135    running = 0.0
136    for position, index in enumerate(order, start=1):
137        beta = float(equation.standardized_weights[index])
138        share = float(betas[index] / total) if total > 0.0 else 0.0
139        running += share
140        low, high = np.percentile(matrix[:, index], [10.0, 90.0])
141        term = equation.terms[index]
142        rows.append(
143            {
144                "rank": position,
145                "term": term.name,
146                "group": classify(term.features, dataset_features, model_features),
147                "features": ", ".join(term.features),
148                "weight": float(equation.weights[index]),
149                "beta": beta,
150                "effect": float(high - low),
151                "share": share,
152                "cumulative": running,
153                # The term that crosses the threshold is kept, so the flagged group always
154                # accounts for at least MAJOR_MASS rather than just short of it.
155                "major": bool(running - share < MAJOR_MASS),
156                "stability": float(frequency.get(term.name, float("nan"))),
157                "direction": direction(beta),
158            }
159        )
160    return pl.DataFrame(rows)

One row per term, ordered by standardised weight, with the major ones flagged.

beta is the standardised weight: the MCC the term contributes per standard deviation of itself. effect is its 10th-to-90th-percentile contribution swing on the real data. share is |beta| as a fraction of the total, and cumulative its running sum down the ranking, so major marks the leading terms that together reach MAJOR_MASS.

Ranking on beta rather than on the raw weight is not cosmetic. Raw weights carry the units of whatever the term computes, so a term over instance counts and a term over class entropy have weights that cannot be compared at all; sorting on them would rank the terms by the size of their units.

def term_sentences( importance: polars.dataframe.frame.DataFrame, limit: int | None = None) -> list[str]:
163def term_sentences(importance: pl.DataFrame, limit: int | None = None) -> list[str]:
164    """A written line per major term, generated from its row of `term_importance`.
165
166    The sentence says three things, in the order a reader needs them: what the term does
167    to MCC, how much of the equation's weight mass it carries, and how often the folds
168    agreed it belonged. Nothing is inferred beyond the numbers in the row -- in
169    particular no claim is made about the *features* inside the term, because a feature's
170    direction depends on the other terms it appears in and is `ml_meta_perf.practices`' job.
171    """
172    if importance.height == 0:
173        return []
174    table = importance.filter(pl.col("major")) if "major" in importance.columns else importance
175    if limit is not None:
176        table = table.head(limit)
177
178    lines: list[str] = []
179    for row in table.iter_rows(named=True):
180        beta = float(row["beta"])
181        stability = float(row["stability"])
182        agreement = (
183            "selected in every fold"
184            if stability >= 0.999
185            else f"selected in {stability:.0%} of folds"
186            if not np.isnan(stability)
187            else "fold agreement not measured"
188        )
189        lines.append(
190            f"`{row['term']}` ({row['group']}) {row['direction']}: one standard deviation of "
191            f"this term is worth {beta:+.3f} MCC, it moves predicted MCC by {float(row['effect']):.3f} "
192            f"across the middle 80% of its observed range, it carries {float(row['share']):.1%} of the "
193            f"equation's weight mass, and it was {agreement}."
194        )
195    return lines

A written line per major term, generated from its row of term_importance.

The sentence says three things, in the order a reader needs them: what the term does to MCC, how much of the equation's weight mass it carries, and how often the folds agreed it belonged. Nothing is inferred beyond the numbers in the row -- in particular no claim is made about the features inside the term, because a feature's direction depends on the other terms it appears in and is ml_meta_perf.practices' job.

def group_sentences(shares: polars.dataframe.frame.DataFrame) -> list[str]:
198def group_sentences(shares: pl.DataFrame) -> list[str]:
199    """A written line per feature group, generated from the share table."""
200    readings = {
201        "dataset": "terms over dataset meta-features alone (how hard is this data)",
202        "model": "terms over model meta-features alone (how capable is this model)",
203        "mixed": "terms mixing dataset and model features (which model suits which data)",
204    }
205    lines: list[str] = []
206    for row in shares.iter_rows(named=True):
207        group = str(row["group"])
208        count = int(row["n_terms"])
209        lines.append(
210            f"{readings.get(group, group)}: {count} term{'' if count == 1 else 's'}, "
211            f"{float(row['share']):.1%} of the equation's output variance."
212        )
213    return lines

A written line per feature group, generated from the share table.

def coverage(importance: polars.dataframe.frame.DataFrame) -> dict[str, float | int]:
216def coverage(importance: pl.DataFrame) -> dict[str, float | int]:
217    """How concentrated the equation is: how few terms carry how much of it.
218
219    ``effective_terms`` is the inverse Simpson index of the weight shares,
220    $1 / \\sum_i s_i^2$. It answers "how many equally-weighted terms would behave like
221    this equation": it equals the term count when every term carries the same weight, and
222    falls toward 1 as one term takes over. It is reported because the headline count and
223    the major count both depend on where a threshold is drawn, and this does not.
224    """
225    if importance.height == 0:
226        return {
227            "n_terms": 0,
228            "n_major": 0,
229            "major_share": 0.0,
230            "top_share": 0.0,
231            "effective_terms": 0.0,
232        }
233    major = importance.filter(pl.col("major"))
234    shares = importance["share"].to_numpy()
235    return {
236        "n_terms": importance.height,
237        "n_major": major.height,
238        "major_share": float(major["share"].sum()),
239        "top_share": float(importance["share"][0]),
240        "effective_terms": float(1.0 / np.sum(shares**2)),
241    }

How concentrated the equation is: how few terms carry how much of it.

effective_terms is the inverse Simpson index of the weight shares, $1 / \sum_i s_i^2$. It answers "how many equally-weighted terms would behave like this equation": it equals the term count when every term carries the same weight, and falls toward 1 as one term takes over. It is reported because the headline count and the major count both depend on where a threshold is drawn, and this does not.

def unstable_majors( importance: polars.dataframe.frame.DataFrame, threshold: float = 0.5) -> polars.dataframe.frame.DataFrame:
244def unstable_majors(importance: pl.DataFrame, threshold: float = 0.5) -> pl.DataFrame:
245    """Major terms the folds did not agree on, which are the ones not to build advice on.
246
247    A large standardised weight and a low selection frequency together mean the term is
248    doing a lot of work for *this* training set and would be replaced by something else
249    on another. That combination is invisible in the equation as printed -- it looks like
250    any other large coefficient -- so it is extracted rather than left to be noticed.
251    """
252    if importance.height == 0 or "stability" not in importance.columns:
253        return importance
254    return importance.filter(
255        pl.col("major") & pl.col("stability").is_not_nan() & (pl.col("stability") < threshold)
256    ).select("rank", "term", "group", "beta", "share", "stability")

Major terms the folds did not agree on, which are the ones not to build advice on.

A large standardised weight and a low selection frequency together mean the term is doing a lot of work for this training set and would be replaced by something else on another. That combination is invisible in the equation as printed -- it looks like any other large coefficient -- so it is extracted rather than left to be noticed.

def feature_usage( equation: ml_meta_perf.model.Equation, importance: polars.dataframe.frame.DataFrame, available: tuple[str, ...]) -> polars.dataframe.frame.DataFrame:
269def feature_usage(
270    equation: Equation,
271    importance: pl.DataFrame,
272    available: tuple[str, ...],
273) -> pl.DataFrame:
274    """Which raw features the equation reached for, how often, and under what transforms.
275
276    A second way to read a flat equation. When no single term dominates, the question
277    "which term matters" has no useful answer, but "which of the features on offer did the
278    search actually use, and how did it have to bend them" still does -- and it is asked of
279    the vocabulary rather than of the weights, so a spread of weights does not blunt it.
280
281    ``share`` sums the standardised-weight mass of every term a feature appears in. A
282    feature in two terms is credited both, so the column does not sum to 1: it answers
283    "how much of the equation touches this feature", not "how much does this feature own".
284    ``unused`` features are reported too, since a feature the search declined to use after
285    seeing every transform of it is a finding about the meta-data.
286    """
287    mass = dict(zip(importance["term"].to_list(), importance["share"].to_list(), strict=True))
288    rows: list[dict[str, object]] = []
289    for feature in available:
290        indices = [index for index, term in enumerate(equation.terms) if feature in term.features]
291        transforms = sorted(
292            {atom.transform for index in indices for atom in _atoms(equation.terms[index]) if atom.feature == feature}
293        )
294        operations = sorted({equation.terms[index].operation for index in indices})
295        rows.append(
296            {
297                "feature": feature,
298                "meaning": FEATURE_GLOSSARY.get(feature, feature),
299                "n_terms": len(indices),
300                "share": float(sum(mass.get(equation.terms[index].name, 0.0) for index in indices)),
301                "transforms": ", ".join(transforms),
302                "operations": ", ".join(operations),
303            }
304        )
305    return pl.DataFrame(rows).sort("share", descending=True)

Which raw features the equation reached for, how often, and under what transforms.

A second way to read a flat equation. When no single term dominates, the question "which term matters" has no useful answer, but "which of the features on offer did the search actually use, and how did it have to bend them" still does -- and it is asked of the vocabulary rather than of the weights, so a spread of weights does not blunt it.

share sums the standardised-weight mass of every term a feature appears in. A feature in two terms is credited both, so the column does not sum to 1: it answers "how much of the equation touches this feature", not "how much does this feature own". unused features are reported too, since a feature the search declined to use after seeing every transform of it is a finding about the meta-data.

def operation_usage( equation: ml_meta_perf.model.Equation, importance: polars.dataframe.frame.DataFrame, max_arity: int | None = None) -> polars.dataframe.frame.DataFrame:
308def operation_usage(
309    equation: Equation,
310    importance: pl.DataFrame,
311    max_arity: int | None = None,
312) -> pl.DataFrame:
313    """Which grammar operations and transforms earned their place in the equation.
314
315    The vocabulary offers five operations and five transforms; the search is free to
316    ignore any of them, and what it declined is as much a result as what it chose --
317    every unused entry is a shape the data turned out not to need.
318
319    That reading only holds for entries the search could actually have used, so
320    ``max_arity`` marks the rest ``offered = no``. Reporting ``ratio_of_sums`` as
321    "declined" when a three-feature cap kept it out of the library would be reading a
322    configuration choice as a finding about the data.
323    """
324    mass = dict(zip(importance["term"].to_list(), importance["share"].to_list(), strict=True))
325    rows: list[dict[str, object]] = []
326    for kind, names in (("operation", OPERATIONS), ("transform", TRANSFORMS)):
327        for name in names:
328            if kind == "operation":
329                indices = [index for index, term in enumerate(equation.terms) if term.operation == name]
330                offered = max_arity is None or OPERATION_ARITY[name] <= max_arity
331            else:
332                indices = [
333                    index
334                    for index, term in enumerate(equation.terms)
335                    if any(atom.transform == name for atom in _atoms(term))
336                ]
337                offered = True
338            rows.append(
339                {
340                    "kind": kind,
341                    "name": name,
342                    "offered": offered,
343                    "n_terms": len(indices),
344                    "share": float(sum(mass.get(equation.terms[index].name, 0.0) for index in indices)),
345                }
346            )
347    return pl.DataFrame(rows)

Which grammar operations and transforms earned their place in the equation.

The vocabulary offers five operations and five transforms; the search is free to ignore any of them, and what it declined is as much a result as what it chose -- every unused entry is a shape the data turned out not to need.

That reading only holds for entries the search could actually have used, so max_arity marks the rest offered = no. Reporting ratio_of_sums as "declined" when a three-feature cap kept it out of the library would be reading a configuration choice as a finding about the data.

def term_groups( equation: ml_meta_perf.model.Equation, columns: dict[str, numpy.ndarray], importance: polars.dataframe.frame.DataFrame, *, threshold: float = 0.5) -> polars.dataframe.frame.DataFrame:
370def term_groups(
371    equation: Equation,
372    columns: dict[str, np.ndarray],
373    importance: pl.DataFrame,
374    *,
375    threshold: float = 0.5,
376) -> pl.DataFrame:
377    """Terms whose contributions move together, read as one block.
378
379    An additive equation invites reading term by term, and that works when one or two
380    weights dominate. When they do not -- and here they do not -- the honest unit of
381    explanation is larger than a term and smaller than the whole equation. Terms are
382    grouped by the correlation of their per-row contributions, so a group is a set of terms
383    that rise and fall together across the meta-dataset and can be described in one
384    sentence.
385
386    Grouping on contributions rather than on shared features is deliberate. Two terms can
387    share no feature and still track each other, and two terms over the same feature can
388    move independently once their transforms differ; what a reader needs to know is whether
389    they say the same thing about a row.
390
391    Correlation is signed and taken on the contribution, so a term entering with a negative
392    weight joins the group it actually agrees with rather than the one it merely shares
393    features with.
394    """
395    if not equation.terms:
396        return pl.DataFrame(
397            schema={
398                "group": pl.Int64,
399                "n_terms": pl.Int64,
400                "share": pl.Float64,
401                "effect": pl.Float64,
402                "direction": pl.String,
403                "shared": pl.String,
404                "terms": pl.String,
405            }
406        )
407
408    matrix = contributions(equation, columns)
409    centered = matrix - matrix.mean(axis=0)
410    scale = np.sqrt((centered**2).sum(axis=0))
411    scale = np.where(scale < 1e-12, 1.0, scale)
412    correlation = (centered / scale).T @ (centered / scale)
413
414    mass = dict(zip(importance["term"].to_list(), importance["share"].to_list(), strict=True))
415    # Single-link agglomeration: a term joins a group when it tracks *any* member, which is
416    # the right rule here because a chain of terms that each track the next is one story
417    # told in several pieces, not several stories.
418    assignment = list(range(len(equation.terms)))
419    for i in range(len(equation.terms)):
420        for j in range(i + 1, len(equation.terms)):
421            if correlation[i, j] >= threshold:
422                merged, absorbed = sorted((assignment[i], assignment[j]))
423                assignment = [merged if label == absorbed else label for label in assignment]
424
425    rows: list[dict[str, object]] = []
426    for label in sorted(set(assignment)):
427        members = [index for index, value in enumerate(assignment) if value == label]
428        total = matrix[:, members].sum(axis=1)
429        low, high = np.percentile(total, [10.0, 90.0])
430        swing = float(high - low)
431        # The group's own sign: whether its combined contribution rises with itself, which
432        # is what a one-sentence description of the block has to state.
433        signed = float(np.sum([equation.standardized_weights[index] for index in members]))
434        rows.append(
435            {
436                "group": len(rows) + 1,
437                "n_terms": len(members),
438                "share": float(sum(mass.get(equation.terms[index].name, 0.0) for index in members)),
439                "effect": swing,
440                "direction": direction(signed),
441                "shared": ", ".join(_shared_features(equation, members)),
442                "terms": " ; ".join(equation.terms[index].name for index in members),
443            }
444        )
445    return pl.DataFrame(rows).sort("share", descending=True).with_columns(pl.int_range(1, pl.len() + 1).alias("group"))

Terms whose contributions move together, read as one block.

An additive equation invites reading term by term, and that works when one or two weights dominate. When they do not -- and here they do not -- the honest unit of explanation is larger than a term and smaller than the whole equation. Terms are grouped by the correlation of their per-row contributions, so a group is a set of terms that rise and fall together across the meta-dataset and can be described in one sentence.

Grouping on contributions rather than on shared features is deliberate. Two terms can share no feature and still track each other, and two terms over the same feature can move independently once their transforms differ; what a reader needs to know is whether they say the same thing about a row.

Correlation is signed and taken on the contribution, so a term entering with a negative weight joins the group it actually agrees with rather than the one it merely shares features with.

def marginal_versus_conditional( practices: polars.dataframe.frame.DataFrame, columns: dict[str, numpy.ndarray], truth: numpy.ndarray) -> polars.dataframe.frame.DataFrame:
448def marginal_versus_conditional(
449    practices: pl.DataFrame,
450    columns: dict[str, np.ndarray],
451    truth: np.ndarray,
452) -> pl.DataFrame:
453    """Each practice's direction against the feature's own correlation with MCC.
454
455    A practice states what the *equation* does as a feature rises, with every other term
456    present. A marginal correlation states what the feature does alone. The two are
457    different quantities and on this data they mostly disagree, which is what conditioning
458    does rather than a defect: a marginal correlation mixes a feature's effect with
459    everything it travels with, while inside the equation the terms carrying those
460    companions are already there.
461
462    It is worth printing because the marginal view is the one a reader will accidentally
463    check against -- plot the feature against MCC and be puzzled -- so the disagreement is
464    better stated than discovered.
465    """
466    if practices.height == 0 or "direction" not in practices.columns:
467        return pl.DataFrame(
468            schema={
469                "feature": pl.String,
470                "meaning": pl.String,
471                "marginal": pl.Float64,
472                "conditional": pl.Float64,
473                "practice_says": pl.String,
474                "agrees": pl.Boolean,
475            }
476        )
477    rows: list[dict[str, object]] = []
478    for row in practices.iter_rows(named=True):
479        feature = str(row["feature"])
480        marginal = spearman(columns[feature], truth)
481        conditional = float(row["direction"])
482        rows.append(
483            {
484                "feature": feature,
485                "meaning": str(row.get("meaning", feature)),
486                "marginal": marginal,
487                "conditional": conditional,
488                "practice_says": "higher" if conditional > 0.0 else "lower",
489                "agrees": bool(np.sign(marginal) == np.sign(conditional)),
490            }
491        )
492    return pl.DataFrame(rows)

Each practice's direction against the feature's own correlation with MCC.

A practice states what the equation does as a feature rises, with every other term present. A marginal correlation states what the feature does alone. The two are different quantities and on this data they mostly disagree, which is what conditioning does rather than a defect: a marginal correlation mixes a feature's effect with everything it travels with, while inside the equation the terms carrying those companions are already there.

It is worth printing because the marginal view is the one a reader will accidentally check against -- plot the feature against MCC and be puzzled -- so the disagreement is better stated than discovered.

def single_prediction( equation: ml_meta_perf.model.Equation, columns: dict[str, numpy.ndarray], truth: numpy.ndarray, frame: polars.dataframe.frame.DataFrame, *, limit: int = 6) -> str:
545def single_prediction(
546    equation: Equation,
547    columns: dict[str, np.ndarray],
548    truth: np.ndarray,
549    frame: pl.DataFrame,
550    *,
551    limit: int = 6,
552) -> str:
553    """One row's prediction broken into its terms, as a printed block.
554
555    The interpretability payoff in its most direct form: a reader can add the column up and
556    get the prediction back, which is a thing no opaque regressor lets anyone do.
557
558    **The row is chosen by a rule, not picked.** The equation's median absolute error is a
559    typical prediction rather than its best case, and choosing it arithmetically is what keeps
560    this example from being a flattering one somebody selected once. The chapter this lands in
561    is the chapter arguing that the analysis is generated rather than authored; it carried a
562    hand-written breakdown until 2026-09-07, and every figure in it had gone stale -- the
563    intercept it printed was 1.4787 against the equation's actual 1.3586.
564
565    Ties are broken by taking the first such row in corpus order, so the choice is a function
566    of the data and the equation alone.
567    """
568    predicted = equation.predict(columns)
569    error = np.abs(truth - predicted)
570    # The median error itself, not the row at the middle of the sorted order: with an even
571    # row count those differ, and `argmin` against the median value picks an actual row.
572    row = int(np.argmin(np.abs(error - float(np.median(error)))))
573
574    shares = contributions(equation, columns)[row]
575    ordered = sorted(zip(shares, equation.terms, strict=True), key=lambda pair: -abs(pair[0]))
576    total = equation.intercept + float(shares.sum())
577
578    lines = [
579        f"dataset  : {frame[DATASET_COLUMN][row]}",
580        f"model    : {frame[MODEL_COLUMN][row]}",
581        f"actual   : {truth[row]:+.4f}",
582        f"predicted: {predicted[row]:+.4f}",
583        "",
584        "contribution breakdown:",
585        f"    {equation.intercept:+.4f}   intercept",
586    ]
587    for value, term in ordered[:limit]:
588        lines.append(f"    {value:+.4f}   {term.name}")
589    if len(ordered) > limit:
590        remainder = sum(value for value, _ in ordered[limit:])
591        lines.append(f"    {remainder:+.4f}   the remaining {len(ordered) - limit} terms")
592    lines.append(f"  = {total:+.4f}   sum")
593    # `Equation.predict` clips to the range MCC can take at all, so on a row where the linear
594    # form runs past the end of that range the column does not add up to the number printed
595    # above it. Saying so is the honest form: a breakdown a reader cannot add up, with no
596    # explanation of why, is worse than no breakdown.
597    if abs(total - predicted[row]) > 5e-5:
598        lines.append(f"    clipped to {predicted[row]:+.4f}, the range MCC can take")
599    return "```\n" + "\n".join(lines) + "\n```"

One row's prediction broken into its terms, as a printed block.

The interpretability payoff in its most direct form: a reader can add the column up and get the prediction back, which is a thing no opaque regressor lets anyone do.

The row is chosen by a rule, not picked. The equation's median absolute error is a typical prediction rather than its best case, and choosing it arithmetically is what keeps this example from being a flattering one somebody selected once. The chapter this lands in is the chapter arguing that the analysis is generated rather than authored; it carried a hand-written breakdown until 2026-09-07, and every figure in it had gone stale -- the intercept it printed was 1.4787 against the equation's actual 1.3586.

Ties are broken by taking the first such row in corpus order, so the choice is a function of the data and the equation alone.

CEILING_ROWS = {'E1': 'E1 reference: true dataset means'}
def render( report: ml_meta_perf.experiment.Report, columns: dict[str, numpy.ndarray], truth: numpy.ndarray, dataset_features: tuple[str, ...], model_features: tuple[str, ...], *, frame: polars.dataframe.frame.DataFrame | None = None, config: ml_meta_perf.config.Configuration | None = None, selection: ml_meta_perf.config.SelectionConfig | None = None, source: str | None = None) -> str:
1185def render(
1186    report: Report,
1187    columns: dict[str, np.ndarray],
1188    truth: np.ndarray,
1189    dataset_features: tuple[str, ...],
1190    model_features: tuple[str, ...],
1191    *,
1192    frame: pl.DataFrame | None = None,
1193    config: Configuration | None = None,
1194    selection: SelectionConfig | None = None,
1195    source: str | None = None,
1196) -> str:
1197    """The full study as a markdown report.
1198
1199    Every table and every sentence is computed from ``report``. The prose that is fixed
1200    -- section headings and the explanations of what each table means -- is fixed because
1201    it describes the *method*, which does not change between runs; anything describing
1202    the *result* is generated.
1203    """
1204    rule = selection or load_config().selection
1205    importance = term_importance(report.e3.equation, columns, dataset_features, model_features, report.e3.stability)
1206    concentration = coverage(importance)
1207    equation = report.e3.equation
1208    # Counted from the corpus rather than written into the prose below it. A generated
1209    # sentence carrying a literal "20 datasets" is the same staleness the generated sections
1210    # exist to prevent, one level further in.
1211    datasets = int(frame[DATASET_COLUMN].n_unique()) if frame is not None else 0
1212
1213    parts: list[str] = []
1214    parts.append("# ml-meta-perf — fitted equation and extracted practices\n")
1215    parts.append(
1216        "Generated by `ml_meta_perf.report.render`. Every number, table and sentence below is "
1217        "computed from the fitted equation and the validation run; nothing is written by "
1218        "hand at report time.\n"
1219    )
1220
1221    parts.append("## 1. The configuration this run used\n")
1222    if source:
1223        parts.append(f"Meta-dataset: `{source}`\n")
1224    if config is not None:
1225        parts.append("Every hyperparameter below is read from `config/study.json`; E1, E2 and E3 share all of them.\n")
1226        parts.append(_configuration(config, selection) + "\n")
1227
1228    parts.append("## 1b. The corpus\n")
1229    parts.append(
1230        "What the meta-dataset is, computed from the file the run was fitted on rather than "
1231        "transcribed into prose beside it.\n"
1232    )
1233    if frame is not None:
1234        parts.append(_table(corpus_summary(frame)) + "\n")
1235        parts.append(
1236            "**The absent cells are not missing at random.** Every dataset short of models is "
1237            "one of the smallest in the corpus, which is what the instance counts show:\n"
1238        )
1239        parts.append(_table(missing_cells(frame), float_format="{:.0f}") + "\n")
1240        parts.append("And how MCC is distributed over those rows:\n")
1241        parts.append(_table(target_summary(frame)) + "\n")
1242        parts.append(
1243            "**The table counts observations exactly at 0 or 1, and those below "
1244            "zero.** That is "
1245            "what makes MAE rather than SMAPE the reported error: SMAPE divides by "
1246            "`|truth| + |prediction|`, so every row at exactly zero contributes the full 200% "
1247            "unless the prediction is exactly zero too, and the metric ends up dominated by "
1248            "the rows the equation is already known to handle worst.\n"
1249        )
1250        parts.append(
1251            "Two things these tables cannot say, both of which bound every number in the study. "
1252            "Each row is the **best of five seeds**, not their mean, so the target is "
1253            "optimistic and subject to seed variation that is not modelled separately; and "
1254            "tuning uses a sample **capped at 100,000 rows**, while later performance evaluation "
1255            "uses available source data with dataset/model-specific limits. `nr_inst` is the "
1256            "source dataset's size rather than necessarily the training set's. These are properties of the "
1257            "corpus builder upstream, and are audited against it in the prose above.\n"
1258        )
1259    else:
1260        parts.append("_(not summarised: the meta-dataset was not supplied to the renderer)_\n")
1261
1262    parts.append("## 1c. The headline\n")
1263    parts.append(
1264        "The one table the study is summarised by, so that the summary cannot drift from "
1265        f"the chapters. Every row is scored on the same {int(report.e3.in_sample['n'])} rows "
1266        "under the same protocols. E1, E2, and E3-Valid share the retained base configuration; "
1267        "E3-MAX takes the raw floor maximum at the configured capability arity; the current "
1268        "primary and capability arities both equal three.\n"
1269    )
1270    parts.append(_table(_headline(report)) + "\n")
1271    parts.append(
1272        "**Do not read these R² values as achievements against each other.** They share a "
1273        "scale but not a ceiling: E1 sees only dataset features, every row of a dataset "
1274        "shares one feature vector, and so E1 can predict nothing but a per-dataset "
1275        "constant. The `true dataset means` row gives E1's structural maximum. "
1276        "The last column gives only E1's fraction of that "
1277        "bound. E2's model-mean reference is descriptive because Processing Units Number "
1278        "can vary with dataset shape; no ceiling fraction is reported for E2.\n"
1279    )
1280    parts.append(
1281        "**E2, E3-Valid, and E3-MAX have blank ceiling cells.** Their recorded predictors are "
1282        "not constant within the corresponding identity groups. The comparison table in "
1283        "chapter 5 also shows the additive mean-based reference. On the incomplete grid that "
1284        "reference is not a jointly fitted least-squares additive bound; mixed terms can "
1285        "represent interactions beyond marginal group effects.\n"
1286    )
1287
1288    parts.append("## 2. The equation\n")
1289    parts.append(
1290        f"E3-Valid uses **{equation.n_terms} terms** over dataset and model meta-features, "
1291        "simplified and refitted after pruning, so it evaluates exactly as printed.\n"
1292    )
1293    parts.append("```\n" + str(equation) + "\n```\n")
1294    parts.append("LaTeX:\n")
1295    parts.append("```latex\n" + equation.to_latex() + "\n```\n")
1296
1297    parts.append("## 3. How well it does\n")
1298    parts.append("| protocol | R² | MAE | RMSE | n |")
1299    parts.append("|---|---|---|---|---|")
1300    parts.append(_scores("IS", report.e3.in_sample))
1301    protocol_labels = {"loo_dataset": "LODO", "loo_model": "LOMO", "loo_cell": "DHO"}
1302    for label, scores in report.e3.cross_validated.items():
1303        parts.append(_scores(protocol_labels.get(label, label.replace("_", "-")), scores))
1304    parts.append("")
1305    parts.append(
1306        "Cross-validated rows hold out a whole dataset or a whole model, so the equation "
1307        "is scored on a group it has never seen. That is the number that matters, and it "
1308        "is well below the IS value at this sample size.\n"
1309    )
1310    parts.append("Against the baselines and descriptive group-mean references:\n")
1311    parts.append(_table(report.comparison) + "\n")
1312
1313    parts.append("### The trivial predictors, at both centres\n")
1314    parts.append(_table(report.baselines) + "\n")
1315    parts.append(_baseline_centre_note(report.baselines))
1316
1317    parts.append("## 3c. How far the form could reach\n")
1318    parts.append(
1319        "The following library fits and length comparison describe representational reach "
1320        "under the recorded corpus and settings. They are not independent performance "
1321        "estimates or universal ceilings.\n"
1322    )
1323    parts.append("### How far the additive form reaches\n")
1324    parts.append(_capability_note(report))
1325
1326    parts.append("### What the vocabulary could reach, before any search\n")
1327    parts.append(_reach_note(report))
1328
1329    parts.append("### Where the variance is, before any equation\n")
1330    parts.append("Variance of MCC explained by group identity alone, with no equation involved:\n")
1331    parts.append(_table(report.decomposition) + "\n")
1332
1333    parts.append("### How fast interaction pays\n")
1334    parts.append(
1335        "The additive form cannot represent dataset-by-model interaction beyond what its mixed "
1336        "terms reach. The ladder below adds interaction components to an oracle that is "
1337        "handed the true group means, so it measures the ceiling rather than any equation:\n"
1338    )
1339    parts.append(_table(report.oracles) + "\n")
1340    parts.append(
1341        "That is a ceiling, not a score. Whether the equation reaches any of it is a separate "
1342        "question, and the answer is that it reaches some: below, `alignment` is the squared "
1343        "correlation between the equation's own interaction residual and the leading components "
1344        "of the oracle's, over observed cells. `leading_share` is how much of the interaction "
1345        "variance those components carry, and `interaction_share` how much of MCC's variance is "
1346        "interaction at all.\n"
1347    )
1348    parts.append(_table(report.interaction) + "\n")
1349
1350    parts.append("## 3b. Why a subset rather than every term\n")
1351    parts.append(
1352        "The control for the whole selection stage. If handing every candidate term to "
1353        "unpenalised least squares in one go transferred well, the beam search and the length "
1354        "rule would be machinery in search of a problem.\n"
1355    )
1356    parts.append(_table(report.saturated) + "\n")
1357    parts.append(_saturated_note(report))
1358
1359    parts.append("## 4. Equation analysis\n")
1360    parts.append(
1361        f"The equation has {concentration['n_terms']} terms, of which "
1362        f"**{concentration['n_major']}** carry {float(concentration['major_share']):.0%} of the "
1363        f"standardised weight mass; the single largest carries "
1364        f"{float(concentration['top_share']):.1%}, and the weights behave like "
1365        f"**{float(concentration['effective_terms']):.1f} equally-weighted terms** "
1366        "(inverse Simpson index of the shares).\n"
1367    )
1368    flatness = float(concentration["effective_terms"]) / max(int(concentration["n_terms"]), 1)
1369    parts.append(
1370        "That last number is the one to read for concentration, because it does not depend "
1371        "on where a threshold is drawn. At "
1372        f"{flatness:.0%} of the term count the equation is "
1373        + (
1374            "**flat**: no single term dominates. That is a statement about the *unit of "
1375            "explanation*, not about the quality of the equation — MCC here is inferred by "
1376            "a set of terms acting together rather than by one or two that could be quoted "
1377            "on their own. Three readings follow, and the sections below give each one: "
1378            "read the terms in the blocks that move together, read which features the "
1379            "search reached for, and read which operations it needed to apply to them.\n"
1380            if flatness > 0.6
1381            else "**concentrated**: a minority of terms does most of the work, and reading "
1382            "those few is close to reading the whole equation.\n"
1383        )
1384    )
1385    parts.append(
1386        "`beta` is the standardised weight — the MCC contributed per standard deviation of "
1387        "the term, which is what makes terms in unrelated units comparable. `effect` is the "
1388        "swing in predicted MCC across the middle 80% of the term's observed range. "
1389        "`stability` is the fraction of LODO folds that selected the term.\n"
1390    )
1391    parts.append(_table(importance) + "\n")
1392
1393    shown = min(SENTENCE_LIMIT, int(concentration["n_major"]))
1394    parts.append(f"### The {shown} largest terms, in words\n")
1395    if int(concentration["n_major"]) > shown:
1396        parts.append(
1397            f"({int(concentration['n_major'])} terms are flagged major; the leading {shown} are "
1398            "written out, and the table above carries the rest.)\n"
1399        )
1400    for index, sentence in enumerate(term_sentences(importance, limit=SENTENCE_LIMIT), start=1):
1401        parts.append(f"{index}. {sentence}")
1402    parts.append("")
1403
1404    unstable = unstable_majors(importance)
1405    parts.append("### Large terms the folds disagreed on\n")
1406    if unstable.height == 0:
1407        parts.append("None: every major term was selected by at least half of the LODO folds.\n")
1408    else:
1409        parts.append(
1410            f"{unstable.height} of the major terms were selected by fewer than half of the "
1411            "folds. A large weight and a low selection frequency together mean the term is "
1412            "doing its work for *this* training set and would be replaced by something else "
1413            "on another, which the equation as printed does not show. **Do not build "
1414            "guidance on these.**\n"
1415        )
1416        parts.append(_table(unstable) + "\n")
1417
1418    blocks = term_groups(report.e3.equation, columns, importance)
1419    parts.append("### Reading the terms in blocks\n")
1420    parts.append(
1421        "An additive form invites reading one term at a time, and that works when one or "
1422        "two weights dominate. When they do not, the honest unit is larger than a term and "
1423        "smaller than the equation: terms whose per-row contributions move together say the "
1424        "same thing about a row and can be read as one block. Grouping is on the "
1425        "contributions rather than on shared features, because two terms can share no "
1426        "feature and still track each other.\n"
1427    )
1428    parts.append(
1429        f"{blocks.height} blocks over {equation.n_terms} terms"
1430        + (
1431            f", the largest holding {int(blocks['n_terms'][0])} terms and "
1432            f"{float(blocks['share'][0]):.0%} of the weight mass.\n"
1433            if blocks.height
1434            else ".\n"
1435        )
1436    )
1437    parts.append(_table(blocks) + "\n")
1438
1439    parts.append("### Which features the search reached for\n")
1440    usage = feature_usage(report.e3.equation, importance, dataset_features + model_features)
1441    used = usage.filter(pl.col("n_terms") > 0)
1442    parts.append(
1443        f"**{used.height} of {usage.height}** available meta-features appear in the "
1444        "equation. `share` sums the weight mass of every term a feature appears in, so a "
1445        "feature in two terms is credited both and the column does not sum to 1 — it "
1446        "answers how much of the equation touches this feature, not how much it owns. A "
1447        "feature the search declined to use after seeing every transform of it is itself a "
1448        "result.\n"
1449    )
1450    parts.append(_table(usage) + "\n")
1451
1452    parts.append("### Which operations the equation needed\n")
1453    operations = operation_usage(report.e3.equation, importance, config.max_arity if config is not None else None)
1454    parts.append(
1455        "The vocabulary offers five operations and five transforms and the search is free "
1456        "to ignore any of them, so a row that was offered and went unused is a shape this "
1457        "data turned out not to need. Rows marked `offered = no` were kept out of the "
1458        "library by the arity cap and say nothing about the data:\n"
1459    )
1460    parts.append(_table(operations) + "\n")
1461
1462    parts.append("### Where the equation's variance comes from\n")
1463    for sentence in group_sentences(report.shares):
1464        parts.append(f"- {sentence}")
1465    parts.append("")
1466    parts.append(_table(report.shares) + "\n")
1467
1468    parts.append("## 4b. The ceiling on model descriptors\n")
1469    parts.append(
1470        "Under LODO every model appears in every training fold, so the "
1471        "equation's residual can be averaged per model on the training rows and applied to "
1472        "the held-out dataset using fitting histories. These model-identity corrections are "
1473        "conditional diagnostic references, not universal ceilings on better descriptors. "
1474        "They are unavailable for a learner whose entire history is excluded.\n"
1475    )
1476    parts.append(_table(report.identity) + "\n")
1477    parts.append(_identity_note(report))
1478
1479    parts.append("## 5. Best practices\n")
1480    parts.append(
1481        "A best practice is general, transferable advice that already circulates in the "
1482        "field — not a property of this equation. So the practices below are taken from the "
1483        "literature and this study is used to *weigh* them: each verdict, and the numbers "
1484        "inside it, are computed from this run by `ml_meta_perf.guidance`, against a stated "
1485        "threshold, so other data can overturn any of them.\n"
1486    )
1487    if frame is not None:
1488        verdicts = assess(frame, report)
1489        parts.append(render_guidance(verdicts) + "\n")
1490        parts.append("At a glance:\n")
1491        parts.append(_table(as_guidance_table(verdicts).select("practice", "verdict", "magnitude")) + "\n")
1492    else:
1493        parts.append("_(not assessed: the meta-dataset was not supplied to the renderer)_\n")
1494
1495    parts.append("### Each practice against the equation's own terms\n")
1496    parts.append(
1497        "The verdicts above are drawn from corpus averages -- family means, variance shares, "
1498        "paired tests -- which any study with this corpus could compute. This table asks the "
1499        "stronger question, and the one an interpretability-first study is uniquely able to "
1500        "ask: **which of the equation's terms carries this practice, with what strength and "
1501        "which sign?**\n"
1502    )
1503    if frame is not None:
1504        parts.append(_table(equation_evidence(report, columns)) + "\n")
1505        parts.append(
1506            "**One row per (practice, term) pair**, because the equation is a sum of terms and "
1507            "a term is the unit a practice can be held against. `expected` is what the "
1508            "practice predicts as the feature rises; `direction` is what *that term's own "
1509            "contribution* does, measured on the data rather than read off the weight sign -- "
1510            "which would be wrong the moment the feature sits in a denominator, and several "
1511            "here do. `not selected` marks a claim resting on a feature the search never took, "
1512            "so the equation is silent on it rather than supporting it.\n"
1513        )
1514        parts.append(_coverage_note(report, columns))
1515    else:
1516        parts.append("_(not checked: the meta-dataset was not supplied to the renderer)_\n")
1517
1518    parts.append("## 5b. The measurements underneath\n")
1519    parts.append(
1520        "What the fitted equation says about each raw feature it uses, kept only when the "
1521        "feature moves predicted MCC enough to matter, does so monotonically enough for a "
1522        "sentence to be true of it, and does so through terms that survived most folds. "
1523        "Directions are measured on the data rather than read off weight signs, because a "
1524        "feature can appear in several terms and inside denominators.\n"
1525    )
1526    parts.append(
1527        f"**These are associations across {datasets} datasets, not causal claims, and not practices "
1528        "on their own** — a statement about a meta-feature column is a measurement. Section "
1529        "5 is where they become advice, by supporting or failing to support something a "
1530        "practitioner could already have been told.\n"
1531    )
1532    parts.append(render_practices(report.practices) + "\n")
1533    parts.append("Evidence:\n")
1534    parts.append(
1535        _table(
1536            report.practices.select("feature", "meaning", "n_terms", "direction", "effect", "stability", "confidence")
1537            if report.practices.height
1538            else report.practices
1539        )
1540        + "\n"
1541    )
1542
1543    parts.append("### These are conditional statements, not marginal ones\n")
1544    conditional = marginal_versus_conditional(report.practices, columns, truth)
1545    agree = int(conditional["agrees"].sum()) if conditional.height else 0
1546    parts.append(
1547        "A practice states what the *equation* does as a feature rises, with every other "
1548        "term present. A marginal correlation states what the feature does alone. They are "
1549        f"different quantities, and here **{agree} of {conditional.height} agree** on the "
1550        "sign:\n"
1551    )
1552    parts.append(_table(conditional) + "\n")
1553    parts.append(
1554        "Disagreement is what conditioning does, not a defect. A marginal correlation mixes "
1555        "a feature's effect with everything it travels with; inside the equation the terms "
1556        "carrying those companions are already present, so what is left for this feature is "
1557        "what it adds beyond them. The practical consequence: **these statements describe "
1558        "what to expect once the other factors are accounted for, not what a scatter plot of "
1559        "that one feature will show** — and the scatter plot is what a reader will "
1560        "accidentally check against.\n"
1561    )
1562
1563    parts.append("## 5c. Reading a single prediction\n")
1564    parts.append(
1565        "The same equation on one row, term by term. The column adds up, and a reader can "
1566        "check that it does, which is the interpretability payoff in its most direct form "
1567        "-- and the row is "
1568        "chosen arithmetically, at the equation's **median absolute error**, so the example "
1569        "is a typical prediction rather than a flattering one somebody picked.\n"
1570    )
1571    if frame is not None:
1572        parts.append(single_prediction(equation, columns, truth, frame) + "\n")
1573    else:
1574        parts.append("_(not shown: the meta-dataset was not supplied to the renderer)_\n")
1575
1576    parts.append("## 6. Acting on it\n")
1577    parts.append(
1578        "R² is the wrong question for a practitioner, who asks whether a model will work on "
1579        "some data rather than what its MCC will be to three decimals. Thresholding both "
1580        "the prediction and the truth turns the equation into a go/no-go rule, scored here "
1581        "on held-out datasets:\n"
1582    )
1583    parts.append(_table(report.decision) + "\n")
1584    parts.append(
1585        "`majority` is the floor any such rule has to clear. The harder comparison is a "
1586        'predictor that answers "how well does this model usually do", thresholded the same '
1587        "way — at both centres, for the reason the error metrics report both:\n"
1588    )
1589    parts.append(_table(report.decision_baselines) + "\n")
1590
1591    parts.append(_protocol_note(report))
1592
1593    parts.append("Ranking models within a held-out dataset:\n")
1594    regret = float(np.mean(report.selection["regret"].to_numpy()))
1595    parts.append(
1596        f"- mean top-1 regret **{regret:.3f}** MCC — what you give up by taking the model the equation ranks first\n"
1597    )
1598    parts.append(_table(report.ranking_baselines) + "\n")
1599    parts.append(_ranking_verdict(report.ranking_baselines))
1600
1601    parts.append("## 6b. What an opaque model reaches, and does not\n")
1602    parts.append(
1603        "The other side of the trade, priced. Three standard regressors on all eighteen "
1604        "raw corpus columns, under the same protocols, with the same clip to the training "
1605        "fold's range that every reported number uses.\n"
1606    )
1607    parts.append(_table(report.opaque) + "\n")
1608    parts.append(_opaque_note(report))
1609
1610    parts.append("## 7. Why a random split is not a protocol\n")
1611    parts.append(
1612        "The same equation under three splits. A random k-fold puts rows of one dataset on "
1613        "both sides of the fold, so it does not test transfer to an unseen dataset. The "
1614        "difference between that row and the grouped splits measures how much this potential "
1615        "leakage changes the score; here random k-fold and LODO are essentially "
1616        "identical:\n"
1617    )
1618    parts.append(_table(report.leakage) + "\n")
1619
1620    parts.append("## 8. Equation length\n")
1621    parts.append(_length_note(report, rule))
1622    parts.append("The full curve reports all four protocols at every length; the length rule reads their minimum:\n")
1623    parts.append(_table(report.e3.curve) + "\n")
1624
1625    parts.append("## 8b. How much of the transfer is the form's selection\n")
1626    parts.append(_optimism_note(report))
1627    parts.append("## 9. The dataset-only and model-only controls\n")
1628    parts.append(
1629        "E1 sees dataset meta-features only, so it can predict just one value per dataset; "
1630        "E2 sees model meta-features only. Together they show how much of MCC each half of "
1631        "the meta-data explains on its own.\n"
1632    )
1633    parts.append(f"**E1** ({report.e1.equation.n_terms} terms):\n")
1634    parts.append("```\n" + str(report.e1.equation) + "\n```\n")
1635    parts.append(f"**E2** ({report.e2.equation.n_terms} terms):\n")
1636    parts.append("```\n" + str(report.e2.equation) + "\n```\n")
1637    parts.append(_shared_terms_note(report))
1638
1639    if report.e3_capability is report.e3:
1640        parts.append("### E3-Valid and E3-MAX coincide\n")
1641        parts.append(
1642            f"The length rule and the capability rule select the same arity-{report.e3.arity}, "
1643            f"{report.e3.equation.n_terms}-term equation. The full "
1644            "equation above therefore serves both roles; repeating it here would create a "
1645            "duplicate result.\n"
1646        )
1647    else:
1648        parts.append("### The capability variant, in full\n")
1649        parts.append(
1650            "The same feature sets under the most capable searched grammar. This variant is "
1651            "reported so that the published equation's accuracy can be read against what the "
1652            "wider additive form can do, rather than only against oracles and baselines.\n"
1653        )
1654        capability_scores = report.e3_capability.cross_validated
1655        parts.append(
1656            f"It reaches **{float(report.e3_capability.in_sample['r2']):.4f}** under IS against "
1657            f"the published equation's {float(report.e3.in_sample['r2']):.4f}, and "
1658            f"**{float(capability_scores['loo_dataset']['r2']):.4f}** under LODO "
1659            f"against {float(report.e3.cross_validated['loo_dataset']['r2']):.4f}.\n"
1660        )
1661        parts.append(f"**E3-MAX** ({report.e3_capability.equation.n_terms} terms):\n")
1662        parts.append("```\n" + str(report.e3_capability.equation) + "\n```\n")
1663        parts.append("LaTeX:\n")
1664        parts.append("```latex\n" + report.e3_capability.equation.to_latex() + "\n```\n")
1665
1666    return "\n".join(parts)

The full study as a markdown report.

Every table and every sentence is computed from report. The prose that is fixed -- section headings and the explanations of what each table means -- is fixed because it describes the method, which does not change between runs; anything describing the result is generated.

CHAPTER_SECTIONS: dict[str, tuple[str, ...]] = {'index.md': ('1c. The headline',), '01-dataset.md': ('1b. The corpus',), '03-term-selection.md': ('1. The configuration this run used', '3b. Why a subset rather than every term', '8. Equation length', "8b. How much of the transfer is the form's selection"), '04-equation.md': ('2. The equation', '3c. How far the form could reach', '4. Equation analysis', '4b. The ceiling on model descriptors', '9. The dataset-only and model-only controls'), '05-evaluation.md': ('3. How well it does', '6. Acting on it', '6b. What an opaque model reaches, and does not', '7. Why a random split is not a protocol'), '06-practices.md': ('5. Best practices', '5b. The measurements underneath', '5c. Reading a single prediction')}
README_SECTION = '1c. The headline'
def sections(text: str) -> dict[str, str]:
1709def sections(text: str) -> dict[str, str]:
1710    """Split a rendered report into its ``## N. Title`` sections, keyed by that title."""
1711    found: dict[str, str] = {}
1712    title, body = None, []
1713    for line in text.split("\n"):
1714        if line.startswith("## "):
1715            if title is not None:
1716                found[title] = "\n".join(body).strip()
1717            title, body = line[3:].strip(), []
1718        elif title is not None:
1719            body.append(line)
1720    if title is not None:
1721        found[title] = "\n".join(body).strip()
1722    return found

Split a rendered report into its ## N. Title sections, keyed by that title.

def splice(page: str, block: str) -> str:
1730def splice(page: str, block: str) -> str:
1731    """Replace the generated block in ``page``, or insert one if it has none.
1732
1733    A new block goes *before* the chapter's closing limitations section, so a chapter reads
1734    method, then results, then caveats -- the order a paper is read in. Replacing an existing
1735    block leaves it wherever it already is, so the position survives a hand edit.
1736    """
1737    stamped = f"{BEGIN}\n\n{block.strip()}\n\n{END}"
1738    if BEGIN in page and END in page:
1739        head, rest = page.split(BEGIN, 1)
1740        _, tail = rest.split(END, 1)
1741        return head + stamped + tail
1742    for line in page.split("\n"):
1743        if line.startswith("## Limitations"):
1744            head, tail = page.split(line, 1)
1745            return head.rstrip() + "\n\n" + stamped + "\n\n" + line + tail
1746    return page.rstrip() + "\n\n" + stamped + "\n"

Replace the generated block in page, or insert one if it has none.

A new block goes before the chapter's closing limitations section, so a chapter reads method, then results, then caveats -- the order a paper is read in. Replacing an existing block leaves it wherever it already is, so the position survives a hand edit.

def write_into_chapters( report: ml_meta_perf.experiment.Report, columns: dict[str, numpy.ndarray], truth: numpy.ndarray, dataset_features: tuple[str, ...], model_features: tuple[str, ...], docs: str | pathlib.Path, *, frame: polars.dataframe.frame.DataFrame | None = None, config: ml_meta_perf.config.Configuration | None = None, selection: ml_meta_perf.config.SelectionConfig | None = None, source: str | None = None, readme: str | pathlib.Path | None = None) -> list[pathlib.Path]:
1749def write_into_chapters(
1750    report: Report,
1751    columns: dict[str, np.ndarray],
1752    truth: np.ndarray,
1753    dataset_features: tuple[str, ...],
1754    model_features: tuple[str, ...],
1755    docs: str | Path,
1756    *,
1757    frame: pl.DataFrame | None = None,
1758    config: Configuration | None = None,
1759    selection: SelectionConfig | None = None,
1760    source: str | None = None,
1761    readme: str | Path | None = None,
1762) -> list[Path]:
1763    """Write each generated section into the chapter it belongs to.
1764
1765    The results live in the chapters that discuss them rather than in a separate generated
1766    chapter, and they are still generated rather than narrated: everything between `BEGIN` and
1767    `END` is replaced on every run, and everything outside is hand-written prose that the
1768    pipeline never touches. A chapter therefore cannot carry a stale table, which is the
1769    property the separate report chapter existed to guarantee.
1770    """
1771    text = render(
1772        report,
1773        columns,
1774        truth,
1775        dataset_features,
1776        model_features,
1777        frame=frame,
1778        config=config,
1779        selection=selection,
1780        source=source,
1781    )
1782    available = sections(text)
1783    folder = Path(docs)
1784    written: list[Path] = []
1785    for filename, titles in CHAPTER_SECTIONS.items():
1786        page = folder / filename
1787        if not page.is_file():
1788            continue
1789        blocks: list[str] = []
1790        for title in titles:
1791            body = available.get(title)
1792            if body:
1793                # Drop the "N. " ordinal: the chapter supplies the position, and a generated
1794                # heading numbered against the report would contradict the chapter it sits in.
1795                heading = title.split(". ", 1)[-1]
1796                blocks.append(f"## {heading}\n\n{_demote(body)}")
1797        if blocks:
1798            content = page.read_text(encoding="utf-8")
1799            page.write_text(
1800                splice(content, "\n\n".join(blocks)),
1801                encoding="utf-8",
1802            )
1803            written.append(page)
1804    # The README carries the headline too, so the first numbers a reader sees are generated
1805    # like every other number. It must already hold the markers: a README without them is
1806    # left alone rather than given a block in a place nobody chose.
1807    if readme is not None and Path(readme).is_file():
1808        page = Path(readme)
1809        content = page.read_text(encoding="utf-8")
1810        body = available.get(README_SECTION)
1811        if body and BEGIN in content and END in content:
1812            heading = README_SECTION.split(". ", 1)[-1]
1813            page.write_text(splice(content, f"## {heading}\n\n{_demote(body)}"), encoding="utf-8")
1814            written.append(page)
1815    return written

Write each generated section into the chapter it belongs to.

The results live in the chapters that discuss them rather than in a separate generated chapter, and they are still generated rather than narrated: everything between BEGIN and END is replaced on every run, and everything outside is hand-written prose that the pipeline never touches. A chapter therefore cannot carry a stale table, which is the property the separate report chapter existed to guarantee.

def glossary() -> polars.dataframe.frame.DataFrame:
1818def glossary() -> pl.DataFrame:
1819    """The feature glossary as a table, for appending to a report or a paper.
1820
1821    Restricted to `ALL_FEATURES`, which is the generated corpus schema and exactly the set
1822    of columns the equations may draw on.
1823    """
1824    return pl.DataFrame([{"feature": name, "meaning": FEATURE_GLOSSARY[name]} for name in ALL_FEATURES])

The feature glossary as a table, for appending to a report or a paper.

Restricted to ALL_FEATURES, which is the generated corpus schema and exactly the set of columns the equations may draw on.