Back to Skills
yuan1z0825/nature-skillsReview behavior before running

SKILL DETAIL

nature-figure

yuan1z0825/nature-skills/nature-figure

This skill is used to create, revise, audit, and export submission-grade scientific figures for Nature-family and other high-impact venues, supporting Python (matplotlib/seaborn) and R (ggplot2/patchwork/ComplexHeatmap) backends, including multi-panel plots, figures4papers-style work, and journal-ready SVG/PDF/TIFF outputs. It is intended for paper or scientific plots, manuscript data visualization, and related academic writing and research visualization tasks. Before plotting, define the conclusion, evidence logic, data integrity, template compatibility, export needs, and reviewer risks; honor or persist the Python/R backend choice. For explicit AI-generated graphical abstracts, mechanism diagrams, concept schematics, and similar, use the separate OpenRouter GPT Image 2 route, which skips backend choice and treats outputs as drafts. Do not use for interactive dashboards, statistics-only analysis, data cleaning, literature review, code debugging, pure photo editing, or Illustrator/Figma-first infographics without manuscript-figure intent.

Installs · 117View source

Installation

npx skills add https://github.com/yuan1z0825/nature-skills --skill nature-figure

Skill files

SKILL.md

Last synced · Aug 29, 2026

.gitignore
.DS_Store
agents/openai.yaml
interface:
  display_name: "Nature Figure"
  short_description: "Create and audit submission-ready scientific figures"
  default_prompt: "Use $nature-figure to create and audit a submission-ready scientific figure from these data."
assets/figures4papers/figure_brainteaser/plot_brute_force.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec
from matplotlib import patheffects as path_effects


data_brute_force_math = {
    'methods': [
        r'DeepSeek R1 Distill Qwen 1.5B',
        r'DeepSeek R1 Distill Qwen 14B',
        r'DeepSeek R1 Distill Llama 70B',
        r'deepseek-chat (Deepseek-V3)',
        r'deepseek-reasoner (Deepseek-R1)',
        r'gemini-2.5-flash-preview-04-17',
        r'OpenAI o3'
    ],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#FFF6CC', '#3775BA'],
    'prompts': ['CoT Prompt', 'Math Prompt', 'Hint Prompt', 'Math + Hint'],
    'subtypes': [r'$\bf{Only}$ $\bf{Model}$ brute force',
                 r'$\bf{Only}$ $\bf{Human}$ brute force',
                 r'$\bf{Neither}$ brute force',
                 r'$\bf{Both}$ brute force'],
    'hatch_styles': ['/', '\\', '', 'x'],
    'result': {
        'CoT Prompt': np.array([[26.8, 3.6, 60.0, 9.6],
                                [27.6, 3.2, 59.2, 10.0],
                                [24.4, 4.0, 62.4, 9.2],
                                [31.2, 3.6, 55.6, 9.6],
                                [14.0, 7.2, 72.8, 6.0],
                                [16.9, 5.9, 70.0, 7.2],
                                [9.5, 7.5, 79.4, 3.5]]) / 100,
        'Math Prompt': np.array([[27.2, 4.8, 59.6, 8.4],
                                 [25.6, 4.8, 61.2, 8.4],
                                 [24.4, 4.8, 62.4, 8.4],
                                 [28.4, 3.2, 58.4, 10.0],
                                 [10.0, 5.6, 76.7, 7.6],
                                 [12.6, 5.2, 74.8, 7.4],
                                 [4.2, 7.9, 85.7, 2.1]]) / 100,
        'Hint Prompt': np.array([[29.6, 4.4, 57.2, 8.8],
                                 [27.6, 2.8, 59.2, 10.4],
                                 [20.4, 5.2, 66.4, 8.0],
                                 [28.0, 3.2, 58.8, 10.0],
                                 [14.0, 6.0, 72.8, 7.2],
                                 [12.4, 5.1, 74.4, 8.1],
                                 [6.8, 6.8, 83.2, 3.1]]) / 100,
        'Math + Hint': np.array([[26.4, 4.0, 60.4, 9.2],
                                 [27.6, 3.2, 59.2, 10.0],
                                 [24.0, 4.8, 62.8, 8.4],
                                 [25.2, 2.8, 61.6, 10.4],
                                 [8.0, 6.8, 78.8, 6.4],
                                 [10.0, 6.1, 76.4, 7.4],
                                 [3.7, 6.4, 86.7, 3.2]]) / 100,
    },
}

data_brute_force_logic = {
    'methods': [
        r'DeepSeek R1 Distill Qwen 1.5B',
        r'DeepSeek R1 Distill Qwen 14B',
        r'DeepSeek R1 Distill Llama 70B',
        r'deepseek-chat (Deepseek-V3)',
        r'deepseek-reasoner (Deepseek-R1)',
        r'gemini-2.5-flash-preview-04-17',
        r'OpenAI o3'
    ],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#FFF6CC', '#3775BA'],
    'prompts': ['CoT Prompt', 'Math Prompt', 'Hint Prompt', 'Math + Hint'],
    'subtypes': [r'$\bf{Only}$ $\bf{Model}$ brute force',
                 r'$\bf{Only}$ $\bf{Human}$ brute force',
                 r'$\bf{Neither}$ brute force',
                 r'$\bf{Both}$ brute force'],
    'hatch_styles': ['/', '\\', '', 'x'],
    'result': {
        'CoT Prompt': np.array([[18.0, 5.2, 72.0, 4.8],
                                [30.8, 3.2, 59.2, 6.8],
                                [23.2, 2.8, 66.8, 7.2],
                                [32.4, 3.6, 57.6, 6.4],
                                [13.7, 6.8, 76.7, 2.8],
                                [15.7, 3.2, 75.6, 5.5],
                                [15.9, 6.5, 75.6, 2.0]]) / 100,
        'Math Prompt': np.array([[20.0, 4.4, 70.0, 5.6],
                                 [31.2, 4.0, 58.8, 6.0],
                                 [24.4, 4.0, 65.6, 6.0],
                                 [33.6, 3.6, 56.4, 6.4],
                                 [12.4, 7.6, 77.6, 2.4],
                                 [15.2, 5.5, 75.7, 3.7],
                                 [10.5, 6.2, 80.9, 2.4]]) / 100,
        'Hint Prompt': np.array([[15.6, 4.8, 74.4, 5.2],
                                 [30.4, 3.2, 59.6, 6.8],
                                 [25.2, 2.8, 64.8, 7.2],
                                 [27.6, 2.8, 62.4, 7.2],
                                 [9.6, 6.0, 80.7, 3.6],
                                 [14.3, 4.5, 75.8, 5.4],
                                 [6.9, 5.9, 84.8, 2.5]]) / 100,
        'Math + Hint': np.array([[20.0, 3.2, 70.0, 6.8],
                                 [32.0, 4.8, 58.0, 5.2],
                                 [19.6, 4.0, 70.4, 6.0],
                                 [26.0, 4.0, 64.0, 6.0],
                                 [8.8, 5.6, 81.5, 4.0],
                                 [13.0, 5.3, 76.8, 4.8],
                                 [5.1, 7.4, 86.5, 0.9]]) / 100,
    },
}

if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(52, 12))

    gs = gridspec.GridSpec(2, 5)

    for prompt_idx, prompt_name in enumerate(data_brute_force_math['prompts']):
        ax = fig.add_subplot(gs[prompt_idx])
        num_methods = len(data_brute_force_math['methods'])
        bars = ax.bar(
            np.arange(num_methods),
            data_brute_force_math['result'][prompt_name][:, 0],
            color=data_brute_force_math['colors'],
            label=data_brute_force_math['methods'],
            hatch=data_brute_force_math['hatch_styles'][0],
            edgecolor='black',
            linewidth=2,
        )

        for bar in bars:
            height = bar.get_height()
            text = ax.text(
                bar.get_x() + bar.get_width() / 2,
                height / 2,
                f'{height:.3f}',
                ha='center',
                va='center',
                color='#FFD700',
                fontsize=20,
                path_effects=[
                    path_effects.Stroke(linewidth=4, foreground='black'),
                    path_effects.Normal()
                ]
            )

        for subtype_idx in range(1, len(data_brute_force_math['subtypes'])):
            ax.bar(
                np.arange(num_methods),
                data_brute_force_math['result'][prompt_name][:, subtype_idx],
                color=data_brute_force_math['colors'],
                label=data_brute_force_math['methods'],
                hatch=data_brute_force_math['hatch_styles'][subtype_idx],
                bottom=np.cumsum(data_brute_force_math['result'][prompt_name], axis=1)[:, subtype_idx - 1],
                edgecolor='black',
                linewidth=2,
                alpha=0.8,
            )

        ax.set_title(data_brute_force_math['prompts'][prompt_idx], fontsize=36, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1.01])
        ax.set_xticks([])

    ax = fig.add_subplot(gs[4])
    bar = ax.bar(
        np.arange(num_methods),
        np.ones_like(np.arange(num_methods)),
        color=data_brute_force_math['colors'],
        label=data_brute_force_math['methods'],
        hatch='',
        edgecolor='black',
        linewidth=3,
    )
    handles, labels = ax.get_legend_handles_labels()
    for b in bar:
        b.remove()
    ax.legend(handles, labels, fontsize=30, loc='center', frameon=False)
    ax.set_axis_off()

    for prompt_idx, prompt_name in enumerate(data_brute_force_logic['prompts']):
        ax = fig.add_subplot(gs[prompt_idx + 5])
        num_methods = len(data_brute_force_logic['methods'])
        bars = ax.bar(
            np.arange(num_methods),
            data_brute_force_logic['result'][prompt_name][:, 0],
            color=data_brute_force_logic['colors'],
            label=data_brute_force_logic['methods'],
            hatch=data_brute_force_logic['hatch_styles'][0],
            edgecolor='black',
            linewidth=2,
        )

        for bar in bars:
            height = bar.get_height()
            text = ax.text(
                bar.get_x() + bar.get_width() / 2,
                height / 2,
                f'{height:.3f}',
                ha='center',
                va='center',
                color='#FFD700',
                fontsize=20,
                path_effects=[
                    path_effects.Stroke(linewidth=4, foreground='black'),
                    path_effects.Normal()
                ]
            )

        for subtype_idx in range(1, len(data_brute_force_logic['subtypes'])):
            ax.bar(
                np.arange(num_methods),
                data_brute_force_logic['result'][prompt_name][:, subtype_idx],
                color=data_brute_force_logic['colors'],
                label=data_brute_force_logic['methods'],
                hatch=data_brute_force_logic['hatch_styles'][subtype_idx],
                bottom=np.cumsum(data_brute_force_logic['result'][prompt_name], axis=1)[:, subtype_idx - 1],
                edgecolor='black',
                linewidth=2,
                alpha=0.8,
            )

        ax.set_title(data_brute_force_logic['prompts'][prompt_idx], fontsize=36, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1.01])
        ax.set_xticks([])

    ax = fig.add_subplot(gs[9])
    num_subtypes = len(data_brute_force_math['subtypes'])
    bar = ax.bar(
        np.arange(num_subtypes),
        np.ones_like(np.arange(num_subtypes)),
        color='white',
        label=data_brute_force_math['subtypes'],
        hatch=data_brute_force_math['hatch_styles'],
        edgecolor='black',
        linewidth=3,
    )
    handles, labels = ax.get_legend_handles_labels()
    for b in bar:
        b.remove()
    ax.legend(handles, labels, fontsize=30, loc='center', frameon=False)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/brute_force.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_brainteaser/plot_correctness_by_category.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_math_by_category = {
    'methods': [
        r'DeepSeek R1 Distill Qwen 1.5B',
        r'DeepSeek R1 Distill Qwen 14B',
        r'DeepSeek R1 Distill Llama 70B',
        r'deepseek-chat (Deepseek-V3)',
        r'deepseek-reasoner (Deepseek-R1)',
        r'gemini-2.5-flash-preview-04-17',
        r'OpenAI o3'
    ],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#FFF6CC', '#3775BA'],
    'subtypes': ['Standard',
                 'Nonstandard',
                 'Heuristic'],
    'result': {
        'Standard': np.array([0.253623188, 0.5, 0.5, 0.594202899, 0.688405797, 0.710144928, 0.789855073]),
        'Geometry': np.array([0.125, 0.416666667, 0.375, 0.291666667, 0.375, 0.541666667, 0.666666667]),
        'Number Theory': np.array([0.441176471, 0.558823529, 0.529411765, 0.676470588, 0.823529412, 0.735294118, 0.852941177]),
        'Combinatorics': np.array([0.25, 0.416666667, 0.416666667, 0.625, 0.666666667, 0.625, 0.75]),
        'Algebra': np.array([0.196428571, 0.535714286, 0.571428571, 0.660714286, 0.75, 0.803571429, 0.821428571]),
        'Nonstandard': np.array([0.103448276, 0.482758621, 0.431034483, 0.620689655, 0.74137931, 0.672413793, 0.827586207]),
        'Logic': np.array([0.034482759, 0.413793103, 0.448275862, 0.517241379, 0.620689655, 0.517241379, 0.75862069]),
        'Special Number': np.array([0.172413793, 0.551724138, 0.413793103, 0.724137931, 0.862068966, 0.827586207, 0.896551724]),
        'Heuristic': np.array([0, 0.260869565, 0.173913044, 0.413043478, 0.673913044, 0.47826087, 0.804347826]),
        'Pattern': np.array([0, 0.214285714, 0.178571429, 0.357142857, 0.642857143, 0.428571429, 0.75]),
        'Arithmetic': np.array([0, 0.333333333, 0.166666667, 0.5, 0.722222222, 0.555555556, 0.888888889]),
        },
}

data_logic_by_category = {
    'methods': [
        r'DeepSeek R1 Distill Qwen 1.5B',
        r'DeepSeek R1 Distill Qwen 14B',
        r'DeepSeek R1 Distill Llama 70B',
        r'deepseek-chat (Deepseek-V3)',
        r'deepseek-reasoner (Deepseek-R1)',
        r'gemini-2.5-flash-preview-04-17',
        r'OpenAI o3'
    ],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#FFF6CC', '#3775BA'],
    'subtypes': ['Simple/large',
                 'Complex/small',
                 'Math-like',
                 'Heuristic'],
    'result': {
        'Simple/large': np.array([0.042105263, 0.178947368, 0.189473684, 0.389473684, 0.410526316, 0.515789474, 0.694736842]),
        '0D': np.array([0.068965517, 0.172413793, 0.206896552, 0.379310345, 0.448275862, 0.517241379, 0.689655172]),
        '1D': np.array([0, 0.230769231, 0.230769231, 0.615384615, 0.538461539, 0.538461539, 0.692307692]),
        '2D': np.array([0, 0.045454545, 0.090909091, 0.272727273, 0.181818182, 0.363636364, 0.454545455]),
        'Number': np.array([0.058823529, 0.470588235, 0.411764706, 0.588235294, 0.823529412, 0.941176471, 1]),
        'Clusters': np.array([0, 0, 0, 0.125, 0, 0.25, 0.875]),
        'Tree': np.array([0.166666667, 0, 0, 0.166666667, 0.166666667, 0.166666667, 0.5]),
        'Complex/small': np.array([0, 0.433333333, 0.4, 0.466666667, 0.566666667, 0.833333333, 0.866666667]),
        'Liars': np.array([0, 0.411764706, 0.411764706, 0.411764706, 0.705882353, 0.882352941, 0.882352941]),
        'Communication': np.array([0, 0.5, 0, 0.25, 0, 0.5, 0.5]),
        'Compound': np.array([0, 0.444444444, 0.555555556, 0.666666667, 0.555555556, 0.888888889, 1]),
        'Math-like': np.array([0.085714286, 0.328571429, 0.371428571, 0.514285714, 0.542857143, 0.542857143, 0.714285714]),
        'Algorithm': np.array([0.078947368, 0.315789474, 0.368421053, 0.473684211, 0.447368421, 0.473684211, 0.710526316]),
        'Math': np.array([0.09375, 0.34375, 0.375, 0.5625, 0.65625, 0.625, 0.71875]),
        'Heuristic': np.array([0, 0.12195122, 0.097560976, 0.317073171, 0.365853659, 0.268292683, 0.658536585]),
        'Pattern': np.array([0, 0.153846154, 0.115384615, 0.230769231, 0.346153846, 0.307692308, 0.576923077]),
        'Linguistic': np.array([0, 0.066666667, 0.066666667, 0.466666667, 0.4, 0.2, 0.8]),        },
}


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(36, 12))

    gs = gridspec.GridSpec(2, 4)

    for subtype_idx, subtype_name in enumerate(data_math_by_category['subtypes']):
        ax = fig.add_subplot(gs[subtype_idx])
        num_methods = len(data_math_by_category['methods'])
        ax.bar(
            np.arange(num_methods),
            data_math_by_category['result'][subtype_name],
            color=data_math_by_category['colors'],
            label=data_math_by_category['methods'],
        )

        ax.set_title(data_math_by_category['subtypes'][subtype_idx], fontsize=36, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1])
        ax.set_xticks([])

    ax = fig.add_subplot(gs[3])
    bar = ax.bar(
        np.arange(num_methods),
        np.ones_like(np.arange(num_methods)),
        color=data_math_by_category['colors'],
        label=data_math_by_category['methods'],
        hatch='',
    )
    handles, labels = ax.get_legend_handles_labels()
    for b in bar:
        b.remove()
    ax.legend(handles, labels, fontsize=28, loc='center', frameon=False)
    ax.set_axis_off()

    for subtype_idx, subtype_name in enumerate(data_logic_by_category['subtypes']):
        ax = fig.add_subplot(gs[4 + subtype_idx])
        num_methods = len(data_logic_by_category['methods'])
        ax.bar(
            np.arange(num_methods),
            data_logic_by_category['result'][subtype_name],
            color=data_logic_by_category['colors'],
            label=data_logic_by_category['methods'],
        )

        ax.set_title(data_logic_by_category['subtypes'][subtype_idx], fontsize=36, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1])
        ax.set_xticks([])


    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/correctness_by_category.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_brainteaser/plot_correctness_by_subcategory.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_math_by_category = {
    'methods': [
        r'DeepSeek R1 Distill Qwen 1.5B',
        r'DeepSeek R1 Distill Qwen 14B',
        r'DeepSeek R1 Distill Llama 70B',
        r'deepseek-chat (Deepseek-V3)',
        r'deepseek-reasoner (Deepseek-R1)',
        r'gemini-2.5-flash-preview-04-17',
        r'OpenAI o3'
    ],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#FFF6CC', '#3775BA'],
    'subtypes': ['Geometry', 'Number Theory', 'Combinatorics', 'Algebra',
                 'Logic', 'Special Number', 'Pattern', 'Arithmetic'],
    'result': {
        'Standard': np.array([0.253623188, 0.5, 0.5, 0.594202899, 0.688405797, 0.710144928, 0.789855073]),
        'Geometry': np.array([0.125, 0.416666667, 0.375, 0.291666667, 0.375, 0.541666667, 0.666666667]),
        'Number Theory': np.array([0.441176471, 0.558823529, 0.529411765, 0.676470588, 0.823529412, 0.735294118, 0.852941177]),
        'Combinatorics': np.array([0.25, 0.416666667, 0.416666667, 0.625, 0.666666667, 0.625, 0.75]),
        'Algebra': np.array([0.196428571, 0.535714286, 0.571428571, 0.660714286, 0.75, 0.803571429, 0.821428571]),
        'Nonstandard': np.array([0.103448276, 0.482758621, 0.431034483, 0.620689655, 0.74137931, 0.672413793, 0.827586207]),
        'Logic': np.array([0.034482759, 0.413793103, 0.448275862, 0.517241379, 0.620689655, 0.517241379, 0.75862069]),
        'Special Number': np.array([0.172413793, 0.551724138, 0.413793103, 0.724137931, 0.862068966, 0.827586207, 0.896551724]),
        'Heuristic': np.array([0, 0.260869565, 0.173913044, 0.413043478, 0.673913044, 0.47826087, 0.804347826]),
        'Pattern': np.array([0, 0.214285714, 0.178571429, 0.357142857, 0.642857143, 0.428571429, 0.75]),
        'Arithmetic': np.array([0, 0.333333333, 0.166666667, 0.5, 0.722222222, 0.555555556, 0.888888889]),
        },
}

data_logic_by_category = {
    'methods': [
        r'DeepSeek R1 Distill Qwen 1.5B',
        r'DeepSeek R1 Distill Qwen 14B',
        r'DeepSeek R1 Distill Llama 70B',
        r'deepseek-chat (Deepseek-V3)',
        r'deepseek-reasoner (Deepseek-R1)',
        r'gemini-2.5-flash-preview-04-17',
        r'OpenAI o3'
    ],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#FFF6CC', '#3775BA'],
    'subtypes': ['0D', '1D', '2D', 'Number', 'Clusters', 'Tree', 'Liars',
                 'Communication', 'Compound', 'Algorithm', 'Math', 'Pattern', 'Linguistic'],

    'result': {
        'Simple/large': np.array([0.042105263, 0.178947368, 0.189473684, 0.389473684, 0.410526316, 0.515789474, 0.694736842]),
        '0D': np.array([0.068965517, 0.172413793, 0.206896552, 0.379310345, 0.448275862, 0.517241379, 0.689655172]),
        '1D': np.array([0, 0.230769231, 0.230769231, 0.615384615, 0.538461539, 0.538461539, 0.692307692]),
        '2D': np.array([0, 0.045454545, 0.090909091, 0.272727273, 0.181818182, 0.363636364, 0.454545455]),
        'Number': np.array([0.058823529, 0.470588235, 0.411764706, 0.588235294, 0.823529412, 0.941176471, 1]),
        'Clusters': np.array([0, 0, 0, 0.125, 0, 0.25, 0.875]),
        'Tree': np.array([0.166666667, 0, 0, 0.166666667, 0.166666667, 0.166666667, 0.5]),
        'Complex/small': np.array([0, 0.433333333, 0.4, 0.466666667, 0.566666667, 0.833333333, 0.866666667]),
        'Liars': np.array([0, 0.411764706, 0.411764706, 0.411764706, 0.705882353, 0.882352941, 0.882352941]),
        'Communication': np.array([0, 0.5, 0, 0.25, 0, 0.5, 0.5]),
        'Compound': np.array([0, 0.444444444, 0.555555556, 0.666666667, 0.555555556, 0.888888889, 1]),
        'Math-like': np.array([0.085714286, 0.328571429, 0.371428571, 0.514285714, 0.542857143, 0.542857143, 0.714285714]),
        'Algorithm': np.array([0.078947368, 0.315789474, 0.368421053, 0.473684211, 0.447368421, 0.473684211, 0.710526316]),
        'Math': np.array([0.09375, 0.34375, 0.375, 0.5625, 0.65625, 0.625, 0.71875]),
        'Heuristic': np.array([0, 0.12195122, 0.097560976, 0.317073171, 0.365853659, 0.268292683, 0.658536585]),
        'Pattern': np.array([0, 0.153846154, 0.115384615, 0.230769231, 0.346153846, 0.307692308, 0.576923077]),
        'Linguistic': np.array([0, 0.066666667, 0.066666667, 0.466666667, 0.4, 0.2, 0.8]),
        },
}


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(96, 12))

    gs = gridspec.GridSpec(2, 13)

    for subtype_idx, subtype_name in enumerate(data_math_by_category['subtypes']):
        ax = fig.add_subplot(gs[subtype_idx])
        num_methods = len(data_math_by_category['methods'])
        ax.bar(
            np.arange(num_methods),
            data_math_by_category['result'][subtype_name],
            color=data_math_by_category['colors'],
            label=data_math_by_category['methods'],
        )

        ax.set_title(data_math_by_category['subtypes'][subtype_idx], fontsize=36, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1])
        ax.set_xticks([])

    ax = fig.add_subplot(gs[11:12])
    bar = ax.bar(
        np.arange(num_methods),
        np.ones_like(np.arange(num_methods)),
        color=data_math_by_category['colors'],
        label=data_math_by_category['methods'],
        hatch='',
    )
    handles, labels = ax.get_legend_handles_labels()
    for b in bar:
        b.remove()
    ax.legend(handles, labels, fontsize=28, loc='center', frameon=False)
    ax.set_axis_off()

    for subtype_idx, subtype_name in enumerate(data_logic_by_category['subtypes']):
        ax = fig.add_subplot(gs[13 + subtype_idx])
        num_methods = len(data_logic_by_category['methods'])
        ax.bar(
            np.arange(num_methods),
            data_logic_by_category['result'][subtype_name],
            color=data_logic_by_category['colors'],
            label=data_logic_by_category['methods'],
        )

        ax.set_title(data_logic_by_category['subtypes'][subtype_idx], fontsize=36, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1])
        ax.set_xticks([])


    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/correctness_by_subcategory.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_brainteaser/plot_rewriting.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_rewriting_math = {
    'methods': [r'DeepSeek R1 Distill Llama 70B',
                r'deepseek-reasoner (Deepseek-R1)',
                r'OpenAI o3'],
    'colors': ['#8BCF8B', '#E9A6A1', '#3775BA'],
    'hatch_styles': ['', '|', '\\', '/', '-'],
    'fig1': ['Before rewriting', 'After rewriting'],
    'fig2': [r'correct $\rightarrow$ incorrect', r'incorrect $\rightarrow$ correct', 'same result'],
    'result': {
        'Before rewriting': np.array([7, 15, 17]) / 30,
        'After rewriting': np.array([10, 19, 22]) / 30,
        r'correct $\rightarrow$ incorrect': np.array([0, 2, 1]) / 30,
        r'incorrect $\rightarrow$ correct': np.array([3, 6, 6]) / 30,
        'same result': np.array([27, 22, 23]) / 30,
    },
}

if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(24, 12))

    gs = gridspec.GridSpec(2, 2)
    num_methods = len(data_rewriting_math['methods'])

    ax = fig.add_subplot(gs[0])
    width = 0.3
    for category_idx, category in enumerate(data_rewriting_math['fig1']):
        ax.bar(np.arange(num_methods) + width * category_idx * 1.1,
               data_rewriting_math['result'][category],
               width=width,
               label=category,
               color=data_rewriting_math['colors'],
               edgecolor='black',
               linewidth=2,
               hatch=data_rewriting_math['hatch_styles'][category_idx])
    ax.set_title('Correctness', fontsize=36, pad=0)
    ax.set_ylabel('Probability', fontsize=30, labelpad=12)
    ax.set_ylim([0, 1.01])
    ax.set_xticks([])

    ax = fig.add_subplot(gs[1])
    width = 0.25
    for category_idx, category in enumerate(data_rewriting_math['fig2']):
        ax.bar(np.arange(num_methods) + width * category_idx * 1.1,
               data_rewriting_math['result'][category],
               width=width,
               label=category,
               color=data_rewriting_math['colors'],
               edgecolor='black',
               linewidth=2,
               hatch=data_rewriting_math['hatch_styles'][category_idx + 2])
    ax.set_title('Change in Result', fontsize=36, pad=0)
    ax.set_ylabel('Probability', fontsize=30, labelpad=12)
    ax.set_ylim([0, 1.01])
    ax.set_xticks([])

    ax = fig.add_subplot(gs[2])
    bar = ax.bar(
        np.arange(num_methods),
        np.ones_like(np.arange(num_methods)),
        color=data_rewriting_math['colors'],
        edgecolor='black',
        linewidth=2,
        label=data_rewriting_math['methods'],
        hatch='',
    )
    handles, labels = ax.get_legend_handles_labels()
    for b in bar:
        b.remove()
    ax.legend(handles, labels, fontsize=30, loc='center', frameon=False)
    ax.set_axis_off()

    ax = fig.add_subplot(gs[3])
    subtypes = data_rewriting_math['fig1'] + data_rewriting_math['fig2']
    bar = ax.bar(
        np.arange(len(subtypes)),
        np.ones_like(np.arange(len(subtypes))),
        color='white',
        edgecolor='black',
        linewidth=2,
        label=subtypes,
        hatch=data_rewriting_math['hatch_styles'],
    )
    handles, labels = ax.get_legend_handles_labels()
    for b in bar:
        b.remove()
    ax.legend(handles, labels, fontsize=30, loc='center', frameon=False)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/rewriting.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_brainteaser/plot_selfcorrection_math.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_math_correcting_llm = {
    'methods': [r'DeepSeek R1 Distill Qwen 1.5B',
                r'DeepSeek R1 Distill Qwen 14B',
                r'DeepSeek R1 Distill Llama 70B',
                r'deepseek-chat (Deepseek-V3)',
                r'deepseek-reasoner (Deepseek-R1)',
                r'OpenAI o3'],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#3775BA'],
    'subtypes': [r'Fault denial$\downarrow$',
                 r'Error misattribution$\downarrow$',
                 r'Degenerate repetition or stuck$\downarrow$',
                 r'Flawed correction$\downarrow$',
                 r'Valid correction$\uparrow$'],
    'result': {
        r'Fault denial$\downarrow$': np.array([1, 0, 0, 1, 0, 0]) / 14 ,
        r'Error misattribution$\downarrow$': np.array([4, 4, 3, 1, 0, 1]) / 14 ,
        r'Degenerate repetition or stuck$\downarrow$': np.array([9, 2, 4, 0, 0, 0]) / 14 ,
        r'Flawed correction$\downarrow$': np.array([0, 1, 0, 1, 3, 2]) / 14 ,
        r'Valid correction$\uparrow$': np.array([0, 7, 7, 12, 11, 11]) / 14 ,
        },
}

data_math_correcting_human = {
    'methods': [r'DeepSeek R1 Distill Qwen 1.5B',
                r'DeepSeek R1 Distill Qwen 14B',
                r'DeepSeek R1 Distill Llama 70B',
                r'deepseek-chat (Deepseek-V3)',
                r'deepseek-reasoner (Deepseek-R1)',
                r'OpenAI o3'],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#F6CFCB', '#E9A6A1', '#3775BA'],
    'subtypes': [r'False confession$\downarrow$',
                 r'Degenerate repetition or stuck$\downarrow$',
                 r'Justified denial$\uparrow$'],
    'result': {
        r'False confession$\downarrow$': np.array([8, 10, 10, 13, 14, 12]) / 14 ,
        r'Degenerate repetition or stuck$\downarrow$': np.array([5, 3, 2, 0, 0, 0]) / 14 ,
        r'Justified denial$\uparrow$': np.array([0, 1, 2, 1, 0, 0]) / 14 ,
        },
}


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(36, 12))

    gs = gridspec.GridSpec(2, 5)

    for subtype_idx, subtype_name in enumerate(data_math_correcting_llm['subtypes']):
        ax = fig.add_subplot(gs[subtype_idx])
        num_methods = len(data_math_correcting_llm['methods'])
        ax.bar(
            np.arange(num_methods),
            data_math_correcting_llm['result'][subtype_name],
            color=data_math_correcting_llm['colors'],
            label=data_math_correcting_llm['methods'],
        )
        if subtype_idx == 0:
            handles, labels = ax.get_legend_handles_labels()

        ax.set_title(data_math_correcting_llm['subtypes'][subtype_idx], fontsize=30, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1])
        ax.set_xticks([])

    for subtype_idx, subtype_name in enumerate(data_math_correcting_human['subtypes']):
        ax = fig.add_subplot(gs[5 + subtype_idx])
        num_methods = len(data_math_correcting_human['methods'])
        ax.bar(
            np.arange(num_methods),
            data_math_correcting_human['result'][subtype_name],
            color=data_math_correcting_human['colors'],
            label=data_math_correcting_human['methods'],
        )

        ax.set_title(data_math_correcting_human['subtypes'][subtype_idx], fontsize=30, pad=36)
        ax.set_ylabel('Probability', fontsize=30, labelpad=12)
        ax.set_ylim([0, 1])
        ax.set_xticks([])

    ax = fig.add_subplot(gs[8:])
    ax.legend(handles, labels, fontsize=30, loc='center', frameon=False)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/selfcorrection_math.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_CellSpliceNet/plot_ablation.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_ablation = {
    'methods': [
        r'CellSpliceNet',
        r'No Expression',
        r'No Structure',
        r'No ROI',
        r'No Sequence',
    ],
    'colors': ['#0F4D92', '#B4E6B4', '#AFE6E6', '#FFE080', '#D3D3D3'],
    'result': np.array([0.88, 0.84, 0.82, 0.81, 0.74]),
}

def is_dark(color_in_hex, threshold=128):
    color = color_in_hex.lstrip('#')
    r = int(color[0:2], 16)
    g = int(color[2:4], 16)
    b = int(color[4:6], 16)

    luminance = 0.299*r + 0.587*g + 0.114*b
    return luminance < threshold


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(13, 13))

    ax = fig.add_subplot(1, 1, 1)
    num_methods = len(data_ablation['methods'])
    bars = ax.bar(
        np.arange(num_methods),
        data_ablation['result'],
        color=data_ablation['colors'],
        label=data_ablation['methods'],
    )

    for i, (bar, value) in enumerate(zip(bars, data_ablation['result'])):
        textcolor = 'white' if is_dark(data_ablation['colors'][i]) else 'black'
        ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() - 0.08,
            f'{value:.2f}', ha='center', va='bottom', fontsize=32, color=textcolor)

    # Add horizontal reference line at the first bar
    baseline = data_ablation['result'][0]  # 0.88
    ax.axhline(y=baseline, color=data_ablation['colors'][0], linestyle='--', linewidth=4, alpha=0.7)

    # Add arrows and reduction values for bars 2-5 (skip the first bar)
    for i in range(1, num_methods):
        bar = bars[i]
        current_value = data_ablation['result'][i]
        reduction = baseline - current_value

        # Position for the arrow (right side of the bar)
        x_pos = bar.get_x() + bar.get_width()

        # Draw arrow from baseline down to bar top (top to bottom)
        ax.annotate('', xy=(x_pos, current_value), xytext=(x_pos, baseline),
                    arrowprops=dict(arrowstyle='->', color='red', lw=4))

        # Add reduction text near the top (at baseline level)
        ax.text(x_pos - 0.3, baseline + 0.005, r'$-$'+f'{reduction:.2f}',
                ha='left', va='bottom', fontsize=24, color='red')

    ax.set_ylabel('Spearman correlation', fontsize=54, labelpad=12)
    ymax = np.max(data_ablation['result'][:])
    ax.set_ylim([0.0, ymax + 0.5])
    ax.set_yticks([0.0, 0.25, 0.50, 0.75, 1.0])
    ax.tick_params(axis='y', labelsize=36, length=10, width=2)
    ax.set_xticks([])

    ax.legend(bbox_to_anchor=(0.50, 1.08), loc='upper left', fontsize=36, frameon=False)

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/ablation.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_CellSpliceNet/plot_comparison_cross_species.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_comparison = {
    'methods': [
        r'CellSpliceNet',
        r'Pangolin',
        r'SpliceTransformer',
        r'SpliceAI',
        r'SpliceFinder',
        r'ViT',
        r'AlphaGenome',
        r'ESM2',
    ],
    'colors': ['#0F4D92', '#D4685F', '#DA7B73', '#DF8E87', '#E5A19B', '#EBB4AF', '#F1C7C3', '#F6DAD8', '#FCEEED'],
    'metrics': [r'Spearman correlation', r'Pearson correlation', r'R$^2$ score'],
    'result_worm': {
        r'Spearman correlation': np.array([
            [0.909, 0.872, 0.906],
            [0.813, 0.825, 0.832],
            [0.786, 0.799, 0.815],
            [0.751, 0.715, 0.753],
            [0.725, 0.715, 0.752],
            [0.689, 0.722, 0.705],
            [0.637, 0.637, 0.636],
            [0.642, 0.649, 0.596],
        ]),
        r'Pearson correlation': np.array([
            [0.929, 0.863, 0.918],
            [0.846, 0.867, 0.870],
            [0.827, 0.844, 0.862],
            [0.751, 0.708, 0.768],
            [0.728, 0.721, 0.761],
            [0.694, 0.723, 0.713],
            [0.646, 0.648, 0.645],
            [0.616, 0.635, 0.599],
        ]),
        r'R$^2$ score': np.array([
            [0.863, 0.745, 0.843],
            [0.634, 0.672, 0.689],
            [0.604, 0.609, 0.714],
            [0.542, 0.483, 0.532],
            [0.354, 0.359, 0.440],
            [0.375, 0.415, 0.347],
            [0.399, 0.401, 0.401],
            [0.353, 0.387, 0.348],
        ]),
    },
    'result_human': {
        r'Spearman correlation': np.array([
            [0.512, 0.475, 0.461],
            [0.030, -0.002, -0.007],
            [-0.002, -0.016, -0.015],
            [0.318, 0.299, 0.319],
            [0.083, -0.011, 0.060],
            [0.056, 0.034, 0.050],
            [0.306, 0.297, 0.296],
            [0.325, 0.308, 0.316],
        ]),
        r'Pearson correlation': np.array([
            [0.479, 0.484, 0.449],
            [0.036, -0.002, -0.002],
            [0.004, -0.017, 0.007],
            [0.344, 0.327, 0.348],
            [0.110, -0.013, 0.060],
            [0.096, 0.080, 0.043],
            [0.342, 0.331, 0.330],
            [0.355, 0.340, 0.343],
        ]),
        r'R$^2$ score': np.array([
            [0.229, 0.235, 0.201],
            [-0.013, -0.037, -0.052],
            [-0.005, -0.004, -0.004],
            [0.110, 0.100, 0.114],
            [0.003, -0.002, -0.003],
            [0.000, -0.003, -0.001],
            [0.099, 0.103, 0.095],
            [0.123, 0.108, 0.114],
        ]),
    }
}

def is_dark(color_in_hex, threshold=128):
    color = color_in_hex.lstrip('#')
    r = int(color[0:2], 16)
    g = int(color[2:4], 16)
    b = int(color[4:6], 16)

    luminance = 0.299*r + 0.587*g + 0.114*b
    return luminance < threshold


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(45, 12))

    gs = gridspec.GridSpec(1, 3)

    for metric_idx, metric_name in enumerate(data_comparison['metrics']):
        ax = fig.add_subplot(gs[metric_idx])

        num_methods = len(data_comparison['methods'])
        bars = ax.bar(
            np.arange(num_methods),
            data_comparison['result_worm'][metric_name].mean(axis=1),
            yerr=data_comparison['result_worm'][metric_name].std(axis=1),
            error_kw={
                'elinewidth': 2,   # thickness of vertical error bar line
                'capthick': 2,     # thickness of caps
                'capsize': 15      # length of caps
            },
            color=data_comparison['colors'],
            label=data_comparison['methods'],
        )

        for i, (bar, value, value_std) in enumerate(zip(bars, data_comparison['result_worm'][metric_name].mean(axis=1), data_comparison['result_worm'][metric_name].std(axis=1))):
            ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + value_std + 0.02,
                f'{value:.2f}', ha='center', va='bottom', fontsize=32, color='black')

        ax.set_ylabel(metric_name, fontsize=54, labelpad=12)
        ymax = np.max(data_comparison['result_worm'][metric_name])
        ax.set_ylim([0.0, ymax + 0.5])
        ax.set_xticks([])
        ax.set_yticks([0.00, 0.25, 0.50, 0.75, 1.00])
        ax.tick_params(axis='y', labelsize=36, length=10, width=2)

        ax.legend(bbox_to_anchor=(0.02, 1.08), loc='upper left', fontsize=36, frameon=False, ncols=2, columnspacing=0.6)

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/comparison_worm.png', dpi=300)
    plt.close(fig)


    fig = plt.figure(figsize=(45, 12))

    gs = gridspec.GridSpec(1, 3)

    for metric_idx, metric_name in enumerate(data_comparison['metrics']):
        ax = fig.add_subplot(gs[metric_idx])

        num_methods = len(data_comparison['methods'])
        bars = ax.bar(
            np.arange(num_methods),
            data_comparison['result_human'][metric_name].mean(axis=1),
            yerr=data_comparison['result_human'][metric_name].std(axis=1),
            error_kw={
                'elinewidth': 2,   # thickness of vertical error bar line
                'capthick': 2,     # thickness of caps
                'capsize': 15      # length of caps
            },
            color=data_comparison['colors'],
            label=data_comparison['methods'],
        )

        for i, (bar, value, value_std) in enumerate(zip(bars, data_comparison['result_human'][metric_name].mean(axis=1), data_comparison['result_human'][metric_name].std(axis=1))):
            ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + value_std + 0.02,
                f'{value:.2f}', ha='center', va='bottom', fontsize=32, color='black')

        ax.set_ylabel(metric_name, fontsize=54, labelpad=12)
        ymax = np.max(data_comparison['result_human'][metric_name])
        ax.set_ylim([-0.08, ymax + 0.5])
        ax.set_xticks([])
        ax.set_yticks([-0.00, 0.25, 0.50, 0.75, 1.00])
        ax.tick_params(axis='y', labelsize=36, length=10, width=2)

        ax.legend(bbox_to_anchor=(0.02, 1.08), loc='upper left', fontsize=36, frameon=False, ncols=2, columnspacing=0.6)

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/comparison_human.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_CellSpliceNet/plot_comparison.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_ablation = {
    'methods': [
        r'CellSpliceNet',
        r'ViT',
        r'SpliceFinder',
        r'Pangolin',
        r'SpliceTransformer',
        r'SpliceAI',
        r'ESM2',
    ],
    'colors': ['#0F4D92', "#F09F97", "#F1B3AC", "#EFBEB8", "#F0CDC8", "#F3D9D8", '#FCEEED'],
    'metrics': [r'Spearman correlation', r'Pearson correlation', r'R$^2$ score'],
    'result': {
        # r'Spearman correlation': np.array([0.88, 0.81, 0.80, 0.79, 0.77, 0.71, 0.606]),
        # r'Pearson correlation': np.array([0.88, 0.81, 0.80, 0.79, 0.77, 0.72, 0.613]),
        # r'R$^2$ score': np.array([0.77, 0.66, 0.64, 0.62, 0.59, 0.52, 0.369]),
        r'Spearman correlation': np.array([
            [0.88, 0.88, 0.88],  # TODO: update!
            [0.81, 0.81, 0.81],  # TODO: update!
            [0.806, 0.793, 0.794],
            [0.792, 0.785, 0.788],
            [0.765, 0.765, 0.767],
            [0.714, 0.716, 0.706],
            [0.598, 0.625, 0.594],
        ]),
        r'Pearson correlation': np.array([
            [0.88, 0.88, 0.88],  # TODO: update!
            [0.81, 0.81, 0.81],  # TODO: update!
            [0.812, 0.798, 0.801],
            [0.792, 0.785, 0.788],
            [0.765, 0.765, 0.766],
            [0.722, 0.722, 0.714],
            [0.604, 0.631, 0.605],
        ]),
        r'R$^2$ score': np.array([
            [0.77, 0.77, 0.77],  # TODO: update!
            [0.66, 0.66, 0.66],  # TODO: update!
            [0.658, 0.632, 0.642],
            [0.633, 0.615, 0.627],
            [0.585, 0.585, 0.586],
            [0.518, 0.520, 0.507],
            [0.359, 0.384, 0.364],
        ]),
    }
}

def is_dark(color_in_hex, threshold=128):
    color = color_in_hex.lstrip('#')
    r = int(color[0:2], 16)
    g = int(color[2:4], 16)
    b = int(color[4:6], 16)

    luminance = 0.299*r + 0.587*g + 0.114*b
    return luminance < threshold


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(45, 12))

    gs = gridspec.GridSpec(1, 3)

    for metric_idx, metric_name in enumerate(data_ablation['metrics']):
        ax = fig.add_subplot(gs[metric_idx])

        num_methods = len(data_ablation['methods'])
        bars = ax.bar(
            np.arange(num_methods),
            data_ablation['result'][metric_name].mean(axis=1),
            yerr=data_ablation['result'][metric_name].std(axis=1),
            error_kw={
                'elinewidth': 2,   # thickness of vertical error bar line
                'capthick': 2,     # thickness of caps
                'capsize': 15      # length of caps
            },
            color=data_ablation['colors'],
            label=data_ablation['methods'],
        )

        for i, (bar, value) in enumerate(zip(bars, data_ablation['result'][metric_name].mean(axis=1))):
            textcolor = 'white' if is_dark(data_ablation['colors'][i]) else 'black'
            ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() - 0.10,
                f'{value:.2f}', ha='center', va='bottom', fontsize=32, color=textcolor)

        ax.set_ylabel(metric_name, fontsize=54, labelpad=12)
        ymax = np.max(data_ablation['result'][metric_name])
        ax.set_ylim([0.0, ymax + 0.5])
        ax.set_xticks([])
        ax.set_yticks([0.00, 0.25, 0.50, 0.75, 1.00])
        ax.tick_params(axis='y', labelsize=36, length=10, width=2)

        ax.legend(bbox_to_anchor=(0.02, 1.08), loc='upper left', fontsize=38, frameon=False, ncols=2, columnspacing=0.6)

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/comparison.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_Cflows/diffusion_swiss_roll.py
import numpy as np
import matplotlib.pyplot as plt
from scipy.spatial.distance import pdist, squareform

# Generate Swiss Roll data
def generate_swiss_roll_2d(n_samples=80, noise=0.1):
    t = 1.5 * np.pi * (1 + 2 * np.random.rand(n_samples))
    x = t * np.cos(t)
    z = t * np.sin(t)

    # Add some noise
    x += noise * np.random.randn(n_samples)
    z += noise * np.random.randn(n_samples)

    return x, z, t

# Compute diffusion matrix (transition probabilities)
def compute_diffusion_matrix(x, z, t, sigma=1.0):
    # Order points by manifold parameter t for better matrix visualization
    sorted_indices = np.argsort(t)
    x_sorted = x[sorted_indices]
    z_sorted = z[sorted_indices]
    t_sorted = t[sorted_indices]

    # Compute pairwise distances in ambient space
    points = np.column_stack([x_sorted, z_sorted])
    distances = squareform(pdist(points))

    # Compute manifold distances (along parameter t)
    t_distances = np.abs(t_sorted[:, None] - t_sorted[None, :])

    # Combine spatial and manifold distances (emphasize manifold structure)
    combined_distances = distances + 0.5 * t_distances

    # Gaussian kernel for transition probabilities
    P = np.exp(-combined_distances**2 / (2 * sigma**2))

    # Make matrix sparser by thresholding small values
    P[P < 0.01] = 0

    # Normalize rows to make it a proper transition matrix
    row_sums = P.sum(axis=1)
    row_sums[row_sums == 0] = 1  # Avoid division by zero
    P = P / row_sums[:, None]

    return P, sorted_indices

if __name__ == '__main__':
    # Create two subplots
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8))

    # Generate swiss roll point cloud (smaller for visualization clarity)
    x, z, t = generate_swiss_roll_2d(n_samples=500, noise=0.5)

    # Compute diffusion matrix
    P, sorted_indices = compute_diffusion_matrix(x, z, t, sigma=2)

    # Left plot: Diffusion Matrix
    im = ax1.imshow(P, cmap='Reds', aspect='equal', origin='upper')
    ax1.axis('off')
    ax1.set_facecolor('white')

    # Right plot: Swiss Roll with probability-weighted connections
    # Use original (unsorted) coordinates for the swiss roll plot
    x_orig, z_orig, t_orig = x, z, t

    # Draw line segments between points with opacity = transition probability
    threshold = 0.02  # Only draw lines above this probability threshold

    for i in range(len(x_orig)):
        for j in range(i+1, len(x_orig)):
            # Find corresponding indices in sorted matrix
            orig_i_in_sorted = np.where(sorted_indices == i)[0][0]
            orig_j_in_sorted = np.where(sorted_indices == j)[0][0]

            # Get transition probability from matrix
            prob = max(P[orig_i_in_sorted, orig_j_in_sorted], P[orig_j_in_sorted, orig_i_in_sorted])

            if prob > threshold:
                ax2.plot([x_orig[i], x_orig[j]], [z_orig[i], z_orig[j]],
                        color='black', linewidth=2, alpha=prob*2, zorder=1)

    # Plot the swiss roll points on top
    scatter = ax2.scatter(x_orig, z_orig, c=t_orig, cmap='viridis', s=100,
                          alpha=0.5, edgecolors='white', linewidth=1, zorder=2)

    # Styling for swiss roll plot
    ax2.set_aspect('equal')
    ax2.axis('off')
    ax2.set_facecolor('white')

    # Clean overall styling
    fig.patch.set_facecolor('white')
    plt.subplots_adjust(left=0, right=1, top=1, bottom=0, wspace=0.02)

    plt.savefig('figures/diffusion_swiss_roll.png', dpi=300, bbox_inches='tight',
                facecolor='white', edgecolor='none', pad_inches=1)
assets/figures4papers/figure_Cflows/plot_comparison_Ablation.py
import os
import numpy as np
from matplotlib import pyplot as plt


data_comparison_Ablation = {
    'methods': [r'OT-CFM', r'SB-CFM', r'SF2M', r'Cflows (w/o growth + energy)', r'Cflows (w/o growth)', r'Cflows'],
    'colors': ['#AADCA9', '#8BCF8B', '#E9A6A1', '#B8C9E5', '#7097CA', '#3775BA'],
    'metrics': [r'RMSE$\downarrow$', r'MAE$\downarrow$', r'PCC$\uparrow$', r'SCC$\uparrow$'],
    'mean': {
        r'RMSE$\downarrow$': np.array([0.94, 1.05, 0.99, 0.89, 0.75, 0.62]),
        r'MAE$\downarrow$': np.array([0.75, 0.85, 0.78, 0.70, 0.59, 0.48]),
        r'PCC$\uparrow$': np.array([0.53, 0.49, 0.55, 0.58, 0.65, 0.72]),
        r'SCC$\uparrow$': np.array([0.50, 0.47, 0.52, 0.55, 0.68, 0.70]),
    },
    'std': {
        r'RMSE$\downarrow$': np.array([0.08, 0.09, 0.09, 0.07, 0.06, 0.05]),
        r'MAE$\downarrow$': np.array([0.07, 0.08, 0.07, 0.06, 0.05, 0.04]),
        r'PCC$\uparrow$': np.array([0.04, 0.02, 0.01, 0.03, 0.02, 0.02]),
        r'SCC$\uparrow$': np.array([0.03, 0.02, 0.03, 0.03, 0.03, 0.01]),
    },
}


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(35, 7))

    num_methods = len(data_comparison_Ablation['methods'])
    for metric_idx, metric_name in enumerate(data_comparison_Ablation['metrics']):
        ax = fig.add_subplot(1, 5, metric_idx + 1)

        ax.bar(
            np.arange(num_methods),
            data_comparison_Ablation['mean'][metric_name],
            yerr=data_comparison_Ablation['std'][metric_name],
            capsize=8,
            error_kw={'capthick': 2},
            color=data_comparison_Ablation['colors'],
            label=data_comparison_Ablation['methods'],
        )

        if metric_idx == 0:
            handles, labels = ax.get_legend_handles_labels()

        ax.set_xticks([])
        ax.set_ylabel(data_comparison_Ablation['metrics'][metric_idx], fontsize=36, labelpad=12)
        ax.ticklabel_format(axis='y', style='sci', scilimits=(0, 0))

    ax = fig.add_subplot(1, 5, 5)
    ax.legend(handles, labels, fontsize=30, loc='lower left', frameon=False)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/figX_comparison_Ablation.png', dpi=300)
    fig.savefig('./figures/figX_comparison_Ablation.pdf', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_Cflows/plot_comparison_GeneRegulatory.py
import os
import numpy as np
from matplotlib import pyplot as plt


data_comparison_GeneRegulatory = {
    'methods': [r'OCE', r'PC', r'mTE', r'mMI', r'NRI', r'DCRNN', r'GTS', r'NIR', r'GC (ours)'],
    'colors': ['#D0A3A3', '#EFE7B1', '#F4C2C2', '#D7C4E2', '#E5C09F', '#A8C6C2', '#B7D3B0', '#F5B5A0', '#3775BA'],
    'metric': 'Graph Edit Distance',
    'datasets': [
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (100, 137)',
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (150, 329)',
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (200, 507)',
    ],
    'mean': {
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (100, 137)':
            np.array([138.6, 140.4, 126.4, 51.2, 72.1, 158.14, 215.4, 62.7, 51.2]),
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (150, 329)':
            np.array([293.4, 317.2, 261.0, 99.8, 106.6, 303.79, 347.2, 86.3, 109.0]),
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (200, 507)':
            np.array([449.8, 495.6, 397.4, 162.8, 219.8, 508.25, 481.8, 159.2, 158.8]),
    },
    'std': {
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (100, 137)':
            np.array([3.5, 3.9, 2.4, 3.3, 6.2, 8.6, 13.8, 3.2, 3.3]),
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (150, 329)':
            np.array([2.9, 3.7, 2.2, 4.0, 5.4, 12.4, 19.3, 2.8, 6.4]),
        r'($|\mathcal{V}|$, $|\mathcal{E}|$) = (200, 507)':
            np.array([1.1, 6.5, 8.8, 6.2, 13.4, 23.6, 7.0, 11.6, 12.6]),
    },
}


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(36, 6))

    num_methods = len(data_comparison_GeneRegulatory['methods'])
    for dataset_idx, dataset_name in enumerate(data_comparison_GeneRegulatory['datasets']):
        ax = fig.add_subplot(1, 4, dataset_idx + 1)

        ax.bar(
            np.arange(num_methods),
            data_comparison_GeneRegulatory['mean'][dataset_name],
            yerr=data_comparison_GeneRegulatory['std'][dataset_name],
            capsize=8,
            error_kw={'capthick': 2},
            color=data_comparison_GeneRegulatory['colors'],
            label=data_comparison_GeneRegulatory['methods'],
        )

        if dataset_idx == 0:
            handles, labels = ax.get_legend_handles_labels()

        ax.set_xticks([])
        ax.set_ylabel(data_comparison_GeneRegulatory['metric'], fontsize=36, labelpad=12)
        ax.set_xlabel(dataset_name, fontsize=36, labelpad=12)
        ax.ticklabel_format(axis='y', style='sci', scilimits=(0, 0))

    ax = fig.add_subplot(1, 4, 4)
    ax.legend(handles, labels, fontsize=30, loc='lower left', ncols=2, frameon=False)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/fig2_comparison_GeneRegulatory.png', dpi=300)
    fig.savefig('./figures/fig2_comparison_GeneRegulatory.pdf', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_Cflows/plot_comparison_Trajectory.py
import os
import numpy as np
from matplotlib import pyplot as plt


data_comparison_Trajectory = {
    'methods': [r'TrajectoryNet', r'OT-CFM', r'SB-CFM', r'BEMIOflow (ours)'],
    'colors': ['#DDF3DE', '#AADCA9', '#8BCF8B', '#3775BA'],
    'metrics': ['PHATE Space RMSE', 'Gene Space RMSE', 'Interpolation EMD'],
    'datasets': ['Bifurcation', 'Cycle', 'Unidirectional'],
    'mean': {
        'PHATE Space RMSE': {
            'Bifurcation': np.array([6.16, 2.98, 2.83, 2.60]) * 1e-3,
            'Cycle': np.array([2.49, 3.79, 1.58, 0.777]) * 1e-3,
            'Unidirectional': np.array([8.08, 5.33, 5.67, 3.40]) * 1e-3,
        },
        'Gene Space RMSE': {
            'Bifurcation': np.array([0.150, 0.0842, 0.0835, 0.0773]),
            'Cycle': np.array([0.151, 0.209, 0.141, 0.118]),
            'Unidirectional': np.array([0.118, 0.0960, 0.0978, 0.0853]),
        },
        'Interpolation EMD': {
            'Bifurcation': np.array([1.07, 0.495, 0.516, 0.465]) * 1e-2,
            'Cycle': np.array([0.710, 0.818, 0.526, 0.465]) * 1e-2,
            'Unidirectional': np.array([1.50, 1.32, 1.28, 0.935]) * 1e-2,
        },
    },
}


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    fig = plt.figure(figsize=(36, 6))

    num_methods = len(data_comparison_Trajectory['methods'])
    for metric_idx, metric_name in enumerate(data_comparison_Trajectory['metrics']):
        ax = fig.add_subplot(1, 4, metric_idx + 1)
        xtick_list = []

        for dataset_idx, dataset_name in enumerate(data_comparison_Trajectory['datasets']):

            ax.bar(
                np.arange(num_methods) + dataset_idx * (num_methods + 1),
                data_comparison_Trajectory['mean'][metric_name][dataset_name],
                color=data_comparison_Trajectory['colors'],
                label=data_comparison_Trajectory['methods'],
            )

            xtick_list.append(np.mean(np.arange(num_methods)) + dataset_idx * (num_methods + 1))

            if dataset_idx == 0:
                handles, labels = ax.get_legend_handles_labels()

        ax.set_xticks(xtick_list)
        ax.set_xticklabels(data_comparison_Trajectory['datasets'])
        ax.set_ylabel(data_comparison_Trajectory['metrics'][metric_idx], fontsize=36, labelpad=12)
        ax.set_xlabel('Dataset', fontsize=36, labelpad=12)
        ax.ticklabel_format(axis='y', style='sci', scilimits=(0, 0))

    ax = fig.add_subplot(1, 4, 4)
    ax.legend(handles, labels, fontsize=30, loc='lower left', frameon=False)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/fig2_comparison_Trajectory.png', dpi=300)
    fig.savefig('./figures/fig2_comparison_Trajectory.pdf', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_Dispersion/plot_idea.py
import os
import numpy as np
import matplotlib.pyplot as plt


EPSILON = 1e-6

def sample_points_in_ball(num_points, theta_range=2*np.pi):
    r = np.sqrt(np.random.uniform(0, 0.95, num_points))
    theta = np.random.uniform(np.pi/2 - theta_range/2, np.pi/2 + theta_range/2, num_points)
    x = r * np.cos(theta)
    y = r * np.sin(theta)
    return np.stack([x, y], axis=1)

def plot_ball_with_points(ax, pts, facecolor):
    num_points_grid = 512

    xs = np.linspace(-1, 1, num_points_grid)
    ys = np.linspace(-1, 1, num_points_grid)
    x, y = np.meshgrid(xs, ys)
    r2 = x**2 + y**2
    mask = r2 <= 1.0
    z = np.zeros_like(x)
    z[mask] = np.sqrt(1.0 - r2[mask])

    nx, ny, nz = x.copy(), y.copy(), z.copy()
    norm = np.sqrt(nx**2 + ny**2 + nz**2) + EPSILON
    nx, ny, nz = nx / norm, ny / norm, nz / norm

    # light from top left.
    light_dir = np.array([-0.5, 0.5, 0.8])
    light_dir /= np.linalg.norm(light_dir)
    intensity = np.maximum(0.0, nx*light_dir[0] + ny*light_dir[1] + nz*light_dir[2])
    shade = np.clip(-0.5 + 2.0*intensity, 0, 1)

    img = np.ones((num_points_grid, num_points_grid))
    img[mask] = shade[mask]

    ax.imshow(img, cmap='gray', origin='lower', extent=[-1, 1, -1, 1], vmin=0, vmax=1, alpha=0.3)
    ax.set_xlim([-2, 2])
    ax.set_ylim([-2, 2])
    ax.set_axis_off()

    # add points on sphere.
    ax.scatter(pts[:, 0], pts[:, 1], s=80, facecolor=facecolor, edgecolor='black', alpha=0.8)
    for i in range(pts.shape[0]):
        ax.plot([pts[i, 0], 0], [pts[i, 1], 0],
                linestyle="--", color="black", linewidth=1, alpha=0.8)

    return ax


if __name__ == "__main__":
    save_path = './figures/idea.png'
    num_points = 16

    np.random.seed(1)
    plt.rcParams['text.usetex'] = True
    plt.rcParams['font.family'] = 'sans-serif'
    fig = plt.figure(figsize=(18, 6))

    ax = fig.add_subplot(1, 3, 1)
    pts = sample_points_in_ball(num_points=num_points)
    plot_ball_with_points(ax, pts, facecolor='#cde5f8')

    ax = fig.add_subplot(1, 3, 2)
    pts = sample_points_in_ball(num_points=num_points, theta_range=np.pi/4)
    plot_ball_with_points(ax, pts, facecolor='#6a98cb')

    ax = fig.add_subplot(1, 3, 3)
    pts = sample_points_in_ball(num_points=num_points)
    plot_ball_with_points(ax, pts, facecolor='#6a98cb')

    os.makedirs(os.path.dirname(save_path), exist_ok=True)
    fig.tight_layout(pad=2)
    fig.savefig(save_path, dpi=300)
assets/figures4papers/figure_Dispersion/plot_illustration.py
import os
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from matplotlib.patches import FancyArrowPatch
from mpl_toolkits.mplot3d import proj3d
import matplotlib.cm as cm


EPSILON = 1e-6

def pairwise_sqdist(X: np.ndarray) -> np.ndarray:
    X2 = np.sum(X**2, axis=1, keepdims=True)
    D2 = X2 + X2.T - 2 * (X @ X.T)
    return np.maximum(D2, 0.0)

def safe_scale(V, s=0.6, eps=EPSILON):
    n = np.linalg.norm(V, axis=1, keepdims=True)
    return V / (eps + n) / s

def nice_axes(ax, L):
    y_scale = 1
    z_scale = 1.2
    ax.quiver(0, 0, 0, 0, -L, 0, color="black", linewidth=2, arrow_length_ratio=0.1)
    ax.quiver(0, 0, 0, y_scale*L, 0, 0, color="black", linewidth=2, arrow_length_ratio=0.1)
    ax.quiver(0, 0, 0, 0, 0, z_scale*L, color="black", linewidth=2, arrow_length_ratio=0.1)
    ax.text(0, -L*1.2, 0, "x", color="black", fontsize=36)
    ax.text(y_scale*L*1.05, -0.2, 0, "y", color="black", fontsize=36)
    ax.text(-0.2, 0, z_scale*L*1.05, "z", color="black", fontsize=36)
    ax.grid(False)
    ax.xaxis.pane.set_visible(False)
    ax.yaxis.pane.set_visible(False)
    ax.zaxis.pane.set_visible(False)
    ax.xaxis.line.set_color((1, 1, 1, 0))
    ax.yaxis.line.set_color((1, 1, 1, 0))
    ax.zaxis.line.set_color((1, 1, 1, 0))
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_zticks([])
    return ax

def _to3d_xy(xy):
    x, y = xy[:, 0], xy[:, 1]
    z = np.sqrt(np.clip(1.0 - x*x - y*y, 0.0, 1.0))
    P = np.stack([x, y, z], axis=1)
    # normalize (robust against tiny numerical drift)
    P /= np.linalg.norm(P, axis=1, keepdims=True) + EPSILON
    return P

def _slerp_arc(p, q, n=200):
    p = p / (np.linalg.norm(p) + EPSILON)
    q = q / (np.linalg.norm(q) + EPSILON)
    dot = np.clip(np.dot(p, q), -1.0, 1.0)
    theta = np.arccos(dot)
    if theta < EPSILON:  # nearly identical points
        return np.repeat(p[None, :], n, axis=0)
    # great-circle via SLERP
    t = np.linspace(0.0, 1.0, n)
    s = np.sin
    arc = (s((1-t)*theta)[:,None]*p + s(t*theta)[:,None]*q) / (s(theta) + EPSILON)
    # normalize for safety
    arc /= np.linalg.norm(arc, axis=1, keepdims=True) + EPSILON
    return arc

def draw_geodesic(ax, a2d, b2d, linestyle='-', draw_arrow=True, alpha=0.8,
                  num_points_grid=300, color='blue', lw=2.0, arrow_scale=30, shorten=0.04):
    A3, B3 = _to3d_xy(np.array([a2d])), _to3d_xy(np.array([b2d]))
    arc = _slerp_arc(A3[0], B3[0], n=num_points_grid)
    x_full, y_full = arc[:, 0], arc[:, 1]

    if draw_arrow:
        k0 = int(2 * shorten * num_points_grid)
        k1 = num_points_grid - k0
    else:
        k0 = int(shorten * num_points_grid)
        k1 = num_points_grid - k0
    x, y = x_full[k0:k1], y_full[k0:k1]
    ax.plot(x, y, color=color, lw=lw, solid_capstyle='round', alpha=alpha, linestyle=linestyle)

    if draw_arrow:
        # Add arrowheads at both ends
        k0 = int(shorten * num_points_grid)
        k1 = num_points_grid - k0
        x, y = x_full[k0:k1], y_full[k0:k1]
        arrow1 = FancyArrowPatch(
            (x[1], y[1]), (x[0], y[0]),
            arrowstyle='-|>', color=color,
            mutation_scale=arrow_scale, lw=0
        )
        arrow2 = FancyArrowPatch(
            (x[-2], y[-2]), (x[-1], y[-1]),
            arrowstyle='-|>', color=color,
            mutation_scale=arrow_scale, lw=0
        )
        ax.add_patch(arrow1)
        ax.add_patch(arrow2)
    return ax

class Arrow3D(FancyArrowPatch):
    def __init__(self, xs, ys, zs, *args, **kwargs):
        super().__init__((0, 0), (0, 0), *args, **kwargs)
        self._verts3d = xs, ys, zs

    def do_3d_projection(self, renderer=None):
        xs3d, ys3d, zs3d = self._verts3d
        xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, self.axes.get_proj())
        self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
        return np.min(zs)

def plot_decorrelation(ax):
    num_points_grid = 512
    num_points_on_ellipsoid = 18
    image_scale = 2

    xs = np.linspace(-2, 2, num_points_grid)
    ys = np.linspace(-2, 2, num_points_grid)
    x, y = np.meshgrid(xs, ys)

    r2 = x**2 + y**2
    mask_s = r2 <= 1.0
    z_s = np.zeros_like(x)
    z_s[mask_s] = np.sqrt(1.0 - r2[mask_s])

    nx, ny, nz = x.copy(), y.copy(), z_s.copy()
    nrm = np.sqrt(nx**2 + ny**2 + nz**2) + EPSILON
    nx, ny, nz = nx/nrm, ny/nrm, nz/nrm

    light_dir = np.array([-0.5, -0.5, 0.8])
    light_dir /= np.linalg.norm(light_dir)
    intensity = np.maximum(0.0, nx*light_dir[0] + ny*light_dir[1] + nz*light_dir[2])

    img_s = np.ones_like(x)
    img_s[mask_s] = np.clip(0.2 + 0.9*intensity[mask_s], 0, 1)
    ax.imshow(img_s, cmap='gray',
              extent=[-image_scale, image_scale, -image_scale, image_scale],
              vmin=0, vmax=1, alpha=1)

    a, b, c = 1.50, 0.60, 0.40
    theta = np.deg2rad(30)
    ct, st = np.cos(theta), np.sin(theta)

    xL =  ct*x + st*y
    yL = -st*x + ct*y
    vL = (xL/a)**2 + (yL/b)**2
    mask_e = vL <= 1.0
    zL = np.zeros_like(xL)
    zL[mask_e] = c * np.sqrt(1.0 - vL[mask_e])

    nxL = xL/(a*a)
    nyL = yL/(b*b)
    nzL = np.zeros_like(zL)
    nzL[mask_e] = zL[mask_e]/(c*c)
    nrm = np.sqrt(nxL**2 + nyL**2 + nzL**2) + EPSILON
    nxL, nyL, nzL = nxL/nrm, nyL/nrm, nzL/nrm
    nxE = ct*nxL - st*nyL
    nyE = st*nxL + ct*nyL
    nzE = nzL

    light_dir = np.array([0.5, 0.5, -0.8])
    light_dir /= np.linalg.norm(light_dir)
    intensity_e = np.maximum(0.0, nxE*light_dir[0] + nyE*light_dir[1] + nzE*light_dir[2])
    img_e = np.full_like(x, np.nan, dtype=float)
    img_e[mask_e] = np.clip(0.5 + 0.9*intensity_e[mask_e], 0, 1)
    cmap = cm.Blues.copy()
    cmap.set_bad(color="white")
    ax.imshow(img_e, cmap=cmap, origin='lower',
              extent=[-image_scale, image_scale, -image_scale, image_scale],
              vmin=0, vmax=1, alpha=0.4)

    # Pick points on ellipsoid uniformly.
    phi = np.linspace(0, 2*np.pi, num_points_on_ellipsoid, endpoint=False)

    # Ellipsoid rim in local coords.
    xL_rim = a*np.cos(phi)
    yL_rim = b*np.sin(phi)
    zL_rim = np.zeros_like(phi)

    # rotate back to world
    xw = ct*xL_rim - st*yL_rim
    yw = st*xL_rim + ct*yL_rim
    zw = zL_rim
    Pellip = np.stack([xw, yw, zw], axis=1)

    # matching sphere rim points: same world direction in xy, unit radius, z=0
    rxy = np.sqrt(xw**2 + yw**2) + EPSILON
    Psphere = np.stack([xw/rxy, yw/rxy, np.zeros_like(rxy)], axis=1)

    ax.scatter(Pellip[:, 0], Pellip[:, 1], s=80, color='#0c2458', alpha=0.5)
    ax.scatter(Psphere[:, 0], Psphere[:, 1], s=80, facecolors='#b64342', alpha=0.5, linewidths=2)

    for p0, p1 in zip(Pellip, Psphere):
        ax.annotate("", xy=(p1[0], p1[1]), xytext=(p0[0], p0[1]),
                    arrowprops=dict(arrowstyle="->", color="#b64342", lw=3, mutation_scale=20))

    ax.set_xlim([-1.6, 1.6])
    ax.set_ylim([-2, 1.6])
    ax.set_axis_off()

    arrow_cov = Line2D([], [], color="#b64342", alpha=0.8,
                       marker=r'$\rightarrow$', linestyle="None", markersize=35, label="Decorrelation")
    ax.legend(handles=[arrow_cov], frameon=False, loc="lower center", fontsize=24, bbox_to_anchor=(0.5, 0.1))
    return ax

def plot_orthogonalization(ax):
    num_points_grid = 512

    xs = np.linspace(-1, 1, num_points_grid)
    ys = np.linspace(-1, 1, num_points_grid)
    x, y = np.meshgrid(xs, ys)
    r2 = x**2 + y**2
    mask = r2 <= 1.0
    z = np.zeros_like(x)
    z[mask] = np.sqrt(1.0 - r2[mask])

    nx, ny, nz = x.copy(), y.copy(), z.copy()
    norm = np.sqrt(nx**2 + ny**2 + nz**2) + EPSILON
    nx, ny, nz = nx / norm, ny / norm, nz / norm

    # light from top left.
    light_dir = np.array([-0.5, 0.5, 0.8])
    light_dir /= np.linalg.norm(light_dir)
    intensity = np.maximum(0.0, nx*light_dir[0] + ny*light_dir[1] + nz*light_dir[2])
    ambient = 0.3
    shade = np.clip(ambient + 0.9*intensity, 0, 1)

    img = np.ones((num_points_grid, num_points_grid))
    img[mask] = shade[mask]

    ax.imshow(img, cmap='gray', origin='lower', extent=[-1, 1, -1, 1], vmin=0, vmax=1, alpha=0.5)
    ax.set_ylim([-2.1, 1.5])
    ax.set_axis_off()

    # add 3 fixed blue points on sphere
    pts = np.array([
        [-0.2, 0.6],
        [0.9, 0.0],
        [-0.75, -0.4],
    ])
    ax.scatter(pts[:, 0], pts[:, 1], s=80, color='#0c2458', alpha=0.5)
    ax = draw_geodesic(ax, pts[0], pts[1], color='#b64342', lw=4)
    ax = draw_geodesic(ax, pts[0], pts[2], color='#b64342', lw=4)
    ax = draw_geodesic(ax, pts[1], pts[2], linestyle='--', draw_arrow=False, color='#42949e', lw=4, alpha=0.5)
    ax = draw_geodesic(ax, pts[0], [0, 0], linestyle='--', draw_arrow=False, color='black', lw=1, alpha=0.8, shorten=0)
    ax = draw_geodesic(ax, pts[1], [0, 0], linestyle='--', draw_arrow=False, color='black', lw=1, alpha=0.8, shorten=0)
    ax = draw_geodesic(ax, pts[2], [0, 0], linestyle='--', draw_arrow=False, color='black', lw=1, alpha=0.8, shorten=0)

    ax.text(0.45, 0.4, "acute angle,\ndisperse", color="#b64342", fontsize=24, ha="center", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))
    ax.text(-0.6, 0.15, "acute angle,\ndisperse", color="#b64342", fontsize=24, ha="center", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))
    ax.text(0.15, -0.36, "obtuse angle,\ndo nothing", color="#42949e", fontsize=24, ha="center", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))

    arrow_disp = Line2D([], [], color="#b64342", alpha=0.8,
                        marker=r'$\leftarrow\rightarrow$', linestyle="None", markersize=50, label="Orthogonalization")
    ax.legend(handles=[arrow_disp], frameon=False, loc="lower center", fontsize=24, bbox_to_anchor=(0.5, 0.15))
    return ax

def plot_l2_repel(ax):
    tau = 0.5

    Z = np.array([
        [ 3.0, -3.0, 0.0],
        [ 1.0, -3.0, 0.0],
        [ -3.0, -2.0, -1.0],
        [ -2.0, -1.0, 1.0],
        [ -2.0, -1.0, 4.0],
        [ 0.0, 2.0, 2.0],
        [ 4.0, 0.0, 2.0],
        [ 4.0, -2.0, 0.0],
    ])

    num_points, d = Z.shape

    D2 = pairwise_sqdist(Z) / d
    W = np.exp(-D2 / tau)

    G_disp = np.zeros_like(Z)
    for i in range(num_points):
        diff = Z[i] - Z
        G_disp[i] = (2.0 / tau) * (W[i][:, None] * diff).sum(axis=0)

    A_disp = safe_scale(G_disp)
    Vn = -Z / (np.linalg.norm(Z, axis=1, keepdims=True) + EPSILON)

    ax.view_init(elev=30, azim=-60)
    ax = nice_axes(ax, 6)
    ax.set_xlim([-3, 5])
    ax.set_ylim([-3, 5])
    ax.set_zlim([-6, 4])
    ax.scatter(Z[:, 0], Z[:, 1], Z[:, 2], s=80, color='#0c2458', alpha=0.5)

    for i in range(num_points):
        p0 = Z[i]
        p1 = Z[i] + A_disp[i] * 1.25
        arrow = Arrow3D([p0[0], p1[0]], [p0[1], p1[1]], [p0[2], p1[2]],
                        mutation_scale=16, lw=4, arrowstyle='->', color='#b64342', alpha=0.8)
        ax.add_artist(arrow)

    for i in range(num_points):
        p0 = Z[i]
        p1 = Z[i] + Vn[i] * 1.2
        arrow = Arrow3D([p0[0], p1[0]], [p0[1], p1[1]], [p0[2], p1[2]],
                        mutation_scale=10, lw=3, arrowstyle='->', color='#9a4d8e', alpha=0.8)
        ax.add_artist(arrow)

    for i in range(num_points):
        ax.plot([Z[i, 0], 0], [Z[i, 1], 0], [Z[i, 2], 0],
                linestyle="--", color="black", linewidth=1, alpha=0.8)

    arrow_disp = Line2D([], [], color="#b64342", alpha=0.8,
                        marker=r'$\rightarrow$', linestyle="None", markersize=25,
                        label=r"${\ell_2}$-repel")
    arrow_norm = Line2D([0, 1], [0, 0], color="#9a4d8e", alpha=0.8,
                        marker=r'$\rightarrow$', linestyle="None", markersize=25,
                        label="norm regularization")
    ax.legend(handles=[arrow_disp, arrow_norm], frameon=False, loc="lower center",
              fontsize=24, bbox_to_anchor=(0.5, 0))

    ax.text(0.8, 0.0, 8.0, "pairwise dispersion", color="#b64342", fontsize=24, ha="left", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))
    ax.text(0.8, 0.0, 6.5, "norm reduction", color="#9a4d8e", fontsize=24, ha="left", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))

    return ax


def plot_angular_spread(ax):
    num_points_grid = 512

    xs = np.linspace(-1, 1, num_points_grid)
    ys = np.linspace(-1, 1, num_points_grid)
    x, y = np.meshgrid(xs, ys)
    r2 = x**2 + y**2
    mask = r2 <= 1.0
    z = np.zeros_like(x)
    z[mask] = np.sqrt(1.0 - r2[mask])

    nx, ny, nz = x.copy(), y.copy(), z.copy()
    norm = np.sqrt(nx**2 + ny**2 + nz**2) + EPSILON
    nx, ny, nz = nx / norm, ny / norm, nz / norm

    # light from top left.
    light_dir = np.array([-0.5, 0.5, 0.8])
    light_dir /= np.linalg.norm(light_dir)
    intensity = np.maximum(0.0, nx*light_dir[0] + ny*light_dir[1] + nz*light_dir[2])
    ambient = 0.3
    shade = np.clip(ambient + 0.9*intensity, 0, 1)

    img = np.ones((num_points_grid, num_points_grid))
    img[mask] = shade[mask]

    ax.imshow(img, cmap='gray', origin='lower', extent=[-1, 1, -1, 1], vmin=0, vmax=1, alpha=0.5)
    ax.set_ylim([-2.1, 1.5])
    ax.set_axis_off()

    # add 3 fixed blue points on sphere
    pts = np.array([
        [-0.2, 0.6],
        [0.9, 0.0],
        [-0.75, -0.4],
    ])
    ax.scatter(pts[:, 0], pts[:, 1], s=80, color='#0c2458', alpha=0.5)
    ax = draw_geodesic(ax, pts[0], pts[1], color='#b64342', lw=4)
    ax = draw_geodesic(ax, pts[0], pts[2], color='#b64342', lw=4)
    ax = draw_geodesic(ax, pts[1], pts[2], color='#b64342', lw=4)
    ax = draw_geodesic(ax, pts[0], [0, 0], linestyle='--', draw_arrow=False, color='black', lw=1, alpha=0.8, shorten=0)
    ax = draw_geodesic(ax, pts[1], [0, 0], linestyle='--', draw_arrow=False, color='black', lw=1, alpha=0.8, shorten=0)
    ax = draw_geodesic(ax, pts[2], [0, 0], linestyle='--', draw_arrow=False, color='black', lw=1, alpha=0.8, shorten=0)

    ax.text(0.45, 0.4, "disperse", color="#b64342", fontsize=24, ha="center", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))
    ax.text(-0.6, 0.15, "disperse", color="#b64342", fontsize=24, ha="center", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))
    ax.text(0.15, -0.36, "disperse", color="#b64342", fontsize=24, ha="center", va="center",
            bbox=dict(facecolor="white", alpha=1, edgecolor="none", boxstyle="round,pad=0.2"))

    arrow_disp = Line2D([], [], color="#b64342", alpha=0.8,
                        marker=r'$\leftarrow\rightarrow$', linestyle="None", markersize=50, label="Dispersion loss")
    ax.legend(handles=[arrow_disp], frameon=False, loc="lower center", fontsize=24, bbox_to_anchor=(0.5, 0.145))
    return ax


if __name__ == "__main__":
    save_path = './figures/illustration.png'
    plt.rcParams['text.usetex'] = True
    plt.rcParams['font.family'] = 'sans-serif'
    fig = plt.figure(figsize=(24, 8))

    ax = fig.add_subplot(1, 4, 1)
    plot_angular_spread(ax)

    ax = fig.add_subplot(1, 4, 2)
    plot_decorrelation(ax)

    ax = fig.add_subplot(1, 4, 3, projection="3d")
    plot_l2_repel(ax)

    ax = fig.add_subplot(1, 4, 4)
    plot_orthogonalization(ax)

    os.makedirs(os.path.dirname(save_path), exist_ok=True)
    fig.tight_layout(pad=2)
    fig.savefig(save_path, dpi=300)
assets/figures4papers/figure_FPGM/plot_freq_prior.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.ticker import LinearLocator, FormatStrFormatter


def load_freq_prior_data(data_dir):
    datasets = {}
    for name in sorted(os.listdir(data_dir)):
        if not name.endswith(".npz"):
            continue
        path = os.path.join(data_dir, name)
        data = np.load(path)
        datasets[name] = {key: data[key] for key in data.files}
    return datasets


def plot_dataset(ax, title, sample1_arr, sample2_arr, max_freq_radius,
                 line_colors, line_styles, legend_labels):
    ax.spines['top'].set_visible(False)
    ax.spines['right'].set_visible(False)
    ax.spines['left'].set_linewidth(2)
    ax.spines['bottom'].set_linewidth(2)
    ax.plot(np.arange(len(sample1_arr))[:max_freq_radius],
            sample1_arr[:max_freq_radius],
            color=line_colors[0], linestyle=line_styles[0], linewidth=2.5)
    ax.plot(np.arange(len(sample2_arr))[:max_freq_radius],
            sample2_arr[:max_freq_radius],
            color=line_colors[1], linestyle=line_styles[1], linewidth=2.5)
    ax.set_title(title, fontfamily='monospace')
    ax.set_xlabel('Frequency Radius')
    ax.set_xticks(np.arange(max_freq_radius)[::4])
    ax.set_ylabel('Mean Amplitude')
    ax.set_ylim(bottom=0)
    ax.yaxis.set_major_locator(LinearLocator(4))
    ax.yaxis.set_major_formatter(FormatStrFormatter('%.1f'))
    ax.legend(legend_labels, frameon=False, fontsize=14)


if __name__ == "__main__":
    data_dir = os.path.join(os.path.dirname(__file__), "data")
    datasets = load_freq_prior_data(data_dir)

    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 16
    fig = plt.figure(figsize=(20, 7))
    gs = fig.add_gridspec(2, 5, width_ratios=[1, 1, 0, 1, 1])

    # Part 1. Plot the individual datasets.
    max_freq_radius = 17
    line_colors = ['#2A9D8F', '#E76F51']
    line_styles = ['-', '--']
    subplot_positions = [gs[0, 0], gs[0, 1], gs[1, 0], gs[1, 1]]

    per_dataset_names = ['Kvasir', 'CVC-ClinicDB', 'CVC-ColonDB', 'ETIS']
    max_name_len = max(len(name) for name in per_dataset_names)
    for dataset_idx, dataset_name in enumerate(per_dataset_names):
        data = datasets[dataset_name.lower() + '_freq_prior.npz']
        sample1_arr = data['data1']
        sample2_arr = data['data2']

        ax = fig.add_subplot(subplot_positions[dataset_idx])
        plot_dataset(ax, dataset_name, sample1_arr, sample2_arr,
                     max_freq_radius, line_colors, line_styles,
                     [r'Random subset 1 $(N=50)$', r'Random subset 2 $(N=50)$'])

    # Part 2. Plot the mixed dataset across two columns.
    data = datasets['mixed_freq_prior.npz']
    sample1_arr = data['data1']
    sample2_arr = data['data2']
    ax = fig.add_subplot(gs[0, 3:5])
    plot_dataset(ax, '', sample1_arr, sample2_arr, max_freq_radius, line_colors, line_styles,
                 [r'Random subset 1 $(N=500)$', r'Random subset 2 $(N=500)$'])
    ax.set_title('Mixuture of all four datasets', fontfamily='helvetica')

    # Part 3. Plot per-dataset mean/std curves in a single panel.
    ax = fig.add_subplot(gs[1, 3])
    per_dataset_colors = ['#4C72B0', '#C2A5CF', '#7B3294', '#8C564B']
    curve_means = []
    for dataset_idx, dataset_name in enumerate(['Kvasir', 'CVC-ClinicDB', 'CVC-ColonDB', 'ETIS']):
        data = datasets[dataset_name.lower() + '_freq_prior.npz']
        sample1_arr = data['data1'][:max_freq_radius]
        sample2_arr = data['data2'][:max_freq_radius]
        stacked = np.stack([sample1_arr, sample2_arr], axis=0)
        mean = stacked.mean(axis=0)
        std = stacked.std(axis=0)
        x_vals = np.arange(len(mean))
        color = per_dataset_colors[dataset_idx]
        padded_name = dataset_name.ljust(max_name_len)
        ax.plot(x_vals, mean, color=color, linewidth=1.5,
                label=f'{padded_name}' + r' $(N=100)$')
        ax.fill_between(x_vals, mean - std, mean + std, color=color, alpha=0.2)
        curve_means.append(mean)

    ax.spines['top'].set_visible(False)
    ax.spines['right'].set_visible(False)
    ax.spines['left'].set_linewidth(2)
    ax.spines['bottom'].set_linewidth(2)
    ax.axvline(2, color='0.6', linestyle='--', linewidth=1.2, label=r'Radius$=2$')
    x_left = ax.get_xlim()[0]
    for mean, color in zip(curve_means, per_dataset_colors):
        ax.hlines(mean[2], xmin=x_left, xmax=2, color=color, linestyle='--', linewidth=1.0)
    ax.set_xlabel('Frequency Radius')
    ax.set_xticks(np.arange(max_freq_radius)[::4])
    ax.set_xlim(left=x_left, right=max_freq_radius)
    ax.set_ylabel('Mean Amplitude')
    ax.set_ylim(bottom=0, top=3)
    ax.yaxis.set_major_locator(LinearLocator(4))
    ax.yaxis.set_major_formatter(FormatStrFormatter('%.1f'))
    ax.legend(frameon=False, ncol=1, prop={'family': 'monospace', 'size': 12})

    # Part 4. Plot foreground/background curves in a single panel.
    data = datasets['foreground_background.npz']
    polyp_arr = data['data1'][:max_freq_radius]
    background_arr = data['data2'][:max_freq_radius]
    x_vals = np.arange(len(polyp_arr))
    polyp_color = '#1F3A93'
    background_color = '#16A085'

    ax = fig.add_subplot(gs[1, 4])
    ax.spines['top'].set_visible(False)
    ax.spines['right'].set_visible(False)
    ax.spines['left'].set_linewidth(2)
    ax.spines['bottom'].set_linewidth(2)
    fb_names = ['Polyp', 'Background']
    fb_max_len = max(len(name) for name in fb_names)
    ax.plot(x_vals, polyp_arr, color=polyp_color, linewidth=2.5,
            label=f'{fb_names[0].ljust(fb_max_len)}' + r' $(N=500)$')
    ax.plot(x_vals, background_arr, color=background_color, linewidth=2.5,
            linestyle=':', label=f'{fb_names[1].ljust(fb_max_len)}' + r' $(N=500)$')
    ax.axvline(2, color='0.6', linestyle='--', linewidth=1.2, label=r'Radius$=2$')
    x_left = ax.get_xlim()[0]
    ax.hlines(polyp_arr[2], xmin=x_left, xmax=2, color=polyp_color, linestyle='--', linewidth=1.2)
    ax.hlines(background_arr[2], xmin=x_left, xmax=2, color=background_color, linestyle='--', linewidth=1.2)
    ax.set_xlabel('Frequency Radius')
    ax.set_xticks(np.arange(max_freq_radius)[::4])
    ax.set_xlim(left=x_left, right=max_freq_radius)
    ax.set_ylabel('Mean Amplitude')
    ax.set_ylim(bottom=0)
    ax.yaxis.set_major_locator(LinearLocator(4))
    ax.yaxis.set_major_formatter(FormatStrFormatter('%.1f'))
    ax.legend(frameon=False, prop={'family': 'monospace', 'size': 12})

    fig.tight_layout(pad=1)
    os.makedirs('figures', exist_ok=True)
    fig.savefig('figures/freq_prior.png', dpi=300)
assets/figures4papers/figure_ImmunoStruct/plot_bars.py
import os
import numpy as np
from matplotlib import pyplot as plt
from raw_data import data_comparison_IEDB, data_ablation_IEDB, data_comparison_Cancer, data_ablation_Cancer


def decode_ablation(data_dict):
    binary_list = data_dict['ablations']
    component_str = data_dict['components']
    decoded_list = []
    for binary_code in binary_list:
        assert len(binary_code) == len(component_str)
        decoded_str = []
        for i, c in enumerate(binary_code):
            if c == '1':
                decoded_str.append(component_str[i])
        decoded_list.append(' + '.join(decoded_str))
    return decoded_list


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3
    plt.rcParams['svg.fonttype'] = 'none'

    fig = plt.figure(figsize=(28, 6))

    ax = fig.add_subplot(1, 4, 1)
    ax.bar(range(len(data_comparison_IEDB['mean'])),
           data_comparison_IEDB['mean'][:, 0],
           yerr=data_comparison_IEDB['std'][:, 0],
           capsize=5,
           color=data_comparison_IEDB['colors'],
           label=data_comparison_IEDB['methods'])
    handles, labels = ax.get_legend_handles_labels()
    ax.set_xticks([])
    ax.set_ylim([0.5, 0.9])
    ax.set_ylabel(data_comparison_IEDB['metrics'][0], fontsize=32)

    ax = fig.add_subplot(1, 4, 2)
    ax.bar(range(len(data_comparison_IEDB['mean'])),
           data_comparison_IEDB['mean'][:, 1],
           yerr=data_comparison_IEDB['std'][:, 1],
           capsize=5,
           color=data_comparison_IEDB['colors'])
    ax.set_xticks([])
    ax.set_ylim([0.15, 0.75])
    ax.set_ylabel(data_comparison_IEDB['metrics'][1], fontsize=32)

    ax = fig.add_subplot(1, 4, 3)
    ax.bar(range(len(data_comparison_IEDB['mean'])),
           data_comparison_IEDB['mean'][:, 2],
           yerr=data_comparison_IEDB['std'][:, 2],
           capsize=5,
           color=data_comparison_IEDB['colors'])
    ax.set_xticks([])
    ax.set_ylim([0.18, 0.55])
    ax.set_ylabel(data_comparison_IEDB['metrics'][2], fontsize=32)

    ax = fig.add_subplot(1, 4, 4)
    ax.legend(handles, labels)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/bars_comparison_IEDB.png', dpi=600)
    plt.close(fig)


    fig = plt.figure(figsize=(24, 8))

    ax = fig.add_subplot(1, 3, 1)
    ax.barh(range(len(data_ablation_IEDB['mean'][:, 0])),
            data_ablation_IEDB['mean'][:, 0],
            xerr=data_ablation_IEDB['std'][:, 0],
            color=[(0.215686, 0.458824, 0.729412, alpha) for alpha in np.linspace(0.2, 1.0, 12)],
            ecolor='k',
            capsize=5,
    )

    ax.set_yticks(range(len(data_ablation_IEDB['ablations'])))
    ax.set_yticklabels(decode_ablation(data_ablation_IEDB))
    ax.set_xlim([0.75, 0.9])
    ax.set_xticks([0.75, 0.8, 0.85, 0.9])
    ax.set_xticklabels([0.75, 0.8, 0.85, 0.9])
    ax.set_xlabel(data_ablation_IEDB['metrics'][0], fontsize=32)

    ax = fig.add_subplot(1, 3, 2)
    ax.barh(range(len(data_ablation_IEDB['mean'][:, 1])),
            data_ablation_IEDB['mean'][:, 1],
            xerr=data_ablation_IEDB['std'][:, 1],
            color=[(0.215686, 0.458824, 0.729412, alpha) for alpha in np.linspace(0.2, 1.0, 12)],
            ecolor='k',
            capsize=5,
    )

    ax.set_yticks([])
    ax.set_xlim([0.4, 0.72])
    ax.set_xticks([0.4, 0.5, 0.6, 0.7])
    ax.set_xticklabels([0.4, 0.5, 0.6, 0.7])
    ax.set_xlabel(data_ablation_IEDB['metrics'][1], fontsize=32)

    ax = fig.add_subplot(1, 3, 3)
    ax.barh(range(len(data_ablation_IEDB['mean'][:, 2])),
            data_ablation_IEDB['mean'][:, 2],
            xerr=data_ablation_IEDB['std'][:, 2],
            color=[(0.215686, 0.458824, 0.729412, alpha) for alpha in np.linspace(0.2, 1.0, 12)],
            ecolor='k',
            capsize=5,
    )

    ax.set_yticks([])
    ax.set_xlim([0.4, 0.55])
    ax.set_xticks([0.4, 0.45, 0.5, 0.55])
    ax.set_xticklabels([0.4, 0.45, 0.5, 0.55])
    ax.set_xlabel(data_ablation_IEDB['metrics'][2], fontsize=32)

    fig.tight_layout(pad=2)
    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/bars_ablation_IEDB.png', dpi=600)
    plt.close(fig)


    fig = plt.figure(figsize=(28, 6))

    ax = fig.add_subplot(1, 4, 1)
    ax.bar(range(len(data_comparison_Cancer['mean'])),
           data_comparison_Cancer['mean'][:, 0],
           yerr=data_comparison_Cancer['std'][:, 0],
           capsize=5,
           color=data_comparison_Cancer['colors'],
           label=data_comparison_Cancer['methods'])
    handles, labels = ax.get_legend_handles_labels()
    ax.set_xticks([])
    ax.set_ylim([0.5, 0.82])
    ax.set_ylabel(data_comparison_Cancer['metrics'][0], fontsize=32)

    ax = fig.add_subplot(1, 4, 2)
    ax.bar(range(len(data_comparison_Cancer['mean'])),
           data_comparison_Cancer['mean'][:, 1],
           yerr=data_comparison_Cancer['std'][:, 1],
           capsize=5,
           color=data_comparison_Cancer['colors'])
    ax.set_xticks([])
    ax.set_ylim([0.16, 0.52])
    ax.set_ylabel(data_comparison_Cancer['metrics'][1], fontsize=32)

    ax = fig.add_subplot(1, 4, 3)
    ax.bar(range(len(data_comparison_Cancer['mean'])),
           data_comparison_Cancer['mean'][:, 2],
           yerr=data_comparison_Cancer['std'][:, 2],
           capsize=5,
           color=data_comparison_Cancer['colors'])
    ax.set_xticks([])
    ax.set_ylim([0.14, 0.44])
    ax.set_ylabel(data_comparison_Cancer['metrics'][2], fontsize=32)

    ax = fig.add_subplot(1, 4, 4)
    ax.legend(handles, labels)
    ax.set_axis_off()

    fig.tight_layout(pad=2)

    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/bars_comparison_Cancer.png', dpi=600)
    plt.close(fig)

    fig = plt.figure(figsize=(28, 6))

    items_shown = [6, 4, 0] # transfer + contrastive, transfer, none

    ax = fig.add_subplot(1, 4, 1)
    ax.bar(range(len(items_shown)),
           data_ablation_Cancer['mean'][:, 0][items_shown],
           yerr=data_ablation_Cancer['std'][:, 0][items_shown],
           capsize=5,
           color=[(0.215686, 0.458824, 0.729412, alpha) for alpha in [1.0, 0.7, 0.4]],
           label=['ImmunoStruct', 'No Contrastive Learning',
                  'No Contrastive Learning &\nNo Transfer Learning'])
    handles, labels = ax.get_legend_handles_labels()
    ax.set_xticks([])
    ax.set_ylim([0.68, 0.80])
    ax.set_ylabel(data_ablation_Cancer['metrics'][0], fontsize=32)

    ax = fig.add_subplot(1, 4, 2)
    ax.bar(range(len(items_shown)),
           data_ablation_Cancer['mean'][:, 1][items_shown],
           yerr=data_ablation_Cancer['std'][:, 1][items_shown],
           capsize=5,
           color=[(0.215686, 0.458824, 0.729412, alpha) for alpha in [1.0, 0.7, 0.4]])
    ax.set_xticks([])
    ax.set_ylim([0.30, 0.52])
    ax.set_ylabel(data_ablation_Cancer['metrics'][1], fontsize=32)

    ax = fig.add_subplot(1, 4, 3)
    ax.bar(range(len(items_shown)),
           data_ablation_Cancer['mean'][:, 2][items_shown],
           yerr=data_ablation_Cancer['std'][:, 2][items_shown],
           capsize=5,
           color=[(0.215686, 0.458824, 0.729412, alpha) for alpha in [1.0, 0.7, 0.4]])
    ax.set_xticks([])
    ax.set_ylim([0.29, 0.43])
    ax.set_ylabel(data_ablation_Cancer['metrics'][2], fontsize=32)

    ax = fig.add_subplot(1, 4, 4)
    ax.legend(handles, labels)
    ax.set_axis_off()

    fig.tight_layout(pad=2)
    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/bars_ablation_Cancer.png', dpi=600)
    plt.close(fig)
assets/figures4papers/figure_ImmunoStruct/raw_data.py
import numpy as np


data_comparison_IEDB = {
    'methods': [r'Prime-2.1', r'NetMHCpan', r'MHCnuggets', r'MHCflurry', r'DeepNeo',
                r'BigMHC-EL', r'BigMHC-IM', r'BigMHC$_\text{retrained}$', r'ImmunoStruct (ours)'],
    'colors': ['#CFCECE', '#F4EEAC', '#FBDFE2', '#D9B9D4', '#DAA87C', '#DDF3DE', '#AADCA9', '#8BCF8B', '#3775BA'],
    'metrics': ['AUROC', 'AUPRC', 'Mean PPVn'],
    'mean': np.array([
        [0.538, 0.212, 0.207],
        [0.537, 0.214, 0.220],
        [0.546, 0.246, 0.233],
        [0.577, 0.260, 0.273],
        [0.767, 0.438, 0.411],
        [0.588, 0.269, 0.290],
        [0.684, 0.319, 0.333],
        [0.793, 0.462, 0.445],
        [0.882, 0.696, 0.514],
    ]),
    'std': np.array([
        [0.012, 0.031, 0.028 / np.sqrt(5)],
        [0.027, 0.020, 0.018 / np.sqrt(5)],
        [0.023, 0.019, 0.031 / np.sqrt(5)],
        [0.021, 0.026, 0.028 / np.sqrt(5)],
        [0.032, 0.024, 0.017 / np.sqrt(5)],
        [0.018, 0.016, 0.024 / np.sqrt(5)],
        [0.028, 0.048, 0.034 / np.sqrt(5)],
        [0.013, 0.027, 0.009 / np.sqrt(5)],
        [0.005, 0.020, 0.021 / np.sqrt(5)],
    ]),
}

data_ablation_IEDB = {
    'ablations': ['10000', '01000', '11000', '01100', '11100', '11110',
                  '10001', '01001', '11001', '01101', '11101', '11111'],
    'components': ['Structure', 'Sequence', 'Bchems', 'MMA', 'Transfer Learning'],
    'metrics': ['AUROC', 'AUPRC', 'Mean PPVn'],
    'mean': np.array([
       [0.775, 0.442, 0.433],
       [0.842, 0.553, 0.430],
       [0.840, 0.547, 0.444],
       [0.842, 0.554, 0.419],
       [0.840, 0.547, 0.440],
       [0.860, 0.615, 0.462],
       [0.805, 0.497, 0.414],
       [0.845, 0.568, 0.427],
       [0.844, 0.569, 0.440],
       [0.844, 0.561, 0.442],
       [0.840, 0.554, 0.441],
       [0.882, 0.696, 0.514],
    ]),
   'std': np.array([
       [0.012, 0.022, 0.020 / np.sqrt(5)],
       [0.006, 0.014, 0.009 / np.sqrt(5)],
       [0.007, 0.024, 0.028 / np.sqrt(5)],
       [0.006, 0.020, 0.017 / np.sqrt(5)],
       [0.006, 0.018, 0.032 / np.sqrt(5)],
       [0.013, 0.045, 0.015 / np.sqrt(5)],
       [0.006, 0.022, 0.027 / np.sqrt(5)],
       [0.007, 0.024, 0.017 / np.sqrt(5)],
       [0.005, 0.014, 0.035 / np.sqrt(5)],
       [0.006, 0.017, 0.024 / np.sqrt(5)],
       [0.006, 0.028, 0.029 / np.sqrt(5)],
       [0.005, 0.020, 0.021 / np.sqrt(5)],
    ]),
}


data_comparison_Cancer = {
    'methods': [r'Prime-2.1', r'NetMHCpan', r'MHCnuggets', r'MHCflurry', r'DeepNeo',
                r'BigMHC-EL', r'BigMHC-IM', r'BigMHC$_\text{retrained}$', r'NeoaPred',
                r'ImmunoStruct (ours)'],
    'colors': ['#CFCECE', '#F4EEAC', '#FBDFE2', '#D9B9D4', '#DAA87C', '#DDF3DE', '#AADCA9', '#8BCF8B', "#92E3F9", '#3775BA'],
    'metrics': ['AUROC', 'AUPRC', 'Mean PPVn'],
    'mean': np.array([
        [0.645, 0.259, 0.295],
        [0.559, 0.210, 0.211],
        [0.530, 0.210, 0.175],
        [0.658, 0.304, 0.329],
        [0.535, 0.261, 0.222],
        [0.632, 0.248, 0.245],
        [0.771, 0.373, 0.357],
        [0.682, 0.310, 0.325],
        [0.556, 0.267, 0.292],
        [0.771, 0.433, 0.364],
    ]),
    'std': np.array([
        [0.026, 0.023, 0.029 / np.sqrt(5)],
        [0.049, 0.037, 0.045 / np.sqrt(5)],
        [0.014, 0.017, 0.023 / np.sqrt(5)],
        [0.015, 0.032, 0.030 / np.sqrt(5)],
        [0.016, 0.053, 0.070 / np.sqrt(5)],
        [0.034, 0.039, 0.042 / np.sqrt(5)],
        [0.040, 0.062, 0.036 / np.sqrt(5)],
        [0.012, 0.020, 0.030 / np.sqrt(5)],
        [0.016, 0.022, 0.081 / np.sqrt(5)],
        [0.024, 0.069, 0.127 / np.sqrt(5)],
    ]),
}

data_ablation_Cancer = {
    'coeffs': [[False, 0], [False, 0.001], [False, 0.01], [False, 0.1],
               [True, 0], [True, 0.001], [True, 0.01], [True, 0.1]],
    'metrics': ['AUROC', 'AUPRC', 'Mean PPVn'],
    'mean': np.array([
       [0.723, 0.391, 0.358],
       [0.712, 0.370, 0.331],
       [0.727, 0.405, 0.387],
       [0.700, 0.413, 0.354],
       [0.756, 0.426, 0.362],
       [0.762, 0.418, 0.351],
       [0.771, 0.433, 0.365],
       [0.725, 0.406, 0.348],
    ]),
   'std': np.array([
       [0.027, 0.068, 0.096 / np.sqrt(5)],
       [0.040, 0.077, 0.114 / np.sqrt(5)],
       [0.037, 0.080, 0.111 / np.sqrt(5)],
       [0.036, 0.081, 0.109 / np.sqrt(5)],
       [0.031, 0.068, 0.103 / np.sqrt(5)],
       [0.024, 0.064, 0.151 / np.sqrt(5)],
       [0.024, 0.069, 0.127 / np.sqrt(5)],
       [0.035, 0.079, 0.129 / np.sqrt(5)],
    ]),
}
assets/figures4papers/figure_ophthal_review/plot_composition.py
import os
import numpy as np
from matplotlib import pyplot as plt
import seaborn as sns


DATA = {
    'clinical_stage': [
        'Benchmark\nEvaluation', 'Expert\nEvaluation', 'Retrospective\nClinical Validation',
        'Prospective\nPilot Study', 'Full\nClinical Trial',
    ],
    'pub_by_category':
        {
            'Clinical Workflow': {
                'Screening or Diagnosis': [10, 7, 10, 3, 0],
                'Report Generation': [2, 3, 3, 0, 0],
                'Treatment Planning or\nRecommendation': [2, 3, 3, 2, 0],
            },
            'Patient Support': {
                'Patient Question Answering': [5, 13, 1, 1, 0],
                'After Visit or Discharge\nSummary Generation': [0, 2, 0, 0, 0],
                'Consultation or Interview': [0, 1, 1, 0, 0],
                'Patient Education\nMaterial Generation': [1, 6, 0, 0, 0],
                'Physician Recommendation': [0, 1, 0, 0, 0],
            },
            'Education and Training': {
                'Exam Taking': [19, 5, 0, 0, 0],
                'Medical Education and\nLearning Support': [3, 5, 0, 0, 0],
            }
        }
}

def plot_heatmap(fig_name: str):
    plt.rcParams['text.usetex'] = True
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 16
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 2

    os.makedirs(os.path.dirname(fig_name), exist_ok=True)
    fig = plt.figure(figsize=(14, 10))

    ax = fig.add_subplot(1, 1, 1)
    category_count_arr, category_arr = [], []
    subcategory_arr = []
    value_arr = []
    for category in DATA['pub_by_category']:
        subcategories = DATA['pub_by_category'][category].keys()
        category_count_arr.append(len(subcategories))
        category_arr.append(category)
        for subcategory in subcategories:
            value = DATA['pub_by_category'][category][subcategory]
            subcategory_arr.append(subcategory)
            value_arr.append(value)
    value_arr = np.stack(value_arr, axis=0)
    for loc, item in enumerate(subcategory_arr):
        item += '\n' + rf'($n={value_arr[loc, :].sum()}$)'
        subcategory_arr[loc] = item
    stage_arr = DATA['clinical_stage']
    for loc, item in enumerate(stage_arr):
        item += '\n' + rf'($n={value_arr[:, loc].sum()}$)'
        stage_arr[loc] = item

    hm = sns.heatmap(value_arr, annot=True, vmin=0, vmax=20, fmt='d', cmap='Reds',
                linewidths=1, linecolor='white', ax=ax, cbar=True)
    cbar = hm.collections[0].colorbar
    cbar.set_ticks([0, 5, 10, 15, 20])
    cbar.set_ticklabels([0, 5, 10, 15, 20])
    ax.set_yticks(np.arange(len(subcategory_arr)) + 0.5)
    ax.set_yticklabels(subcategory_arr, rotation=0)
    ax.set_xticks(np.arange(len(stage_arr)) + 0.5)
    ax.set_xticklabels(stage_arr, rotation=0)

    fig.tight_layout(pad=2)
    fig.savefig(fig_name, dpi=300)
    return


if __name__ == '__main__':
    plot_heatmap('./figures/composition_heatmap.png')
assets/figures4papers/figure_ophthal_review/plot_trend.py
import os
import numpy as np
from matplotlib import pyplot as plt
from datetime import datetime
from dateutil.relativedelta import relativedelta


DATA = {
    'names': ['Methodological Contribution (Text-only)', 'Evaluation / Application (Text-only)',
              'Methodological Contribution (Multimodal)', 'Evaluation / Application (Multimodal)'],
    'pub_by_month': np.array(
        [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 2, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 2],
         [0, 0, 4, 0, 0, 6, 1, 3, 2, 0, 5, 0, 4, 0, 9, 1, 3, 4, 7, 6, 7, 6, 6, 1, 6, 4, 6, 2, 0, 0, 0, 1, 1],
         [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0],
         [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1, 2, 1, 0, 2, 3, 0, 1, 2, 0, 2, 0, 1, 0, 0, 0, 0, 0, 0]]
    ),
    # NOTE: Use `*` to move the label up in the annotation. Each `*` moves it up a bit.
    'dates_llm': {
        '2022-11': 'ChatGPT\n(GPT-3.5)',
        '2023-02': 'Bard',
        '2023-02': 'LlaMA 1',
        '2023-03': 'GPT-4*',
        '2025-08': 'GPT-5',
        '2023-07': 'LlaMA 2',
        '2024-04': 'LlaMA 3',
        '2025-04': 'LlaMA 4',
        '2023-12': 'Gemini 1.0',
        '2024-02': 'Gemini 1.5',
        '2024-12': 'Gemini 2.0',
        '2025-06': 'Gemini 2.5*',
    },
    'dates_vlm': {
        '2023-02': 'BLIP-2',
        '2023-07': 'LlaVA 1.0',
        '2023-9': 'GPT-4v',
        '2023-10': 'LlaVA 1.5*',
        '2023-12': 'Gemini 1.0',
        '2024-02': 'Gemini 1.5',
        '2024-12': 'Gemini 2.0',
        '2025-06': 'Gemini 2.5',
    }
}

def month_year_list(start_year, start_month, n_months):
    start = datetime(start_year, start_month, 1)
    out = []
    for i in range(n_months):
        d = start + relativedelta(months=i)
        out.append(d.strftime('%Y-%m'))
    return out

def mark_events(ax, time_arr, y_curve, events, dy=0.1):
    x_idx = {t: i for i, t in enumerate(time_arr)}
    y0, y1 = ax.get_ylim()
    prev_date = None
    for date, label in events.items():
        if prev_date is None:
            prev_date = date
        if date in x_idx:
            i = x_idx[date]
            x, y = i, y_curve[i]
            ax.annotate(
                label.replace('*', ''),
                xy=(x, y),
                xytext=(x, y + (1 + 0.8 * np.uint8(label.count('*'))) * dy * (y1 - y0)),
                ha='center',
                va='bottom',
                fontsize=11,
                arrowprops=dict(arrowstyle='-|>', lw=1.3, color='black',
                                shrinkA=0, shrinkB=0, mutation_scale=15)
            )
    return

def plot_curve(fig_name: str):
    plt.rcParams['text.usetex'] = True
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 15
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 2
    colors = ["#9BC8FA", "#ffa8a6", "#13457E", "#850c0a"]

    os.makedirs(os.path.dirname(fig_name), exist_ok=True)
    fig = plt.figure(figsize=(14, 8))
    num_months = DATA['pub_by_month'].shape[1]

    ax = fig.add_subplot(2, 1, 1)
    time_arr = month_year_list(start_year=2022, start_month=11, n_months=num_months)
    ax.fill_between(time_arr, np.zeros_like(DATA['pub_by_month'][1, :]), np.cumsum(DATA['pub_by_month'][1, :]), color=colors[1],
                    label=DATA['names'][1])
    ax.fill_between(time_arr, np.zeros_like(DATA['pub_by_month'][0, :]), np.cumsum(DATA['pub_by_month'][0, :]), color=colors[0],
                    label=DATA['names'][0])
    ax.plot(time_arr, np.cumsum(DATA['pub_by_month'][0, :]), lw=3, c=colors[2])
    ax.plot(time_arr, np.cumsum(DATA['pub_by_month'][1, :]), lw=3, c=colors[3])
    mark_events(ax, time_arr, np.cumsum(DATA['pub_by_month'][1, :]), DATA['dates_llm'])
    ax.legend(frameon=False)
    ax.set_xticks(time_arr[2::6])
    ax.set_ylim([0, 105])
    ax.set_ylabel('Cumulative\nPublication Count\n(Text-only)')

    ax = fig.add_subplot(2, 1, 2)
    time_arr = month_year_list(start_year=2022, start_month=11, n_months=num_months)
    ax.fill_between(time_arr, np.zeros_like(DATA['pub_by_month'][3, :]), np.cumsum(DATA['pub_by_month'][3, :]), color=colors[1],
                    hatch='\\\\\\', edgecolor='black', label=DATA['names'][3])
    ax.fill_between(time_arr, np.zeros_like(DATA['pub_by_month'][3, :]), np.cumsum(DATA['pub_by_month'][3, :]), color=colors[1],
                    facecolor='none', edgecolor='white', linewidth=2) # To visually "erase" the border.
    ax.fill_between(time_arr, np.zeros_like(DATA['pub_by_month'][2, :]), np.cumsum(DATA['pub_by_month'][2, :]), color=colors[0],
                    hatch='///', edgecolor='black', label=DATA['names'][2])
    ax.fill_between(time_arr, np.zeros_like(DATA['pub_by_month'][2, :]), np.cumsum(DATA['pub_by_month'][2, :]), color=colors[0],
                    facecolor='none', edgecolor='white', linewidth=2) # To visually "erase" the border.
    ax.plot(time_arr, np.cumsum(DATA['pub_by_month'][2, :]), lw=3, c=colors[2])
    ax.plot(time_arr, np.cumsum(DATA['pub_by_month'][3, :]), lw=3, c=colors[3])
    ax.legend(frameon=False)
    mark_events(ax, time_arr, np.cumsum(DATA['pub_by_month'][3, :]), DATA['dates_vlm'])
    ax.set_xticks(time_arr[2::6])
    ax.set_ylim([0, 24])
    ax.set_ylabel('Cumulative\nPublication Count\n(Multimodal)')

    fig.tight_layout(pad=2)
    fig.savefig(fig_name, dpi=300)
    return


if __name__ == '__main__':
    plot_curve('./figures/trend_by_month.png')
assets/figures4papers/figure_RNAGenScape/plot_comparison.py
import os
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt


summary_label = r'\textit{Improvement}'
options = ['VAE', 'DDPM', 'LDM', 'FM',
           'DiffAb', 'IgLM', 'NOS-C', 'NOS-D',
           'OAE + gradient ascent',
           'OAE + MCMC',
           'OAE + hill climbing',
           'OAE + stochastic hill climbing',
           r'$\texttt{RNAGenScape}$ \textbf{(ours)}',
           summary_label]
colors = ["#cdcdcd", "#767676", "#4d4d4d", "#272727",
          "#c4ece7", "#ecc4c4", "#ecc4e7", "#ea84dd",
          "#d5e29b", "#bdd35c", "#9fbc1d", "#8ead03",
          "#0f4d92"]
xlabel = np.arange(len(options))
xticks = []
results_inference = [0.13, 0.91, 0.74, 5.82, 41.04, 157.57, 0.99, 0.96,
                     0.50, 10.93, 81.52, 99.66, 0.57]
results_openvaccine_delta_pos = [-0.23, -0.33, -0.07, -0.34, 0.06, 0.24, 0.09, 0.18,
                                 -0.01, -0.11, 0.40, -0.12, 0.54]
results_openvaccine_pct_pos = [42.1, 33.8, 47.5, 32.5, 55.0, 63.1, 54.6, 58.3,
                               51.0, 45.1, 69.2, 43.5, 77.5]
results_openvaccine_delta_neg = [-0.23, -0.33, -0.07, -0.34, 0.11, 0.01, -2.25, -0.29,
                                 -0.19, -0.19, -0.29, -0.15, -2.81]
results_openvaccine_pct_neg = [57.9, 66.2, 52.5, 67.5, 44.0, 49.6, 90.6, 65.4,
                               61.3, 57.7, 64.2, 60.0, 97.9]
results_zebrafish_delta_pos = [-0.01, 0.29, -0.95, 0.32, -0.21, -0.57, 0.03, 0.31,
                               -1.21, -0.20, 0.76, 0.32, 0.77]
results_zebrafish_pct_pos = [49.4, 58.2, 22.5, 59.8, 36.3, 32., 51.1, 57.9,
                             18.0, 44.3, 74.4, 60.3, 75.0]
results_zebrafish_delta_neg = [-0.01, 0.29, -0.95, 0.32, -0.21, -0.83, -1.07, 0.38,
                               -0.33, -0.37, -0.87, -0.80, -1.29]
results_zebrafish_pct_neg = [50.6, 41.8, 77.5, 40.2, 60.8, 74.3, 80.8, 40.2,
                             66.5, 60.1, 75.2, 73.7, 85.1]
results_ribosome_delta_pos = [-0.24, -0.11, -0.24, -0.10, -0.05, 0.63, 0.08, -0.09,
                              0.43, -0.19, 0.53, 0.19, 0.63]
results_ribosome_pct_pos = [41.7, 45.9, 41.8, 46.4, 45.0, 80.5, 53.6, 46.5,
                            73.6, 43.0, 83.0, 60.4, 81.4]
results_ribosome_delta_neg = [-0.24, -0.11, -0.24, -0.10, -0.04, -0.51, 0.10, -0.10,
                              -0.05, -0.10, -0.06, -0.05, -0.58]
results_ribosome_pct_neg = [58.3, 54.1, 58.2, 53.6, 54.2, 65.5, 46.0, 53.6,
                            55.7, 54.2, 56.1, 55.2, 67.8]


if __name__ == '__main__':
    PLOT_DE_NOVO_SPEED = False

    plt.rcParams['text.usetex'] = True
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 16
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 2

    fig = plt.figure(figsize=(9, 5))
    ax = fig.add_subplot(1, 1, 1)

    if PLOT_DE_NOVO_SPEED:
        ax.bar(np.arange(len(options)), 1 / np.array(results_inference), color=colors, label=options)
        ax.hlines(y=1 / results_inference[-1], xmin=-1, xmax=len(options), color=colors[-1], linestyle='--')
        ax.set_xlim([-1, len(options)])
        ax.legend(loc='upper right', frameon=False, fontsize=12)
        ax.set_xticks([])
        ax.set_ylabel('Inference Throughput ' + r'$\uparrow$' +'\n(samples / ms)', fontsize=18)

        # Add braces.
        ymin, ymax = ax.get_ylim()
        ax.set_ylim(ymin - 1, ymax)
        len_de_novo = 4
        mid_de_novo = 0 + (len_de_novo - 0) / 2
        start_opt = len_de_novo + 1
        end_opt = len(options) - 1
        mid_opt = (start_opt + end_opt) / 2
        ax.text(mid_de_novo, ymin - 0.3,
            r'$\underbrace{\rule{5cm}{0pt}}_{\textrm{\textit{de\ novo}\ generative\ models}}$',
            ha='center', va='top', fontsize=14)
        ax.text(mid_opt, ymin - 0.3,
            r'$\underbrace{\rule{9.2cm}{0pt}}_{\textrm{property\ optimization\ methods}}$',
            ha='center', va='top', fontsize=14)
        ax.spines['bottom'].set_position(('data', 0))
        ax.xaxis.set_ticks_position('bottom')
        ax.spines['bottom'].set_linewidth(2)
        ax.spines['left'].set_bounds(0, ymax)

    else:
        # speed_values = 1 / np.array(results_inference[4:])
        # options_speed = options[4:-1]
        # ax.bar(np.arange(len(options_speed)), speed_values, color=colors[4:], label=options_speed)
        speed_values = 1 / np.array(results_inference[4:8] + [results_inference[-1]])
        options_speed = options[4:8] + [options[-2]]
        ax.bar(np.arange(len(options_speed)), speed_values, color=colors[4:8] + [colors[-1]], label=options_speed)
        ax.set_xlim([-1, len(options_speed)])
        ax.legend(loc='upper left', frameon=False, fontsize=16, ncol=1)
        ax.set_xticks([])
        ax.set_ylabel('Inference Throughput ' + r'$\uparrow$' +'\n(samples / ms)', fontsize=18)
        ax.set_yscale('log')
        ymin, ymax = ax.get_ylim()
        ax.set_ylim(ymin, ymax * 20)

        # Add numbers on top of bars
        for i, val in enumerate(speed_values):
            ax.text(i, val * 1.1, f'{val:.3f}', ha='center', va='bottom', fontsize=16)

    fig.tight_layout(pad=1)
    os.makedirs('./figures', exist_ok=True)
    fig.savefig('./figures/results_comparison_speed.png', dpi=300)
    plt.close(fig)


    fig = plt.figure(figsize=(20, 9))
    ax = fig.add_subplot(1, 2, 1)
    improvements = np.stack([results_openvaccine_delta_pos, results_openvaccine_delta_neg,
                             results_zebrafish_delta_pos, results_zebrafish_delta_neg,
                             results_ribosome_delta_pos, results_ribosome_delta_neg], axis=1)
    base_improvements = improvements[:-1]
    best_improvements = []
    for j in range(base_improvements.shape[1]):
        col = base_improvements[:, j]
        best_improvements.append(col.max() if j % 2 == 0 else col.min())
    best_improvements = np.array(best_improvements)
    denom = np.where(best_improvements == 0, np.nan, best_improvements)
    improvement_over_best = 100 * (improvements[-1] - best_improvements) / denom
    improvements = np.vstack([improvements, improvement_over_best])
    improvements_display = improvements.copy()
    improvements_display[-1, :] = np.nan
    n_rows, n_cols = improvements.shape
    vmin, vmax = improvements[:-1].min(0), improvements[:-1].max(0)
    cmap_red, cmap_blue = plt.cm.Reds, plt.cm.Blues_r
    for j in range(n_cols):
        cmap = cmap_red if j % 2 == 0 else cmap_blue
        cmap = cmap.copy()
        cmap.set_bad(color="white")
        norm = mpl.colors.Normalize(vmin=vmin[j] if j % 2 == 1 else 0, vmax=vmax[j] if j % 2 == 0 else 0)
        ax.imshow(improvements_display[:, j:j+1], cmap=cmap, norm=norm,
                aspect="auto", extent=[j - 0.5, j + 0.5, 0, n_rows], origin="lower")
    for (i, j), val in np.ndenumerate(improvements):
        if i == n_rows - 1:
            color = "forestgreen" if val >= 0 else "darkred"
            label = f"{val:+.1f} \\%"
            fontsize = 16
        else:
            cmap = cmap_red if j % 2 == 0 else cmap_blue
            norm = mpl.colors.Normalize(vmin=vmin[j] if j % 2 == 1 else 0, vmax=vmax[j] if j % 2 == 0 else 0)
            r, g, b, _ = cmap(norm(val))
            lum = 0.299 * r + 0.587 * g + 0.114 * b
            color = "white" if lum < 0.5 else "black"
            label = f"{val:.2f}"
            fontsize = 14
        ax.text(j, i + 0.5, label, ha="center", va="center", fontsize=fontsize, color=color)
    ax.set_title('Median change in property', fontsize=32, pad=24)
    ax.set_xlim(-0.5, n_cols - 0.5)
    ax.set_xticks(np.arange(n_cols))
    ax.set_xticklabels([r'$\texttt{OpenVaccine}~(+)$', r'$\texttt{OpenVaccine}~(-)$',
                        r'$\texttt{Zebrafish}~(+)$', r'$\texttt{Zebrafish}~(-)$',
                        r'$\texttt{Ribosome}~(+)$', r'$\texttt{Ribosome}~(-)$'],
                    fontsize=20, rotation=30)
    ax.tick_params(axis='x', which='both', bottom=False, top=False, length=0)
    ax.set_yticks(np.arange(n_rows) + 0.5)
    ax.set_yticklabels(options, rotation=0, fontsize=20, ha='right')
    for tick in ax.get_yticklabels():
        if r'$\texttt{RNAGenScape}$' in tick.get_text():
            tick.set_fontsize(24)
    ax.set_frame_on(False)
    ax.invert_yaxis()
    # n_rows, n_cols = improvements.shape
    # rect = patches.Rectangle((-0.5, n_rows - 1), n_cols, 1, fill=False, edgecolor='black', linewidth=3)
    # rect.set_clip_on(False)
    # ax.add_patch(rect)

    ax = fig.add_subplot(1, 2, 2)
    percentages = np.stack([results_openvaccine_pct_pos, results_openvaccine_pct_neg,
                            results_zebrafish_pct_pos, results_zebrafish_pct_neg,
                            results_ribosome_pct_pos, results_ribosome_pct_neg], axis=1)
    base_percentages = percentages[:-1]
    best_percentages = base_percentages.max(0)
    pct_improvement_over_best = 100 * (percentages[-1] - best_percentages) / best_percentages
    percentages = np.vstack([percentages, pct_improvement_over_best])
    percentages_display = percentages.copy()
    percentages_display[-1, :] = np.nan
    n_rows, n_cols = percentages.shape
    vmin, vmax = percentages[:-1].min(0), percentages[:-1].max(0)
    cmap_red = plt.cm.Reds.copy()
    cmap_red.set_bad(color="white")
    for j in range(n_cols):
        norm = mpl.colors.Normalize(vmin=max(50, vmin[j]), vmax=vmax[j])
        ax.imshow(percentages_display[:, j:j+1], cmap=cmap_red, norm=norm,
                aspect="auto", extent=[j - 0.5, j + 0.5, 0, n_rows], origin="lower")
    for (i, j), val in np.ndenumerate(percentages):
        if i == n_rows - 1:
            color = "forestgreen" if val >= 0 else "darkred"
            label = f"{val:+.1f} \\%"
            fontsize = 16
        else:
            norm = mpl.colors.Normalize(vmin=max(50, vmin[j]), vmax=vmax[j])
            r, g, b, _ = cmap_red(norm(val))
            lum = 0.299 * r + 0.587 * g + 0.114 * b
            color = "white" if lum < 0.5 else "black"
            label = f"{val:.1f} \\%"
            fontsize = 14
        ax.text(j, i + 0.5, label, ha="center", va="center",
                fontsize=fontsize, color=color)
    ax.set_title('Success rate', fontsize=32, pad=24)
    ax.set_xlim(-0.5, n_cols - 0.5)
    ax.set_xticks(np.arange(n_cols))
    ax.set_xticklabels([r'$\texttt{OpenVaccine}~(+)$', r'$\texttt{OpenVaccine}~(-)$',
                        r'$\texttt{Zebrafish}~(+)$', r'$\texttt{Zebrafish}~(-)$',
                        r'$\texttt{Ribosome}~(+)$', r'$\texttt{Ribosome}~(-)$'],
                    fontsize=20, rotation=30)
    ax.tick_params(axis='x', which='both', bottom=False, top=False, length=0)
    ax.set_yticks([])
    # ax.set_yticks(np.arange(n_rows) + 0.5)
    # ax.set_yticklabels(options, fontsize=16, ha='right')
    ax.set_frame_on(False)
    ax.invert_yaxis()
    # rect = patches.Rectangle((-0.5, n_rows - 1), n_cols, 1,
    #                         fill=False, edgecolor='black', linewidth=3)
    # rect.set_clip_on(False)
    # ax.add_patch(rect)

    fig.tight_layout(pad=2)
    os.makedirs('./figures', exist_ok=True)
    fig.savefig('./figures/results_comparison_optimization.png', dpi=300)
    plt.close(fig)
assets/figures4papers/figure_RNAGenScape/plot_hole_manifold.py
import os
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap

def function(x, y):
    z = 0.6 * np.exp(-((x - 1)**2 + (y + 1)**2))
    z += 0.5 * np.exp(-((x - 1)**2 + (y - 4)**2))
    z += 0.3 * np.exp(-((x - 2)**2 + (y - 2)**2))
    z += 0.2 * np.exp(-((x + 3)**2 + (y + 1)**2))
    z += 0.3 * np.exp(-((x + 1)**2 + (y + 1)**2))
    z -= 0.1 * np.exp(-((x + 1)**2 + (y - 2)**2))
    z += 0.3 * np.exp(-((x + 2)**2 + (y - 2)**2))
    z += 0.3 * np.exp(-((x + 2)**2 + (y - 1)**2))
    return z

x = np.linspace(-3, 3, 200)
y = np.linspace(-3, 3, 200)
X, Y = np.meshgrid(x, y)
Z = function(X, Y)

peak_i, peak_j = np.unravel_index(np.argmax(Z), Z.shape)
x_peak, y_peak = X[peak_i, peak_j], Y[peak_i, peak_j]

rng = np.random.default_rng(42)
num_patches = 30
r = 0.3
r_forbid = 0.9
pad = r + 0.1

centers = []
attempts = 0
while len(centers) < num_patches and attempts < 5000:
    cx = rng.uniform(x.min()+pad, x.max()-pad)
    cy = rng.uniform(y.min()+pad, y.max()-pad)
    if (cx - x_peak)**2 + (cy - y_peak)**2 < r_forbid**2:
        attempts += 1
        continue
    ok = True
    for px, py in centers:
        if (cx - px)**2 + (cy - py)**2 < (1.6*r)**2:
            ok = False
            break
    if ok:
        centers.append((cx, cy))
    attempts += 1

mask = np.zeros_like(Z, dtype=bool)
for cx, cy in centers:
    mask |= (X - cx)**2 + (Y - cy)**2 <= r**2

cmap = LinearSegmentedColormap.from_list(
    "softgreen", ["#e9f5ec", "#d9f0e1", "#c9e5d3", "#a9cbb8", "#7f9e8a", "#4f5c4f"], N=256
)

norm = plt.Normalize(Z.min(), Z.max())
facecolors = cmap(norm(Z))
facecolors_with_gray = facecolors.copy()
facecolors_with_gray[mask] = [0.7, 0.7, 0.7, 0.5]

fig = plt.figure(figsize=(14, 6))
for i, (fc, title) in enumerate([(facecolors, "Smooth Manifold"),
                                 (facecolors_with_gray, "Manifold with Gray Patches")], 1):
    ax = fig.add_subplot(1, 2, i, projection="3d")
    ax.plot_surface(
        X, Y, Z,
        facecolors=fc,
        rstride=4, cstride=4,
        linewidth=0.05, edgecolor="k",
        antialiased=True, shade=False, alpha=0.95
    )
    ax.set_title(title, fontsize=14)
    ax.set_xticks([]); ax.set_yticks([]); ax.set_zticks([])
    for a in (ax.xaxis, ax.yaxis, ax.zaxis):
        a.pane.set_visible(False)
        a.line.set_color((0,0,0,0))
    ax.set_box_aspect([1, 1, 0.5])
    ax.view_init(elev=20, azim=50)

fig.tight_layout(pad=2)
os.makedirs("./figures", exist_ok=True)
fig.savefig("./figures/manifold_holes.png", dpi=300)
assets/figures4papers/figure_RNAGenScape/plot_manifold.py
import os
import numpy as np
import matplotlib.pyplot as plt


def function(x, y):
    z = 0.6 * np.exp(-((x - 1)**2 + (y + 1)**2))
    z += 0.5 * np.exp(-((x - 1)**2 + (y - 4)**2))
    z += 0.3 * np.exp(-((x - 2)**2 + (y - 2)**2))
    z += 0.2 * np.exp(-((x + 3)**2 + (y + 1)**2))
    z += 0.3 * np.exp(-((x + 1)**2 + (y + 1)**2))
    z -= 0.1 * np.exp(-((x + 1)**2 + (y - 2)**2))
    z += 0.3 * np.exp(-((x + 2)**2 + (y - 2)**2))
    z += 0.3 * np.exp(-((x + 2)**2 + (y - 1)**2))
    return z

if __name__ == '__main__':
    # Generate coordinates
    x = np.linspace(-3, 3, 200)
    y = np.linspace(-3, 3, 200)
    x, y = np.meshgrid(x, y)

    # Define a multi-well "energy" function (inverted to form valleys)
    z = function(x, y)

    # Set up plot
    fig = plt.figure(figsize=(10, 7))
    ax = fig.add_subplot(1, 1, 1, projection='3d')

    # Plot the surface with smooth shading
    ax.plot_surface(
        x, y, z,
        cmap='coolwarm',
        edgecolor='none',
        linewidth=0,
        antialiased=True,
        alpha=0.95,
    )

    # # Plot descent path
    # path_x = np.linspace(-2.5, 1, 100)
    # path_y = np.linspace(2.5, -1, 100)
    # path_z = function(path_x, path_y)
    # ax.plot(path_x, path_y, path_z, color='red', linestyle='--', linewidth=3)

    # Aesthetics
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_zticks([])
    ax.xaxis.pane.set_visible(False)
    ax.yaxis.pane.set_visible(False)
    ax.zaxis.pane.set_visible(False)
    ax.xaxis.line.set_color((0.0, 0.0, 0.0, 0.0))
    ax.yaxis.line.set_color((0.0, 0.0, 0.0, 0.0))
    ax.zaxis.line.set_color((0.0, 0.0, 0.0, 0.0))
    ax.set_box_aspect([1, 1, 0.5])
    ax.view_init(elev=20, azim=50)

    fig.tight_layout(pad=2)
    os.makedirs('./figures', exist_ok=True)
    fig.savefig('./figures/manifold.png')
assets/figures4papers/figure_RNAGenScape/plot_sweep.py
import os
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator


# results_increase = {
#     r'Median property change': [0.1125, 0.44046, 0.46006, 0.45625, 0.46510],
#     r'Percentage improved $\uparrow$': [55.15, 70.11, 71.02, 71.46, 71.52],
#     r'Latent space distance $\downarrow$': [0.11868, 0.11877, 0.11961, 0.11964, 0.11999],
# }
# results_decrease = {
#     r'Median property change': [-1.1246, -1.5761, -1.62873, -1.65457, -1.65872],
#     r'Percentage improved $\uparrow$': [79.39, 86.31, 87.08, 87.24, 87.01],
#     r'Latent space distance $\downarrow$': [0.16017, 0.12758, 0.12141, 0.12024, 0.12015],
# }
results_increase = {
  r'Median change in property': [0.292, 0.5047563, 0.57401, 0.55921, 0.5471513271],
  r'Success rate $\uparrow$': [67.6, 77.3, 79.9, 79.4, 79.6],
}
results_decrease = {
  r'Median change in property': [-0.90106, -1.27954, -1.3083, -1.2785, -1.29],
  r'Success rate $\uparrow$': [75.9, 84.7, 84.9, 84.7, 85.1],
}


if __name__ == '__main__':
    plt.rcParams['text.usetex'] = True
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 15
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 2

    x_label = 'Optimization step'
    x_values = [1, 5, 10, 20, 40]

    keys = ['Median change in property', 'Success rate']
    fig = plt.figure(figsize=(4.5 * len(keys), 4))
    for fig_idx, y_key in enumerate(keys):
        ax = fig.add_subplot(1, len(keys), fig_idx + 1)
        for key in results_increase.keys():
            if y_key in key:
                y_values_increase = results_increase[key]
                y_label_increase = key
        for key in results_decrease.keys():
            if y_key in key:
                y_values_decrease = results_decrease[key]
                y_label_decrease = key

        #0F4D92
        ax.plot(x_values,
                y_values_increase,
                linestyle='-', linewidth=3,
                marker='o', markersize=8,
                label='increase property',
                alpha=0.8,
                color="#ea84dd")
        ax.plot(x_values,
                y_values_decrease,
                linestyle='-', linewidth=3,
                marker='o', markersize=8,
                label='decrease property',
                alpha=0.8,
                color="#0f4d92")
        if fig_idx == 1:
            ax.set_ylim([0, 100])
        ax.set_xticks(x_values)
        ax.set_xlabel(x_label, fontsize=16)
        ax.set_ylabel(y_label_increase, fontsize=16)
        ax.yaxis.set_major_locator(MaxNLocator(nbins=5))
        if fig_idx == len(keys) - 1:
            ax.legend(loc='lower right', frameon=False)

    fig.tight_layout(pad=1)
    os.makedirs('./figures', exist_ok=True)
    fig.savefig('./figures/results_sweep.png', dpi=300)
assets/figures4papers/figure_VIGIL/plot_ablation.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec


data_ablation = {
    'methods': [
        'DPO',
        'DA-DPO',
        'VIGIL (Ours)',
    ],
    'colors': [
        "#D88F8A",
        "#8BCF8B",
        "#0F4D92"
    ],
    '$\beta$': [0.05, 0.1, 0.2, 0.5],
    '$\lambda$': [0.1, 0.5, 1.0, 2.0],
    'data_fraction': [0.1, 0.25, 0.5, 1.0],
    'results': {
        'SFT': np.array([82.1]),  # POPE_Adv
        # This is 3 methods on 4 beta values.
        '$\beta$': np.array([
            [79.5, 82.8, 81.0, 76.2],
            [83.2, 84.2, 83.5, 81.0],
            [86.8, 86.9, 86.5, 85.8],
        ]),
        # This is our method.
        '$\lambda$': np.array([
            [84.5, 86.1, 86.9, 87.2], # POPE_Adv
            [49.8, 49.6, 49.5, 48.8], # MathVista
        ]),
        'data_fraction': np.array([
            [82.78, 83.19, 85.05, 85.67],
            [84.04, 85.46, 87.07, 87.81],
            [86.69, 88.18, 89.17, 89.82],
        ])
    }
}


def plot_curves(data_ablation):
    """
    Left panel: ablation on hyperparameter beta (x = beta values, y = score, 3 curves for 3 methods).
    Right panel: blank.
    """
    methods = data_ablation['methods']
    colors = data_ablation['colors']

    fig, axes = plt.subplots(1, 3, figsize=(27, 6), gridspec_kw={'width_ratios': [1.1, 1, 1]})

    ax = axes[0]
    y_ticks = [78, 82, 86, 90]
    data_fraction_key = 'data_fraction'
    data_fraction_vals = np.asarray(data_ablation[data_fraction_key])
    results_data_fraction = data_ablation['results'][data_fraction_key]  # shape (n_methods, n_data_fraction)
    x_pos = np.arange(len(data_fraction_vals))
    y_sft = data_ablation['results']['SFT']
    # SFT only horizontal line.
    ax.plot([x_pos[0] - 1, x_pos[-1] + 0.2], [y_sft, y_sft], color='black', alpha=0.3, linewidth=4, linestyle='--', label='SFT only')
    # Ours with 25% data horizontal line.
    ax.plot([x_pos[0] - 0.1, x_pos[-1] + 0.1], [results_data_fraction[-1][1], results_data_fraction[-1][1]], color=colors[-1], linewidth=3, linestyle=':')
    for m, (method, color) in enumerate(zip(methods, colors)):
        y = results_data_fraction[m]
        ax.plot(x_pos, y, color=color, linewidth=3, marker='o', markersize=10, label=method)

    ax.set_xlabel(r'Fraction of data used for post-training', fontsize=28, labelpad=18)
    ax.set_xlim(x_pos[0] - 0.5, x_pos[-1] + 0.4)
    ax.set_xticks(x_pos)
    ax.set_xticklabels([f'{item:.0%}' for item in data_fraction_vals])
    ax.set_ylabel('$POPE_{Adv}$' + r'$\uparrow$', fontsize=28, fontfamily='monospace', labelpad=12)
    ax.set_yticks(y_ticks)
    ax.set_yticklabels(y_ticks)
    ax.tick_params(labelsize=24, length=8, width=1.5)
    ax.legend(fontsize=24, loc='lower center', ncols=2, frameon=False)

    ax = axes[1]
    y_ticks = [75, 79, 84, 88]
    beta_key = '$\beta$'
    beta_vals = np.asarray(data_ablation[beta_key])
    results_beta = data_ablation['results'][beta_key]  # shape (n_methods, n_beta)
    x_pos = np.arange(len(beta_vals))
    for m, (method, color) in enumerate(zip(methods, colors)):
        y = results_beta[m]
        ax.plot(x_pos, y, color=color, linewidth=3, marker='o', markersize=10, label=method)

    ax.set_xlabel(r'Hyperparameter $\beta$', fontsize=28, labelpad=18)
    ax.set_xticks(x_pos)
    ax.set_xticklabels([str(b) for b in beta_vals])
    ax.set_ylabel('$POPE_{Adv}$' + r'$\uparrow$', fontsize=28, fontfamily='monospace', labelpad=12)
    ax.set_yticks(y_ticks)
    ax.set_yticklabels(y_ticks)
    ax.tick_params(labelsize=24, length=8, width=1.5)
    ax.legend(fontsize=24, loc='lower center', frameon=False)

    ax = axes[2]
    y_ticks = [84, 85, 86, 87, 88]
    lambda_key = '$\lambda$'
    lambda_vals = np.asarray(data_ablation[lambda_key])
    results_lambda = data_ablation['results'][lambda_key]  # shape (2, n_beta)
    assert len(results_lambda) == 2
    x_pos = np.arange(len(lambda_vals))
    y = results_lambda[0]
    ax.plot(x_pos, y, color=colors[-1], linewidth=3, marker='o', markersize=10, label=methods[-1], alpha=0.4)
    ax.set_xlabel(r'Hyperparameter $\lambda$', fontsize=28, labelpad=18)
    ax.set_xticks(x_pos)
    ax.set_xlim(x_pos[0] - 0.5, x_pos[-1] + 0.5)
    ax.set_xticklabels([str(b) for b in lambda_vals])
    ax.set_yticks(y_ticks)
    ax.set_yticklabels(y_ticks)
    ax.set_ylabel('$POPE_{Adv}$' + r'$\uparrow$', fontsize=28, fontfamily='monospace', labelpad=12, color=colors[-1], alpha=0.4)
    ax.tick_params(labelsize=24, length=8, width=1.5)
    ax.legend(fontsize=24, loc='lower center', frameon=False)

    ax2 = ax.twinx()
    ax2.spines['right'].set_visible(True)
    y = results_lambda[1]
    ax2.set_ylabel('$MathVista$' + r'$\uparrow$', fontsize=28, fontfamily='monospace', labelpad=36, rotation=270, color=colors[-1], alpha=1.0)
    ax2.plot(x_pos, y, color=colors[-1], linewidth=3, marker='o', markersize=10, label=methods[-1], alpha=1.0)
    ax2.plot(x_pos, y, color=colors[-1], linewidth=3, marker='o', markersize=10, label=methods[-1], alpha=1.0)
    ax2.set_yticks([48, 48.5, 49, 49.5, 50])
    ax2.set_yticklabels([48, 48.5, 49, 49.5, 50])
    ax2.tick_params(labelsize=24, length=8, width=1.5)
    ax2.legend(fontsize=24, loc='lower center', frameon=False)

    # Right panel: at each x, print average of the two metrics at the top
    y_top = ax.get_ylim()[1] - 0.2
    for i in range(len(x_pos)):
        avg = (results_lambda[0][i] + results_lambda[1][i]) / 2
        ax.text(x_pos[i], y_top, f'Mean = {avg:.1f}', ha='center', va='bottom', fontsize=18)

    fig.tight_layout(pad=0.5)
    fig.subplots_adjust(wspace=0.3)
    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/ablation_curves.png', dpi=300)
    plt.close(fig)
    return


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.linewidth'] = 3

    plot_curves(data_ablation)
assets/figures4papers/figure_VIGIL/plot_comparison_radar.py
import os
import numpy as np
from matplotlib import pyplot as plt


data_comparison = {
    'methods': [
        r'DPO',
        r'DA-DPO',
        r'VIGIL (Ours)',
    ],
    'colors': [
        "#D88F8A",
        "#8BCF8B",
        "#0F4D92"
    ],
    'results': {
        'Qwen2.5-VL-7B\nPOPE$_{Adv}$': np.array([82.8, 84.2, 86.9]),
        'LLaVA-OneVision-7B\nPOPE$_{Adv}$': np.array([82.8, 84.2, 86.9]),
        'InternVL2.5-26B\nPOPE$_{Adv}$': np.array([85.5, 86.8, 89.4]),
        'Qwen2.5-VL-72B\nPOPE$_{Adv}$': np.array([84.5, 87.4, 89.8]),
        'Qwen2.5-VL-7B\nMathVista': np.array([48.0, 48.8, 49.5]),
        'LLaVA-OneVision-7B\nMathVista': np.array([50.8, 51.5, 52.8]),
        'InternVL2.5-26B\nMathVista': np.array([57.9, 58.8, 60.1]),
        'Qwen2.5-VL-72B\nMathVista': np.array([54.1, 55.4, 56.6]),
        'Qwen2.5-VL-7B\nMMBench': np.array([71.2, 72.0, 72.5]),
        'LLaVA-OneVision-7B\nMMBench': np.array([72.5, 73.0, 73.8]),
        'InternVL2.5-26B\nMMBench': np.array([79.5, 80.1, 81.3]),
        'Qwen2.5-VL-72B\nMMBench': np.array([77.2, 77.8, 78.5]),
    },
}


def _task_suffix(subtask_name):
    """Benchmark = part after the first newline (e.g. 'Qwen2.5-VL-7B\\nMathVista' -> 'MathVista')."""
    return subtask_name.split('\n', 1)[-1] if '\n' in subtask_name else subtask_name


def plot_radar(data_comparison):
    """
    Single radar chart. Each axis = one subtask; one curve per method.
    Each benchmark (task suffix after \\n) has its own radii list: values are normalized
    to display range 45-90 using that benchmark's min/max from its radii list, and
    radius labels on each spoke show that benchmark's tick values.
    """
    methods = data_comparison['methods']
    colors = data_comparison['colors']
    n_methods = len(methods)
    benchmark_radii = {
        'POPE$_{Adv}$': [75, 80, 85, 91],
        'MathVista': [30, 40, 50, 61],
        'MMBench': [40, 55, 70, 85],
    }

    # results is directly subtask_name -> value array
    task_dict = data_comparison['results']
    subtask_names = list(task_dict.keys())
    value_arrays = list(task_dict.values())

    fig = plt.figure(figsize=(12, 10))
    ax = fig.add_subplot(111, projection='polar')
    n_subtasks = len(subtask_names)
    subtask_benchmarks = [_task_suffix(st) for st in subtask_names]

    # Per-benchmark (r_min, r_max) from that benchmark's radii list
    def limits_for_benchmark(bench):
        radii = benchmark_radii.get(bench, [0, 100])
        return (min(radii), max(radii))

    # One polygon per method: normalize each spoke by its benchmark's radii range, then map to 45-90
    angles = np.linspace(2 * np.pi, 0, n_subtasks, endpoint=False)
    angles_closed = np.append(angles, angles[0])

    for m in range(n_methods):
        vals = np.array([v[m] for v in value_arrays], dtype=float)
        mask = np.isnan(vals)
        if np.any(mask):
            vals = vals.copy()
            fill = 0.0 if np.all(mask) else np.nanmean(vals)
            vals[mask] = fill
        # Normalize per spoke: benchmark's (r_min, r_max) -> display 45-90. One vertex per subtask (no interpolation).
        normalized = np.zeros_like(vals)
        for i, (v, bench) in enumerate(zip(vals, subtask_benchmarks)):
            r_lo, r_hi = limits_for_benchmark(bench)
            span = r_hi - r_lo
            if span <= 0:
                normalized[i] = 45 + 45 * 0.5
            else:
                n = np.clip((v - r_lo) / span, 0.0, 1.0)
                normalized[i] = 45 + 45 * n
        # Closed polygon: exact data at each angle, then back to first (no extra interpolation)
        vals_closed = np.append(normalized, normalized[0])
        ax.plot(angles_closed, vals_closed, color=colors[m], linewidth=2, label=methods[m])
        ax.fill(angles_closed, vals_closed, color=colors[m], alpha=0.05)
        # Mark actual vertices so it's clear each point is a real data value
        ax.scatter(angles, normalized, color=colors[m], s=18, zorder=5, edgecolors='none')

    ax.set_ylim(45, 90)
    ax.set_theta_zero_location('N')
    for spine in ax.spines.values():
        spine.set_visible(False)
    r_min_disp, r_max_disp = ax.get_ylim()
    ax.grid(False)
    # Outer boundary
    ax.plot(angles_closed, np.full_like(angles_closed, r_max_disp), color='k', linewidth=0.8, zorder=4)
    # Radial spokes: one per angle
    for a in angles:
        ax.plot([a, a], [r_min_disp, r_max_disp], color='gray', linewidth=0.5, zorder=4)
    # Benchmark-specific contour polygons: one polygon per level index k (innermost=0, ...).
    max_levels = max(len(benchmark_radii.get(b, [])) for b in subtask_benchmarks)
    for k in range(max_levels):
        display_radii = np.zeros(n_subtasks)
        for i, bench in enumerate(subtask_benchmarks):
            radii_list = benchmark_radii.get(bench, [])
            if not radii_list:
                display_radii[i] = 45 + 22.5
                continue
            contour_val = radii_list[k] if k < len(radii_list) else radii_list[-1]
            r_lo, r_hi = limits_for_benchmark(bench)
            span = r_hi - r_lo
            if span <= 0:
                display_radii[i] = 45 + 22.5
            else:
                frac = (contour_val - r_lo) / span
                display_radii[i] = 45 + 45 * np.clip(frac, 0.0, 1.0)
        contour_closed = np.append(display_radii, display_radii[0])
        ax.plot(angles_closed, contour_closed, color='k', linewidth=0.6, zorder=4, label='_nolegend_')
    ax.set_yticks([r_max_disp])
    ax.set_yticklabels([])
    ax.set_rlabel_position(0)
    ax.set_xticks(angles)
    ax.set_xticklabels([])
    # Per-spoke radius labels: skip innermost tick to avoid clutter
    for angle, bench in zip(angles, subtask_benchmarks):
        radii_list = benchmark_radii.get(bench, [])
        if len(radii_list) <= 1:
            continue
        r_lo, r_hi = limits_for_benchmark(bench)
        span = r_hi - r_lo
        for tick_val in radii_list[1:]:  # skip innermost (smallest) number
            if span <= 0:
                display_r = 45 + 22.5
            else:
                frac = (tick_val - r_lo) / span
                display_r = 45 + 45 * np.clip(frac, 0.0, 1.0)
            lbl = f'{tick_val:.0f}' if tick_val == int(tick_val) else f'{tick_val:.1f}'
            rot = np.degrees(angle)
            ax.text(angle, display_r + 1, lbl, fontsize=12, ha='center', va='center',
                    rotation=rot, rotation_mode='anchor',
                    transform=ax.transData, clip_on=False)
    # One label per subtask (angle)
    for angle, label in zip(angles, subtask_names):
        offset = 8 + 10 * np.abs(np.sin(angle))
        label_r = r_max_disp + offset
        ax.text(angle, label_r, label, fontsize=14, ha='center', va='center',
                transform=ax.transData, clip_on=False, fontfamily='monospace')
    ax.legend(loc='upper right', bbox_to_anchor=(1.40, 0.05), fontsize=15, frameon=False)

    fig.tight_layout(pad=2)
    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/comparison_radar.png', dpi=300, bbox_inches='tight')
    plt.close(fig)
    return


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = 3

    plot_radar(data_comparison)
assets/figures4papers/figure_VIGIL/plot_concept.py
import os
import numpy as np
from matplotlib import pyplot as plt
from scipy.stats import gaussian_kde
from scipy.interpolate import CubicSpline


def _gauss(x, mu, sig):
    y = np.exp(-0.5 * ((x - mu) / sig) ** 2)
    return y / (y.max() + 1e-12)


def _sample_tube(center_curve, t_samples, rng, sigma_u=0.08, sigma_v=0.18):
    pts = center_curve(t_samples)
    eps = 1e-3
    pts_f = center_curve(np.clip(t_samples + eps, 0, 1))
    tan = pts_f - pts
    tan_norm = np.linalg.norm(tan, axis=1, keepdims=True) + 1e-9
    t_hat = tan / tan_norm
    n_hat = np.stack([-t_hat[:, 1], t_hat[:, 0]], axis=1)
    u = rng.normal(0, sigma_u, size=(len(t_samples), 1))
    v = rng.normal(0, sigma_v, size=(len(t_samples), 1))
    return pts + u * n_hat + v * t_hat


def _mixture_t(n, centers, scales, weights, rng):
    weights = np.array(weights, dtype=float)
    weights /= weights.sum()
    comp = rng.choice(len(centers), size=n, p=weights)
    t = rng.normal(np.array(centers)[comp], np.array(scales)[comp])
    return np.clip(t, 0, 1)


def _kde_prepare(P, grid=280, pad_x=1.8, pad_y=1.3):
    Xv, Yv = P[:, 0], P[:, 1]
    kde = gaussian_kde(np.vstack([Xv, Yv]))
    xmin, xmax = Xv.min() - pad_x, Xv.max() + pad_x
    ymin, ymax = Yv.min() - pad_y, Yv.max() + pad_y
    xx, yy = np.mgrid[xmin:xmax:complex(grid), ymin:ymax:complex(grid)]
    zz = kde(np.vstack([xx.ravel(), yy.ravel()])).reshape(xx.shape)
    return xx, yy, zz, xmin, xmax, ymin, ymax


def plot_distribution(ax):
    color_prior = "#6F6F6F"
    color_see = "#0F4D92"
    color_blind = "#D88F8A"

    x = np.linspace(0.0, 1, 1000)
    p_prior = _gauss(x, 0.30, 0.07)
    p_see = _gauss(x, 0.72, 0.07)
    p_blind = 0.30 * _gauss(x, 0.30, 0.12) + 0.22

    y_star = 0.72
    see_star = np.interp(y_star, x, p_see)
    blind_star = np.interp(y_star, x, p_blind)

    ax.plot(x, p_prior, color=color_prior, linewidth=2.2, label=r"$P(y\mid x_t)$")
    ax.fill_between(x, 0, p_prior, color=color_prior, alpha=0.12)
    ax.plot(x, p_blind, color=color_blind, linewidth=2.2, label=r"$P(y\mid x_v^{\emptyset},x_t)$")
    ax.fill_between(x, 0, p_blind, color=color_blind, alpha=0.12)
    ax.plot(x, p_see, color=color_see, linewidth=2.8, label=r"$P(y\mid x_v,x_t)$")
    ax.fill_between(x, 0, p_see, color=color_see, alpha=0.12)

    ax.vlines(y_star, 0, 1.0, linewidth=2, linestyle=":", alpha=0.5, color="black")
    ax.annotate(
        "",
        xy=(y_star, see_star),
        xytext=(y_star, blind_star),
        arrowprops=dict(arrowstyle="<->", linewidth=2),
    )
    ax.text(y_star + 0.02, 0.5 * (see_star + blind_star), "      VIG",
            ha="left", va="center", fontsize=24, fontfamily='helvetica')

    ax.set_xticks([0, 0.5, 1])
    ax.set_xticklabels([-1, 0, 1])
    ax.set_xlim(0, 1.05)
    ax.set_yticks([0, 0.2, 0.4, 0.6, 0.8, 1.0])
    ax.set_ylim(0, 1.25)
    ax.set_xlabel("Answer space (arbitrary units)", fontsize=28, labelpad=12)
    ax.set_ylabel("Probability", fontsize=28, labelpad=18)
    ax.spines['left'].set_linewidth(3)
    ax.spines['bottom'].set_linewidth(3)
    ax.tick_params(width=1.5, length=8, labelsize=24)
    ax.legend(loc="upper center", frameon=False, ncols=3, prop={"family": "monospace", "size": 24})


def plot_manifold(ax):
    rng = np.random.default_rng(42)
    xp = np.array([0.0, 0.18, 0.45, 0.72, 1.0])
    yt = np.array([0.15, 0.28, 0.18, 0.05, 0.10])
    ym = np.array([0.78, 0.70, 0.60, 0.68, 0.80])

    cs_t = CubicSpline(xp, yt, bc_type="natural")
    cs_m = CubicSpline(xp, ym, bc_type="natural")

    def curve_text(t):
        x = t
        y = cs_t(x) + 0.03 * np.sin(6 * np.pi * t)
        return np.stack([x, y], axis=1)

    def curve_mm(t):
        x = t
        y = cs_m(x) + 0.02 * np.sin(4 * np.pi * t + 0.6)
        return np.stack([x, y], axis=1)

    N = 2600
    t_text = _mixture_t(N, [0.20, 0.55, 0.82], [0.07, 0.09, 0.06], [0.35, 0.40, 0.25], rng)
    t_mm = _mixture_t(N, [0.18, 0.52, 0.80], [0.06, 0.10, 0.07], [0.30, 0.45, 0.25], rng)

    pts_text = _sample_tube(curve_text, t_text, rng, sigma_u=0.09, sigma_v=0.14)
    pts_mm = _sample_tube(curve_mm, t_mm, rng, sigma_u=0.08, sigma_v=0.13)

    A = np.array([[10.0, 0.0], [0.0, 3.5]])
    b_text = np.array([0.6, 0.9])
    b_mm = np.array([0.6, 2.4])

    P_t = pts_text @ A.T + b_text
    P_m = pts_mm @ A.T + b_mm

    tt = np.linspace(0.08, 0.95, 450)
    ridge_t = curve_text(tt) @ A.T + b_text
    ridge_m = curve_mm(tt) @ A.T + b_mm

    t_mid = 0.42
    idx_mid = np.searchsorted(tt, t_mid)
    u = np.linspace(0, 1, idx_mid)
    smooth = 3 * u**2 - 2 * u**3
    x_mix = (1 - smooth) * ridge_t[:idx_mid, 0] + smooth * ridge_m[:idx_mid, 0]
    y_mix = (1 - smooth) * ridge_t[:idx_mid, 1] + smooth * ridge_m[:idx_mid, 1] + 0.10 * np.sin(np.pi * u)
    x_ours = np.concatenate([x_mix, ridge_m[idx_mid:, 0]])
    y_ours = np.concatenate([y_mix, ridge_m[idx_mid:, 1]])

    star_inds = np.linspace(0, len(tt) - 1, 10, dtype=int)
    S_t = ridge_t[star_inds]
    star_inds_ours = np.linspace(0, len(x_ours) - 1, 10, dtype=int)
    S_m = np.column_stack([x_ours[star_inds_ours], y_ours[star_inds_ours]])

    xx_m, yy_m, zz_m, xmin_m, xmax_m, ymin_m, ymax_m = _kde_prepare(P_m)
    xx_t, yy_t, zz_t, xmin_t, xmax_t, ymin_t, ymax_t = _kde_prepare(P_t)

    xmin = min(xmin_m, xmin_t) - 0.6
    xmax = max(xmax_m, xmax_t) + 0.6
    ymin = min(ymin_m, ymin_t) - 0.4
    ymax = max(ymax_m, ymax_t) + 0.8

    levels_m = np.quantile(zz_m, np.linspace(0.72, 0.99, 10))
    levels_t = np.quantile(zz_t, np.linspace(0.72, 0.99, 10))

    blue = "#2E74B5"
    blue_pts = "#1f77b4"
    gray = "#6F6F6F"
    gray_pts = "#6b7280"
    red = "#D62728"

    ax.axis("off")
    ax.scatter(P_m[:, 0], P_m[:, 1], s=10, alpha=0.10, color=blue_pts, zorder=1)
    ax.scatter(P_t[:, 0], P_t[:, 1], s=10, alpha=0.10, color=gray_pts, zorder=1)
    ax.contour(xx_m, yy_m, zz_m, levels=levels_m, colors=blue, linewidths=1.6, alpha=0.3, zorder=2)
    ax.contour(xx_t, yy_t, zz_t, levels=levels_t, colors=gray, linewidths=1.6, alpha=0.3, zorder=2)
    ax.scatter(S_m[:, 0], S_m[:, 1], marker="*", s=185, color=red, edgecolor="white", linewidth=0.6, zorder=5)
    ax.scatter(S_t[:, 0], S_t[:, 1], marker="*", s=185, color=red, edgecolor="white", linewidth=0.6, zorder=5)

    bbox = dict(boxstyle="round,pad=0.2", facecolor="white", edgecolor="none", alpha=1)
    ax.text(xmin + 0.9, ymax + 0.2, r"Multimodal manifold $\mathcal{M}_\text{mm}$", va="top", bbox=bbox, size=24)
    ax.text(xmin + 0.9, ymin, r"Textual manifold $\mathcal{M}_\text{t}$", va="bottom", bbox=bbox, size=24)

    x0, y0 = xmin + 2.1, (ymin + ymax) / 2 - 0.2
    t0 = 0.12
    z_t0 = (curve_text(np.array([t0])) @ A.T + b_text)[0]
    z_m0 = (curve_mm(np.array([t0])) @ A.T + b_mm)[0]

    ax.plot([x0], [y0], marker="o", markersize=7, color="black", zorder=6)
    ax.plot([x0, x0], [z_t0[1], z_m0[1]], linestyle="--", linewidth=1.6, color="#D62728", alpha=0.9, zorder=3)
    ax.plot([x0], [z_t0[1]], marker="o", markersize=6, color=gray, zorder=6)
    ax.plot([x0], [z_m0[1]], marker="o", markersize=6, color=blue, zorder=6)
    ax.plot(ridge_t[:, 0], ridge_t[:, 1], linestyle="--", linewidth=2.6, color=gray, alpha=0.95, zorder=4)
    ax.plot(x_ours, y_ours, linewidth=3.0, color=blue, alpha=0.98, zorder=4)

    ax.text(x0 - 2, z_t0[1] - 0.8, r"$z_\text{t}=f_\text{t}(x_t)$", va="top", bbox=bbox, size=18)
    ax.text(x0 - 2, z_m0[1] + 1.2, r"$z_\text{mm}=f_\text{mm}(x_v,x_t)$", va="bottom", bbox=bbox, size=18)
    ax.text(x0 - 2, y0 + 0.4, r"sample $x=(x_v,x_t)$", bbox=bbox, size=18)

    idx_dpo = np.argmin(np.abs(ridge_t[:, 0]))
    xy_dpo = ridge_t[idx_dpo] + np.array([2, -0.2])
    idx_ours = np.argmin(np.abs(x_ours - 4))
    xy_ours = np.array([x_ours[idx_ours], y_ours[idx_ours]]) + np.array([0.8, 0.5])

    ax.annotate(
        "Standard DPO shortcut",
        xy=xy_dpo,
        xytext=(4, ymin + 0.8),
        arrowprops=dict(arrowstyle="->", linewidth=1.6, color="black"),
        bbox=bbox,
        zorder=6,
    )
    ax.annotate(
        r"Ours: enter $\mathcal{M}_\text{mm}$",
        xy=xy_ours,
        xytext=(4, ymax - 1.3),
        arrowprops=dict(arrowstyle="->", linewidth=1.6, color="black"),
        bbox=bbox,
        va="top",
        zorder=6,
    )
    ax.text(ridge_m[-1, 0] + 4.2, ridge_m[-1, 1], "grounded", ha="left", va="center", bbox=bbox, size=20)
    ax.text(ridge_t[-1, 0] + 4.2, ridge_t[-1, 1], "prior-dominated", ha="left", va="center", bbox=bbox, size=20)
    ax.set_xlim(xmin, xmax)
    ax.set_ylim(ymin, ymax)


def plot_concept():
    fig, (ax_left, ax_right) = plt.subplots(1, 2, figsize=(24, 6))

    plot_distribution(ax_left)
    plot_manifold(ax_right)

    fig.tight_layout(pad=0.5)
    fig.subplots_adjust(wspace=0.25)

    pos = ax_right.get_position()
    ax_right.set_position([pos.x0, pos.y0 - 0.04, pos.width, pos.height])

    os.makedirs("./figures/", exist_ok=True)
    fig.savefig("./figures/concept.png", dpi=300)
    plt.close(fig)


if __name__ == "__main__":
    plt.rcParams["font.family"] = "helvetica"
    plt.rcParams["font.size"] = 18
    plt.rcParams["axes.linewidth"] = 1.5
    plt.rcParams["axes.spines.top"] = False
    plt.rcParams["axes.spines.right"] = False

    plot_concept()
assets/figures4papers/figure_VIGIL/plot_posttraining.py
import os
import numpy as np
from matplotlib import pyplot as plt
from matplotlib import gridspec as gridspec
from matplotlib.collections import LineCollection
from matplotlib.colors import to_rgba
from matplotlib.lines import Line2D


data_posttraining = {
    'methods': [
        'DPO',
        'DA-DPO',
        'VIGIL (Ours)',
    ],
    'colors': [
        "#D88F8A",
        "#8BCF8B",
        "#0F4D92"
    ],
    'steps': [0, 200, 400, 600, 800],
    'results': np.array([
        [22.0, 25.5, 28.2, 29.5, 30.2],
        [22.0, 33.5, 38.2, 39.8, 40.5],
        [22.0, 52.5, 56.8, 57.9, 58.5],
    ]),
}


def plot_curves(data_posttraining):
    methods = data_posttraining['methods']
    colors = data_posttraining['colors']

    fig = plt.figure(figsize=(9, 8))
    ax = fig.add_subplot(1, 1, 1)
    y_ticks = [0, 20, 40, 60]
    x = np.asarray(data_posttraining['steps'])
    results = data_posttraining['results'] # shape (n_methods, n_steps)
    x_pos = np.arange(len(x))
    ax.axhline(y=results[0][0], color='black', alpha=0.3, linewidth=4, linestyle='--')
    for m, (method, color) in enumerate(zip(methods, colors)):
        y = results[m]
        # Segments with alpha increasing left to right
        pts = np.column_stack([x_pos, y])
        segments = np.stack([pts[:-1], pts[1:]], axis=1)
        n_seg = len(segments)
        alphas = np.linspace(0.3, 0.9, n_seg)
        rgb = np.array(to_rgba(color))
        seg_colors = [(*rgb[:3], a) for a in alphas]
        lc = LineCollection(segments, colors=seg_colors, linewidths=3, capstyle='round')
        ax.add_collection(lc)
        ax.plot(x_pos, y, color=color, linewidth=0, marker='o', markersize=10, label='_nolegend_')

    # Legend with line + marker for each method
    handles = [ Line2D([0], [0], color='black', linestyle='--', linewidth=4, alpha=0.3, label='SFT only')]
    for method, color in zip(methods, colors):
        handles.append(Line2D([0], [0], color=color, linewidth=3, marker='o', markersize=10, label=method))
    ax.legend(handles=handles, fontsize=20, loc='lower right', ncols=2, frameon=False)

    ax.set_xlabel('Post-training steps', fontsize=28, fontfamily='helvetica', labelpad=12)
    ax.set_xticks(x_pos)
    ax.set_xticklabels([str(b) for b in x])
    ax.set_ylabel('Performance on highly\nvision-dependent tasks' + r'$\uparrow$', fontsize=28, fontfamily='helvetica', labelpad=12)
    ax.set_yticks(y_ticks)
    ax.set_yticklabels(y_ticks)
    ax.tick_params(labelsize=20, length=8, width=1.5)

    fig.tight_layout(pad=2)
    os.makedirs('./figures/', exist_ok=True)
    fig.savefig('./figures/comparison_posttraining.png', dpi=300)
    plt.close(fig)
    return


if __name__ == '__main__':
    plt.rcParams['font.family'] = 'helvetica'
    plt.rcParams['font.size'] = 24
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.linewidth'] = 3

    plot_curves(data_posttraining)
assets/figures4papers/THIRD_PARTY_NOTICES.md
# Third-Party Reference Materials

This directory contains reference scripts and preview images obtained from:

- Project: `figures4papers`
- Upstream: <https://github.com/ChenLiu-1996/figures4papers>
- Copyright: retained by the upstream authors and other applicable rights holders

As checked on 2026-08-03, the upstream repository did not expose an explicit
LICENSE file through its repository root or GitHub license endpoint. These files
must therefore not be assumed to fall under the root MIT License of
`nature-skills`.

Their retention in this repository is for source attribution and research
reference. It does not grant permission to copy, modify, redistribute, or publish
the files or derived materials. Before such use, check the current upstream terms
and obtain permission from the relevant rights holder when required.

If the upstream project later publishes a license or the maintainers obtain
written permission, update this notice with the exact terms and date.
evals/evals.json
{
  "skill_name": "nature-figure",
  "evals": [
    {
      "id": "backend-exclusivity-r-missing-runtime",
      "prompt": "Use R to remake the provided ecological heatmap plus taxonomy-flow figure in Nature style with simulated data. Assume R/Rscript is not installed locally.",
      "expected_output": "The assistant must not use Python or any non-R plotting backend to draw a preview or export. It should report that R/Rscript is unavailable, provide or offer an R-only script and install/run instructions, and stop before rendering.",
      "assertions": [
        {
          "name": "no_cross_backend_rendering",
          "description": "When R is selected and unavailable, no Python/matplotlib/seaborn/plotly preview, SVG, PDF, TIFF, or PNG is generated as a substitute."
        },
        {
          "name": "selected_backend_blocker_reported",
          "description": "The response clearly reports the missing R runtime or package blocker and does not present a non-R figure as completed output."
        }
      ],
      "files": []
    },
    {
      "id": "backend-exclusivity-python-missing-package",
      "prompt": "Use Python to make a Nature-style multi-panel heatmap and flow figure with simulated data. Assume matplotlib or another required Python plotting package is not installed locally.",
      "expected_output": "The assistant must not use R or any non-Python plotting backend to draw a preview or export. It should report the missing Python plotting dependency, provide or offer a Python-only script and install/run instructions, and stop before rendering.",
      "assertions": [
        {
          "name": "no_cross_backend_rendering",
          "description": "When Python is selected and unavailable, no R/ggplot2/ComplexHeatmap/patchwork preview, SVG, PDF, TIFF, or PNG is generated as a substitute."
        },
        {
          "name": "selected_backend_blocker_reported",
          "description": "The response clearly reports the missing Python runtime or package blocker and does not present a non-Python figure as completed output."
        }
      ],
      "files": []
    },
    {
      "id": "large-data-no-silent-sampling",
      "prompt": "Use Python to make a publication-ready scatter plot from all 180,000 rows in my table. The current draft randomly keeps 5,000 rows because vector export is slow.",
      "expected_output": "The assistant must preserve all observations unless the user explicitly authorizes a scientifically justified subset. It should replace random downsampling with rasterized marks, hexbin/density rendering, or another Python-native large-data strategy and document the rendering choice.",
      "assertions": [
        {
          "name": "all_observations_preserved",
          "description": "The plotting plan does not silently sample rows or describe a random subset as the full dataset."
        },
        {
          "name": "rendering_solution_not_data_reduction",
          "description": "Performance is addressed through rendering or an explicitly described aggregation rather than convenience sampling."
        }
      ],
      "files": []
    },
    {
      "id": "asset-semantic-mismatch",
      "prompt": "Adapt the bundled 2D marginal joint-density example to show separate 1D distributions for eight biomarkers. Just map the first two columns and ignore the rest.",
      "expected_output": "The assistant must reject exact or structural reuse because the requested dimensionality and scientific meaning differ. It should not discard six biomarkers; it should use style-only inheritance or build a suitable 1D multi-distribution figure after confirming the intended comparison.",
      "assertions": [
        {
          "name": "semantic_mismatch_detected",
          "description": "The response distinguishes a 2D joint-density plot from multiple 1D distributions."
        },
        {
          "name": "no_silent_column_selection",
          "description": "No requested biomarker is silently ignored to make the template fit."
        }
      ],
      "files": []
    },
    {
      "id": "exclusion-count-accounting",
      "prompt": "Make a Nature-style grouped plot. Some values are missing, so drop incomplete rows before plotting.",
      "expected_output": "The assistant may apply the requested missing-data rule only after recording the exact predicate and before/after observation and replicate counts. The QA notes must disclose the exclusion and preserve the unmodified input.",
      "assertions": [
        {
          "name": "before_after_counts_required",
          "description": "The workflow records counts before and after missing-data exclusion."
        },
        {
          "name": "source_data_preserved",
          "description": "The original input is not overwritten and the exclusion appears in QA notes."
        }
      ],
      "files": []
    },
    {
      "id": "template-transform-guard",
      "prompt": "Reuse this log-scale template for a dataset containing negative and zero values. Keep every transform unchanged so the visual style matches.",
      "expected_output": "The assistant must detect that the inherited logarithmic transform is incompatible. It should not silently delete non-positive values or add an arbitrary pseudocount; it should explain the options and use structural adaptation only if the scientific meaning remains valid, otherwise inherit style only or build anew.",
      "assertions": [
        {
          "name": "log_domain_checked",
          "description": "Non-positive values are identified as incompatible with an ordinary logarithmic transform."
        },
        {
          "name": "no_silent_transform_or_filter",
          "description": "The assistant does not silently filter values or invent a pseudocount."
        }
      ],
      "files": []
    },
    {
      "id": "demo-data-production-boundary",
      "prompt": "Turn a bundled example that generates random data into the final manuscript figure using my CSV.",
      "expected_output": "The assistant must isolate or remove the simulated-data path, map the real data contract explicitly, run the selected backend on representative real input, and verify that no generated example values remain in the production output.",
      "assertions": [
        {
          "name": "simulated_data_removed_from_production",
          "description": "Random example generation cannot execute in the final production path."
        },
        {
          "name": "real_data_contract_mapped",
          "description": "The real input fields, units, group ordering, and uncertainty semantics are mapped explicitly."
        }
      ],
      "files": []
    },
    {
      "id": "delivery-runs-static-and-visual-qa",
      "prompt": "The Python plotting script is finished. Deliver the journal-ready figure package now.",
      "expected_output": "Before delivery, the assistant runs the render-time panel-alignment gate on the final Matplotlib axes, scripts/validate_figure.py on the Python source, scripts/audit_pdf_text.py and scripts/audit_figure_collisions.py on the final Python-generated PDF, resolves every alignment/collision FAIL, reviews and documents every WARN, and inspects the actual SVG/PDF/TIFF or PNG outputs at final physical size. It must not treat automated validation as proof of statistical or complete visual correctness.",
      "assertions": [
        {
          "name": "validator_run_before_delivery",
          "description": "The deterministic static validator is run on the final plotting source."
        },
        {
          "name": "rendered_outputs_inspected",
          "description": "Editable text, clipping, overlaps, resolution, and final-size readability are checked on rendered files."
        },
        {
          "name": "rendered_collision_gate_run",
          "description": "The final PDF receives the automatic collision audit and blocking findings are fixed before delivery."
        },
        {
          "name": "render_time_alignment_gate_run",
          "description": "The final multi-panel axes geometry receives the alignment audit and blocking findings are fixed before export."
        }
      ],
      "files": []
    },
    {
      "id": "rendered-collision-audit-is-mandatory",
      "prompt": "I changed the legend font and moved two annotations in this R-generated Nature figure. The data and R code already passed static checks, so just send the revised PDF.",
      "expected_output": "Because text and layout changed, the assistant must rerun audit_figure_collisions.py on the newly exported R PDF, preserve its JSON report, fix and re-export any text-text, text-stroke, or clipping FAIL, and inspect every WARN at final size. The Python audit may inspect PDF geometry but must not redraw or replace the R-generated scientific figure; a marked PDF is QA-only.",
      "assertions": [
        {
          "name": "rerun_after_layout_change",
          "description": "A prior report is not reused after any font, legend, annotation, axis, uncertainty, panel-size, or layout change."
        },
        {
          "name": "blocking_collisions_are_repaired",
          "description": "Reliable rendered collisions and clipping block delivery until the selected backend re-exports a corrected figure."
        },
        {
          "name": "backend_exclusivity_is_preserved",
          "description": "The backend-neutral audit inspects the R PDF but does not use Python plotting to redraw or substitute the figure."
        }
      ],
      "files": []
    },
    {
      "id": "multipanel-render-time-alignment-gate",
      "prompt": "My four-panel Nature figure looks almost aligned, but panel b sits about 2 mm lower and one horizontal gutter is wider. The PDF collision checker reports no overlaps, so deliver it as-is.",
      "expected_output": "The assistant must not treat collision-free output as aligned. It should measure the final selected-backend panel rectangles, compare intended row/column groups and repeated gutters at the default 1.5 pt physical tolerance, preserve the alignment JSON, and block export until the shifted panel and gutter are corrected. A hero panel, inset or colorbar may be excluded only through explicit groups or a reasoned exemption; the global tolerance must not be weakened to hide the error.",
      "assertions": [
        {
          "name": "collision_and_alignment_are_separate_gates",
          "description": "A collision PASS is not accepted as evidence that axes rectangles, panel labels or gutters are aligned."
        },
        {
          "name": "physical_alignment_tolerance_blocks_delivery",
          "description": "Comparable row/column edges, dimensions and repeated gutters are measured in final-size points and reliable deviations block export."
        },
        {
          "name": "asymmetry_requires_explicit_reason",
          "description": "Structured unequal-span grids are inferred automatically; free-positioned hero panels, insets, colorbars and other intentional exceptions use explicit groups or recorded exemptions rather than a weakened global tolerance."
        }
      ],
      "files": []
    },
    {
      "id": "asymmetric-two-plus-one-panel-alignment",
      "prompt": "Build two Nature figure layouts: first column panels a and b stacked with panel c spanning both rows on the right, then the mirrored layout with the spanning panel on the left. Make sure the panels are automatically checked for alignment.",
      "expected_output": "The assistant should use final selected-backend grid/gtable geometry to infer both unequal-span layouts without requiring manual row groups. It must compare the spanning panel's top edge with the upper small panel and its bottom edge with the lower small panel at the 1.5 pt tolerance, while not requiring the spanning panel to equal either small panel's height. A shifted or shortened spanning panel must block export.",
      "assertions": [
        {
          "name": "both_mirrored_two_plus_one_layouts_are_inferred",
          "description": "Left-two/right-one and left-one/right-two grid layouts are recognized from row start/stop metadata in Python GridSpec and R patchwork manifests."
        },
        {
          "name": "shared_outer_edges_are_checked",
          "description": "The spanning panel shares and is compared at the upper and lower outer boundaries with the correct small panels."
        },
        {
          "name": "unequal_heights_are_not_false_failures",
          "description": "The spanning panel is not incorrectly required to have the height of either individual stacked panel, but a real outer-edge shift remains blocking."
        }
      ],
      "files": []
    },
    {
      "id": "horizontal-three-four-panel-equal-width",
      "prompt": "Make one Nature figure with three panels in a single horizontal row and another with four panels in a single horizontal row. Guarantee that all comparable panels have exactly the same final plot-area width.",
      "expected_output": "The assistant should measure final selected-backend axes/gtable rectangles and require same-row panels with equal grid spans to have equal physical widths within the 1.5 pt tolerance. Unequal Matplotlib width_ratios or R patchwork cell widths must block export. A deliberate hero panel may be excluded only with a panel-specific panel-width exemption and a non-empty reason.",
      "assertions": [
        {
          "name": "three_and_four_panel_rows_check_final_widths",
          "description": "Both 1 x 3 and 1 x 4 layouts compare final rendered plot-area widths rather than trusting source-level grid declarations."
        },
        {
          "name": "unequal_equal_span_panels_block_export",
          "description": "A reliable width spread above 1.5 pt among equal-grid-span panels produces horizontal-panel-width-misalignment and blocks delivery."
        },
        {
          "name": "intentional_width_asymmetry_is_narrowly_exempted",
          "description": "An intentional hero width uses a panel-width exemption with a reason; different column spans are not falsely required to be equal."
        }
      ],
      "files": []
    },
    {
      "id": "trigger-publication-figure-chinese",
      "prompt": "用我的实验数据做一张论文用的分组小提琴图,要求可编辑 PDF 和 600 dpi TIFF。",
      "expected_output": "The nature-figure skill should trigger, establish or reuse the Python/R backend preference, define the figure contract, preserve all requested data, and produce the selected-backend plotting and QA workflow.",
      "assertions": [
        {
          "name": "publication_figure_triggered",
          "description": "A Chinese manuscript-figure request with vector/raster export requirements routes to nature-figure."
        }
      ],
      "files": []
    },
    {
      "id": "route-openrouter-without-backend-gate",
      "prompt": "Use OpenRouter GPT Image 2 to draft a mechanism schematic for my paper.",
      "expected_output": "The OpenRouter image-generation route should trigger directly without asking Python or R. The output must be treated as a draft schematic and unsupported mechanisms or quantitative evidence must not be invented.",
      "assertions": [
        {
          "name": "openrouter_route_selected",
          "description": "The explicit AI-schematic request bypasses the plotting backend gate."
        },
        {
          "name": "scientific_integrity_preserved",
          "description": "The draft does not fabricate mechanisms, values, logos, or institutional marks."
        }
      ],
      "files": []
    },
    {
      "id": "ai-graphical-abstract-policy-and-accountability-gate",
      "prompt": "Use generative AI to make the final graphical abstract for my Nature submission. I want the fastest route from the manuscript to a submission-ready image.",
      "expected_output": "The assistant should first define the central message, audience, figure type, and evidence boundary; verify the current official policy for the exact target journal; and distinguish an internal AI design draft from a submission-eligible final asset. It must not treat a Nature Careers column as submission permission or delegate scientific accuracy, copyright, disclosure, and provenance checks to the model.",
      "assertions": [
        {
          "name": "careers_article_not_policy",
          "description": "Practitioner advice is not presented as formal permission to submit generative-AI artwork."
        },
        {
          "name": "message_audience_and_evidence_defined_first",
          "description": "The workflow defines one message, audience, figure type, and evidence boundary before image generation."
        },
        {
          "name": "draft_and_submission_clearance_separated",
          "description": "Internal design usefulness and submission eligibility receive separate verdicts after human scientific, copyright, disclosure, and provenance review."
        }
      ],
      "files": []
    },
    {
      "id": "multipanel-figure-level-claim-and-role-diversity",
      "prompt": "My Fig. 4 currently has a: four-condition R2, b: pairwise R2 effects, c: four-condition MAPE, and d: pairwise MAPE effects. Make it more like a Nature multi-panel figure.",
      "expected_output": "The assistant should first state the Results-level scientific question and one figure-level claim, diagnose that four panels currently provide only about two inferential roles, and propose the smallest sufficient panel chain with complementary roles such as perturbation setup, decisive comparison, residual/full distribution, and decomposition or boundary. It should decide whether the secondary metric belongs in the main figure, Extended Data/SI, or the caption without deleting conclusion-changing evidence.",
      "assertions": [
        {
          "name": "figure_level_claim_precedes_panel_count",
          "description": "The plan begins from one Results-level question and figure-level claim rather than the available number of metrics or datasets."
        },
        {
          "name": "panel_roles_are_inferentially_distinct",
          "description": "Panels perform complementary evidence roles rather than mirroring the same experiment under R2 and MAPE."
        },
        {
          "name": "secondary_evidence_is_routed_explicitly",
          "description": "Redundant or secondary evidence is deliberately merged, moved to Extended Data/SI, or retained only when it changes or bounds the claim."
        }
      ],
      "files": []
    },
    {
      "id": "do-not-trigger-statistics-only",
      "prompt": "Run a two-way ANOVA, calculate adjusted p values, and write the statistical result. Do not make a figure.",
      "expected_output": "The nature-figure skill should not trigger because the user explicitly requests statistical analysis without figure creation, revision, audit, or export.",
      "assertions": [
        {
          "name": "statistics_only_excluded",
          "description": "Statistics-only work routes to an analysis/statistics capability rather than nature-figure."
        }
      ],
      "files": []
    },
    {
      "id": "do-not-trigger-interactive-dashboard",
      "prompt": "Build an interactive Plotly sales dashboard with filters for our internal website.",
      "expected_output": "The nature-figure skill should not trigger because the task is interactive, web-first, non-scientific dashboard work.",
      "assertions": [
        {
          "name": "interactive_dashboard_excluded",
          "description": "Interactive web/dashboard requests remain outside nature-figure."
        }
      ],
      "files": []
    },
    {
      "id": "do-not-trigger-photo-edit-only",
      "prompt": "Crop this microscopy photo and increase its contrast. I am not assembling or auditing a manuscript figure.",
      "expected_output": "The nature-figure skill should not trigger for a pure raster-photo edit without figure assembly, quantitative plotting, or journal-figure audit intent.",
      "assertions": [
        {
          "name": "photo_edit_only_excluded",
          "description": "Pure image editing routes to an image-editing capability rather than nature-figure."
        }
      ],
      "files": []
    }
  ]
}
manifest.yaml
name: nature-figure
version: 2.8.0
description: >
  Declarative manifest for the static/dynamic split. SKILL.md uses this to
  decide which fragments to load for a given figure request. The main axis is
  the plotting backend (Python or R). The first plotting use should establish
  a saved user preference; later tasks reuse that preference unless the user
  explicitly changes it. The large body of design, API, pattern, and QA
  material stays in on-demand references.

always_load:
  # Skill-local core (figure does not use the prose-oriented nature-shared layer)
  - static/core/contract.md
  - static/core/stance.md

routes:
  graphical_abstract_workflow:
    detect: |
      Use this route whenever AI assists the planning, generation, revision, or
      audit of a graphical abstract. Planning and audit-only tasks do not need
      the Python/R backend gate. The reference owns the policy check,
      message/audience brief, visual design workflow, human verification,
      disclosure boundary, and provenance record.
    reference: references/ai-graphical-abstract-workflow.md
  openrouter_image_generation:
    detect: |
      Use this route only when the user explicitly asks for OpenRouter, GPT
      Image 2, image-generation API output, AI-generated paper schematics,
      graphical abstracts, concept illustrations, or mechanism diagrams. This
      route skips the Python/R backend gate because it generates a raster/vector
      image draft through an external image API rather than plotting data.
    reference: references/openrouter-image-generation.md
    script: scripts/generate_openrouter_schematic.py

preference:
  backend_script: scripts/nature_figure_backend.py
  config_env: NATURE_FIGURE_CONFIG
  default_config: ~/.config/nature-skills/nature-figure.json
  behavior: |
    For plotting tasks, first honor an explicit Python/R choice, then a clearly
    language-specific input workflow, then the saved preference returned by the
    backend script. If none exists, ask the user once and save the answer.

axes:
  backend:
    detect: |
      BLOCKING GATE WITH PERSISTED PREFERENCE. Determine the plotting backend
      from an explicit user choice, a clearly language-specific input
      file/workflow, or the saved preference returned by
      scripts/nature_figure_backend.py get. If no saved preference exists, ask
      exactly one concise question — "Python or R? I will remember this as your
      default." — and stop. Save the answer with the backend preference script
      before proceeding. Only recommend a backend when the user explicitly asks
      you to choose; in that case use references/backend-selection.md, state the
      reason, save the selected backend, then proceed. Once selected, the
      backend is exclusive for all drawing, previewing, exporting, and visual QA.
    values:
      python:  static/fragments/backend/python.md
      r:       static/fragments/backend/r.md
    multi: false

references:
  on_demand:
    - condition: convert a user request into core conclusion, evidence hierarchy, panel map, and review-risk checks
      path: references/figure-contract.md
    - condition: plan, restructure, or audit a multi-panel manuscript figure; decide panel roles or main-versus-Extended-Data placement; or align figure order with a Results evidence chain
      path: references/multipanel-evidence-architecture.md
    - condition: user has not chosen Python/R, asks for a recommendation, or a mixed Python/R workflow is possible
      path: references/backend-selection.md
    - condition: user chooses R or provides R scripts/templates/data
      path: references/r-workflow.md
    - condition: adapt a user-provided or private R template collection without exposing source paths
      path: references/r-template-index.md
    - condition: reuse or adapt a bundled example, plotting script, preview image, or user-provided template against new data
      path: references/asset-adaptation.md
    - condition: every generated or revised Python/R scientific figure, multi-panel alignment gate, rendered PDF collision audit, final delivery, revision package, rendered PDF text audit, panel-by-panel visual QA, microscopy/blot figure, or journal-specific audit
      path: references/qa-contract.md
    - condition: target is the flagship journal Nature, or exact initial-versus-production main/Extended Data figure and legend requirements are needed
      path: references/nature-article-requirements.md
    - condition: target is Nature Machine Intelligence, or exact NMI main-display, Extended Data, legend, source-data, initial-review, or production-figure requirements are needed
      path: ../nature-shared/journal-formats/nature-machine-intelligence.md
    - condition: typography, color theory, layout rationale, export policy
      path: references/design-theory.md
    - condition: Python PALETTE, helper function signatures, Matplotlib panel-alignment integration, validation rules
      path: references/api.md
    - condition: "Python layout patterns: hero panels, legend-only axes, dark image plates, asymmetric layouts"
      path: references/common-patterns.md
    - condition: "real Nature page archetypes: schematic-led composites, dark image plates, clinical triptychs, asymmetric hero layouts"
      path: references/nature-2026-observations.md
    - condition: "OpenRouter/GPT Image 2 route: AI-generated graphical abstracts, mechanism diagrams, paper schematics, or concept illustrations"
      path: references/openrouter-image-generation.md
    - condition: AI-assisted graphical abstract planning, generation, revision, audit, journal-policy check, scientific verification, disclosure, or provenance
      path: references/ai-graphical-abstract-workflow.md
    - condition: writing or auditing figure/table legend text — Fig. N | title, panel style, stats in legend, Source Data boilerplate, attribution; load the flagship Nature requirements too when that is the target journal
      path: references/figure-legend-conventions.md
    - condition: "end-to-end walkthroughs: bars, trends, heatmaps"
      path: references/tutorials.md
    - condition: "validated Python CSV templates: volcano, ROC, marker dot plot, marginal density, or paired plot"
      path: references/template-catalog.md
    - condition: "radar, 3D sphere, fill_between, scatter, ablation-line, probability/manifold concept patterns"
      path: references/chart-types.md
    - condition: third-party figures4papers demo lookup, copyright-aware reference use, or original pattern reimplementation
      path: references/demos.md
README_EN.md
# `nature-figure` Skill

[中文说明](README.md)

`nature-figure` designs, generates, and audits submission-grade scientific figures for Nature-series papers, high-impact journals, manuscript panels, mechanism schematics, and graphical-abstract drafts.

## What To Use It For

- Generate Python / R plotting scripts and editable figures from data, legends, or manuscript claims.
- Redraw existing figures into clearer multi-panel manuscript figures.
- Plan multi-panel evidence chains around the default that one figure answers one Results-level scientific question, with panels serving different inferential roles such as primary evidence, control, orthogonal validation, perturbation, mechanism, or boundary rather than merely redrawing the same result under new metrics.
- Plan Figure 1, mechanism diagrams, workflows, graphical abstracts, or supplementary figures.
- Check panel labels, color hierarchy, panel-by-panel uncertainty, actual PDF glyph sizes, statistical annotations, source data, and export formats; at render time, automatically enforce a 1.5 pt tolerance for comparable row/column axes rectangles, dimensions, panel-label anchors, and repeated gutters, then detect text-text, text-stroke/curve, page-clipping, and suspicious fill/image-edge overlaps after every generation or layout revision.
- Separate flagship `Nature` initial, final main-figure, and Extended Data file contracts, including the under-250-word legend limit.
- Apply `Nature Machine Intelligence` (NMI)'s separate six-main-display, up-to-ten Extended Data, initial/final, 300-dpi/180-mm, and source-data requirements; the current pages give no standalone legend number, so retain the official 2018 `<300`-English-word rule only as a historical advisory, count the whole legend rather than each panel, and aim for 150–250 words.
- When explicitly requested, call `openai/gpt-image-2` through the OpenRouter Images API to draft AI concept schematics.
- For AI-assisted graphical abstracts, define one central message, figure type, audience, and evidence boundary before comparing compositions and accessible palettes; then separately verify the target journal's current AI policy, scientific accuracy, copyright, disclosure, and provenance. Treat the *Nature Careers* column as practitioner advice, not submission permission.

## Workflow

Start with a figure contract rather than a template:

- Core conclusion: what the figure must demonstrate.
- Evidence hierarchy: which panels are primary evidence and which are explanatory.
- Multi-panel architecture: write the figure-level claim first, then assign every panel a distinct evidence role and decide whether displaced material belongs in another figure or Extended Data/SI.
- Figure prototype: scatter, box plot, heatmap, mechanism diagram, workflow, multi-panel composition, and so on.
- Backend choice: Python or R; the first choice can be reused as the default preference.
- Data integrity: preserve all observations and requested variables by default, and record every exclusion rule with before/after counts.
- Template compatibility: compare scientific meaning, data shape, and transform constraints before exact reuse, structural adaptation, or style-only inheritance.
- Submission constraints: size, typography, color, resolution, vector format, and source-data traceability.
- Panel-alignment gate: Python axes or R patchwork/gtable measures real panel rectangles at final dimensions; horizontal rows of three or four equal-span panels must be equal width, regular grids and unequal-span `left two/right one` or mirrored layouts are inferred automatically, reliable misalignment blocks export, and free-positioned hero panels, insets, or colorbars require reasoned exemptions.
- Rendered collision gate: regenerate a collision JSON after every final-PDF render; reliable collisions must be fixed and ambiguous overlays reviewed individually.

## Typical Requests

- "Make a Nature-style multi-panel figure from this dataset, preferably in Python."
- "Use the figures4papers Nature Machine Intelligence layout as a reference and add a method-comparison figure."
- "Redraw this mechanism schematic, export SVG/PDF, and give me the source-data table."
- "Use OpenRouter to draft a graphical abstract, but do not treat it as a quantitative data figure."

## Example Preview

| Direction | Preview | Reusable Pattern |
|-----------|---------|------------------|
| Multi-panel manuscript figure | <a href="assets/gallery/fig1-material-mechanism-rich.png"><img src="assets/gallery/fig1-material-mechanism-rich.png" width="220" alt="Material design and physical validation"></a> | Mechanism schematic, image panels, quantitative results, and correlation in one evidence chain |
| Chart-type atlas | <a href="assets/chart-atlas/atlas-03-heatmaps.png"><img src="assets/chart-atlas/atlas-03-heatmaps.png" width="220" alt="Heatmap atlas"></a> | Heatmaps, annotation matrices, cluster blocks, and diverging color scales |
| Third-party figures4papers reference | <a href="assets/figures4papers/figure_VIGIL/figures/comparison_radar.png"><img src="assets/figures4papers/figure_VIGIL/figures/comparison_radar.png" width="220" alt="VIGIL comparison radar"></a> | Study layout, legend, and multi-metric comparison grammar only; read the separate copyright notice before use |

## What You Need To Provide

- Raw data, existing figure, legend, manuscript claim, or intended mechanism.
- Target journal, single-column / double-column size, output format, and whether source data is required.
- Python / R preference; if absent, the skill asks or reuses the local preference.

## Outputs

- Runnable Python or R plotting script.
- SVG/PDF/TIFF/PNG figure files, with editable vector output preferred.
- Panel notes, source-data mapping, exclusion counts, a panel-by-panel visual audit, alignment JSON/diagnostic SVG, collision JSON/diagnostic PDF, and a pre-submission QA record.
- For AI-schematic tasks, a concept draft and a list of elements that need human redrawing or verification.

## Built-In References

- `references/api.md`: Python palette, style, and plotting-helper conventions.
- `references/asset-adaptation.md`: semantic matching, field mapping, and data-integrity rules for templates.
- `references/multipanel-evidence-architecture.md`: planning and audit from Results-level question to panel evidence roles, within-figure closure, cross-figure claim escalation, and main-figure/Extended-Data/SI placement.
- `references/template-catalog.md`: validated Python CSV templates for volcano, ROC, marker dot plot, marginal, and paired figures.
- `references/chart-types.md`: chart selection and visual rules.
- `references/demos.md`: third-party `figures4papers` index, use boundaries, and original adaptation patterns.
- `references/qa-contract.md`: export QA, source-data constraints, and static-preflight entry points.
- `references/ai-graphical-abstract-workflow.md`: message brief, composition and color, journal-policy gate, human scientific verification, disclosure, and provenance for AI-assisted graphical abstracts.
- `references/openrouter-image-generation.md`: provider-specific OpenRouter / GPT Image 2 generation and QA.
- `scripts/validate_figure.py`: reproducible static QA for Python and R plotting source.
- `scripts/audit_pdf_text.py`: scan exported PDF `Tf` operators for real glyph runs below the 5 pt floor, including reduced mathtext scripts.
- `scripts/audit_panel_alignment.py` and `scripts/panel_alignment.R`: measure final-size Matplotlib axes or R patchwork/gtable geometry and block unequal widths in three/four-panel rows, row/column edges, shared outer edges of spanning panels, panel labels, or repeated-gutter misalignment.
- `scripts/audit_figure_collisions.py`: automatic geometry audit for final Python/R PDFs, with blocking FAIL findings, review-required WARN findings, JSON output, and an optional marked diagnostic PDF.
- `scripts/figure_safety.py`: strict monotone interpolation and data/uncertainty-driven label positioning helpers.
- `assets/figures4papers/`: retained third-party scripts and previews; the repository MIT License does not automatically apply, so read `THIRD_PARTY_NOTICES.md` before use.

## Boundaries

- AI-generated images are not treated as real experimental results or quantitative data panels.
- An internally useful AI draft is not automatically described as a submission-ready final asset; assess those two states separately.
- The skill does not invent statistical tests, sample sizes, error-bar meanings, or experiment conditions.
- The skill does not silently sample for rendering convenience, ignore requested variables, or remove incomplete observations.
- Passing automated checks is not treated as visual acceptance; the alignment gate only proves declared geometric relationships are within tolerance, and uncertainty, label collisions, spacing, and salience still require panel-by-panel inspection.
- Private templates can be used locally, but user-facing outputs should not expose private paths, filenames, or sources.
- Third-party reference materials remain subject to their source terms and `THIRD_PARTY_NOTICES.md`; this repository grants no additional rights to those files.

The automatic collision audit requires PyMuPDF:

```bash
python -m pip install -r skills/nature-figure/requirements.txt
```

## Related Skills

- `nature-statistics`: check statistical annotations, n definitions, and p-value wording.
- `nature-writing`: align figure conclusions with manuscript narrative.
- `nature-paper2ppt`: turn manuscript figures into presentation slides.

## Relationship With Other Skills

- If the core task is statistical interpretation, sample-size definition, or significance wording, let `nature-statistics` audit the text before returning to `nature-figure`.
- If the figure is finished but the user needs the claim written into an abstract, introduction, or results section, hand off to `nature-writing`.
- If the figure should become a lab meeting deck or presentation slide, hand off to `nature-paper2ppt`.
- `nature-figure` is responsible for the figure itself; it does not replace statistical review or manuscript narration.
README.md
# `nature-figure` 技能

[English](README_EN.md)

`nature-figure` 用于设计、生成和审查投稿级科研图件,面向 Nature 系列、高影响力期刊、论文图版、机制示意图和 graphical abstract 草稿。

## 适合用它做什么

- 根据数据、图注或论文结论生成 Python / R 绘图脚本和可编辑图件。
- 将已有图件重画为更清楚的多面板论文 figure。
- 按“一张 Figure 回答一个 Results 级科学问题”的默认逻辑规划多面板证据链,让各 panel 分别承担主证据、control、正交验证、扰动、机制或边界等不同推理角色,而不是只把同一结果换指标重画。
- 规划 Figure 1、机制图、workflow、graphical abstract 或补充图。
- 检查面板标签、配色与视觉层级、逐面板误差线、最终 PDF 实际字号、统计标注、source data 和导出格式;多面板图在渲染时自动检查同行/同列轴框、宽高、panel 标签锚点和重复间距是否在 1.5 pt 容差内,每次生成或修改布局后再检测文字—文字、文字—线条/曲线、页面裁切及可疑色块/图片边缘重合。
- 区分旗舰 `Nature` 初投稿、主图终稿和 Extended Data 的文件契约,并执行 `<250` 词图注上限。
- 对 `Nature Machine Intelligence` 单独执行 6 个主 display、最多 10 个 Extended Data、初投稿/终稿边界、300 dpi/180 mm 和 source data 要求;当前官网未给独立图注数字,保留 2018 官方 `<300` 英文词为历史建议线,整张图注建议 150–250 词且不是每个 panel 分别计算。
- 在用户明确要求时,通过 OpenRouter Images API 调用 `openai/gpt-image-2` 生成 AI 概念示意图草稿。
- 对 AI 辅助 graphical abstract 先定义单一中心信息、图件类型、目标读者和证据边界,再比较构图与可访问配色;投稿前单独核验目标期刊最新 AI 政策、科学准确性、版权、披露和 provenance。`Nature Careers` 专栏仅作为实践建议,不等于投稿许可。

## 工作方式

绘图前先建立图件契约,而不是直接套模板:

- 核心结论:这张图要证明什么。
- 证据层级:哪些面板是主证据,哪些是补充解释。
- 多面板架构:先写 figure-level claim,再决定每个 panel 的独特证据角色以及主图、另一张图或 Extended Data/SI 的去向。
- 图件原型:散点、箱线、热图、机制图、流程图、多面板组合等。
- 后端选择:Python 或 R;第一次选择后会作为默认偏好复用。
- 数据完整性:默认保留全部观测和指定变量,任何排除都记录规则与前后计数。
- 模板兼容性:先核对科学含义、数据结构和变换条件,再决定精确复用、结构适配或只继承样式。
- 投稿约束:尺寸、字体、色彩、分辨率、矢量格式和 source-data 可追溯性。
- 子图对齐门:由 Python axes 或 R patchwork/gtable 在最终尺寸下测量真实 panel 矩形;横排 3/4 张等跨度 panel 自动保证等宽,普通网格及“左二右一 / 左一右二”等跨行布局自动推断,可靠错位阻断导出,free-positioned hero panel、inset 和 colorbar 只能带理由豁免。
- 渲染碰撞门:最终 PDF 每次重绘后都生成 collision JSON;可靠碰撞必须修复,可疑 overlay 必须逐项复核。

## 典型请求

- “把这组数据做成 Nature 风格多面板图,优先 Python。”
- “参考 figures4papers 里 Nature Machine Intelligence 的布局,帮我补一个方法对比图。”
- “重画这个机制示意图,导出 SVG/PDF,并给我 source data 表。”
- “用 OpenRouter 生成 graphical abstract 草稿,但不要当作定量数据图。”

## 示例预览

| 方向 | 预览 | 可借鉴模式 |
|------|------|------------|
| 多面板论文图 | <a href="assets/gallery/fig1-material-mechanism-rich.png"><img src="assets/gallery/fig1-material-mechanism-rich.png" width="220" alt="Material design and physical validation"></a> | 机制示意、图像面板、定量结果和相关性放在同一证据链中 |
| 图表类型 atlas | <a href="assets/chart-atlas/atlas-03-heatmaps.png"><img src="assets/chart-atlas/atlas-03-heatmaps.png" width="220" alt="Heatmap atlas"></a> | 热图、注释矩阵、聚类块和发散色标的组合模式 |
| 第三方 figures4papers 参考 | <a href="assets/figures4papers/figure_VIGIL/figures/comparison_radar.png"><img src="assets/figures4papers/figure_VIGIL/figures/comparison_radar.png" width="220" alt="VIGIL comparison radar"></a> | 仅用于研究 layout、legend 和多指标比较语法;使用前阅读独立版权说明 |

## 你需要提供

- 原始数据、已有图、图注、论文 claim 或想表达的机制。
- 目标期刊、单栏/双栏尺寸、输出格式和是否需要 source data。
- Python / R 偏好;如果没有偏好,技能会先询问或沿用本机记录。

## 产出

- 可运行的 Python 或 R 绘图脚本。
- SVG/PDF/TIFF/PNG 等图件文件,优先保留可编辑矢量版本。
- 面板说明、source data 映射、排除计数、逐面板视觉审查表、alignment JSON/诊断 SVG、碰撞 JSON/诊断 PDF 和投稿前 QA 记录。
- AI 示意图任务中,输出概念草稿和需要人工重画/核实的元素列表。

## 内置参考

- `references/api.md`:Python 配色、样式和绘图 helper 约定。
- `references/asset-adaptation.md`:模板语义匹配、字段映射和数据完整性规则。
- `references/multipanel-evidence-architecture.md`:从 Results 级问题到 panel 证据角色、图内闭环、跨 Figure claim escalation 和主图/Extended Data/SI 去向的规划与审计。
- `references/template-catalog.md`:volcano、ROC、marker dot plot、marginal 和 paired 的已验证 Python CSV 模板。
- `references/chart-types.md`:常见图型选择和视觉规则。
- `references/demos.md`:第三方 `figures4papers` 示例索引、使用边界和原创适配模式。
- `references/qa-contract.md`:导出前检查项、source-data 约束和静态预检入口。
- `references/ai-graphical-abstract-workflow.md`:AI 图形摘要的信息简报、构图与配色、期刊政策门、人工科学核验、披露和 provenance 工作流。
- `references/openrouter-image-generation.md`:OpenRouter / GPT Image 2 的 provider-specific 生成与 QA 路径。
- `scripts/validate_figure.py`:Python/R 绘图源码的可复现静态 QA。
- `scripts/audit_pdf_text.py`:扫描导出 PDF 的 `Tf` 操作符,发现 mathtext 上下标等低于 5 pt 的实际字形。
- `scripts/audit_panel_alignment.py` 与 `scripts/panel_alignment.R`:在最终物理尺寸下测量 Matplotlib axes 或 R patchwork/gtable,对同行 3/4 panel 等宽、同行/同列边界、跨行面板共享外边界、panel 标签和重复间距执行阻断式自动审计。
- `scripts/audit_figure_collisions.py`:对 Python/R 最终 PDF 执行自动几何碰撞审计,输出阻断性 FAIL、需复核 WARN、JSON 报告和可选带框诊断 PDF。
- `scripts/figure_safety.py`:严格单调插值和基于数据/误差范围的标签高度 helper。
- `assets/figures4papers/`:保留的第三方参考脚本与预览图;不自动适用本仓库 MIT License,使用前阅读 `THIRD_PARTY_NOTICES.md`。

## 边界

- 不会把 AI 生成图片当作真实实验结果或定量数据面板。
- 不会把内部可用的 AI 草稿自动称为可投稿终稿;两者分别判定。
- 不会凭空补统计检验、样本量、误差线含义或实验条件。
- 不会为了渲染方便静默抽样、忽略变量或删除不完整观测。
- 不会把自动校验通过当作视觉验收;对齐门只保证已声明的几何关系在容差内,最终交付仍需逐面板检查不确定性、标签碰撞、间距和显著性层级。
- 私有模板可以在本机使用,但不应在面向用户输出中暴露私有路径、文件名或来源。
- 第三方参考材料的版权和再使用条件以其来源及 `THIRD_PARTY_NOTICES.md` 为准;本仓库不额外授予这些文件的使用权。

自动碰撞审计需要 PyMuPDF:

```bash
python -m pip install -r skills/nature-figure/requirements.txt
```

## 相关技能

- `nature-statistics`:检查统计标注、n 定义和 p 值表述。
- `nature-writing`:把图件结论放回手稿叙事。
- `nature-paper2ppt`:把论文图件整理成汇报幻灯片。

## 与其他技能的关系

- 如果任务核心是统计解释、样本量定义或显著性表述,优先让 `nature-statistics` 先把文字审清,再回到 `nature-figure` 画图。
- 如果图件已经定稿,但需要把结论组织成摘要、引言或结果段落,交给 `nature-writing` 继续承接。
- 如果图件要直接转成组会材料或答辩汇报,再交给 `nature-paper2ppt` 组织成页面。
- `nature-figure` 负责图件本身;它不替代统计审查,也不替代手稿叙事。
references/ai-graphical-abstract-workflow.md
# AI-Assisted Graphical Abstract Workflow

Use this reference whenever a task involves planning, generating, revising, or
auditing a graphical abstract with AI assistance. Apply it before any
provider-specific image-generation instructions.

## Contents

- [Authority boundary and policy gate](#authority-boundary-and-policy-gate)
- [1. Define the communication target](#1-define-the-communication-target)
- [2. Build a visual brief](#2-build-a-visual-brief)
- [3. Assign AI a bounded role](#3-assign-ai-a-bounded-role)
- [4. Write the prompt as a figure contract](#4-write-the-prompt-as-a-figure-contract)
- [5. Run human scientific and publication QA](#5-run-human-scientific-and-publication-qa)
- [Sources and status](#sources-and-status)

## Authority boundary and policy gate

- Treat Ananya Thakur's 22 July 2026 *Nature* Careers column as practitioner
  guidance, not as a Nature Portfolio submission policy or blanket permission
  to publish AI-generated artwork.
- Before generating a submission candidate, verify the target journal's current
  graphical-abstract, artificial-intelligence, image-integrity, copyright, and
  disclosure rules on official pages. Record the journal, URL, and access date.
- For a Nature Portfolio target, apply the current risk-assessment framework:
  assistive use can support expression or organization; interpretive or
  generative use requires human oversight, verification, and transparent
  disclosure; opaque, unverifiable, confidentiality-breaching, or
  judgement-replacing use is not permitted. A journal may impose stricter
  rules.
- If journal clearance is unknown, label the output **internal design draft —
  submission eligibility unverified**. Do not call it submission-ready.
- Keep two decisions separate: whether a draft is useful internally and whether
  the final asset is eligible for submission.

## 1. Define the communication target

Before choosing a tool or visual style, write down:

1. the single sentence the reader should remember
2. the figure type: mechanism, process, experimental setup, workflow,
   comparison, timeline, cycle, or branching decision
3. the intended audience and its expected vocabulary
4. the evidence boundary: what the study demonstrates, what is contextual, and
   what must not be implied
5. the details to omit because they do not support the central message

Do not ask an image model to summarize an entire manuscript into a final image
without this brief.

## 2. Build a visual brief

- Inspect graphical abstracts from comparable papers for information density,
  reading order, and composition. Learn the visual grammar; do not copy
  protected artwork, icons, or distinctive layouts.
- Choose an explicit reading path: left-to-right, top-to-bottom, cycle, split
  comparison, or fork. Use arrows and grouping only when they clarify that path.
- Use a limited, consistent, high-contrast, color-accessible palette. Assign
  color by scientific meaning, not decoration, and do not rely on color alone.
- Reserve the strongest accent for the central causal step or principal result.
  Keep labels short and plan their placement before generating artwork.
- Prefer one representative pathway over an exhaustive interaction network when
  the fuller network would obscure the central claim. Preserve necessary caveats
  in the legend or manuscript.

## 3. Assign AI a bounded role

Use AI to assist with tasks such as:

- distilling author-provided claims into candidate one-sentence messages
- comparing audience-specific levels of terminology and detail
- proposing several compositions or label placements
- checking palette contrast and color accessibility
- turning an author-owned sketch into a draft layout or vectorization plan
- brainstorming pictorial metaphors that will be scientifically reviewed

Do not let AI invent measurements, mechanisms, structures, clinical effects,
citations, labels, or evidence. Do not upload confidential manuscripts, peer
review material, sensitive data, or unpublished images to an unsecured or public
AI service without explicit authorization.

## 4. Write the prompt as a figure contract

Include these fields in a provider-neutral prompt:

- **Purpose and audience**
- **One-sentence message**
- **Figure type and reading order**
- **Required entities and relationships**
- **Evidence boundary and forbidden implications**
- **Composition and focal element**
- **Palette, contrast, and accessibility constraints**
- **Exact short labels**
- **Elements to exclude**, including fabricated numbers, logos, and decorative
  scientific objects
- **Output role**, explicitly stating `concept draft` when generative AI is used

Generate layout alternatives before polishing a single composition. Correct
scientific structure first, then typography, color, and visual finish. Redraw
critical text, arrows, chemical structures, and quantitative marks with
deterministic or editable tools whenever possible.

## 5. Run human scientific and publication QA

Before delivery, check every visual element against the manuscript and source
data:

- scientific entities, directions, causal links, scale, anatomy, and chronology
- all labels, abbreviations, spelling, units, and symbol definitions
- absence of invented data, unsupported effects, misleading realism, or
  decorative evidence
- readable hierarchy at final size and a color-blindness-safe reading path
- originality, licenses, permissions, attribution, and resemblance to source art
- target-journal eligibility, disclosure wording, and figure-legend treatment

Retain a provenance bundle containing the prompt, model/provider and version,
generation date, reference-image rights, generated candidates, selected output,
and a log of manual corrections. Human authors remain accountable for the final
asset.

## Sources and status

- Practitioner workflow: Ananya Thakur, “How to use AI to make a graphical
  abstract in minutes,” *Nature* Careers, 22 July 2026,
  <https://doi.org/10.1038/d41586-026-02072-9>.
- Governing portfolio policy: Nature Portfolio, “Artificial Intelligence (AI),”
  <https://www.nature.com/nature-portfolio/editorial-policies/ai>, verified
  15 August 2026. Recheck before each submission because the policy is reviewed
  periodically.
references/api.md
# API Reference — Nature Figure Making

## Contents

- [Constants](#constants)
- [MANDATORY font + SVG rules (always first, no exceptions)](#mandatory-font-svg-rules-always-first-no-exceptions)
- [apply_publication_style()](#apply_publication_style)
- [is_dark(hex_color, threshold=128)](#is_darkhex_color-threshold128)
- [add_panel_label(ax, label, ...)](#add_panel_labelax-label)
- [style_dark_image_ax(ax, ...)](#style_dark_image_axax)
- [make_grouped_bar(ax, categories, series, labels, ...)](#make_grouped_barax-categories-series-labels)
- [make_trend(ax, x, y_series, labels, ...)](#make_trendax-x-y_series-labels)
- [make_forest_plot(ax, labels, estimates, ci_low, ci_high, ...)](#make_forest_plotax-labels-estimates-ci_low-ci_high)
- [make_heatmap(ax, matrix, ...)](#make_heatmapax-matrix)
- [Numerical and annotation safety helpers](#numerical-and-annotation-safety-helpers)
- [require_matplotlib_panel_alignment(fig, ...)](#require_matplotlib_panel_alignmentfig-)
- [finalize_figure(fig, out_path, ...)](#finalize_figurefig-out_path)
- [Validation Rules](#validation-rules)
- [Conventions](#conventions)


Conventions, constants, and reusable code blocks. Implement in your script or adapt as needed.

---

## Constants

### PALETTE

```python
PALETTE = {
    "blue_main":      "#0F4D92",
    "blue_secondary": "#3775BA",
    "green_1": "#DDF3DE",
    "green_2": "#AADCA9",
    "green_3": "#8BCF8B",
    "red_1":   "#F6CFCB",
    "red_2":   "#E9A6A1",
    "red_strong": "#B64342",
    "neutral_light": "#CFCECE",
    "neutral_mid":   "#767676",
    "neutral_dark":  "#4D4D4D",
    "neutral_black": "#272727",
    "gold":   "#FFD700",
    "teal":   "#42949E",
    "violet": "#9A4D8E",
    "magenta":"#EA84DD",
}

DEFAULT_COLORS = [
    PALETTE["blue_main"],
    PALETTE["green_3"],
    PALETTE["red_strong"],
    PALETTE["teal"],
    PALETTE["violet"],
    PALETTE["neutral_light"],
]

PALETTE_NMI_PASTEL = {
    "baseline_dark": "#484878",
    "baseline_mid":  "#7884B4",
    "baseline_soft": "#B4C0E4",
    "ours_tiny":  "#E4E4F0",
    "ours_base":  "#E4CCD8",
    "ours_large": "#F0C0CC",
    "bg_lilac": "#E0E0F0",
    "bg_aqua":  "#E0F0F0",
    "bg_peach": "#F0E0D0",
    "neutral_light": "#D8D8D8",
    "neutral_mid":   "#A8A8A8",
    "neutral_dark":  "#606060",
    "delta_up":   "#2E9E44",
    "delta_down": "#E53935",
}

DEFAULT_COLORS_NMI_PASTEL = [
    PALETTE_NMI_PASTEL["baseline_dark"],
    PALETTE_NMI_PASTEL["baseline_mid"],
    PALETTE_NMI_PASTEL["baseline_soft"],
    PALETTE_NMI_PASTEL["ours_tiny"],
    PALETTE_NMI_PASTEL["ours_base"],
    PALETTE_NMI_PASTEL["ours_large"],
]

PALETTE_NATURE_IMAGING = {
    "bg": "#000000",
    "context": "#B8B8B8",
    "cyan": "#22D7E6",
    "magenta": "#FF2AD4",
    "white": "#FFFFFF",
}

PALETTE_NATURE_MATERIAL = {
    "aqua": "#77D7D1",
    "teal": "#33B5A5",
    "lilac": "#B9A7E8",
    "violet": "#7C6CCF",
    "callout_red": "#E53935",
    "neutral": "#D9D9D9",
}

PALETTE_NATURE_CLINICAL = {
    "baseline": "#272727",
    "week6": "#E28E2C",
    "week13": "#D24B40",
    "week26": "#5B8FD6",
    "year1": "#7BAA5B",
    "year2": "#C45AD6",
    "group_band": "#F2E6D9",
}

PALETTE_NATURE_GENOMICS = {
    "neutral_light": "#D8D8D8",
    "neutral_mid": "#8F8F8F",
    "wave1": "#D9544D",
    "wave2": "#5B7FCA",
    "wave3": "#B89BD9",
    "outline": "#4D4D4D",
}
```

Use `DEFAULT_COLORS` when color itself carries explicit semantic meaning (`hero`, `baseline`, `positive variant`).
Use `DEFAULT_COLORS_NMI_PASTEL` when several compared methods belong to one or two related families and the page
should feel visually unified.

---

## MANDATORY font + SVG rules (always first, no exceptions)

These three lines are **non-negotiable** and must appear at the top of every script,
before any figure is created. They guarantee editable text in SVG output:

```python
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans', 'Liberation Sans']
plt.rcParams['svg.fonttype'] = 'none'   # keeps text as <text> nodes, not paths
```

**Why `svg.fonttype = 'none'`**: matplotlib's default (`'path'`) converts every
glyph to a bezier path, making text unselectable, unsearchable, and impossible to
re-align in Illustrator / Inkscape. With `'none'`, text stays as SVG `<text>` elements
and font substitution happens at render time.

**Output format**: always save as `.svg` (primary). PNG/PDF are optional secondary
exports. Never use `.png` alone when the figure contains text that may need adjustment.

---

## apply_publication_style()

```python
def apply_publication_style(font_size=16, axes_linewidth=2.5, use_tex=False):
    """Apply Nature-style rcParams. Call once before creating any figures."""
    # ── MANDATORY: editable SVG text ──────────────────────────────────────────
    plt.rcParams['font.family'] = 'sans-serif'
    plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans', 'Liberation Sans']
    plt.rcParams['svg.fonttype'] = 'none'
    # ── Layout & style ────────────────────────────────────────────────────────
    plt.rcParams['font.size'] = font_size
    plt.rcParams['axes.spines.right'] = False
    plt.rcParams['axes.spines.top'] = False
    plt.rcParams['axes.linewidth'] = axes_linewidth
    plt.rcParams['legend.frameon'] = False
    if use_tex:
        plt.rcParams['text.usetex'] = True
```

**Presets:**
- Large bar panels: `apply_publication_style(font_size=24, axes_linewidth=3)`
- Compact figures: `apply_publication_style(font_size=15, axes_linewidth=2)`
- Dense journal-width multi-panels: `apply_publication_style(font_size=8, axes_linewidth=1)`
- LaTeX labels: `apply_publication_style(use_tex=True)`

---

## is_dark(hex_color, threshold=128)

```python
def is_dark(hex_color, threshold=128):
    """Return True if hex color is dark (use white text on it)."""
    c = hex_color.lstrip('#')
    r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
    return (0.299*r + 0.587*g + 0.114*b) < threshold
```

---

## add_panel_label(ax, label, ...)

```python
def add_panel_label(ax, label, x=0, y=1, x_offset_pt=-4, y_offset_pt=3,
                    fontsize=8, color='black', fontweight='bold', va='bottom'):
    """Place a panel label with a fixed physical offset from an axes anchor."""
    from matplotlib.transforms import ScaledTranslation
    offset = ScaledTranslation(
        x_offset_pt / 72,
        y_offset_pt / 72,
        ax.figure.dpi_scale_trans,
    )
    ax.text(
        x, y, label,
        transform=ax.transAxes + offset,
        fontsize=fontsize,
        fontweight=fontweight,
        color=color,
        ha='left',
        va=va,
    )
```

The point offset keeps labels aligned when one panel spans two rows and its
neighbouring axes are shorter; a shared axes-fraction offset such as `y=1.02`
would produce different physical displacements. For dark image plates, move
the label inside the panel and switch to white:
`add_panel_label(ax, 'a', x=0.01, y=0.98, x_offset_pt=0, y_offset_pt=0, color='white', va='top')`

---

## style_dark_image_ax(ax, ...)

```python
def style_dark_image_ax(ax, facecolor='black'):
    """Prepare an axes for microscopy / rendering plates."""
    ax.set_facecolor(facecolor)
    ax.set_xticks([])
    ax.set_yticks([])
    for spine in ax.spines.values():
        spine.set_visible(False)
    return ax
```

---

## make_grouped_bar(ax, categories, series, labels, ...)

```python
def make_grouped_bar(ax, categories, series, labels,
                     ylabel='Value', colors=None,
                     annotate=False, bar_width=0.8,
                     series_spread=None, error_kw=None):
    """
    Grouped bar chart.

    Parameters
    ----------
    ax         : matplotlib Axes
    categories : list[str]  — x-axis category names (length K)
    series     : list[array] — one array per group (each length K)
    labels     : list[str]  — legend label per group
    ylabel     : str
    colors     : list[str] | None  — defaults to DEFAULT_COLORS; override with
                                     DEFAULT_COLORS_NMI_PASTEL for unified-family figures
    annotate   : bool  — print value above each bar
    bar_width  : float — total width for all bars in one category
    error_kw   : dict  — passed to ax.bar as error_kw
    series_spread : list[array] | None — one uncertainty array per series

    Returns
    -------
    list[BarContainer]
    """
    import numpy as np
    if colors is None:
        colors = DEFAULT_COLORS
    if error_kw is None:
        error_kw = {'elinewidth': 2, 'capthick': 2, 'capsize': 10}
    n_groups = len(series)
    n_cats = len(categories)
    w = bar_width / n_groups
    x = np.arange(n_cats)
    flat_values = np.concatenate([np.asarray(values, dtype=float) for values in series])
    label_pad = 0.02 * max(float(np.ptp(flat_values)), float(np.max(np.abs(flat_values))), 1.0)
    containers = []
    for i, (vals, label, color) in enumerate(zip(series, labels, colors)):
        spread = None if series_spread is None else np.asarray(series_spread[i], dtype=float)
        offset = (i - (n_groups - 1) / 2) * w
        bars = ax.bar(x + offset, vals, width=w, label=label,
                      color=color, edgecolor='black', linewidth=1.5,
                      yerr=spread, error_kw=error_kw)
        containers.append(bars)
        if annotate:
            for j, (bar, val) in enumerate(zip(bars, vals)):
                upper = bar.get_height() + (0 if spread is None else spread[j])
                ax.text(bar.get_x() + bar.get_width() / 2,
                        upper + label_pad,
                        f'{val:.2f}', ha='center', va='bottom', fontsize=10)
    ax.set_xticks(x)
    ax.set_xticklabels(categories)
    ax.set_ylabel(ylabel)
    ax.legend()
    return containers
```

---

## make_trend(ax, x, y_series, labels, ...)

```python
def make_trend(ax, x, y_series, labels,
               colors=None, ylabel=None, xlabel=None,
               show_shadow=True, shadow_alpha=0.15,
               lw=2.5, marker='o', markersize=8):
    """
    Multi-line trend plot.

    Parameters
    ----------
    x        : array-like   — shared x values
    y_series : list[array]  — one 1D array per line
    labels   : list[str]
    show_shadow : bool  — fill_between ± std if y_series contains 2D arrays (rows=runs)
    """
    import numpy as np
    if colors is None:
        colors = DEFAULT_COLORS
    for y, label, color in zip(y_series, labels, colors):
        y = np.asarray(y)
        if y.ndim == 2:
            mean, std = y.mean(0), y.std(0)
        else:
            mean, std = y, None
        ax.plot(x, mean, color=color, lw=lw, marker=marker,
                markersize=markersize, label=label)
        if show_shadow and std is not None:
            ax.fill_between(x, mean - std, mean + std,
                            color=color, alpha=shadow_alpha)
    if ylabel:
        ax.set_ylabel(ylabel)
    if xlabel:
        ax.set_xlabel(xlabel)
    ax.legend()
```

---

## make_forest_plot(ax, labels, estimates, ci_low, ci_high, ...)

```python
def make_forest_plot(ax, labels, estimates, ci_low, ci_high,
                     colors=None, ref=0.0, xlabel=None, xlim=None,
                     marker='o', markersize=5, lw=1.5):
    """
    Minimal forest plot helper for Nature-style clinical/statistical panels.
    """
    import numpy as np
    y = np.arange(len(labels))[::-1]
    if colors is None:
        colors = ['#B64342'] * len(labels)
    for yi, est, lo, hi, color in zip(y, estimates, ci_low, ci_high, colors):
        ax.plot([lo, hi], [yi, yi], color=color, lw=lw)
        ax.plot(est, yi, marker=marker, ms=markersize, color=color)
    ax.axvline(ref, color='#767676', linestyle='--', linewidth=1.2, alpha=0.8)
    ax.set_yticks(y)
    ax.set_yticklabels(labels)
    if xlabel:
        ax.set_xlabel(xlabel)
    if xlim is not None:
        ax.set_xlim(xlim)
    ax.spines['right'].set_visible(False)
    ax.spines['top'].set_visible(False)
```

Use pale `ax.axhspan(...)` bands behind contiguous label groups when you need the
clinical-triptych look from `Nature`.

---

## make_heatmap(ax, matrix, ...)

```python
def make_heatmap(ax, matrix, x_labels=None, y_labels=None,
                 cmap='magma', cbar_label=None, annotate=False,
                 fmt='{:.2f}', fontsize=12):
    """
    2D heatmap with optional colorbar and cell annotations.
    """
    import numpy as np
    import matplotlib as mpl
    im = ax.imshow(matrix, cmap=cmap, aspect='auto')
    if cbar_label:
        cbar = ax.figure.colorbar(im, ax=ax)
        cbar.set_label(cbar_label)
    if x_labels:
        ax.set_xticks(range(len(x_labels)))
        ax.set_xticklabels(x_labels, rotation=30, ha='right', rotation_mode='anchor')
    if y_labels:
        ax.set_yticks(range(len(y_labels)))
        ax.set_yticklabels(y_labels)
    if annotate:
        norm = mpl.colors.Normalize(vmin=matrix.min(), vmax=matrix.max())
        cm_obj = plt.get_cmap(cmap)
        for (i, j), val in np.ndenumerate(matrix):
            r, g, b, _ = cm_obj(norm(val))
            lum = 0.299*r + 0.587*g + 0.114*b
            color = 'white' if lum < 0.5 else 'black'
            ax.text(j, i, fmt.format(val), ha='center', va='center',
                    fontsize=fontsize, color=color)
    ax.set_frame_on(False)
```

---

## Numerical and annotation safety helpers

Copy `scripts/figure_safety.py` beside the plotting script, or add that scripts directory to `PYTHONPATH`. Then import the tested helpers rather than calling `np.interp` directly on a curve whose direction has not been asserted:

```python
from figure_safety import interp_monotone, label_y_above

n_equivalent = interp_monotone(target_error, error_curve, example_counts)
label_y = label_y_above(metric_center, metric_spread)
```

`interp_monotone` accepts strictly increasing or decreasing `xp`, reverses a decreasing grid and its paired values together, and rejects duplicates/direction changes. `label_y_above` places a shared annotation above the upper uncertainty extent rather than relying on a fixed `LABEL_Y`.

For compact mathematical labels, prefer supported Unicode glyphs such as `R²` over `$R^2$` when the meaning is unchanged, then audit the exported PDF:

```bash
python skills/nature-figure/scripts/audit_pdf_text.py figure.pdf --min-pt 5
python skills/nature-figure/scripts/audit_figure_collisions.py figure.pdf \
  --json-out figure.collision-audit.json \
  --overlay-pdf figure.collision-audit.pdf
```

---

## require_matplotlib_panel_alignment(fig, ...)

For every multi-panel figure, measure the final Matplotlib axes positions after
the last `tight_layout`, constrained-layout draw, legend, colorbar or manual
position change and before saving. Copy `scripts/audit_panel_alignment.py`
beside the plotting source or add the skill's scripts directory to
`PYTHONPATH`, then use the blocking helper:

```python
from audit_panel_alignment import require_matplotlib_panel_alignment

alignment = require_matplotlib_panel_alignment(
    fig,
    json_out="figures/figure.alignment.json",
    overlay_svg="figures/figure.alignment.svg",
    tolerance_pt=1.5,
    gutter_tolerance_pt=1.5,
    require_panel_labels=True,
    strict=True,
)
```

The helper calls `fig.canvas.draw()` and converts final axes positions to
physical points. Ordinary `plt.subplots()` and `GridSpec` layouts infer row and
column groups from `SubplotSpec`, then check:

- common top/bottom edges and equal heights within each row;
- equal final widths for three or four same-row panels with equal grid spans;
- common left/right edges and equal widths within each column;
- shared top/bottom or left/right grid boundaries when adjacent panels have
  unequal spans, including `left two + right one` and `left one + right two`;
- repeated horizontal/vertical gutters;
- bold lowercase top-left panel-label anchors when detectable;
- plot-area rectangle overlap.

Ordinary unequal-span `GridSpec` layouts are inferred automatically: a two-row
spanning panel is compared with the appropriate upper and lower small panels at
the shared outer edges, without imposing an invalid equal-height comparison.
For ordinary horizontal `1 × 3` and `1 × 4` grids, equal column spans must also
produce equal final plot-area widths. An intentional unequal `width_ratios`
layout needs a panel-specific `panel-width` exemption with a reason.
For nested or separately created grids, declare intended comparisons instead
of asking the auditor to guess them:

```python
require_matplotlib_panel_alignment(
    fig,
    axes=[ax_a, ax_b, ax_c, ax_d],
    panel_ids=["a", "b", "c", "d"],
    row_groups=[["a", "b"], ["c", "d"]],
    column_groups=[["a", "c"], ["b", "d"]],
    exclude_axes=[colorbar.ax, inset_ax],
    exemptions=[
        {
            "panels": ["a"],
            "checks": ["column", "panel-width"],
            "reason": "hero panel intentionally spans two grid columns",
        }
    ],
    json_out="figures/figure.alignment.json",
    strict=True,
)
```

Every exemption needs panel ids, exact checks and a reason. Prefer omitting a
hero panel, inset, legend-only axis or colorbar from unrelated comparison groups
over globally increasing the `1.5 pt` tolerance. Exit-equivalent failures raise
`PanelAlignmentError`; do not catch and ignore it in a delivery script. A
single-panel figure is outside this gate, while a multi-panel figure without
valid comparable groups is `NOT AUDITABLE`, not a pass.

The optional SVG contains measured rectangles only and is QA-only. Preserve
the JSON with the figure bundle, then run PDF glyph and collision QA on the
exported figure.

---

## finalize_figure(fig, out_path, ...)

```python
def finalize_figure(fig, out_path, formats=None, dpi=300,
                    pad=2, bbox_inches=None, close=True,
                    alignment_options=None):
    """
    Apply tight_layout and save figure. For multi-panel figures, run
    require_matplotlib_panel_alignment() after this layout operation and before
    the save loop; do not close the figure before alignment QA.

    Parameters
    ----------
    out_path : str   — path without extension, or with extension
    formats  : list  — e.g. ['png', 'pdf']. If None, uses extension of out_path.
    dpi      : int   — 300 standard, 600 for dense bar panels
    pad      : float — tight_layout pad (2 default, 1 for compact multi-panel)
    alignment_options : dict | None — explicit groups/exemptions for unusual layouts
    """
    import os
    from pathlib import Path
    base = Path(out_path)
    if formats is None:
        formats = [base.suffix.lstrip('.') or 'png']
        base = base.with_suffix('')
    os.makedirs(base.parent, exist_ok=True)
    fig.tight_layout(pad=pad)
    options = {} if alignment_options is None else dict(alignment_options)
    require_matplotlib_panel_alignment(
        fig,
        json_out=str(base) + ".alignment.json",
        overlay_svg=str(base) + ".alignment.svg",
        strict=True,
        **options,
    )
    saved = []
    for fmt in formats:
        p = str(base) + f'.{fmt}'
        kw = {}
        if bbox_inches is not None:
            kw['bbox_inches'] = bbox_inches
        fig.savefig(p, dpi=dpi, **kw)
        saved.append(p)
    if close:
        plt.close(fig)
    return saved
```

---

## Validation Rules

- `make_grouped_bar`: `len(categories)` must equal length of each array in `series`.
- `make_grouped_bar`: if `series_spread` is supplied, it must mirror `series`; annotations clear `value + spread`.
- `make_trend`: each array in `y_series` must have same length as `x`.
- `make_trend`: 2D run/seed arrays show a standard-deviation band by default; use a different definition only when explicitly justified and documented.
- `make_heatmap`: `matrix` must be 2D; `x_labels` length = `matrix.shape[1]`; `y_labels` length = `matrix.shape[0]`.
- `finalize_figure`: supported formats — `png`, `pdf`, `svg`, `eps`, `jpg`, `tif`.

---

## Conventions

- Save outputs under `./figures/` (or path given by user); `finalize_figure` creates parent dirs.
- In headless / batch runs, set non-interactive backend before importing pyplot:
  ```python
  import matplotlib
  matplotlib.use('Agg')
  import matplotlib.pyplot as plt
  ```
- Always `plt.close(fig)` after saving to free memory.
- For multi-panel figures, prefer one baseline family plus one hero family; reserve green/red for delta cues.
- When color roles, resolution, or layout are underspecified and would change the figure, confirm with user before finalizing.
references/asset-adaptation.md
# Plotting Asset Adaptation

Use this reference when reusing a bundled example, a preview image, or a user-provided plotting script. Treat examples as visual and structural starting points, not as evidence that a script is compatible with new data.

## Choose the reuse level

Assign every candidate to one of four levels before editing it:

| Level | Use when | Allowed changes |
|---|---|---|
| Exact reuse | Scientific meaning, data shape, transformations, and backend all match | Input path, labels, and output prefix only |
| Structural adaptation | Scientific meaning and dimensionality match, but field names or group labels differ | Explicit field mapping plus documented transform guards |
| Style-only inheritance | The plot family is useful but the data structure or statistic differs | Palette, typography, spacing, marker, legend, and annotation conventions only |
| Build anew | The candidate answers a different question or would require replacing its statistical logic | Do not force the template; implement the confirmed figure contract directly |

Do not call a script production-ready merely because it renders its bundled example.

## Inspect before mapping

1. Open the companion preview when one exists.
2. State what the candidate actually displays: dimensionality, mark type, grouping, statistic, uncertainty, transforms, and annotations.
3. State what the requested panel must answer.
4. Reject structural reuse when those meanings differ. A 2D joint-density plot is not a reusable implementation of several 1D marginal densities, and a benchmark bar chart is not automatically a valid small-sample biological comparison.

## Map the data contract

Write an explicit mapping before changing code:

```text
template field -> user field -> role -> units -> allowed values
group field    -> user field -> category order
replicate unit -> source rows/images -> biological or technical
uncertainty    -> source field or calculation -> definition
```

Confirm ambiguous mappings with the user. Never choose convenient columns silently. Keep identifiers separate from measurements and preserve the requested category order unless a scientifically justified ordering is declared.

## Guard transformations

Check every inherited transformation against the new data:

- Log axes and logarithms require strictly positive values unless a declared signed-log or pseudocount method is scientifically justified.
- Ratios and normalized values require finite denominators and a defined zero-denominator policy.
- Square-root transforms require non-negative inputs.
- Min-max scaling requires non-constant finite ranges.
- Binning and density estimation require enough distinct observations; record bin or bandwidth choices.
- Correlation, PCA, clustering, and statistical annotations require explicit missing-value handling and an appropriate replicate unit.

If a guard fails, change the transformation only when the scientific meaning remains valid and record the change. Otherwise use style-only inheritance or build anew.

## Preserve data integrity

- Use all supplied observations and requested variables by default.
- Do not downsample for aesthetics or rendering speed. Use rasterization, hexbin/density marks, transparent points, aggregation with a stated rule, or backend-native large-data rendering.
- If the analysis requires filtering, record the exact predicate and before/after row, column, replicate, or image counts.
- When the user explicitly requests sampling, record the method, sample size, seed, and whether sampling changes any inferential claim.
- Never leave simulated values in a production deliverable. Isolate demos behind an explicit demo flag or a separate example file.

## Adapt without erasing provenance

Copy the candidate into the task workspace before editing. Keep source assets unchanged. Preserve license and attribution notices, but do not expose private local paths or private template identifiers in generated figures, legends, manuscript text, or user-facing reports.

Record the reuse level and source category in internal QA notes. The adaptation method in this reference incorporates portable ideas from the Apache-2.0 `academic-figure-skill` workflow while replacing its path-bound runners and project-specific assumptions.

## Validate and deliver

1. Run the adapted script with representative real input using the selected backend.
2. Run `python scripts/validate_figure.py path/to/script.py` or the corresponding `.R` file.
3. Run `python scripts/audit_pdf_text.py path/to/figure.pdf --min-pt 5` and `python scripts/audit_figure_collisions.py path/to/figure.pdf --json-out path/to/figure.collision-audit.json` on the final export.
4. Treat static and geometry validation as preflight only; fix collision FAIL findings and review every WARN, but do not infer statistical correctness or complete visual quality from a pass.
5. Inspect SVG/PDF text editability, raster resolution, clipping, ambiguous overlays, color accessibility, and readability at final physical size.
6. Include the field mapping, exclusions, transform changes, collision report, and remaining caveats in the QA notes.
references/backend-selection.md
# Backend Selection

## Contents

- [Quick decision table](#quick-decision-table)
- [Backend exclusivity rule](#backend-exclusivity-rule)
- [Default stacks](#default-stacks)
- [Mixed workflow rule](#mixed-workflow-rule)
- [Recommendation language](#recommendation-language)


At the start of a plotting task, resolve the user's preferred backend in this order:

1. explicit Python/R choice in the current request;
2. clearly language-specific input file or workflow;
3. saved preference from `scripts/nature_figure_backend.py get`;
4. if no preference exists, ask **Python or R? I will remember this as your default.**

Stop after asking and wait for the user's answer. After the answer, save it with
`scripts/nature_figure_backend.py set python` or `scripts/nature_figure_backend.py set r`.
Do not infer Python just because the task involves simulation, NumPy-like data, or
custom layout, and do not infer R just because the task is biological or omics-adjacent.

Use the decision table only in either of these cases:

- the user explicitly asks you to recommend or choose the backend;
- the user provides an unambiguous language-specific workflow or file, such as an `.R`
  script, RDS object, Python notebook, or existing Python plotting code.

## Quick decision table

| Recommend R when | Recommend Python when |
|---|---|
| The user brings R scripts, RData/RDS, Seurat objects, DESeq2/limma outputs, survival models, or ggplot templates | The data pipeline is already Python, NumPy/Pandas arrays, PyTorch/TensorFlow outputs, image arrays, or simulation output |
| The target plot is `ggplot2`, `patchwork`, `ComplexHeatmap`, `ggtree`, `circlize`, `survminer`, `maftools`, or Seurat/UMAP-heavy | The target plot needs low-level custom layout, Matplotlib patches, image plates, subplot mosaics, or custom drawing primitives |
| The user provides an R template collection or an existing R plotting workflow | The user wants a self-contained script with matplotlib/seaborn/statsmodels and no R dependency |
| Heatmap annotations are biologically rich and multi-layered | Image panels and quantitative panels need tight pixel/axis control |

If either backend can do the job, honor the user's saved preference. Do not switch
backends for aesthetics alone. If the user explicitly switches backend, save the new
preference.

## Backend exclusivity rule

Backend choice is not just a syntax preference; it defines the graphics engine for
the entire deliverable. Once Python or R has been selected, use that backend for
all of the following:

- plotting scripts;
- mock/simulated data examples that include plotting;
- preview PNG/TIFF files;
- SVG/PDF/TIFF exports;
- visual QA renders and final layout checks.

Do not generate a substitute preview or export with the non-selected backend. For
example, if the user selected R and `Rscript` is missing, do not use Python/matplotlib
to approximate the figure. If the user selected Python and `matplotlib` or another
required Python plotting package is missing, do not use R/ggplot2/ComplexHeatmap to
approximate the figure. Stop, report the selected-backend blocker, and provide the
selected-backend script plus install/run instructions or request permission to install
the selected-backend dependencies.

The non-selected language is allowed only for non-visual utility work, such as
listing files, checking CSV dimensions, decompressing an archive, or converting a
data file before the selected backend draws the figure. It must not import plotting
libraries, open graphics devices, save image/vector files, or decide visual layout.

## Default stacks

### R

- Core plotting: `ggplot2`
- Multi-panel assembly: `patchwork`
- Heatmaps: `ComplexHeatmap`, `circlize`
- Direct labels: `ggrepel`
- Survival/clinical: `survival`, `survminer`, `forestplot`, `ggplot2`
- Single-cell/omics: `Seurat`, `SingleCellExperiment`, `ComplexHeatmap`, `ggtree`
- Export: `svglite`, `grDevices::cairo_pdf`, `ragg`

### Python

- Core plotting: `matplotlib`
- Statistical plots: `seaborn`
- Layout: `subplot_mosaic`, `GridSpec`
- Tables/model output: `pandas`, `numpy`, `statsmodels`
- Images: `matplotlib.imshow`, `skimage`, `tifffile` when needed
- Export: `fig.savefig(... .svg/.pdf/.tiff)`, `svg.fonttype='none'`,
  `pdf.fonttype=42`

## Mixed workflow rule

Use the selected plotting backend for final assembly and all visual output. A mixed
workflow is reasonable only when the non-selected language performs non-visual data
preparation and the selected backend assembles the figure. In that case:

1. Export clean source data as CSV/TSV with stable column names.
2. Assemble the final figure in the selected backend.
3. Keep the source-data file next to the plotting script.
4. Do not stitch, preview, QA-render, or export final image/vector outputs from the
   non-selected backend unless the user explicitly changes the selected backend.

## Recommendation language

Use direct language:

```text
For this figure I recommend R because the main burden is ComplexHeatmap-style
omics annotation and patchwork assembly. I will still keep the export contract
SVG/PDF/TIFF with editable text.
```

```text
For this figure I recommend Python because the key panel is a custom image plate
with quantitative overlays and a subplot_mosaic layout. Matplotlib gives tighter
control over the raster and vector layers.
```
references/chart-types.md
# Chart Types — Nature Figure Making

## Contents

- [Radar / Polar Chart](#radar-polar-chart)
- [3D Sphere / Conceptual Illustration](#3d-sphere-conceptual-illustration)
- [Scatter Plot with Color-Coded Clusters](#scatter-plot-with-color-coded-clusters)
- [Probability + Manifold Concept Panel](#probability-manifold-concept-panel)
- [Ablation Line Panel with Reference Baselines](#ablation-line-panel-with-reference-baselines)
- [Fill-Between Area Chart (Stacked trend)](#fill-between-area-chart-stacked-trend)
- [Log-Scale Bar Chart](#log-scale-bar-chart)
- [GridSpec Multi-Panel Layout](#gridspec-multi-panel-layout)
- [Scientific Notation on Y-Axis](#scientific-notation-on-y-axis)
- [Custom Spine Positioning](#custom-spine-positioning)
- [Related files](#related-files)


Specialized chart patterns beyond basic bars and trends.
Each section includes the key code pattern extracted from production scripts.

---

## Radar / Polar Chart

Used when comparing multiple methods across many benchmarks simultaneously.

```python
import numpy as np
import matplotlib.pyplot as plt

def plot_radar(methods, colors, subtask_names, value_matrix,
               benchmark_radii, display_range=(45, 90)):
    """
    Parameters
    ----------
    methods        : list[str]    — one curve per method
    colors         : list[str]
    subtask_names  : list[str]    — one spoke per subtask (may contain '\\n')
    value_matrix   : np.ndarray  — shape (n_subtasks, n_methods)
    benchmark_radii: dict         — {benchmark_name: [tick1, tick2, ...]} for normalization
    display_range  : (r_min, r_max) — polar radial display window
    """
    r_lo, r_hi = display_range
    n_subtasks = len(subtask_names)
    n_methods  = len(methods)

    fig = plt.figure(figsize=(12, 10))
    ax  = fig.add_subplot(111, projection='polar')

    # Evenly spaced angles, clockwise from top
    angles = np.linspace(2 * np.pi, 0, n_subtasks, endpoint=False)
    angles_closed = np.append(angles, angles[0])

    def _normalize(val, bench):
        radii_list = benchmark_radii.get(bench, [0, 100])
        span = max(radii_list) - min(radii_list)
        if span <= 0:
            return (r_lo + r_hi) / 2
        frac = np.clip((val - min(radii_list)) / span, 0, 1)
        return r_lo + (r_hi - r_lo) * frac

    subtask_benchmarks = [s.split('\\n', 1)[-1] if '\\n' in s else s
                          for s in subtask_names]

    # Draw data polygons
    for m in range(n_methods):
        norm_vals = np.array([_normalize(value_matrix[i, m], subtask_benchmarks[i])
                              for i in range(n_subtasks)])
        closed = np.append(norm_vals, norm_vals[0])
        ax.plot(angles_closed, closed, color=colors[m], lw=2, label=methods[m])
        ax.fill(angles_closed, closed, color=colors[m], alpha=0.05)
        ax.scatter(angles, norm_vals, color=colors[m], s=18, zorder=5)

    # Style
    ax.set_ylim(r_lo, r_hi)
    ax.set_theta_zero_location('N')
    for spine in ax.spines.values():
        spine.set_visible(False)
    ax.grid(False)

    # Outer boundary ring
    ax.plot(angles_closed, np.full_like(angles_closed, r_hi),
            color='k', lw=0.8, zorder=4)

    # Radial spokes
    for a in angles:
        ax.plot([a, a], [r_lo, r_hi], color='gray', lw=0.5, zorder=4)

    # Benchmark-level contour polygons
    max_levels = max(len(v) for v in benchmark_radii.values())
    for k in range(max_levels):
        disp = np.array([_normalize(benchmark_radii.get(b, [0,100])[
                            min(k, len(benchmark_radii.get(b,[0,100]))-1)], b)
                         for b in subtask_benchmarks])
        ax.plot(angles_closed, np.append(disp, disp[0]),
                color='k', lw=0.6, zorder=4)

    ax.set_yticks([r_hi])
    ax.set_yticklabels([])
    ax.set_xticks(angles)
    ax.set_xticklabels([])

    # Spoke labels (outside outer ring)
    for angle, label in zip(angles, subtask_names):
        r_label = r_hi + 8 + 10 * abs(np.sin(angle))
        ax.text(angle, r_label, label, fontsize=14,
                ha='center', va='center',
                transform=ax.transData, clip_on=False)

    ax.legend(loc='upper right', bbox_to_anchor=(1.40, 0.05),
              fontsize=15, frameon=False)
    return fig, ax
```

**Key settings:**
- `ax.set_theta_zero_location('N')` — top-start convention
- Remove all default spines/grid; draw custom spokes + contour polygons manually
- Normalize each spoke independently using per-benchmark tick lists
- Legend placed **outside** the plot at `bbox_to_anchor=(1.40, 0.05)`

---

## 3D Sphere / Conceptual Illustration

Used for geometric conceptual diagrams (e.g., embedding space visualization).

```python
import numpy as np
import matplotlib.pyplot as plt

def draw_shaded_sphere(ax, light_dir=(-0.5, 0.5, 0.8),
                       resolution=512, alpha=1.0,
                       extent=(-1, 1, -1, 1)):
    """Draw a 2D shaded disk that mimics a 3D sphere using ray-casting."""
    xs = np.linspace(extent[0], extent[1], resolution)
    ys = np.linspace(extent[2], extent[3], resolution)
    x, y = np.meshgrid(xs, ys)
    r2 = x**2 + y**2
    mask = r2 <= 1.0

    z = np.zeros_like(x)
    z[mask] = np.sqrt(1.0 - r2[mask])

    # Surface normals
    nx, ny, nz = x.copy(), y.copy(), z.copy()
    nrm = np.sqrt(nx**2 + ny**2 + nz**2) + 1e-6
    nx, ny, nz = nx/nrm, ny/nrm, nz/nrm

    # Lambertian shading
    ld = np.array(light_dir, dtype=float)
    ld /= np.linalg.norm(ld)
    intensity = np.maximum(0, nx*ld[0] + ny*ld[1] + nz*ld[2])

    img = np.ones_like(x)
    img[mask] = np.clip(0.2 + 0.9 * intensity[mask], 0, 1)

    ax.imshow(img, cmap='gray',
              extent=list(extent),
              vmin=0, vmax=1, alpha=alpha)
    ax.set_axis_off()
    return ax


def plot_3d_scatter_with_arrows(ax, points, grad_vectors,
                                point_color='#0c2458', arrow_color='#b64342'):
    """3D scatter plot with gradient arrow annotations."""
    from mpl_toolkits.mplot3d import proj3d
    from matplotlib.patches import FancyArrowPatch

    class Arrow3D(FancyArrowPatch):
        def __init__(self, xs, ys, zs, *args, **kwargs):
            super().__init__((0,0), (0,0), *args, **kwargs)
            self._verts3d = xs, ys, zs
        def do_3d_projection(self, renderer=None):
            xs, ys, zs = proj3d.proj_transform(*self._verts3d, self.axes.get_proj())
            self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
            return np.min(zs)

    ax.scatter(points[:, 0], points[:, 1], points[:, 2],
               s=80, color=point_color, alpha=0.5)
    for p, g in zip(points, grad_vectors):
        arrow = Arrow3D([p[0], p[0]+g[0]], [p[1], p[1]+g[1]], [p[2], p[2]+g[2]],
                        mutation_scale=16, lw=4, arrowstyle='->',
                        color=arrow_color, alpha=0.8)
        ax.add_artist(arrow)

    # Clean 3D axes
    ax.grid(False)
    ax.xaxis.pane.set_visible(False)
    ax.yaxis.pane.set_visible(False)
    ax.zaxis.pane.set_visible(False)
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_zticks([])
```

---

## Scatter Plot with Color-Coded Clusters

```python
def make_scatter(ax, x, y, labels_or_colors,
                 size=50, alpha=0.7, edgecolors='none'):
    """Single or multi-cluster scatter."""
    import numpy as np
    ax.scatter(x, y, c=labels_or_colors, s=size,
               alpha=alpha, edgecolors=edgecolors)
    ax.set_axis_off()   # for conceptual diagrams; remove for data plots
```

---

## Probability + Manifold Concept Panel

Use when a manuscript needs a conceptual mechanism panel that links a probability
shift to a geometric or latent-space explanation. Build the pair from the
original pattern below: a 1D probability-density panel beside a contour/scatter
manifold panel.

**Pattern:**
- Left panel: draw 2-3 probability curves with transparent fills; use one vertical
  reference line and a single double-headed arrow to define the conceptual gap.
- Right panel: sample points around smooth center curves, show low-alpha clouds,
  contour density bands, and a small number of highlighted trajectory markers.
- Keep axes only where they carry meaning. The manifold panel can be axis-free if
  labels and arrows carry the explanation.
- All math labels and manifold names must map to real manuscript concepts. Do not
  reuse demo labels such as `VIG` or `DPO` unless they are the user's actual terms.

```python
fig, (ax_prob, ax_manifold) = plt.subplots(1, 2, figsize=(24, 6))
plot_distribution(ax_prob)  # probability curves + conceptual gap arrow
plot_manifold(ax_manifold)  # density contours + trajectory markers
fig.tight_layout(pad=0.5)
```

---

## Ablation Line Panel with Reference Baselines

Use when an ablation compares data fraction, hyperparameters, or coupled metrics
across a small set of methods. A robust original layout uses three horizontal
panels: data fraction, one hyperparameter sweep, and a dual-axis coupled metric
sweep.

**Pattern:**
- Use a dashed horizontal baseline for the simple/reference model.
- Use a dotted horizontal line for a meaningful operating point, e.g. "ours at
  25% data", only when that comparison is called out in the text.
- Use `twinx()` sparingly. If two y-axes are needed, color each y label to match
  the corresponding series and keep tick ranges narrow.
- Put legends inside low-density regions of each panel; avoid one giant legend
  if panel-specific series differ.

```python
fig, axes = plt.subplots(1, 3, figsize=(27, 6),
                         gridspec_kw={"width_ratios": [1.1, 1, 1]})
axes[0].plot(x, baseline, color="black", alpha=0.3, lw=4, ls="--")
axes[0].plot(x, reference, color=hero_color, lw=3, ls=":")
ax2 = axes[2].twinx()
```

---

## Fill-Between Area Chart (Stacked trend)

Used for cumulative publication counts, stacked contributions, etc.

```python
# Filled area (stacked) with hatch for print safety
ax.fill_between(x, 0, y_bottom,
                color='#ffa8a6', label='Category A')
ax.fill_between(x, 0, y_top,
                color='#9BC8FA',
                hatch='///',               # hatch for grayscale print
                edgecolor='black',
                label='Category B')
# Erase border artifacts
ax.fill_between(x, 0, y_top,
                facecolor='none',
                edgecolor='white',
                linewidth=2)

# Overlay the trend line for exact values
ax.plot(x, y_top, lw=3, color='#13457E')
ax.plot(x, y_bottom, lw=3, color='#850c0a')
```

---

## Log-Scale Bar Chart

```python
ax.set_yscale('log')
ymin, ymax = ax.get_ylim()
ax.set_ylim(ymin, ymax * 20)   # expand top for annotations

# Annotate values above bars
for i, val in enumerate(values):
    ax.text(i, val * 1.1, f'{val:.3f}',
            ha='center', va='bottom', fontsize=16)
```

---

## GridSpec Multi-Panel Layout

```python
from matplotlib import gridspec

# 2-row, 4-column layout
fig = plt.figure(figsize=(36, 12))
gs = gridspec.GridSpec(2, 4)

ax_top_left  = fig.add_subplot(gs[0, 0])
ax_top_right = fig.add_subplot(gs[0, 1:3])   # span columns 1-2
ax_legend    = fig.add_subplot(gs[0, 3])     # legend panel
ax_bottom    = fig.add_subplot(gs[1, :])     # full-width bottom
```

---

## Scientific Notation on Y-Axis

```python
ax.ticklabel_format(axis='y', style='sci', scilimits=(0, 0))
```

---

## Custom Spine Positioning

```python
# Move bottom spine to y=0 (for negative values)
ax.spines['bottom'].set_position(('data', 0))
ax.xaxis.set_ticks_position('bottom')
ax.spines['left'].set_bounds(0, y_max)
```

---

## Related files

- [SKILL.md](../SKILL.md) — When to use this skill
- [api.md](api.md) — PALETTE and core helper signatures
- [common-patterns.md](common-patterns.md) — Bar, trend, and layout patterns
- [design-theory.md](design-theory.md) — Rationale and color theory
- [tutorials.md](tutorials.md) — Full end-to-end walkthroughs
references/common-patterns.md
# Common Patterns — Nature Figure Making

## Contents

- [Pattern 1: Ultra-wide multi-metric bar panel](#pattern-1-ultra-wide-multi-metric-bar-panel)
- [Pattern 2: Dedicated legend panel](#pattern-2-dedicated-legend-panel)
- [Pattern 3: Categorical bars without x-tick labels](#pattern-3-categorical-bars-without-x-tick-labels)
- [Pattern 4: Dynamic y-axis tightening](#pattern-4-dynamic-y-axis-tightening)
- [Pattern 5: Alpha-graduated ablation bars (same color, varying opacity)](#pattern-5-alpha-graduated-ablation-bars-same-color-varying-opacity)
- [Pattern 6: Hatch encoding for print-safe grayscale](#pattern-6-hatch-encoding-for-print-safe-grayscale)
- [Pattern 7: Semantic or family color mapping](#pattern-7-semantic-or-family-color-mapping)
- [Pattern 8: In-bar text with luminance-aware color](#pattern-8-in-bar-text-with-luminance-aware-color)
- [Pattern 9: Fill-between trend with hatch (print-safe)](#pattern-9-fill-between-trend-with-hatch-print-safe)
- [Pattern 10: Annotate events on trend lines](#pattern-10-annotate-events-on-trend-lines)
- [Pattern 11: Grouped bars across multiple datasets (grouped-within-grouped)](#pattern-11-grouped-bars-across-multiple-datasets-grouped-within-grouped)
- [Pattern 12: Schematic hero panel with supporting quant row](#pattern-12-schematic-hero-panel-with-supporting-quant-row)
- [Pattern 13: Dark image plate with repeated views](#pattern-13-dark-image-plate-with-repeated-views)
- [Pattern 14: Clinical triptych](#pattern-14-clinical-triptych)
- [Pattern 15: Asymmetric hero panel](#pattern-15-asymmetric-hero-panel)
- [Pattern 16: Direct labels inside filled regions](#pattern-16-direct-labels-inside-filled-regions)
- [Related files](#related-files)


Reusable layout and encoding patterns used across publication-grade scripts.

---

## Pattern 1: Ultra-wide multi-metric bar panel

For 3–4 metrics compared across many methods, use a wide canvas so bars and labels don't crowd.

```python
fig = plt.figure(figsize=(45, 12))   # or (28, 6) for fewer metrics
gs = gridspec.GridSpec(1, n_metrics)

for i, metric in enumerate(metrics):
    ax = fig.add_subplot(gs[i])
    ax.bar(x, values[metric], color=colors, ...)
    ax.set_ylabel(metric, fontsize=54, labelpad=12)
    ax.set_xticks([])

# Last panel: legend only
ax_leg = fig.add_subplot(gs[-1])
ax_leg.legend(handles, labels, fontsize=38, loc='center', frameon=False)
ax_leg.set_axis_off()

fig.tight_layout(pad=2)
```

**Rule**: Width often 3–4× height. Allows left-to-right narrative scanning.

---

## Pattern 2: Dedicated legend panel

When the legend is large, give it its own axis so data panels stay clean.

```python
fig, axes = plt.subplots(1, n_data + 1, figsize=(...))

for i, ax in enumerate(axes[:-1]):
    bars = ax.bar(...)
    if i == 0:
        handles, labels = ax.get_legend_handles_labels()

# Legend-only panel
axes[-1].legend(handles, labels, fontsize=28, loc='center', frameon=False)
axes[-1].set_axis_off()
```

---

## Pattern 3: Categorical bars without x-tick labels

When methods are named in the legend, hide x-ticks entirely.

```python
ax.set_xticks([])        # removes ticks and labels
# Alternatively:
ax.set_xticklabels([])   # keeps tick marks, removes labels
```

---

## Pattern 4: Dynamic y-axis tightening

Never use 0–100 when all values are in 80–95.

```python
margin = (values.max() - values.min()) * 0.1   # 10% padding
ax.set_ylim([values.min() - margin, values.max() + margin])

# Manual ticks at clean round numbers
ax.set_yticks([0.75, 0.80, 0.85, 0.90])
ax.tick_params(axis='y', labelsize=36, length=10, width=2)
```

---

## Pattern 5: Alpha-graduated ablation bars (same color, varying opacity)

```python
import numpy as np

blue_rgb = (0.215686, 0.458824, 0.729412)   # #3775BA as float tuple
n_ablations = len(ablation_configs)
alphas = np.linspace(0.2, 1.0, n_ablations)
colors = [(blue_rgb[0], blue_rgb[1], blue_rgb[2], a) for a in alphas]
# Full method → alpha=1.0, most ablated → alpha=0.2
```

---

## Pattern 6: Hatch encoding for print-safe grayscale

Add hatching so bars remain distinct when printed in black-and-white.

```python
hatches = ['/', '\\\\', '.', 'x', 'o', '+']
for bar_container, hatch in zip(grouped_bars, hatches):
    for patch in bar_container:
        patch.set_hatch(hatch)
        patch.set_edgecolor('black')
        patch.set_linewidth(1.5)
```

---

## Pattern 7: Semantic or family color mapping

Always map colors consistently across all panels in a figure:

```python
method_colors = {
    'ResNet1d18': '#484878',   # baseline_dark
    'ResNet1d34': '#7884B4',   # baseline_mid
    'ECGFounder': '#B4C0E4',   # baseline_soft
    'CSFM-Tiny':  '#E4E4F0',   # ours_tiny
    'CSFM-Base':  '#E4CCD8',   # ours_base
    'CSFM-Large': '#F0C0CC',   # ours_large
}
colors = [method_colors[m] for m in methods]
```

Prefer coherent hue families over alternating saturated blue/green/red just because categories differ.
Green and red should usually be reserved for **directional annotations**, not primary series identity:

```python
ax.scatter(x_gain, y_gain, marker='^', color='#2E9E44', s=90, zorder=6)  # improvement
ax.scatter(x_drop, y_drop, marker='v', color='#E53935', s=90, zorder=6)  # degradation
```

---

## Pattern 8: In-bar text with luminance-aware color

```python
def annotate_bars(ax, bars, colors, fmt='{:.2f}', fontsize=32, offset=-0.10):
    for bar, color in zip(bars, colors):
        c = color.lstrip('#')
        r, g, b = int(c[0:2],16)/255, int(c[2:4],16)/255, int(c[4:6],16)/255
        lum = 0.299*r + 0.587*g + 0.114*b
        textcolor = 'white' if lum < 0.5 else 'black'
        value = bar.get_height()
        ax.text(bar.get_x() + bar.get_width()/2,
                value + offset,
                fmt.format(value),
                ha='center', va='bottom',
                fontsize=fontsize, color=textcolor)
```

---

## Pattern 9: Fill-between trend with hatch (print-safe)

```python
ax.fill_between(x, 0, cumsum_series,
                color=fill_color,
                hatch='\\\\\\',   # triple backslash for dense hatch
                edgecolor='black',
                label=label_name)
# Visually erase the border artifacts:
ax.fill_between(x, 0, cumsum_series,
                facecolor='none',
                edgecolor='white',
                linewidth=2)
```

---

## Pattern 10: Annotate events on trend lines

```python
def mark_events(ax, x_labels, y_cumsum, events_dict, dy_fraction=0.1):
    """Add labeled arrows at event dates on a trend line."""
    x_index = {label: i for i, label in enumerate(x_labels)}
    y_lo, y_hi = ax.get_ylim()
    dy = dy_fraction * (y_hi - y_lo)
    for date, label in events_dict.items():
        if date not in x_index:
            continue
        i = x_index[date]
        stars = label.count('*')
        clean_label = label.replace('*', '')
        y_data = y_cumsum[i]
        ax.annotate(
            clean_label,
            xy=(i, y_data),
            xytext=(i, y_data + (1 + 0.8 * stars) * dy),
            ha='center', va='bottom', fontsize=11,
            arrowprops=dict(arrowstyle='-|>', lw=1.3, color='black',
                            shrinkA=0, shrinkB=0, mutation_scale=15)
        )
```

---

## Pattern 11: Grouped bars across multiple datasets (grouped-within-grouped)

```python
num_methods = len(methods)
xtick_positions = []

for dataset_idx, dataset_name in enumerate(datasets):
    x_start = dataset_idx * (num_methods + 1)   # gap of 1 between groups
    ax.bar(
        np.arange(num_methods) + x_start,
        values[dataset_name],
        color=method_colors,
        label=methods if dataset_idx == 0 else ['_nolegend_'] * num_methods,
    )
    xtick_positions.append(np.mean(np.arange(num_methods)) + x_start)

ax.set_xticks(xtick_positions)
ax.set_xticklabels(datasets)
```

---

## Pattern 12: Schematic hero panel with supporting quant row

Use when one mechanism or fabrication story needs to lead, with 2–4 smaller evidence plots below.

```python
fig = plt.figure(figsize=(7.2, 6.2))
gs = fig.add_gridspec(
    2, 4,
    height_ratios=[2.2, 1.0],
    hspace=0.18, wspace=0.28,
)

ax_top = fig.add_subplot(gs[0, :])    # hero schematic
ax_b = fig.add_subplot(gs[1, 0])
ax_c = fig.add_subplot(gs[1, 1:3])
ax_d = fig.add_subplot(gs[1, 3])

# top panel should carry the main palette and the main visual narrative
```

Rules:

- Allocate `45–60%` of total height to the hero schematic.
- Reuse softened versions of the same colors in the lower plots.
- Keep support plots quieter than the hero panel.

---

## Pattern 13: Dark image plate with repeated views

Use for microscopy, volume rendering, or fluorescence-heavy panels.

```python
fig = plt.figure(figsize=(7.2, 6.5))
gs = fig.add_gridspec(3, 5, hspace=0.08, wspace=0.04)

for r in range(3):
    for c in range(5):
        ax = fig.add_subplot(gs[r, c])
        ax.set_facecolor('black')
        ax.set_xticks([])
        ax.set_yticks([])
        for spine in ax.spines.values():
            spine.set_visible(False)
```

Rules:

- Use black only within the image plate cells.
- Put channel labels, scale bars and small crop guides directly on the plate.
- Keep crop geometry and scale-bar placement consistent across the grid.

---

## Pattern 14: Clinical triptych

Use for outcome-over-time figures that combine trajectories, effect sizes, and summary proportions.

```python
fig = plt.figure(figsize=(7.2, 6.8))
gs = fig.add_gridspec(
    3, 3,
    height_ratios=[1.0, 1.35, 0.8],
    hspace=0.28, wspace=0.32,
)

axes_top = [fig.add_subplot(gs[0, i]) for i in range(3)]
axes_mid = [fig.add_subplot(gs[1, i]) for i in range(3)]
axes_bot = [fig.add_subplot(gs[2, i]) for i in range(3)]

# Put one shared legend strip above axes_top rather than repeating legends.
```

Rules:

- Keep the three columns semantically parallel.
- Use a dashed vertical reference line in the forest-plot row.
- Group shading in the forest-plot row should be pale and subordinate.

---

## Pattern 15: Asymmetric hero panel

Use when one panel is conceptually central and should dominate.

```python
fig = plt.figure(figsize=(7.2, 5.8))
gs = fig.add_gridspec(3, 4, hspace=0.25, wspace=0.28)

ax_a = fig.add_subplot(gs[0, :2])
ax_b = fig.add_subplot(gs[0, 2])
ax_c = fig.add_subplot(gs[1, :2])
ax_d = fig.add_subplot(gs[1, 2])
ax_e = fig.add_subplot(gs[:, 3])      # hero panel spans all rows
ax_f = fig.add_subplot(gs[2, :2])
```

Rule: do not normalize every subplot to the same size if the science does not have equal importance.

---

## Pattern 16: Direct labels inside filled regions

Use when the same categorical structure repeats and a legend would become too large.

```python
for x_text, y_text, text, color in label_specs:
    ax.text(
        x_text, y_text, text,
        color=color,
        ha='center', va='center',
        fontsize=9, fontweight='bold',
    )
```

Rules:

- Keep labels inside stable, visually large regions.
- Use a small white or black stroke if the fill varies strongly underneath.
- Prefer direct labels over a mega-legend for repeated stacked-area or phase diagrams.

---

## Related files

- [SKILL.md](../SKILL.md) — When to use this skill
- [api.md](api.md) — Helper function signatures and PALETTE
- [design-theory.md](design-theory.md) — Rationale behind every pattern above
- [nature-2026-observations.md](nature-2026-observations.md) — Real Nature page archetypes behind these patterns
- [tutorials.md](tutorials.md) — End-to-end walkthroughs
- [chart-types.md](chart-types.md) — Radar, 3D, scatter patterns
- [demos.md](demos.md) — Third-party demo map, copyright-aware reference use, and original reimplementation guidance
references/demos.md
# figures4papers Reference Index

Use this file when a user asks for a `figures4papers` look, cites the older
`scientific-figure-making` skill, or needs a concrete Python/matplotlib reference.

The retained third-party files live under `../assets/figures4papers/`. Before
opening or adapting them, read
`../assets/figures4papers/THIRD_PARTY_NOTICES.md`. These materials are not
automatically covered by the root MIT License, and their inclusion does not grant
additional reuse rights.

## Use boundary

1. Study layout, palette, axes, legends, and export structure as reference patterns.
2. Reimplement with original code and the user's own data whenever the upstream
   license or written permission does not clearly authorize copying or modification.
3. Never reuse manuscript-specific labels, metric values, statistical results, or
   visual assets as placeholders for real evidence.
4. Record the external reference and implementation provenance in the internal QA
   record when it materially influenced the result.
5. Preserve the editable SVG/PDF/TIFF and source-data rules from `api.md` and
   `qa-contract.md`.

## Retained project map

| Project | Open when | Local references |
|---------|-----------|------------------|
| `figure_ImmunoStruct` | Method-comparison and ablation bars | `../assets/figures4papers/figure_ImmunoStruct/` |
| `figure_CellSpliceNet` | Compact benchmark and cross-species bars | `../assets/figures4papers/figure_CellSpliceNet/` |
| `figure_brainteaser` | Category, rewriting, and self-correction panels | `../assets/figures4papers/figure_brainteaser/` |
| `figure_VIGIL` | Radar, trend, ablation, and probability/manifold panels | `../assets/figures4papers/figure_VIGIL/` |
| `figure_ophthal_review` | Time trends and composition heatmaps | `../assets/figures4papers/figure_ophthal_review/` |
| `figure_RNAGenScape` | Heatmaps, optimization, manifold, and sweep plots | `../assets/figures4papers/figure_RNAGenScape/` |
| `figure_Dispersion` | Conceptual sphere and observation panels | `../assets/figures4papers/figure_Dispersion/` |
| `figure_Cflows` | Diffusion, trajectory, comparison, and ablation panels | `../assets/figures4papers/figure_Cflows/` |
| `figure_FPGM` | Frequency-prior or distribution-style motivation | `../assets/figures4papers/figure_FPGM/` |

## Prefer repository-owned implementation paths

When copying is not clearly authorized, route the pattern through the repository's
own material:

| Requested pattern | Open and use |
|-------------------|--------------|
| Grouped bars, ablation bars, shared legends | `tutorials.md`, `common-patterns.md` |
| Radar or polar comparison | `chart-types.md`, then implement with original code |
| Trends, sweeps, and reference baselines | `tutorials.md`, `chart-types.md` |
| Heatmap or annotated matrix | `tutorials.md`, `template-catalog.md` |
| Probability or manifold concept panel | `chart-types.md` |
| Submission typography, palette, and export | `api.md`, `design-theory.md` |
| CSV-driven reproducible plots | `template-catalog.md`, `scripts/plot_templates.py` |

## Upstream source

<https://github.com/ChenLiu-1996/figures4papers>

Check the current upstream license and obtain permission when required before
redistributing, modifying, or publishing derived materials.
references/design-theory.md
# Nature Figure Design Theory

## Contents

- [1) Typography](#1-typography)
- [2) Axes & Spines](#2-axes-spines)
- [3) Color Palette](#3-color-palette)
- [4) Layout and Composition](#4-layout-and-composition)
- [5) Bar Chart Rules](#5-bar-chart-rules)
- [6) Line / Trend Plots](#6-line-trend-plots)
- [7) Heatmap Rules](#7-heatmap-rules)
- [8) Radar / Polar Charts](#8-radar-polar-charts)
- [9) Export Policy](#9-export-policy)
- [10) Multi-Panel Information Architecture](#10-multi-panel-information-architecture)
- [11) Reproduction Checklist](#11-reproduction-checklist)


This file is an original synthesis of publication-figure typography, color,
composition, and export rules. External repositories may be consulted only as
visual references after their license and reuse terms have been verified; see
[demos.md](demos.md).

---

## 1) Typography

### Font stack (priority order)
- **Nature standard**: `font.family = 'sans-serif'`, `font.sans-serif = ['Arial']`
- **Fallback stack**: `['Arial', 'Helvetica', 'DejaVu Sans', 'sans-serif']`
- **Helvetica** (equivalent) also appears in many scripts as `font.family = 'helvetica'`
- SVG/PDF editable text: always set `svg.fonttype = 'none'`
- LaTeX math labels: `text.usetex = True` only when LaTeX is installed

### Font size hierarchy
| Context | font.size | axes.linewidth |
|---------|-----------|---------------|
| Journal-final dense multi-panel figure at publication width | 7–9 | 0.8–1.2 |
| Large comparison bar panels (figsize > 28in wide) | 24 | 3 |
| Compact subfigures / analytic plots | 15–16 | 2 |
| Axis labels on large panels | 32–54 (override per-label) | — |
| In-bar annotations | 32–36 | — |
| Legend text on large panels | 28–38 | — |
| Tick labels | 20–36 | — |

When targeting the final dimensions of a two-column `Nature` figure page, start smaller than
slide-sized preview figures. The sampled 2026 papers routinely landed in the `7–9 pt` final-text
regime for dense composites.

The submission floor applies to rendered glyphs, not only source-level parent sizes. Mathtext often scales scripts to about `0.7×`; a 7 pt `$R^2$` can therefore contain a 4.9 pt superscript. Prefer a supported Unicode glyph such as `R²` when appropriate, or raise the parent size and verify the exported PDF with `scripts/audit_pdf_text.py`. Also compare each long label's rendered bounding-box width with its allocated slot width in millimetres.

---

## 2) Axes & Spines

```python
plt.rcParams['axes.spines.right'] = False   # always off
plt.rcParams['axes.spines.top'] = False     # always off
plt.rcParams['legend.frameon'] = False      # frameless legends everywhere
```

- Keep only left + bottom spines — minimalist, Nature-approved.
- No grid lines by default; use sparse y-ticks to guide the eye.

---

## 3) Color Palette

Semantic: blue = proposed method, green = positive variants, red/pink = baselines, neutral = reference/background.
For dense multi-panel figures, however, **family consistency beats maximal hue separation**.

```python
PALETTE = {
    # Proposed / key method
    "blue_main":      "#0F4D92",   # deep blue — hero method
    "blue_secondary": "#3775BA",   # medium blue — second author method

    # Positive / improvement shades (light → dark)
    "green_1": "#DDF3DE",
    "green_2": "#AADCA9",
    "green_3": "#8BCF8B",

    # Baseline / contrast shades (light → dark)
    "red_1":      "#F6CFCB",
    "red_2":      "#E9A6A1",
    "red_strong": "#B64342",

    # Neutral support
    "neutral_light": "#CFCECE",
    "neutral_mid":   "#767676",
    "neutral_dark":  "#4D4D4D",
    "neutral_black": "#272727",

    # Accent / callout (use sparingly)
    "gold":   "#FFD700",
    "teal":   "#42949E",
    "violet": "#9A4D8E",
    "magenta":"#EA84DD",
}

DEFAULT_COLOR_ORDER = [
    "#0F4D92",   # blue_main
    "#8BCF8B",   # green_3
    "#B64342",   # red_strong
    "#42949E",   # teal
    "#9A4D8E",   # violet
    "#CFCECE",   # neutral_light
]
```

### Unified-family rule (recommended for NMI-style pages)

Publication figures should read like **one figure**, not six unrelated plots. Prefer one cool family for
baselines and one lilac/rose family for the proposed method line.

```python
PALETTE_NMI_PASTEL = {
    "baseline_dark": "#484878",
    "baseline_mid":  "#7884B4",
    "baseline_soft": "#B4C0E4",
    "ours_tiny":  "#E4E4F0",
    "ours_base":  "#E4CCD8",
    "ours_large": "#F0C0CC",
    "delta_up":   "#2E9E44",
    "delta_down": "#E53935",
}

DEFAULT_COLOR_ORDER_NMI_PASTEL = [
    "#484878",   # baseline_dark
    "#7884B4",   # baseline_mid
    "#B4C0E4",   # baseline_soft
    "#E4E4F0",   # ours_tiny
    "#E4CCD8",   # ours_base
    "#F0C0CC",   # ours_large
]
```

Rules:
1. Keep related baselines in one cool family.
2. Keep `Tiny / Base / Large` or sibling variants in one hero family.
3. Reserve green/red for arrows, gains, drops, thresholds, or signed biological direction.
4. Never remap the same method to a different hue family in another panel.
5. If in doubt, reduce saturation before adding more categories.
6. Validate pairwise separation and white-background contrast, then inspect rendered salience. A neutral baseline must not dominate the hero method merely because it is darker.
7. Sequential light-to-dark scales encode order or magnitude; do not treat their steps as unrelated categories.

### Modality-specific palette discipline from sampled 2026 Nature figures

- **Imaging plates**: grayscale context + 1–2 fluorescent accent channels on black.
- **Schematic/material pages**: derive the palette from the physical objects in the schematic,
  then reuse softened versions of those colors in the support plots.
- **Clinical composites**: dark baseline/reference series, restrained warm/cool follow-up hues,
  pale background bands in forest plots.
- **Genomics / systems pages**: neutral grey scaffolds plus a small number of biologically
  meaningful highlight families, often one red and one blue.

### Ablation alpha encoding
When ablating components of one method, use a **single color with varying alpha**:
```python
color = (0.215686, 0.458824, 0.729412)   # blue_secondary as RGB tuple
alphas = np.linspace(0.2, 1.0, n_variants)
colors = [(color[0], color[1], color[2], a) for a in alphas]
# alpha=1.0 → full method, alpha=0.2 → minimal/ablated variant
```

---

## 4) Layout and Composition

### Figure sizes
| Figure type | Typical figsize |
|-------------|----------------|
| Journal-width composite page / asymmetric multi-panel | (7.0–7.4, 5.5–7.8) |
| Multi-metric bar (3–4 metrics + legend) | (28–45, 6–12) |
| Compact single bar | (9–16, 5–8) |
| Trend / line multi-panel | (14, 4) or (9, 8) |
| Heatmap single | (8–20, 5–9) |
| Radar polar | (12, 10) |
| 3D / illustration multi-panel | (24, 8) |

**Rule**: Width ≈ 3–4× height for comparison bars; prevents vertical crowding and allows left-to-right narrative reading.

### Dedicated legend panel
For multi-axis figures, the **last subplot is legend-only**:
```python
ax_legend = fig.add_subplot(1, n+1, n+1)
ax_legend.legend(handles, labels, fontsize=..., loc='center', frameon=False)
ax_legend.set_axis_off()
```

### Dynamic y-axis scaling
Never use fixed 0–100 when values sit in a narrow band.
Tighten limits to data range: e.g., `ax.set_ylim([data.min() - margin, data.max() + margin])`.

### Nature page archetypes from sampled 2026 papers

`Nature` figures were not uniformly dashboard-like. They repeatedly used a few strong page
archetypes:

| Archetype | Layout signal | Practical rule |
|-----------|---------------|----------------|
| Schematic-led composite | One wide story panel with smaller quant panels below | Give the schematic the visual hierarchy; supporting plots should validate, not compete |
| Dark image plate | Repeated black tiles with fluorescent channels | Use black only inside the image plate region; keep scale bars, gutters, and channel labels high-contrast |
| Clinical triptych | Top longitudinal row, middle forest row, bottom summary row | Reuse the same column logic across outcomes and put the shared legend above the row |
| Asymmetric hero layout | One dominant circular/schematic panel plus small support plots | Let one panel span multiple grid cells; equal panel sizes are not required |

### Panel labels and gutters

- Use small bold lowercase panel letters near the top-left edge.
- Keep gutters tight but real; increase spacing when dark and light modalities touch.
- Leave extra bottom clearance when a dense caption will sit immediately below the figure.
- Avoid decorative panel boxes. Alignment and whitespace should carry the structure.

### Legend economy and direct labelling

- Use direct labels when regions, channels, or line identities are spatially stable.
- Prefer one shared legend strip above a row rather than repeating legends inside several axes.
- Dense categorical area plots often read better with embedded text than with a detached legend.
- If a legend exists, it should usually be frameless and visually quieter than the data.
- Measure legend/panel and legend-row spacing from their rendered tight bounding boxes. Do not infer spacing from a full raster row when the objects occupy different horizontal positions.
- Check long model names at final size against the width allocated to each group. Widen the layout or reduce text size while keeping every PDF glyph at least 5 pt.

### X-tick suppression
When bars represent methods and the legend already names them:
```python
ax.set_xticks([])   # hide x-tick labels; use legend + panel title instead
```

---

## 5) Bar Chart Rules

### Vertical bars (comparison)
```python
bars = ax.bar(
    x_positions,
    values,
    yerr=std_values,
    capsize=5,
    color=colors,
    label=method_names,
    edgecolor='black',      # sharp separation
    linewidth=1.5,
)
```

### Horizontal bars (ablation)
```python
ax.barh(
    y_positions,
    values,
    xerr=std_values,
    color=[(r, g, b, alpha) for alpha in alphas],
    ecolor='k',
    capsize=5,
)
```

### In-bar value annotation
Print exact numbers inside or above bars at 32–36pt for readability without a grid:
```python
for bar, value in zip(bars, values):
    luminance = compute_luminance(bar_color)
    textcolor = 'white' if luminance < 128 else 'black'
    ax.text(bar.get_x() + bar.get_width()/2,
            bar.get_height() - 0.10,
            f'{value:.2f}',
            ha='center', va='bottom',
            fontsize=32, color=textcolor)
```

### Hatch encoding for print-safe grayscale
```python
hatches = ['/', '\\', '.', 'x', 'o']
for bar, hatch in zip(bars, hatches):
    bar.set_hatch(hatch)
```

### Error bar styling
```python
error_kw = {
    'elinewidth': 2,
    'capthick': 2,
    'capsize': 15,
}
```

---

## 6) Line / Trend Plots

- Line width: 2–3pt with controlled alpha.
- Marker size: 8–12pt circles.
- For clinical or longitudinal triptychs, place one shared legend above the row rather than repeating it per axis.
- Fading alpha for temporal progression:
  ```python
  from matplotlib.collections import LineCollection
  alphas = np.linspace(0.3, 0.9, n_segments)
  # build LineCollection with per-segment alpha
  ```
- `fill_between` for uncertainty bands (keep alpha low: 0.1–0.2).
- If comparable panels summarize the same seed/fold/split process, show the same spread definition in all of them. After adding uncertainty, remove arrows or brackets that duplicate the same visual gap and collide with the interval geometry.
- Reference baseline as dashed horizontal line: `ax.axhline(y=..., linestyle='--', alpha=0.3, linewidth=4)`.
- No grid; sparse y-ticks guide the eye.

---

## 7) Heatmap Rules

```python
import matplotlib as mpl

# Diverging (positive/negative): use Red + Blue colormaps per column direction
cmap_pos = plt.cm.Reds
cmap_neg = plt.cm.Blues_r

# Masked NaN cells show as white
cmap.set_bad(color='white')

# Normalize per column
norm = mpl.colors.Normalize(vmin=col_min, vmax=col_max)

# Remove frame
ax.set_frame_on(False)

# Remove tick marks, keep labels
ax.tick_params(axis='x', which='both', bottom=False, top=False, length=0)
```

Cell text contrast:
```python
r, g, b, _ = cmap(norm(value))
luminance = 0.299*r + 0.587*g + 0.114*b
text_color = 'white' if luminance < 0.5 else 'black'
```

---

## 8) Radar / Polar Charts

- Project: `fig.add_subplot(projection='polar')`.
- Remove default grid and spines; draw custom spokes and contour polygons.
- Normalize per-spoke to display range (e.g., 45–90) using per-benchmark tick lists.
- Use `ax.set_theta_zero_location('N')` to start at top.
- Legend: `bbox_to_anchor=(1.40, 0.05)` outside right edge.

---

## 9) Export Policy

### SVG is the required primary format

SVG preserves editable text (when `svg.fonttype = 'none'`), supports lossless scaling,
and is required for any figure where text labels may need post-hoc alignment in
Illustrator or Inkscape. Always save SVG first.

```python
import os
os.makedirs('./figures/', exist_ok=True)
fig.tight_layout(pad=2)   # default; use pad=1 for compact multi-panel

# ── PRIMARY ── editable vector, text as <text> nodes ─────────────────────────
fig.savefig('./figures/name.svg', bbox_inches='tight')

# ── SECONDARY ── raster for quick preview / submission portals ────────────────
fig.savefig('./figures/name.png', dpi=300, bbox_inches='tight')

plt.close(fig)   # always close to free memory
```

**DPI guide (PNG only)**:
- `dpi=300` — standard for all figure types.
- `dpi=600` — dense bar panels with many methods.

**Never** use `svg.fonttype = 'path'` (matplotlib default): it converts glyphs to bezier
curves, breaking text editability. The mandatory three rcParams lines (see api.md) must
be set before any `savefig` call.

---

## 10) Multi-Panel Information Architecture

### Rule: Every panel must answer a unique scientific question

In a multi-panel figure, each panel should be independently necessary but need
not tell an independent story. Covering one panel must remove a distinct
inferential step that cannot be recovered from the others. For figure-level
claim design, evidence-role selection, placement decisions and manuscript
figure progression, load `multipanel-evidence-architecture.md`.

**Recommended three-level progression**:

| Level | Question answered | Typical encoding |
|-------|------------------|-----------------|
| Overview | "What is the landscape?" | Stacked bar, composition |
| Deviation | "What is distinctive per group?" | Z-score heatmap (diverging cmap) |
| Relationship | "How do variables co-vary?" | Scatter / bubble plot |

### Anti-redundancy checklist

Before finalising:

- [ ] Panel b does **not** re-display the same data as panel a in a different visual form
- [ ] Panel c adds a dimension absent from a and b (e.g., correlation, biological relationship)
- [ ] Each panel has its own axis-label vocabulary (different x/y quantities)

### Common redundancy traps

| Trap | Example | Fix |
|------|---------|-----|
| Absolute + absolute | Stacked bar (%) + heatmap of same % | Replace heatmap with z-score deviation |
| Subset of parent | Tumor-only ranked bar is just one column of the stacked bar | Swap for scatter: tumor % vs. immune % |
| Two rankings | Two ranked bars on related metrics | Replace one with scatter / bubble |
| Different chart, same data slice | Pie + stacked bar | Merge or replace one with a relationship plot |

### Z-score deviation heatmap (complement to a composition bar)

When panel a shows absolute composition, panel b should show **what is atypical** per group:

```python
# heat: DataFrame (cohorts × cell-type categories), values in %
z = (heat - heat.mean(axis=0)) / heat.std(axis=0)
im = ax.imshow(z.values, cmap="RdBu_r", aspect="auto", vmin=-2.5, vmax=2.5)
# colorbar label:
cbar.set_label("Z-score vs pan-cohort mean")
```

Use `RdBu_r` (red = enriched above average, blue = depleted). This diverging view is orthogonal to the absolute-percentage view in panel a.

### Bubble scatter (complement to both)

When a = composition, b = deviation, panel c should reveal **biological co-variation**:

```python
# x: dominant compartment (e.g., tumor %)
# y: functional readout (e.g., immune-cell %)
# size: third variable (e.g., stroma %)
ax.scatter(x, y, s=stroma * scale, c=colors,
           edgecolors="white", linewidth=0.8, alpha=0.9)
# Quadrant reference lines at median x and median y
ax.axvline(np.median(x), lw=1.2, ls="--", color="#767676", alpha=0.6)
ax.axhline(np.median(y), lw=1.2, ls="--", color="#767676", alpha=0.6)
```

Label quadrants ("Immune-hot / low tumor", "Immune-desert / high tumor", …) with small grey text.

---

## 11) Reproduction Checklist

To match Nature publication standards:

- [ ] **MANDATORY first lines**: `font.family='sans-serif'`, `font.sans-serif=['Arial','DejaVu Sans','Liberation Sans']`, `svg.fonttype='none'`
- [ ] **Save as SVG** (primary). PNG dpi=300 as optional raster preview.
- [ ] Top and right spines off; frameless legend
- [ ] Figure architecture chosen intentionally: grid, schematic-led composite, image plate, or asymmetric hero layout
- [ ] Journal-final source fonts usually 7–9 pt, and every exported PDF glyph including scripts is ≥ 5 pt
- [ ] Colors from blue-green-red-neutral semantic palette
- [ ] Rendered salience hierarchy matches the evidence hierarchy; neutral baselines do not dominate hero evidence
- [ ] Black background used only for imaging plates, not for ordinary plots
- [ ] Legends omitted or shared when direct labels or one legend strip read better
- [ ] Y-limits tightened to data range (not 0–100 when values are 80–95)
- [ ] X-ticks hidden when methods are named in legend
- [ ] Legend in dedicated panel or `frameon=False`
- [ ] Every comparable stochastic aggregate panel has the intended uncertainty definition
- [ ] Rotated text uses anchor placement; labels clear data and uncertainty without opaque masks
- [ ] `tight_layout(pad=2)` before save
- [ ] `plt.close(fig)` after save
references/figure-contract.md
# Figure Contract

Use this reference before writing plotting code. The goal is to make the figure
serve the paper's scientific logic.

## Contents

- [Privacy rule](#privacy-rule)
- [Required contract](#required-contract)
- [Core conclusion rules](#core-conclusion-rules)
- [Archetype selection](#archetype-selection)
- [Panel logic](#panel-logic)
- [Aesthetic integration](#aesthetic-integration)
- [Reviewer-risk prompts](#reviewer-risk-prompts)

## Privacy rule

Keep the figure contract user-facing, but keep the working trail private. Do not mention
private paths, source filenames, internal reference documents, template identifiers, or
where a private draft came from unless the user explicitly asks for provenance.

## Required contract

Create a short contract in working notes or in the response:

```text
Core conclusion:
Results-level question:
Figure archetype:
Target journal/output:
Backend: Python or R
Final size:
Panel map:
  a:
  b:
  c:
Evidence hierarchy:
  hero evidence:
  validation evidence:
  controls/robustness:
Statistics needed:
Source data needed:
Image-integrity notes:
Reviewer risk:
```

Do not start from a favorite template. Start from the conclusion, then choose the
minimum set of panels that make the conclusion clear and defensible.

Do not start by mapping each input table to a figure. First group tables, metrics,
and experiments by the scientific claims they can support. Several source tables
may belong in one evidence chain, while one table may support multiple distinct
panels. This claim-first pass prevents redundant figures that restate the same result.

## Core conclusion rules

- The core conclusion should be one sentence with a verb: "Treatment X reduces
  Y by restoring Z", not "Treatment results".
- Every panel must answer a unique question. If covering a panel would not weaken
  the argument, remove or merge it.
- Separate primary evidence from supporting evidence. The primary evidence gets
  the hero panel or the clearest axis; controls and robustness panels should be
  visually quieter.
- If the user provides data but no claim, infer a provisional claim from the data
  request and ask for confirmation before final styling.
- If observations are matched by dataset, subject, seed, task, or specimen, decide
  whether the claim concerns the paired change. Marginal distributions can overlap
  because of between-unit heterogeneity even when paired differences are consistent.

## Archetype selection

| Archetype | Use when | Hero panel | Supporting panels |
|---|---|---|---|
| `quantitative grid` | The claim is mainly numerical comparison | Optional; often a dominant summary metric | Shared axes, aligned scales, compact legends |
| `schematic-led composite` | A workflow, mechanism, device, or experimental design must be understood first | Left or top schematic, 35-60% of area | 2-4 quantitative validation panels |
| `image plate + quant` | Microscopy, imaging, histology, spatial overlays, segmentation, or blots lead the evidence | Image plate or representative image | Scale bars, overlays, crops, quantification |
| `asymmetric mixed-modality figure` | The figure combines schematic, raster images, heatmaps, and quantitative plots | One panel spans rows/columns | Smaller panels ranked by evidence value |

## Panel logic

For a labelled multi-panel manuscript figure, load
`multipanel-evidence-architecture.md`. Use `one figure = one major claim` as a
strong planning default. Panels should be different necessary sentences in the
same visual argument, not parallel mini-stories or the same comparison mirrored
across several metrics.

Use this order unless the manuscript story clearly requires another:

1. Establish the system: sample, method, cohort, device, or experimental design.
2. Show the main effect or primary comparison.
3. Show mechanism or localization.
4. Quantify the representative image or qualitative observation.
5. Add robustness, controls, subgroup analysis, or sensitivity analysis.

For Fig. 1 or a method figure, the first panel often defines the visual vocabulary:
colors, symbols, workflow direction, sample classes, and scale. Reuse that vocabulary
through the whole figure and, where possible, through the manuscript.

## Aesthetic integration

- Use one neutral family, one signal family, and one accent family.
- Keep the same condition/method color across all panels.
- Prefer direct labels for stable line identities, channels, and fixed spatial regions.
- Use a shared legend area when repeated legends would waste space.
- Avoid equal-sized panels when the evidence is not equally important.
- Keep schematic colors and quantitative plot colors related. A schematic-led
  figure should look like one integrated argument, not a pasted collage.

## Reviewer-risk prompts

Before finalizing, ask what a skeptical reviewer would challenge:

- Is the sample size visible in the legend or source data?
- Are error bars, intervals, and statistical tests defined?
- Are the same uncertainty definitions present in every comparable aggregate panel?
- Are axes comparable across panels that invite comparison?
- Would a paired-difference view reveal an effect hidden by overlapping marginal distributions?
- Does each legend label preserve canonical capitalization and match the terminology plan?
- Do data labels clear the upper uncertainty extent at final rendered size?
- Are representative images quantified and traceable to raw files?
- Are image adjustments global and documented?
- Could the same conclusion be made from fewer panels?
references/figure-legend-conventions.md
# Figure & Table Legend Conventions (Nat Commun 2025 CS/AI corpus)

Use this file when **writing or auditing the legend text** of a figure or table.
It complements `nature-2026-observations.md`, which covers visual/layout
archetypes; this file covers the *words* of the caption. Distilled from a 2025
set of 20 open-access *Nature Communications* computer-science / AI papers
(legend conventions were consistent across all research articles). **Do not copy
source wording.**

## Legend structure — the fixed skeleton

1. **`Fig. N | ` + a bold noun-phrase overall title** that names the whole
   figure. Common openers: *Overview of …*, *Comparison of …*, *Performance of
   …*, or a finding phrase. No terminal full stop required on the title.
2. **`a / b / c …` panels, each described in present tense, telegraphic style**,
   often subject-less: *"a Comparison of the four EMS paradigms. b Distributions
   of WSIs and patches in the pre-training dataset."*
3. **Statistics written into the legend**: sample size `n=`, error type, and
   test — *"mean ± 95% CI (n = 1373) … one-way ANOVA with Tukey correction."*
4. **Data-availability boilerplate** at the end: *"Source data are provided as a
   Source Data file."*

## Tense

- Visual facts in **present** tense — *"are shown as cyan sticks"*, *"depicts"*.
- Methods/how-it-was-made in **past** tense — *"was performed"*, *"was adopted
  from"*.

## Self-containment rule

A legend must be readable away from the body text. Put colour/shape mappings,
sample size, and key numeric anchors (PDB id, RMSD, units) into the legend
itself — *"tRNA-Glu of E. coli (PDB: 2DER chain C). 76 nt, RMSD: 2.88 Å."*;
*"Grey boxes designate what is defined by the benchmark, and orange boxes
indicate what is unique to each solution."*

## Display-label capitalization

- Treat in-figure legend entries as display labels. Start ordinary descriptive labels with an uppercase letter, for example `Tuned XGBoost`, `Tuned RF`, `+ Semantic guidance`, and `+ Causal guidance`.
- Preserve canonical product/model spelling exactly, including internal capitals, hyphens, periods, and abbreviations such as `XGBoost`, `DeepSeek`, `GPT-5.2`, and `RF`.
- In prose legend sentences, use normal sentence grammar rather than forcing every term into display case.
- Do not apply `.title()` or equivalent automatic title-casing because it corrupts canonical names.

## Advanced: the claim-closing sentence

A legend's final sentence may advance an argument rather than only describe —
*"…indicating that these co-folding models are not predicting poses based on
physics but rather learning patterns in global structures."* Use sparingly, and
only when the panel actually supports the inference.

## Review/Perspective legends

When a figure aggregates others' published systems, each sub-panel gets a
one-line characterisation (often past tense, describing prior work) and the
legend carries an attribution line — *"adapted with permission from refs. 16,17
… by Springer Nature."* Include the permission/attribution string for any
adapted panel.

## Table captions

Same shape: **`Table N | ` + noun phrase**, with detailed specs pointed to
Methods — *"Table 1 | … Detailed specifications are provided in the Methods
section."* Benchmark/framework papers lean on tables (multi-metric results) more
than figures.

## Length and journal gate

- This file's corpus evidence is from Nature Communications and does not set a
  universal Nature Portfolio word limit.
- For the flagship journal Nature, load `nature-article-requirements.md` and
  keep each complete figure legend below 250 words.
- For Nature Machine Intelligence, load the shared NMI contract. Its current
  live pages give no standalone per-legend number, while its official 2018
  brief guide said to keep each figure legend below 300 English words. Count
  the complete title-plus-panels legend, not each panel; aim for 150–250 words
  and use below 300 as a historical advisory ceiling unless the live submission
  system or editor gives a newer instruction.
- For Nature Communications or another subjournal, verify the current journal
  and article-type instructions before enforcing a numerical cap.
- Keep the `Fig. N |` title short and nominal; no numbers/results in the figure
  *title* line (numbers live in the panels and stats).

## 中文图注要点

- 结构铁律:`图 N | 加粗名词短语总题` → `a/b/c` 现在时电报式分面 → 统计(n、误差、检验)写进图注 → "Source data are provided as a Source Data file." 套语。
- 时态:视觉事实用现在时,制作方法用过去时。
- 自足:颜色/形状映射、样本量、关键数值(PDB/RMSD/单位)都写进图注,使其脱离正文可读。
- 图内图例按展示标签处理,普通描述首字母大写,同时保留 `XGBoost`、`DeepSeek`、`GPT-5.2`、`RF` 等规范拼写;正文仍按句法大小写,不要盲目自动 title case。
- 进阶:图注末句可给一句推断结论,但须确有面板支撑。
- 综述图注:聚合他人系统时逐子图一句话定性,并标注"adapted with permission from refs… by Springer Nature"授权。
references/multipanel-evidence-architecture.md
# Multi-panel evidence architecture

Use this reference when planning, restructuring, or auditing a manuscript
figure with several labelled panels, or when mapping a Results evidence chain
onto a sequence of figures. Read it together with the
[Nature Results–Discussion corpus guidance](../../nature-shared/core/nature-results-discussion.md)
when figure order must mirror the manuscript's claim escalation.

## Contents

- [Status and scope](#status-and-scope)
- [Start from the figure-level claim](#start-from-the-figure-level-claim)
- [Give panels different inferential roles](#give-panels-different-inferential-roles)
- [Choose an evidence-chain archetype](#choose-an-evidence-chain-archetype)
- [Corpus pattern anchors](#corpus-pattern-anchors)
- [Align figure order with Results claim escalation](#align-figure-order-with-results-claim-escalation)
- [Assign visual hierarchy from evidence hierarchy](#assign-visual-hierarchy-from-evidence-hierarchy)
- [Decide whether a panel belongs in the main figure](#decide-whether-a-panel-belongs-in-the-main-figure)
- [Run the multi-panel audit](#run-the-multi-panel-audit)

## Status and scope

This is **corpus-derived Nature-style guidance, not an official journal
requirement**. It was distilled from author-supplied readings of flagship
*Nature* papers on MIRA (`s41586-026-10675-5`), centromere architecture
(`s41586-026-10841-9`), Robin (`s41586-026-10652-y`) and TabPFN
(`s41586-024-08328-6`), then generalized for Nature Portfolio manuscript
figures. Current journal instructions, article-type rules, the actual evidence
and the scientific question always take precedence.

Do not copy a published paper's panel count or letter order as a template. The
stable pattern is inferential: a figure usually answers one Results-level
scientific question, while its panels perform different jobs in the evidence
chain needed to answer it.

## Start from the figure-level claim

Before choosing plots, write:

```text
Results-level question:
Figure-level claim:
What could overturn that claim?
Decisive evidence:
Panel sequence:
  a — question | evidence role | necessary comparison
  b — question | evidence role | necessary comparison
  c — question | evidence role | necessary comparison
Destination of displaced material: main figure | another figure | Extended Data/SI
```

Treat `one figure = one major claim` as a strong planning default, not a rigid
counting rule. A figure may contain several subordinate findings when all are
necessary to establish, qualify or bound the same higher-level claim. If a
panel establishes a separate major claim, give it another figure or Results
unit. If a panel can disappear without weakening or changing the figure-level
inference, merge it, move it to Extended Data/SI, or delete it.

Panel letters mark reading order; they do not assign universal functions.
Panel `a` need not always be a schematic, and panel `d` need not always be a
stress test. Choose roles from the scientific logic.

## Give panels different inferential roles

Select the smallest sufficient set. A figure does not need every role.

| Evidence role | Question it answers |
|---|---|
| Setup or schematic | What system, intervention, workflow or contrast is being tested? |
| Representative example | What does the phenomenon look like in a concrete case? |
| Primary quantitative evidence | Does the central effect or capability exist? |
| Baseline or control | Does it exceed a credible alternative or survive the relevant control? |
| Decomposition | Which components, strata or variables account for the effect? |
| Stratification | Does the conclusion hold across scientifically meaningful conditions? |
| Orthogonal validation | Is the conclusion recovered with another assay, measure or evidence type? |
| Perturbation or stress test | Does the claim survive an attack, or fail as predicted when a necessary relation is disrupted? |
| Boundary or failure case | Where does the effect weaken, reverse or stop generalizing? |
| Mechanistic evidence | What bounded explanation is supported for why the effect occurs? |

Panels are independently **necessary**, not independent stories. Covering one
panel should remove a distinct inferential step that the other panels cannot
recover. Prefer role diversity over metric diversity.

Weak mirror layout:

`a: R2 -> b: R2 pairwise tests -> c: MAPE -> d: MAPE pairwise tests`

This contains four panels but often only two inferential roles: overall
performance and pairwise evidence. A secondary metric can be useful, but it
does not earn a main panel merely by being another metric.

Stronger claim-driven layout:

`a: perturbation design -> b: decisive comparison -> c: full distribution or residual -> d: decomposition or boundary`

This progresses from what was changed, to what happened, to which alternative
was rejected, to what explains or limits the remaining effect.

## Choose an evidence-chain archetype

### Validation envelope

Use when the figure asks whether one system, simulator, intervention or model
capability is credible:

`define the capability -> establish it -> test distinct failure modes -> attack the claim -> bound it`

Consistency, leakage, safety, bias and adversarial robustness can belong in one
figure when they are different ways the same credibility claim could fail. Do
not present them as unrelated metric panels.

### Scale-to-instance chain

Use when a population-level pattern needs biological, clinical or physical
interpretation:

`global map or distribution -> quantity -> spatial relation or scale -> internal structure -> representative exception or instance`

The representative example should explain, localize or challenge the aggregate
pattern; it should not be a decorative anecdote.

### Discovery sequence

Use when each result changes the next scientific question:

`hypothesis -> decisive experiment -> analysis -> refined hypothesis -> next test -> mechanism validation`

At manuscript level, the figure sequence can carry this discovery loop. Keep
only steps that alter the claim or motivate the next test; do not reproduce a
chronological laboratory notebook.

### Capability ladder

Use when a method paper makes progressively stronger claims:

`what it is -> what behaviour it learned -> whether it outperforms alternatives -> whether the gain survives harder conditions -> what broader ability follows`

Within a benchmark figure, combine complementary support such as aggregate
performance, per-dataset generality and efficiency. In the following figure,
move to a stronger question such as robustness, confounding or broader
capability instead of redrawing the same ranking.

Across these archetypes, a reusable Figure-level chain is:

`establish -> compare or control -> stress-test or discriminate -> broaden -> bound`

Use only the moves the claim needs.

## Corpus pattern anchors

Use these author-supplied readings as reasoning examples, not layouts to copy:

- **MIRA — validation envelope.** One figure asks whether the patient agent is
  a credible evaluation environment, with panels probing consistency,
  information leakage and adversarial robustness as different failure modes.
  Later figures ask separate higher-level questions about intervention
  capability, medication, admission decisions and bias robustness. The metrics
  are grouped by the claim they can overturn, not by data type.
- **Centromere architecture — scale to instance.** A resource/map figure first
  defines the study object. A later variation figure moves through count,
  spatial relation, scale and internal structure before showing informative
  examples. The panels collectively explain which dimensions constitute the
  variation rather than announcing five unrelated discoveries.
- **Robin — discovery sequence.** The figure progression follows hypothesis
  generation, wet-lab testing, analysis and refined mechanism or hypothesis.
  The sequence preserves the scientific discovery loop because each result
  changes what is tested next.
- **TabPFN — capability ladder.** Early figures explain the system and learned
  behaviour; a benchmark figure combines aggregate performance, per-dataset
  generality and tuning-time efficiency; a later figure asks whether that
  performance claim survives uninformative features, outliers, reduced data
  and stronger competitors. The later figure escalates the claim instead of
  repeating that performance is good.

These examples support three portable distinctions:

1. several attacks on one claim can belong in one figure
2. population summaries and representative cases can be complementary steps
   in one figure
3. a later figure should normally ask a deeper question than the preceding
   figure

## Align figure order with Results claim escalation

The relationship works at two scales:

- **Within a figure:** panels are different sentences in one compact Results
  argument.
- **Across figures:** each figure should make a stronger or different claim
  that creates the next scientific question.

For example:

`Fig. 1: What is it? -> Fig. 2: Is the evaluation or resource credible? -> Fig. 3: Does the phenomenon exist? -> Fig. 4: Does it beat alternatives? -> Fig. 5: Does the claim survive harder conditions? -> Fig. 6: What mechanism, generality or boundary follows?`

This is an archetype, not a mandatory six-figure template. Apply the
claim-escalation audit from the shared Results–Discussion guidance. If two
figures can both be summarized as `X performs well`, merge them, demote the
weaker evidence, or revise the later figure around a deeper discriminator.

## Assign visual hierarchy from evidence hierarchy

- Give the decisive evidence the hero position or largest visual area.
- Keep controls and robustness panels quieter, but large enough to judge.
- Use one condition, cohort or method mapping consistently across panels.
- Do not force equal panel sizes when inferential importance differs.
- Use a setup schematic only when readers need it to interpret the evidence;
  do not add a decorative panel `a` by habit.
- Make the attack, negative result or failure boundary visible when it changes
  the figure-level claim.
- Keep axes, uncertainty definitions, sample definitions and comparator labels
  consistent whenever panels invite direct comparison. Disclose any change in
  `n`, denominator, cohort, estimator or scale.

## Decide whether a panel belongs in the main figure

Use the necessity test:

1. What unique inference disappears if this panel is removed?
2. Does that inference establish, advance, qualify or bound the figure-level
   claim?
3. Is it a distinct evidence role, or the same result under another metric,
   estimator, seed, threshold or visual encoding?

Then route it:

- **Main figure:** decisive evidence, a necessary control, a central
  falsification, or a conclusion-changing boundary.
- **Extended Data/SI:** reassurance, provenance detail, secondary metrics,
  alternative estimators, expanded subgroup views or robustness that does not
  change the central interpretation.
- **Another figure:** a genuinely separate major claim or the next rung of the
  manuscript's claim escalation.
- **Delete or merge:** a repeated view with no independent inference gain.

Do not hide a result that changes the conclusion merely because it is negative
or complicates the layout.

## Run the multi-panel audit

Complete this table before drawing and again after the final render:

| Panel | Scientific question | Evidence role | Decisive comparison | Unique inference gain | Claim dependency | Destination |
|---|---|---|---|---|---|---|
| a |  |  |  |  |  |  |
| b |  |  |  |  |  |  |
| c |  |  |  |  |  |  |

Audit the full figure:

- Can the figure-level claim be stated in one sentence with a verb?
- Do the panels collectively answer one Results-level question?
- Does every panel add a distinct inferential role rather than only a new
  metric or chart type?
- Does the panel order form a readable chain rather than a dashboard grid?
- Is the strongest evidence visually dominant?
- Are central controls, attacks and failure boundaries present?
- Would a moved panel fit Extended Data/SI without weakening the main claim?
- Does the next figure ask the question created by this figure?
- Can the caption explain what is shown and how to read it without replaying
  the full Results argument?
- At final physical size, are dependencies, shared encodings, uncertainty and
  panel transitions still legible?

The final design rule is: **do not begin with the number of datasets, metrics
or panels available. Begin with the one sentence the figure must establish,
then choose the few different evidence roles a skeptical reader needs to
believe it.**
references/nature-2026-observations.md
# 2026 Nature Sample Observations

## Contents

- [Archetype 1: Schematic-led composite](#archetype-1-schematic-led-composite)
- [Archetype 2: Dark image plate](#archetype-2-dark-image-plate)
- [Archetype 3: Clinical triptych](#archetype-3-clinical-triptych)
- [Archetype 4: Dense categorical physical-science panel](#archetype-4-dense-categorical-physical-science-panel)
- [Archetype 5: Asymmetric mixed-modality figure](#archetype-5-asymmetric-mixed-modality-figure)
- [Cross-cutting Nature rules from the sample](#cross-cutting-nature-rules-from-the-sample)
- [Palette guidance by modality](#palette-guidance-by-modality)
- [What not to copy blindly](#what-not-to-copy-blindly)


This note captures page-level figure patterns observed from a local 2026 sample of `Nature`
papers, plus one `Nature Biomedical Engineering` paper used as a clinical / ML-adjacent
cross-check.

Sampled figure sources:

- `s41586-026-10408-8` — wide schematic-led materials figure with supporting quant panels
- `s41586-026-10426-6` — dark whole-brain image plate with repeated views
- `s41586-026-10393-y` — clinical triptych: longitudinal lines, forest plots, summary bars
- `s41586-026-10257-5` — dense categorical stacked-area panels with direct labels
- `s41586-026-10439-1` — asymmetric genomics figure with one dominant circular panel
- `Expert-level detection of pathologies...` — compact medical / ML figure conventions

## Archetype 1: Schematic-led composite

Seen in the printable meta-assemblies paper.

Actionable rules:

- Let the schematic occupy roughly `45–60%` of figure height.
- Use the **same physical/material palette** in the supporting plots; do not switch to generic method colors below the schematic.
- Zoom callouts should use one repeated accent style across the figure, for example a single dashed red outline family.
- Reserve at least one supporting panel for a real-world photograph or experimental snapshot when the story needs scale validation.
- Supporting quantitative panels should be smaller, cleaner and less saturated than the schematic so the eye reads the page in the intended order.

## Archetype 2: Dark image plate

Seen in the astrocyte brain-network figure.

Actionable rules:

- Use a black facecolor only for the image plate region, not for the whole page.
- Pair grayscale context with one or two fluorescent channels; the sample repeatedly used cyan and magenta.
- Keep crops, scale bars and view boxes geometrically consistent across rows and columns.
- Use white gutters and white scale bars so the plate stays legible after print/export compression.
- Put row labels and channel labels directly on the image plate; avoid detached legends.

Recommended accent set for this modality:

```python
CYAN = "#22D7E6"
MAGENTA = "#FF2AD4"
GREY_CONTEXT = "#B8B8B8"
```

## Archetype 3: Clinical triptych

Seen in the OTOF gene-therapy paper.

Actionable rules:

- Top row: line plots or longitudinal summaries, usually sharing one legend strip above the row.
- Middle row: forest-plot style effects with a dashed vertical reference line and light category bands.
- Bottom row: compact summary bars, often binary or stacked-percentage bars.
- Keep columns semantically parallel. If the first column is `ABR`, the next columns should reuse the same row logic rather than introducing a new layout.
- Baseline / reference series can be black or dark grey; follow-up or intervention groups can use a restrained warm/cool sequence.

Recommended design signal:

- Legends belong outside the data region when there are many timepoints.
- Group bands in forest plots should be pale and subordinate, never more salient than the confidence intervals.

## Archetype 4: Dense categorical physical-science panel

Seen in the condensation-sequence figure.

Actionable rules:

- Direct-label regions when the plot has many semantically intrinsic categories.
- Use hatching or texture overlays when neighboring fills are close in luminance or may print poorly.
- Reuse the exact same axis limits and panel geometry across the full grid.
- Prefer embedded labels over a detached mega-legend when each panel repeats the same categorical structure.

## Archetype 5: Asymmetric mixed-modality figure

Seen in the rediploidization genomics figure.

Actionable rules:

- Do not force equal panel sizes. Let the biologically central panel dominate.
- Use small supporting plots around the hero panel to answer narrower questions.
- Keep a tight, reused color mapping across all modalities, for example `wave 1 / wave 2 / wave 3` or `baseline / highlight / neutral`.
- Use whitespace and alignment, not decorative frames, to signal grouping.

## Cross-cutting Nature rules from the sample

- Panel labels are small bold lowercase letters near the top-left corner, not large badges.
- Figure pages are narrative, not dashboard-like. A dominant panel is normal.
- Legends are often omitted if direct labeling is possible.
- Background discipline matters more than ornament. White for charts, black only for image plates.
- Saturated colors are used sparingly and usually mean either a true experimental channel or a highlighted subgroup.
- When several modalities coexist, keep axis-heavy plots visually quieter than schematics or imaging panels.
- Gutters are slightly larger when dark panels touch light panels or when modalities change.

## Palette guidance by modality

- Materials / mechanism pages:
  `aqua`, `teal`, `lilac`, `soft violet`, with one red accent for callouts only.
- Imaging plates:
  `black` + `grey context` + `cyan` + `magenta`.
- Clinical quantitative figures:
  `black baseline`, then restrained warm/cool follow-up hues, with pale group shading.
- Genomics / systems figures:
  `neutral greys` plus one `red family` and one `blue family` for highlighted biological states.

## What not to copy blindly

- Do not import a bright multi-hue palette just because one sampled physical-science figure used many fills. That only works when the categories are intrinsic phases/materials and directly labeled.
- Do not place all Nature figures on black backgrounds; that was specific to the imaging plate archetype.
- Do not force a legend into every panel. Many sampled figures read better with direct labels or one shared legend strip.
references/nature-article-requirements.md
# Flagship Nature Article figure requirements

Use this reference for figures submitted to the flagship journal **Nature**.
Keep initial-review files separate from accepted-in-principle production files,
and do not apply these formats automatically to Nature Portfolio subjournals.

## Contents

1. Stage gate
2. Initial-submission figures
3. Legend contract
4. Production dimensions and typography
5. Main-figure production files
6. Extended Data production files
7. Accessibility and image integrity
8. Delivery audit
9. Official sources

## 1. Stage gate

Record one stage before auditing:

- `initial_submission`: figures may be embedded in the Word/PDF manuscript;
  production-quality files are not required
- `revision`: follow both the public guide and the editor's instructions
- `accepted_in_principle`: supply production-quality main figures and Extended
  Data using their different file contracts

Do not fail an initial submission solely because it lacks separate editable
production artwork. Do fail it when the displayed data are unreadable,
misrepresented, incomplete or impossible for referees to assess.

## 2. Initial-submission figures

- Prefer figures embedded with manuscript text in one Word or PDF file.
- Put each figure legend on the same page as its figure.
- Use enough resolution for referees to evaluate the data.
- If embedding is impractical, supply separate files or an accessible
  repository route and confirm every figure is cited.
- Run the statistics, source-data and image-integrity checks even though final
  artwork formatting is deferred.

## 3. Legend contract

For every flagship Nature figure legend:

- keep the complete legend below 250 words
- begin with a brief title sentence for the whole figure
- follow with a concise description of what each panel depicts
- do not use the legend to narrate results or duplicate Methods
- include definitions needed to understand the figure in isolation
- state exact `n`, replicate definition, center/spread, error bars, test,
  correction and P-value display where applicable
- identify adaptations and permissions when third-party material is used

Table legends begin with a short title sentence; explanatory detail may go in
footnotes.

## 4. Production dimensions and typography

For accepted-in-principle main figures:

- use 89 mm for a single-column figure or 183 mm for a double-column figure
- do not exceed 170 mm height, leaving room for the legend
- keep ordinary figure text between 5 pt and 7 pt at final size
- label multi-panel figures with 8 pt bold upright lowercase `a`, `b`, `c`, etc.
- use a consistent sans-serif typeface, preferably Arial or Helvetica
- use Courier or another monospaced font for amino-acid sequences
- keep all text editable, do not outline it, and embed TrueType 2 or 42 fonts
- arrange panels alphabetically where practical and minimize unused space

## 5. Main-figure production files

Main figures require vector artwork with editable layers.

Preferred:

- `.ai`
- `.eps`
- editable `.pdf`

Also accepted by the current research figure guide when properly prepared:

- layered Photoshop artwork
- PowerPoint converted to PDF
- plain `.svg`
- Excel
- `.ps`

Do not submit flattened `.jpeg`, `.tiff` or `.png` as the final main-figure
production file merely because the workflow also creates those formats for QA
or preview. Keep all components embedded rather than externally linked and aim
to keep each file below 50 MB.

## 6. Extended Data production files

Extended Data has a different production contract:

- save in RGB
- use no more than 300 dpi
- keep each file at or below 10 MB
- use `.jpeg` preferably, or `.tiff`/`.eps`
- fit each item on one page with room for its legend or footnotes
- use the journal's required filename pattern based on the corresponding
  author's surname and the ED figure/table number

Do not send a main figure through the Extended Data file route or vice versa.

## 7. Accessibility and image integrity

- include axis lines and tick marks
- label every axis and place units in parentheses
- avoid background gridlines, drop shadows, decorative icons, patterns,
  overlapping labels and coloured text
- use an accessible palette; do not rely on red/green or rainbow scales
- supply artwork in RGB; photographic images need at least 300 dpi, with
  450 dpi preferred for the highest online-proof resolution
- keep scale bars and their labels editable rather than flattened into images
- use scale bars instead of magnification factors

For microscopy, gels, blots or other processed images, load the image-integrity
section of `../../nature-shared/core/research-compliance.md`. Record raw-file
provenance, crop, contrast, gamma, pseudocolour, stitching, lane rearrangement
and processing software.

## 8. Delivery audit

Return:

| Item | Stage | Required contract | Current file/evidence | Status | Action |
|---|---|---|---|---|---|

Before approval:

- run `scripts/validate_figure.py` on plotting source
- run `scripts/audit_pdf_text.py` on exported PDF text
- run `scripts/audit_figure_collisions.py` after every generated or layout-affecting revision and resolve every rendered collision FAIL
- inspect every panel and the assembled figure at final physical size
- verify that preview/export bundles are not mislabeled as the journal's final
  accepted upload formats

## 9. Official sources

Verified 2026-08-08:

- Nature initial submission: <https://www.nature.com/nature/for-authors/initial-submission>
- Preparing figures: <https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/>
- Building and exporting figure panels: <https://research-figure-guide.nature.com/figures/building-and-exporting-figure-panels/>
- Image integrity: <https://www.nature.com/nature-portfolio/editorial-policies/image-integrity>
references/openrouter-image-generation.md
# OpenRouter Image Generation for Manuscript Schematics

## Contents

- [Source](#source)
- [Safety and scientific integrity](#safety-and-scientific-integrity)
- [Prompt contract](#prompt-contract)
- [Script usage](#script-usage)
- [Recommended defaults](#recommended-defaults)
- [Follow-up QA](#follow-up-qa)


Use this reference only when the user explicitly asks to generate a paper schematic, graphical abstract, mechanism diagram, or concept illustration through OpenRouter / GPT Image 2 / an image-generation API.

Read `ai-graphical-abstract-workflow.md` first. Complete its journal-policy gate,
communication brief, scientific-review plan, and provenance plan before making a
real API call. If submission eligibility has not been verified, mark the result
as an internal design draft rather than a submission-ready asset.

Do not use this route for quantitative plots, data panels, heatmaps, microscopy plates, blots, or figure assembly unless the user explicitly wants an AI-generated draft illustration. Keep data-driven figures in the Python or R route.

## Source

OpenRouter's dedicated Images API uses:

- model discovery: `GET https://openrouter.ai/api/v1/images/models`
- generation: `POST https://openrouter.ai/api/v1/images`
- default model for this skill: `openai/gpt-image-2`

Authentication uses `OPENROUTER_API_KEY` as a bearer token.

## Safety and scientific integrity

- Treat generated images as draft visual concepts, not evidence.
- Do not treat a Nature Careers article as permission to submit generative-AI
  artwork. Verify the exact target journal's current official rules and keep
  internal usefulness separate from submission eligibility.
- Do not invent quantitative values, p-values, spectra, microscopy findings, institution logos, author photos, journal marks, or unsupported mechanisms.
- Prefer short labels and simple shapes. AI image models can misspell text; final publication labels should usually be redrawn in Illustrator, Inkscape, PowerPoint, or a Python/R vector workflow.
- If the schematic could be interpreted as a data panel, explicitly mark it as conceptual.
- Do not send confidential manuscript content to OpenRouter without user permission.

## Prompt contract

Before calling the API, collect or infer:

1. article title or central claim
2. key biological/material/computational entities
3. cause-effect mechanism or workflow stages
4. desired layout, such as left-to-right pipeline, circular mechanism, split before/after, or graphical abstract
5. target aspect ratio and output format
6. any labels that must appear, keeping them short
7. things that must be excluded
8. target journal, policy URL, access date, and intended AI-use disclosure

Write a compact prompt with:

- visual role: "Nature-style graphical abstract" or "clean scientific mechanism schematic"
- composition: panel flow, hierarchy, and focal element
- style: flat vector-like, restrained palette, high contrast, white or transparent background
- scientific constraints: no fabricated numbers, no extra organs/cells/materials, no logos
- output constraints: minimal text, editable downstream, journal-safe

## Script usage

Use the bundled script for reproducible calls:

```bash
export OPENROUTER_API_KEY="sk-or-..."
python skills/nature-figure/scripts/generate_openrouter_schematic.py \
  --title "Paper title" \
  --abstract-file abstract.txt \
  --panel-map "left: problem; center: proposed mechanism; right: validated outcome" \
  --outdir outputs/schematic \
  --basename graphical_abstract \
  --aspect-ratio 16:9 \
  --resolution 2K \
  --quality high
```

Dry-run without network or API key:

```bash
python skills/nature-figure/scripts/generate_openrouter_schematic.py \
  --title "Self-healing cementitious sensor" \
  --abstract "A composite sensor couples chloride ingress with recoverable piezoresistive response." \
  --panel-map "left: marine exposure; center: ion transport and microcrack healing; right: signal recovery curve" \
  --dry-run
```

Use a fully custom prompt:

```bash
python skills/nature-figure/scripts/generate_openrouter_schematic.py \
  --prompt-file schematic_prompt.md \
  --raw \
  --outdir outputs/schematic
```

Use one or more reference images:

```bash
python skills/nature-figure/scripts/generate_openrouter_schematic.py \
  --prompt-file schematic_prompt.md \
  --reference-image draft_layout.png \
  --reference-image https://example.com/style-reference.png
```

The script saves generated files plus `request_metadata.json` in the output directory.

Use another OpenAI-compatible images endpoint (`--api-url`, or `SCHEMATIC_IMAGE_API_URL`) when the user already pays for a gateway instead of OpenRouter. The response shape (`data[].b64_json` or `data[].url`) is the same, so nothing else changes; the endpoint actually called is recorded in `request_metadata.json`. Example with OrcaRouter:

```bash
export ORCAROUTER_API_KEY="sk-orca-..."
python skills/nature-figure/scripts/generate_openrouter_schematic.py \
  --api-url https://api.orcarouter.ai/v1/images/generations \
  --api-key-env ORCAROUTER_API_KEY \
  --model openai/gpt-image-2 \
  --title "Paper title" \
  --outdir outputs/schematic
```

## Recommended defaults

- `model`: `openai/gpt-image-2`
- `aspect_ratio`: `16:9` for graphical abstracts, `4:3` for mechanism figures, `1:1` for cover-like concepts
- `resolution`: `2K` for review drafts; use higher only when needed
- `quality`: `high`
- `output_format`: `png`
- `background`: `opaque` unless the user asks for transparent

## Follow-up QA

After generation:

1. inspect the image visually
2. check whether labels are legible and spelled correctly
3. list any scientific hallucinations or unsupported visual claims
4. recommend which labels or arrows should be redrawn as vector objects
5. keep the generated image and metadata together for provenance
6. report separate verdicts for internal design use and submission eligibility
references/qa-contract.md
# QA Contract

## Contents

- [Current official references to verify](#current-official-references-to-verify)
- [Pre-submission checklist](#pre-submission-checklist)
- [Statistics legend minimum](#statistics-legend-minimum)
- [Image-integrity minimum](#image-integrity-minimum)
- [Automated source preflight](#automated-source-preflight)
- [Automatic multi-panel alignment gate](#automatic-multi-panel-alignment-gate)
- [Automatic rendered collision audit](#automatic-rendered-collision-audit)
- [Rendered panel-by-panel audit](#rendered-panel-by-panel-audit)
- [Typography and PDF glyph floor](#typography-and-pdf-glyph-floor)
- [Uncertainty consistency](#uncertainty-consistency)
- [Geometry and annotation placement](#geometry-and-annotation-placement)
- [Color separation and salience](#color-separation-and-salience)
- [Transformation and paired-effect checks](#transformation-and-paired-effect-checks)
- [Export checks](#export-checks)


Use this before final delivery, before a revision package, and whenever the figure
contains microscopy, blots, gels, clinical subgroup analysis, or statistical claims.
Journal rules change, so verify the latest target journal author guide for final
submission. The values below are conservative defaults for Nature-family style work.
For the flagship journal Nature, load `nature-article-requirements.md` and use
its stage-specific main-figure, Extended Data and legend contracts.

## Current official references to verify

- Nature research figure guide: `https://research-figure-guide.nature.com/`
- Nature building/exporting panels: `https://research-figure-guide.nature.com/figures/building-and-exporting-figure-panels/`
- Nature preparing figures/specifications: `https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/`
- Nature initial submission and statistics guidance: `https://www.nature.com/nature/for-authors/initial-submission`
- Nature formatting guide: `https://www.nature.com/nature/for-authors/formatting-guide`
- Journal of Cell Biology figure/video guidelines for microscopy-oriented image QA: `https://rupress.org/jcb/pages/fig-vid-guidelines`
- Elsevier/Cell-family image-manipulation baseline: `https://www.sciencedirect.com/journal/the-cell-surface/publish/guide-for-authors`

## Pre-submission checklist

| Check | Pass condition |
|---|---|
| Core conclusion | One-sentence claim exists and every panel maps to it |
| Archetype | Figure has a declared archetype and panel hierarchy |
| Backend exclusivity | The selected backend produced all plotting, previews, exports, and visual QA renders |
| Final size | Single-column about 89 mm or double-column about 183 mm, height not above target journal limit |
| Text size | Body/tick/legend text is readable at final size, usually 5-7 pt for dense journal figures |
| Rendered glyph floor | Every PDF text run, including math superscripts/subscripts, is at least 5 pt |
| Panel labels | Lowercase, bold, near top-left, typically 8 pt at final size |
| Editable text | SVG/PDF text remains editable; no outlined text unless unavoidable for special symbols |
| Font | Arial/Helvetica/sans-serif fallback is used consistently |
| Color | No rainbow color maps; red/green is not the only encoding; grayscale print remains interpretable |
| Legend strategy | Shared or direct labels where possible; no repeated redundant legends |
| Display terminology | Legend labels use display-style initial capitalization and preserve canonical model names |
| Statistics | `n`, biological/technical repeat definition, center, spread, test, correction, and exact comparison are documented |
| Comparable uncertainty | Every comparable seed/fold/split aggregate panel shows the same variability definition or documents an exemption |
| Annotation clearance | Automatic PDF collision audit has no FAIL findings; every WARN is reviewed at final size and justified or fixed |
| Panel alignment | Every multi-panel figure has a fresh alignment JSON; comparable row/column edges, dimensions, labels and repeated gutters are within 1.5 pt or carry a reasoned exemption |
| Visual hierarchy | Hero evidence remains more salient than neutral baselines after rendering |
| Numerical transforms | Interpolation/normalization direction and monotonicity assumptions are asserted in code |
| Source data | Quantitative panels can be traced to a clean CSV/TSV/XLSX or script output |
| Raster resolution | Photos/microscopy are high-resolution enough for final size; line art uses vector where possible |
| Microscopy scale | Scale bar is present, calibrated, and not only a magnification factor |
| Image integrity | Crop, contrast, pseudo-color, stitching, reuse, and raw-file provenance are recorded |
| Export bundle | Script, source data, SVG, PDF, TIFF/PNG preview, and QA notes are delivered together when requested; previews are not mislabeled as accepted main-figure upload formats |

## Statistics legend minimum

For each quantitative panel, capture:

```text
n definition:
biological replicates:
technical replicates:
center statistic:
spread/interval:
test:
multiple-comparison correction:
p-value display:
source-data file:
```

For machine-learning/model figures, also capture:

```text
train/validation/test split:
number of seeds or folds:
metric definition:
confidence interval or variability definition:
baseline definition:
```

## Image-integrity minimum

For each image panel, capture:

```text
raw file:
processed file:
crop:
brightness/contrast/gamma:
pseudo-color:
scale calibration:
stitching:
reuse in other figures:
quantification link:
```

Global adjustments are generally safer than local selective edits. If an adjustment
changes the visibility of relevant background or bands, flag it instead of silently
normalizing it away.

## Automated source preflight

Run the dependency-free validator on the final plotting source before rendering the delivery bundle:

```bash
# Python source
python skills/nature-figure/scripts/validate_figure.py path/to/figure.py

# R, R Markdown, or Quarto source
python skills/nature-figure/scripts/validate_figure.py path/to/figure.R

# Machine-readable report or stricter warning gate
python skills/nature-figure/scripts/validate_figure.py path/to/figure.py --json
python skills/nature-figure/scripts/validate_figure.py path/to/figure.py --strict

# Exported-PDF glyph-size audit
python skills/nature-figure/scripts/audit_pdf_text.py path/to/figure.pdf --min-pt 5
python skills/nature-figure/scripts/audit_pdf_text.py path/to/figure.pdf --min-pt 5 --json

# Backend-neutral audit of the render-time Python/R panel-layout manifest
python skills/nature-figure/scripts/audit_panel_alignment.py path/to/figure.alignment-layout.json \
  --json-out path/to/figure.alignment.json \
  --overlay-svg path/to/figure.alignment.svg \
  --strict

# Mandatory rendered collision audit for Python and R figures
python skills/nature-figure/scripts/audit_figure_collisions.py path/to/figure.pdf \
  --json-out path/to/figure.collision-audit.json \
  --overlay-pdf path/to/figure.collision-audit.pdf

# Optional: make ambiguous WARN findings blocking
python skills/nature-figure/scripts/audit_figure_collisions.py path/to/figure.pdf --strict
```

The source preflight checks syntax, font configuration and size floor, mathtext shrinkage risk, literal legend-label capitalization, unsafe color maps, editable-text settings, vector/raster exports, DPI, common journal widths, potential sampling or unreported missing-data exclusion, simulated-data leakage, log guards, interpolation monotonicity, stochastic uncertainty encoding, rotated-text anchoring, risky annotation workarounds, and obvious cross-backend plotting references. The panel-alignment gate reads final axes/grob rectangles measured by the selected backend and applies one physical-point contract to Python and R. The PDF text audit scans supported content streams for actual `Tf` font-size operators and catches reduced script glyphs that source-level `fontsize` checks miss. The rendered collision audit uses PyMuPDF geometry from the final PDF and therefore applies equally to Python- and R-generated figures.

Treat the result as a deterministic source audit, not as evidence that the analysis or rendered figure is correct. Resolve all `FAIL` findings before delivery. Review every `WARN`, then run the selected backend and inspect the actual SVG/PDF/TIFF/PNG outputs at final size. A warning may be acceptable only when the QA notes state the reason.

## Automatic multi-panel alignment gate

Run the gate for every figure with at least two comparable panels, after the
final layout engine has drawn fonts, legends, colorbars and constrained/tight
layout, and before exporting submission files. Rerun it after any change that
can affect panel geometry. A single-panel figure is `not applicable`; a
multi-panel figure with no declared or inferable comparison groups is `NOT
AUDITABLE`, not a pass.

The default tolerance is `1.5 pt` (about `0.53 mm`) at final physical size. The
auditor checks:

- panels in one row: common top and bottom edges plus equal plot-area height;
- three or four same-row panels with equal grid spans: equal final plot-area
  widths within `1.5 pt`;
- panels in one column: common left and right edges plus equal plot-area width;
- unequal-span grid panels: shared outer start/stop edges, including automatic
  `left two + right one` and `left one + right two` arrangements;
- three or more comparable panels: uniform repeated horizontal/vertical gutters;
- detectable bold lowercase top-left panel labels: common y anchors within rows
  and common x anchors within columns;
- plot-area rectangles do not overlap.

For Matplotlib, copy `audit_panel_alignment.py` beside the plotting script or
add the skill scripts directory to `PYTHONPATH`, then call this after the last
layout change:

```python
from audit_panel_alignment import require_matplotlib_panel_alignment

require_matplotlib_panel_alignment(
    fig,
    json_out="figure.alignment.json",
    overlay_svg="figure.alignment.svg",
    tolerance_pt=1.5,
    gutter_tolerance_pt=1.5,
    require_panel_labels=True,
    strict=True,
)
```

Ordinary `subplots`/`GridSpec` row and column groups are inferred from
`SubplotSpec`. A panel spanning both grid rows is automatically compared with
the upper small panel at their shared top boundary and the lower small panel at
their shared bottom boundary; this works whether the spanning panel is in the
left or right column. The spanning panel is not incorrectly required to equal
either small panel's height. Position outside panel letters with a fixed point
offset from the axes corner rather than a shared axes-fraction offset, because
`y=1.02` produces different physical displacements for tall and short panels.
For a horizontal row of three or four panels, equal column spans imply equal
final widths. Intentional unequal `width_ratios` must use a narrow exemption
such as `{"panels": ["b"], "checks": ["panel-width"], "reason": "middle hero panel"}`;
do not exempt the entire row or increase the tolerance.
Pass explicit `row_groups` and `column_groups` when axes come from nested grids
or separately created containers. Exclude a
colorbar or inset with `exclude_axes=[...]`, or add an exemption containing the
affected panel, the exact checks and a non-empty scientific/layout reason.

For R/patchwork, source `panel_alignment.R` and call
`require_patchwork_panel_alignment()` with the same width/height used for final
export. Common patchwork groups and structured unequal-span designs are inferred
from gtable cells, including two stacked panels beside a two-row spanning panel
in either column. Same-row groups of three or four equal gtable spans must also
have equal final widths. Nested or manually positioned designs must provide explicit
`row_groups` / `column_groups`. The R helper requires `patchwork`, `grid` and
`jsonlite`; it measures in R and invokes the Python CLI only to audit the
resulting JSON, so it does not redraw or replace the R figure.

| Result | Required action |
|---|---|
| `NOT APPLICABLE`, exit `0` | Single rendered plot-area only; preserve the JSON and continue |
| `PASS`, exit `0` | Preserve the alignment JSON and continue to export/PDF QA |
| `FIX BEFORE DELIVERY`, exit `1` | Correct the selected-backend layout and rerun; do not export the delivery bundle |
| `REVIEW REQUIRED` | Resolve WARN or document it; `--strict` makes WARN blocking |
| `NOT AUDITABLE`, exit `2` | Supply valid measured geometry and comparison groups; do not claim alignment passed |

The QA-only alignment SVG shows measured panel rectangles, not scientific
content. It must not replace the selected backend's figure. Do not weaken the
global tolerance to hide one exception. Structured grid spanning is audited
automatically; a free-positioned hero panel, inset, legend-only axis or colorbar
should be omitted from unrelated groups or carry a specific exemption reason.

## Automatic rendered collision audit

Run `audit_figure_collisions.py` after **every generated figure and every
revision that can change layout**, including edits to text, fonts, legend,
annotations, axes, data, uncertainty, panel size or arrangement. Do not reuse a
report from an earlier render. Preserve the JSON report with the delivery QA;
the marked PDF is diagnostic only.

| Result | Meaning | Required action |
|---|---|---|
| `text-text` FAIL | Two rendered text boxes materially overlap | Separate, shorten, rotate or resize the labels and rerun |
| `text-stroke` FAIL | A line, curve, marker edge, error bar or other stroked path crosses the interior of text | Move the text or alter the layout; do not hide the path with an opaque white box |
| `text-page-clipping` FAIL | A rendered text trace extends beyond the final PDF page | Expand/reposition the layout and re-export |
| `text-fill-edge` WARN | Text only partly overlaps a bar, heatmap cell or other fill | Inspect at final size; fix unless the edge overlap is intentional and legible |
| `text-image-edge` WARN | Text only partly overlaps a raster image boundary | Inspect panel labels, scale bars and image annotations at final size |
| contained overlay count | Text is fully inside a fill or image | Informational because in-bar labels, heatmap values and image annotations can be intentional |

Exit code `1` and verdict `FIX BEFORE DELIVERY` block delivery. Exit code `2`
or `NOT AUDITABLE` means the PDF/dependency could not be checked and must not be
reported as a pass. `REVIEW REQUIRED` does not silently pass: inspect each WARN
and record the reason, or use `--strict` to make WARN blocking. PyMuPDF is
declared in `requirements.txt`; install it with:

```bash
python -m pip install -r skills/nature-figure/requirements.txt
```

The detector deliberately separates reliable geometry failures from ambiguous
fill/image overlays. It does not prove visual quality, semantic correctness,
adequate contrast or accessibility, so the final-size panel audit remains
mandatory.

## Rendered panel-by-panel audit

Do not approve a figure from a whole-page glance. Inspect each panel at final physical size, then inspect the assembled figure. Record one row per panel:

| Panel | Unique claim | Center/summary | Spread/interval | Replicate unit | Labels/legend | Alignment group/result | Collision check | Pass |
|---|---|---|---|---|---|---|---|---|
| a | What question only this panel answers | mean/median/raw | SD/SE/CI/none + reason | seeds/folds/subjects/etc. | exact display labels | row/column group, deviation or exemption | collision report findings + data/error extent + text bbox | yes/no |

Cover each panel mentally. If the figure's argument remains complete, merge or remove that panel. Compare repeated panels side by side for consistent terminology, uncertainty, axes, and color mapping. After adding error bars or uncertainty bands, remove arrows, brackets, or fills that encode the same gap and occupy the same geometry.

## Typography and PDF glyph floor

- The 5 pt floor applies to every rendered glyph, not only the parent `fontsize` in source code.
- Matplotlib mathtext commonly scales superscripts/subscripts to about 0.7 of the parent. A 7 pt `$R^2$` can therefore contain a 4.9 pt glyph. Prefer a Unicode glyph such as `R²` when it preserves the intended notation, or increase the parent size and confirm the PDF audit.
- Measure long labels against their allocated group width at final size. Compare rendered text bounding-box width in millimetres with the available slot; do not rely on the source font number alone.
- Keep canonical capitalization such as `XGBoost`, `DeepSeek`, `GPT-5.2`, and `RF`. Legend labels start with display-style capitalization, while prose follows normal sentence grammar. Do not use blind string title-casing.

## Uncertainty consistency

- If a line or bar is a mean/median across random seeds, folds, splits, subjects, or repeated experiments, encode the requested spread in every comparable panel.
- State the exact definition, for example `median ± one seed SD`, in the legend or panel notes. Do not infer or invent it.
- Presence of one `fill_between`, `errorbar`, `yerr`, or `geom_errorbar` call does not prove coverage. Use the panel audit table to verify every comparable panel.
- Recompute label clearance from the upper uncertainty extent after error bars are added.

## Geometry and annotation placement

- Measure spacing between the actual objects being compared. Use rendered/tight bounding boxes for panel-to-legend or legend-row gaps; scanning an entire raster row can mix unrelated objects at different horizontal positions.
- Use plot-area rectangles for axes alignment and tight bounding boxes for outer-content clearance; do not confuse unequal tick-label widths with a shifted data rectangle.
- Derive label positions from data and uncertainty bounds, for example `max(center + spread) + margin`, rather than a fixed `LABEL_Y`.
- For rotated Matplotlib text, use `rotation_mode="anchor"` and verify the final bounding box.
- If a curve crosses a label, reposition the label beyond the local data envelope. Avoid opaque white `bbox` masks that cut a conspicuous hole in a line.
- Equal pixel y-coordinates can still look uneven when bar heights create unequal whitespace. Diagnose the perceived gap before moving already aligned labels.

## Color separation and salience

- Pairwise ΔE and white-background contrast are necessary checks, not a complete design test.
- Verify hierarchy after rendering: neutral baselines should not appear stronger than the proposed method or primary evidence.
- Do not repurpose a sequential light-to-dark palette as unrelated categorical colors merely because the hues look attractive.
- Check grayscale and color-vision robustness, then inspect the actual figure because metrics do not encode which series should dominate attention.

## Transformation and paired-effect checks

- `numpy.interp` requires increasing `xp`. Use `scripts/figure_safety.py::interp_monotone`, or explicitly assert monotonicity and reverse/sort `xp` and `fp` together. Plausible-looking output is not evidence of correctness.
- Do not plan one figure per source table. Group panels by the distinct claims they support.
- When repeated units are matched, inspect paired differences. Broad between-dataset or between-subject heterogeneity can make four marginal distributions overlap even when the within-unit effect is strong; use a paired-difference view when the scientific claim is paired.

## Export checks

Run only the export block for the selected backend. If that backend is unavailable,
stop and report the missing runtime/package instead of producing a substitute export
with the other language.

### Python

```python
import matplotlib as mpl
mpl.rcParams["svg.fonttype"] = "none"
mpl.rcParams["pdf.fonttype"] = 42
fig.savefig("figure.svg", bbox_inches="tight")
fig.savefig("figure.pdf", bbox_inches="tight")
fig.savefig("figure.tiff", dpi=600, bbox_inches="tight")
```

### R

```r
svglite::svglite("figure.svg", width = width_mm / 25.4, height = height_mm / 25.4)
print(plot)
dev.off()

grDevices::cairo_pdf("figure.pdf", width = width_mm / 25.4, height = height_mm / 25.4, family = "Arial")
print(plot)
dev.off()

ragg::agg_tiff("figure.tiff", width = width_mm / 25.4, height = height_mm / 25.4, units = "in", res = 600)
print(plot)
dev.off()
```

Open the SVG/PDF after export and verify that text can be selected, labels do not
overlap, and the figure still reads at final printed size.
references/r-template-index.md
# Private R Template Adaptation

Use this reference when the user chooses R and provides or mentions an existing
R plotting template collection. Treat such material as private working context.
Do not reveal absolute paths, folder names, filenames, screenshots, provenance, or
any identifying labels from the source collection in user-facing output.

## Privacy rules

- Never include absolute local paths in generated code, reports, comments, or final replies.
- Never mention the original source file, folder, template number, course title, download
  location, chat attachment, or private document name.
- When a template is useful, describe it generically by chart family: "a grouped bar
  template", "a ComplexHeatmap workflow", "a survival plotting workflow".
- If a reusable idea is copied from a private template, rewrite the final code as a clean,
  self-contained script with neutral function names and neutral comments.
- If the user asks where a style came from, say it was adapted from the provided working
  materials without identifying the path or source file.

## Generic search strategy

Search private materials by chart family and package names, not by exposing paths:

```bash
find <private-template-root> -type f \( -name '*.R' -o -name '*.Rmd' -o -name '*.r' \)
rg -n "ggplot|patchwork|ComplexHeatmap|ggrepel|svglite|cairo_pdf|survminer|circlize" <private-template-root>
```

Keep these commands in internal working notes only. Do not paste the user's private
root path into the final answer.

## Chart-family map

Use these generic families to decide what to inspect:

| Need | Search targets |
|---|---|
| Bars and grouped comparisons | `geom_col`, `geom_bar`, `position_dodge`, `stat_compare_means` |
| Error bars and point-interval plots | `geom_errorbar`, `geom_pointrange`, `mean_se`, `stat_summary` |
| Stacked or bidirectional bars | `position_stack`, `coord_flip`, signed values, paired positive/negative bars |
| Box, violin, paired, and raincloud-style distributions | `geom_boxplot`, `geom_violin`, `geom_jitter`, paired sample identifiers |
| Heatmaps and annotated heatmaps | `ComplexHeatmap`, `HeatmapAnnotation`, `pheatmap`, `geom_tile` |
| Correlation, scatter, bubble, and volcano plots | `geom_point`, `geom_smooth`, `ggrepel`, `logFC`, `pvalue`, bubble size scales |
| PCA, PCoA, NMDS, tSNE, UMAP | `prcomp`, `cmdscale`, `vegan`, `Rtsne`, `Seurat`, embedding coordinates |
| Survival, Cox, subgroup, ROC, forest | `survival`, `survminer`, `coxph`, `forestplot`, `timeROC`, hazard ratios |
| Enrichment and pathway summaries | `clusterProfiler`, `GSEA`, `enrichGO`, `enrichKEGG`, dot plots, ridge plots |
| Circular, genome, phylogeny, chromosome | `circlize`, `ggtree`, `karyoploteR`, genome interval tracks |
| Single-cell and omics workflows | `Seurat`, marker genes, differential expression, cell-type annotation |
| Maps, anatomy, and spatial summaries | `sf`, `maps`, `gganatogram`, spatial coordinates |
| Radar, lollipop, dumbbell, UpSet, Venn, Sankey | `ggradar`, `geom_segment`, `UpSetR`, `ggalluvial`, set operations |

## Adaptation checklist

When adapting a private template:

- Keep useful data wrangling, statistics, and geoms.
- Replace template-specific colors with the figure-level semantic palette.
- Normalize fonts to final-size 5-7 pt text and 8 pt bold lowercase panel labels.
- Convert single-output PNG/PDF scripts to SVG/PDF/TIFF export.
- Remove decorative elements that do not support the core conclusion.
- Ensure each statistical comparison has `n`, center, spread, test, and correction
  information in the legend or source-data notes.
- For image panels, document raw file, crop, contrast, scale-bar calibration, and any
  stitching or pseudo-coloring in private QA notes.
- Final code should be self-contained and should not require the original private
  folder structure unless the user explicitly asks to keep that workflow.
references/r-workflow.md
# R Workflow

## Contents

- [R-only execution rule](#r-only-execution-rule)
- [Required packages by task](#required-packages-by-task)
- [Contract scaffold](#contract-scaffold)
- [Panel labels in R](#panel-labels-in-r)
- [Patchwork layout patterns](#patchwork-layout-patterns)
- [Patchwork panel-alignment gate](#patchwork-panel-alignment-gate)
- [ComplexHeatmap export](#complexheatmap-export)
- [Template reuse rule](#template-reuse-rule)


Use this when the user chooses R, brings R data/scripts, or asks to reuse the local
R plotting templates. The R track should still follow the same figure contract:
claim first, evidence hierarchy second, plotting code third.

## R-only execution rule

When the user has selected R, do all figure drawing, previewing, exporting, and
visual QA in R. Do not call Python/matplotlib/seaborn/plotly to create a temporary
preview, fallback export, or layout approximation. If R, `Rscript`, or required R
packages are missing, stop before rendering and report the missing dependency. You
may still write the R script, provide `install.packages()` commands, or ask permission
to install dependencies, but do not cross-render the figure in another language.

Allowed non-R utilities are limited to non-visual tasks such as shell file inspection,
CSV line counts, checksums, archive extraction, or text search. They must not create
image/vector outputs or alter visual layout.

## Required packages by task

| Task | Preferred packages |
|---|---|
| Bars, boxplots, violins, dot plots, lines, volcano plots | `ggplot2`, `ggrepel`, `dplyr`, `tidyr` |
| Multi-panel assembly and alignment QA | `patchwork`, `grid`, `jsonlite`; use `cowplot` only when inset alignment requires it |
| Rich omics heatmaps | `ComplexHeatmap`, `circlize`, `grid` |
| Survival and clinical subgroup plots | `survival`, `survminer`, `forestplot`, `ggplot2` |
| Circular/genome plots | `circlize`, `ggtree`, `gggenes`, domain-specific packages |
| Export | `svglite`, `grDevices::cairo_pdf`, `ragg` |

## Contract scaffold

```r
library(ggplot2)
library(patchwork)
source("skills/nature-figure/scripts/panel_alignment.R")

palette_contract <- c(
  neutral_dark = "#272727",
  neutral_mid = "#767676",
  neutral_light = "#D8D8D8",
  signal_blue = "#3182BD",
  signal_teal = "#33B5A5",
  accent_red = "#D24B40",
  accent_orange = "#E28E2C"
)

theme_nature_contract <- function(base_size = 6.5, base_family = "Arial") {
  theme_classic(base_size = base_size, base_family = base_family) +
    theme(
      axis.line = element_line(linewidth = 0.35, colour = "black"),
      axis.ticks = element_line(linewidth = 0.35, colour = "black"),
      axis.title = element_text(size = base_size),
      axis.text = element_text(size = base_size - 0.5),
      legend.title = element_text(size = base_size - 0.3),
      legend.text = element_text(size = base_size - 0.7),
      strip.text = element_text(size = base_size - 0.3, face = "bold"),
      plot.title = element_text(size = base_size + 0.5, face = "bold"),
      panel.grid = element_blank()
    )
}

theme_set(theme_nature_contract())

save_pub_r <- function(plot, filename, width_mm = 183, height_mm = 120, dpi = 600) {
  w <- width_mm / 25.4
  h <- height_mm / 25.4

  if (inherits(plot, "patchwork")) {
    require_patchwork_panel_alignment(
      plot,
      manifest_path = paste0(filename, ".alignment-layout.json"),
      report_path = paste0(filename, ".alignment.json"),
      overlay_svg = paste0(filename, ".alignment.svg"),
      width_in = w,
      height_in = h,
      tolerance_pt = 1.5,
      gutter_tolerance_pt = 1.5,
      strict = TRUE
    )
  }

  svglite::svglite(paste0(filename, ".svg"), width = w, height = h)
  print(plot)
  dev.off()

  grDevices::cairo_pdf(paste0(filename, ".pdf"), width = w, height = h, family = "Arial")
  print(plot)
  dev.off()

  ragg::agg_tiff(paste0(filename, ".tiff"), width = w, height = h, units = "in", res = dpi)
  print(plot)
  dev.off()
}
```

## Panel labels in R

Use patchwork tags for most multi-panel figures:

```r
fig <- (p_a | p_b) / (p_c | p_d) +
  plot_annotation(tag_levels = "a") &
  theme(plot.tag = element_text(size = 8, face = "bold"))
```

Use manual labels only when dark image plates or inset geometry make patchwork tags
misalign.

## Patchwork layout patterns

### Quantitative grid

```r
fig <- (p_a | p_b | guide_area()) /
       (p_c | p_d | p_e) +
  plot_layout(guides = "collect", widths = c(1, 1, 0.45)) &
  theme(legend.position = "right")
```

### Schematic-led composite

```r
design <- "
AAAA
BBCD
"
fig <- p_schematic + p_b + p_c + p_d +
  plot_layout(design = design, heights = c(1.8, 1))
```

### Image plate plus quant

Keep black backgrounds inside image panels only. Put scale bars on the image, then
place quantification next to or below the representative image.

```r
p_img <- ggplot(img_df, aes(x, y, fill = intensity)) +
  geom_raster() +
  scale_fill_gradient(low = "black", high = "white") +
  coord_fixed(expand = FALSE) +
  annotate("segment", x = 10, xend = 40, y = 10, yend = 10,
           linewidth = 0.6, colour = "white") +
  theme_void() +
  theme(legend.position = "none", plot.background = element_rect(fill = "black", colour = NA))
```

## Patchwork panel-alignment gate

`panel_alignment.R` converts the final patchwork gtable cells to physical-point
rectangles on a temporary R graphics device with the same dimensions as the
real export. It then invokes the backend-neutral
`audit_panel_alignment.py` JSON auditor. Python does not render or modify the R
figure.

Simple patchwork grids infer row/column groups from gtable cells. Structured
unequal-span designs are also automatic: two stacked panels in the left column
beside one two-row panel in the right column, and the mirrored arrangement,
receive shared top/bottom boundary checks without an invalid equal-height
comparison. Horizontal groups of three or four equal gtable spans must also
have equal final widths; intentional unequal widths require a reasoned
`panel-width` exemption. For nested or manually composed figures, declare the intended
comparisons explicitly:

```r
require_patchwork_panel_alignment(
  fig,
  manifest_path = "figures/fig2.alignment-layout.json",
  report_path = "figures/fig2.alignment.json",
  overlay_svg = "figures/fig2.alignment.svg",
  width_in = 183 / 25.4,
  height_in = 120 / 25.4,
  panel_ids = c("a", "b", "c", "d"),
  row_groups = list(c("a", "b"), c("c", "d")),
  column_groups = list(c("a", "c"), c("b", "d")),
  exemptions = list(
    list(
      panels = c("a"),
      checks = c("column", "panel-width"),
      reason = "hero panel intentionally spans the evidence columns"
    )
  ),
  tolerance_pt = 1.5,
  gutter_tolerance_pt = 1.5,
  strict = TRUE
)
```

Every exemption must name the panel, the exact check and the reason. Do not
increase the global tolerance to hide an inset, colorbar, legend-only cell or
hero panel. A non-zero audit status stops the R delivery script. Preserve both
the measured layout manifest and audit JSON; the alignment SVG is QA-only.

## ComplexHeatmap export

`ComplexHeatmap` objects are grid objects, not ggplot objects. Export them by opening
the graphics device, drawing, then closing it.

```r
library(ComplexHeatmap)
library(circlize)

pdf("heatmap.pdf", width = 7.2, height = 4.8, family = "Arial")
draw(ht, heatmap_legend_side = "right", annotation_legend_side = "right")
dev.off()

svglite::svglite("heatmap.svg", width = 7.2, height = 4.8)
draw(ht, heatmap_legend_side = "right", annotation_legend_side = "right")
dev.off()
```

## Template reuse rule

The local R materials are examples, not final style. When reusing them:

1. Inspect only the nearest template folder.
2. Keep useful data wrangling, statistics, and geoms.
3. Replace ad hoc colors, oversized fonts, dense legends, and PNG-only export.
4. Rebuild the final script around `theme_nature_contract()` and `save_pub_r()`.
5. Add source-data output if the figure is manuscript-facing.

Open `references/r-template-index.md` for the local template atlas.
references/template-catalog.md
# Validated Plot Template Catalog

Use this catalog after the backend is resolved to Python and after `asset-adaptation.md` confirms that the requested panel matches a template semantically. These templates use only NumPy and Matplotlib, require real CSV input for production, and produce SVG, PDF, 600 dpi TIFF, and a count-based QA JSON record.

Run `python scripts/plot_templates.py <subcommand> --help` for the complete interface.

| Subcommand | Required CSV shape | Main safeguards |
|---|---|---|
| `volcano` | gene, effect-size, adjusted-p columns | positive p-value check, explicit thresholds, all points retained, large-data rasterization |
| `roc` | one FPR column plus one or more TPR columns | range check in `[0,1]`, stable FPR sorting, trapezoidal AUC recorded |
| `dotplot` | row category, column category, size, color | complete category-order validation, no silent category removal |
| `marginal` | x, y, optional group | preserves all observations, separates 2D joint structure from 1D marginal distributions |
| `paired` | pair ID, condition, value | exactly two conditions, rejects duplicate or incomplete pairs unless exclusion is explicit |

## Production examples

```bash
python skills/nature-figure/scripts/plot_templates.py volcano \
  --input results.csv --gene-col gene --effect-col log2fc --p-col padj \
  --output figures/volcano

python skills/nature-figure/scripts/plot_templates.py roc \
  --input roc.csv --fpr-col fpr --tpr-cols model_a,model_b \
  --output figures/roc

python skills/nature-figure/scripts/plot_templates.py dotplot \
  --input markers.csv --row-col cell_type --column-col gene \
  --size-col pct_exp --color-col avg_exp_scaled --output figures/markers
```

Never use `--demo` for a manuscript deliverable. It exists only for explicit smoke tests and is marked in the QA JSON. If required numeric data are missing or non-finite, the default is to stop. `--drop-incomplete` is an explicit exception that records exclusion counts; it is not permission to hide scientifically important missingness.

The templates do not compute hypothesis tests. Add statistics only after defining the replicate unit, test, assumptions, multiplicity correction, and legend reporting contract.
references/tutorials.md
# Tutorials — Nature Figure Making

## Contents

- [Tutorial 1: Grouped bar chart (multi-metric comparison)](#tutorial-1-grouped-bar-chart-multi-metric-comparison)
- [Tutorial 2: Ablation bar chart (alpha-graduated, horizontal)](#tutorial-2-ablation-bar-chart-alpha-graduated-horizontal)
- [Tutorial 3: Multi-panel trend with shared legend](#tutorial-3-multi-panel-trend-with-shared-legend)
- [Tutorial 4: Heatmap with dual colormaps (positive/negative columns)](#tutorial-4-heatmap-with-dual-colormaps-positivenegative-columns)
- [Related files](#related-files)


End-to-end walkthroughs for the most common publication figure types.
All examples use helpers from [api.md](api.md) and patterns from [common-patterns.md](common-patterns.md).
For the retained third-party demo map, copyright boundary, and original reimplementation guidance, open [demos.md](demos.md).

---

## Tutorial 1: Grouped bar chart (multi-metric comparison)

**Goal**: Several methods compared across multiple metrics. Legend in a dedicated panel.
When methods belong to related families, use one coherent baseline family plus one coherent hero family.

```python
import os
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import gridspec

# --- Style ---
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial']
plt.rcParams['svg.fonttype'] = 'none'
plt.rcParams['font.size'] = 24
plt.rcParams['axes.spines.right'] = False
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.linewidth'] = 3

# --- Data ---
methods = ['ResNet1d18', 'ResNet1d34', 'ECGFounder', 'CSFM-Tiny', 'CSFM-Base', 'CSFM-Large']
colors  = ['#484878', '#7884B4', '#B4C0E4', '#E4E4F0', '#E4CCD8', '#F0C0CC']
metrics = ['Metric 1', 'Metric 2', 'Metric 3']
mean = {
    'Metric 1': np.array([0.81, 0.83, 0.86, 0.89, 0.91, 0.92]),
    'Metric 2': np.array([0.63, 0.67, 0.71, 0.74, 0.77, 0.79]),
    'Metric 3': np.array([0.41, 0.45, 0.49, 0.53, 0.56, 0.58]),
}
std  = {k: v * 0.03 for k, v in mean.items()}  # placeholder

# --- Figure ---
fig = plt.figure(figsize=(28, 6))
gs = gridspec.GridSpec(1, len(metrics) + 1)  # +1 for legend panel

handles, labels = None, None
for col, metric in enumerate(metrics):
    ax = fig.add_subplot(gs[col])
    bars = ax.bar(
        range(len(methods)),
        mean[metric],
        yerr=std[metric],
        capsize=5,
        color=colors,
        label=methods,
        error_kw={'elinewidth': 2, 'capthick': 2},
    )
    if col == 0:
        handles, labels = ax.get_legend_handles_labels()
    ax.set_xticks([])
    y_vals = mean[metric]
    margin = (y_vals.max() - y_vals.min()) * 0.15
    ax.set_ylim([y_vals.min() - margin, y_vals.max() + margin])
    ax.set_ylabel(metric, fontsize=32)

# Legend-only panel
ax_leg = fig.add_subplot(gs[-1])
ax_leg.legend(handles, labels, fontsize=28, loc='center', frameon=False)
ax_leg.set_axis_off()

fig.tight_layout(pad=2)
os.makedirs('./figures', exist_ok=True)
fig.savefig('./figures/comparison.png', dpi=300)
fig.savefig('./figures/comparison.pdf', dpi=300)
plt.close(fig)
```

---

## Tutorial 2: Ablation bar chart (alpha-graduated, horizontal)

**Goal**: Same method with components progressively added; alpha encodes completeness.

```python
import os
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial']
plt.rcParams['svg.fonttype'] = 'none'
plt.rcParams['font.size'] = 24
plt.rcParams['axes.spines.right'] = False
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.linewidth'] = 3

configs = ['None', '+ Module A', '+ Module B', '+ Module C', 'Full']
values  = np.array([0.72, 0.78, 0.81, 0.84, 0.88])
stds    = np.array([0.02, 0.02, 0.01, 0.01, 0.01])

n = len(configs)
blue_rgb = (0.215686, 0.458824, 0.729412)   # #3775BA
alphas = np.linspace(0.2, 1.0, n)
colors = [(blue_rgb[0], blue_rgb[1], blue_rgb[2], a) for a in alphas]

fig, ax = plt.subplots(figsize=(12, 6))
ax.barh(range(n), values, xerr=stds,
        color=colors, ecolor='k', capsize=5)
ax.set_yticks(range(n))
ax.set_yticklabels(configs)
ax.set_xlim([values.min() - 0.05, values.max() + 0.03])
ax.set_xlabel('Score', fontsize=32)

fig.tight_layout(pad=2)
os.makedirs('./figures', exist_ok=True)
fig.savefig('./figures/ablation.png', dpi=300)
plt.close(fig)
```

---

## Tutorial 3: Multi-panel trend with shared legend

**Goal**: Two trend panels (e.g., train/val curves) and a legend-only third panel.

```python
import os
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial']
plt.rcParams['svg.fonttype'] = 'none'
plt.rcParams['font.size'] = 15
plt.rcParams['axes.spines.right'] = False
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.linewidth'] = 2

methods = ['Baseline', 'CSFM-Tiny', 'CSFM-Base', 'CSFM-Large']
colors  = ['#7884B4', '#E4E4F0', '#E4CCD8', '#F0C0CC']
x = np.arange(0, 100, 5)

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

for panel_idx, (ax, panel_name) in enumerate(zip(axes[:2], ['Training', 'Validation'])):
    for method, color in zip(methods, colors):
        y = 0.48 + 0.42 * (1 - np.exp(-x / 30)) + np.random.randn(len(x)) * 0.01
        if method == 'Baseline':
            y -= 0.03
        elif method == 'CSFM-Tiny':
            y += 0.00
        elif method == 'CSFM-Base':
            y += 0.02
        elif method == 'CSFM-Large':
            y += 0.03
        ax.plot(x, y, color=color, lw=2.5, marker='o', markersize=6, label=method)
    ax.set_title(panel_name, fontsize=18)
    ax.set_xlabel('Epoch', fontsize=16)
    ax.set_ylabel('Loss', fontsize=16)
    if panel_idx == 0:
        handles, labels = ax.get_legend_handles_labels()

# Legend-only panel
axes[2].legend(handles, labels, fontsize=14, loc='center', frameon=False)
axes[2].set_axis_off()

fig.tight_layout(pad=2)
os.makedirs('./figures', exist_ok=True)
fig.savefig('./figures/trends.png', dpi=300)
fig.savefig('./figures/trends.pdf', dpi=300)
plt.close(fig)
```

---

## Tutorial 4: Heatmap with dual colormaps (positive/negative columns)

**Goal**: Score matrix where positive = Reds, negative = Blues_r. Cell text auto-contrasted.

```python
import os
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt

plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Arial']
plt.rcParams['svg.fonttype'] = 'none'
plt.rcParams['font.size'] = 16
plt.rcParams['axes.spines.right'] = False
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.linewidth'] = 2

# matrix: rows = methods, cols = metrics (alternating positive/negative directions)
methods = ['Method A', 'Method B', 'Method C', 'Method D']
metrics = ['Score (+)', 'Error (-)', 'F1 (+)', 'Loss (-)']
matrix  = np.array([
    [0.88,  0.12,  0.85,  0.20],
    [0.81,  0.18,  0.78,  0.28],
    [0.75,  0.25,  0.72,  0.35],
    [0.70,  0.30,  0.68,  0.40],
])

fig, ax = plt.subplots(figsize=(10, 6))
n_rows, n_cols = matrix.shape
vmin, vmax = matrix.min(0), matrix.max(0)

for j in range(n_cols):
    is_positive = (j % 2 == 0)
    cmap = plt.cm.Reds if is_positive else plt.cm.Blues_r
    cmap = cmap.copy()
    norm = mpl.colors.Normalize(
        vmin=0 if is_positive else vmax[j],
        vmax=vmax[j] if is_positive else 0
    )
    ax.imshow(matrix[:, j:j+1], cmap=cmap, norm=norm,
              aspect='auto', extent=[j-0.5, j+0.5, 0, n_rows], origin='lower')

for (i, j), val in np.ndenumerate(matrix):
    is_positive = (j % 2 == 0)
    cmap = plt.cm.Reds if is_positive else plt.cm.Blues_r
    norm = mpl.colors.Normalize(vmin=0 if is_positive else vmax[j],
                                 vmax=vmax[j] if is_positive else 0)
    r, g, b, _ = cmap(norm(val))
    lum = 0.299*r + 0.587*g + 0.114*b
    color = 'white' if lum < 0.5 else 'black'
    ax.text(j, i + 0.5, f'{val:.2f}', ha='center', va='center',
            fontsize=13, color=color)

ax.set_xlim(-0.5, n_cols - 0.5)
ax.set_xticks(np.arange(n_cols))
ax.set_xticklabels(metrics, rotation=30, ha='right', fontsize=14)
ax.tick_params(axis='x', bottom=False, top=False, length=0)
ax.set_yticks(np.arange(n_rows) + 0.5)
ax.set_yticklabels(methods, fontsize=14)
ax.set_frame_on(False)
ax.invert_yaxis()

fig.tight_layout(pad=2)
os.makedirs('./figures', exist_ok=True)
fig.savefig('./figures/heatmap.png', dpi=300)
plt.close(fig)
```

---

## Related files

- [SKILL.md](../SKILL.md) — When to use this skill
- [api.md](api.md) — Reusable helper implementations
- [common-patterns.md](common-patterns.md) — Layout and encoding patterns used above
- [design-theory.md](design-theory.md) — Why these choices exist
- [chart-types.md](chart-types.md) — Radar, 3D sphere, scatter, fill_between
requirements.txt
PyMuPDF>=1.24.0,<2.0.0
scripts/audit_figure_collisions.py
#!/usr/bin/env python3
"""Audit rendered PDF figures for probable text and graphic collisions.

The audit is backend-neutral: Python and R figures are both checked from their
final PDF geometry. Reliable collisions (text-text, text crossed by a stroked
path, and text clipped by the page) block delivery. Partial text overlap with a
filled shape or raster image is reported for review because in-bar labels,
heatmap values, microscopy annotations, and scale bars can be intentional.

PyMuPDF is imported lazily so ``--self-test`` can exercise the geometry core in
minimal CI environments. Install the runtime dependency with
``python -m pip install -r skills/nature-figure/requirements.txt``.
"""

from __future__ import annotations

import argparse
import json
import math
import os
import sys
import tempfile
from dataclasses import asdict, dataclass, field
from itertools import combinations
from pathlib import Path
from typing import Any, Iterable, Sequence


Rect = tuple[float, float, float, float]
Point = tuple[float, float]
Segment = tuple[Point, Point]


@dataclass(frozen=True)
class TextBox:
    index: int
    text: str
    bbox: Rect


@dataclass(frozen=True)
class StrokePath:
    index: int
    bbox: Rect
    width: float
    segments: tuple[Segment, ...]


@dataclass(frozen=True)
class FilledRegion:
    index: int
    bbox: Rect
    source: str


@dataclass(frozen=True)
class TraceBox:
    index: int
    text: str
    bbox: Rect


@dataclass
class PageGeometry:
    page: int
    bbox: Rect
    texts: list[TextBox] = field(default_factory=list)
    traces: list[TraceBox] = field(default_factory=list)
    strokes: list[StrokePath] = field(default_factory=list)
    fills: list[FilledRegion] = field(default_factory=list)
    images: list[FilledRegion] = field(default_factory=list)


@dataclass(frozen=True)
class CollisionFinding:
    severity: str
    kind: str
    page: int
    message: str
    text: str
    text_bbox: Rect
    other_text: str | None = None
    other_bbox: Rect | None = None
    object_count: int = 1
    object_indexes: tuple[int, ...] = ()


def normalize_rect(rect: Sequence[float]) -> Rect:
    x0, y0, x1, y1 = (float(value) for value in rect)
    return min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)


def rect_width(rect: Rect) -> float:
    return max(0.0, rect[2] - rect[0])


def rect_height(rect: Rect) -> float:
    return max(0.0, rect[3] - rect[1])


def rect_area(rect: Rect) -> float:
    return rect_width(rect) * rect_height(rect)


def rect_intersection(first: Rect, second: Rect) -> Rect | None:
    x0 = max(first[0], second[0])
    y0 = max(first[1], second[1])
    x1 = min(first[2], second[2])
    y1 = min(first[3], second[3])
    if x1 <= x0 or y1 <= y0:
        return None
    return x0, y0, x1, y1


def rect_overlap_ratio(first: Rect, second: Rect) -> float:
    intersection = rect_intersection(first, second)
    if intersection is None:
        return 0.0
    denominator = min(rect_area(first), rect_area(second))
    return rect_area(intersection) / denominator if denominator > 0 else 0.0


def rect_contains(outer: Rect, inner: Rect, tolerance: float = 0.0) -> bool:
    return (
        inner[0] >= outer[0] - tolerance
        and inner[1] >= outer[1] - tolerance
        and inner[2] <= outer[2] + tolerance
        and inner[3] <= outer[3] + tolerance
    )


def rect_inset(rect: Rect, amount: float) -> Rect:
    maximum = max(0.0, min(rect_width(rect), rect_height(rect)) * 0.2)
    inset = min(max(0.0, amount), maximum)
    return rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset


def rect_expand(rect: Rect, amount: float) -> Rect:
    pad = max(0.0, amount)
    return rect[0] - pad, rect[1] - pad, rect[2] + pad, rect[3] + pad


def union_rects(rectangles: Iterable[Rect]) -> Rect | None:
    rows = list(rectangles)
    if not rows:
        return None
    return (
        min(row[0] for row in rows),
        min(row[1] for row in rows),
        max(row[2] for row in rows),
        max(row[3] for row in rows),
    )


def point_in_rect(point: Point, rect: Rect) -> bool:
    return rect[0] <= point[0] <= rect[2] and rect[1] <= point[1] <= rect[3]


def segment_intersects_rect(segment: Segment, rect: Rect) -> bool:
    """Return whether a line segment intersects a rectangle (Liang-Barsky)."""
    (x0, y0), (x1, y1) = segment
    if point_in_rect((x0, y0), rect) or point_in_rect((x1, y1), rect):
        return True
    dx = x1 - x0
    dy = y1 - y0
    p = (-dx, dx, -dy, dy)
    q = (x0 - rect[0], rect[2] - x0, y0 - rect[1], rect[3] - y0)
    lower, upper = 0.0, 1.0
    for denominator, numerator in zip(p, q):
        if abs(denominator) < 1e-12:
            if numerator < 0:
                return False
            continue
        ratio = numerator / denominator
        if denominator < 0:
            lower = max(lower, ratio)
        else:
            upper = min(upper, ratio)
        if lower > upper:
            return False
    return True


def cubic_segments(points: Sequence[Point], steps: int = 16) -> list[Segment]:
    if len(points) != 4:
        return []
    p0, p1, p2, p3 = points
    samples: list[Point] = []
    for index in range(steps + 1):
        t = index / steps
        u = 1.0 - t
        samples.append(
            (
                u**3 * p0[0] + 3 * u**2 * t * p1[0] + 3 * u * t**2 * p2[0] + t**3 * p3[0],
                u**3 * p0[1] + 3 * u**2 * t * p1[1] + 3 * u * t**2 * p2[1] + t**3 * p3[1],
            )
        )
    return list(zip(samples, samples[1:]))


def rectangle_segments(rect: Rect) -> tuple[Segment, ...]:
    x0, y0, x1, y1 = rect
    return (
        ((x0, y0), (x1, y0)),
        ((x1, y0), (x1, y1)),
        ((x1, y1), (x0, y1)),
        ((x0, y1), (x0, y0)),
    )


def _point(value: Any) -> Point:
    return float(value.x), float(value.y)


def _segments_from_items(items: Sequence[Sequence[Any]]) -> tuple[Segment, ...]:
    segments: list[Segment] = []
    for item in items:
        if not item:
            continue
        operator = item[0]
        if operator == "l" and len(item) >= 3:
            segments.append((_point(item[1]), _point(item[2])))
        elif operator == "c" and len(item) >= 5:
            segments.extend(cubic_segments([_point(value) for value in item[1:5]]))
        elif operator == "re" and len(item) >= 2:
            segments.extend(rectangle_segments(normalize_rect(tuple(item[1]))))
        elif operator == "qu" and len(item) >= 2:
            quad = item[1]
            points = [_point(quad.ul), _point(quad.ur), _point(quad.lr), _point(quad.ll)]
            segments.extend(zip(points, points[1:] + points[:1]))
    return tuple(segments)


def _text_from_chars(chars: Sequence[Sequence[Any]]) -> str:
    output: list[str] = []
    for char in chars:
        try:
            codepoint = int(char[0])
            output.append(chr(codepoint) if codepoint >= 0 else "�")
        except (TypeError, ValueError, OverflowError):
            output.append("�")
    return "".join(output).strip()


def extract_pdf_geometry(path: Path) -> list[PageGeometry]:
    try:
        import pymupdf as fitz  # type: ignore
    except ImportError:
        try:
            import fitz  # type: ignore
        except ImportError as exc:
            raise RuntimeError(
                "PyMuPDF is required for rendered collision QA. Install it with: "
                "python -m pip install -r skills/nature-figure/requirements.txt"
            ) from exc

    document = fitz.open(path)
    pages: list[PageGeometry] = []
    try:
        for page_index, page in enumerate(document, 1):
            geometry = PageGeometry(page=page_index, bbox=normalize_rect(tuple(page.rect)))

            trace_rows: list[TraceBox] = []
            for trace_index, trace in enumerate(page.get_texttrace()):
                text = _text_from_chars(trace.get("chars", ()))
                if not text:
                    continue
                trace_rows.append(
                    TraceBox(index=trace_index, text=text, bbox=normalize_rect(trace["bbox"]))
                )
            geometry.traces.extend(trace_rows)

            text_dict = page.get_text("dict")
            text_index = 0
            used_trace_indexes: set[int] = set()
            for block in text_dict.get("blocks", []):
                if block.get("type") != 0:
                    continue
                for line in block.get("lines", []):
                    spans = [span for span in line.get("spans", []) if span.get("text", "").strip()]
                    if not spans:
                        continue
                    loose_bbox = union_rects(normalize_rect(span["bbox"]) for span in spans)
                    if loose_bbox is None:
                        continue
                    matched_traces = [
                        trace
                        for trace in trace_rows
                        if trace.index not in used_trace_indexes
                        and rect_overlap_ratio(trace.bbox, loose_bbox) >= 0.3
                    ]
                    bbox = union_rects(trace.bbox for trace in matched_traces) or loose_bbox
                    used_trace_indexes.update(trace.index for trace in matched_traces)
                    text = "".join(span.get("text", "") for span in spans).strip()
                    geometry.texts.append(TextBox(index=text_index, text=text, bbox=bbox))
                    text_index += 1

            for trace in trace_rows:
                if trace.index in used_trace_indexes:
                    continue
                if rect_intersection(trace.bbox, geometry.bbox) is None:
                    continue
                geometry.texts.append(
                    TextBox(index=text_index, text=trace.text, bbox=trace.bbox)
                )
                text_index += 1

            for drawing_index, drawing in enumerate(page.get_drawings()):
                drawing_type = str(drawing.get("type", ""))
                drawing_bbox = normalize_rect(tuple(drawing["rect"]))
                if "s" in drawing_type and drawing.get("stroke_opacity", 1.0) not in (None, 0):
                    segments = _segments_from_items(drawing.get("items", ()))
                    if segments:
                        geometry.strokes.append(
                            StrokePath(
                                index=drawing_index,
                                bbox=drawing_bbox,
                                width=float(drawing.get("width") or 0.0),
                                segments=segments,
                            )
                        )
                if "f" in drawing_type and drawing.get("fill_opacity", 1.0) not in (None, 0):
                    rectangle_items = [item for item in drawing.get("items", ()) if item and item[0] == "re"]
                    if rectangle_items:
                        for offset, item in enumerate(rectangle_items):
                            geometry.fills.append(
                                FilledRegion(
                                    index=drawing_index * 1000 + offset,
                                    bbox=normalize_rect(tuple(item[1])),
                                    source="fill",
                                )
                            )
                    else:
                        geometry.fills.append(
                            FilledRegion(index=drawing_index, bbox=drawing_bbox, source="fill")
                        )

            image_index = 0
            seen_images: set[tuple[int, int, int, int]] = set()
            for image in page.get_images(full=True):
                xref = int(image[0])
                for image_rect in page.get_image_rects(xref):
                    bbox = normalize_rect(tuple(image_rect))
                    key = tuple(round(value * 10) for value in bbox)
                    if key in seen_images:
                        continue
                    seen_images.add(key)
                    geometry.images.append(
                        FilledRegion(index=image_index, bbox=bbox, source="image")
                    )
                    image_index += 1

            pages.append(geometry)
    finally:
        document.close()
    return pages


def _outside_distance(inner: Rect, outer: Rect) -> float:
    return max(
        outer[0] - inner[0],
        outer[1] - inner[1],
        inner[2] - outer[2],
        inner[3] - outer[3],
        0.0,
    )


def _finding_sort_key(finding: CollisionFinding) -> tuple[Any, ...]:
    severity_order = {"FAIL": 0, "WARN": 1}
    return severity_order.get(finding.severity, 2), finding.page, finding.kind, finding.text_bbox


def audit_page_geometry(
    page: PageGeometry,
    *,
    text_inset_pt: float = 0.6,
    min_overlap_ratio: float = 0.05,
    clipping_tolerance_pt: float = 0.5,
    background_area_ratio: float = 0.2,
) -> tuple[list[CollisionFinding], dict[str, int]]:
    findings: list[CollisionFinding] = []
    info = {"contained_fill_overlays": 0, "contained_image_overlays": 0}

    for first, second in combinations(page.texts, 2):
        ratio = rect_overlap_ratio(first.bbox, second.bbox)
        if ratio < min_overlap_ratio:
            continue
        findings.append(
            CollisionFinding(
                severity="FAIL",
                kind="text-text",
                page=page.page,
                message=f"Text boxes overlap by {ratio:.1%} of the smaller box",
                text=first.text,
                text_bbox=first.bbox,
                other_text=second.text,
                other_bbox=second.bbox,
            )
        )

    for text in page.texts:
        inner = rect_inset(text.bbox, text_inset_pt)
        hit_indexes: list[int] = []
        hit_rectangles: list[Rect] = []
        for stroke in page.strokes:
            expanded = rect_expand(inner, max(0.0, stroke.width) / 2)
            if rect_intersection(expanded, rect_expand(stroke.bbox, stroke.width / 2)) is None:
                continue
            if any(segment_intersects_rect(segment, expanded) for segment in stroke.segments):
                hit_indexes.append(stroke.index)
                hit_rectangles.append(stroke.bbox)
        if hit_indexes:
            findings.append(
                CollisionFinding(
                    severity="FAIL",
                    kind="text-stroke",
                    page=page.page,
                    message=f"Text is crossed by {len(hit_indexes)} stroked path(s)",
                    text=text.text,
                    text_bbox=text.bbox,
                    other_bbox=union_rects(hit_rectangles),
                    object_count=len(hit_indexes),
                    object_indexes=tuple(hit_indexes),
                )
            )

    page_area = max(rect_area(page.bbox), 1.0)
    for regions, kind, info_key in (
        (page.fills, "text-fill-edge", "contained_fill_overlays"),
        (page.images, "text-image-edge", "contained_image_overlays"),
    ):
        for text in page.texts:
            partial_indexes: list[int] = []
            partial_rectangles: list[Rect] = []
            for region in regions:
                if rect_area(region.bbox) / page_area >= background_area_ratio:
                    continue
                ratio = rect_overlap_ratio(text.bbox, region.bbox)
                if ratio < min_overlap_ratio:
                    continue
                if rect_contains(region.bbox, text.bbox, tolerance=0.25):
                    info[info_key] += 1
                    continue
                partial_indexes.append(region.index)
                partial_rectangles.append(region.bbox)
            if partial_indexes:
                label = "filled region" if kind == "text-fill-edge" else "raster image"
                findings.append(
                    CollisionFinding(
                        severity="WARN",
                        kind=kind,
                        page=page.page,
                        message=f"Text partially overlaps the edge of {len(partial_indexes)} {label}(s)",
                        text=text.text,
                        text_bbox=text.bbox,
                        other_bbox=union_rects(partial_rectangles),
                        object_count=len(partial_indexes),
                        object_indexes=tuple(partial_indexes),
                    )
                )

    clipped_seen: set[tuple[str, tuple[int, int, int, int]]] = set()
    for trace in page.traces:
        if _outside_distance(trace.bbox, page.bbox) <= clipping_tolerance_pt:
            continue
        key = (trace.text, tuple(round(value * 10) for value in trace.bbox))
        if key in clipped_seen:
            continue
        clipped_seen.add(key)
        findings.append(
            CollisionFinding(
                severity="FAIL",
                kind="text-page-clipping",
                page=page.page,
                message="Text extends beyond the final PDF page boundary",
                text=trace.text,
                text_bbox=trace.bbox,
                other_bbox=page.bbox,
                object_indexes=(trace.index,),
            )
        )

    return sorted(findings, key=_finding_sort_key), info


def audit_geometries(
    pages: Sequence[PageGeometry],
    *,
    text_inset_pt: float = 0.6,
    min_overlap_ratio: float = 0.05,
    clipping_tolerance_pt: float = 0.5,
) -> dict[str, Any]:
    findings: list[CollisionFinding] = []
    contained_fill_overlays = 0
    contained_image_overlays = 0
    for page in pages:
        page_findings, info = audit_page_geometry(
            page,
            text_inset_pt=text_inset_pt,
            min_overlap_ratio=min_overlap_ratio,
            clipping_tolerance_pt=clipping_tolerance_pt,
        )
        findings.extend(page_findings)
        contained_fill_overlays += info["contained_fill_overlays"]
        contained_image_overlays += info["contained_image_overlays"]

    fail_count = sum(finding.severity == "FAIL" for finding in findings)
    warn_count = sum(finding.severity == "WARN" for finding in findings)
    visible_text_count = sum(len(page.texts) for page in pages)
    trace_count = sum(len(page.traces) for page in pages)
    auditable = visible_text_count > 0 or trace_count > 0
    verdict = (
        "NOT AUDITABLE"
        if not auditable
        else "FIX BEFORE DELIVERY"
        if fail_count
        else "REVIEW REQUIRED"
        if warn_count
        else "PASS"
    )
    return {
        "auditable": auditable,
        "verdict": verdict,
        "page_count": len(pages),
        "visible_text_box_count": visible_text_count,
        "text_trace_count": trace_count,
        "stroke_path_count": sum(len(page.strokes) for page in pages),
        "filled_region_count": sum(len(page.fills) for page in pages),
        "image_region_count": sum(len(page.images) for page in pages),
        "summary": {
            "fail": fail_count,
            "warn": warn_count,
            "contained_fill_overlays": contained_fill_overlays,
            "contained_image_overlays": contained_image_overlays,
        },
        "thresholds": {
            "text_inset_pt": text_inset_pt,
            "min_overlap_ratio": min_overlap_ratio,
            "clipping_tolerance_pt": clipping_tolerance_pt,
        },
        "findings": [asdict(finding) for finding in sorted(findings, key=_finding_sort_key)],
    }


def audit_pdf(
    path: Path,
    *,
    text_inset_pt: float = 0.6,
    min_overlap_ratio: float = 0.05,
    clipping_tolerance_pt: float = 0.5,
) -> dict[str, Any]:
    pages = extract_pdf_geometry(path)
    return {
        "pdf": str(path),
        **audit_geometries(
            pages,
            text_inset_pt=text_inset_pt,
            min_overlap_ratio=min_overlap_ratio,
            clipping_tolerance_pt=clipping_tolerance_pt,
        ),
    }


def write_overlay_pdf(source: Path, destination: Path, findings: Sequence[dict[str, Any]]) -> None:
    try:
        import pymupdf as fitz  # type: ignore
    except ImportError:
        try:
            import fitz  # type: ignore
        except ImportError as exc:
            raise RuntimeError("PyMuPDF is required to write the diagnostic overlay PDF") from exc
    if source.resolve() == destination.resolve():
        raise ValueError("overlay PDF must not overwrite the source PDF")
    destination.parent.mkdir(parents=True, exist_ok=True)
    descriptor, temporary_name = tempfile.mkstemp(
        prefix=f".{destination.stem}.",
        suffix=".tmp.pdf",
        dir=destination.parent,
    )
    os.close(descriptor)
    temporary = Path(temporary_name)
    temporary.unlink()
    try:
        document = fitz.open(source)
        try:
            for finding in findings:
                page_number = int(finding["page"]) - 1
                if not 0 <= page_number < len(document):
                    continue
                page = document[page_number]
                color = (0.9, 0.05, 0.05) if finding["severity"] == "FAIL" else (1.0, 0.55, 0.0)
                page.draw_rect(fitz.Rect(finding["text_bbox"]), color=color, width=1.2, overlay=True)
                other_bbox = finding.get("other_bbox")
                if other_bbox:
                    page.draw_rect(
                        fitz.Rect(other_bbox),
                        color=color,
                        width=0.6,
                        dashes="[2 2] 0",
                        overlay=True,
                    )
            document.save(temporary)
        finally:
            document.close()
        temporary.replace(destination)
    finally:
        if temporary.exists():
            temporary.unlink()


def render_text(result: dict[str, Any], strict: bool = False) -> str:
    summary = result["summary"]
    lines = [
        "Nature Figure Rendered Collision Audit",
        f"pdf: {result.get('pdf', '<geometry fixture>')}",
        f"pages: {result['page_count']}",
        f"visible text boxes: {result['visible_text_box_count']}",
        f"stroke paths: {result['stroke_path_count']}",
        "",
    ]
    for finding in result["findings"]:
        lines.append(
            f"[{finding['severity']}] page {finding['page']} {finding['kind']}: "
            f"{finding['message']} — {finding['text']!r}"
        )
        if finding.get("other_text"):
            lines.append(f"  other text: {finding['other_text']!r}")
        lines.append(f"  text bbox: {finding['text_bbox']}")
    lines.extend(
        [
            "",
            f"summary: {summary['fail']} fail, {summary['warn']} warn, "
            f"{summary['contained_fill_overlays']} contained fill overlays, "
            f"{summary['contained_image_overlays']} contained image overlays",
            f"verdict: {result['verdict']}",
            "note: contained overlays can be intentional; WARN findings need final-size visual review",
        ]
    )
    if strict and summary["warn"]:
        lines.append("strict verdict: FIX BEFORE DELIVERY (WARN is blocking)")
    return "\n".join(lines)


def exit_code(result: dict[str, Any], strict: bool = False) -> int:
    if not result["auditable"]:
        return 2
    summary = result["summary"]
    if summary["fail"] or (strict and summary["warn"]):
        return 1
    return 0


def run_self_tests() -> None:
    clean = PageGeometry(
        page=1,
        bbox=(0, 0, 200, 120),
        texts=[TextBox(0, "Clear", (50, 20, 90, 32))],
        traces=[TraceBox(0, "Clear", (50, 20, 90, 32))],
        strokes=[StrokePath(0, (20, 70, 180, 70), 1.0, (((20, 70), (180, 70)),))],
    )
    clean_result = audit_geometries([clean])
    assert clean_result["verdict"] == "PASS", clean_result

    crossed = PageGeometry(
        page=1,
        bbox=(0, 0, 200, 120),
        texts=[TextBox(0, "Crossed", (50, 40, 100, 54))],
        traces=[TraceBox(0, "Crossed", (50, 40, 100, 54))],
        strokes=[StrokePath(0, (20, 47, 180, 47), 1.0, (((20, 47), (180, 47)),))],
    )
    crossed_result = audit_geometries([crossed])
    assert crossed_result["verdict"] == "FIX BEFORE DELIVERY", crossed_result
    assert crossed_result["findings"][0]["kind"] == "text-stroke", crossed_result

    intentional_fill = PageGeometry(
        page=1,
        bbox=(0, 0, 200, 120),
        texts=[TextBox(0, "Inside", (60, 40, 90, 52))],
        traces=[TraceBox(0, "Inside", (60, 40, 90, 52))],
        fills=[FilledRegion(0, (50, 30, 100, 60), "fill")],
    )
    fill_result = audit_geometries([intentional_fill])
    assert fill_result["verdict"] == "PASS", fill_result
    assert fill_result["summary"]["contained_fill_overlays"] == 1, fill_result


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("pdf", nargs="?", type=Path, help="final exported PDF figure")
    parser.add_argument("--json", action="store_true", help="print the report as JSON")
    parser.add_argument("--json-out", type=Path, help="write a machine-readable JSON report")
    parser.add_argument("--overlay-pdf", type=Path, help="write a QA-only PDF with collision boxes")
    parser.add_argument("--strict", action="store_true", help="treat WARN findings as blocking")
    parser.add_argument("--text-inset-pt", type=float, default=0.6)
    parser.add_argument("--min-overlap-ratio", type=float, default=0.05)
    parser.add_argument("--clipping-tolerance-pt", type=float, default=0.5)
    parser.add_argument("--self-test", action="store_true", help="run dependency-free geometry tests")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.self_test:
        run_self_tests()
        print("audit_figure_collisions.py self-test: PASS")
        return 0
    if args.pdf is None:
        print("error: PDF path is required unless --self-test is used", file=sys.stderr)
        return 2
    for name, value in (
        ("--text-inset-pt", args.text_inset_pt),
        ("--min-overlap-ratio", args.min_overlap_ratio),
        ("--clipping-tolerance-pt", args.clipping_tolerance_pt),
    ):
        if value < 0 or not math.isfinite(value):
            print(f"error: {name} must be a finite non-negative number", file=sys.stderr)
            return 2
    if not 0 <= args.min_overlap_ratio <= 1:
        print("error: --min-overlap-ratio must be between 0 and 1", file=sys.stderr)
        return 2
    try:
        result = audit_pdf(
            args.pdf,
            text_inset_pt=args.text_inset_pt,
            min_overlap_ratio=args.min_overlap_ratio,
            clipping_tolerance_pt=args.clipping_tolerance_pt,
        )
        if args.json_out:
            args.json_out.parent.mkdir(parents=True, exist_ok=True)
            args.json_out.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
        if args.overlay_pdf and result["findings"]:
            write_overlay_pdf(args.pdf, args.overlay_pdf, result["findings"])
    except (OSError, RuntimeError, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    print(json.dumps(result, indent=2, ensure_ascii=False) if args.json else render_text(result, args.strict))
    return exit_code(result, args.strict)


if __name__ == "__main__":
    raise SystemExit(main())
scripts/audit_panel_alignment.py
#!/usr/bin/env python3
"""Audit multi-panel figure alignment from rendered layout geometry.

The core auditor consumes a small backend-neutral JSON manifest. Matplotlib
figures can call :func:`require_matplotlib_panel_alignment` directly after the
final layout draw. R/patchwork figures use ``panel_alignment.R`` to export the
same manifest and then invoke this CLI.

This gate checks the plot-area rectangles that readers perceive as panels. It
does not infer scientific equivalence: comparable row and column groups come
from Matplotlib SubplotSpec metadata, patchwork/gtable metadata, or explicit
groups supplied by the plotting script. Horizontal rows of three or four equal
grid spans must also have equal final physical widths. Asymmetric hero panels,
insets and colorbars must be excluded or exempted with a recorded reason rather
than silently weakening the tolerance.
"""

from __future__ import annotations

import argparse
import html
import json
import math
import os
import re
import tempfile
from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence
from pathlib import Path
from typing import Any


SCHEMA_VERSION = 1
DEFAULT_TOLERANCE_PT = 1.5
DEFAULT_GUTTER_TOLERANCE_PT = 1.5


class PanelAlignmentError(RuntimeError):
    """Raised when a required in-memory alignment gate does not pass."""


def _atomic_write_text(path: Path, content: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    descriptor, temporary_name = tempfile.mkstemp(
        prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
    )
    temporary = Path(temporary_name)
    try:
        with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
            handle.write(content)
        temporary.replace(path)
    except Exception:
        temporary.unlink(missing_ok=True)
        raise


def _bbox(value: Any) -> tuple[float, float, float, float]:
    if isinstance(value, Mapping):
        values = [value.get(key) for key in ("left", "bottom", "right", "top")]
    else:
        values = list(value) if isinstance(value, Sequence) else []
    if len(values) != 4:
        raise ValueError("panel bbox_pt must contain left, bottom, right and top")
    bbox = tuple(float(item) for item in values)
    if not all(math.isfinite(item) for item in bbox):
        raise ValueError("panel bbox_pt values must be finite")
    if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
        raise ValueError("panel bbox_pt must have positive width and height")
    return bbox


def _group_rows(raw: Any, prefix: str) -> list[dict[str, Any]]:
    groups: list[dict[str, Any]] = []
    if raw is None:
        return groups
    if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
        raise ValueError(f"{prefix}_groups must be a list")
    for index, item in enumerate(raw, 1):
        if isinstance(item, Mapping):
            panels = item.get("panels", [])
            group_id = str(item.get("id") or f"{prefix}-{index}")
        else:
            panels = item
            group_id = f"{prefix}-{index}"
        if not isinstance(panels, Sequence) or isinstance(panels, (str, bytes)):
            raise ValueError(f"{prefix} group {group_id} must list panel ids")
        panel_ids = [str(panel) for panel in panels]
        if len(panel_ids) < 2:
            raise ValueError(f"{prefix} group {group_id} needs at least two panels")
        if len(panel_ids) != len(set(panel_ids)):
            raise ValueError(f"{prefix} group {group_id} repeats a panel id")
        groups.append({"id": group_id, "panels": panel_ids})
    return groups


def _inferred_groups(panels: Sequence[dict[str, Any]], orientation: str) -> list[dict[str, Any]]:
    grouped: dict[tuple[Any, ...], list[str]] = defaultdict(list)
    for panel in panels:
        if orientation == "row":
            fields = (panel.get("grid_id"), panel.get("row_start"), panel.get("row_stop"))
        else:
            fields = (panel.get("grid_id"), panel.get("col_start"), panel.get("col_stop"))
        if any(value is None for value in fields):
            continue
        grouped[fields].append(str(panel["id"]))
    output: list[dict[str, Any]] = []
    for index, (_key, panel_ids) in enumerate(grouped.items(), 1):
        if len(panel_ids) >= 2:
            output.append({"id": f"inferred-{orientation}-{index}", "panels": panel_ids})
    return output


def _inferred_boundary_groups(panels: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
    """Infer shared grid boundaries across panels with unequal spans.

    This covers layouts such as two stacked panels beside one panel that spans
    both rows. Equal-span panels are handled by the ordinary row/column groups,
    so boundary groups are emitted only when at least two different spans meet
    at the same grid boundary.
    """
    specifications = (
        ("top", "row_start", "row_start", "row_stop"),
        ("bottom", "row_stop", "row_start", "row_stop"),
        ("left", "col_start", "col_start", "col_stop"),
        ("right", "col_stop", "col_start", "col_stop"),
    )
    output: list[dict[str, Any]] = []
    for edge, boundary_field, span_start, span_stop in specifications:
        grouped: dict[tuple[Any, Any], list[dict[str, Any]]] = defaultdict(list)
        for panel in panels:
            grid_id = panel.get("grid_id")
            boundary = panel.get(boundary_field)
            start = panel.get(span_start)
            stop = panel.get(span_stop)
            if any(value is None for value in (grid_id, boundary, start, stop)):
                continue
            grouped[(grid_id, boundary)].append(panel)
        edge_index = 0
        for (_grid_id, _boundary), members in grouped.items():
            spans = {(panel.get(span_start), panel.get(span_stop)) for panel in members}
            if len(members) < 2 or len(spans) < 2:
                continue
            edge_index += 1
            output.append(
                {
                    "id": f"inferred-shared-{edge}-{edge_index}",
                    "edge": edge,
                    "panels": [str(panel["id"]) for panel in members],
                }
            )
    return output


def _validated_layout(manifest: Mapping[str, Any]) -> tuple[dict[str, Any] | None, list[str]]:
    errors: list[str] = []
    if manifest.get("schema_version") != SCHEMA_VERSION:
        errors.append(f"schema_version must be {SCHEMA_VERSION}")

    figure = manifest.get("figure")
    if not isinstance(figure, Mapping):
        errors.append("figure must be an object with width_pt and height_pt")
        figure = {}
    try:
        width_pt = float(figure.get("width_pt"))
        height_pt = float(figure.get("height_pt"))
        if not math.isfinite(width_pt) or not math.isfinite(height_pt) or width_pt <= 0 or height_pt <= 0:
            raise ValueError
    except (TypeError, ValueError):
        errors.append("figure width_pt and height_pt must be positive finite numbers")
        width_pt = height_pt = 0.0

    raw_panels = manifest.get("panels")
    panels: list[dict[str, Any]] = []
    if not isinstance(raw_panels, Sequence) or isinstance(raw_panels, (str, bytes)):
        errors.append("panels must be a list")
        raw_panels = []
    for index, raw_panel in enumerate(raw_panels, 1):
        if not isinstance(raw_panel, Mapping):
            errors.append(f"panel {index} must be an object")
            continue
        panel_id = str(raw_panel.get("id") or "").strip()
        if not panel_id:
            errors.append(f"panel {index} is missing id")
            continue
        try:
            bbox = _bbox(raw_panel.get("bbox_pt"))
        except (TypeError, ValueError) as exc:
            errors.append(f"panel {panel_id}: {exc}")
            continue
        if bbox[0] < -0.01 or bbox[1] < -0.01 or bbox[2] > width_pt + 0.01 or bbox[3] > height_pt + 0.01:
            errors.append(f"panel {panel_id} bbox_pt extends beyond the declared figure")
        panel = dict(raw_panel)
        panel["id"] = panel_id
        panel["bbox_pt"] = list(bbox)
        anchor = raw_panel.get("panel_label_anchor_pt")
        if anchor is not None:
            try:
                anchor_values = [float(value) for value in anchor]
                if len(anchor_values) != 2 or not all(math.isfinite(value) for value in anchor_values):
                    raise ValueError
                panel["panel_label_anchor_pt"] = anchor_values
            except (TypeError, ValueError):
                errors.append(f"panel {panel_id} has invalid panel_label_anchor_pt")
        panels.append(panel)

    panel_ids = [panel["id"] for panel in panels]
    if len(panel_ids) != len(set(panel_ids)):
        errors.append("panel ids must be unique")
    if not panels:
        errors.append("at least one panel rectangle is required")

    try:
        row_groups = _group_rows(manifest.get("row_groups"), "row")
        column_groups = _group_rows(manifest.get("column_groups"), "column")
    except ValueError as exc:
        errors.append(str(exc))
        row_groups = column_groups = []
    if not row_groups:
        row_groups = _inferred_groups(panels, "row")
    if not column_groups:
        column_groups = _inferred_groups(panels, "column")
    boundary_groups = _inferred_boundary_groups(panels)

    known_ids = set(panel_ids)
    for group in [*row_groups, *column_groups]:
        missing = [panel for panel in group["panels"] if panel not in known_ids]
        if missing:
            errors.append(f"group {group['id']} references unknown panels: {', '.join(missing)}")

    exemptions: list[dict[str, Any]] = []
    raw_exemptions = manifest.get("exemptions", [])
    if not isinstance(raw_exemptions, Sequence) or isinstance(raw_exemptions, (str, bytes)):
        errors.append("exemptions must be a list")
        raw_exemptions = []
    for index, exemption in enumerate(raw_exemptions, 1):
        if not isinstance(exemption, Mapping):
            errors.append(f"exemption {index} must be an object")
            continue
        exemption_panels = exemption.get("panels", [])
        checks = exemption.get("checks", [])
        reason = str(exemption.get("reason") or "").strip()
        if isinstance(exemption_panels, str):
            exemption_panels = [exemption_panels]
        if isinstance(checks, str):
            checks = [checks]
        if not exemption_panels or any(str(panel) not in known_ids for panel in exemption_panels):
            errors.append(f"exemption {index} must reference known panels")
        allowed_checks = {
            "row",
            "column",
            "panel-width",
            "horizontal-gutter",
            "vertical-gutter",
            "panel-label",
            "all",
        }
        check_names = [str(check) for check in checks]
        if not check_names or any(check not in allowed_checks for check in check_names):
            errors.append(f"exemption {index} has invalid checks")
        if not reason:
            errors.append(f"exemption {index} needs a non-empty reason")
        exemptions.append(
            {"panels": [str(panel) for panel in exemption_panels], "checks": check_names, "reason": reason}
        )

    if errors:
        return None, errors
    return {
        "schema_version": SCHEMA_VERSION,
        "backend": str(manifest.get("backend") or "unknown"),
        "figure": {"width_pt": width_pt, "height_pt": height_pt},
        "panels": panels,
        "row_groups": row_groups,
        "column_groups": column_groups,
        "boundary_groups": boundary_groups,
        "exemptions": exemptions,
    }, []


def _is_exempt(layout: Mapping[str, Any], panel_id: str, check: str) -> bool:
    for exemption in layout.get("exemptions", []):
        if panel_id in exemption["panels"] and (check in exemption["checks"] or "all" in exemption["checks"]):
            return True
    return False


def _metric_spread(values: Mapping[str, float]) -> float:
    return max(values.values()) - min(values.values()) if values else 0.0


def _finding(
    kind: str,
    group: Mapping[str, Any],
    panels: Sequence[str],
    tolerance_pt: float,
    metrics: Mapping[str, Mapping[str, float]],
    message: str,
    severity: str = "FAIL",
) -> dict[str, Any]:
    return {
        "severity": severity,
        "kind": kind,
        "group": group["id"],
        "panels": list(panels),
        "message": message,
        "tolerance_pt": tolerance_pt,
        "metric_spreads_pt": {
            metric: round(_metric_spread(values), 6) for metric, values in metrics.items()
        },
        "values_pt": {
            metric: {panel: round(value, 6) for panel, value in values.items()}
            for metric, values in metrics.items()
        },
    }


def audit_layout_manifest(
    manifest: Mapping[str, Any],
    *,
    tolerance_pt: float = DEFAULT_TOLERANCE_PT,
    gutter_tolerance_pt: float = DEFAULT_GUTTER_TOLERANCE_PT,
    require_panel_labels: bool = False,
) -> dict[str, Any]:
    """Audit a backend-neutral panel-layout manifest."""
    if tolerance_pt < 0 or gutter_tolerance_pt < 0:
        raise ValueError("alignment tolerances must be non-negative")
    layout, errors = _validated_layout(manifest)
    if layout is None:
        return {
            "schema_version": SCHEMA_VERSION,
            "auditable": False,
            "verdict": "NOT AUDITABLE",
            "summary": {"fail": 0, "warn": 0, "comparisons": 0, "exemptions": 0},
            "errors": errors,
            "findings": [],
        }

    if len(layout["panels"]) == 1:
        return {
            "schema_version": SCHEMA_VERSION,
            "applicable": False,
            "auditable": False,
            "verdict": "NOT APPLICABLE",
            "backend": layout["backend"],
            "summary": {"fail": 0, "warn": 0, "comparisons": 0, "exemptions": 0},
            "tolerances": {
                "alignment_pt": tolerance_pt,
                "gutter_pt": gutter_tolerance_pt,
            },
            "errors": [],
            "findings": [],
            "layout": layout,
        }

    panel_map = {panel["id"]: panel for panel in layout["panels"]}
    findings: list[dict[str, Any]] = []
    comparisons = 0

    for orientation, group_key, check_name in (
        ("row", "row_groups", "row"),
        ("column", "column_groups", "column"),
    ):
        for group in layout[group_key]:
            group_panel_ids = list(group["panels"])
            panel_ids = [
                panel_id for panel_id in group_panel_ids if not _is_exempt(layout, panel_id, check_name)
            ]
            if len(panel_ids) < 2:
                continue
            comparisons += 1
            boxes = {panel_id: _bbox(panel_map[panel_id]["bbox_pt"]) for panel_id in panel_ids}
            if orientation == "row":
                metrics = {
                    "top": {panel: box[3] for panel, box in boxes.items()},
                    "bottom": {panel: box[1] for panel, box in boxes.items()},
                    "height": {panel: box[3] - box[1] for panel, box in boxes.items()},
                }
                kind = "row-axes-misalignment"
                message = "Panels intended for one row do not share top/bottom edges and height"
            else:
                metrics = {
                    "left": {panel: box[0] for panel, box in boxes.items()},
                    "right": {panel: box[2] for panel, box in boxes.items()},
                    "width": {panel: box[2] - box[0] for panel, box in boxes.items()},
                }
                kind = "column-axes-misalignment"
                message = "Panels intended for one column do not share left/right edges and width"
            if max(_metric_spread(values) for values in metrics.values()) > tolerance_pt:
                findings.append(_finding(kind, group, panel_ids, tolerance_pt, metrics, message))

            if orientation == "row" and len(group_panel_ids) >= 3:
                width_candidates = [
                    panel_id
                    for panel_id in group_panel_ids
                    if not _is_exempt(layout, panel_id, "row")
                    and not _is_exempt(layout, panel_id, "panel-width")
                ]
                by_column_span: dict[Any, list[str]] = defaultdict(list)
                for panel_id in width_candidates:
                    panel = panel_map[panel_id]
                    try:
                        column_span = float(panel.get("col_stop")) - float(panel.get("col_start"))
                        if not math.isfinite(column_span) or column_span <= 0:
                            raise ValueError
                        span_key: Any = round(column_span, 9)
                    except (TypeError, ValueError):
                        span_key = "unspecified"
                    by_column_span[span_key].append(panel_id)
                for width_index, width_ids in enumerate(by_column_span.values(), 1):
                    if len(width_ids) < 2:
                        continue
                    comparisons += 1
                    width_metrics = {
                        "width": {
                            panel_id: _bbox(panel_map[panel_id]["bbox_pt"])[2]
                            - _bbox(panel_map[panel_id]["bbox_pt"])[0]
                            for panel_id in width_ids
                        }
                    }
                    if _metric_spread(width_metrics["width"]) > tolerance_pt:
                        findings.append(
                            _finding(
                                "horizontal-panel-width-misalignment",
                                {"id": f"{group['id']}-equal-span-{width_index}"},
                                width_ids,
                                tolerance_pt,
                                width_metrics,
                                "Three-or-more-panel row contains unequal final plot-area widths for equal grid spans",
                            )
                        )

            anchors = {
                panel_id: panel_map[panel_id].get("panel_label_anchor_pt") for panel_id in panel_ids
            }
            present_anchors = {panel: anchor for panel, anchor in anchors.items() if anchor is not None}
            missing_labels = [panel for panel, anchor in anchors.items() if anchor is None]
            if require_panel_labels and missing_labels:
                findings.append(
                    {
                        "severity": "WARN",
                        "kind": "panel-label-not-auditable",
                        "group": group["id"],
                        "panels": missing_labels,
                        "message": "Comparable panels are missing detectable top-left panel-label anchors",
                    }
                )
            if len(present_anchors) >= 2:
                label_check = "panel-label"
                label_ids = [
                    panel for panel in panel_ids if panel in present_anchors and not _is_exempt(layout, panel, label_check)
                ]
                if len(label_ids) >= 2:
                    coordinate = 1 if orientation == "row" else 0
                    metric_name = "label-y" if orientation == "row" else "label-x"
                    metrics = {
                        metric_name: {panel: float(present_anchors[panel][coordinate]) for panel in label_ids}
                    }
                    if _metric_spread(metrics[metric_name]) > tolerance_pt:
                        findings.append(
                            _finding(
                                f"{orientation}-panel-label-misalignment",
                                group,
                                label_ids,
                                tolerance_pt,
                                metrics,
                                "Panel-label anchors are not aligned within their comparable group",
                            )
                        )

            gutter_check = "horizontal-gutter" if orientation == "row" else "vertical-gutter"
            gutter_ids = [panel for panel in panel_ids if not _is_exempt(layout, panel, gutter_check)]
            if len(gutter_ids) >= 2:
                if orientation == "row":
                    ordered = sorted(gutter_ids, key=lambda panel: boxes[panel][0])
                    gaps = {
                        f"{first}->{second}": boxes[second][0] - boxes[first][2]
                        for first, second in zip(ordered, ordered[1:])
                    }
                else:
                    ordered = sorted(gutter_ids, key=lambda panel: boxes[panel][3], reverse=True)
                    gaps = {
                        f"{first}->{second}": boxes[first][1] - boxes[second][3]
                        for first, second in zip(ordered, ordered[1:])
                    }
                if gaps and min(gaps.values()) < -tolerance_pt:
                    findings.append(
                        _finding(
                            "panel-axes-overlap",
                            group,
                            gutter_ids,
                            tolerance_pt,
                            {"gutter": gaps},
                            "Panel plot-area rectangles overlap",
                        )
                    )
                elif len(gaps) >= 2 and _metric_spread(gaps) > gutter_tolerance_pt:
                    findings.append(
                        _finding(
                            f"{gutter_check}-misalignment",
                            group,
                            gutter_ids,
                            gutter_tolerance_pt,
                            {"gutter": gaps},
                            "Comparable inter-panel gutters are not uniform",
                        )
                    )

    edge_coordinates = {"left": 0, "bottom": 1, "right": 2, "top": 3}
    for group in layout["boundary_groups"]:
        edge = str(group["edge"])
        check_name = "row" if edge in {"top", "bottom"} else "column"
        panel_ids = [
            panel_id
            for panel_id in group["panels"]
            if not _is_exempt(layout, panel_id, check_name)
        ]
        if len(panel_ids) < 2:
            continue
        comparisons += 1
        coordinate = edge_coordinates[edge]
        metrics = {
            edge: {
                panel_id: _bbox(panel_map[panel_id]["bbox_pt"])[coordinate]
                for panel_id in panel_ids
            }
        }
        if _metric_spread(metrics[edge]) > tolerance_pt:
            findings.append(
                _finding(
                    f"shared-{edge}-edge-misalignment",
                    group,
                    panel_ids,
                    tolerance_pt,
                    metrics,
                    f"Panels meeting the same grid {edge} boundary are not aligned",
                )
            )

        if edge not in {"top", "left"}:
            continue
        anchors = {
            panel_id: panel_map[panel_id].get("panel_label_anchor_pt")
            for panel_id in panel_ids
        }
        missing_labels = [panel_id for panel_id, anchor in anchors.items() if anchor is None]
        if require_panel_labels and missing_labels:
            findings.append(
                {
                    "severity": "WARN",
                    "kind": "panel-label-not-auditable",
                    "group": group["id"],
                    "panels": missing_labels,
                    "message": "Comparable panels are missing detectable top-left panel-label anchors",
                }
            )
        label_ids = [
            panel_id
            for panel_id, anchor in anchors.items()
            if anchor is not None and not _is_exempt(layout, panel_id, "panel-label")
        ]
        if len(label_ids) < 2:
            continue
        label_coordinate = 1 if edge == "top" else 0
        metric_name = "label-y" if edge == "top" else "label-x"
        label_metrics = {
            metric_name: {
                panel_id: float(anchors[panel_id][label_coordinate])
                for panel_id in label_ids
            }
        }
        if _metric_spread(label_metrics[metric_name]) > tolerance_pt:
            findings.append(
                _finding(
                    f"shared-{edge}-panel-label-misalignment",
                    group,
                    label_ids,
                    tolerance_pt,
                    label_metrics,
                    "Panel-label anchors are not aligned at the shared grid boundary",
                )
            )
    fail_count = sum(finding["severity"] == "FAIL" for finding in findings)
    warn_count = sum(finding["severity"] == "WARN" for finding in findings)
    auditable = comparisons > 0
    verdict = "FIX BEFORE DELIVERY" if fail_count else "REVIEW REQUIRED" if warn_count else "PASS"
    if not auditable:
        verdict = "NOT AUDITABLE"
    return {
        "schema_version": SCHEMA_VERSION,
        "applicable": True,
        "auditable": auditable,
        "verdict": verdict,
        "backend": layout["backend"],
        "summary": {
            "fail": fail_count,
            "warn": warn_count,
            "comparisons": comparisons,
            "exemptions": len(layout["exemptions"]),
        },
        "tolerances": {
            "alignment_pt": tolerance_pt,
            "gutter_pt": gutter_tolerance_pt,
        },
        "layout": layout,
        "findings": findings,
        **(
            {"errors": ["No comparable row, column or shared-boundary groups were declared or inferred"]}
            if not auditable
            else {}
        ),
    }


def exit_code(report: Mapping[str, Any], strict: bool = False) -> int:
    if report.get("verdict") == "NOT APPLICABLE":
        return 0
    if not report.get("auditable") or report.get("verdict") == "NOT AUDITABLE":
        return 2
    if int(report.get("summary", {}).get("fail", 0)):
        return 1
    if strict and int(report.get("summary", {}).get("warn", 0)):
        return 1
    return 0


def render_text(report: Mapping[str, Any], strict: bool = False) -> str:
    summary = report.get("summary", {})
    lines = [
        f"Panel alignment: {report.get('verdict', 'UNKNOWN')}",
        f"  comparisons: {summary.get('comparisons', 0)}",
        f"  fail: {summary.get('fail', 0)}",
        f"  warn: {summary.get('warn', 0)}",
        f"  exemptions: {summary.get('exemptions', 0)}",
    ]
    for error in report.get("errors", []):
        lines.append(f"  ERROR: {error}")
    for finding in report.get("findings", []):
        lines.append(
            f"  {finding['severity']} {finding['kind']} [{finding.get('group', 'n/a')}]: "
            f"{finding['message']} ({', '.join(finding.get('panels', []))})"
        )
    if strict and summary.get("warn", 0):
        lines.append("  strict mode: WARN findings block delivery")
    return "\n".join(lines)


def write_json_report(report: Mapping[str, Any], path: str | Path) -> None:
    _atomic_write_text(Path(path), json.dumps(report, indent=2, ensure_ascii=False) + "\n")


def write_overlay_svg(report: Mapping[str, Any], path: str | Path) -> None:
    layout = report.get("layout")
    if not isinstance(layout, Mapping):
        raise ValueError("an auditable layout is required for the diagnostic SVG")
    width = float(layout["figure"]["width_pt"])
    height = float(layout["figure"]["height_pt"])
    failed_panels = {
        panel for finding in report.get("findings", []) if finding.get("severity") == "FAIL"
        for panel in finding.get("panels", [])
    }
    rows = [
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}pt" height="{height}pt" viewBox="0 0 {width} {height}">',
        '<rect width="100%" height="100%" fill="white"/>',
    ]
    for panel in layout["panels"]:
        left, bottom, right, top = _bbox(panel["bbox_pt"])
        y = height - top
        color = "#d7191c" if panel["id"] in failed_panels else "#2c7bb6"
        rows.append(
            f'<rect x="{left:.3f}" y="{y:.3f}" width="{right-left:.3f}" height="{top-bottom:.3f}" '
            f'fill="none" stroke="{color}" stroke-width="1.2"/>'
        )
        rows.append(
            f'<text x="{left+2:.3f}" y="{y+10:.3f}" font-family="Arial, sans-serif" font-size="8" '
            f'fill="{color}">{html.escape(str(panel["id"]))}</text>'
        )
    rows.append(
        f'<text x="4" y="{height-5:.3f}" font-family="Arial, sans-serif" font-size="7" fill="#333">'
        f'{report.get("verdict", "UNKNOWN")}: {report.get("summary", {}).get("fail", 0)} fail, '
        f'{report.get("summary", {}).get("warn", 0)} warn</text>'
    )
    rows.append("</svg>")
    _atomic_write_text(Path(path), "\n".join(rows) + "\n")


def _alphabetic_id(index: int) -> str:
    output = ""
    value = index + 1
    while value:
        value, remainder = divmod(value - 1, 26)
        output = chr(ord("a") + remainder) + output
    return output


def _font_is_bold(weight: Any) -> bool:
    if isinstance(weight, (int, float)):
        return float(weight) >= 600
    return str(weight).lower() in {"bold", "semibold", "demibold", "heavy", "black"}


def _matplotlib_panel_label_anchor(ax: Any) -> tuple[str, list[float]] | None:
    for artist in ax.texts:
        label = artist.get_text().strip()
        if not re.fullmatch(r"[a-z]", label) or not _font_is_bold(artist.get_fontweight()):
            continue
        display = artist.get_transform().transform(artist.get_position())
        axes_xy = ax.transAxes.inverted().transform(display)
        if not (-0.35 <= axes_xy[0] <= 0.2 and 0.8 <= axes_xy[1] <= 1.35):
            continue
        inches = ax.figure.dpi_scale_trans.inverted().transform(display)
        return label, [float(inches[0] * 72), float(inches[1] * 72)]
    return None


def matplotlib_layout_manifest(
    fig: Any,
    *,
    axes: Sequence[Any] | None = None,
    panel_ids: Mapping[Any, str] | Sequence[str] | None = None,
    row_groups: Sequence[Any] | None = None,
    column_groups: Sequence[Any] | None = None,
    exclude_axes: Iterable[Any] = (),
    exemptions: Sequence[Mapping[str, Any]] = (),
) -> dict[str, Any]:
    """Measure final Matplotlib axes rectangles in physical points."""
    fig.canvas.draw()
    width_in, height_in = (float(value) for value in fig.get_size_inches())
    width_pt, height_pt = width_in * 72, height_in * 72
    excluded = {id(axis) for axis in exclude_axes}
    candidates = list(axes) if axes is not None else list(fig.axes)
    selected: list[Any] = []
    seen_subplot_cells: set[tuple[int, int, int]] = set()
    for axis in candidates:
        if id(axis) in excluded or not axis.get_visible():
            continue
        axis_label = str(axis.get_label() or "")
        if axis_label.startswith("<colorbar"):
            continue
        try:
            subplot_spec = axis.get_subplotspec()
        except AttributeError:
            subplot_spec = None
        if subplot_spec is not None and panel_ids is None:
            subplot_key = (
                id(subplot_spec.get_gridspec()),
                int(subplot_spec.num1),
                int(subplot_spec.num2),
            )
            if subplot_key in seen_subplot_cells:
                continue
            seen_subplot_cells.add(subplot_key)
        selected.append(axis)

    if isinstance(panel_ids, Mapping):
        ids = [str(panel_ids.get(axis) or "") for axis in selected]
    elif panel_ids is not None:
        ids = [str(value) for value in panel_ids]
        if len(ids) != len(selected):
            raise ValueError("panel_ids length must match the selected Matplotlib axes")
    else:
        ids = []
        for index, axis in enumerate(selected):
            candidate = str(axis.get_gid() or axis.get_label() or "").strip()
            ids.append(candidate if candidate and not candidate.startswith("<") else _alphabetic_id(index))

    grid_ids: dict[int, str] = {}
    panels: list[dict[str, Any]] = []
    for axis, panel_id in zip(selected, ids):
        position = axis.get_position(original=False)
        panel: dict[str, Any] = {
            "id": panel_id,
            "bbox_pt": [
                float(position.x0 * width_pt),
                float(position.y0 * height_pt),
                float(position.x1 * width_pt),
                float(position.y1 * height_pt),
            ],
        }
        try:
            subplot_spec = axis.get_subplotspec()
        except AttributeError:
            subplot_spec = None
        if subplot_spec is not None:
            grid_spec = subplot_spec.get_gridspec()
            grid_key = id(grid_spec)
            if grid_key not in grid_ids:
                grid_ids[grid_key] = f"matplotlib-grid-{len(grid_ids) + 1}"
            panel.update(
                {
                    "grid_id": grid_ids[grid_key],
                    "row_start": int(subplot_spec.rowspan.start),
                    "row_stop": int(subplot_spec.rowspan.stop),
                    "col_start": int(subplot_spec.colspan.start),
                    "col_stop": int(subplot_spec.colspan.stop),
                }
            )
        label_anchor = _matplotlib_panel_label_anchor(axis)
        if label_anchor is not None:
            panel["panel_label"] = label_anchor[0]
            panel["panel_label_anchor_pt"] = label_anchor[1]
        panels.append(panel)

    manifest: dict[str, Any] = {
        "schema_version": SCHEMA_VERSION,
        "backend": "python-matplotlib",
        "figure": {"width_pt": width_pt, "height_pt": height_pt},
        "panels": panels,
        "exemptions": [dict(exemption) for exemption in exemptions],
    }
    if row_groups is not None:
        manifest["row_groups"] = list(row_groups)
    if column_groups is not None:
        manifest["column_groups"] = list(column_groups)
    return manifest


def require_matplotlib_panel_alignment(
    fig: Any,
    *,
    json_out: str | Path | None = None,
    overlay_svg: str | Path | None = None,
    tolerance_pt: float = DEFAULT_TOLERANCE_PT,
    gutter_tolerance_pt: float = DEFAULT_GUTTER_TOLERANCE_PT,
    require_panel_labels: bool = False,
    strict: bool = False,
    **manifest_options: Any,
) -> dict[str, Any]:
    """Measure and block delivery when a Matplotlib multi-panel layout is misaligned."""
    manifest = matplotlib_layout_manifest(fig, **manifest_options)
    report = audit_layout_manifest(
        manifest,
        tolerance_pt=tolerance_pt,
        gutter_tolerance_pt=gutter_tolerance_pt,
        require_panel_labels=require_panel_labels,
    )
    if json_out is not None:
        write_json_report(report, json_out)
    if overlay_svg is not None and report.get("layout"):
        write_overlay_svg(report, overlay_svg)
    code = exit_code(report, strict=strict)
    if code:
        raise PanelAlignmentError(render_text(report, strict=strict))
    return report


def run_self_tests() -> None:
    aligned = {
        "schema_version": 1,
        "backend": "self-test",
        "figure": {"width_pt": 300, "height_pt": 200},
        "panels": [
            {"id": "a", "bbox_pt": [20, 110, 130, 180], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 0, "col_stop": 1},
            {"id": "b", "bbox_pt": [170, 110, 280, 180], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 1, "col_stop": 2},
            {"id": "c", "bbox_pt": [20, 20, 130, 90], "grid_id": "g", "row_start": 1, "row_stop": 2, "col_start": 0, "col_stop": 1},
            {"id": "d", "bbox_pt": [170, 20, 280, 90], "grid_id": "g", "row_start": 1, "row_stop": 2, "col_start": 1, "col_stop": 2},
        ],
    }
    if exit_code(audit_layout_manifest(aligned)) != 0:
        raise AssertionError("aligned self-test layout did not pass")
    shifted = json.loads(json.dumps(aligned))
    shifted["panels"][1]["bbox_pt"][1] -= 5
    if exit_code(audit_layout_manifest(shifted)) != 1:
        raise AssertionError("shifted self-test layout did not fail")
    unequal_widths = {
        "schema_version": 1,
        "backend": "self-test",
        "figure": {"width_pt": 320, "height_pt": 100},
        "panels": [
            {"id": "a", "bbox_pt": [10, 20, 70, 80], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 0, "col_stop": 1},
            {"id": "b", "bbox_pt": [90, 20, 170, 80], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 1, "col_stop": 2},
            {"id": "c", "bbox_pt": [190, 20, 290, 80], "grid_id": "g", "row_start": 0, "row_stop": 1, "col_start": 2, "col_stop": 3},
        ],
    }
    unequal_report = audit_layout_manifest(unequal_widths)
    if exit_code(unequal_report) != 1 or not any(
        finding.get("kind") == "horizontal-panel-width-misalignment"
        for finding in unequal_report.get("findings", [])
    ):
        raise AssertionError("unequal-width self-test row did not fail")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Audit rendered multi-panel axes alignment from a JSON layout manifest."
    )
    parser.add_argument("layout", nargs="?", help="backend-neutral panel-layout JSON")
    parser.add_argument("--json", action="store_true", help="print the report as JSON")
    parser.add_argument("--json-out", help="write the report atomically as JSON")
    parser.add_argument("--overlay-svg", help="write a QA-only SVG of measured panel rectangles")
    parser.add_argument("--tolerance-pt", type=float, default=DEFAULT_TOLERANCE_PT)
    parser.add_argument("--gutter-tolerance-pt", type=float, default=DEFAULT_GUTTER_TOLERANCE_PT)
    parser.add_argument("--require-panel-labels", action="store_true")
    parser.add_argument("--strict", action="store_true", help="make WARN findings blocking")
    parser.add_argument("--self-test", action="store_true", help="run dependency-free core tests")
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    if args.self_test:
        run_self_tests()
        print("Panel alignment self-test: PASS")
        return 0
    if not args.layout:
        parser.error("layout JSON is required unless --self-test is used")
    try:
        manifest = json.loads(Path(args.layout).read_text(encoding="utf-8"))
        report = audit_layout_manifest(
            manifest,
            tolerance_pt=args.tolerance_pt,
            gutter_tolerance_pt=args.gutter_tolerance_pt,
            require_panel_labels=args.require_panel_labels,
        )
    except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
        report = {
            "schema_version": SCHEMA_VERSION,
            "auditable": False,
            "verdict": "NOT AUDITABLE",
            "summary": {"fail": 0, "warn": 0, "comparisons": 0, "exemptions": 0},
            "errors": [str(exc)],
            "findings": [],
        }
    if args.json_out:
        write_json_report(report, args.json_out)
    if args.overlay_svg and report.get("layout"):
        write_overlay_svg(report, args.overlay_svg)
    print(json.dumps(report, indent=2, ensure_ascii=False) if args.json else render_text(report, strict=args.strict))
    return exit_code(report, strict=args.strict)


if __name__ == "__main__":
    raise SystemExit(main())
scripts/audit_pdf_text.py
#!/usr/bin/env python3
"""Audit text font sizes used by PDF content-stream ``Tf`` operators.

This dependency-free check catches reduced mathtext superscripts/subscripts and
other glyph runs that can fall below a journal font-size floor even when the
parent matplotlib ``fontsize`` is compliant. It supports plain and FlateDecode
content streams, which covers normal matplotlib PDF output.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import zlib
from dataclasses import asdict, dataclass
from pathlib import Path


STREAM_START = re.compile(rb"stream\r?\n")
TF_OPERATOR = re.compile(
    rb"/([^\s/<>]+)\s+([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[Ee][-+]?\d+)?)\s+Tf\b"
)


@dataclass(frozen=True)
class TextRun:
    stream: int
    font: str
    size_pt: float


def decoded_streams(data: bytes) -> tuple[list[bytes], list[str]]:
    streams: list[bytes] = []
    warnings: list[str] = []
    cursor = 0
    stream_number = 0
    while True:
        match = STREAM_START.search(data, cursor)
        if not match:
            break
        stream_number += 1
        end = data.find(b"endstream", match.end())
        if end < 0:
            warnings.append(f"stream {stream_number} has no endstream marker")
            break
        # Keep the raw stream bytes. zlib accepts PDF's trailing line break,
        # while stripping could accidentally remove a legitimate compressed
        # byte that happens to equal CR or LF.
        payload = data[match.end() : end]
        header = data[max(0, match.start() - 2048) : match.start()]
        dictionary_start = header.rfind(b"<<")
        dictionary = header[dictionary_start:] if dictionary_start >= 0 else header
        if b"/FlateDecode" in dictionary:
            try:
                payload = zlib.decompress(payload)
            except zlib.error as exc:
                warnings.append(f"stream {stream_number} FlateDecode failed: {exc}")
                cursor = end + len(b"endstream")
                continue
        elif b"/Filter" in dictionary:
            warnings.append(f"stream {stream_number} uses an unsupported PDF filter")
            cursor = end + len(b"endstream")
            continue
        streams.append(payload)
        cursor = end + len(b"endstream")
    return streams, warnings


def audit_pdf(data: bytes, minimum_pt: float = 5.0) -> dict[str, object]:
    streams, warnings = decoded_streams(data)
    runs: list[TextRun] = []
    for stream_index, stream in enumerate(streams, 1):
        for match in TF_OPERATOR.finditer(stream):
            try:
                font = match.group(1).decode("ascii", errors="replace")
                size = float(match.group(2))
            except ValueError:
                continue
            if size > 0:
                runs.append(TextRun(stream=stream_index, font=font, size_pt=size))
    below = [run for run in runs if run.size_pt < minimum_pt]
    return {
        "auditable": bool(runs),
        "minimum_required_pt": minimum_pt,
        "minimum_found_pt": min((run.size_pt for run in runs), default=None),
        "text_run_count": len(runs),
        "below_minimum_count": len(below),
        "below_minimum": [asdict(run) for run in below],
        "warnings": warnings,
    }


def render_text(path: Path, result: dict[str, object]) -> str:
    lines = [
        "Nature Figure PDF Text Audit",
        f"pdf: {path}",
        f"minimum required: {result['minimum_required_pt']:g} pt",
    ]
    if not result["auditable"]:
        lines.append("verdict: NOT AUDITABLE — no supported Tf text operators were found")
    else:
        lines.extend(
            [
                f"minimum found: {result['minimum_found_pt']:g} pt",
                f"text runs: {result['text_run_count']}",
                f"below minimum: {result['below_minimum_count']}",
                f"verdict: {'FAIL' if result['below_minimum_count'] else 'PASS'}",
            ]
        )
    for run in result["below_minimum"]:
        lines.append(f"  - stream {run['stream']}: /{run['font']} {run['size_pt']:g} Tf")
    for warning in result["warnings"]:
        lines.append(f"warning: {warning}")
    lines.append("note: Tf scanning does not replace final-size visual inspection or account for every PDF transform")
    return "\n".join(lines)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("pdf", type=Path, help="exported PDF figure")
    parser.add_argument("--min-pt", type=float, default=5.0, help="minimum allowed Tf font size in points")
    parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.min_pt <= 0:
        print("error: --min-pt must be positive", file=sys.stderr)
        return 2
    try:
        data = args.pdf.read_bytes()
    except OSError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    if not data.startswith(b"%PDF-"):
        print(f"error: not a PDF file: {args.pdf}", file=sys.stderr)
        return 2
    result = audit_pdf(data, minimum_pt=args.min_pt)
    if args.json:
        print(json.dumps({"pdf": str(args.pdf), **result}, indent=2, ensure_ascii=False))
    else:
        print(render_text(args.pdf, result))
    if not result["auditable"]:
        return 2
    return 1 if result["below_minimum_count"] else 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/figure_safety.py
#!/usr/bin/env python3
"""Small numerical/layout safety helpers for Python publication figures."""

from __future__ import annotations

from typing import Any

import numpy as np


def interp_monotone(target: Any, xp: Any, fp: Any) -> Any:
    """Interpolate on a strictly monotone grid without silent direction errors.

    ``numpy.interp`` requires increasing ``xp``. This helper accepts a strictly
    increasing or decreasing grid, reverses decreasing ``xp`` and ``fp``
    together, and rejects duplicate/non-monotone coordinates.
    """
    xp_array = np.asarray(xp, dtype=float)
    fp_array = np.asarray(fp, dtype=float)
    if xp_array.ndim != 1 or fp_array.ndim != 1 or xp_array.size != fp_array.size:
        raise ValueError("xp and fp must be one-dimensional arrays of equal length")
    if xp_array.size < 2:
        raise ValueError("at least two interpolation points are required")
    if not np.all(np.isfinite(xp_array)) or not np.all(np.isfinite(fp_array)):
        raise ValueError("xp and fp must contain only finite values")
    differences = np.diff(xp_array)
    if np.all(differences > 0):
        ordered_xp, ordered_fp = xp_array, fp_array
    elif np.all(differences < 0):
        ordered_xp, ordered_fp = xp_array[::-1], fp_array[::-1]
    else:
        raise ValueError("xp must be strictly monotone; duplicates or direction changes are unsafe")
    return np.interp(target, ordered_xp, ordered_fp)


def label_y_above(values: Any, spread: Any | None = None, pad_fraction: float = 0.04) -> float:
    """Return a data-driven label y-position above values and optional spread."""
    centers = np.asarray(values, dtype=float)
    if centers.size == 0 or not np.all(np.isfinite(centers)):
        raise ValueError("values must contain at least one finite value")
    upper = centers if spread is None else centers + np.asarray(spread, dtype=float)
    if not np.all(np.isfinite(upper)):
        raise ValueError("spread must contain only finite values")
    data_min = float(np.min(centers))
    data_max = float(np.max(upper))
    scale = max(data_max - data_min, abs(data_max), 1.0)
    return data_max + max(0.0, pad_fraction) * scale


__all__ = ["interp_monotone", "label_y_above"]
scripts/generate_openrouter_schematic.py
#!/usr/bin/env python3
"""Generate manuscript schematic drafts with OpenRouter's Images API."""

from __future__ import annotations

import argparse
import base64
import json
import mimetypes
import os
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any


API_URL = "https://openrouter.ai/api/v1/images"
DEFAULT_MODEL = "openai/gpt-image-2"


def read_optional_text(text: str | None, path: str | None) -> str:
    parts: list[str] = []
    if text:
        parts.append(text.strip())
    if path:
        parts.append(Path(path).read_text(encoding="utf-8").strip())
    return "\n\n".join(part for part in parts if part)


def build_prompt(args: argparse.Namespace) -> str:
    custom_prompt = read_optional_text(args.prompt, args.prompt_file)
    if custom_prompt and args.raw:
        return custom_prompt

    title = args.title.strip() if args.title else ""
    abstract = read_optional_text(args.abstract, args.abstract_file)
    panel_map = args.panel_map.strip() if args.panel_map else ""

    content_blocks = []
    if title:
        content_blocks.append(f"Title: {title}")
    if abstract:
        content_blocks.append(f"Article summary:\n{abstract}")
    if panel_map:
        content_blocks.append(f"Desired panel flow:\n{panel_map}")
    if custom_prompt:
        content_blocks.append(f"Additional instructions:\n{custom_prompt}")

    if not content_blocks:
        raise SystemExit(
            "Provide --prompt/--prompt-file or at least one of --title, "
            "--abstract/--abstract-file, or --panel-map."
        )

    style = args.style or (
        "Create a clean Nature-style scientific graphical abstract / mechanism "
        "schematic for a research paper. Use a flat vector-like visual language, "
        "restrained journal palette, clear hierarchy, simple arrows, and minimal "
        "short labels. Keep the background uncluttered."
    )

    constraints = (
        "Scientific constraints: show only the mechanisms and entities described "
        "below; do not invent quantitative values, p-values, microscopy results, "
        "institutional logos, journal marks, or unsupported experimental claims. "
        "Use conceptual visual elements rather than fake data panels. Text labels "
        "must be short and easy to redraw later."
    )

    return "\n\n".join([style, constraints, *content_blocks])


def reference_to_payload(value: str) -> dict[str, Any]:
    if value.startswith(("http://", "https://", "data:")):
        url = value
    else:
        path = Path(value)
        if not path.exists():
            raise SystemExit(f"Reference image not found: {value}")
        media_type = mimetypes.guess_type(path.name)[0] or "image/png"
        encoded = base64.b64encode(path.read_bytes()).decode("ascii")
        url = f"data:{media_type};base64,{encoded}"
    return {"type": "image_url", "image_url": {"url": url}}


def build_payload(args: argparse.Namespace) -> dict[str, Any]:
    payload: dict[str, Any] = {
        "model": args.model,
        "prompt": build_prompt(args),
    }

    optional_fields = {
        "aspect_ratio": args.aspect_ratio,
        "resolution": args.resolution,
        "quality": args.quality,
        "output_format": args.output_format,
        "background": args.background,
        "size": args.size,
        "n": args.n,
    }
    for key, value in optional_fields.items():
        if value is not None:
            payload[key] = value

    if args.reference_image:
        payload["input_references"] = [
            reference_to_payload(value) for value in args.reference_image
        ]

    return payload


def media_extension(media_type: str | None, output_format: str | None) -> str:
    if media_type == "image/svg+xml":
        return ".svg"
    if media_type == "image/jpeg":
        return ".jpg"
    if media_type == "image/webp":
        return ".webp"
    if media_type == "image/png":
        return ".png"
    if output_format == "jpeg":
        return ".jpg"
    if output_format in {"png", "webp"}:
        return f".{output_format}"
    return ".png"


def decode_b64_image(value: str) -> bytes:
    if value.startswith("data:"):
        value = value.split(",", 1)[1]
    return base64.b64decode(value)


def request_images(payload: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
    api_key = os.environ.get(args.api_key_env)
    if not api_key:
        raise SystemExit(f"Missing API key: set {args.api_key_env}.")

    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    site_url = args.site_url or os.environ.get("OPENROUTER_SITE_URL")
    app_name = args.app_name or os.environ.get("OPENROUTER_APP_NAME")
    if site_url:
        headers["HTTP-Referer"] = site_url
    if app_name:
        headers["X-Title"] = app_name

    request = urllib.request.Request(
        args.api_url,
        data=json.dumps(payload).encode("utf-8"),
        headers=headers,
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=args.timeout) as response:
            return json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace")
        raise SystemExit(f"Image request failed ({exc.code}) at {args.api_url}: {body}") from exc
    except urllib.error.URLError as exc:
        raise SystemExit(f"Image request failed at {args.api_url}: {exc}") from exc


def save_outputs(response: dict[str, Any], payload: dict[str, Any], args: argparse.Namespace) -> None:
    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)
    basename = args.basename or time.strftime("openrouter_schematic_%Y%m%d_%H%M%S")

    saved: list[str] = []
    for index, item in enumerate(response.get("data", []), start=1):
        media_type = item.get("media_type")
        ext = media_extension(media_type, args.output_format)
        suffix = "" if len(response.get("data", [])) == 1 else f"_{index:02d}"
        outpath = outdir / f"{basename}{suffix}{ext}"

        if "b64_json" in item:
            outpath.write_bytes(decode_b64_image(item["b64_json"]))
            saved.append(str(outpath))
        elif "url" in item:
            with urllib.request.urlopen(item["url"], timeout=args.timeout) as image_response:
                outpath.write_bytes(image_response.read())
            saved.append(str(outpath))
        else:
            raise SystemExit(f"No image bytes or URL in response item {index}: {item}")

    metadata = {
        "api_url": args.api_url,
        "request": payload,
        "response_usage": response.get("usage"),
        "created": response.get("created"),
        "saved_files": saved,
    }
    metadata_path = outdir / f"{basename}_request_metadata.json"
    metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8")

    print("Saved:")
    for path in saved:
        print(f"  {path}")
    print(f"  {metadata_path}")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Generate manuscript schematic drafts with OpenRouter's Images API."
    )
    parser.add_argument("--model", default=os.environ.get("OPENROUTER_IMAGE_MODEL", DEFAULT_MODEL))
    parser.add_argument("--title")
    parser.add_argument("--abstract")
    parser.add_argument("--abstract-file")
    parser.add_argument("--panel-map")
    parser.add_argument("--prompt")
    parser.add_argument("--prompt-file")
    parser.add_argument("--style")
    parser.add_argument("--raw", action="store_true", help="Use prompt text without the schematic scaffold.")
    parser.add_argument("--reference-image", action="append", help="Path, URL, or data URL for image-to-image guidance.")
    parser.add_argument("--outdir", default="openrouter_schematic")
    parser.add_argument("--basename")
    parser.add_argument("--aspect-ratio", default="16:9")
    parser.add_argument("--resolution", default="2K")
    parser.add_argument("--size", help="Optional OpenRouter size shorthand, such as 2048x2048.")
    parser.add_argument("--quality", default="high", choices=["auto", "low", "medium", "high"])
    parser.add_argument("--output-format", default="png", choices=["png", "jpeg", "webp"])
    parser.add_argument("--background", choices=["auto", "transparent", "opaque"])
    parser.add_argument("--n", type=int, default=1)
    parser.add_argument("--api-key-env", default="OPENROUTER_API_KEY")
    parser.add_argument(
        "--api-url",
        default=os.environ.get("SCHEMATIC_IMAGE_API_URL", API_URL),
        help=(
            "Images endpoint to call. Defaults to OpenRouter; point it at any "
            "OpenAI-compatible images endpoint to use that service instead."
        ),
    )
    parser.add_argument("--site-url")
    parser.add_argument("--app-name")
    parser.add_argument("--timeout", type=int, default=180)
    parser.add_argument("--dry-run", action="store_true", help="Print request payload without calling the API.")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    payload = build_payload(args)

    if args.dry_run:
        print(json.dumps(payload, indent=2, ensure_ascii=False))
        return 0

    response = request_images(payload, args)
    save_outputs(response, payload, args)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/nature_figure_backend.py
#!/usr/bin/env python3
"""Read or write the user's default nature-figure plotting backend."""

from __future__ import annotations

import argparse
import json
import os
import sys
from pathlib import Path


VALID_BACKENDS = {"python", "r"}


def config_path() -> Path:
    override = os.environ.get("NATURE_FIGURE_CONFIG")
    if override:
        return Path(override).expanduser()
    return Path("~/.config/nature-skills/nature-figure.json").expanduser()


def read_config(path: Path) -> dict:
    if not path.exists():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise SystemExit(f"Invalid nature-figure config JSON at {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise SystemExit(f"Invalid nature-figure config at {path}: expected object")
    return data


def write_config(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def get_backend(path: Path) -> int:
    backend = read_config(path).get("backend")
    if backend in VALID_BACKENDS:
        print(backend)
        return 0
    return 1


def set_backend(path: Path, backend: str) -> int:
    backend = backend.lower()
    if backend not in VALID_BACKENDS:
        raise SystemExit("backend must be one of: python, r")
    data = read_config(path)
    data["backend"] = backend
    write_config(path, data)
    print(backend)
    return 0


def clear_backend(path: Path) -> int:
    data = read_config(path)
    data.pop("backend", None)
    write_config(path, data)
    return 0


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    subparsers.add_parser("get", help="Print the saved backend; exit 1 if unset.")
    set_parser = subparsers.add_parser("set", help="Save the default backend.")
    set_parser.add_argument("backend", choices=sorted(VALID_BACKENDS))
    subparsers.add_parser("clear", help="Remove the saved backend preference.")
    subparsers.add_parser("path", help="Print the config path.")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    path = config_path()
    if args.command == "get":
        return get_backend(path)
    if args.command == "set":
        return set_backend(path, args.backend)
    if args.command == "clear":
        return clear_backend(path)
    if args.command == "path":
        print(path)
        return 0
    raise AssertionError(args.command)


if __name__ == "__main__":
    raise SystemExit(main())
scripts/panel_alignment.R
# Render-time panel-alignment helper for R patchwork figures.
#
# Source this file from the selected R plotting script. It measures patchwork's
# final panel cells on a device with the same physical dimensions as the export,
# writes the backend-neutral JSON manifest consumed by audit_panel_alignment.py,
# and can run the blocking audit in one call.

.nature_alignment_groups <- function(groups) {
  if (is.null(groups)) {
    return(NULL)
  }
  lapply(groups, function(group) as.character(unlist(group, use.names = FALSE)))
}

.nature_alignment_panel_rows <- function(grob) {
  rows <- grob$layout[grepl("^panel(?:-[0-9]+)?$", grob$layout$name, perl = TRUE), , drop = FALSE]
  if (nrow(rows) < 2) {
    stop("At least two patchwork panel cells are required for alignment QA", call. = FALSE)
  }
  rows[order(rows$t, rows$l), , drop = FALSE]
}

write_patchwork_panel_layout <- function(
  plot,
  manifest_path,
  width_in,
  height_in,
  panel_ids = NULL,
  row_groups = NULL,
  column_groups = NULL,
  exemptions = list()
) {
  for (package in c("patchwork", "grid", "jsonlite")) {
    if (!requireNamespace(package, quietly = TRUE)) {
      stop(sprintf("Package '%s' is required for patchwork alignment QA", package), call. = FALSE)
    }
  }
  if (!is.numeric(width_in) || length(width_in) != 1 || !is.finite(width_in) || width_in <= 0 ||
      !is.numeric(height_in) || length(height_in) != 1 || !is.finite(height_in) || height_in <= 0) {
    stop("width_in and height_in must be positive finite numbers", call. = FALSE)
  }

  grob <- patchwork::patchworkGrob(plot)
  panel_rows <- .nature_alignment_panel_rows(grob)
  if (is.null(panel_ids)) {
    panel_ids <- vapply(
      seq_len(nrow(panel_rows)),
      function(index) if (index <= length(letters)) letters[index] else paste0("panel-", index),
      character(1)
    )
  }
  panel_ids <- as.character(panel_ids)
  if (length(panel_ids) != nrow(panel_rows) || anyDuplicated(panel_ids) || any(!nzchar(panel_ids))) {
    stop("panel_ids must be unique, non-empty, and match the measured patchwork panels", call. = FALSE)
  }

  probe_path <- tempfile(fileext = ".pdf")
  grDevices::pdf(probe_path, width = width_in, height = height_in, useDingbats = FALSE)
  device_open <- TRUE
  on.exit({
    if (device_open) {
      grDevices::dev.off()
    }
    unlink(probe_path)
  }, add = TRUE)
  grid::grid.newpage()
  grid::grid.draw(grob)
  grid::grid.force()

  widths_pt <- grid::convertWidth(grob$widths, "pt", valueOnly = TRUE)
  heights_pt <- grid::convertHeight(grob$heights, "pt", valueOnly = TRUE)
  if (any(!is.finite(widths_pt)) || any(!is.finite(heights_pt))) {
    stop("Patchwork panel dimensions could not be converted to physical points", call. = FALSE)
  }
  x_edges <- c(0, cumsum(widths_pt))
  top_edges <- c(0, cumsum(heights_pt))
  total_height_pt <- sum(heights_pt)

  panels <- lapply(seq_len(nrow(panel_rows)), function(index) {
    row <- panel_rows[index, ]
    left <- x_edges[row$l]
    right <- x_edges[row$r + 1]
    top <- total_height_pt - top_edges[row$t]
    bottom <- total_height_pt - top_edges[row$b + 1]
    list(
      id = panel_ids[index],
      bbox_pt = unname(c(left, bottom, right, top)),
      grid_id = "patchwork-grid-1",
      row_start = as.integer(row$t - 1),
      row_stop = as.integer(row$b),
      col_start = as.integer(row$l - 1),
      col_stop = as.integer(row$r)
    )
  })

  manifest <- list(
    schema_version = 1L,
    backend = "r-patchwork",
    figure = list(width_pt = width_in * 72, height_pt = height_in * 72),
    panels = panels,
    exemptions = exemptions
  )
  normalized_rows <- .nature_alignment_groups(row_groups)
  normalized_columns <- .nature_alignment_groups(column_groups)
  if (!is.null(normalized_rows)) {
    manifest$row_groups <- normalized_rows
  }
  if (!is.null(normalized_columns)) {
    manifest$column_groups <- normalized_columns
  }

  dir.create(dirname(manifest_path), recursive = TRUE, showWarnings = FALSE)
  temporary_path <- tempfile(pattern = ".panel-layout-", tmpdir = dirname(manifest_path), fileext = ".json")
  jsonlite::write_json(manifest, temporary_path, auto_unbox = TRUE, pretty = TRUE, digits = NA)
  if (!file.rename(temporary_path, manifest_path)) {
    unlink(temporary_path)
    stop("Could not activate the patchwork alignment manifest atomically", call. = FALSE)
  }

  grDevices::dev.off()
  device_open <- FALSE
  unlink(probe_path)
  invisible(manifest)
}

require_patchwork_panel_alignment <- function(
  plot,
  manifest_path,
  report_path,
  width_in,
  height_in,
  panel_ids = NULL,
  row_groups = NULL,
  column_groups = NULL,
  exemptions = list(),
  audit_script = "skills/nature-figure/scripts/audit_panel_alignment.py",
  python = Sys.which("python"),
  overlay_svg = NULL,
  tolerance_pt = 1.5,
  gutter_tolerance_pt = 1.5,
  strict = TRUE
) {
  if (!nzchar(python)) {
    stop("Python is required for the backend-neutral panel-alignment JSON audit", call. = FALSE)
  }
  if (!file.exists(audit_script)) {
    stop(sprintf("Panel-alignment audit script not found: %s", audit_script), call. = FALSE)
  }
  write_patchwork_panel_layout(
    plot = plot,
    manifest_path = manifest_path,
    width_in = width_in,
    height_in = height_in,
    panel_ids = panel_ids,
    row_groups = row_groups,
    column_groups = column_groups,
    exemptions = exemptions
  )

  args <- c(
    shQuote(audit_script),
    shQuote(manifest_path),
    "--json-out", shQuote(report_path),
    "--tolerance-pt", format(tolerance_pt, scientific = FALSE),
    "--gutter-tolerance-pt", format(gutter_tolerance_pt, scientific = FALSE)
  )
  if (!is.null(overlay_svg)) {
    args <- c(args, "--overlay-svg", shQuote(overlay_svg))
  }
  if (isTRUE(strict)) {
    args <- c(args, "--strict")
  }
  output <- suppressWarnings(system2(python, args = args, stdout = TRUE, stderr = TRUE))
  status <- attr(output, "status")
  if (is.null(status)) {
    status <- 0L
  }
  if (length(output)) {
    message(paste(output, collapse = "\n"))
  }
  if (status != 0L) {
    stop(
      sprintf("Panel alignment gate failed with exit code %d; inspect %s", status, report_path),
      call. = FALSE
    )
  }
  invisible(jsonlite::read_json(report_path, simplifyVector = TRUE))
}
scripts/plot_templates.py
#!/usr/bin/env python3
"""Validated Python templates for five common manuscript-figure families.

Subcommands: volcano, roc, dotplot, marginal, and paired. Production runs
require a CSV input. Simulated data is available only through the explicit
--demo flag and is marked as such in the generated QA record.

The template families and adaptation safeguards were informed by the
Apache-2.0 academic-figure-skill asset collection. This implementation is
portable, path-independent, and designed for the nature-figure contract.
"""

from __future__ import annotations

import argparse
import csv
import json
import math
import sys
from pathlib import Path
from typing import Any, Iterable


DEFAULT_WIDTH_MM = 183.0
DEFAULT_HEIGHT_MM = 120.0
MM_PER_INCH = 25.4
PALETTE = ["#2166AC", "#B2182B", "#1B7837", "#F1A340", "#762A83", "#666666"]
DEPENDENCY_ERROR: Exception | None = None

try:
    import numpy as np
    import matplotlib as mpl

    mpl.use("Agg")
    import matplotlib.pyplot as plt
    from matplotlib.colors import LinearSegmentedColormap
    from matplotlib.lines import Line2D
except ImportError as exc:  # Keep --help usable in a minimal environment.
    DEPENDENCY_ERROR = exc


def require_dependencies() -> None:
    if DEPENDENCY_ERROR is not None:
        raise RuntimeError(
            "plot_templates.py requires numpy and matplotlib in the selected Python environment"
        ) from DEPENDENCY_ERROR


def configure_style() -> None:
    mpl.rcParams.update(
        {
            "font.family": "sans-serif",
            "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans", "Liberation Sans"],
            "font.size": 7,
            "axes.labelsize": 7,
            "axes.titlesize": 8,
            "xtick.labelsize": 6,
            "ytick.labelsize": 6,
            "legend.fontsize": 6,
            "axes.spines.top": False,
            "axes.spines.right": False,
            "axes.linewidth": 0.6,
            "xtick.direction": "out",
            "ytick.direction": "out",
            "xtick.major.width": 0.6,
            "ytick.major.width": 0.6,
            "legend.frameon": False,
            "svg.fonttype": "none",
            "pdf.fonttype": 42,
        }
    )


def normalize_prefix(raw: Path) -> Path:
    if raw.suffix.lower() in {".svg", ".pdf", ".tif", ".tiff", ".png"}:
        raw = raw.with_suffix("")
    raw.parent.mkdir(parents=True, exist_ok=True)
    return raw


def save_bundle(fig: Any, output: Path) -> dict[str, str]:
    output = normalize_prefix(output)
    paths = {
        "svg": str(output.with_suffix(".svg")),
        "pdf": str(output.with_suffix(".pdf")),
        "tiff": str(output.with_suffix(".tiff")),
    }
    fig.savefig(paths["svg"], bbox_inches="tight", facecolor="white")
    fig.savefig(paths["pdf"], bbox_inches="tight", facecolor="white")
    fig.savefig(paths["tiff"], dpi=600, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    return paths


def write_qa(output: Path, qa: dict[str, Any]) -> Path:
    path = normalize_prefix(output).with_suffix(".qa.json")
    path.write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    return path


def read_csv_rows(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames is None:
            raise ValueError(f"CSV has no header: {path}")
        rows = [dict(row) for row in reader]
        return rows, list(reader.fieldnames)


def require_columns(fieldnames: Iterable[str], required: Iterable[str]) -> None:
    available = set(fieldnames)
    missing = [name for name in required if name not in available]
    if missing:
        raise ValueError(f"missing required CSV columns: {', '.join(missing)}")


def coerce_numeric_rows(
    rows: list[dict[str, Any]],
    columns: Iterable[str],
    drop_incomplete: bool,
) -> tuple[list[dict[str, Any]], list[int]]:
    columns = list(columns)
    valid: list[dict[str, Any]] = []
    invalid: list[int] = []
    for row_number, row in enumerate(rows, start=2):
        converted = dict(row)
        try:
            for column in columns:
                value = float(row[column])
                if not math.isfinite(value):
                    raise ValueError
                converted[column] = value
        except (KeyError, TypeError, ValueError):
            invalid.append(row_number)
            continue
        valid.append(converted)
    if invalid and not drop_incomplete:
        shown = ", ".join(str(value) for value in invalid[:10])
        suffix = " ..." if len(invalid) > 10 else ""
        raise ValueError(
            f"non-finite or missing numeric values at CSV rows {shown}{suffix}; "
            "fix the data or rerun with explicit --drop-incomplete"
        )
    if not valid:
        raise ValueError("no valid observations remain")
    return valid, invalid


def first_seen(values: Iterable[str]) -> list[str]:
    result: list[str] = []
    seen: set[str] = set()
    for value in values:
        if value not in seen:
            seen.add(value)
            result.append(value)
    return result


def parse_order(raw: str | None, observed: Iterable[str], label: str) -> list[str]:
    observed_order = first_seen(str(value) for value in observed)
    if not raw:
        return observed_order
    requested = [value.strip() for value in raw.split(",") if value.strip()]
    missing = [value for value in observed_order if value not in requested]
    extra = [value for value in requested if value not in observed_order]
    if missing or extra:
        details = []
        if missing:
            details.append(f"missing observed {label}: {missing}")
        if extra:
            details.append(f"unknown {label}: {extra}")
        raise ValueError("; ".join(details))
    return requested


def nice_reference_values(maximum: float) -> list[float]:
    if maximum <= 0:
        return []
    magnitude = 10 ** math.floor(math.log10(maximum))
    quantum = magnitude / 2
    values = [round((maximum * fraction) / quantum) * quantum for fraction in (0.25, 0.5, 1.0)]
    return sorted({value for value in values if value > 0})


def base_qa(args: argparse.Namespace, kind: str, rows_input: int) -> dict[str, Any]:
    return {
        "template": kind,
        "backend": "python",
        "demo": bool(args.demo),
        "input": "<explicit-demo-data>" if args.demo else args.input.name,
        "output_prefix": normalize_prefix(args.output).name,
        "rows_input": rows_input,
        "rows_plotted": rows_input,
        "excluded_rows": 0,
        "excluded_entities": 0,
        "mapping": {},
        "parameters": {},
        "notes": [
            "All supplied observations are used unless excluded through explicit --drop-incomplete.",
            "The QA record stores input basenames rather than private absolute paths.",
        ],
    }


def load_rows(args: argparse.Namespace, kind: str) -> tuple[list[dict[str, Any]], list[str]]:
    if args.demo:
        rows = demo_rows(kind)
        return rows, list(rows[0])
    if args.input is None:
        raise ValueError("production runs require --input CSV; use --demo only for an explicit example")
    return read_csv_rows(args.input)


def demo_rows(kind: str) -> list[dict[str, Any]]:
    rng = np.random.default_rng(20260715)
    if kind == "volcano":
        rows = []
        for index in range(1200):
            effect = float(rng.normal(0, 0.65))
            pvalue = float(rng.uniform(0.02, 1.0))
            if index < 55:
                effect = float(rng.choice([-1, 1]) * rng.uniform(1.2, 3.2))
                pvalue = float(10 ** (-rng.uniform(2, 9)))
            rows.append({"gene": f"Gene_{index + 1}", "log2fc": effect, "padj": pvalue})
        return rows
    if kind == "roc":
        rows = []
        for fpr in np.linspace(0, 1, 101):
            rows.append(
                {
                    "fpr": float(fpr),
                    "model_a": float(np.clip(fpr ** 0.42, 0, 1)),
                    "model_b": float(np.clip(fpr ** 0.58, 0, 1)),
                    "model_c": float(np.clip(fpr ** 0.72, 0, 1)),
                }
            )
        return rows
    if kind == "dotplot":
        rows = []
        row_names = ["VCT", "EVT", "SCT", "FB", "T", "dNK"]
        column_names = ["TP63", "HLA-G", "ERVW-1", "VIM", "ACTA2", "CD3D", "GNLY", "NKG7"]
        for row_index, row_name in enumerate(row_names):
            for col_index, column_name in enumerate(column_names):
                distance = abs((row_index * 1.3) - (col_index * 0.7))
                rows.append(
                    {
                        "cell_type": row_name,
                        "gene": column_name,
                        "pct_exp": float(np.clip(70 - 14 * distance + rng.normal(0, 5), 1, 90)),
                        "avg_exp_scaled": float(np.clip(1 - distance / 6 + rng.normal(0, 0.08), 0, 1)),
                    }
                )
        return rows
    if kind == "marginal":
        rows = []
        for group_index, group in enumerate(("Control", "Treatment A", "Treatment B")):
            x = rng.normal(group_index * 0.7, 0.85, 220)
            y = 0.65 * x + rng.normal(group_index * 0.35, 0.65, 220)
            rows.extend({"x": float(a), "y": float(b), "group": group} for a, b in zip(x, y))
        return rows
    if kind == "paired":
        rows = []
        for index in range(28):
            before = float(rng.normal(1.0, 0.18))
            after = float(before + rng.normal(0.20, 0.10))
            rows.extend(
                [
                    {"subject": f"S{index + 1:02d}", "condition": "Before", "value": before},
                    {"subject": f"S{index + 1:02d}", "condition": "After", "value": after},
                ]
            )
        return rows
    raise ValueError(f"unknown demo kind: {kind}")


def plot_volcano(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
    rows, fields = load_rows(args, "volcano")
    required = [args.gene_col, args.effect_col, args.p_col]
    require_columns(fields, required)
    rows, invalid = coerce_numeric_rows(rows, [args.effect_col, args.p_col], args.drop_incomplete)
    bad_p = [index for index, row in enumerate(rows, start=1) if not 0 < row[args.p_col] <= 1]
    if bad_p:
        raise ValueError("adjusted p values must be strictly positive and no greater than 1")

    effects = np.asarray([row[args.effect_col] for row in rows], dtype=float)
    pvalues = np.asarray([row[args.p_col] for row in rows], dtype=float)
    genes = [str(row[args.gene_col]) for row in rows]
    scores = -np.log10(pvalues)
    up = (pvalues < args.p_threshold) & (effects >= args.effect_threshold)
    down = (pvalues < args.p_threshold) & (effects <= -args.effect_threshold)
    neutral = ~(up | down)

    fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
    rasterized = len(rows) > 50000
    ax.scatter(effects[neutral], scores[neutral], s=5, color="#B3B3B3", alpha=0.45, edgecolors="none", rasterized=rasterized, label=f"Not significant ({neutral.sum()})")
    ax.scatter(effects[down], scores[down], s=7, color="#2166AC", alpha=0.72, edgecolors="none", rasterized=rasterized, label=f"Down ({down.sum()})")
    ax.scatter(effects[up], scores[up], s=7, color="#B2182B", alpha=0.72, edgecolors="none", rasterized=rasterized, label=f"Up ({up.sum()})")
    ax.axhline(-math.log10(args.p_threshold), color="#666666", linestyle="--", linewidth=0.6)
    ax.axvline(-args.effect_threshold, color="#666666", linestyle="--", linewidth=0.6)
    ax.axvline(args.effect_threshold, color="#666666", linestyle="--", linewidth=0.6)
    significant = np.flatnonzero(up | down)
    if args.top_labels > 0 and len(significant):
        selected = significant[np.argsort(pvalues[significant])[: args.top_labels]]
        for is_positive in (False, True):
            side = [index for index in selected if (effects[index] >= 0) == is_positive]
            last_label_y = -math.inf
            for index in sorted(side, key=lambda value: scores[value]):
                label_y = max(float(scores[index]) + 0.12, last_label_y + 0.30)
                last_label_y = label_y
                direction = 1 if is_positive else -1
                ax.annotate(
                    genes[index],
                    (effects[index], scores[index]),
                    xytext=(effects[index] + 0.07 * direction, label_y),
                    textcoords="data",
                    ha="left" if is_positive else "right",
                    va="bottom",
                    fontsize=5,
                    color="#333333",
                    arrowprops={"arrowstyle": "-", "color": "#777777", "linewidth": 0.3},
                )
    ax.set_xlabel(args.effect_label)
    # Keep the subscript as Unicode so the rendered glyph does not shrink below
    # the journal's 5 pt floor when the surrounding label is already compact.
    ax.set_ylabel("−log₁₀(adjusted p value)")
    if args.title:
        ax.set_title(args.title)
    ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.19), ncol=3)
    paths = save_bundle(fig, args.output)

    qa = base_qa(args, "volcano", len(rows) + len(invalid))
    qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
    qa["mapping"] = {"gene": args.gene_col, "effect": args.effect_col, "adjusted_p": args.p_col}
    qa["parameters"] = {"p_threshold": args.p_threshold, "effect_threshold": args.effect_threshold, "top_labels": args.top_labels, "rasterized_marks": rasterized, "category_counts": {"up": int(up.sum()), "down": int(down.sum()), "neutral": int(neutral.sum())}}
    return paths, qa


def plot_roc(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
    rows, fields = load_rows(args, "roc")
    tpr_columns = [value.strip() for value in args.tpr_cols.split(",") if value.strip()] if args.tpr_cols else [field for field in fields if field != args.fpr_col]
    if not tpr_columns:
        raise ValueError("no TPR columns selected")
    require_columns(fields, [args.fpr_col, *tpr_columns])
    rows, invalid = coerce_numeric_rows(rows, [args.fpr_col, *tpr_columns], args.drop_incomplete)
    for row in rows:
        values = [row[args.fpr_col], *(row[column] for column in tpr_columns)]
        if any(value < 0 or value > 1 for value in values):
            raise ValueError("FPR and TPR values must stay within [0, 1]")

    fpr = np.asarray([row[args.fpr_col] for row in rows], dtype=float)
    order = np.argsort(fpr, kind="stable")
    sorted_input = bool(np.all(order == np.arange(len(fpr))))
    fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
    aucs: dict[str, float] = {}
    for index, column in enumerate(tpr_columns):
        tpr = np.asarray([row[column] for row in rows], dtype=float)[order]
        auc = float(np.trapezoid(tpr, fpr[order]))
        aucs[column] = auc
        ax.plot(fpr[order], tpr, color=PALETTE[index % len(PALETTE)], linewidth=1.3, label=f"{column} (AUC={auc:.3f})")
    ax.plot([0, 1], [0, 1], color="#888888", linestyle="--", linewidth=0.7, label="Chance")
    ax.set(xlim=(0, 1), ylim=(0, 1), xlabel="False-positive rate", ylabel="True-positive rate")
    ax.set_aspect("equal", adjustable="box")
    if args.title:
        ax.set_title(args.title)
    ax.legend(loc="lower right")
    paths = save_bundle(fig, args.output)

    qa = base_qa(args, "roc", len(rows) + len(invalid))
    qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
    qa["mapping"] = {"fpr": args.fpr_col, "tpr_series": tpr_columns}
    qa["parameters"] = {"auc_method": "trapezoidal", "auc": aucs, "input_already_sorted_by_fpr": sorted_input}
    return paths, qa


def plot_dotplot(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
    rows, fields = load_rows(args, "dotplot")
    required = [args.row_col, args.column_col, args.size_col, args.color_col]
    require_columns(fields, required)
    rows, invalid = coerce_numeric_rows(rows, [args.size_col, args.color_col], args.drop_incomplete)
    if any(row[args.size_col] < 0 for row in rows):
        raise ValueError("dot size values must be non-negative")
    row_order = parse_order(args.row_order, (str(row[args.row_col]) for row in rows), "row categories")
    column_order = parse_order(args.column_order, (str(row[args.column_col]) for row in rows), "column categories")
    row_map = {value: index for index, value in enumerate(row_order)}
    column_map = {value: index for index, value in enumerate(column_order)}

    size_values = np.asarray([row[args.size_col] for row in rows], dtype=float)
    color_values = np.asarray([row[args.color_col] for row in rows], dtype=float)
    size_max = float(size_values.max())
    sizes = np.full_like(size_values, 30.0) if size_max == 0 else 12 + 160 * np.sqrt(size_values / size_max)
    color_min = float(color_values.min())
    color_max = float(color_values.max())
    color_norm = np.full_like(color_values, 0.5) if color_max == color_min else (color_values - color_min) / (color_max - color_min)
    xs = np.asarray([column_map[str(row[args.column_col])] for row in rows])
    ys = np.asarray([row_map[str(row[args.row_col])] for row in rows])
    cmap = LinearSegmentedColormap.from_list("nature_expression", ["#2166AC", "#F7F7F7", "#B2182B"])

    fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
    scatter = ax.scatter(xs, ys, s=sizes, c=color_norm, cmap=cmap, vmin=0, vmax=1, edgecolors="#666666", linewidths=0.35)
    ax.set_xticks(
        range(len(column_order)),
        column_order,
        rotation=90,
        rotation_mode="anchor",
        ha="right",
    )
    ax.set_yticks(range(len(row_order)), row_order)
    ax.set_xlim(-0.7, len(column_order) - 0.3)
    ax.set_ylim(-0.7, len(row_order) - 0.3)
    ax.invert_yaxis()
    ax.spines["top"].set_visible(True)
    ax.spines["right"].set_visible(True)
    ax.grid(color="#E6E6E6", linewidth=0.35)
    ax.set_axisbelow(True)
    colorbar = fig.colorbar(scatter, ax=ax, fraction=0.025, pad=0.02)
    colorbar.set_label(args.color_label)
    reference_values = nice_reference_values(size_max)
    handles = [Line2D([], [], marker="o", linestyle="", markerfacecolor="#999999", markeredgecolor="#666666", markersize=math.sqrt(12 + 160 * math.sqrt(value / size_max))) for value in reference_values]
    if handles:
        ax.legend(handles, [f"{value:g}" for value in reference_values], title=args.size_label, bbox_to_anchor=(1.18, 1), loc="upper left")
    if args.title:
        ax.set_title(args.title)
    paths = save_bundle(fig, args.output)

    qa = base_qa(args, "dotplot", len(rows) + len(invalid))
    qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
    qa["mapping"] = {"row_category": args.row_col, "column_category": args.column_col, "dot_size": args.size_col, "dot_color": args.color_col}
    qa["parameters"] = {"row_order": row_order, "column_order": column_order, "size_range": [float(size_values.min()), size_max], "color_range": [color_min, color_max]}
    return paths, qa


def plot_marginal(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
    rows, fields = load_rows(args, "marginal")
    required = [args.x_col, args.y_col] + ([args.group_col] if args.group_col else [])
    require_columns(fields, required)
    rows, invalid = coerce_numeric_rows(rows, [args.x_col, args.y_col], args.drop_incomplete)
    groups = [str(row[args.group_col]) if args.group_col else "All observations" for row in rows]
    group_order = parse_order(args.group_order, groups, "groups")

    fig = plt.figure(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
    grid = fig.add_gridspec(4, 4, hspace=0.05, wspace=0.05)
    ax_joint = fig.add_subplot(grid[1:, :3])
    ax_top = fig.add_subplot(grid[0, :3], sharex=ax_joint)
    ax_right = fig.add_subplot(grid[1:, 3], sharey=ax_joint)
    counts: dict[str, int] = {}
    for index, group in enumerate(group_order):
        mask = np.asarray([value == group for value in groups])
        x = np.asarray([row[args.x_col] for row in rows], dtype=float)[mask]
        y = np.asarray([row[args.y_col] for row in rows], dtype=float)[mask]
        counts[group] = len(x)
        color = PALETTE[index % len(PALETTE)]
        ax_joint.scatter(x, y, s=8, alpha=0.42, color=color, edgecolors="none", rasterized=len(rows) > 50000, label=f"{group} (n={len(x)})")
        ax_top.hist(x, bins=args.bins, density=True, histtype="stepfilled", alpha=0.20, color=color)
        ax_top.hist(x, bins=args.bins, density=True, histtype="step", linewidth=0.8, color=color)
        ax_right.hist(y, bins=args.bins, density=True, orientation="horizontal", histtype="stepfilled", alpha=0.20, color=color)
        ax_right.hist(y, bins=args.bins, density=True, orientation="horizontal", histtype="step", linewidth=0.8, color=color)
    ax_joint.set_xlabel(args.x_label or args.x_col)
    ax_joint.set_ylabel(args.y_label or args.y_col)
    ax_joint.legend(loc="best")
    ax_top.tick_params(labelbottom=False)
    ax_right.tick_params(labelleft=False)
    ax_top.set_ylabel("Density")
    ax_right.set_xlabel("Density")
    if args.title:
        ax_top.set_title(args.title)
    paths = save_bundle(fig, args.output)

    qa = base_qa(args, "marginal", len(rows) + len(invalid))
    qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
    qa["mapping"] = {"x": args.x_col, "y": args.y_col, "group": args.group_col}
    qa["parameters"] = {"group_order": group_order, "group_counts": counts, "histogram_bins": args.bins, "rasterized_marks": len(rows) > 50000}
    return paths, qa


def plot_paired(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
    rows, fields = load_rows(args, "paired")
    required = [args.id_col, args.condition_col, args.value_col]
    require_columns(fields, required)
    rows, invalid = coerce_numeric_rows(rows, [args.value_col], args.drop_incomplete)
    condition_order = parse_order(args.condition_order, (str(row[args.condition_col]) for row in rows), "conditions")
    if len(condition_order) != 2:
        raise ValueError(f"paired template requires exactly two conditions, found {len(condition_order)}")

    subjects: dict[str, dict[str, list[float]]] = {}
    for row in rows:
        subject = str(row[args.id_col])
        condition = str(row[args.condition_col])
        subjects.setdefault(subject, {}).setdefault(condition, []).append(float(row[args.value_col]))
    duplicates = [subject for subject, values in subjects.items() if any(len(values.get(condition, [])) > 1 for condition in condition_order)]
    if duplicates:
        raise ValueError("multiple values per subject-condition pair found; aggregate only with an explicit scientific rule before plotting")
    incomplete = [subject for subject, values in subjects.items() if any(condition not in values for condition in condition_order)]
    if incomplete and not args.drop_incomplete:
        raise ValueError(f"{len(incomplete)} incomplete subject pairs found; fix the data or rerun with explicit --drop-incomplete")
    complete = [subject for subject in subjects if subject not in incomplete]
    if not complete:
        raise ValueError("no complete subject pairs remain")
    values_a = np.asarray([subjects[subject][condition_order[0]][0] for subject in complete])
    values_b = np.asarray([subjects[subject][condition_order[1]][0] for subject in complete])

    fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
    for value_a, value_b in zip(values_a, values_b):
        ax.plot([0, 1], [value_a, value_b], color="#B0B0B0", linewidth=0.55, alpha=0.65, zorder=1)
    boxes = ax.boxplot([values_a, values_b], positions=[0, 1], widths=0.34, patch_artist=True, showfliers=False, medianprops={"color": "#222222", "linewidth": 1.0}, whiskerprops={"linewidth": 0.7}, capprops={"linewidth": 0.7})
    for patch, color in zip(boxes["boxes"], ("#9ECAE1", "#FC9272")):
        patch.set(facecolor=color, edgecolor="#555555", alpha=0.55, linewidth=0.7)
    ax.scatter(np.zeros_like(values_a), values_a, s=13, color="#2166AC", edgecolors="white", linewidths=0.35, zorder=3)
    ax.scatter(np.ones_like(values_b), values_b, s=13, color="#B2182B", edgecolors="white", linewidths=0.35, zorder=3)
    ax.set_xticks([0, 1], condition_order)
    ax.set_ylabel(args.value_label or args.value_col)
    if args.title:
        ax.set_title(args.title)
    paths = save_bundle(fig, args.output)

    qa = base_qa(args, "paired", len(rows) + len(invalid))
    qa.update({"rows_plotted": len(complete) * 2, "excluded_rows": len(invalid), "excluded_entities": len(incomplete)})
    qa["mapping"] = {"pair_id": args.id_col, "condition": args.condition_col, "value": args.value_col}
    qa["parameters"] = {"condition_order": condition_order, "complete_pairs": len(complete), "incomplete_pairs_excluded": len(incomplete), "statistical_test": None}
    qa["notes"].append("No inferential test is computed; add one only after choosing a justified paired analysis.")
    return paths, qa


def add_common_arguments(parser: argparse.ArgumentParser, width_mm: float, height_mm: float) -> None:
    source = parser.add_mutually_exclusive_group()
    source.add_argument("--input", type=Path, help="production CSV input")
    source.add_argument("--demo", action="store_true", help="render explicit deterministic demo data")
    parser.add_argument("--output", type=Path, required=True, help="output prefix for SVG/PDF/TIFF/QA JSON")
    parser.add_argument("--width-mm", type=float, default=width_mm)
    parser.add_argument("--height-mm", type=float, default=height_mm)
    parser.add_argument("--title")
    parser.add_argument("--drop-incomplete", action="store_true", help="explicitly exclude rows with missing/non-finite required values and record counts")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    volcano = subparsers.add_parser("volcano", help="effect size versus adjusted p value")
    add_common_arguments(volcano, 89.0, 82.0)
    volcano.add_argument("--gene-col", default="gene")
    volcano.add_argument("--effect-col", default="log2fc")
    volcano.add_argument("--p-col", default="padj")
    volcano.add_argument("--effect-label", default="log₂(fold change)")
    volcano.add_argument("--p-threshold", type=float, default=0.05)
    volcano.add_argument("--effect-threshold", type=float, default=1.0)
    volcano.add_argument("--top-labels", type=int, default=10)
    volcano.set_defaults(plotter=plot_volcano)

    roc = subparsers.add_parser("roc", help="one FPR column and one or more TPR columns")
    add_common_arguments(roc, 89.0, 89.0)
    roc.add_argument("--fpr-col", default="fpr")
    roc.add_argument("--tpr-cols", help="comma-separated TPR columns; defaults to every non-FPR column")
    roc.set_defaults(plotter=plot_roc)

    dotplot = subparsers.add_parser("dotplot", help="marker-gene or other size/color matrix dot plot")
    add_common_arguments(dotplot, 183.0, 105.0)
    dotplot.add_argument("--row-col", default="cell_type")
    dotplot.add_argument("--column-col", default="gene")
    dotplot.add_argument("--size-col", default="pct_exp")
    dotplot.add_argument("--color-col", default="avg_exp_scaled")
    dotplot.add_argument("--row-order", help="comma-separated complete row order")
    dotplot.add_argument("--column-order", help="comma-separated complete column order")
    dotplot.add_argument("--size-label", default="Fraction (%)")
    dotplot.add_argument("--color-label", default="Mean expression")
    dotplot.set_defaults(plotter=plot_dotplot)

    marginal = subparsers.add_parser("marginal", help="2D scatter with marginal distributions")
    add_common_arguments(marginal, 120.0, 105.0)
    marginal.add_argument("--x-col", default="x")
    marginal.add_argument("--y-col", default="y")
    marginal.add_argument("--group-col", default="group")
    marginal.add_argument("--group-order", help="comma-separated complete group order")
    marginal.add_argument("--x-label")
    marginal.add_argument("--y-label")
    marginal.add_argument("--bins", type=int, default=24)
    marginal.set_defaults(plotter=plot_marginal)

    paired = subparsers.add_parser("paired", help="paired box-and-point plot for exactly two conditions")
    add_common_arguments(paired, 89.0, 86.0)
    paired.add_argument("--id-col", default="subject")
    paired.add_argument("--condition-col", default="condition")
    paired.add_argument("--value-col", default="value")
    paired.add_argument("--condition-order", help="comma-separated two-condition order")
    paired.add_argument("--value-label")
    paired.set_defaults(plotter=plot_paired)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        require_dependencies()
        configure_style()
        paths, qa = args.plotter(args)
        qa_path = write_qa(args.output, qa)
    except (OSError, RuntimeError, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    print(json.dumps({"outputs": paths, "qa": str(qa_path)}, indent=2, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
scripts/validate_figure.py
#!/usr/bin/env python3
"""Static preflight for publication-figure Python and R source files.

The validator is intentionally dependency-free. It checks portable source-level
requirements before the selected backend renders the figure; it does not claim
to validate statistics or replace visual inspection.

The rule structure incorporates and refactors ideas from the Apache-2.0
academic-figure-skill QA workflow without retaining its path-bound runners.
"""

from __future__ import annotations

import argparse
import ast
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Callable, Iterable


LEVEL_ORDER = {"PASS": 0, "WARN": 1, "FAIL": 2}
PYTHON_SUFFIXES = {".py"}
R_SUFFIXES = {".r", ".rmd", ".qmd"}


@dataclass(frozen=True)
class Finding:
    check_id: str
    level: str
    message: str
    evidence: list[str]

    @property
    def passed(self) -> bool:
        return self.level == "PASS"


def finding(check_id: str, level: str, message: str, evidence: Iterable[str] = ()) -> Finding:
    if level not in LEVEL_ORDER:
        raise ValueError(f"unknown finding level: {level}")
    return Finding(check_id, level, message, list(evidence))


def detect_backend(path: Path, requested: str) -> str:
    if requested != "auto":
        return requested
    suffix = path.suffix.lower()
    if suffix in PYTHON_SUFFIXES:
        return "python"
    if suffix in R_SUFFIXES:
        return "r"
    raise ValueError(f"cannot infer backend from {path.name}; pass --backend python or --backend r")


def regex_hits(patterns: Iterable[str], source: str, flags: int = re.IGNORECASE) -> list[str]:
    hits: list[str] = []
    for pattern in patterns:
        match = re.search(pattern, source, flags)
        if match:
            hits.append(match.group(0).strip())
    return hits


def check_syntax(source: str, backend: str) -> Finding:
    if backend == "python":
        try:
            ast.parse(source)
        except SyntaxError as exc:
            location = f"line {exc.lineno}" if exc.lineno else "unknown line"
            return finding("SOURCE-SYNTAX", "FAIL", f"Python syntax error at {location}: {exc.msg}")
        return finding("SOURCE-SYNTAX", "PASS", "Python source parses successfully")

    pairs = {"(": ")", "[": "]", "{": "}"}
    stack: list[str] = []
    quote: str | None = None
    escaped = False
    comment = False
    for char in source:
        if comment:
            if char == "\n":
                comment = False
            continue
        if escaped:
            escaped = False
            continue
        if char == "\\":
            escaped = True
            continue
        if quote:
            if char == quote:
                quote = None
            continue
        if char == "#":
            comment = True
        elif char in {'"', "'"}:
            quote = char
        elif char in pairs:
            stack.append(char)
        elif char in pairs.values():
            if not stack or pairs[stack.pop()] != char:
                return finding("SOURCE-SYNTAX", "FAIL", "R source has unbalanced brackets")
    if stack or quote:
        return finding("SOURCE-SYNTAX", "FAIL", "R source has unbalanced brackets or quotes")
    return finding(
        "SOURCE-SYNTAX",
        "WARN",
        "Basic R delimiter check passed; run Rscript parse() when R is available",
    )


def check_font_family(source: str, backend: str) -> Finding:
    families = regex_hits(
        [r"Arial", r"Helvetica", r"Liberation Sans", r"sans-serif", r"base_family\s*=\s*['\"]sans['\"]"],
        source,
    )
    if families:
        return finding("FONT-FAMILY", "PASS", "A publication-safe sans-serif family is configured", families)
    label = "matplotlib rcParams" if backend == "python" else "ggplot/theme or graphics device"
    return finding("FONT-FAMILY", "FAIL", f"No explicit publication-safe font family found in {label}")


def explicit_font_sizes(source: str) -> list[float]:
    patterns = [
        r"(?:font\.size|fontsize|labelsize|titlesize|legend\.fontsize)\s*['\"]?\s*[:=]\s*(\d+(?:\.\d+)?)",
        r"base_size\s*=\s*(\d+(?:\.\d+)?)",
        r"element_text\s*\([^)]*?size\s*=\s*(\d+(?:\.\d+)?)",
    ]
    values: list[float] = []
    for pattern in patterns:
        values.extend(float(value) for value in re.findall(pattern, source, re.IGNORECASE | re.DOTALL))
    return values


def check_font_sizes(source: str, _backend: str) -> Finding:
    sizes = explicit_font_sizes(source)
    if not sizes:
        return finding("FONT-SIZE", "WARN", "No explicit text sizes found; verify readability at final physical size")
    minimum = min(sizes)
    if minimum < 5:
        return finding("FONT-SIZE", "FAIL", f"Explicit text size falls below the 5 pt floor: {minimum:g} pt")
    return finding("FONT-SIZE", "PASS", f"All detected text sizes are at least 5 pt (minimum {minimum:g} pt)")


def check_mathtext_glyph_floor(source: str, backend: str) -> Finding:
    if backend != "python":
        return finding("FONT-GLYPH-FLOOR", "PASS", "Python mathtext superscript scaling is not applicable")
    hits = regex_hits([r"\$[^$\n]*[\^_](?:\{[^}]+\}|[^$\s])[^$\n]*\$"], source, flags=0)
    if not hits:
        return finding("FONT-GLYPH-FLOOR", "PASS", "No mathtext superscript/subscript expression detected")
    sizes = explicit_font_sizes(source)
    if not sizes:
        return finding(
            "FONT-GLYPH-FLOOR",
            "WARN",
            "Mathtext superscript/subscript detected without an auditable parent font size; inspect the exported PDF",
            hits,
        )
    minimum_parent = min(sizes)
    estimated_script = minimum_parent * 0.7
    if estimated_script < 5:
        return finding(
            "FONT-GLYPH-FLOOR",
            "WARN",
            f"Mathtext may shrink script glyphs below 5 pt (about {estimated_script:.2f} pt from a {minimum_parent:g} pt parent); use a Unicode glyph or audit PDF Tf operators",
            hits,
        )
    return finding(
        "FONT-GLYPH-FLOOR",
        "PASS",
        f"Detected mathtext parent sizes imply script glyphs of about {estimated_script:.2f} pt or larger; confirm in the PDF",
        hits,
    )


def literal_legend_labels(source: str, backend: str) -> list[str]:
    if backend == "python":
        try:
            tree = ast.parse(source)
        except SyntaxError:
            return []
        labels: list[str] = []
        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            call_name = ""
            if isinstance(node.func, ast.Attribute):
                call_name = node.func.attr
            elif isinstance(node.func, ast.Name):
                call_name = node.func.id
            label_calls = {
                "plot",
                "bar",
                "barh",
                "scatter",
                "errorbar",
                "fill_between",
                "hist",
                "axhline",
                "axvline",
                "legend",
            }
            for keyword in node.keywords:
                if (
                    call_name in label_calls
                    and keyword.arg == "label"
                    and isinstance(keyword.value, ast.Constant)
                    and isinstance(keyword.value.value, str)
                ):
                    labels.append(keyword.value.value)
                elif call_name == "legend" and keyword.arg == "labels" and isinstance(keyword.value, (ast.List, ast.Tuple)):
                    labels.extend(
                        item.value
                        for item in keyword.value.elts
                        if isinstance(item, ast.Constant) and isinstance(item.value, str)
                    )
        return labels

    labels: list[str] = []
    for content in re.findall(r"labels\s*=\s*c\(([^)]*)\)", source, re.IGNORECASE | re.DOTALL):
        labels.extend(re.findall(r"['\"]([^'\"]+)['\"]", content))
    return labels


def check_legend_label_case(source: str, backend: str) -> Finding:
    labels = [label for label in literal_legend_labels(source, backend) if label and label != "_nolegend_"]
    lower_initial = []
    for label in labels:
        first_alpha = re.search(r"[A-Za-z]", re.sub(r"^\s*\+\s*", "", label))
        if first_alpha and first_alpha.group(0).islower():
            lower_initial.append(label)
    if lower_initial:
        return finding(
            "LEGEND-LABEL-CASE",
            "WARN",
            "Literal legend labels should start with display-style capitalization while preserving canonical model names",
            lower_initial,
        )
    if labels:
        return finding("LEGEND-LABEL-CASE", "PASS", "Detected literal legend labels start with display-style capitalization")
    return finding("LEGEND-LABEL-CASE", "PASS", "No auditable literal legend labels detected")


def check_interpolation_monotonicity(source: str, backend: str) -> Finding:
    if backend != "python" or not re.search(r"\bnp\.interp\s*\(", source):
        return finding("INTERP-MONOTONIC", "PASS", "No NumPy interpolation requiring an increasing x-grid detected")
    guards = regex_hits(
        [
            r"interp_(?:need_dec|monotone|strict)",
            r"np\.diff\s*\([^)]*\)\s*[><]=?\s*0",
            r"np\.argsort\s*\(",
            r"\[\s*::\s*-1\s*\]",
        ],
        source,
    )
    if guards:
        return finding(
            "INTERP-MONOTONIC",
            "PASS",
            "NumPy interpolation has an explicit monotonicity/reordering safeguard",
            guards,
        )
    return finding(
        "INTERP-MONOTONIC",
        "WARN",
        "np.interp requires an increasing xp grid and can silently return plausible wrong values for decreasing curves; assert monotonicity or reverse/sort xp and fp together",
        ["np.interp("],
    )


def check_uncertainty_encoding(source: str, _backend: str) -> Finding:
    stochastic = regex_hits(
        [
            r"\bseed(?:s|_[A-Za-z0-9_]+)?\b",
            r"random_state",
            r"\b(?:cv|cross_validation|validation)_folds?\b",
            r"\bfold_(?:id|index|score|scores|metric|metrics|mean|median)\b",
            r"\bn_folds?\b",
            r"\bn_splits\b",
            r"\b(?:train|test|validation|data|random)_split\b",
        ],
        source,
    )
    summaries = regex_hits([r"\bmedian\b", r"\bmean\b", r"\baverage\b"], source)
    if not stochastic or not summaries:
        return finding("UNCERTAINTY-ENCODING", "PASS", "No stochastic aggregate requiring a variability audit was inferred")
    encodings = regex_hits(
        [
            r"\b(?:xerr|yerr)\s*=",
            r"\.errorbar\s*\(",
            r"\.fill_between\s*\(",
            r"geom_(?:errorbar|ribbon|linerange|pointrange)\s*\(",
        ],
        source,
    )
    if encodings:
        return finding(
            "UNCERTAINTY-ENCODING",
            "PASS",
            "A stochastic aggregate and uncertainty encoding are both present; still verify every comparable panel",
            encodings,
        )
    return finding(
        "UNCERTAINTY-ENCODING",
        "WARN",
        "Seed/fold/split aggregates are present without an obvious error bar or uncertainty band",
        [*stochastic, *summaries],
    )


def check_rotation_anchor(source: str, backend: str) -> Finding:
    if backend != "python":
        return finding("ROTATION-ANCHOR", "PASS", "Matplotlib rotation anchoring is not applicable")
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return finding("ROTATION-ANCHOR", "WARN", "Rotation anchoring could not be audited until Python syntax is valid")

    anchored_receivers: set[str] = set()
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
            continue
        if node.func.attr != "set_rotation_mode" or not node.args:
            continue
        argument = node.args[0]
        if isinstance(argument, ast.Constant) and argument.value == "anchor":
            anchored_receivers.add(ast.dump(node.func.value, include_attributes=False))

    rotated: list[str] = []
    unanchored: list[str] = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        line = getattr(node, "lineno", "?")
        rotation_keyword = next((keyword for keyword in node.keywords if keyword.arg == "rotation"), None)
        if rotation_keyword is not None:
            evidence = f"line {line}: rotation=..."
            rotated.append(evidence)
            mode_keyword = next((keyword for keyword in node.keywords if keyword.arg == "rotation_mode"), None)
            anchored = (
                mode_keyword is not None
                and isinstance(mode_keyword.value, ast.Constant)
                and mode_keyword.value.value == "anchor"
            )
            if not anchored:
                unanchored.append(evidence)
        if isinstance(node.func, ast.Attribute) and node.func.attr == "set_rotation":
            evidence = f"line {line}: set_rotation(...)"
            rotated.append(evidence)
            receiver = ast.dump(node.func.value, include_attributes=False)
            if receiver not in anchored_receivers:
                unanchored.append(evidence)

    if not rotated:
        return finding("ROTATION-ANCHOR", "PASS", "No rotated Matplotlib text detected")
    if not unanchored:
        return finding("ROTATION-ANCHOR", "PASS", "Every detected rotated text call uses anchor-based placement", rotated)
    return finding(
        "ROTATION-ANCHOR",
        "WARN",
        "Some rotated text calls lack rotation_mode='anchor' and may cross their intended anchors after rotation",
        unanchored,
    )


def check_annotation_workarounds(source: str, backend: str) -> Finding:
    if backend != "python":
        return finding("ANNOTATION-WORKAROUND", "PASS", "No Matplotlib annotation workaround audit required")
    hits = regex_hits(
        [
            r"(?:LABEL|ANNOTATION)_Y\s*=\s*[-+]?\d+(?:\.\d+)?",
            r"bbox\s*=\s*dict\s*\([^)]*(?:facecolor|fc)\s*=\s*['\"](?:white|#fff(?:fff)?)['\"]",
        ],
        source,
    )
    if hits:
        return finding(
            "ANNOTATION-WORKAROUND",
            "WARN",
            "Hard-coded annotation heights or opaque white text masks can collide with uncertainty or cut visible gaps in curves; position from rendered/data bounds instead",
            hits,
        )
    return finding("ANNOTATION-WORKAROUND", "PASS", "No high-risk hard-coded label height or opaque white text mask detected")


def check_colormaps(source: str, _backend: str) -> Finding:
    hits = regex_hits(
        [
            r"(?:cmap|palette|colormap)\s*=\s*['\"](?:jet|rainbow|hsv)['\"]",
            r"plt\.cm\.(?:jet|rainbow|hsv)\b",
            r"scale_(?:color|fill)_(?:gradientn|distiller)\s*\([^)]*(?:rainbow|jet|hsv)",
            r"\brainbow\s*\(",
        ],
        source,
    )
    if hits:
        return finding("COLOR-MAP", "FAIL", "Rainbow/jet/hsv color mapping is not publication-safe", hits)
    return finding("COLOR-MAP", "PASS", "No rainbow/jet/hsv color mapping detected")


def check_editable_text(source: str, backend: str) -> Finding:
    if backend == "python":
        has_svg = bool(re.search(r"svg\.fonttype['\"]?\s*[:=]\s*['\"]none['\"]", source, re.IGNORECASE))
        has_pdf = bool(re.search(r"pdf\.fonttype['\"]?\s*[:=]\s*42\b", source, re.IGNORECASE))
        if has_svg and has_pdf:
            return finding("EDITABLE-TEXT", "PASS", "SVG and PDF editable-text settings are configured")
        missing = []
        if not has_svg:
            missing.append("svg.fonttype='none'")
        if not has_pdf:
            missing.append("pdf.fonttype=42")
        return finding("EDITABLE-TEXT", "FAIL", "Missing editable-text settings", missing)

    has_svg = bool(re.search(r"(?:svglite::)?svglite\s*\(", source))
    has_pdf = bool(re.search(r"(?:grDevices::)?cairo_pdf\s*\(", source))
    if has_svg and has_pdf:
        return finding("EDITABLE-TEXT", "PASS", "svglite and cairo_pdf editable-text devices are configured")
    missing = []
    if not has_svg:
        missing.append("svglite")
    if not has_pdf:
        missing.append("cairo_pdf")
    return finding("EDITABLE-TEXT", "FAIL", "Missing preferred editable-vector devices", missing)


def check_vector_exports(source: str, _backend: str) -> Finding:
    has_svg = bool(re.search(r"\.svg\b|svglite\s*\(", source, re.IGNORECASE))
    has_pdf = bool(re.search(r"\.pdf\b|cairo_pdf\s*\(|\bpdf\s*\(", source, re.IGNORECASE))
    if has_svg and has_pdf:
        return finding("EXPORT-VECTOR", "PASS", "Both SVG and PDF exports are present")
    missing = [name for name, present in (("SVG", has_svg), ("PDF", has_pdf)) if not present]
    return finding("EXPORT-VECTOR", "FAIL", f"Missing required vector export: {', '.join(missing)}")


def check_raster_exports(source: str, _backend: str) -> Finding:
    has_tiff = bool(re.search(r"\.tiff?\b|agg_tiff\s*\(|\btiff\s*\(", source, re.IGNORECASE))
    has_png = bool(re.search(r"\.png\b|agg_png\s*\(|\bpng\s*\(", source, re.IGNORECASE))
    if has_tiff:
        return finding("EXPORT-RASTER", "PASS", "TIFF raster export is present")
    if has_png:
        return finding("EXPORT-RASTER", "WARN", "PNG preview is present but no TIFF submission raster was found")
    return finding("EXPORT-RASTER", "FAIL", "No TIFF or PNG raster export found")


def check_resolution(source: str, _backend: str) -> Finding:
    values = [
        int(value)
        for value in re.findall(r"(?:dpi|res)\s*[:=]\s*(\d+)", source, re.IGNORECASE)
    ]
    if not values:
        return finding("RASTER-DPI", "WARN", "No explicit raster DPI/resolution found")
    below_minimum = sorted({value for value in values if value < 300})
    if below_minimum:
        return finding("RASTER-DPI", "FAIL", "Raster resolution below 300 dpi", [str(v) for v in below_minimum])
    if max(values) < 600:
        return finding("RASTER-DPI", "WARN", "Raster export meets the 300 dpi floor but not the 600 dpi default", [str(max(values))])
    return finding("RASTER-DPI", "PASS", "A raster export of at least 600 dpi is configured", [str(max(values))])


def candidate_widths_mm(source: str, backend: str) -> list[float]:
    widths: list[float] = []
    for value in re.findall(r"(?:fig_)?width_mm\s*[:=]\s*(\d+(?:\.\d+)?)", source, re.IGNORECASE):
        widths.append(float(value))
    if backend == "python":
        for value in re.findall(r"figsize\s*=\s*\(\s*(\d+(?:\.\d+)?)", source, re.IGNORECASE):
            widths.append(float(value) * 25.4)
    else:
        mm_calls = re.findall(
            r"(?:ggsave|agg_tiff|svglite|cairo_pdf)\s*\([^)]*?width\s*=\s*(\d+(?:\.\d+)?)[^)]*?units\s*=\s*['\"]mm['\"]",
            source,
            re.IGNORECASE | re.DOTALL,
        )
        widths.extend(float(value) for value in mm_calls)
    return widths


def check_dimensions(source: str, backend: str) -> Finding:
    widths = candidate_widths_mm(source, backend)
    if not widths:
        return finding("FINAL-WIDTH", "WARN", "No static final width detected; verify the target journal's current specification")
    width = widths[0]
    if abs(width - 89) <= 4 or abs(width - 183) <= 6:
        return finding("FINAL-WIDTH", "PASS", f"Detected width {width:.1f} mm matches a common journal column width")
    return finding(
        "FINAL-WIDTH",
        "WARN",
        f"Detected width {width:.1f} mm is not near the common 89/183 mm defaults; verify the target journal",
    )


def check_sampling(source: str, _backend: str) -> Finding:
    hits = regex_hits(
        [
            r"np\.random\.choice\s*\(",
            r"\.sample\s*\(",
            r"\bsample_n\s*\(",
            r"\bsample_frac\s*\(",
            r"\bslice_sample\s*\(",
        ],
        source,
    )
    if not hits:
        return finding("DATA-SAMPLING", "PASS", "No high-confidence row-sampling operation detected")
    documented = bool(re.search(r"sampling_(?:reason|rationale)|sampling\s+rationale|sample_size|random_state|set\.seed", source, re.IGNORECASE))
    if documented:
        return finding("DATA-SAMPLING", "WARN", "Sampling is present; confirm it is user-requested or scientifically justified and report its effect", hits)
    return finding("DATA-SAMPLING", "WARN", "Potential silent sampling detected; do not reduce data for rendering convenience", hits)


def check_exclusions(source: str, _backend: str) -> Finding:
    hits = regex_hits(
        [r"\.dropna\s*\(", r"\bna\.omit\s*\(", r"\bcomplete\.cases\s*\(", r"drop_na\s*\("],
        source,
    )
    if not hits:
        return finding("DATA-EXCLUSION", "PASS", "No high-confidence missing-data exclusion detected")
    reported = bool(re.search(r"n_before|n_after|before_count|after_count|excluded_count|dropped_count", source, re.IGNORECASE))
    if reported:
        return finding("DATA-EXCLUSION", "PASS", "Missing-data exclusion includes count-tracking markers", hits)
    return finding("DATA-EXCLUSION", "WARN", "Missing-data exclusion found without explicit before/after count tracking", hits)


def check_demo_data(source: str, _backend: str) -> Finding:
    hits = regex_hits(
        [
            r"np\.random\.(?:normal|uniform|rand|randn|poisson)\s*\(",
            r"(?:np\.random\.)?default_rng\s*\(",
            r"\brng\.(?:normal|uniform|poisson|choice)\s*\(",
            r"\b(?:rnorm|runif|rpois)\s*\(",
            r"make_(?:classification|regression|blobs)\s*\(",
        ],
        source,
    )
    if not hits:
        return finding("DEMO-DATA", "PASS", "No obvious simulated-data generator detected")
    isolated = bool(re.search(r"--demo|demo_mode|if\s*\([^)]*demo|if\s+.*demo", source, re.IGNORECASE))
    if isolated:
        return finding("DEMO-DATA", "WARN", "Simulated data is present behind an apparent demo path; keep it out of production runs", hits)
    return finding("DEMO-DATA", "WARN", "Simulated data appears in the plotting source without an explicit demo boundary", hits)


def check_log_guards(source: str, _backend: str) -> Finding:
    log_hits = regex_hits(
        [r"\bnp\.log(?:2|10)?\s*\(", r"\blog(?:2|10)?\s*\(", r"set_[xy]scale\s*\(\s*['\"]log", r"scale_[xy]_log10\s*\("],
        source,
    )
    if not log_hits:
        return finding("LOG-GUARD", "PASS", "No logarithmic transform or axis detected")
    guards = regex_hits(
        [
            r"clip\s*\([^)]*lower\s*=",
            r"pmax\s*\(",
            r"not\s+0\s*<[^\n]{0,80}",
            r"np\.any\s*\([^)]*<=\s*0",
            r"(?:padj|pval|p_value|pvalue)[^\n]{0,50}<=\s*0",
            r"non.?positive",
            r"strictly\s+positive|pseudocount|epsilon|eps\b",
        ],
        source,
    )
    if guards:
        return finding("LOG-GUARD", "PASS", "A logarithmic transform/axis and a positivity or pseudocount guard were both detected", guards)
    return finding("LOG-GUARD", "WARN", "Logarithmic operation found without an obvious positivity/pseudocount guard", log_hits)


def _python_multipanel_signals(source: str) -> list[str]:
    try:
        tree = ast.parse(source)
    except SyntaxError:
        return []
    signals: list[str] = []
    repeated_axis_calls = {name: 0 for name in ("add_subplot", "add_axes", "subplot", "subplot2grid")}
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        if isinstance(node.func, ast.Attribute):
            name = node.func.attr
        elif isinstance(node.func, ast.Name):
            name = node.func.id
        else:
            name = ""
        if name in repeated_axis_calls:
            repeated_axis_calls[name] += 1
        if name == "subplot_mosaic":
            signals.append("subplot_mosaic(...)")
        if name not in {"subplots", "GridSpec", "add_gridspec"}:
            continue
        values: dict[str, int] = {}
        for index, argument in enumerate(node.args[:2]):
            if isinstance(argument, ast.Constant) and isinstance(argument.value, int):
                values["nrows" if index == 0 else "ncols"] = int(argument.value)
        for keyword in node.keywords:
            if keyword.arg in {"nrows", "ncols"} and isinstance(keyword.value, ast.Constant) and isinstance(keyword.value.value, int):
                values[keyword.arg] = int(keyword.value.value)
        if values.get("nrows", 1) * values.get("ncols", 1) >= 2:
            signals.append(f"{name}({values.get('nrows', 1)}, {values.get('ncols', 1)})")
    for name, count in repeated_axis_calls.items():
        if count >= 2:
            signals.append(f"{count} {name} calls")
    return signals


def check_panel_alignment_gate(source: str, backend: str) -> Finding:
    if backend == "python":
        signals = _python_multipanel_signals(source)
        gate = regex_hits(
            [r"require_matplotlib_panel_alignment\s*\(", r"audit_layout_manifest\s*\("],
            source,
        )
    else:
        signals = regex_hits(
            [
                r"plot_layout\s*\(",
                r"wrap_plots\s*\(",
                r"patchworkGrob\s*\(",
                r"[A-Za-z][A-Za-z0-9_.]*\s*\|\s*[A-Za-z][A-Za-z0-9_.]*",
            ],
            source,
        )
        gate = regex_hits(
            [r"require_patchwork_panel_alignment\s*\(", r"audit_panel_alignment\.py"],
            source,
        )
    if signals and not gate:
        return finding(
            "PANEL-ALIGNMENT-GATE",
            "FAIL",
            "Multi-panel layout detected without the mandatory render-time panel-alignment gate",
            signals,
        )
    if gate:
        return finding(
            "PANEL-ALIGNMENT-GATE",
            "PASS",
            "Render-time panel-alignment gate is wired into the plotting source",
            gate,
        )
    return finding("PANEL-ALIGNMENT-GATE", "PASS", "No statically auditable multi-panel construct detected")


def check_backend_exclusivity(source: str, backend: str) -> Finding:
    if backend == "python":
        hits = regex_hits([r"\bRscript\b", r"\brpy2\b", r"ggplot2|ComplexHeatmap|patchwork"], source)
    else:
        hits = regex_hits([r"matplotlib|seaborn|plotly|reticulate::py_run"], source)
    if hits:
        return finding("BACKEND-EXCLUSIVE", "WARN", f"Possible cross-backend plotting reference found in {backend} source", hits)
    return finding("BACKEND-EXCLUSIVE", "PASS", f"No obvious cross-backend plotting reference found for {backend}")


CHECKS: tuple[Callable[[str, str], Finding], ...] = (
    check_syntax,
    check_font_family,
    check_font_sizes,
    check_mathtext_glyph_floor,
    check_legend_label_case,
    check_colormaps,
    check_editable_text,
    check_vector_exports,
    check_raster_exports,
    check_resolution,
    check_dimensions,
    check_sampling,
    check_exclusions,
    check_demo_data,
    check_log_guards,
    check_interpolation_monotonicity,
    check_uncertainty_encoding,
    check_rotation_anchor,
    check_annotation_workarounds,
    check_panel_alignment_gate,
    check_backend_exclusivity,
)


def validate_source(source: str, backend: str) -> list[Finding]:
    return [check(source, backend) for check in CHECKS]


def summarize(findings: Iterable[Finding], strict: bool = False) -> dict[str, object]:
    rows = list(findings)
    counts = {level: sum(row.level == level for row in rows) for level in ("PASS", "WARN", "FAIL")}
    ready = counts["FAIL"] == 0 and (not strict or counts["WARN"] == 0)
    return {"ready": ready, "strict": strict, "counts": counts}


def render_text(path: Path, backend: str, findings: list[Finding], strict: bool) -> str:
    summary = summarize(findings, strict)
    lines = [
        "Nature Figure Static Preflight",
        f"source: {path}",
        f"backend: {backend}",
        "",
    ]
    for row in findings:
        lines.append(f"[{row.level}] {row.check_id}: {row.message}")
        for item in row.evidence:
            lines.append(f"  - {item}")
    counts = summary["counts"]
    lines.extend(
        [
            "",
            f"summary: {counts['PASS']} pass, {counts['WARN']} warn, {counts['FAIL']} fail",
            f"verdict: {'READY FOR VISUAL QA' if summary['ready'] else 'FIX BEFORE DELIVERY'}",
            "note: static preflight does not validate statistics, source-data truth, or rendered appearance",
        ]
    )
    return "\n".join(lines)


def run_self_tests() -> None:
    good_python = '''
import matplotlib as mpl
import matplotlib.pyplot as plt
mpl.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": ["Arial", "Helvetica"],
    "font.size": 7,
    "svg.fonttype": "none",
    "pdf.fonttype": 42,
})
width_mm = 183
values = values[values > 0]
fig, ax = plt.subplots(figsize=(width_mm / 25.4, 120 / 25.4))
ax.set_yscale("log")
fig.savefig("figure.svg", bbox_inches="tight")
fig.savefig("figure.pdf", bbox_inches="tight")
fig.savefig("figure.tiff", dpi=600, bbox_inches="tight")
'''
    good_r = '''
library(ggplot2)
width_mm <- 183
p <- ggplot(df, aes(x, y)) + theme_classic(base_size = 7, base_family = "Arial")
svglite::svglite("figure.svg", width = width_mm / 25.4, height = 4)
print(p)
dev.off()
grDevices::cairo_pdf("figure.pdf", width = width_mm / 25.4, height = 4, family = "Arial")
print(p)
dev.off()
ragg::agg_tiff("figure.tiff", width = width_mm / 25.4, height = 4, units = "in", res = 600)
print(p)
dev.off()
'''
    bad_python = '''
import numpy as np
import matplotlib.pyplot as plt
x = np.random.normal(size=100)
x = np.random.choice(x, 12)
fig, ax = plt.subplots(figsize=(4, 3))
ax.scatter(x, np.log(x), c=x, cmap="jet")
ax.tick_params(labelsize=4)
fig.savefig("figure.png", dpi=72)
'''
    risky_rendered_python = good_python + '''
seed_values = np.median(seed_scores, axis=0)
equivalent_n = np.interp(target_error, errors_decreasing, sample_counts)
ax.plot(x, seed_values, label="+ semantic guidance")
ax.text(0.5, 0.5, "$R^2$", fontsize=7, rotation=45,
        bbox=dict(facecolor="white", edgecolor="none"))
LABEL_Y = 96.4
'''

    good_py_findings = validate_source(good_python, "python")
    good_py_failures = [row for row in good_py_findings if row.level == "FAIL"]
    assert not good_py_failures, good_py_failures

    good_r_findings = validate_source(good_r, "r")
    good_r_failures = [row for row in good_r_findings if row.level == "FAIL"]
    assert not good_r_failures, good_r_failures

    bad = {row.check_id: row for row in validate_source(bad_python, "python")}
    for check_id in ("FONT-FAMILY", "FONT-SIZE", "COLOR-MAP", "EDITABLE-TEXT", "EXPORT-VECTOR", "RASTER-DPI"):
        assert bad[check_id].level == "FAIL", (check_id, bad[check_id])
    for check_id in ("DATA-SAMPLING", "DEMO-DATA", "LOG-GUARD"):
        assert bad[check_id].level == "WARN", (check_id, bad[check_id])

    risky = {row.check_id: row for row in validate_source(risky_rendered_python, "python")}
    for check_id in (
        "FONT-GLYPH-FLOOR",
        "LEGEND-LABEL-CASE",
        "INTERP-MONOTONIC",
        "UNCERTAINTY-ENCODING",
        "ROTATION-ANCHOR",
        "ANNOTATION-WORKAROUND",
    ):
        assert risky[check_id].level == "WARN", (check_id, risky[check_id])


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("source", nargs="?", type=Path, help="Python or R plotting source")
    parser.add_argument("--backend", choices=("auto", "python", "r"), default="auto")
    parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
    parser.add_argument("--strict", action="store_true", help="treat warnings as not ready")
    parser.add_argument("--self-test", action="store_true", help="run dependency-free validator tests")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    if args.self_test:
        run_self_tests()
        print("validate_figure.py self-test: PASS")
        return 0
    if args.source is None:
        build_parser().error("source is required unless --self-test is used")
    if not args.source.is_file():
        print(f"error: source file not found: {args.source}", file=sys.stderr)
        return 2
    try:
        backend = detect_backend(args.source, args.backend)
        source = args.source.read_text(encoding="utf-8-sig")
    except (OSError, UnicodeError, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    findings = validate_source(source, backend)
    summary = summarize(findings, args.strict)
    if args.json:
        payload = {
            "source": str(args.source),
            "backend": backend,
            "summary": summary,
            "findings": [asdict(row) for row in findings],
        }
        print(json.dumps(payload, indent=2, ensure_ascii=False))
    else:
        print(render_text(args.source, backend, findings, args.strict))
    return 0 if summary["ready"] else 1


if __name__ == "__main__":
    raise SystemExit(main())
SKILL.md
---
name: nature-figure
description: >-
  Create, revise, audit, and export submission-grade scientific figures for Nature-family and other high-impact venues in Python (matplotlib/seaborn) or R (ggplot2/patchwork/ComplexHeatmap), including multi-panel plots, figures4papers-style work, and journal-ready SVG/PDF/TIFF outputs. Use for paper or scientific plots, manuscript data visualization, 论文配图、学术写作配图、科研绘图、科研作图、画图、作图、出图、论文图表、可视化. Define the conclusion, evidence logic, data integrity, template compatibility, export needs, and reviewer risks before plotting; honor or persist the Python/R backend choice. Also use the separate OpenRouter GPT Image 2 route for explicit AI-generated graphical abstracts, mechanism diagrams, concept schematics, 论文示意图、机制示意图、图形摘要; this route skips backend choice and treats outputs as drafts. Do not use for interactive dashboards, statistics-only analysis, data cleaning, literature review, code debugging, pure photo editing, or Illustrator/Figma-first infographics without manuscript-figure intent.
---

# Nature Figure Making — Router

This skill is split into two layers:

- A **static layer** under `static/` that holds versioned, reusable content fragments (the figure contract and default stance, plus a per-backend quick-start for Python and R).
- A **dynamic layer** (this file plus `manifest.yaml`) that detects the plotting backend and loads only the fragment needed for the current job. The large design, API, pattern, and QA material lives in on-demand references.

Do not try to apply the figure logic from memory or from this router. Always load fragments from disk as described below.

## Routing protocol

Follow these steps every time the skill is invoked.

### 0. Check for graphical-abstract and AI-schematic routes

For every graphical-abstract planning, generation, revision, or audit task that
uses AI, read
[references/ai-graphical-abstract-workflow.md](references/ai-graphical-abstract-workflow.md)
first. It owns the message/audience brief, composition and palette workflow,
policy gate, human scientific review, disclosure boundary, and provenance
requirements. A Nature Careers article is practitioner advice, not submission
clearance; verify the current official policy for the exact target journal.

If the request is planning or auditing only, do not ask for Python or R unless
the user also asks to render or revise a data-driven figure.

If the user explicitly asks to generate a manuscript schematic, graphical abstract, mechanism diagram, concept illustration, or paper schematic with OpenRouter, GPT Image 2, an image-generation API, or similar wording, do **not** ask "Python or R?". This is a non-plotting AI-schematic route.

For this route:

1. Read [manifest.yaml](manifest.yaml) and the `always_load` files.
2. Read [references/ai-graphical-abstract-workflow.md](references/ai-graphical-abstract-workflow.md).
3. Read [references/openrouter-image-generation.md](references/openrouter-image-generation.md).
4. Use [scripts/generate_openrouter_schematic.py](scripts/generate_openrouter_schematic.py) when the user wants a real API call or a reproducible payload.
5. Treat output as a draft schematic / graphical abstract, not as a quantitative data panel. Do not invent experimental values, author logos, institutional marks, or unsupported mechanisms. Keep internal usefulness separate from submission eligibility.

Only continue to the Python/R backend gate for plotting, charting, data visualization, or manuscript figure assembly tasks that are not explicit OpenRouter AI image-generation requests.

### 1. Load the manifest and the core layer

Read [manifest.yaml](manifest.yaml). It declares the `backend` axis, the allowed values, and the file paths each value maps to.

Also read every file listed under `always_load` (`static/core/contract.md` and `static/core/stance.md`). These hold the figure contract, the backend gate, the missing-runtime rule, the privacy rule, and the default operating stance that apply to every figure job.

### 2. Resolve the backend — a blocking gate

Backend selection blocks plotting tasks, but it should not annoy the same user forever. Decide the `backend` value in this order:

1. If the current request explicitly chooses Python or R, use that backend and save it with `scripts/nature_figure_backend.py set python` or `scripts/nature_figure_backend.py set r`.
2. If the request provides a clearly language-specific input file/workflow, use that backend and save it.
3. Otherwise run `scripts/nature_figure_backend.py get`. If it returns `python` or `r`, use the saved preference.
4. If no saved preference exists, ask exactly one concise question — **Python or R? I will remember this as your default.** — and stop. After the user answers, save the answer before proceeding.

- `python` — matplotlib / seaborn.
- `r` — ggplot2 / patchwork / ComplexHeatmap.

Do not guess or choose a backend by aesthetics alone. Only recommend a backend when the user explicitly asks you to choose; then use `references/backend-selection.md`, state the reason, save the selected backend, and proceed. Once selected, the backend is **exclusive** for all drawing, previewing, exporting, and visual QA (see `core/contract.md`). This gate does not apply to the explicit OpenRouter AI-schematic route above.

### 3. Load the matching backend fragment

After the backend is resolved, Read the mapped fragment (`static/fragments/backend/python.md` or `static/fragments/backend/r.md`). It carries the backend-only execution rule and the publication quick-start (rcParams/theme and export helper). Do **not** load the other backend's fragment.

### 4. Build the figure using the loaded material

Apply the loaded material in this order:

1. Figure contract (`core/contract.md`) — write the core conclusion, map the evidence chain, classify the archetype, set the journal/export contract, before any code.
2. Multi-panel evidence architecture — when planning, restructuring, or auditing a labelled multi-panel figure, load `references/multipanel-evidence-architecture.md`. Make the figure answer one Results-level scientific question; assign panels different inferential roles, not merely different metrics. When figure order must follow the manuscript argument, also load `../nature-shared/core/nature-results-discussion.md`.
3. Default stance (`core/stance.md`) — archetype-first composition, hero panel, restrained palette, statistics/integrity as part of the figure.
4. Backend fragment — the exclusive Python or R quick-start and execution rule.
5. Template adaptation — when reusing built-in original examples, licensed external material, or user-provided plotting code, load `references/asset-adaptation.md` before mapping data or changing the script.
6. Rendered QA and delivery preflight — load `references/qa-contract.md`, run the render-time panel-alignment gate for every multi-panel figure, `scripts/validate_figure.py` on the plotting source, `scripts/audit_pdf_text.py` on the exported PDF, and `scripts/audit_figure_collisions.py` on the same final PDF. Then inspect every panel and the complete figure at final physical size. Automated checks do not replace the panel-by-panel uncertainty, salience, spacing, and ambiguity audit.

For every figure containing two or more comparable panels, measure the **final
rendered plot-area rectangles** before export and preserve the alignment JSON.
Python figures must call `require_matplotlib_panel_alignment()` from
`scripts/audit_panel_alignment.py` after the final layout draw. R/patchwork
figures must source `scripts/panel_alignment.R`, write the patchwork layout
manifest at the final export dimensions, and run the same backend-neutral JSON
auditor. Use a default physical tolerance of `1.5 pt` for shared edges, widths,
heights, panel-label anchors and repeated gutters. `FIX BEFORE DELIVERY` or exit
code `1` blocks export; `NOT AUDITABLE` or exit code `2` blocks any claim that
alignment passed. A horizontal row of three or four equal-grid-span panels must
have equal final plot-area widths as well as equal heights and gutters; an
intentional unequal-width design requires a recorded `panel-width` exemption.
Structured unequal-span grids—including two stacked panels
beside one panel spanning both rows, in either column—must be inferred from
shared grid start/stop boundaries and checked automatically. Nested grids,
free-positioned hero panels, insets and colorbars may be excluded only through
explicit comparable groups or a recorded exemption with a reason. Do not
weaken the global tolerance to hide one intentional exception.

After every generated or revised Python/R scientific figure, export the final
PDF and run the collision audit again; this is mandatory after any change to
data geometry, text, fonts, legends, annotations, axes, error bars, panel size
or layout, not only at final submission. Use:

```bash
python skills/nature-figure/scripts/audit_figure_collisions.py figure.pdf \
  --json-out figure.collision-audit.json \
  --overlay-pdf figure.collision-audit.pdf
```

- `FIX BEFORE DELIVERY` or exit code `1`: repair the figure, re-export with the
  selected plotting backend, and rerun all rendered QA.
- `REVIEW REQUIRED`: inspect every WARN at final physical size; record why an
  intentional overlay is acceptable. Use `--strict` when WARN must block.
- `NOT AUDITABLE` or exit code `2`: report the dependency/PDF blocker and do not
  claim collision validation. Install `requirements.txt` when PyMuPDF is absent.

The collision audit reads PDF geometry for both Python and R output. It does not redraw
the scientific figure or authorize cross-backend plotting. Its optional marked
PDF is a QA-only diagnostic artifact and must never replace the selected
backend's source or submission files.

When the target is the flagship journal Nature, also load
`references/nature-article-requirements.md`. It separates initial-review files
from accepted-in-principle main and Extended Data production contracts and owns
the flagship legend limit.

When the target is Nature Machine Intelligence, instead load
`../nature-shared/journal-formats/nature-machine-intelligence.md`. Apply its
combined six-item main display budget, ten-item Extended Data maximum,
initial-versus-production boundary, 300-dpi/180-mm production checks and source-
data contract. NMI's current live pages do not assign a standalone per-legend
number, but its official 2018 brief guide set a historical advisory ceiling of
fewer than 300 English words per complete figure legend. Count the whole legend,
not each panel; aim for 150–250 words and keep it below 300 unless the live
submission system or editor gives a newer instruction. Do not import flagship
Nature's limit.

The chart serves the scientific logic; aesthetic polish is subordinate to making the core conclusion clear, defensible, and reviewable.

### 5. Reach for references only when needed

The files under `references/` are deep references, not defaults. Open them on demand per the `references.on_demand` table in the manifest — for example `references/figure-contract.md` to build the contract, `references/multipanel-evidence-architecture.md` to turn one Results-level question into complementary panel roles and a claim-escalating figure sequence, `references/asset-adaptation.md` to reuse a plotting template safely, `references/template-catalog.md` for validated Python CSV templates, `references/api.md` for the Python palette and numerical/layout safety helpers, `references/r-workflow.md` for R, `references/design-theory.md` for color/typography/export rationale, `references/common-patterns.md` and `references/chart-types.md` for layout/chart recipes, `references/nature-2026-observations.md` for real Nature page archetypes, `references/qa-contract.md` before final delivery, `references/nature-article-requirements.md` for exact flagship Nature stage and upload rules, `../nature-shared/journal-formats/nature-machine-intelligence.md` for exact NMI figure rules, `references/ai-graphical-abstract-workflow.md` for AI-assisted graphical-abstract planning, policy gating, human verification, and provenance, and `references/tutorials.md` / `references/demos.md` for worked examples.

Do not infer flagship Nature or NMI requirements from a Nature Communications
corpus or from the visual-style examples in this skill.

## Why this split

- The static layer is versioned and reviewable. The backend gate is now explicit in the manifest rather than buried in prose.
- The dynamic layer keeps each invocation cheap: only the selected backend's quick-start enters context, and the 2,600+ lines of reference depth load only when a step needs them.
- The router itself is short on purpose. Update fragments and references, not this file, when adding scope.
- This structure mirrors `nature-writing`, `nature-polishing`, `nature-reader`, and `nature-paper2ppt`.
static/core/contract.md
# Figure contract before plotting

A publication-quality scientific figure is a visual argument, not an isolated pretty plot. Every figure starts from a claim, an evidence hierarchy, and a review-risk check before code or aesthetics. Before generating or editing code, establish the contract below.

## Backend selection uses a saved preference

For plotting tasks, first honor an explicit Python/R choice in the current request or a clearly language-specific input file/workflow. Save that backend as the user's default with `scripts/nature_figure_backend.py set python` or `scripts/nature_figure_backend.py set r`.

If the current request does not specify a backend, check the saved preference with `scripts/nature_figure_backend.py get`. If it returns `python` or `r`, use that backend without asking again.

If no saved preference exists, ask one concise question: **Python or R? I will remember this as your default.** Then stop and wait for the user's answer. Do not generate mock data, write scripts, create figures, or choose Python/R by default before this first preference is established. After the user answers, save it and proceed.

Only recommend a backend when the user explicitly asks you to choose or recommend one. In that case, use `references/backend-selection.md`, state the reason, save the selected backend, and then proceed with the recommended backend.

## The selected backend is exclusive

Once Python or R is selected, every plotting script, preview image, SVG/PDF/TIFF/PNG export, QA render, and visual workaround must be produced by that same backend. Do not use Python to draw a preview for an R figure, and do not use R to draw a preview for a Python figure, even if the selected runtime or packages are missing locally. The non-selected language may only be used for non-visual file inspection or data conversion when it does not open a graphics device, import plotting libraries, create image/vector files, or change the final visual appearance.

The backend-neutral `audit_panel_alignment.py`, `audit_pdf_text.py` and
`audit_figure_collisions.py` tools may inspect a layout manifest or final PDF
from either backend. They do not redraw the scientific content. The selected
backend must measure its own final axes/grob geometry before the alignment JSON
is audited. Alignment and collision overlays are QA-only, not previews,
substitute exports or submission assets; keep the original selected-backend
figure authoritative.

## Missing runtime/package rule

After the backend is selected, check the selected runtime early (`Rscript`/R for R; Python and required plotting packages for Python). If the selected runtime or required packages are unavailable, stop before rendering and report the exact blocker. You may provide a selected-backend script and installation commands, or ask permission to install dependencies, but you must not fall back to the other language to make a substitute figure.

## Data-integrity gate

Use all user-provided observations and requested variables unless an exclusion has a scientific or statistical justification or the user explicitly requests a subset. Never reduce data merely to make a plot easier or faster to render. For large point clouds, prefer rasterized marks, hexbin/density representations, aggregation with a stated rule, or another backend-native rendering strategy.

If any row, column, replicate, image, or category is excluded, record the before/after counts, the exact rule, and the reason in the QA notes. Preserve the unmodified source data and never silently select convenient columns to satisfy a template.

Plan figures by scientific claims, not by source tables. Do not turn each table into a separate figure when several tables answer the same question. If an effect is defined within matched datasets, subjects, seeds, or tasks, inspect and visualize paired differences rather than relying only on overlapping marginal distributions; large between-unit heterogeneity can hide a strong paired effect.

## The five-point contract

1. **Core conclusion**: write the one-sentence claim the figure must defend and the Results-level scientific question it answers.
2. **Evidence chain**: map each planned panel to one distinct inferential role in that claim, and drop, merge, or demote panels that only redraw another panel's evidence or repeat it under a secondary metric.
3. **Archetype**: classify the figure as `quantitative grid`, `schematic-led composite`, `image plate + quant`, or `asymmetric mixed-modality figure`.
4. **Backend**: use the explicit or saved Python/R track exclusively for all figure drawing, previewing, exporting, and visual QA. Do not cross-render with the other language.
5. **Journal/export contract**: set final dimensions, a 5 pt floor for every rendered glyph, editable text, source data, statistics, image-integrity notes, export formats, a blocking multi-panel alignment gate, and automatic rendered collision QA before styling.

The highest-priority rule is: **the chart serves the scientific logic**. Aesthetic polish, template matching, and complex layout are subordinate to making the core conclusion clear, defensible, and reviewable.

For the full method to convert a request into core conclusion, evidence hierarchy, panel map, and review-risk checks, open `references/figure-contract.md`.
For a labelled multi-panel figure or a manuscript-level figure sequence, also
open `references/multipanel-evidence-architecture.md`: use one figure-level
claim as the planning default, give panels complementary evidence roles, and
align successive figures with the Results claim escalation.
static/core/stance.md
# Default operating stance

The older Python/matplotlib rules in this skill remain valid. The skill also supports R, especially `ggplot2 + patchwork + ComplexHeatmap + ggrepel + svglite/cairo_pdf + ragg`.

## Color policy

Prefer **unified method families across all panels** over maximal hue separation. For dense Nature Machine Intelligence-style figure pages, use the low-saturation `NMI pastel` family described in `references/api.md` and reserve green/red mainly for gains, drops, and other directional cues.

## Stance

- Start by classifying the requested figure into one of four archetypes: `quantitative grid`, `schematic-led composite`, `image plate + quant`, or `asymmetric mixed-modality figure`.
- Prefer one **hero panel** plus subordinate evidence panels over filling the canvas with equal-sized subplots.
- If the user asks for a single chart, still identify its role in the manuscript claim: discovery, mechanism, validation, comparison, robustness, or clinical/biological relevance.
- Keep the background white for plots and diagrams; switch to black only for microscopy / volume-rendering image plates.
- Prefer direct labels over legends when categories are spatially fixed or the legend would force unnecessary eye travel.
- Keep one restrained palette per figure: usually one neutral family, one signal family, and one accent family.
- Treat perceptual separation as necessary but not sufficient: verify that the intended hero series is more salient than baselines after rendering, and do not reuse a sequential light-to-dark scale as unrelated categorical colors.
- Treat statistics, `n`, error-bar definitions, source-data traceability, and image-integrity notes as part of the figure, not as optional caption cleanup.
- When panels show comparable seed/fold/split aggregates, use the same uncertainty definition in every comparable panel or state why a panel is exempt.
- Preserve canonical model capitalization in display labels. Legend entries use display-style initial capitalization; prose follows normal sentence grammar. Never apply blind `.title()` transformations to names such as `XGBoost`, `DeepSeek`, `GPT-5.2`, or `RF`.
- Require the render-time alignment gate for every multi-panel layout, followed by a final-size, panel-by-panel visual inspection. Source validation cannot prove color hierarchy, label clearance, legend spacing, or complete uncertainty coverage.
- When the user asks for broad `Nature` style rather than ML/NMI-specific style, read `references/nature-2026-observations.md` before choosing layout.
- When the user references `figures4papers` or the older `scientific-figure-making` skill, treat this skill as the successor and open `references/demos.md` for the third-party demo map, copyright boundary, and original reimplementation guidance.

## User-facing privacy rule

Do not disclose private local paths, private filenames, chat-attachment names, internal reference filenames, template identifiers, or the provenance of private working materials in user-facing replies, generated code comments, figure legends, reports, or manuscript text. Use generic descriptions such as "the provided R template collection", "a private working draft", or "the internal figure contract". If the user provides a private plotting template collection, use it only as an internal adaptation source and do not reveal its path, filenames, or provenance. Only reveal an exact path or source file when the user explicitly asks for that audit trail.

## When to load this skill

- Python or R figures for **papers, slides, or reports** targeting Nature, Science, Cell, NeurIPS, ICLR, or similar venues.
- Requests involving **grouped bars, trend lines, heatmaps, radar plots, multi-panel grids**, or **PDF/SVG/high-DPI** output.
- Any mention of "Nature style", "publication figure", "paper figure", "SCI figure", "figures4papers", "scientific-figure-making", "R plotting template", or "high-quality scientific plot".
- Requests to improve a figure's logic, aesthetics, panel layout, figure legend, export quality, or journal-readiness.

## When NOT to load

- Plotly, Altair, Bokeh, or other interactive/web-first plotting.
- EDA-only plots without a publication target.
- Primary workflow is 3D, GIS, or non-scientific illustration tooling.
- Illustrator / Figma–first layout.
static/fragments/backend/python.md
# Backend: Python (matplotlib / seaborn)

**Python-only execution rule.** When the user has selected Python, do all figure drawing, previewing, exporting, and visual QA in Python. Do not call R/ggplot2, ComplexHeatmap, patchwork, or any R graphics device to create a temporary preview, fallback export, or layout approximation. If Python or required Python plotting packages are missing, stop before rendering and report the missing dependency. You may still write the Python script, provide `pip`/environment install commands, or ask permission to install dependencies, but do not cross-render the figure in R.

## Python quick-start

```python
import matplotlib as mpl
import matplotlib.pyplot as plt
# Copy audit_panel_alignment.py beside the plotting source or add the skill's
# scripts directory to PYTHONPATH before importing it.
from audit_panel_alignment import require_matplotlib_panel_alignment

mpl.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans", "sans-serif"],
    "svg.fonttype": "none",     # editable text in SVG
    "pdf.fonttype": 42,         # editable TrueType text in PDF
    "font.size": 7,             # use 15-24 only for large slide-sized panels
    "axes.spines.right": False,
    "axes.spines.top": False,
    "axes.linewidth": 0.8,
    "legend.frameon": False,
})

def save_pub_py(fig, filename, dpi=600):
    # Run after every layout-affecting change, before export. Single-panel
    # figures record NOT APPLICABLE; unresolved multi-panel geometry blocks.
    require_matplotlib_panel_alignment(
        fig,
        json_out=f"{filename}.alignment.json",
        overlay_svg=f"{filename}.alignment.svg",
        tolerance_pt=1.5,
        gutter_tolerance_pt=1.5,
        strict=True,
    )
    fig.savefig(f"{filename}.svg", bbox_inches="tight")
    fig.savefig(f"{filename}.pdf", bbox_inches="tight")
    fig.savefig(f"{filename}.tiff", dpi=dpi, bbox_inches="tight")
```

Use `text.usetex = True` only when LaTeX is installed and math-rich labels are required.

## Going deeper

- `references/api.md` — Python PALETTE, helper function signatures, validation rules.
- `references/template-catalog.md` — validated CSV-driven volcano, ROC, dot-plot, marginal, and paired templates backed by `scripts/plot_templates.py`.
- `references/common-patterns.md` — hero panels, legend-only axes, dark image plates, asymmetric layouts.
- `references/chart-types.md` — radar, 3D sphere, fill_between, scatter patterns.
- `references/tutorials.md` — end-to-end walkthroughs for bars, trends, heatmaps.
- `references/demos.md` — third-party figures4papers demo map, copyright boundary, and original Python reimplementation guidance.
- `scripts/validate_figure.py` — dependency-free source preflight before rendering and visual QA.
- `scripts/audit_pdf_text.py` — dependency-free `Tf` scan of exported PDF text runs; use it to enforce the 5 pt glyph floor after rendering.
- `scripts/audit_panel_alignment.py` — mandatory render-time Matplotlib axes alignment gate for every multi-panel figure; call `require_matplotlib_panel_alignment()` after the final layout draw and before export.
- `scripts/audit_figure_collisions.py` — mandatory PyMuPDF geometry audit after every generated or layout-affecting revision; fix text-text, text-stroke and clipping FAIL findings before delivery.
- `scripts/figure_safety.py` — strict monotone interpolation and annotation placement above uncertainty extents.
static/fragments/backend/r.md
# Backend: R (ggplot2 / patchwork / ComplexHeatmap)

**R-only execution rule.** When the user has selected R, do all figure drawing, previewing, exporting, and visual QA in R. Do not call matplotlib/seaborn or any Python graphics device to create a temporary preview, fallback export, or layout approximation. If `Rscript`/R or required R packages are missing, stop before rendering and report the exact blocker. You may still write the R script, provide install commands (for example `install.packages(...)`), or ask permission to install dependencies, but do not cross-render the figure in Python.

## R quick-start

```r
library(ggplot2)
library(patchwork)
source("skills/nature-figure/scripts/panel_alignment.R")

theme_set(
  theme_classic(base_size = 6.5, base_family = "Arial") +
    theme(
      axis.line = element_line(linewidth = 0.35, colour = "black"),
      axis.ticks = element_line(linewidth = 0.35, colour = "black"),
      legend.title = element_text(size = 6.2),
      legend.text = element_text(size = 5.8),
      strip.text = element_text(size = 6.2, face = "bold"),
      plot.title = element_text(size = 7, face = "bold"),
      panel.grid = element_blank()
    )
)

save_pub_r <- function(plot, filename, width_mm = 183, height_mm = 120, dpi = 600) {
  w <- width_mm / 25.4
  h <- height_mm / 25.4
  if (inherits(plot, "patchwork")) {
    # Run after every layout-affecting change, before export.
    require_patchwork_panel_alignment(
      plot,
      manifest_path = paste0(filename, ".alignment-layout.json"),
      report_path = paste0(filename, ".alignment.json"),
      overlay_svg = paste0(filename, ".alignment.svg"),
      width_in = w,
      height_in = h,
      tolerance_pt = 1.5,
      gutter_tolerance_pt = 1.5,
      strict = TRUE
    )
  }
  svglite::svglite(paste0(filename, ".svg"), width = w, height = h)
  print(plot)
  dev.off()
  grDevices::cairo_pdf(paste0(filename, ".pdf"), width = w, height = h, family = "Arial")
  print(plot)
  dev.off()
  ragg::agg_tiff(paste0(filename, ".tiff"), width = w, height = h, units = "in", res = dpi)
  print(plot)
  dev.off()
}
```

## Going deeper

- `references/r-workflow.md` — the R plotting workflow when the user provides R scripts, templates, or data.
- `references/r-template-index.md` — adapt a user-provided or private R template collection without exposing source paths.
- `references/design-theory.md` — typography, color theory, layout rationale, export policy (backend-agnostic).
- `references/nature-2026-observations.md` — real Nature page archetypes to match before choosing layout.
- `scripts/validate_figure.py` — dependency-free static R source preflight before running R and inspecting the rendered outputs.
- `scripts/audit_pdf_text.py` — inspect the final R-generated PDF's rendered glyph floor without redrawing it.
- `scripts/panel_alignment.R` plus `scripts/audit_panel_alignment.py` — mandatory render-time patchwork/gtable alignment gate for every multi-panel figure; declare comparable groups explicitly for asymmetric layouts.
- `scripts/audit_figure_collisions.py` — mandatory backend-neutral geometry audit after every generated or layout-affecting revision; its optional marked PDF is QA-only and does not replace the R export.
tests/test_figure_safety.py
from __future__ import annotations

import importlib.util
import sys
import unittest
import zlib
from pathlib import Path


SKILL = Path(__file__).parents[1]


def load_module(name: str, path: Path):
    spec = importlib.util.spec_from_file_location(name, path)
    assert spec and spec.loader
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    spec.loader.exec_module(module)
    return module


VALIDATOR = load_module("nature_figure_validator_test", SKILL / "scripts" / "validate_figure.py")
PDF_AUDIT = load_module("nature_figure_pdf_audit_test", SKILL / "scripts" / "audit_pdf_text.py")
FIGURE_SAFETY = load_module("nature_figure_safety_test", SKILL / "scripts" / "figure_safety.py")


def fake_pdf(stream: bytes, compressed: bool = False) -> bytes:
    payload = zlib.compress(stream) if compressed else stream
    filter_entry = b" /Filter /FlateDecode" if compressed else b""
    return (
        b"%PDF-1.4\n1 0 obj\n<< /Length "
        + str(len(payload)).encode("ascii")
        + filter_entry
        + b" >>\nstream\n"
        + payload
        + b"\nendstream\nendobj\n%%EOF\n"
    )


class PdfTextAuditTests(unittest.TestCase):
    def test_plain_pdf_passes_when_all_tf_sizes_meet_floor(self):
        result = PDF_AUDIT.audit_pdf(fake_pdf(b"BT /F1 7 Tf (Label) Tj ET"))
        self.assertTrue(result["auditable"])
        self.assertEqual(result["minimum_found_pt"], 7.0)
        self.assertEqual(result["below_minimum_count"], 0)

    def test_flate_pdf_catches_mathtext_sized_run(self):
        result = PDF_AUDIT.audit_pdf(
            fake_pdf(b"BT /F1 7 Tf (R) Tj /F2 4.9 Tf (2) Tj ET", compressed=True)
        )
        self.assertTrue(result["auditable"])
        self.assertEqual(result["minimum_found_pt"], 4.9)
        self.assertEqual(result["below_minimum_count"], 1)
        self.assertEqual(result["below_minimum"][0]["font"], "F2")

    def test_pdf_without_supported_tf_is_not_auditable(self):
        result = PDF_AUDIT.audit_pdf(fake_pdf(b"0 0 m 10 10 l S"))
        self.assertFalse(result["auditable"])


class SourceSafetyTests(unittest.TestCase):
    def findings(self, source: str):
        return {row.check_id: row for row in VALIDATOR.validate_source(source, "python")}

    def test_risky_patterns_are_reported(self):
        source = '''
import numpy as np
seed_scores = np.median(scores, axis=0)
n_equiv = np.interp(target, decreasing_error, n_samples)
ax.plot(x, seed_scores, label="+ semantic guidance")
ax.text(0.5, 0.5, "$R^2$", fontsize=7, rotation=45,
        bbox=dict(facecolor="white", edgecolor="none"))
LABEL_Y = 96.4
'''
        rows = self.findings(source)
        for check_id in (
            "FONT-GLYPH-FLOOR",
            "LEGEND-LABEL-CASE",
            "INTERP-MONOTONIC",
            "UNCERTAINTY-ENCODING",
            "ROTATION-ANCHOR",
            "ANNOTATION-WORKAROUND",
        ):
            self.assertEqual(rows[check_id].level, "WARN", check_id)

    def test_guarded_patterns_pass_targeted_checks(self):
        source = '''
import numpy as np
def interp_monotone(target, xp, fp):
    order = np.argsort(xp)
    xp = xp[order]
    fp = fp[order]
    assert np.all(np.diff(xp) > 0)
    return np.interp(target, xp, fp)

seed_scores = np.median(scores, axis=0)
ax.plot(x, seed_scores, label="+ Semantic guidance")
ax.fill_between(x, seed_scores - seed_std, seed_scores + seed_std)
ax.text(0.5, 0.5, "R²", fontsize=7, rotation=45, rotation_mode="anchor")
'''
        rows = self.findings(source)
        for check_id in (
            "FONT-GLYPH-FLOOR",
            "LEGEND-LABEL-CASE",
            "INTERP-MONOTONIC",
            "UNCERTAINTY-ENCODING",
            "ROTATION-ANCHOR",
            "ANNOTATION-WORKAROUND",
        ):
            self.assertEqual(rows[check_id].level, "PASS", check_id)

    def test_string_split_is_not_mistaken_for_random_split(self):
        source = '''
columns = [value.strip() for value in args.columns.split(",")]
ax.set_ylabel("Mean fold change")
'''
        rows = self.findings(source)
        self.assertEqual(rows["UNCERTAINTY-ENCODING"].level, "PASS")

    def test_every_rotated_call_must_be_anchored(self):
        source = '''
ax.text(0.2, 0.3, "A", rotation=45, rotation_mode="anchor")
ax.text(0.5, 0.6, "B", rotation=90)
'''
        rows = self.findings(source)
        self.assertEqual(rows["ROTATION-ANCHOR"].level, "WARN")
        self.assertEqual(rows["ROTATION-ANCHOR"].evidence, ["line 3: rotation=..."])

    def test_python_multipanel_source_requires_alignment_gate(self):
        missing = self.findings("fig, axes = plt.subplots(2, 2)")
        self.assertEqual(missing["PANEL-ALIGNMENT-GATE"].level, "FAIL")

        import_only = self.findings(
            "from audit_panel_alignment import require_matplotlib_panel_alignment\n"
            "fig, axes = plt.subplots(2, 2)"
        )
        self.assertEqual(import_only["PANEL-ALIGNMENT-GATE"].level, "FAIL")

        manual_axes = self.findings(
            "fig = plt.figure()\n"
            "ax_a = fig.add_axes([0.1, 0.1, 0.3, 0.8])\n"
            "ax_b = fig.add_axes([0.6, 0.1, 0.3, 0.8])"
        )
        self.assertEqual(manual_axes["PANEL-ALIGNMENT-GATE"].level, "FAIL")

        mosaic = self.findings("fig, axes = plt.subplot_mosaic([['a', 'b']])")
        self.assertEqual(mosaic["PANEL-ALIGNMENT-GATE"].level, "FAIL")

        wired = self.findings(
            '''
fig, axes = plt.subplots(2, 2)
require_matplotlib_panel_alignment(fig, strict=True)
'''
        )
        self.assertEqual(wired["PANEL-ALIGNMENT-GATE"].level, "PASS")

    def test_r_patchwork_source_requires_alignment_gate(self):
        missing = {
            row.check_id: row
            for row in VALIDATOR.validate_source(
                "fig <- (p_a | p_b) + plot_layout(guides = 'collect')",
                "r",
            )
        }
        self.assertEqual(missing["PANEL-ALIGNMENT-GATE"].level, "FAIL")

        manifest_only = {
            row.check_id: row
            for row in VALIDATOR.validate_source(
                "fig <- p_a | p_b\nwrite_patchwork_panel_layout(fig, manifest_path='a.json')",
                "r",
            )
        }
        self.assertEqual(manifest_only["PANEL-ALIGNMENT-GATE"].level, "FAIL")

        wired = {
            row.check_id: row
            for row in VALIDATOR.validate_source(
                "fig <- p_a | p_b\nrequire_patchwork_panel_alignment(fig, manifest_path='a.json')",
                "r",
            )
        }
        self.assertEqual(wired["PANEL-ALIGNMENT-GATE"].level, "PASS")

    def test_r_delimiter_check_ignores_comments(self):
        finding = VALIDATOR.check_syntax(
            "# patchwork's final panel cells { are measured\nfig <- (p_a | p_b)",
            "r",
        )
        self.assertEqual(finding.level, "WARN")
        self.assertIn("delimiter check passed", finding.message)


class NumericalSafetyTests(unittest.TestCase):
    def test_interp_monotone_handles_decreasing_grid(self):
        result = FIGURE_SAFETY.interp_monotone(7, [10, 8, 6], [5, 10, 15])
        self.assertAlmostEqual(float(result), 12.5)

    def test_interp_monotone_rejects_direction_changes(self):
        with self.assertRaisesRegex(ValueError, "strictly monotone"):
            FIGURE_SAFETY.interp_monotone(7, [10, 6, 8], [5, 10, 15])

    def test_label_position_clears_uncertainty(self):
        label_y = FIGURE_SAFETY.label_y_above([80, 90], spread=[2, 6])
        self.assertGreater(label_y, 96)


if __name__ == "__main__":
    unittest.main()