medsci-skills 5.9.2 → 5.11.0

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 (53) hide show
  1. package/metadata/distribution_files.json +61 -46
  2. package/metadata/distribution_manifest.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/humanize/SKILL.md +2 -0
  5. package/skills/make-figures/references/exemplar_plots/README.md +14 -5
  6. package/skills/make-figures/scripts/render_core_figures.py +290 -2
  7. package/skills/make-figures/scripts/render_core_figures_challenge/fixture/synthetic_inputs.json +49 -0
  8. package/skills/make-figures/scripts/render_core_figures_challenge/problem.md +28 -12
  9. package/skills/make-figures/scripts/render_core_figures_challenge/verify.sh +21 -12
  10. package/skills/peer-review/SKILL.md +9 -9
  11. package/skills/peer-review/references/domain-probes/ai_overclaiming.md +5 -0
  12. package/skills/peer-review/references/domain-probes/diagnostic_accuracy.md +14 -0
  13. package/skills/peer-review/references/domain-probes/observational_confounding.md +5 -0
  14. package/skills/peer-review/references/domain-probes/sr_ma.md +11 -0
  15. package/skills/self-review/SKILL.md +31 -5
  16. package/skills/self-review/references/domain-probes/ai_overclaiming.md +5 -0
  17. package/skills/self-review/references/domain-probes/diagnostic_accuracy.md +14 -0
  18. package/skills/self-review/references/domain-probes/observational_confounding.md +5 -0
  19. package/skills/self-review/references/domain-probes/sr_ma.md +11 -0
  20. package/skills/self-review/scripts/check_binning_consistency.py +12 -6
  21. package/skills/self-review/scripts/check_claim_artifact.py +66 -15
  22. package/skills/self-review/scripts/check_classical_style.py +15 -1
  23. package/skills/self-review/scripts/check_emphasis_density.py +155 -0
  24. package/skills/self-review/scripts/check_figure_citation.py +135 -0
  25. package/skills/self-review/scripts/check_null_calibration.py +34 -16
  26. package/skills/self-review/scripts/check_rounded_delta.py +169 -0
  27. package/skills/self-review/scripts/check_scope_coherence.py +17 -1
  28. package/skills/self-review/scripts/check_supplement_hygiene.py +45 -0
  29. package/skills/self-review/skill.yml +3 -0
  30. package/skills/self-review/tests/fixtures/claim_manuscript_structured.md +6 -0
  31. package/skills/self-review/tests/fixtures/claim_prereg_structured.md +9 -0
  32. package/skills/self-review/tests/fixtures/classical_metadoc.md +8 -0
  33. package/skills/self-review/tests/fixtures/derived_clean_dfalias/03_cohort.py +7 -0
  34. package/skills/self-review/tests/fixtures/derived_clean_dfalias/08_sensitivity.py +8 -0
  35. package/skills/self-review/tests/fixtures/emphasis_clean.md +6 -0
  36. package/skills/self-review/tests/fixtures/emphasis_overuse.md +6 -0
  37. package/skills/self-review/tests/fixtures/figure_clean.md +11 -0
  38. package/skills/self-review/tests/fixtures/figure_orphan.md +10 -0
  39. package/skills/self-review/tests/fixtures/null_region_masked.md +15 -0
  40. package/skills/self-review/tests/fixtures/rounded_delta_bad.md +3 -0
  41. package/skills/self-review/tests/fixtures/rounded_delta_clean.md +4 -0
  42. package/skills/self-review/tests/fixtures/scope_metadoc.md +12 -0
  43. package/skills/self-review/tests/fixtures/supplement_pii_clean.md +5 -0
  44. package/skills/self-review/tests/fixtures/supplement_pii_tie.md +6 -0
  45. package/skills/self-review/tests/test_binning_consistency.sh +10 -0
  46. package/skills/self-review/tests/test_claim_artifact.sh +16 -0
  47. package/skills/self-review/tests/test_classical_style.sh +12 -0
  48. package/skills/self-review/tests/test_emphasis_density.sh +31 -0
  49. package/skills/self-review/tests/test_figure_citation.sh +39 -0
  50. package/skills/self-review/tests/test_null_calibration.sh +8 -0
  51. package/skills/self-review/tests/test_rounded_delta.sh +35 -0
  52. package/skills/self-review/tests/test_scope_coherence.sh +12 -0
  53. package/skills/self-review/tests/test_supplement_hygiene.sh +17 -0
@@ -1658,8 +1658,8 @@
1658
1658
  },
1659
1659
  {
1660
1660
  "path": "skills/humanize/SKILL.md",
1661
- "size": 13461,
1662
- "sha256": "78e12b996cacfa5b1b16b0a0fe26cee5dd72cb990b70f26e90041d6e0eb65c9a"
1661
+ "size": 14240,
1662
+ "sha256": "f179598c74b1ab0b3d6639fdd26b8cafa33713a9ebe9374aad5d31571340d836"
1663
1663
  },
1664
1664
  {
1665
1665
  "path": "skills/humanize/references/ai_patterns.md",
@@ -2003,8 +2003,8 @@
2003
2003
  },
2004
2004
  {
2005
2005
  "path": "skills/make-figures/references/exemplar_plots/README.md",
2006
- "size": 5872,
2007
- "sha256": "32f93f0c65b2fe353b17dd1345267c85d5646fcf7de7adb9f784be551fc900af"
2006
+ "size": 6524,
2007
+ "sha256": "6dded03ce2aa6cf81277d4a64fa15e0f6814aa568132fc817e2fc753517d5338"
2008
2008
  },
2009
2009
  {
2010
2010
  "path": "skills/make-figures/references/exemplar_plots/bland_altman.md",
@@ -2168,23 +2168,23 @@
2168
2168
  },
