Files
scientific-agent-skills/tests/clinical-decision-support/test_scripts.py
Timothy Kassis 4fb7e0bc29 Test every script-bearing skill, and enforce that it stays that way
AGENTS.md has always said that a skill shipping scripts/ puts its tests in
tests/<name>/, but nothing checked it: 54 of the 100 such skills had no suite
at all, including docx, pptx, xlsx, pdf and scanpy. All 100 now do.

tests/_meta is the guard. It runs the shared structural contract across every
skill in a single process -- safe because it parses scripts with ast and never
imports them -- and fails when a skill ships scripts/ without a suite or
without a [skills.<name>] entry in skill-requirements.toml. It needs no
scientific packages and finishes in seconds, so skill-tests.yml blocks every
pull request on it, plus the packages=[] suites.

tests/_contract holds what the per-skill suites were each reimplementing:
frontmatter conformance, the 500-line limit, no tests or bytecode under
skills/, local links resolving, scripts parsing, no eval/exec/os.system, no
standard-library shadowing, no hardcoded local paths, valid shell scripts. Also
the --help contract, which skips when a skill's packages are absent and runs
for real under --isolated, and shared behaviour for the files docx/pptx/xlsx
and five schematic-shipping skills carry byte-identical copies of, with drift
detection so they cannot diverge silently.

67 new suites, 3325 test functions. The existing suites were retrofitted: 18
no longer pin an exact skill version, so a version bump no longer breaks a
test; 11 duplicated structural methods removed; 19 wired to the --help
contract; and 5 that failed collection without their packages now skip
cleanly. run_all.py in the bare project environment goes from 6 failures to 0.

Writing the tests surfaced 19 defects in the skills, fixed here with version
bumps. The ones that changed scientific output:

  - openpiv reported vorticity 0 for a rotating flow, from a sign error in
    openpiv's y-up coordinate relabelling; solid-body rotation now gives 2w
    exactly, on grids of either orientation
  - deepchem returned solubility predictions in z-scored space while labelling
    them log(mol/L), because it transformed a y-less dataset instead of
    untransforming the output
  - neuropixels-analysis had the Allen and IBL ISI thresholds swapped,
    contradicting its own references/QUALITY_METRICS.md and inverting the two
    standards' relative strictness
  - scanpy's summarize() raised TypeError on every AnnData under anndata 0.13,
    which reports an unnamed None key on .layers; scanpy convert was broken
  - experimental-design's Latin hypercube was never reproducible: pyDOE3 draws
    from its own default_rng and ignores numpy's global seed
  - primekg shipped a hardcoded path naming a person, which is why
    no_personal_paths is now a contract rule

The remainder is upstream API drift, each verified against the installed
package: retired symbols in bioservices 1.16, gget helpers that returned lists
where a string was written, ArviZ 1.x kwargs in pymc, a positional-only
factory in pymoo, ReduceLROnPlateau(verbose=) in torch 2.13, a removed scvelo
parameter, and PyPDF2 in scientific-slides.

Two manifest environments could not build and are pinned: gget, where an
unpinned scanpy walked back to 1.9.8 and pulled llvmlite 0.36 which does not
compile on 3.13, and pymatgen, pinned to the snapshot its own _common.py
enforces rather than loosening that check. deepchem gains torch, without which
no model class exists.

python tests/run_all.py --isolated: 101 passed, 0 failed.
2026-07-28 10:20:12 -07:00

257 lines
11 KiB
Python

