psyclaw 0.28.1 → 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +5 -5
  2. package/agents/recommended/catalog.json +1 -1
  3. package/dist/src/adapters/pi/extension.js +79 -25
  4. package/dist/src/adapters/pi/extension.js.map +1 -1
  5. package/dist/src/ars/keybindings.d.ts +13 -0
  6. package/dist/src/ars/keybindings.js +50 -0
  7. package/dist/src/ars/keybindings.js.map +1 -0
  8. package/dist/src/ars/mode-editor.d.ts +25 -0
  9. package/dist/src/ars/mode-editor.js +78 -0
  10. package/dist/src/ars/mode-editor.js.map +1 -0
  11. package/dist/src/ars/profile.d.ts +4 -1
  12. package/dist/src/ars/profile.js +23 -6
  13. package/dist/src/ars/profile.js.map +1 -1
  14. package/dist/src/chat.js +3 -0
  15. package/dist/src/chat.js.map +1 -1
  16. package/dist/src/panel/server.js +1 -1
  17. package/dist/src/panel/server.js.map +1 -1
  18. package/package.json +10 -3
  19. package/vendor/academic-paper-skills/LICENSE +21 -0
  20. package/vendor/academic-paper-skills/NOTICE.md +7 -0
  21. package/vendor/academic-paper-skills/PSYCLAW_SOURCE.json +14 -0
  22. package/vendor/academic-paper-skills/academic-paper-composer/SKILL.md +840 -0
  23. package/vendor/academic-paper-skills/academic-paper-composer/references/section_guides.md +675 -0
  24. package/vendor/academic-paper-skills/academic-paper-composer/references/writing_standards.md +629 -0
  25. package/vendor/academic-paper-skills/academic-paper-composer/scripts/chapter_quality_check.py +470 -0
  26. package/vendor/academic-paper-skills/academic-paper-composer/scripts/final_evaluation.py +550 -0
  27. package/vendor/academic-paper-skills/academic-paper-strategist/SKILL.md +670 -0
  28. package/vendor/academic-paper-skills/academic-paper-strategist/references/quality_standards.md +336 -0
  29. package/vendor/academic-paper-skills/academic-paper-strategist/references/search_strategy.md +459 -0
  30. package/vendor/academic-paper-skills/academic-paper-strategist/scripts/evaluate_samples.py +300 -0
  31. package/vendor/academic-paper-skills/academic-paper-strategist/scripts/gap_analysis.py +399 -0
  32. package/vendor/ars/pi/wrapper.js +9 -0
  33. package/vendor/nature-skills/LICENSE +201 -0
  34. package/vendor/nature-skills/NOTICE.md +7 -0
  35. package/vendor/nature-skills/PSYCLAW_SOURCE.json +19 -0
  36. package/vendor/nature-skills/skills/nature-figure/README.md +107 -0
  37. package/vendor/nature-skills/skills/nature-figure/README_EN.md +107 -0
  38. package/vendor/nature-skills/skills/nature-figure/SKILL.md +154 -0
  39. package/vendor/nature-skills/skills/nature-figure/agents/openai.yaml +4 -0
  40. package/vendor/nature-skills/skills/nature-figure/evals/evals.json +325 -0
  41. package/vendor/nature-skills/skills/nature-figure/manifest.yaml +103 -0
  42. package/vendor/nature-skills/skills/nature-figure/references/ai-graphical-abstract-workflow.md +129 -0
  43. package/vendor/nature-skills/skills/nature-figure/references/api.md +576 -0
  44. package/vendor/nature-skills/skills/nature-figure/references/asset-adaptation.md +72 -0
  45. package/vendor/nature-skills/skills/nature-figure/references/backend-selection.md +118 -0
  46. package/vendor/nature-skills/skills/nature-figure/references/chart-types.md +348 -0
  47. package/vendor/nature-skills/skills/nature-figure/references/common-patterns.md +371 -0
  48. package/vendor/nature-skills/skills/nature-figure/references/demos.md +58 -0
  49. package/vendor/nature-skills/skills/nature-figure/references/design-theory.md +467 -0
  50. package/vendor/nature-skills/skills/nature-figure/references/figure-contract.md +122 -0
  51. package/vendor/nature-skills/skills/nature-figure/references/figure-legend-conventions.md +90 -0
  52. package/vendor/nature-skills/skills/nature-figure/references/multipanel-evidence-architecture.md +268 -0
  53. package/vendor/nature-skills/skills/nature-figure/references/nature-2026-observations.md +124 -0
  54. package/vendor/nature-skills/skills/nature-figure/references/nature-article-requirements.md +149 -0
  55. package/vendor/nature-skills/skills/nature-figure/references/openrouter-image-generation.md +141 -0
  56. package/vendor/nature-skills/skills/nature-figure/references/qa-contract.md +344 -0
  57. package/vendor/nature-skills/skills/nature-figure/references/r-template-index.md +66 -0
  58. package/vendor/nature-skills/skills/nature-figure/references/r-workflow.md +234 -0
  59. package/vendor/nature-skills/skills/nature-figure/references/template-catalog.md +33 -0
  60. package/vendor/nature-skills/skills/nature-figure/references/tutorials.md +260 -0
  61. package/vendor/nature-skills/skills/nature-figure/requirements.txt +1 -0
  62. package/vendor/nature-skills/skills/nature-figure/scripts/audit_figure_collisions.py +742 -0
  63. package/vendor/nature-skills/skills/nature-figure/scripts/audit_panel_alignment.py +933 -0
  64. package/vendor/nature-skills/skills/nature-figure/scripts/audit_pdf_text.py +152 -0
  65. package/vendor/nature-skills/skills/nature-figure/scripts/figure_safety.py +50 -0
  66. package/vendor/nature-skills/skills/nature-figure/scripts/generate_openrouter_schematic.py +260 -0
  67. package/vendor/nature-skills/skills/nature-figure/scripts/nature_figure_backend.py +93 -0
  68. package/vendor/nature-skills/skills/nature-figure/scripts/panel_alignment.R +188 -0
  69. package/vendor/nature-skills/skills/nature-figure/scripts/plot_templates.py +604 -0
  70. package/vendor/nature-skills/skills/nature-figure/scripts/validate_figure.py +835 -0
  71. package/vendor/nature-skills/skills/nature-figure/static/core/contract.md +55 -0
  72. package/vendor/nature-skills/skills/nature-figure/static/core/stance.md +41 -0
  73. package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/python.md +56 -0
  74. package/vendor/nature-skills/skills/nature-figure/static/fragments/backend/r.md +63 -0
  75. package/vendor/nature-skills/skills/nature-figure/tests/test_figure_safety.py +209 -0
  76. package/vendor/nature-skills/skills/nature-polishing/README.md +58 -0
  77. package/vendor/nature-skills/skills/nature-polishing/README_EN.md +58 -0
  78. package/vendor/nature-skills/skills/nature-polishing/SKILL.md +111 -0
  79. package/vendor/nature-skills/skills/nature-polishing/agents/openai.yaml +4 -0
  80. package/vendor/nature-skills/skills/nature-polishing/manifest.yaml +101 -0
  81. package/vendor/nature-skills/skills/nature-polishing/references/latex-layout.md +211 -0
  82. package/vendor/nature-skills/skills/nature-polishing/references/nat-comms-2025-diction.md +73 -0
  83. package/vendor/nature-skills/skills/nature-polishing/references/phrasebank-playbook.md +175 -0
  84. package/vendor/nature-skills/skills/nature-polishing/references/published-article-patterns.md +126 -0
  85. package/vendor/nature-skills/skills/nature-polishing/references/section-moves.md +252 -0
  86. package/vendor/nature-skills/skills/nature-polishing/references/style-guardrails.md +94 -0
  87. package/vendor/nature-skills/skills/nature-polishing/references/writing-strategy.md +160 -0
  88. package/vendor/nature-skills/skills/nature-polishing/static/core/failure-modes.md +29 -0
  89. package/vendor/nature-skills/skills/nature-polishing/static/core/output-format.md +21 -0
  90. package/vendor/nature-skills/skills/nature-polishing/static/core/stance.md +26 -0
  91. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/generic.md +17 -0
  92. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-comms.md +38 -0
  93. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nat-mach-intell.md +102 -0
  94. package/vendor/nature-skills/skills/nature-polishing/static/fragments/journal/nature.md +19 -0
  95. package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/en.md +19 -0
  96. package/vendor/nature-skills/skills/nature-polishing/static/fragments/language/zh-to-en.md +19 -0
  97. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/algorithmic.md +18 -0
  98. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/hypothesis.md +17 -0
  99. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/methods.md +35 -0
  100. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/research.md +29 -0
  101. package/vendor/nature-skills/skills/nature-polishing/static/fragments/paper_type/review.md +17 -0
  102. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/abstract.md +26 -0
  103. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/conclusion.md +15 -0
  104. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/discussion.md +48 -0
  105. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/intro.md +33 -0
  106. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/methods.md +26 -0
  107. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/results.md +61 -0
  108. package/vendor/nature-skills/skills/nature-polishing/static/fragments/section/title.md +17 -0
  109. package/vendor/nature-skills/skills/nature-ref-verifier/README.md +44 -0
  110. package/vendor/nature-skills/skills/nature-ref-verifier/README_EN.md +44 -0
  111. package/vendor/nature-skills/skills/nature-ref-verifier/SKILL.md +196 -0
  112. package/vendor/nature-skills/skills/nature-ref-verifier/agents/openai.yaml +4 -0
  113. package/vendor/nature-skills/skills/nature-ref-verifier/manifest.yaml +19 -0
  114. package/vendor/nature-skills/skills/nature-ref-verifier/references/common-patterns.md +96 -0
  115. package/vendor/nature-skills/skills/nature-shared/README.md +43 -0
  116. package/vendor/nature-skills/skills/nature-shared/README_EN.md +43 -0
  117. package/vendor/nature-skills/skills/nature-shared/SKILL.md +33 -0
  118. package/vendor/nature-skills/skills/nature-shared/agents/openai.yaml +6 -0
  119. package/vendor/nature-skills/skills/nature-shared/core/consistency-sweep.md +122 -0
  120. package/vendor/nature-skills/skills/nature-shared/core/discussion-argument-language.md +192 -0
  121. package/vendor/nature-skills/skills/nature-shared/core/ethics.md +88 -0
  122. package/vendor/nature-skills/skills/nature-shared/core/main-text-discipline.md +179 -0
  123. package/vendor/nature-skills/skills/nature-shared/core/nature-abstract.md +171 -0
  124. package/vendor/nature-skills/skills/nature-shared/core/nature-introduction.md +164 -0
  125. package/vendor/nature-skills/skills/nature-shared/core/nature-results-discussion.md +215 -0
  126. package/vendor/nature-skills/skills/nature-shared/core/paper-type-taxonomy.md +41 -0
  127. package/vendor/nature-skills/skills/nature-shared/core/reader-workflow.md +21 -0
  128. package/vendor/nature-skills/skills/nature-shared/core/research-compliance.md +192 -0
  129. package/vendor/nature-skills/skills/nature-shared/core/terminology-ledger.md +58 -0
  130. package/vendor/nature-skills/skills/nature-shared/journal-formats/nat-comms.md +139 -0
  131. package/vendor/nature-skills/skills/nature-shared/journal-formats/nature-machine-intelligence.md +431 -0
  132. package/vendor/nature-skills/skills/nature-shared/journal-formats/nature.md +313 -0
  133. package/vendor/nature-skills/skills/nature-shared/manifest.yaml +52 -0
  134. package/vendor/nature-skills/skills/nature-shared/scripts/check_consistency.py +273 -0
  135. package/vendor/nature-skills/skills/nature-shared/tests/test_check_consistency.py +63 -0
