返回 Skills
yuan1z0825/nature-skills· Apache-2.0 内容可用

nature-figure

>-

安装

与 skills.sh 相同的 Command / Prompt 安装方式


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 the OpenRouter AI-schematic route

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 and the always_load files.
  2. Read references/openrouter-image-generation.md.
  3. Use scripts/generate_openrouter_schematic.py when the user wants a real API call or a reproducible payload.
  4. 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.

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. 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. Default stance (core/stance.md) — archetype-first composition, hero panel, restrained palette, statistics/integrity as part of the figure.
  3. Backend fragment — the exclusive Python or R quick-start and execution rule.
  4. Template adaptation — when reusing bundled examples or user-provided plotting code, load references/asset-adaptation.md before mapping data or changing the script.
  5. Delivery preflight — before final delivery, load references/qa-contract.md, run scripts/validate_figure.py on the plotting source, then inspect the rendered outputs at final size.

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/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 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, and references/tutorials.md / references/demos.md for worked examples.

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.

附带文件

.gitignore
.DS_Store
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)
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 scripts/validate_figure.py on the Python source, resolves FAIL findings, reviews WARN findings, renders only with Python, and inspects the actual SVG/PDF/TIFF or PNG outputs at final physical size. It must not treat static validation as proof of statistical or 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."
        }
      ],
      "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": "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.1.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:
  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: 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: before final delivery, revision package, microscopy/blot figure, or journal-specific audit
      path: references/qa-contract.md
    - condition: typography, color theory, layout rationale, export policy
      path: references/design-theory.md
    - condition: Python PALETTE, helper function signatures, 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: writing or auditing figure/table legend text — Fig. N | title, panel style, stats in legend, Source Data boilerplate, attribution
      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: bundled figures4papers Python scripts and output previews for concrete pattern adaptation
      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 Figure 1, mechanism diagrams, workflows, graphical abstracts, or supplementary figures.
- Check panel labels, color, typography, statistical annotations, source data, and export formats.
- When explicitly requested, call `openai/gpt-image-2` through the OpenRouter Images API to draft AI concept schematics.

## 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.
- 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.

## 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 |
| figures4papers demo | <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, and multi-metric comparison grammar from real paper scripts |

## 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, 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/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`: `figures4papers` demos and reusable patterns.
- `references/qa-contract.md`: export QA, source-data constraints, and static-preflight entry points.
- `scripts/validate_figure.py`: reproducible static QA for Python and R plotting source.
- `assets/figures4papers/`: packaged demo scripts and previews.

## Boundaries

- AI-generated images are not treated as real experimental results or quantitative data panels.
- 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.
- Private templates can be used locally, but user-facing outputs should not expose private paths, filenames, or sources.

## 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 1、机制图、workflow、graphical abstract 或补充图。
- 检查面板标签、配色、字体、统计标注、source data 和导出格式。
- 在用户明确要求时,通过 OpenRouter Images API 调用 `openai/gpt-image-2` 生成 AI 概念示意图草稿。

## 工作方式

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

- 核心结论:这张图要证明什么。
- 证据层级:哪些面板是主证据,哪些是补充解释。
- 图件原型:散点、箱线、热图、机制图、流程图、多面板组合等。
- 后端选择:Python 或 R;第一次选择后会作为默认偏好复用。
- 数据完整性:默认保留全部观测和指定变量,任何排除都记录规则与前后计数。
- 模板兼容性:先核对科学含义、数据结构和变换条件,再决定精确复用、结构适配或只继承样式。
- 投稿约束:尺寸、字体、色彩、分辨率、矢量格式和 source-data 可追溯性。

## 典型请求

- “把这组数据做成 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 demo | <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 映射、排除计数和投稿前 QA 记录。
- AI 示意图任务中,输出概念草稿和需要人工重画/核实的元素列表。

## 内置参考

- `references/api.md`:Python 配色、样式和绘图 helper 约定。
- `references/asset-adaptation.md`:模板语义匹配、字段映射和数据完整性规则。
- `references/template-catalog.md`:volcano、ROC、marker dot plot、marginal 和 paired 的已验证 Python CSV 模板。
- `references/chart-types.md`:常见图型选择和视觉规则。
- `references/demos.md`:`figures4papers` demo 与可借鉴模式。
- `references/qa-contract.md`:导出前检查项、source-data 约束和静态预检入口。
- `scripts/validate_figure.py`:Python/R 绘图源码的可复现静态 QA。
- `assets/figures4papers/`:打包的 demo 脚本与预览图。

## 边界

- 不会把 AI 生成图片当作真实实验结果或定量数据面板。
- 不会凭空补统计检验、样本量、误差线含义或实验条件。
- 不会为了渲染方便静默抽样、忽略变量或删除不完整观测。
- 私有模板可以在本机使用,但不应在面向用户输出中暴露私有路径、文件名或来源。

## 相关技能

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