"""Synthetic tests for the research-only clinical-decision-support helpers."""
from __future__ import annotations
import ast
import copy
import json
import sys
import unittest
from pathlib import Path
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2] / "skills" / "clinical-decision-support"
SCRIPTS = ROOT / "scripts"
ASSETS = ROOT / "assets"
sys.path.insert(0, str(SCRIPTS))
import _common # noqa: E402
import cohort_table_generator # noqa: E402
import decision_logic_traceability # noqa: E402
import deidentification_checklist # noqa: E402
import evidence_profile_check # noqa: E402
import model_biomarker_evaluation # noqa: E402
import survival_plan_validator # noqa: E402
import validate_cds_artifact # noqa: E402
import skill_contract
def load_asset(filename: str) -> dict:
return json.loads((ASSETS / filename).read_text(encoding="utf-8"))
def resolve_placeholders(value):
if isinstance(value, dict):
return {key: resolve_placeholders(nested) for key, nested in value.items()}
if isinstance(value, list):
return [resolve_placeholders(nested) for nested in value]
if isinstance(value, str):
if "YYYY-MM-DD" in value:
return "2026-07-23"
if "REPLACE_" in value or "REQUIRES_" in value:
return "Synthetic completed human entry."
return value
class StaticSafetyTests(unittest.TestCase):
def test_scripts_have_no_network_secret_or_dynamic_code_imports(self) -> None:
banned_import_roots = {
"aiohttp",
"httpx",
"openai",
"pickle",
"requests",
"socket",
"urllib",
}
banned_calls = {"eval", "exec"}
for path in sorted(SCRIPTS.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
roots = {alias.name.split(".")[0] for alias in node.names}
self.assertFalse(
roots & banned_import_roots,
f"{path.name} imports a prohibited module",
)
if isinstance(node, ast.ImportFrom) and node.module:
self.assertNotIn(
node.module.split(".")[0],
banned_import_roots,
f"{path.name} imports a prohibited module",
)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
self.assertNotIn(
node.func.id,
banned_calls,
f"{path.name} uses dynamic code execution",
)
source = path.read_text(encoding="utf-8")
self.assertNotIn("os.environ", source)
self.assertNotIn("getenv(", source)
self.assertNotIn("API_KEY", source)
def test_assets_declare_safety_fields(self) -> None:
for path in sorted(ASSETS.glob("*.json")):
document = json.loads(path.read_text(encoding="utf-8"))
self.assertIn("intended_use", document, path.name)
self.assertIn("limitations", document, path.name)
self.assertIn("human_review", document, path.name)
self.assertIn("prohibited_uses", document, path.name)
def test_url_like_input_path_is_rejected(self) -> None:
with self.assertRaises(_common.InputError):
_common.local_input_path("https://example.invalid/data.json")
def test_skill_is_progressively_disclosed_and_versioned(self) -> None:
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
self.assertLess(len(text.splitlines()), 500)
self.assertRegex(text, r'\n version: "\d+\.\d+"\n')
self.assertIn("license: MIT", text)
self.assertIn("metadata:\n version:", text)
class ArtifactValidatorTests(unittest.TestCase):
def test_unresolved_artifact_template_fails_closed(self) -> None:
document = load_asset("artifact_intended_use_template.json")
result = validate_cds_artifact.validate_artifact(document)
self.assertFalse(result.ok)
self.assertTrue(
any("placeholders" in error for error in result.errors), result.errors
)
def test_completed_safe_artifact_passes_structural_checks(self) -> None:
document = copy.deepcopy(load_asset("artifact_intended_use_template.json"))
document["artifact"].update(
{
"id": "SYNTHETIC-ARTIFACT-001",
"owner": "research governance owner",
"date": "2026-07-23",
}
)
document["data_governance"]["data_cut_date"] = "2026-07-23"
document["validation"].update(
{
"external_validation": "Planned before transportability claims.",
"calibration": "Aggregate calibration review planned.",
"subgroup_fairness": "Prespecified subgroup review planned.",
"uncertainty": "Intervals and limitations will be reported.",
"human_factors": "Not evaluated; live use is outside scope.",
}
)
document["sources"][0].update(
{
"citation": "Synthetic governance source, version 1.",
"url": "not_applicable_for_local_synthetic_source",
"accessed": "2026-07-23",
}
)
result = validate_cds_artifact.validate_artifact(document)
self.assertTrue(result.ok, result.errors)
self.assertTrue(result.warnings)
class EvidenceProfileTests(unittest.TestCase):
def test_unresolved_template_requires_human_judgments(self) -> None:
document = load_asset("evidence_profile_template.json")
result, _ = evidence_profile_check.check_profile(document)
self.assertFalse(result.ok)
self.assertTrue(
any("human judgment" in error for error in result.errors),
result.errors,
)
def test_complete_human_profile_passes_without_auto_grading(self) -> None:
document = resolve_placeholders(
copy.deepcopy(load_asset("evidence_profile_template.json"))
)
outcome = document["outcomes"][0]
for domain in outcome["domains"].values():
domain["judgment"] = "not_serious"
domain["rationale"] = "Human reviewers found no serious concern and cited the source."
domain["reviewer_role"] = "systematic-review methodologist"
for item in outcome["upgrading"].values():
item["judgment"] = "not_applicable"
item["rationale"] = "Human reviewers judged the consideration not applicable."
outcome["certainty"].update(
{
"human_judgment": True,
"level": "moderate",
"rationale": "Human panel rationale after explicit domain review.",
"reviewer_role": "GRADE panel chair",
"judgment_date": "2026-07-23",
}
)
document["profile_review"]["completed"] = True
result, summaries = evidence_profile_check.check_profile(document)
self.assertTrue(result.ok, result.errors)
self.assertEqual(summaries[0]["human_entered_certainty"], "moderate")
self.assertFalse(document["auto_grade"])
class AggregateEvaluationTests(unittest.TestCase):
def test_model_evaluation_is_aggregate_and_bounded(self) -> None:
document = load_asset("aggregate_model_evaluation_template.json")
log, report = model_biomarker_evaluation.evaluate(document, minimum=11)
self.assertTrue(log.ok, log.errors)
self.assertFalse(report["individual_output_generated"])
self.assertFalse(report["clinical_classification_generated"])
self.assertFalse(report["recommendation_generated"])
self.assertEqual(len(report["groups"]), 2)
self.assertFalse(report["groups"][0]["suppressed"])
self.assertIn("sensitivity", report["groups"][0]["metrics"])
def test_cohort_table_applies_complementary_suppression(self) -> None:
document = load_asset("aggregate_cohort_table_template.json")
log, _, rows, notes = cohort_table_generator._build_table(document, 11)
self.assertTrue(log.ok, log.errors)
self.assertEqual(rows[-1][1], "SUPP")
self.assertEqual(rows[-1][2], "SUPP-C")
self.assertTrue(any("Complementary suppression" in note for note in notes))
class PlanAndTraceabilityTests(unittest.TestCase):
def test_survival_plan_passes_with_recorded_review_warning(self) -> None:
document = load_asset("survival_analysis_plan_template.json")
result = survival_plan_validator.validate_plan(document)
self.assertTrue(result.ok, result.errors)
self.assertTrue(any("Human review" in warning for warning in result.warnings))
def test_traceability_matrix_is_non_executable(self) -> None:
document = load_asset("decision_logic_traceability_template.json")
result, rows = decision_logic_traceability.validate_matrix(document)
self.assertTrue(result.ok, result.errors)
self.assertFalse(document["metadata"]["executable_logic"])
self.assertEqual(len(rows), 3)
allowed = decision_logic_traceability.ALLOWED_OUTPUT_KINDS
self.assertTrue(all(row["output_kind"] in allowed for row in rows))
class PrivacyChecklistTests(unittest.TestCase):
def test_unresolved_template_fails_closed(self) -> None:
document = load_asset("deidentification_checklist_template.json")
result, summary = deidentification_checklist.check_documentation(document)
self.assertFalse(result.ok)
self.assertFalse(summary["documentation_complete"])
self.assertFalse(summary["hipaa_compliance_determined"])
def test_complete_safe_harbor_documentation_is_not_compliance_claim(self) -> None:
document = resolve_placeholders(
copy.deepcopy(load_asset("deidentification_checklist_template.json"))
)
document["method"] = "safe_harbor"
for entry in document["identifier_categories"]:
entry["status"] = "not_present"
entry["evidence"] = (
"Qualified reviewer documented the category as absent in the inventory."
)
document["safe_harbor_review"]["completed"] = True
document["residual_risk_review"]["completed"] = True
document["human_review"]["completed"] = True
result, summary = deidentification_checklist.check_documentation(document)
self.assertTrue(result.ok, result.errors)
self.assertTrue(summary["documentation_complete"])
self.assertFalse(summary["deidentification_determined"])
self.assertFalse(summary["hipaa_compliance_determined"])
# The shared --help contract: every argparse CLI this skill ships answers --help
# without doing any work. It skips when the skill's packages are absent and runs
# for real under `python tests/run_all.py --isolated`.
CliHelpTests = skill_contract.cli.help_test_case(ROOT)
if __name__ == "__main__":
unittest.main()