2169
2169
  {
2170
2170
  "path": "skills/make-figures/scripts/render_core_figures.py",
2171
- "size": 14436,
2172
- "sha256": "35f91147d51dd73b75dc116be9e0a80588fe1daf502966e82468d3c62b12f25d"
2171
+ "size": 29667,
2172
+ "sha256": "8c5b8a3b564b74977f25931094e11b55e2d1bad7ec6c05436825bd1bf69dc0b0"
2173
2173
  },
2174
2174
  {
2175
2175
  "path": "skills/make-figures/scripts/render_core_figures_challenge/fixture/synthetic_inputs.json",
2176
- "size": 1429,
2177
- "sha256": "24c0944fcb1384f073988d0f6e28b8f375ba4a7eff62fb4e21bc7066726d5ebc"
2176
+ "size": 3609,
2177
+ "sha256": "5ca97899b1f61fec1c933039fafdae13dbe20bec175561019a4c947109a9b88f"
2178
2178
  },
2179
2179
  {
2180
2180
  "path": "skills/make-figures/scripts/render_core_figures_challenge/problem.md",
2181
- "size": 2485,
2182
- "sha256": "a4e62161806d95b637d9ce984541190e5e9e213e2522d1f8740d847ee10c1a30"
2181
+ "size": 3486,
2182
+ "sha256": "86c57b26720a6ae1112a641f9ef1e1736c1bc3e413452de94d7e25ec298c26ba"
2183
2183
  },
2184
2184
  {
2185
2185
  "path": "skills/make-figures/scripts/render_core_figures_challenge/verify.sh",
2186
- "size": 1907,
2187
- "sha256": "7f05fd9cd4e2bfdf6c5b7e8e2a61bee5f8fd44d7d2746e29e2ae77adec84b9c0"
2186
+ "size": 2378,
2187
+ "sha256": "2961607318f21052d1b1e607b024520ac6a53ebf4afeaf4287199ed252c1c034"
2188
2188
  },
2189
2189
  {
2190
2190
  "path": "skills/make-figures/scripts/validate_pptx_mac_compat.py",
@@ -2923,8 +2923,8 @@
2923
2923
  },
2924
2924
  {
2925
2925
  "path": "skills/peer-review/SKILL.md",
2926
- "size": 68213,
2927
- "sha256": "51feccb82dca34a28a0b9ace5467d23ddc654056af2cd9e152a4559cf074cfc3"
2926
+ "size": 68302,
2927
+ "sha256": "bf56fbc16bb3e43fb43f09982d36e68f53f8b0f666564e2828e692472ad0660f"
2928
2928
  },
2929
2929
  {
2930
2930
  "path": "skills/peer-review/references/aczel_2021_reviewer2_patterns.md",
@@ -2933,8 +2933,8 @@
2933
2933
  },
2934
2934
  {
2935
2935
  "path": "skills/peer-review/references/domain-probes/ai_overclaiming.md",
2936
- "size": 14205,
2937
- "sha256": "565aa362e20ea6ca923510ea393829fb851e0caaae3d732f99144dd48a25b951"
2936
+ "size": 15410,
2937
+ "sha256": "bdb07002bf0b351579036937af5bf149e35389a218198699b81d31dc2fda7ec9"
2938
2938
  },
2939
2939
  {
2940
2940
  "path": "skills/peer-review/references/domain-probes/case_report.md",
@@ -2948,8 +2948,8 @@
2948
2948
  },
2949
2949
  {
2950
2950
  "path": "skills/peer-review/references/domain-probes/diagnostic_accuracy.md",
2951
- "size": 9002,
2952
- "sha256": "8232e2023d3c4d52e6a2d9003ae335302d6fe78c54d39b81f591c07becaf4df0"
2951
+ "size": 11692,
2952
+ "sha256": "afc69e56578babcc1cf7c7952d2df301fdc829329fb480a5cf426ff6ab65d9f5"
2953
2953
  },
2954
2954
  {
2955
2955
  "path": "skills/peer-review/references/domain-probes/equity_fairness.md",
@@ -2993,8 +2993,8 @@
2993
2993
  },
2994
2994
  {
2995
2995
  "path": "skills/peer-review/references/domain-probes/observational_confounding.md",
2996
- "size": 33429,
2997
- "sha256": "ac1736047c069aa47df76deea92379cae407043b6989d8d3fc77db8fd84b0031"
2996
+ "size": 34617,
2997
+ "sha256": "ec4a11caed6d604e510437827f2b979fcf43ab1ddea6b298eb95673472b2b459"
2998
2998
  },
2999
2999
  {
3000
3000
  "path": "skills/peer-review/references/domain-probes/polygenic_risk_score.md",
@@ -3028,8 +3028,8 @@
3028
3028
  },
3029
3029
  {
3030
3030
  "path": "skills/peer-review/references/domain-probes/sr_ma.md",
3031
- "size": 17239,
3032
- "sha256": "486d569f559f16d62b882f7256ccfe7443e3e2b4cc72a407a5cd046162a98b25"
3031
+ "size": 20154,
3032
+ "sha256": "82ccb4abc1ab1fffaf528268baad6c79e67c9b2ff929c14d7dcccb9677e10ddc"
3033
3033
  },
3034
3034
  {
3035
3035
  "path": "skills/peer-review/references/domain-probes/survey_research.md",
@@ -3488,13 +3488,13 @@
3488
3488
  },
3489
3489
  {
3490
3490
  "path": "skills/self-review/SKILL.md",
3491
- "size": 102413,
3492
- "sha256": "97219c57684d4eaced9e35b0968ca09da7731095c186a4e97757b4039f43f2f6"
3491
+ "size": 104028,
3492
+ "sha256": "22bc51c9adcb1fc9ff9120c2650faaa2f5f3a0482fe1fd781e231e2191e21081"
3493
3493
  },