## 与其他技能的关系

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

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.06, y=1.02, fontsize=14,
                    color='black', fontweight='bold'):
    """Place a Nature-style panel label near the top-left edge."""
    ax.text(
        x, y, label,
        transform=ax.transAxes,
        fontsize=fontsize,
        fontweight=fontweight,
        color=color,
        ha='left',
        va='bottom',
    )
```

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, color='white')`

---

## 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,
                     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

    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)
    containers = []
    for i, (vals, label, color) in enumerate(zip(series, labels, colors)):
        offset = (i - (n_groups - 1) / 2) * w
        bars = ax.bar(x + offset, vals, width=w, label=label,
                      color=color, edgecolor='black', linewidth=1.5,
                      error_kw=error_kw)
        containers.append(bars)
        if annotate:
            for bar, val in zip(bars, vals):
                ax.text(bar.get_x() + bar.get_width() / 2,
                        bar.get_height() + 0.01,
                        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=False, 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')
    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)
```

---

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

```python
def finalize_figure(fig, out_path, formats=None, dpi=300,
                    pad=2, bbox_inches=None, close=True):
    """
    Apply tight_layout and save figure.

    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)
    """
    import os
    from pathlib import Path
    fig.tight_layout(pad=pad)
    base = Path(out_path)
    os.makedirs(base.parent, exist_ok=True)
    if formats is None:
        formats = [base.suffix.lstrip('.') or 'png']
        base = base.with_suffix('')
    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_trend`: each array in `y_series` must have same length as `x`.
- `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. Treat static validation as preflight only; it cannot confirm statistical correctness or visual quality.
4. Inspect SVG/PDF text editability, raster resolution, clipping, overlaps, color accessibility, and readability at final physical size.
5. Include the field mapping, exclusions, transform changes, and remaining caveats in the QA notes.
references/backend-selection.md
# Backend Selection

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

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. The bundled
`figure_VIGIL/plot_concept.py` demo pairs a 1D probability-density panel with 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
# See ../assets/figures4papers/figure_VIGIL/plot_concept.py for the full pattern.
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. The bundled `figure_VIGIL/plot_ablation.py` demo
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
# See ../assets/figures4papers/figure_VIGIL/plot_ablation.py for the full pattern.
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

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) — Bundled figures4papers scripts and previews
references/demos.md
# figures4papers Demo 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 example
instead of only abstract style rules.

The bundled examples live under `../assets/figures4papers/`. They are reference
materials for the Python track only. Keep the normal `nature-figure` contract first:
define the scientific claim, pick Python or R, and only then adapt a demo pattern.

## How to use the demos

1. Select the closest chart family from the table below.
2. Read the listed `plot_*.py` files for layout, palette, axis, legend, and export
   patterns.
3. Reuse the pattern, not the demo data or manuscript-specific labels.
4. Preserve editable SVG/PDF/TIFF export rules from `api.md`.
5. Do not reveal local repository paths or internal asset filenames in user-facing
   prose unless the user asks for an audit trail.

## Bundled project map

| Project | Open when | Local examples |
|---------|-----------|----------------|
| `figure_ImmunoStruct` | Method comparison bars, ablation bars, large readable annotations | `../assets/figures4papers/figure_ImmunoStruct/plot_bars.py`, `raw_data.py`, `figures/*.png` |
| `figure_CellSpliceNet` | Compact comparison bars, cross-species benchmark bars, and ablation bars | `../assets/figures4papers/figure_CellSpliceNet/plot_comparison.py`, `plot_comparison_cross_species.py`, `plot_ablation.py` |
| `figure_brainteaser` | Composition breakdown bars, category/subcategory comparisons, rewriting/self-correction panels | `../assets/figures4papers/figure_brainteaser/plot_*.py` |
| `figure_VIGIL` | Radar/polar comparison, post-training trends, hyperparameter/data ablation curves, and probability/manifold concept panels | `../assets/figures4papers/figure_VIGIL/plot_comparison_radar.py`, `plot_posttraining.py`, `plot_ablation.py`, `plot_concept.py` |
| `figure_ophthal_review` | Time trends and composition heatmaps for review/survey style figures | `../assets/figures4papers/figure_ophthal_review/plot_trend.py`, `plot_composition.py` |
| `figure_RNAGenScape` | Heatmaps, optimization/speed comparisons, manifold illustrations, sweep plots | `../assets/figures4papers/figure_RNAGenScape/plot_*.py` |
| `figure_Dispersion` | Conceptual 3D-style sphere diagrams and observation/idea panels | `../assets/figures4papers/figure_Dispersion/plot_illustration.py`, `plot_idea.py` |
| `figure_Cflows` | Diffusion/trajectory illustrations, gene-regulatory comparisons, ablation comparisons | `../assets/figures4papers/figure_Cflows/*.py` |
| `figure_FPGM` | Frequency-prior or distribution-style method motivation figure | `../assets/figures4papers/figure_FPGM/plot_freq_prior.py` |
| `assets` | Partially manual schematic/result panels for visual inspiration only | `../assets/figures4papers/assets/*.png` |

