"""Generate LaTeX macros and tables directly from frozen revision artifacts.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path from typing import Any import numpy as np import pandas as pd STRATEGIES = ("canonical_grouped", "scaffold_aware") SEEDS = (123456, 123457, 123458) STRATEGY_LABELS = { "canonical_grouped": "Identity-grouped", "scaffold_aware": "Scaffold-aware", } STRATEGY_MACROS = { "canonical_grouped": "Canonical", "scaffold_aware": "Scaffold", } MODEL_LABELS = { "gat": "GAT", "gcn": "GCN", "fpnn": "FPNN", "arithmetic_mean": "Arithmetic mean", "stack_all_base_only": "Three-base stack", "stack_all_plus_descriptors": "Three-base stack + descriptors", "stack_gat_gcn": "GAT + GCN stack", "stack_gat_fpnn": "GAT + FPNN stack", "stack_gcn_fpnn": "GCN + FPNN stack", "lab_median": "Laboratory median", "descriptor_only_ridge": "Descriptors + laboratory ridge", "descriptor_only_et": "Descriptors + laboratory ExtraTrees", "fingerprint_no_lab_et": "Morgan ExtraTrees, no laboratory", "fingerprint_no_lab_plus_lab_affine": "Morgan ExtraTrees + laboratory affine", "fingerprint_only_et": "Morgan + one-hot laboratory ExtraTrees", "fingerprint_plus_descriptors_et": "Morgan + descriptors + one-hot laboratory ExtraTrees", } def _load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def _escape_latex(value: object) -> str: text = str(value) replacements = { "\\": r"\textbackslash{}", "&": r"\&", "%": r"\%", "$": r"\$", "#": r"\#", "_": r"\_", "{": r"\{", "}": r"\}", } return "".join(replacements.get(character, character) for character in text) def _number(value: object, digits: int = 3, dash_for_missing: bool = True) -> str: try: numeric = float(value) except (TypeError, ValueError): return "--" if dash_for_missing else str(value) if not np.isfinite(numeric): return "--" return f"{numeric:.{digits}f}" def _mean_sd(row: pd.Series, metric: str) -> str: return f"{_number(row[f'{metric}_mean'])} $\\pm$ {_number(row[f'{metric}_sd'])}" def _write_metric_macros( neural: pd.DataFrame, inference_benchmark: dict[str, Any], output_path: Path, ) -> None: model_macros = { "gat": "GAT", "gcn": "GCN", "fpnn": "FPNN", "arithmetic_mean": "ArithmeticMean", "stack_all_base_only": "BaseOnlyStack", "stack_all_plus_descriptors": "Stack", } metric_macros = {"r2": "Rtwo", "mae": "MAE", "rmse": "RMSE"} lines = ["% Generated from summary/neural_metrics_aggregate.csv."] for strategy in STRATEGIES: for model, model_macro in model_macros.items(): row = neural.loc[ (neural["strategy"] == strategy) & (neural["model"] == model) ] if len(row) != 1: raise ValueError(f"Expected one aggregate row for {strategy}/{model}.") record = row.iloc[0] for metric, metric_macro in metric_macros.items(): prefix = f"{STRATEGY_MACROS[strategy]}{model_macro}{metric_macro}" lines.append( f"\\newcommand{{\\{prefix}Mean}}{{{_number(record[f'{metric}_mean'])}}}" ) lines.append( f"\\newcommand{{\\{prefix}SD}}{{{_number(record[f'{metric}_sd'])}}}" ) lines.extend( [ f"\\newcommand{{\\InferenceBenchmarkN}}{{{int(inference_benchmark['n_records'])}}}", f"\\newcommand{{\\InferenceBenchmarkFolds}}{{{int(inference_benchmark['n_fold_models_per_base'])}}}", f"\\newcommand{{\\FeatureGenerationSeconds}}{{{_number(inference_benchmark['feature_generation']['seconds'])}}}", f"\\newcommand{{\\FeaturePerRecordMilliseconds}}{{{_number(inference_benchmark['feature_generation']['milliseconds_per_record'])}}}", f"\\newcommand{{\\InferenceBundleSeconds}}{{{_number(inference_benchmark['full_bundle']['seconds_mean'])}}}", f"\\newcommand{{\\InferenceBundleSecondsSD}}{{{_number(inference_benchmark['full_bundle']['seconds_sd'])}}}", f"\\newcommand{{\\InferencePerRecordMilliseconds}}{{{_number(inference_benchmark['full_bundle']['milliseconds_per_record_mean'])}}}", ] ) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_dataset_table(audit: dict[str, Any], output_path: Path) -> None: rows = ( ("Structure--laboratory RT observations", audit["n_rows"]), ("Laboratories", audit["n_laboratories"]), ("Unique RDKit-canonical SMILES", audit["unique_canonical_isomeric_smiles"]), ("Unique full InChIKeys", audit["unique_full_inchi_keys"]), ("Unique InChIKey connectivity blocks", audit["unique_connectivity_blocks"]), ("Connectivity groups observed in multiple laboratories", audit["structures_seen_in_multiple_labs"]), ("Rows belonging to multi-laboratory connectivity groups", audit["rows_in_multi_lab_structures"]), ("Graph-conversion failures in supplied table", audit["graph_conversion_failures_in_provided_csv"]), ("Exact duplicate extra rows", audit["exact_duplicate_extra_rows"]), ("Structure--laboratory duplicate extra rows", audit["structure_lab_duplicate_extra_rows"]), ("Structure--laboratory groups with conflicting RT", audit["structure_lab_groups_with_conflicting_rt"]), ) lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Audited observational units and molecular-identity counts.}", r"\label{tab:dataset-audit}", r"\begin{tabular}{lr}", r"\toprule", r"Quantity & Count \\", r"\midrule", ] lines.extend(f"{_escape_latex(label)} & {int(value):,} \\\\" for label, value in rows) lines.extend([r"\bottomrule", r"\end{tabular}", r"\end{table}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _laboratory_ids(laboratories: list[str]) -> dict[str, str]: return {laboratory: f"L{index:02d}" for index, laboratory in enumerate(laboratories, start=1)} def _write_laboratory_counts(audit: dict[str, Any], output_path: Path) -> None: counts = {str(key): int(value) for key, value in audit["laboratory_counts"].items()} laboratories = sorted(counts) ids = _laboratory_ids(laboratories) lines = [ r"\begin{longtable}{llr}", r"\caption{Laboratory identifiers and numbers of structure--laboratory observations.}\label{tab:lab-counts}\\", r"\toprule", r"ID & Laboratory & Observations \\", r"\midrule", r"\endfirsthead", r"\toprule", r"ID & Laboratory & Observations \\", r"\midrule", r"\endhead", ] for laboratory in laboratories: lines.append( f"{ids[laboratory]} & {_escape_latex(laboratory)} & {counts[laboratory]:,} \\\\" ) lines.extend([r"\bottomrule", r"\end{longtable}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_shared_lab_matrix(matrix_path: Path, output_path: Path) -> None: matrix = pd.read_csv(matrix_path, index_col=0) matrix.index = matrix.index.astype(str) matrix.columns = matrix.columns.astype(str) laboratories = sorted(set(matrix.index) | set(matrix.columns)) matrix = matrix.reindex(index=laboratories, columns=laboratories) if matrix.isna().any().any(): raise ValueError("Shared-compound matrix is not square over the laboratory labels.") ids = _laboratory_ids(laboratories) column_specification = "l" + "r" * len(laboratories) header = " & ".join(["ID"] + [ids[laboratory] for laboratory in laboratories]) + r" \\" lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Numbers of shared InChIKey-connectivity groups between every laboratory pair. Laboratory IDs are defined in Table~\ref{tab:lab-counts}; diagonal entries are the numbers of unique connectivity groups within each laboratory.}", r"\label{tab:shared-lab-matrix}", r"\resizebox{\textwidth}{!}{%", f"\\begin{{tabular}}{{{column_specification}}}", r"\toprule", header, r"\midrule", ] for laboratory in laboratories: values = " & ".join(str(int(matrix.loc[laboratory, other])) for other in laboratories) lines.append(f"{ids[laboratory]} & {values} \\\\") lines.extend( [ r"\bottomrule", r"\end{tabular}%", r"}", r"\end{table}", ] ) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_split_table(artifacts_root: Path, output_path: Path) -> None: lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Outer split sizes and leakage checks for all predeclared runs.}", r"\label{tab:split-audit}", r"\begin{tabular}{lrrrrrr}", r"\toprule", r"Task & Seed & Development & Test & Test (\%) & Identity overlap & Laboratories \\", r"\midrule", ] for strategy in STRATEGIES: for seed in SEEDS: payload = _load_json( artifacts_root / strategy / f"seed_{seed}" / "split_summary.json" ) laboratories = ( f"{payload['development_laboratories']}/{payload['test_laboratories']}" ) lines.append( f"{STRATEGY_LABELS[strategy]} & {seed} & " f"{payload['n_development_rows']:,} & {payload['n_test_rows']:,} & " f"{100 * payload['test_fraction']:.1f} & " f"{payload['structure_identity_overlap']} & {laboratories} \\\\" ) lines.extend( [ r"\bottomrule", r"\end{tabular}", r"\end{table}", ] ) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_performance_table(neural: pd.DataFrame, output_path: Path) -> None: model_order = ("gat", "gcn", "fpnn", "stack_all_plus_descriptors") lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Outer-test performance across three predeclared seeds (mean $\pm$ SD).}", r"\label{tab:primary-performance}", r"\begin{tabular}{llccc}", r"\toprule", r"Task & Model & $R^2$ & MAE (min) & RMSE (min) \\", r"\midrule", ] for strategy in STRATEGIES: for model in model_order: row = neural.loc[ (neural["strategy"] == strategy) & (neural["model"] == model) ].iloc[0] lines.append( f"{STRATEGY_LABELS[strategy]} & {MODEL_LABELS[model]} & " f"{_mean_sd(row, 'r2')} & {_mean_sd(row, 'mae')} & " f"{_mean_sd(row, 'rmse')} \\\\" ) if strategy != STRATEGIES[-1]: lines.append(r"\addlinespace") lines.extend([r"\bottomrule", r"\end{tabular}", r"\end{table}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_ablation_table( neural: pd.DataFrame, classical: pd.DataFrame, output_path: Path ) -> None: model_order = ( "arithmetic_mean", "stack_all_base_only", "stack_all_plus_descriptors", "stack_gat_gcn", "stack_gat_fpnn", "stack_gcn_fpnn", "lab_median", "descriptor_only_ridge", "fingerprint_no_lab_et", "fingerprint_no_lab_plus_lab_affine", "fingerprint_only_et", "fingerprint_plus_descriptors_et", ) source = pd.concat([neural, classical], ignore_index=True) lines = [ r"\begin{longtable}{llcc}", r"\caption{Fixed component ablations and classical baselines (mean $\pm$ SD across seeds).}\label{tab:ablations}\\", r"\toprule", r"Task & Model & $R^2$ & MAE (min) \\", r"\midrule", r"\endfirsthead", r"\toprule", r"Task & Model & $R^2$ & MAE (min) \\", r"\midrule", r"\endhead", ] for strategy in STRATEGIES: for model in model_order: row = source.loc[ (source["strategy"] == strategy) & (source["model"] == model) ] if row.empty: continue record = row.iloc[0] lines.append( f"{STRATEGY_LABELS[strategy]} & {_escape_latex(MODEL_LABELS[model])} & " f"{_mean_sd(record, 'r2')} & {_mean_sd(record, 'mae')} \\\\" ) if strategy != STRATEGIES[-1]: lines.append(r"\addlinespace") lines.extend([r"\bottomrule", r"\end{longtable}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_per_lab_table(per_lab: pd.DataFrame, output_path: Path) -> None: primary = per_lab.loc[per_lab["model"] == "stack_all_plus_descriptors"].copy() grouped = primary.groupby(["strategy", "Lab"], sort=True) lines = [ r"\begin{landscape}", r"\begingroup", r"\scriptsize", r"\renewcommand{\arraystretch}{0.92}", r"\setlength{\tabcolsep}{3pt}", r"\begin{longtable}{p{1.15in}p{2.2in}rccccc}", r"\caption{Per-laboratory full-stack metrics across outer seeds.}\label{tab:per-lab}\\", r"\toprule", r"Task & Laboratory & $n$ range & MAE & RMSE & Bias & Slope & nMAE \\", r"\midrule", r"\endfirsthead", r"\toprule", r"Task & Laboratory & $n$ range & MAE & RMSE & Bias & Slope & nMAE \\", r"\midrule", r"\endhead", ] for (strategy, laboratory), frame in grouped: n_range = f"{int(frame['n'].min())}--{int(frame['n'].max())}" values = [] for column in ("mae", "rmse", "bias", "calibration_slope", "nmae_by_rt_range"): mean = frame[column].mean() sd = frame[column].std(ddof=1) values.append(f"{_number(mean)} $\\pm$ {_number(sd)}") lines.append( f"{STRATEGY_LABELS[strategy]} & {_escape_latex(laboratory)} & {n_range} & " + " & ".join(values) + r" \\" ) lines.extend( [ r"\bottomrule", r"\end{longtable}", r"\endgroup", r"\end{landscape}", ] ) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_domain_table(domain: pd.DataFrame, output_path: Path) -> None: grouped = domain.groupby(["strategy", "threshold"], sort=True) lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Prospective maximum-Tanimoto threshold sensitivity across outer seeds.}", r"\label{tab:domain}", r"\begin{tabular}{lccccc}", r"\toprule", r"Task & Threshold & Coverage & Accepted MAE & Rejected MAE & $\rho$(similarity, $|e|$) \\", r"\midrule", ] for (strategy, threshold), frame in grouped: lines.append( f"{STRATEGY_LABELS[strategy]} & {threshold:.1f} & " f"{_number(frame['accepted_coverage'].mean())} $\\pm$ {_number(frame['accepted_coverage'].std(ddof=1))} & " f"{_number(frame['accepted_mae'].mean())} $\\pm$ {_number(frame['accepted_mae'].std(ddof=1))} & " f"{_number(frame['rejected_mae'].mean())} $\\pm$ {_number(frame['rejected_mae'].std(ddof=1))} & " f"{_number(frame['spearman_similarity_vs_absolute_error'].mean())} \\\\" ) lines.extend([r"\bottomrule", r"\end{tabular}", r"\end{table}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_paired_table(paired: pd.DataFrame, output_path: Path) -> None: selected = paired.loc[ (paired["reference_model"].isin(["gat", "gcn", "fpnn"])) & (paired["metric"] == "mae") ].copy() lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Paired connectivity-group bootstrap differences in MAE for the full stack relative to each base model. Negative differences favor the full stack.}", r"\label{tab:paired-bootstrap}", r"\begin{tabular}{llrr}", r"\toprule", r"Task & Reference & Seed & Difference (95\% interval), min \\", r"\midrule", ] for _, row in selected.sort_values(["strategy", "reference_model", "seed"]).iterrows(): lines.append( f"{STRATEGY_LABELS[row['strategy']]} & {MODEL_LABELS[row['reference_model']]} & " f"{int(row['seed'])} & {_number(row['difference_point'])} " f"[{_number(row['ci_low'])}, {_number(row['ci_high'])}] \\\\" ) lines.extend([r"\bottomrule", r"\end{tabular}", r"\end{table}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_embedding_stability_table( embedding_stability: pd.DataFrame, output_path: Path ) -> None: lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Cross-seed stability of learned laboratory geometry. Values are Spearman correlations between vectors of mean foldwise pairwise laboratory distances; the interval gives the minimum and maximum across the three seed pairs.}", r"\label{tab:embedding-stability}", r"\begin{tabular}{llc}", r"\toprule", r"Task & Model & Spearman $\rho$ (range) \\", r"\midrule", ] for strategy in STRATEGIES: for model in ("gat", "gcn", "fpnn"): row = embedding_stability.loc[ (embedding_stability["strategy"] == strategy) & (embedding_stability["model"] == model) ] if len(row) != 1: raise ValueError( f"Expected one embedding-stability row for {strategy}/{model}." ) record = row.iloc[0] lines.append( f"{STRATEGY_LABELS[strategy]} & {MODEL_LABELS[model]} & " f"{_number(record['mean'])} " f"[{_number(record['min'])}, {_number(record['max'])}] \\\\" ) if strategy != STRATEGIES[-1]: lines.append(r"\addlinespace") lines.extend([r"\bottomrule", r"\end{tabular}", r"\end{table}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_computational_cost_table(runtime: pd.DataFrame, output_path: Path) -> None: lines = [ r"\begin{table}[htbp]", r"\centering", r"\caption{Trainable parameter counts and cumulative six-fold training time per outer run. Times are mean $\pm$ SD across three seeds on the hardware reported in the text.}", r"\label{tab:computational-cost}", r"\begin{tabular}{llrr}", r"\toprule", r"Task & Model & Parameters & Training time (min) \\", r"\midrule", ] for strategy in STRATEGIES: for model in ("gat", "gcn", "fpnn"): row = runtime.loc[ (runtime["strategy"] == strategy) & (runtime["model"] == model) ] if len(row) != 1: raise ValueError(f"Expected one runtime row for {strategy}/{model}.") record = row.iloc[0] mean_minutes = float(record["training_seconds_mean"]) / 60.0 sd_minutes = float(record["training_seconds_sd"]) / 60.0 lines.append( f"{STRATEGY_LABELS[strategy]} & {MODEL_LABELS[model]} & " f"{int(record['parameter_count']):,} & " f"{mean_minutes:.2f} $\\pm$ {sd_minutes:.2f} \\\\" ) if strategy != STRATEGIES[-1]: lines.append(r"\addlinespace") lines.extend([r"\bottomrule", r"\end{tabular}", r"\end{table}"]) output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _write_table_manifest(output_dir: Path) -> None: rows = [ { "relative_path": path.name, "bytes": path.stat().st_size, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), } for path in sorted(output_dir.glob("*.tex")) ] pd.DataFrame(rows).to_csv(output_dir / "TABLE_MANIFEST.csv", index=False) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--artifacts-root", required=True) parser.add_argument("--output-dir", required=True) args = parser.parse_args() artifacts_root = Path(args.artifacts_root).resolve() output_dir = Path(args.output_dir).resolve() if output_dir.exists() and any(output_dir.iterdir()): raise FileExistsError(f"Refusing nonempty generated-table directory: {output_dir}") output_dir.mkdir(parents=True, exist_ok=True) summary_dir = artifacts_root / "summary" neural = pd.read_csv(summary_dir / "neural_metrics_aggregate.csv") classical = pd.read_csv(summary_dir / "classical_metrics_aggregate.csv") per_lab = pd.read_csv(summary_dir / "neural_per_lab_by_run.csv") domain = pd.read_csv(summary_dir / "prospective_domain_by_run.csv") paired = pd.read_csv(summary_dir / "paired_bootstrap_by_run.csv") embedding_stability = pd.read_csv( summary_dir / "embedding_stability" / "embedding_stability_aggregate.csv" ) runtime = pd.read_csv(summary_dir / "neural_runtime_aggregate.csv") audit = _load_json(artifacts_root / "dataset_audit.json") inference_benchmark = _load_json(artifacts_root / "inference_benchmark.json") _write_metric_macros( neural, inference_benchmark, output_dir / "results_macros.tex", ) _write_dataset_table(audit, output_dir / "table_dataset_audit.tex") _write_laboratory_counts(audit, output_dir / "table_laboratory_counts.tex") _write_shared_lab_matrix( artifacts_root / "shared_compounds_by_lab.csv", output_dir / "table_shared_lab_matrix.tex", ) _write_split_table(artifacts_root, output_dir / "table_split_audit.tex") _write_performance_table(neural, output_dir / "table_performance.tex") _write_ablation_table(neural, classical, output_dir / "table_ablations.tex") _write_per_lab_table(per_lab, output_dir / "table_per_lab.tex") _write_domain_table(domain, output_dir / "table_domain.tex") _write_paired_table(paired, output_dir / "table_paired_bootstrap.tex") _write_embedding_stability_table( embedding_stability, output_dir / "table_embedding_stability.tex", ) _write_computational_cost_table( runtime, output_dir / "table_computational_cost.tex", ) _write_table_manifest(output_dir) print(f"Wrote revision macros and LaTeX tables to: {output_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())