3494
3494
  {
3495
3495
  "path": "skills/self-review/references/domain-probes/ai_overclaiming.md",
3496
- "size": 14205,
3497
- "sha256": "565aa362e20ea6ca923510ea393829fb851e0caaae3d732f99144dd48a25b951"
3496
+ "size": 15410,
3497
+ "sha256": "bdb07002bf0b351579036937af5bf149e35389a218198699b81d31dc2fda7ec9"
3498
3498
  },
3499
3499
  {
3500
3500
  "path": "skills/self-review/references/domain-probes/case_report.md",
@@ -3508,8 +3508,8 @@
3508
3508
  },
3509
3509
  {
3510
3510
  "path": "skills/self-review/references/domain-probes/diagnostic_accuracy.md",
3511
- "size": 9002,
3512
- "sha256": "8232e2023d3c4d52e6a2d9003ae335302d6fe78c54d39b81f591c07becaf4df0"
3511
+ "size": 11692,
3512
+ "sha256": "afc69e56578babcc1cf7c7952d2df301fdc829329fb480a5cf426ff6ab65d9f5"
3513
3513
  },
3514
3514
  {
3515
3515
  "path": "skills/self-review/references/domain-probes/equity_fairness.md",
@@ -3553,8 +3553,8 @@
3553
3553
  },
3554
3554
  {
3555
3555
  "path": "skills/self-review/references/domain-probes/observational_confounding.md",
3556
- "size": 33429,
3557
- "sha256": "ac1736047c069aa47df76deea92379cae407043b6989d8d3fc77db8fd84b0031"
3556
+ "size": 34617,
3557
+ "sha256": "ec4a11caed6d604e510437827f2b979fcf43ab1ddea6b298eb95673472b2b459"
3558
3558
  },
3559
3559
  {
3560
3560
  "path": "skills/self-review/references/domain-probes/polygenic_risk_score.md",
@@ -3588,8 +3588,8 @@
3588
3588
  },
3589
3589
  {
3590
3590
  "path": "skills/self-review/references/domain-probes/sr_ma.md",
3591
- "size": 17239,
3592
- "sha256": "486d569f559f16d62b882f7256ccfe7443e3e2b4cc72a407a5cd046162a98b25"
3591
+ "size": 20154,
3592
+ "sha256": "82ccb4abc1ab1fffaf528268baad6c79e67c9b2ff929c14d7dcccb9677e10ddc"
3593
3593
  },
3594
3594
  {
3595
3595
  "path": "skills/self-review/references/domain-probes/survey_research.md",
@@ -3658,8 +3658,8 @@
3658
3658
  },
3659
3659
  {
3660
3660
  "path": "skills/self-review/scripts/check_binning_consistency.py",
3661
- "size": 19541,
3662
- "sha256": "e3bf7dd2e0871ce6905abc1d33a26c7afac76a93d184bfe2d431af97d0622f74"
3661
+ "size": 20076,
3662
+ "sha256": "c6467eb4a1d954d67da87e283d8a628604a9ec7558c966f8b20c1b661e23b9c9"
3663
3663
  },
3664
3664
  {
3665
3665
  "path": "skills/self-review/scripts/check_citation_order.py",
@@ -3668,13 +3668,13 @@
3668
3668
  },
3669
3669
  {
3670
3670
  "path": "skills/self-review/scripts/check_claim_artifact.py",
3671
- "size": 13065,
3672
- "sha256": "ac29a7e8145152e6641fce8eaf98766cfc943a034db72252cc38538d7ab59db2"
3671
+ "size": 15879,
3672
+ "sha256": "1daa64eaabe493f05ae7e2129a2056b673ec588487d5b6c73f41f10104822935"
3673
3673
  },
3674
3674
  {
3675
3675
  "path": "skills/self-review/scripts/check_classical_style.py",
3676
- "size": 12210,
3677
- "sha256": "c973ee8b776f28515439fb185e1254e08e62c2e1410e260f18a824241a331af0"
3676
+ "size": 13124,
3677
+ "sha256": "941d90559cdcdd24f3eba58daf00eb34a7d227c3fe44267b93b65b80b6092989"
3678
3678
  },
3679
3679
  {
3680
3680
  "path": "skills/self-review/scripts/check_cohort_arithmetic.py",
@@ -3691,10 +3691,20 @@
3691
3691
  "size": 22032,
3692
3692
  "sha256": "42e0e9315e1c97ca0f9943213c65ea91d7064773d25287b2577f0386dfb7fc41"
3693
3693
  },
3694
+ {
3695
+ "path": "skills/self-review/scripts/check_emphasis_density.py",
3696
+ "size": 6626,
3697
+ "sha256": "79e517942c1eca4f879fa2b05e6497f93fe6f4d53ed58ca98e240bf5b44c4821"
3698
+ },
3699
+ {
3700
+ "path": "skills/self-review/scripts/check_figure_citation.py",
3701
+ "size": 5298,
3702
+ "sha256": "342bc1126a420111da9c0fe38c6cd68cc45542b0e485202689d7219de9d80712"
3703
+ },
3694
3704
  {
3695
3705
  "path": "skills/self-review/scripts/check_null_calibration.py",
3696
- "size": 7594,
3697
- "sha256": "9ddff01722c34efb6ffd757ae762c6ee12f5993bf13b11313c2e20b60b26cab3"
3706
+ "size": 8585,
3707
+ "sha256": "e5bd71c515554b3acbaefd3807e584364a536dc6e1101bec5b524bc26b341309"
3698
3708
  },
