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.
257 lines
11 KiB
Python
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()
|