## Pattern routing

- Grouped bars: start with `figure_ImmunoStruct`, `figure_CellSpliceNet`, or
  `figure_brainteaser`; then apply the tighter Nature export and font rules in
  `api.md`.
- Radar/polar: start with `figure_VIGIL`; cross-check `chart-types.md` before
  implementing normalization, radial labels, and legend placement.
- Trend/line and ablation curves: start with `figure_VIGIL` or
  `figure_ophthal_review`; use shared legends, dashed reference baselines, dual
  axes only when two metrics are genuinely coupled, and direct event labels where
  they reduce eye travel.
- Heatmap/matrix: start with `figure_RNAGenScape` or `figure_ophthal_review`; keep
  colorbars and labels readable at final journal dimensions.
- Conceptual 3D/spheres: start with `figure_Dispersion` or `figure_Cflows`; use this
  only when it supports the manuscript claim, not as decorative filler.
- Probability/manifold concept panels: start with `figure_VIGIL/plot_concept.py`;
  treat the output as a conceptual model schematic and replace all labels/data
  metaphors with manuscript-specific, evidence-supported claims.

## Relationship to the older skill

The original `scientific-figure-making` skill focused on publication-ready
matplotlib figures and the figures4papers house style. In this repository, that
guidance is folded into `nature-figure`:

- `api.md` contains the palette, helper signatures, and export conventions.
- `common-patterns.md` expands the reusable layout patterns.
- `design-theory.md` captures the typography, color, and composition rationale.
- `tutorials.md` gives end-to-end scaffold examples.
- This file preserves the real demo script map and bundled example assets.

## External source

Original upstream repository:
<https://github.com/ChenLiu-1996/figures4papers>
references/design-theory.md
# Nature Figure Design Theory

Derived from scripts in the [figures4papers](https://github.com/ChenLiu-1996/figures4papers) repository
(published in *Nature Machine Intelligence* and top ML/bioinformatics venues).

---

## 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.

---

## 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.

### 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.

### 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).
- 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.

---

## 11) Multi-Panel Information Architecture

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

In a multi-panel figure, each panel should be independently informative. Covering one panel must leave a gap that cannot be recovered from the others.

**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.

---

## 10) 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
- [ ] Font size ≥ 16 base; 24 for large bar panels; 32–54 for axis labels on large panels
- [ ] Colors from blue-green-red-neutral semantic palette
- [ ] 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`
- [ ] `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.

## 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:
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.

## 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.

## 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

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 axes comparable across panels that invite comparison?
- 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."*

## 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 & title limits (consistency with style-guardrails)

- Keep a Nature-style legend `<= 300` words.
- 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/单位)都写进图注,使其脱离正文可读。
- 进阶:图注末句可给一句推断结论,但须确有面板支撑。
- 综述图注:聚合他人系统时逐子图一句话定性,并标注"adapted with permission from refs… by Springer Nature"授权。
references/nature-2026-observations.md
# 2026 Nature Sample Observations

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/openrouter-image-generation.md
# OpenRouter Image Generation for Manuscript Schematics

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.

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 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

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.

## 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
references/qa-contract.md
# QA Contract

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.

## 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 |
| 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 |
| Statistics | `n`, biological/technical repeat definition, center, spread, test, correction, and exact comparison are documented |
| 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 |

## 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
```

The preflight checks source syntax, font configuration and size floor, 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, and obvious cross-backend plotting references.

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.

## 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

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 | `patchwork`; 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)

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

  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))
```

## 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

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 real production scripts and output previews from figures4papers, 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
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(
        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"OpenRouter request failed ({exc.code}): {body}") from exc
    except urllib.error.URLError as exc:
        raise SystemExit(f"OpenRouter request failed: {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": 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("--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 OpenRouter.")
    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/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)
    ax.set_ylabel(r"$-\log_{10}$(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)
    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=r"$\log_2$(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
    for char in source:
        if escaped:
            escaped = False
            continue
        if char == "\\":
            escaped = True
            continue
        if quote:
            if char == quote:
                quote = None
            continue
        if 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_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 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_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_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)
'''

    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])


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())
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.

## 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.

## The five-point contract

1. **Core conclusion**: write the one-sentence claim the figure must defend.
2. **Evidence chain**: map each planned panel to the claim, and drop panels that do not carry a unique piece of evidence.
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, editable text, source data, statistics, image-integrity notes, and export formats 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`.
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 statistics, `n`, error-bar definitions, source-data traceability, and image-integrity notes as part of the figure, not as optional caption cleanup.
- 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 bundled Python demo scripts.

## 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

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):
    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` — bundled figures4papers Python scripts and output previews.
- `scripts/validate_figure.py` — dependency-free source preflight before rendering and visual QA.
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)

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
  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.
    nature-figure | Prompt Minder