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.
- package/metadata/distribution_files.json +61 -46
- package/metadata/distribution_manifest.json +1 -1
- package/package.json +1 -1
- package/skills/humanize/SKILL.md +2 -0
- package/skills/make-figures/references/exemplar_plots/README.md +14 -5
- package/skills/make-figures/scripts/render_core_figures.py +290 -2
- package/skills/make-figures/scripts/render_core_figures_challenge/fixture/synthetic_inputs.json +49 -0
- package/skills/make-figures/scripts/render_core_figures_challenge/problem.md +28 -12
- package/skills/make-figures/scripts/render_core_figures_challenge/verify.sh +21 -12
- package/skills/peer-review/SKILL.md +9 -9
- package/skills/peer-review/references/domain-probes/ai_overclaiming.md +5 -0
- package/skills/peer-review/references/domain-probes/diagnostic_accuracy.md +14 -0
- package/skills/peer-review/references/domain-probes/observational_confounding.md +5 -0
- package/skills/peer-review/references/domain-probes/sr_ma.md +11 -0
- package/skills/self-review/SKILL.md +31 -5
- package/skills/self-review/references/domain-probes/ai_overclaiming.md +5 -0
- package/skills/self-review/references/domain-probes/diagnostic_accuracy.md +14 -0
- package/skills/self-review/references/domain-probes/observational_confounding.md +5 -0
- package/skills/self-review/references/domain-probes/sr_ma.md +11 -0
- package/skills/self-review/scripts/check_binning_consistency.py +12 -6
- package/skills/self-review/scripts/check_claim_artifact.py +66 -15
- package/skills/self-review/scripts/check_classical_style.py +15 -1
- package/skills/self-review/scripts/check_emphasis_density.py +155 -0
- package/skills/self-review/scripts/check_figure_citation.py +135 -0
- package/skills/self-review/scripts/check_null_calibration.py +34 -16
- package/skills/self-review/scripts/check_rounded_delta.py +169 -0
- package/skills/self-review/scripts/check_scope_coherence.py +17 -1
- package/skills/self-review/scripts/check_supplement_hygiene.py +45 -0
- package/skills/self-review/skill.yml +3 -0
- package/skills/self-review/tests/fixtures/claim_manuscript_structured.md +6 -0
- package/skills/self-review/tests/fixtures/claim_prereg_structured.md +9 -0
- package/skills/self-review/tests/fixtures/classical_metadoc.md +8 -0
- package/skills/self-review/tests/fixtures/derived_clean_dfalias/03_cohort.py +7 -0
- package/skills/self-review/tests/fixtures/derived_clean_dfalias/08_sensitivity.py +8 -0
- package/skills/self-review/tests/fixtures/emphasis_clean.md +6 -0
- package/skills/self-review/tests/fixtures/emphasis_overuse.md +6 -0
- package/skills/self-review/tests/fixtures/figure_clean.md +11 -0
- package/skills/self-review/tests/fixtures/figure_orphan.md +10 -0
- package/skills/self-review/tests/fixtures/null_region_masked.md +15 -0
- package/skills/self-review/tests/fixtures/rounded_delta_bad.md +3 -0
- package/skills/self-review/tests/fixtures/rounded_delta_clean.md +4 -0
- package/skills/self-review/tests/fixtures/scope_metadoc.md +12 -0
- package/skills/self-review/tests/fixtures/supplement_pii_clean.md +5 -0
- package/skills/self-review/tests/fixtures/supplement_pii_tie.md +6 -0
- package/skills/self-review/tests/test_binning_consistency.sh +10 -0
- package/skills/self-review/tests/test_claim_artifact.sh +16 -0
- package/skills/self-review/tests/test_classical_style.sh +12 -0
- package/skills/self-review/tests/test_emphasis_density.sh +31 -0
- package/skills/self-review/tests/test_figure_citation.sh +39 -0
- package/skills/self-review/tests/test_null_calibration.sh +8 -0
- package/skills/self-review/tests/test_rounded_delta.sh +35 -0
- package/skills/self-review/tests/test_scope_coherence.sh +12 -0
- 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":
|
|
1662
|
-
"sha256": "
|
|
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":
|
|
2007
|
-
"sha256": "
|
|
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":
|
|
2172
|
-
"sha256": "
|
|
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":
|
|
2177
|
-
"sha256": "
|
|
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":
|
|
2182
|
-
"sha256": "
|
|
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":
|
|
2187
|
-
"sha256": "
|
|
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":
|
|
2927
|
-
"sha256": "
|
|
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":
|
|
2937
|
-
"sha256": "
|
|
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":
|
|
2952
|
-
"sha256": "
|
|
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":
|
|
2997
|
-
"sha256": "
|
|
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":
|
|
3032
|
-
"sha256": "
|
|
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":
|
|
3492
|
-
"sha256": "
|
|
3491
|
+
"size": 104028,
|
|
3492
|
+
"sha256": "22bc51c9adcb1fc9ff9120c2650faaa2f5f3a0482fe1fd781e231e2191e21081"
|
|
3493
3493
|
},
|
|
3494
3494
|
{
|
|
3495
3495
|
"path": "skills/self-review/references/domain-probes/ai_overclaiming.md",
|
|
3496
|
-
"size":
|
|
3497
|
-
"sha256": "
|
|
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":
|
|
3512
|
-
"sha256": "
|
|
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":
|
|
3557
|
-
"sha256": "
|
|
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":
|
|
3592
|
-
"sha256": "
|
|
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":
|
|
3662
|
-
"sha256": "
|
|
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":
|
|
3672
|
-
"sha256": "
|
|
3671
|
+
"size": 15879,
|
|
3672
|
+
"sha256": "1daa64eaabe493f05ae7e2129a2056b673ec588487d5b6c73f41f10104822935"
|
|
3673
3673
|
},
|
|
3674
3674
|
{
|
|
3675
3675
|
"path": "skills/self-review/scripts/check_classical_style.py",
|
|
3676
|
-
"size":
|
|
3677
|
-
"sha256": "
|
|
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":
|
|
3697
|
-
"sha256": "
|
|
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":
|
|
3722
|
-
"sha256": "
|
|
3736
|
+
"size": 11920,
|
|
3737
|
+
"sha256": "e600e54fcbe308060a8dadd871ec274b6ae19f89456ae0194490e152c0d09a91"
|
|
3723
3738
|
},
|
|
3724
3739
|
{
|
|
3725
3740
|
"path": "skills/self-review/scripts/check_supplement_hygiene.py",
|
|
3726
|
-
"size":
|
|
3727
|
-
"sha256": "
|
|
3741
|
+
"size": 13579,
|
|
3742
|
+
"sha256": "c61bd5e485d2bce4a6efd7bf2a7980aa0d31ebecb876a7c6895fd12cb6bf7f1c"
|
|
3728
3743
|
},
|
|
3729
3744
|
{
|
|
3730
3745
|
"path": "skills/self-review/skill.yml",
|
|
3731
|
-
"size":
|
|
3732
|
-
"sha256": "
|
|
3746
|
+
"size": 2345,
|
|
3747
|
+
"sha256": "91883d75f33ee873a6ebfbd4d5f2cf0c292d3b90fe4705d6a35e82a26ecf6523"
|
|
3733
3748
|
},
|
|
3734
3749
|
{
|
|
3735
3750
|
"path": "skills/setup-medsci/SKILL.md",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "medsci-skills",
|
|
3
|
-
"version": "5.
|
|
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",
|
package/skills/humanize/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
17
|
-
decision-curve
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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)}
|
|
578
|
+
print(f"PASS: {len(results)} figure(s) rendered + structurally verified.")
|
|
291
579
|
return 0
|
|
292
580
|
|
|
293
581
|
|