3699
3709
  {
3700
3710
  "path": "skills/self-review/scripts/check_panel_diversity.py",
@@ -3716,20 +3726,25 @@
3716
3726
  "size": 15481,
3717
3727
  "sha256": "c9cd8e47cfef1ffcea12ecda5314a71ccd5415f35f4f296addadf53ccb6ceb3b"
3718
3728
  },
3729
+ {
3730
+ "path": "skills/self-review/scripts/check_rounded_delta.py",
3731
+ "size": 7079,
3732
+ "sha256": "2961dd0b25dcd06f202442004e08f2d98c6f2d4c337be3f8b0e3286a59952072"
3733
+ },
3719
3734
  {
3720
3735
  "path": "skills/self-review/scripts/check_scope_coherence.py",
3721
- "size": 10818,
3722
- "sha256": "820dfc264c2a4f62c79c0c7123a3e1a8b59a100b89654617a08ff55deeb25a75"
3736
+ "size": 11920,
3737
+ "sha256": "e600e54fcbe308060a8dadd871ec274b6ae19f89456ae0194490e152c0d09a91"
3723
3738
  },
3724
3739
  {
3725
3740
  "path": "skills/self-review/scripts/check_supplement_hygiene.py",
3726
- "size": 11088,
3727
- "sha256": "f89027472cdf0258357c3b0f0b0f3fec09b5ea65cc1373292797b818d1acf444"
3741
+ "size": 13579,
3742
+ "sha256": "c61bd5e485d2bce4a6efd7bf2a7980aa0d31ebecb876a7c6895fd12cb6bf7f1c"
3728
3743
  },
3729
3744
  {
3730
3745
  "path": "skills/self-review/skill.yml",
3731
- "size": 2223,
3732
- "sha256": "4009f3148776fab2096da3dad8a15e503c5073b4cc66b42c57498948e2040270"
3746
+ "size": 2345,
3747
+ "sha256": "91883d75f33ee873a6ebfbd4d5f2cf0c292d3b90fe4705d6a35e82a26ecf6523"
3733
3748
  },