@@ -0,0 +1,604 @@
1
+ #!/usr/bin/env python3
2
+ """Validated Python templates for five common manuscript-figure families.
3
+
4
+ Subcommands: volcano, roc, dotplot, marginal, and paired. Production runs
5
+ require a CSV input. Simulated data is available only through the explicit
6
+ --demo flag and is marked as such in the generated QA record.
7
+
8
+ The template families and adaptation safeguards were informed by the
9
+ Apache-2.0 academic-figure-skill asset collection. This implementation is
10
+ portable, path-independent, and designed for the nature-figure contract.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import csv
17
+ import json
18
+ import math
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any, Iterable
22
+
23
+
24
+ DEFAULT_WIDTH_MM = 183.0
25
+ DEFAULT_HEIGHT_MM = 120.0
26
+ MM_PER_INCH = 25.4
27
+ PALETTE = ["#2166AC", "#B2182B", "#1B7837", "#F1A340", "#762A83", "#666666"]
28
+ DEPENDENCY_ERROR: Exception | None = None
29
+
30
+ try:
31
+ import numpy as np
32
+ import matplotlib as mpl
33
+
34
+ mpl.use("Agg")
35
+ import matplotlib.pyplot as plt
36
+ from matplotlib.colors import LinearSegmentedColormap
37
+ from matplotlib.lines import Line2D
38
+ except ImportError as exc: # Keep --help usable in a minimal environment.
39
+ DEPENDENCY_ERROR = exc
40
+
41
+
42
+ def require_dependencies() -> None:
43
+ if DEPENDENCY_ERROR is not None:
44
+ raise RuntimeError(
45
+ "plot_templates.py requires numpy and matplotlib in the selected Python environment"
46
+ ) from DEPENDENCY_ERROR
47
+
48
+
49
+ def configure_style() -> None:
50
+ mpl.rcParams.update(
51
+ {
52
+ "font.family": "sans-serif",
53
+ "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans", "Liberation Sans"],
54
+ "font.size": 7,
55
+ "axes.labelsize": 7,
56
+ "axes.titlesize": 8,
57
+ "xtick.labelsize": 6,
58
+ "ytick.labelsize": 6,
59
+ "legend.fontsize": 6,
60
+ "axes.spines.top": False,
61
+ "axes.spines.right": False,
62
+ "axes.linewidth": 0.6,
63
+ "xtick.direction": "out",
64
+ "ytick.direction": "out",
65
+ "xtick.major.width": 0.6,
66
+ "ytick.major.width": 0.6,
67
+ "legend.frameon": False,
68
+ "svg.fonttype": "none",
69
+ "pdf.fonttype": 42,
70
+ }
71
+ )
72
+
73
+
74
+ def normalize_prefix(raw: Path) -> Path:
75
+ if raw.suffix.lower() in {".svg", ".pdf", ".tif", ".tiff", ".png"}:
76
+ raw = raw.with_suffix("")
77
+ raw.parent.mkdir(parents=True, exist_ok=True)
78
+ return raw
79
+
80
+
81
+ def save_bundle(fig: Any, output: Path) -> dict[str, str]:
82
+ output = normalize_prefix(output)
83
+ paths = {
84
+ "svg": str(output.with_suffix(".svg")),
85
+ "pdf": str(output.with_suffix(".pdf")),
86
+ "tiff": str(output.with_suffix(".tiff")),
87
+ }
88
+ fig.savefig(paths["svg"], bbox_inches="tight", facecolor="white")
89
+ fig.savefig(paths["pdf"], bbox_inches="tight", facecolor="white")
90
+ fig.savefig(paths["tiff"], dpi=600, bbox_inches="tight", facecolor="white")
91
+ plt.close(fig)
92
+ return paths
93
+
94
+
95
+ def write_qa(output: Path, qa: dict[str, Any]) -> Path:
96
+ path = normalize_prefix(output).with_suffix(".qa.json")
97
+ path.write_text(json.dumps(qa, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
98
+ return path
99
+
100
+
101
+ def read_csv_rows(path: Path) -> tuple[list[dict[str, Any]], list[str]]:
102
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
103
+ reader = csv.DictReader(handle)
104
+ if reader.fieldnames is None:
105
+ raise ValueError(f"CSV has no header: {path}")
106
+ rows = [dict(row) for row in reader]
107
+ return rows, list(reader.fieldnames)
108
+
109
+
110
+ def require_columns(fieldnames: Iterable[str], required: Iterable[str]) -> None:
111
+ available = set(fieldnames)
112
+ missing = [name for name in required if name not in available]
113
+ if missing:
114
+ raise ValueError(f"missing required CSV columns: {', '.join(missing)}")
115
+
116
+
117
+ def coerce_numeric_rows(
118
+ rows: list[dict[str, Any]],
119
+ columns: Iterable[str],
120
+ drop_incomplete: bool,
121
+ ) -> tuple[list[dict[str, Any]], list[int]]:
122
+ columns = list(columns)
123
+ valid: list[dict[str, Any]] = []
124
+ invalid: list[int] = []
125
+ for row_number, row in enumerate(rows, start=2):
126
+ converted = dict(row)
127
+ try:
128
+ for column in columns:
129
+ value = float(row[column])
130
+ if not math.isfinite(value):
131
+ raise ValueError
132
+ converted[column] = value
133
+ except (KeyError, TypeError, ValueError):
134
+ invalid.append(row_number)
135
+ continue
136
+ valid.append(converted)
137
+ if invalid and not drop_incomplete:
138
+ shown = ", ".join(str(value) for value in invalid[:10])
139
+ suffix = " ..." if len(invalid) > 10 else ""
140
+ raise ValueError(
141
+ f"non-finite or missing numeric values at CSV rows {shown}{suffix}; "
142
+ "fix the data or rerun with explicit --drop-incomplete"
143
+ )
144
+ if not valid:
145
+ raise ValueError("no valid observations remain")
146
+ return valid, invalid
147
+
148
+
149
+ def first_seen(values: Iterable[str]) -> list[str]:
150
+ result: list[str] = []
151
+ seen: set[str] = set()
152
+ for value in values:
153
+ if value not in seen:
154
+ seen.add(value)
155
+ result.append(value)
156
+ return result
157
+
158
+
159
+ def parse_order(raw: str | None, observed: Iterable[str], label: str) -> list[str]:
160
+ observed_order = first_seen(str(value) for value in observed)
161
+ if not raw:
162
+ return observed_order
163
+ requested = [value.strip() for value in raw.split(",") if value.strip()]
164
+ missing = [value for value in observed_order if value not in requested]
165
+ extra = [value for value in requested if value not in observed_order]
166
+ if missing or extra:
167
+ details = []
168
+ if missing:
169
+ details.append(f"missing observed {label}: {missing}")
170
+ if extra:
171
+ details.append(f"unknown {label}: {extra}")
172
+ raise ValueError("; ".join(details))
173
+ return requested
174
+
175
+
176
+ def nice_reference_values(maximum: float) -> list[float]:
177
+ if maximum <= 0:
178
+ return []
179
+ magnitude = 10 ** math.floor(math.log10(maximum))
180
+ quantum = magnitude / 2
181
+ values = [round((maximum * fraction) / quantum) * quantum for fraction in (0.25, 0.5, 1.0)]
182
+ return sorted({value for value in values if value > 0})
183
+
184
+
185
+ def base_qa(args: argparse.Namespace, kind: str, rows_input: int) -> dict[str, Any]:
186
+ return {
187
+ "template": kind,
188
+ "backend": "python",
189
+ "demo": bool(args.demo),
190
+ "input": "<explicit-demo-data>" if args.demo else args.input.name,
191
+ "output_prefix": normalize_prefix(args.output).name,
192
+ "rows_input": rows_input,
193
+ "rows_plotted": rows_input,
194
+ "excluded_rows": 0,
195
+ "excluded_entities": 0,
196
+ "mapping": {},
197
+ "parameters": {},
198
+ "notes": [
199
+ "All supplied observations are used unless excluded through explicit --drop-incomplete.",
200
+ "The QA record stores input basenames rather than private absolute paths.",
201
+ ],
202
+ }
203
+
204
+
205
+ def load_rows(args: argparse.Namespace, kind: str) -> tuple[list[dict[str, Any]], list[str]]:
206
+ if args.demo:
207
+ rows = demo_rows(kind)
208
+ return rows, list(rows[0])
209
+ if args.input is None:
210
+ raise ValueError("production runs require --input CSV; use --demo only for an explicit example")
211
+ return read_csv_rows(args.input)
212
+
213
+
214
+ def demo_rows(kind: str) -> list[dict[str, Any]]:
215
+ rng = np.random.default_rng(20260715)
216
+ if kind == "volcano":
217
+ rows = []
218
+ for index in range(1200):
219
+ effect = float(rng.normal(0, 0.65))
220
+ pvalue = float(rng.uniform(0.02, 1.0))
221
+ if index < 55:
222
+ effect = float(rng.choice([-1, 1]) * rng.uniform(1.2, 3.2))
223
+ pvalue = float(10 ** (-rng.uniform(2, 9)))
224
+ rows.append({"gene": f"Gene_{index + 1}", "log2fc": effect, "padj": pvalue})
225
+ return rows
226
+ if kind == "roc":
227
+ rows = []
228
+ for fpr in np.linspace(0, 1, 101):
229
+ rows.append(
230
+ {
231
+ "fpr": float(fpr),
232
+ "model_a": float(np.clip(fpr ** 0.42, 0, 1)),
233
+ "model_b": float(np.clip(fpr ** 0.58, 0, 1)),
234
+ "model_c": float(np.clip(fpr ** 0.72, 0, 1)),
235
+ }
236
+ )
237
+ return rows
238
+ if kind == "dotplot":
239
+ rows = []
240
+ row_names = ["VCT", "EVT", "SCT", "FB", "T", "dNK"]
241
+ column_names = ["TP63", "HLA-G", "ERVW-1", "VIM", "ACTA2", "CD3D", "GNLY", "NKG7"]
242
+ for row_index, row_name in enumerate(row_names):
243
+ for col_index, column_name in enumerate(column_names):
244
+ distance = abs((row_index * 1.3) - (col_index * 0.7))
245
+ rows.append(
246
+ {
247
+ "cell_type": row_name,
248
+ "gene": column_name,
249
+ "pct_exp": float(np.clip(70 - 14 * distance + rng.normal(0, 5), 1, 90)),
250
+ "avg_exp_scaled": float(np.clip(1 - distance / 6 + rng.normal(0, 0.08), 0, 1)),
251
+ }
252
+ )
253
+ return rows
254
+ if kind == "marginal":
255
+ rows = []
256
+ for group_index, group in enumerate(("Control", "Treatment A", "Treatment B")):
257
+ x = rng.normal(group_index * 0.7, 0.85, 220)
258
+ y = 0.65 * x + rng.normal(group_index * 0.35, 0.65, 220)
259
+ rows.extend({"x": float(a), "y": float(b), "group": group} for a, b in zip(x, y))
260
+ return rows
261
+ if kind == "paired":
262
+ rows = []
263
+ for index in range(28):
264
+ before = float(rng.normal(1.0, 0.18))
265
+ after = float(before + rng.normal(0.20, 0.10))
266
+ rows.extend(
267
+ [
268
+ {"subject": f"S{index + 1:02d}", "condition": "Before", "value": before},
269
+ {"subject": f"S{index + 1:02d}", "condition": "After", "value": after},
270
+ ]
271
+ )
272
+ return rows
273
+ raise ValueError(f"unknown demo kind: {kind}")
274
+
275
+
276
+ def plot_volcano(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
277
+ rows, fields = load_rows(args, "volcano")
278
+ required = [args.gene_col, args.effect_col, args.p_col]
279
+ require_columns(fields, required)
280
+ rows, invalid = coerce_numeric_rows(rows, [args.effect_col, args.p_col], args.drop_incomplete)
281
+ bad_p = [index for index, row in enumerate(rows, start=1) if not 0 < row[args.p_col] <= 1]
282
+ if bad_p:
283
+ raise ValueError("adjusted p values must be strictly positive and no greater than 1")
284
+
285
+ effects = np.asarray([row[args.effect_col] for row in rows], dtype=float)
286
+ pvalues = np.asarray([row[args.p_col] for row in rows], dtype=float)
287
+ genes = [str(row[args.gene_col]) for row in rows]
288
+ scores = -np.log10(pvalues)
289
+ up = (pvalues < args.p_threshold) & (effects >= args.effect_threshold)
290
+ down = (pvalues < args.p_threshold) & (effects <= -args.effect_threshold)
291
+ neutral = ~(up | down)
292
+
293
+ fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
294
+ rasterized = len(rows) > 50000
295
+ ax.scatter(effects[neutral], scores[neutral], s=5, color="#B3B3B3", alpha=0.45, edgecolors="none", rasterized=rasterized, label=f"Not significant ({neutral.sum()})")
296
+ ax.scatter(effects[down], scores[down], s=7, color="#2166AC", alpha=0.72, edgecolors="none", rasterized=rasterized, label=f"Down ({down.sum()})")
297
+ ax.scatter(effects[up], scores[up], s=7, color="#B2182B", alpha=0.72, edgecolors="none", rasterized=rasterized, label=f"Up ({up.sum()})")
298
+ ax.axhline(-math.log10(args.p_threshold), color="#666666", linestyle="--", linewidth=0.6)
299
+ ax.axvline(-args.effect_threshold, color="#666666", linestyle="--", linewidth=0.6)
300
+ ax.axvline(args.effect_threshold, color="#666666", linestyle="--", linewidth=0.6)
301
+ significant = np.flatnonzero(up | down)
302
+ if args.top_labels > 0 and len(significant):
303
+ selected = significant[np.argsort(pvalues[significant])[: args.top_labels]]
304
+ for is_positive in (False, True):
305
+ side = [index for index in selected if (effects[index] >= 0) == is_positive]
306
+ last_label_y = -math.inf
307
+ for index in sorted(side, key=lambda value: scores[value]):
308
+ label_y = max(float(scores[index]) + 0.12, last_label_y + 0.30)
309
+ last_label_y = label_y
310
+ direction = 1 if is_positive else -1
311
+ ax.annotate(
312
+ genes[index],
313
+ (effects[index], scores[index]),
314
+ xytext=(effects[index] + 0.07 * direction, label_y),
315
+ textcoords="data",
316
+ ha="left" if is_positive else "right",
317
+ va="bottom",
318
+ fontsize=5,
319
+ color="#333333",
320
+ arrowprops={"arrowstyle": "-", "color": "#777777", "linewidth": 0.3},
321
+ )
322
+ ax.set_xlabel(args.effect_label)
323
+ # Keep the subscript as Unicode so the rendered glyph does not shrink below
324
+ # the journal's 5 pt floor when the surrounding label is already compact.
325
+ ax.set_ylabel("−log₁₀(adjusted p value)")
326
+ if args.title:
327
+ ax.set_title(args.title)
328
+ ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.19), ncol=3)
329
+ paths = save_bundle(fig, args.output)
330
+
331
+ qa = base_qa(args, "volcano", len(rows) + len(invalid))
332
+ qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
333
+ qa["mapping"] = {"gene": args.gene_col, "effect": args.effect_col, "adjusted_p": args.p_col}
334
+ 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())}}
335
+ return paths, qa
336
+
337
+
338
+ def plot_roc(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
339
+ rows, fields = load_rows(args, "roc")
340
+ 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]
341
+ if not tpr_columns:
342
+ raise ValueError("no TPR columns selected")
343
+ require_columns(fields, [args.fpr_col, *tpr_columns])
344
+ rows, invalid = coerce_numeric_rows(rows, [args.fpr_col, *tpr_columns], args.drop_incomplete)
345
+ for row in rows:
346
+ values = [row[args.fpr_col], *(row[column] for column in tpr_columns)]
347
+ if any(value < 0 or value > 1 for value in values):
348
+ raise ValueError("FPR and TPR values must stay within [0, 1]")
349
+
350
+ fpr = np.asarray([row[args.fpr_col] for row in rows], dtype=float)
351
+ order = np.argsort(fpr, kind="stable")
352
+ sorted_input = bool(np.all(order == np.arange(len(fpr))))
353
+ fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
354
+ aucs: dict[str, float] = {}
355
+ for index, column in enumerate(tpr_columns):
356
+ tpr = np.asarray([row[column] for row in rows], dtype=float)[order]
357
+ auc = float(np.trapezoid(tpr, fpr[order]))
358
+ aucs[column] = auc
359
+ ax.plot(fpr[order], tpr, color=PALETTE[index % len(PALETTE)], linewidth=1.3, label=f"{column} (AUC={auc:.3f})")
360
+ ax.plot([0, 1], [0, 1], color="#888888", linestyle="--", linewidth=0.7, label="Chance")
361
+ ax.set(xlim=(0, 1), ylim=(0, 1), xlabel="False-positive rate", ylabel="True-positive rate")
362
+ ax.set_aspect("equal", adjustable="box")
363
+ if args.title:
364
+ ax.set_title(args.title)
365
+ ax.legend(loc="lower right")
366
+ paths = save_bundle(fig, args.output)
367
+
368
+ qa = base_qa(args, "roc", len(rows) + len(invalid))
369
+ qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
370
+ qa["mapping"] = {"fpr": args.fpr_col, "tpr_series": tpr_columns}
371
+ qa["parameters"] = {"auc_method": "trapezoidal", "auc": aucs, "input_already_sorted_by_fpr": sorted_input}
372
+ return paths, qa
373
+
374
+
375
+ def plot_dotplot(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
376
+ rows, fields = load_rows(args, "dotplot")
377
+ required = [args.row_col, args.column_col, args.size_col, args.color_col]
378
+ require_columns(fields, required)
379
+ rows, invalid = coerce_numeric_rows(rows, [args.size_col, args.color_col], args.drop_incomplete)
380
+ if any(row[args.size_col] < 0 for row in rows):
381
+ raise ValueError("dot size values must be non-negative")
382
+ row_order = parse_order(args.row_order, (str(row[args.row_col]) for row in rows), "row categories")
383
+ column_order = parse_order(args.column_order, (str(row[args.column_col]) for row in rows), "column categories")
384
+ row_map = {value: index for index, value in enumerate(row_order)}
385
+ column_map = {value: index for index, value in enumerate(column_order)}
386
+
387
+ size_values = np.asarray([row[args.size_col] for row in rows], dtype=float)
388
+ color_values = np.asarray([row[args.color_col] for row in rows], dtype=float)
389
+ size_max = float(size_values.max())
390
+ sizes = np.full_like(size_values, 30.0) if size_max == 0 else 12 + 160 * np.sqrt(size_values / size_max)
391
+ color_min = float(color_values.min())
392
+ color_max = float(color_values.max())
393
+ color_norm = np.full_like(color_values, 0.5) if color_max == color_min else (color_values - color_min) / (color_max - color_min)
394
+ xs = np.asarray([column_map[str(row[args.column_col])] for row in rows])
395
+ ys = np.asarray([row_map[str(row[args.row_col])] for row in rows])
396
+ cmap = LinearSegmentedColormap.from_list("nature_expression", ["#2166AC", "#F7F7F7", "#B2182B"])
397
+
398
+ fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
399
+ scatter = ax.scatter(xs, ys, s=sizes, c=color_norm, cmap=cmap, vmin=0, vmax=1, edgecolors="#666666", linewidths=0.35)
400
+ ax.set_xticks(
401
+ range(len(column_order)),
402
+ column_order,
403
+ rotation=90,
404
+ rotation_mode="anchor",
405
+ ha="right",
406
+ )
407
+ ax.set_yticks(range(len(row_order)), row_order)
408
+ ax.set_xlim(-0.7, len(column_order) - 0.3)
409
+ ax.set_ylim(-0.7, len(row_order) - 0.3)
410
+ ax.invert_yaxis()
411
+ ax.spines["top"].set_visible(True)
412
+ ax.spines["right"].set_visible(True)
413
+ ax.grid(color="#E6E6E6", linewidth=0.35)
414
+ ax.set_axisbelow(True)
415
+ colorbar = fig.colorbar(scatter, ax=ax, fraction=0.025, pad=0.02)
416
+ colorbar.set_label(args.color_label)
417
+ reference_values = nice_reference_values(size_max)
418
+ handles = [Line2D([], [], marker="o", linestyle="", markerfacecolor="#999999", markeredgecolor="#666666", markersize=math.sqrt(12 + 160 * math.sqrt(value / size_max))) for value in reference_values]
419
+ if handles:
420
+ ax.legend(handles, [f"{value:g}" for value in reference_values], title=args.size_label, bbox_to_anchor=(1.18, 1), loc="upper left")
421
+ if args.title:
422
+ ax.set_title(args.title)
423
+ paths = save_bundle(fig, args.output)
424
+
425
+ qa = base_qa(args, "dotplot", len(rows) + len(invalid))
426
+ qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
427
+ qa["mapping"] = {"row_category": args.row_col, "column_category": args.column_col, "dot_size": args.size_col, "dot_color": args.color_col}
428
+ qa["parameters"] = {"row_order": row_order, "column_order": column_order, "size_range": [float(size_values.min()), size_max], "color_range": [color_min, color_max]}
429
+ return paths, qa
430
+
431
+
432
+ def plot_marginal(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
433
+ rows, fields = load_rows(args, "marginal")
434
+ required = [args.x_col, args.y_col] + ([args.group_col] if args.group_col else [])
435
+ require_columns(fields, required)
436
+ rows, invalid = coerce_numeric_rows(rows, [args.x_col, args.y_col], args.drop_incomplete)
437
+ groups = [str(row[args.group_col]) if args.group_col else "All observations" for row in rows]
438
+ group_order = parse_order(args.group_order, groups, "groups")
439
+
440
+ fig = plt.figure(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
441
+ grid = fig.add_gridspec(4, 4, hspace=0.05, wspace=0.05)
442
+ ax_joint = fig.add_subplot(grid[1:, :3])
443
+ ax_top = fig.add_subplot(grid[0, :3], sharex=ax_joint)
444
+ ax_right = fig.add_subplot(grid[1:, 3], sharey=ax_joint)
445
+ counts: dict[str, int] = {}
446
+ for index, group in enumerate(group_order):
447
+ mask = np.asarray([value == group for value in groups])
448
+ x = np.asarray([row[args.x_col] for row in rows], dtype=float)[mask]
449
+ y = np.asarray([row[args.y_col] for row in rows], dtype=float)[mask]
450
+ counts[group] = len(x)
451
+ color = PALETTE[index % len(PALETTE)]
452
+ ax_joint.scatter(x, y, s=8, alpha=0.42, color=color, edgecolors="none", rasterized=len(rows) > 50000, label=f"{group} (n={len(x)})")
453
+ ax_top.hist(x, bins=args.bins, density=True, histtype="stepfilled", alpha=0.20, color=color)
454
+ ax_top.hist(x, bins=args.bins, density=True, histtype="step", linewidth=0.8, color=color)
455
+ ax_right.hist(y, bins=args.bins, density=True, orientation="horizontal", histtype="stepfilled", alpha=0.20, color=color)
456
+ ax_right.hist(y, bins=args.bins, density=True, orientation="horizontal", histtype="step", linewidth=0.8, color=color)
457
+ ax_joint.set_xlabel(args.x_label or args.x_col)
458
+ ax_joint.set_ylabel(args.y_label or args.y_col)
459
+ ax_joint.legend(loc="best")
460
+ ax_top.tick_params(labelbottom=False)
461
+ ax_right.tick_params(labelleft=False)
462
+ ax_top.set_ylabel("Density")
463
+ ax_right.set_xlabel("Density")
464
+ if args.title:
465
+ ax_top.set_title(args.title)
466
+ paths = save_bundle(fig, args.output)
467
+
468
+ qa = base_qa(args, "marginal", len(rows) + len(invalid))
469
+ qa.update({"rows_plotted": len(rows), "excluded_rows": len(invalid)})
470
+ qa["mapping"] = {"x": args.x_col, "y": args.y_col, "group": args.group_col}
471
+ qa["parameters"] = {"group_order": group_order, "group_counts": counts, "histogram_bins": args.bins, "rasterized_marks": len(rows) > 50000}
472
+ return paths, qa
473
+
474
+
475
+ def plot_paired(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, Any]]:
476
+ rows, fields = load_rows(args, "paired")
477
+ required = [args.id_col, args.condition_col, args.value_col]
478
+ require_columns(fields, required)
479
+ rows, invalid = coerce_numeric_rows(rows, [args.value_col], args.drop_incomplete)
480
+ condition_order = parse_order(args.condition_order, (str(row[args.condition_col]) for row in rows), "conditions")
481
+ if len(condition_order) != 2:
482
+ raise ValueError(f"paired template requires exactly two conditions, found {len(condition_order)}")
483
+
484
+ subjects: dict[str, dict[str, list[float]]] = {}
485
+ for row in rows:
486
+ subject = str(row[args.id_col])
487
+ condition = str(row[args.condition_col])
488
+ subjects.setdefault(subject, {}).setdefault(condition, []).append(float(row[args.value_col]))
489
+ duplicates = [subject for subject, values in subjects.items() if any(len(values.get(condition, [])) > 1 for condition in condition_order)]
490
+ if duplicates:
491
+ raise ValueError("multiple values per subject-condition pair found; aggregate only with an explicit scientific rule before plotting")
492
+ incomplete = [subject for subject, values in subjects.items() if any(condition not in values for condition in condition_order)]
493
+ if incomplete and not args.drop_incomplete:
494
+ raise ValueError(f"{len(incomplete)} incomplete subject pairs found; fix the data or rerun with explicit --drop-incomplete")
495
+ complete = [subject for subject in subjects if subject not in incomplete]
496
+ if not complete:
497
+ raise ValueError("no complete subject pairs remain")
498
+ values_a = np.asarray([subjects[subject][condition_order[0]][0] for subject in complete])
499
+ values_b = np.asarray([subjects[subject][condition_order[1]][0] for subject in complete])
500
+
501
+ fig, ax = plt.subplots(figsize=(args.width_mm / MM_PER_INCH, args.height_mm / MM_PER_INCH))
502
+ for value_a, value_b in zip(values_a, values_b):
503
+ ax.plot([0, 1], [value_a, value_b], color="#B0B0B0", linewidth=0.55, alpha=0.65, zorder=1)
504
+ 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})
505
+ for patch, color in zip(boxes["boxes"], ("#9ECAE1", "#FC9272")):
506
+ patch.set(facecolor=color, edgecolor="#555555", alpha=0.55, linewidth=0.7)
507
+ ax.scatter(np.zeros_like(values_a), values_a, s=13, color="#2166AC", edgecolors="white", linewidths=0.35, zorder=3)
508
+ ax.scatter(np.ones_like(values_b), values_b, s=13, color="#B2182B", edgecolors="white", linewidths=0.35, zorder=3)
509
+ ax.set_xticks([0, 1], condition_order)
510
+ ax.set_ylabel(args.value_label or args.value_col)
511
+ if args.title:
512
+ ax.set_title(args.title)
513
+ paths = save_bundle(fig, args.output)
514
+
515
+ qa = base_qa(args, "paired", len(rows) + len(invalid))
516
+ qa.update({"rows_plotted": len(complete) * 2, "excluded_rows": len(invalid), "excluded_entities": len(incomplete)})
517
+ qa["mapping"] = {"pair_id": args.id_col, "condition": args.condition_col, "value": args.value_col}
518
+ qa["parameters"] = {"condition_order": condition_order, "complete_pairs": len(complete), "incomplete_pairs_excluded": len(incomplete), "statistical_test": None}
519
+ qa["notes"].append("No inferential test is computed; add one only after choosing a justified paired analysis.")
520
+ return paths, qa
521
+
522
+
523
+ def add_common_arguments(parser: argparse.ArgumentParser, width_mm: float, height_mm: float) -> None:
524
+ source = parser.add_mutually_exclusive_group()
525
+ source.add_argument("--input", type=Path, help="production CSV input")
526
+ source.add_argument("--demo", action="store_true", help="render explicit deterministic demo data")
527
+ parser.add_argument("--output", type=Path, required=True, help="output prefix for SVG/PDF/TIFF/QA JSON")
528
+ parser.add_argument("--width-mm", type=float, default=width_mm)
529
+ parser.add_argument("--height-mm", type=float, default=height_mm)
530
+ parser.add_argument("--title")
531
+ parser.add_argument("--drop-incomplete", action="store_true", help="explicitly exclude rows with missing/non-finite required values and record counts")
532
+
533
+
534
+ def build_parser() -> argparse.ArgumentParser:
535
+ parser = argparse.ArgumentParser(description=__doc__)
536
+ subparsers = parser.add_subparsers(dest="command", required=True)
537
+
538
+ volcano = subparsers.add_parser("volcano", help="effect size versus adjusted p value")
539
+ add_common_arguments(volcano, 89.0, 82.0)
540
+ volcano.add_argument("--gene-col", default="gene")
541
+ volcano.add_argument("--effect-col", default="log2fc")
542
+ volcano.add_argument("--p-col", default="padj")
543
+ volcano.add_argument("--effect-label", default="log₂(fold change)")
544
+ volcano.add_argument("--p-threshold", type=float, default=0.05)
545
+ volcano.add_argument("--effect-threshold", type=float, default=1.0)
546
+ volcano.add_argument("--top-labels", type=int, default=10)
547
+ volcano.set_defaults(plotter=plot_volcano)
548
+
549
+ roc = subparsers.add_parser("roc", help="one FPR column and one or more TPR columns")
550
+ add_common_arguments(roc, 89.0, 89.0)
551
+ roc.add_argument("--fpr-col", default="fpr")
552
+ roc.add_argument("--tpr-cols", help="comma-separated TPR columns; defaults to every non-FPR column")
553
+ roc.set_defaults(plotter=plot_roc)
554
+
555
+ dotplot = subparsers.add_parser("dotplot", help="marker-gene or other size/color matrix dot plot")
556
+ add_common_arguments(dotplot, 183.0, 105.0)
557
+ dotplot.add_argument("--row-col", default="cell_type")
558
+ dotplot.add_argument("--column-col", default="gene")
559
+ dotplot.add_argument("--size-col", default="pct_exp")
560
+ dotplot.add_argument("--color-col", default="avg_exp_scaled")
561
+ dotplot.add_argument("--row-order", help="comma-separated complete row order")
562
+ dotplot.add_argument("--column-order", help="comma-separated complete column order")
563
+ dotplot.add_argument("--size-label", default="Fraction (%)")
564
+ dotplot.add_argument("--color-label", default="Mean expression")
565
+ dotplot.set_defaults(plotter=plot_dotplot)
566
+
567
+ marginal = subparsers.add_parser("marginal", help="2D scatter with marginal distributions")
568
+ add_common_arguments(marginal, 120.0, 105.0)
569
+ marginal.add_argument("--x-col", default="x")
570
+ marginal.add_argument("--y-col", default="y")
571
+ marginal.add_argument("--group-col", default="group")
572
+ marginal.add_argument("--group-order", help="comma-separated complete group order")
573
+ marginal.add_argument("--x-label")
574
+ marginal.add_argument("--y-label")
575
+ marginal.add_argument("--bins", type=int, default=24)
576
+ marginal.set_defaults(plotter=plot_marginal)
577
+
578
+ paired = subparsers.add_parser("paired", help="paired box-and-point plot for exactly two conditions")
579
+ add_common_arguments(paired, 89.0, 86.0)
580
+ paired.add_argument("--id-col", default="subject")
581
+ paired.add_argument("--condition-col", default="condition")
582
+ paired.add_argument("--value-col", default="value")
583
+ paired.add_argument("--condition-order", help="comma-separated two-condition order")
584
+ paired.add_argument("--value-label")
585
+ paired.set_defaults(plotter=plot_paired)
586
+ return parser
587
+
588
+
589
+ def main(argv: list[str] | None = None) -> int:
590
+ args = build_parser().parse_args(argv)
591
+ try:
592
+ require_dependencies()
593
+ configure_style()
594
+ paths, qa = args.plotter(args)
595
+ qa_path = write_qa(args.output, qa)
596
+ except (OSError, RuntimeError, ValueError) as exc:
597
+ print(f"error: {exc}", file=sys.stderr)
598
+ return 2
599
+ print(json.dumps({"outputs": paths, "qa": str(qa_path)}, indent=2, ensure_ascii=False))
600
+ return 0
601
+
602
+
603
+ if __name__ == "__main__":
604
+ raise SystemExit(main())