3734
3749
  {
3735
3750
  "path": "skills/setup-medsci/SKILL.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "version": "5.9.2",
3
+ "version": "5.11.0",
4
4
  "owned_skills": [
5
5
  "academic-aio",
6
6
  "add-journal",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "medsci-skills",
3
- "version": "5.9.2",
3
+ "version": "5.11.0",
4
4
  "description": "MedSci Skills — a medical/scientific research skill suite for AI coding agents (Claude Code, Codex, Cursor, Copilot). The npm package is a terminal-friendly installer shortcut; the canonical distribution remains the GitHub repository and the Claude Code plugin marketplace.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://github.com/Aperivue/medsci-skills#readme",
@@ -178,6 +178,7 @@ If the density remains above 2.0, run another fix-verify cycle (max 3 rounds).
178
178
  | 19 | § (section sign) marker | "as in §2.3", "(see §Discussion)", "§Results" | Delete or replace with section name ("Methods", "Results") — `grep -c "§"` = 0 |
179
179
  | 20 | Methods/Results self-reference parenthetical | "(Methods §X)", "(Results §3.1)", "(Methods, Section 2.3)" | Drop the parenthetical or shorten to "(see Methods)" |
180
180
  | 21 | AI Disclosure boilerplate (body) | "## Artificial Intelligence Disclosure", "Generative AI was not used to create..." in manuscript body | Remove from body → place in cover letter / submission form only (per `~/.claude/rules/journal-ai-image-policies.md`) |
181
+ | 25 | Inline-emphasis over-use (typographic over-signposting) | Single-word italics (*into*, *passive*, *same*), whole-clause italics (*a redesign of the relationship itself*), bold used mid-paragraph to signpost | Remove inline emphasis; keep only legitimate italics — statistical symbols (*P*, *t*, *n*), Latin (*in vivo*, *et al.*), gene/species (*BRCA1*). A bold **run-in subheading** at line start is fine (Nature/npj style) |
181
182
 
182
183
  ### Response-Letter Patterns (R2R)
183
184
 
@@ -247,6 +248,7 @@ the pass/fail status.
247
248
  | Pattern 19 — `§` symbol | ENFORCED (senior MA reviewer prep) | `grep -c "§" manuscript.md` > 0 | auto-strip; verify post-rewrite count == 0 |
248
249
  | Pattern 20 — `(see Methods §X)` self-reference | ENFORCED | match found | rewrite to direct section name reference |
249
250
  | Pattern 21 — AI Disclosure paragraph in body | ENFORCED | "Generative AI was not used..." paragraph in manuscript body | move to cover letter or remove |
251
+ | Pattern 25 — inline-emphasis over-use | ENFORCED | italic-emphasis density over threshold after allowlist | run `/self-review` `scripts/check_emphasis_density.py --manuscript manuscript.md`; `EMPHASIS_OVERUSE` (Minor) means strip inline italics (keep only stat symbols / Latin / gene-species); whole-clause italics are the strongest tell |
250
252
  | Patterns 22-24 — R2R editing-mechanism / draft line-number / tooling leak | TRIAGE (response letters); `§` = 0 hard | detection greps in ai_patterns.md R2R section surface candidates | review each hit (analysis narration, quoted additions, revised-manuscript page/line are NOT tells); rewrite confirmed tells to substantive prose |
251
253
  | Citation preservation invariant | ENFORCED | any pre-existing `[@bibkey]` removed by rewrite | revert that single rewrite; flag for user |
252
254
  | Numerical preservation invariant | ENFORCED | any number changed by rewrite | revert; flag for user |
@@ -13,15 +13,24 @@ against `critic_rubrics/data_plot.md`; do not copy an image.
13
13
 
14
14
  ## Runnable render layer (tested)
15
15
 
16
- For the four highest-yield clinical figures — **Kaplan–Meier, ROC, calibration, and
17
- decision-curve** the prose anatomy here has a matching **runnable, deterministic
18
- generator** in `../../scripts/render_core_figures.py`. It renders already-computed inputs
16
+ Ten of the highest-yield clinical figures — **Kaplan–Meier, ROC, calibration,
17
+ decision-curve, forest, Bland–Altman, confusion matrix, multi-reader ROC (MRMC),
18
+ Manhattan, and clinical timeline** have a matching **runnable, deterministic generator**
19
+ in `../../scripts/render_core_figures.py`. It renders already-computed inputs
19
20
  (the statistical estimation stays in `/analyze-stats`; the render layer never recomputes a
20
21
  number) into the canonical anatomy and asserts each figure's load-bearing elements
21
22
  (number-at-risk table, chance diagonal, identity line, treat-all/treat-none references,
22
- no extrapolation past follow-up). A network-free render-regression challenge
23
+ no extrapolation past follow-up; forest per-study CI rows + null line + pooled diamond;
24
+ Bland–Altman bias + 95% limits of agreement; confusion Predicted/Actual axes + annotated
25
+ cells; MRMC per-reader + averaged curves; Manhattan significance-threshold line; timeline
26
+ baseline + event markers). A network-free render-regression challenge
23
27
  (`scripts/render_core_figures_challenge/`, wired into `skill.yml` validation) renders all
24
- four from a synthetic fixture and confirms the structural gate fails on a malformed figure.
28
+ ten from a synthetic fixture and confirms the structural gate fails on a malformed figure.
29
+
30
+ `imaging_panel.md` stays a **prose-only** exemplar by design: it composes real medical
31
+ images with panel labels, scale bars, and arrows — an image-arrangement task, not a plot of
32
+ computed numbers — so a synthetic generator would only draw placeholder boxes. Use the
33
+ prose model to compose it against real images.
25
34
  Use the generator to produce these four directly; use the prose models below to compose
26
35
  the figure types that do not yet have a generator.
27
36
 
@@ -33,6 +33,7 @@ import matplotlib
33
33
  matplotlib.use("Agg") # headless, deterministic
34
34
  import matplotlib.pyplot as plt # noqa: E402
35
35
  import numpy as np # noqa: E402
36
+ from matplotlib.patches import Polygon # noqa: E402
36
37
 
37
38
 
38
39
  # --------------------------------------------------------------------------- KM
@@ -164,6 +165,188 @@ def decision_curve(thresholds, net_benefit_model, prevalence: float, *,
164
165
  return fig
165
166
 
166
167
 
168
+ # ------------------------------------------------------------------- forest
169
+ def forest_plot(studies: list[dict], pooled: dict, *, null_value: float = 1.0,
170
+ effect_label: str = "Effect (95% CI)", title: str = "",
171
+ log_x: bool = True) -> plt.Figure:
172
+ """Meta-analysis forest plot from already-computed per-study estimates.
173
+
174
+ studies: [{name, est, lo, hi, weight?}] — each study's point estimate + CI.
175
+ pooled: {est, lo, hi, label} — the pooled estimate + CI + model name.
176
+ Draws a weight-scaled marker + CI whisker per study, the null reference line, and a
177
+ pooled diamond; the pooled row is always last. For a ratio measure keep log_x=True and
178
+ null_value=1.0; for a mean difference pass log_x=False, null_value=0.0."""
179
+ n = len(studies)
180
+ fig, ax = plt.subplots(figsize=(6.6, 0.5 * n + 2.0))
181
+ ys = list(range(n, 0, -1)) # top study at the highest y
182
+ weights = np.asarray([s.get("weight", 1.0) for s in studies], float)
183
+ wnorm = weights / weights.max() if weights.max() > 0 else np.ones(n)
184
+ for y, s, w in zip(ys, studies, wnorm):
185
+ ax.plot([s["lo"], s["hi"]], [y, y], color="0.3") # CI whisker
186
+ ax.scatter([s["est"]], [y], s=30 + 120 * w, marker="s",
187
+ color="steelblue", zorder=4) # weight-scaled box
188
+ ax.axvline(null_value, linestyle="--", color="0.5") # null reference
189
+ # pooled diamond on a row below the studies
190
+ yd = 0
191
+ d = pooled
192
+ ax.add_patch(Polygon([[d["lo"], yd], [d["est"], yd + 0.35],
193
+ [d["hi"], yd], [d["est"], yd - 0.35]],
194
+ closed=True, facecolor="crimson", edgecolor="black", zorder=5))
195
+ labels = [s["name"] for s in studies] + [pooled.get("label", "Pooled")]
196
+ ax.set_yticks(ys + [yd])
197
+ ax.set_yticklabels(labels)
198
+ ax.set_ylim(-1, n + 1)
199
+ if log_x:
200
+ ax.set_xscale("log")
201
+ ax.set_xlabel(effect_label)
202
+ ax.set_title(title or "Meta-analysis forest plot")
203
+ fig.tight_layout()
204
+ fig._mf_kind = "forest"
205
+ fig._mf_n_studies = n
206
+ fig._mf_null = null_value
207
+ return fig
208
+
209
+
210
+ # -------------------------------------------------------------- Bland–Altman
211
+ def bland_altman(mean_vals, diff_vals, *, bias: float, sd_diff: float,
212
+ title: str = "") -> plt.Figure:
213
+ """Bland–Altman agreement plot: difference vs mean, with the bias line and the
214
+ 95% limits of agreement (bias ± 1.96·SD) — the load-bearing agreement elements."""
215
+ mean_vals = np.asarray(mean_vals, float)
216
+ diff_vals = np.asarray(diff_vals, float)
217
+ loa_hi, loa_lo = bias + 1.96 * sd_diff, bias - 1.96 * sd_diff
218
+ fig, ax = plt.subplots(figsize=(6.0, 5.0))
219
+ ax.scatter(mean_vals, diff_vals, s=25, color="steelblue", alpha=0.8)
220
+ ax.axhline(bias, color="crimson", label=f"Bias {bias:.2f}")
221
+ ax.axhline(loa_hi, linestyle="--", color="0.4", label=f"+1.96 SD {loa_hi:.2f}")
222
+ ax.axhline(loa_lo, linestyle="--", color="0.4", label=f"−1.96 SD {loa_lo:.2f}")
223
+ ax.set_xlabel("Mean of the two measurements")
224
+ ax.set_ylabel("Difference between measurements")
225
+ ax.set_title(title or "Bland–Altman agreement")
226
+ ax.legend(loc="upper right", frameon=False)
227
+ fig.tight_layout()
228
+ fig._mf_kind = "bland_altman"
229
+ fig._mf_loa = (loa_lo, loa_hi)
230
+ return fig
231
+
232
+
233
+ # ---------------------------------------------------------- confusion matrix
234
+ def confusion_matrix(matrix, labels, *, title: str = "") -> plt.Figure:
235
+ """Confusion matrix from an already-computed count grid. Rows = actual, cols =
236
+ predicted; every cell is annotated with its count (for a 2×2, TN/FP/FN/TP)."""
237
+ m = np.asarray(matrix, float)
238
+ k = m.shape[0]
239
+ if m.shape[0] != m.shape[1] or k != len(labels):
240
+ raise AssertionError("confusion matrix must be square and match the label count")
241
+ fig, ax = plt.subplots(figsize=(1.4 * k + 2, 1.4 * k + 2))
242
+ ax.imshow(m, cmap="Blues")
243
+ thresh = m.max() / 2.0 if m.max() else 0.5
244
+ for i in range(k):
245
+ for j in range(k):
246
+ ax.text(j, i, str(int(m[i, j])), ha="center", va="center",
247
+ color="white" if m[i, j] > thresh else "black")
248
+ ax.set_xticks(range(k)); ax.set_xticklabels(labels)
249
+ ax.set_yticks(range(k)); ax.set_yticklabels(labels)
250
+ ax.set_xlabel("Predicted")
251
+ ax.set_ylabel("Actual")
252
+ ax.set_title(title or "Confusion matrix")
253
+ fig.tight_layout()
254
+ fig._mf_kind = "confusion"
255
+ fig._mf_k = k
256
+ return fig
257
+
258
+
259
+ # ------------------------------------------------------------------ MRMC ROC
260
+ def mrmc_roc(readers: list[dict], averaged: dict, *, delta_auc: dict | None = None,
261
+ title: str = "") -> plt.Figure:
262
+ """Multi-reader multi-case ROC: each reader's ROC curve plus the reader-averaged
263
+ curve and the chance diagonal (the load-bearing MRMC-reader-study elements).
264
+
265
+ readers: [{name, fpr, tpr, auc}] — per-reader ROC coordinates.
266
+ averaged: {fpr, tpr, auc, label} — the reader-averaged curve.
267
+ delta_auc (optional): {value, margin} — a ΔAUC-vs-margin annotation."""
268
+ fig, ax = plt.subplots(figsize=(5.4, 5.2))
269
+ for r in readers:
270
+ ax.plot(np.asarray(r["fpr"], float), np.asarray(r["tpr"], float),
271
+ color="0.7", linewidth=1) # thin per-reader
272
+ a = averaged
273
+ ax.plot(np.asarray(a["fpr"], float), np.asarray(a["tpr"], float),
274
+ color="crimson", linewidth=2.4,
275
+ label=f"{a.get('label', 'Reader-averaged')} (AUC = {a['auc']:.3f})")
276
+ ax.plot([0, 1], [0, 1], linestyle="--", color="0.5", label="Chance")
277
+ ax.set_xlim(0, 1); ax.set_ylim(0, 1.02)
278
+ ax.set_xlabel("1 − specificity (false-positive rate)")
279
+ ax.set_ylabel("Sensitivity (true-positive rate)")
280
+ ax.set_title(title or "Multi-reader multi-case ROC")
281
+ txt = f"averaged AUC = {a['auc']:.3f}"
282
+ if delta_auc:
283
+ txt += f"\nΔAUC = {delta_auc['value']:.3f} (margin {delta_auc['margin']:.3f})"
284
+ ax.text(0.55, 0.08, txt, fontsize=10,
285
+ bbox=dict(boxstyle="round", fc="white", ec="0.7"))
286
+ ax.legend(loc="lower right", frameon=False)
287
+ fig.tight_layout()
288
+ fig._mf_kind = "mrmc_roc"
289
+ fig._mf_n_readers = len(readers)
290
+ return fig
291
+
292
+
293
+ # ------------------------------------------------------------------ Manhattan
294
+ def manhattan(x, neglogp, threshold: float, *, labels=None,
295
+ ylabel: str = "−log10(p)", xlabel: str = "Exposure / position",
296
+ title: str = "") -> plt.Figure:
297
+ """Manhattan / *-wide-scan plot: −log10(p) vs position with the significance
298
+ threshold line (the two load-bearing elements of an agnostic many-test scan)."""
299
+ x = np.asarray(x, float)
300
+ y = np.asarray(neglogp, float)
301
+ fig, ax = plt.subplots(figsize=(7.0, 4.4))
302
+ ax.scatter(x, y, s=14, color="steelblue", alpha=0.8)
303
+ ax.axhline(threshold, color="crimson", linestyle="--",
304
+ label=f"significance threshold (−log10 = {threshold:.2f})")
305
+ if labels: # sparse labelling of hits above the threshold
306
+ for xi, yi, lab in zip(x, y, labels):
307
+ if lab and yi >= threshold:
308
+ ax.annotate(lab, (xi, yi), fontsize=8,
309
+ xytext=(0, 4), textcoords="offset points", ha="center")
310
+ ax.set_xlabel(xlabel)
311
+ ax.set_ylabel(ylabel)
312
+ ax.set_title(title or "Manhattan plot")
313
+ ax.legend(loc="upper right", frameon=False)
314
+ fig.tight_layout()
315
+ fig._mf_kind = "manhattan"
316
+ fig._mf_threshold = threshold
317
+ return fig
318
+
319
+
320
+ # --------------------------------------------------------- clinical timeline
321
+ def clinical_timeline(events: list[dict], *, time_unit: str = "days from admission",
322
+ title: str = "") -> plt.Figure:
323
+ """Case-report clinical timeline: an event marker + label at each time on a single
324
+ time axis (the load-bearing elements of a longitudinal case figure)."""
325
+ times = [float(e["time"]) for e in events]
326
+ fig, ax = plt.subplots(figsize=(max(6.0, 0.9 * len(events)), 3.4))
327
+ lo, hi = (min(times), max(times)) if times else (0, 1)
328
+ pad = max(1.0, (hi - lo) * 0.08)
329
+ ax.axhline(0, color="0.4") # the timeline
330
+ for i, e in enumerate(events):
331
+ t = float(e["time"])
332
+ up = 1 if i % 2 == 0 else -1
333
+ ax.plot([t, t], [0, up * 0.6], color="0.6") # stem
334
+ ax.scatter([t], [0], s=40, color="crimson", zorder=5) # event marker
335
+ ax.annotate(str(e["label"]), (t, up * 0.65), ha="center",
336
+ va="bottom" if up > 0 else "top", fontsize=8)
337
+ ax.set_xlim(lo - pad, hi + pad)
338
+ ax.set_ylim(-1.3, 1.3)
339
+ ax.get_yaxis().set_visible(False)
340
+ for spine in ("left", "right", "top"):
341
+ ax.spines[spine].set_visible(False)
342
+ ax.set_xlabel(f"Time ({time_unit})")
343
+ ax.set_title(title or "Clinical timeline")
344
+ fig.tight_layout()
345
+ fig._mf_kind = "timeline"
346
+ fig._mf_n_events = len(events)
347
+ return fig
348
+
349
+
167
350
  # ----------------------------------------------------- structural invariants
168
351
  def assert_structure(fig: plt.Figure) -> list[str]:
169
352
  """Assert the load-bearing elements for the figure's kind. Returns the list of
@@ -241,6 +424,97 @@ def assert_structure(fig: plt.Figure) -> list[str]:
241
424
  "DCA: treat-all / treat-none not labelled"
242
425
  passed.append("DCA reference strategies labelled")
243
426
 
427
+ elif kind == "forest":
428
+ ax = fig.axes[0]
429
+ n = getattr(fig, "_mf_n_studies")
430
+ # one horizontal CI whisker (2-point line, equal y) per study
431
+ whiskers = [ln for ln in ax.lines
432
+ if len(ln.get_xdata()) == 2 and np.allclose(np.diff(ln.get_ydata()), 0.0)]
433
+ assert len(whiskers) >= n, "forest: missing per-study CI whiskers"
434
+ passed.append(f"forest per-study CI rows present ({n})")
435
+ null = getattr(fig, "_mf_null")
436
+ assert any(len(ln.get_xdata()) == 2 and np.allclose(ln.get_xdata(), [null, null])
437
+ for ln in ax.lines), "forest: null reference line missing"
438
+ passed.append("forest null reference line present")
439
+ assert any(isinstance(p, Polygon) and len(p.get_xy()) >= 4 for p in ax.patches), \
440
+ "forest: pooled diamond missing"
441
+ passed.append("forest pooled diamond present")
442
+ assert len(ax.get_yticklabels()) >= n + 1, "forest: study/pooled row labels missing"
443
+ passed.append("forest study + pooled row labels present")
444
+
445
+ elif kind == "bland_altman":
446
+ ax = fig.axes[0]
447
+ assert ax.collections, "Bland–Altman: scatter of differences missing"
448
+ passed.append("Bland–Altman difference scatter present")
449
+ hlines = [ln for ln in ax.lines if np.allclose(np.diff(ln.get_ydata()), 0.0)]
450
+ assert len(hlines) >= 3, "Bland–Altman: need bias + two limits-of-agreement lines"
451
+ passed.append("Bland–Altman bias + 2 LoA lines present")
452
+ loa_lo, loa_hi = getattr(fig, "_mf_loa")
453
+ yvals = [float(ln.get_ydata()[0]) for ln in hlines]
454
+ assert any(abs(y - loa_hi) < 1e-6 for y in yvals) and any(abs(y - loa_lo) < 1e-6 for y in yvals), \
455
+ "Bland–Altman: LoA lines not at bias ± 1.96·SD"
456
+ passed.append("Bland–Altman LoA at bias ± 1.96·SD")
457
+ assert "difference" in ax.get_ylabel().lower() and "mean" in ax.get_xlabel().lower(), \
458
+ "Bland–Altman: axes not difference-vs-mean"
459
+ passed.append("Bland–Altman difference-vs-mean axes")
460
+
461
+ elif kind == "confusion":
462
+ ax = fig.axes[0]
463
+ k = getattr(fig, "_mf_k")
464
+ assert ax.images, "confusion: matrix image missing"
465
+ passed.append("confusion matrix image present")
466
+ cells = [t for t in ax.texts if t.get_text().strip().lstrip("-").isdigit()]
467
+ assert len(cells) >= k * k, f"confusion: expected {k * k} annotated cells"
468
+ passed.append(f"confusion all {k}×{k} cells annotated")
469
+ assert "predicted" in ax.get_xlabel().lower() and "actual" in ax.get_ylabel().lower(), \
470
+ "confusion: axes not Predicted/Actual"
471
+ passed.append("confusion Predicted/Actual axes")
472
+
473
+ elif kind == "mrmc_roc":
474
+ ax = fig.axes[0]
475
+ n = getattr(fig, "_mf_n_readers")
476
+ curves = [ln for ln in ax.lines
477
+ if len(ln.get_xdata()) > 2] # multi-point ROC curves
478
+ assert len(curves) >= n + 1, "MRMC-ROC: fewer curves than readers + averaged"
479
+ passed.append(f"MRMC-ROC per-reader + averaged curves present ({n}+1)")
480
+ diag = [ln for ln in ax.lines
481
+ if len(ln.get_xdata()) == 2 and np.allclose(ln.get_xdata(), [0, 1])
482
+ and np.allclose(ln.get_ydata(), [0, 1])]
483
+ assert diag, "MRMC-ROC: chance diagonal missing"
484
+ passed.append("MRMC-ROC chance diagonal present")
485
+ assert has_text(ax, "auc"), "MRMC-ROC: averaged-AUC annotation missing"
486
+ passed.append("MRMC-ROC averaged-AUC annotation present")
487
+ assert "sensitiv" in ax.get_ylabel().lower(), "MRMC-ROC: y-label not sensitivity"
488
+ passed.append("MRMC-ROC sensitivity y-label")
489
+
490
+ elif kind == "manhattan":
491
+ ax = fig.axes[0]
492
+ assert ax.collections, "Manhattan: point scatter missing"
493
+ passed.append("Manhattan scatter present")
494
+ thr = getattr(fig, "_mf_threshold")
495
+ assert any(np.allclose(ln.get_ydata(), thr) for ln in ax.lines
496
+ if np.allclose(np.diff(ln.get_ydata()), 0.0)), \
497
+ "Manhattan: significance threshold line missing"
498
+ passed.append("Manhattan significance threshold line present")
499
+ yl = ax.get_ylabel().lower()
500
+ assert "log" in yl and ("10" in yl or "log10" in yl or "−log" in yl or "-log" in yl), \
501
+ "Manhattan: y-label not −log10(p)"
502
+ passed.append("Manhattan −log10(p) y-label")
503
+
504
+ elif kind == "timeline":
505
+ ax = fig.axes[0]
506
+ ne = getattr(fig, "_mf_n_events")
507
+ assert any(np.allclose(ln.get_ydata(), 0.0) for ln in ax.lines), \
508
+ "timeline: baseline axis missing"
509
+ passed.append("timeline baseline present")
510
+ assert ax.collections, "timeline: event markers missing"
511
+ passed.append("timeline event markers present")
512
+ assert len([t for t in ax.texts if t.get_text().strip()]) >= ne, \
513
+ "timeline: an event label is missing"
514
+ passed.append(f"timeline all {ne} event labels present")
515
+ assert "time" in ax.get_xlabel().lower(), "timeline: x-axis not a time axis"
516
+ passed.append("timeline time x-axis")
517
+
244
518
  else:
245
519
  raise AssertionError(f"unknown figure kind: {kind!r}")
246
520
  return passed
@@ -248,7 +522,7 @@ def assert_structure(fig: plt.Figure) -> list[str]:
248
522
 
249
523
  # ----------------------------------------------------------------- driver
250
524
  def render_all(inputs: dict, out_dir: Path) -> dict:
251
- """Render all four figures from ``inputs``, save PNGs, and assert structure.
525
+ """Render each figure kind present in ``inputs``, save PNGs, and assert structure.
252
526
  Returns {kind: [passed checks]}. Raises on any structural violation."""
253
527
  out_dir.mkdir(parents=True, exist_ok=True)
254
528
  results: dict[str, list[str]] = {}
@@ -261,6 +535,20 @@ def render_all(inputs: dict, out_dir: Path) -> dict:
261
535
  ci_low=d.get("ci_low"), ci_high=d.get("ci_high")),
262
536
  "dca": lambda d: decision_curve(d["thresholds"], d["net_benefit_model"],
263
537
  d["prevalence"]),
538
+ "forest": lambda d: forest_plot(d["studies"], d["pooled"],
539
+ null_value=d.get("null_value", 1.0),
540
+ effect_label=d.get("effect_label", "Effect (95% CI)"),
541
+ log_x=d.get("log_x", True)),
542
+ "bland_altman": lambda d: bland_altman(d["mean_vals"], d["diff_vals"],
543
+ bias=d["bias"], sd_diff=d["sd_diff"]),
544
+ "confusion": lambda d: confusion_matrix(d["matrix"], d["labels"]),
545
+ "mrmc_roc": lambda d: mrmc_roc(d["readers"], d["averaged"],
546
+ delta_auc=d.get("delta_auc")),
547
+ "manhattan": lambda d: manhattan(d["x"], d["neglogp"], d["threshold"],
548
+ labels=d.get("labels"),
549
+ xlabel=d.get("xlabel", "Exposure / position")),
550
+ "timeline": lambda d: clinical_timeline(d["events"],
551
+ time_unit=d.get("time_unit", "days from admission")),
264
552
  }
265
553
  for kind, build in builders.items():
266
554
  if kind not in inputs:
@@ -287,7 +575,7 @@ def main(argv=None) -> int:
287
575
  return 1
288
576
  for kind, checks in results.items():
289
577
  print(f"OK [{kind}] {len(checks)} structural invariants: {'; '.join(checks)}")
290
- print(f"PASS: {len(results)}/4 core figures rendered + structurally verified.")
578
+ print(f"PASS: {len(results)} figure(s) rendered + structurally verified.")
291
579
  return 0
292
580
 
293
581