Brain-5D-Space / src /research /protocol_registry.py
github-actions[bot]
Sync: publish Space API fix
5e0b58b
Raw History Blame Contribute Delete
11.7 kB
"""Operational follow-up protocol registry and preregistration gate."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, cast
PROTOCOL_FILE = Path("protocols/EXP_GEN_0021_OPERATIONAL_PROTOCOLS.json")
PREREG_REQUIRED = {
"schema_version",
"preregistration_id",
"research_question",
"hypothesis",
"protocol_id",
"mode",
"primary_outcomes",
"conditions",
"seed_strategy",
"stopping_rule",
"inclusion_criteria",
"exclusion_criteria",
"analysis_plan",
"freeze",
}
OPERATIONAL_RUNNERS: dict[str, str] = {
"recurrence_map_v1": "run_recurrence_map",
"learning_generalization_v1": "run_generalization",
"independent_replication_v1": "run_replication",
"topology_matched_5d_v1": "run_5d_matched",
"closed_loop_regulation_v1": "run_regulation_recovery",
"temporal_order_spiking_v1": "run_temporal_order",
"subsystem_performance_v1": "run_performance_profile",
"recurrence_scale_v1": "run_recurrence_scale",
"learning_interference_screen_v1": "run_learning_interference",
"sustained_activity_stability_v1": "run_sustained_stability",
}
class PreregistrationError(ValueError):
"""Raised when a scientific protocol lacks a valid frozen preregistration."""
def _json_object(path: Path) -> dict[str, Any]:
raw: object = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise PreregistrationError(f"JSON root must be an object: {path}")
return cast(dict[str, Any], raw)
def load_operational_protocols(research_root: Path) -> list[dict[str, Any]]:
path = research_root / PROTOCOL_FILE
if not path.is_file():
return []
raw = _json_object(path)
protocol_values: object = raw.get("protocols")
if not isinstance(protocol_values, list):
raise PreregistrationError("Operational protocol registry is malformed.")
protocols: list[dict[str, Any]] = []
for item_value in cast(list[object], protocol_values):
if isinstance(item_value, dict):
protocols.append(cast(dict[str, Any], item_value))
return protocols
def protocol_by_id(research_root: Path, protocol_id: str) -> dict[str, Any] | None:
return next(
(
protocol
for protocol in load_operational_protocols(research_root)
if protocol.get("id") == protocol_id
),
None,
)
def protocol_for_question(
research_root: Path, question_id: str
) -> dict[str, Any] | None:
matches = [
protocol
for protocol in load_operational_protocols(research_root)
if protocol.get("research_question") == question_id
]
if len(matches) > 1:
raise PreregistrationError(
f"More than one operational protocol is registered for {question_id}."
)
return matches[0] if matches else None
def _validate_prereg_object(
prereg: dict[str, Any],
*,
protocol: dict[str, Any],
) -> None:
missing = sorted(PREREG_REQUIRED - set(prereg))
if missing:
raise PreregistrationError(
f"Preregistration is missing required fields: {', '.join(missing)}"
)
if prereg.get("schema_version") != "1.0":
raise PreregistrationError("Unsupported preregistration schema version.")
for key, protocol_key in (
("research_question", "research_question"),
("hypothesis", "hypothesis"),
("protocol_id", "id"),
):
if prereg.get(key) != protocol.get(protocol_key):
raise PreregistrationError(
f"Preregistration {key} does not match operational protocol."
)
outcomes_value: object = prereg.get("primary_outcomes")
if not isinstance(outcomes_value, list):
raise PreregistrationError("primary_outcomes must be a non-empty string list.")
outcomes = cast(list[object], outcomes_value)
if not outcomes or not all(isinstance(item, str) and item for item in outcomes):
raise PreregistrationError("primary_outcomes must be a non-empty string list.")
conditions_value: object = prereg.get("conditions")
if not isinstance(conditions_value, list) or not conditions_value:
raise PreregistrationError("conditions must be a non-empty list.")
seed_strategy_value: object = prereg.get("seed_strategy")
if not isinstance(seed_strategy_value, dict):
raise PreregistrationError("seed_strategy must be an object.")
seed_strategy = cast(dict[str, Any], seed_strategy_value)
minimum: object = seed_strategy.get("minimum_independent_seeds")
if isinstance(minimum, bool) or not isinstance(minimum, int) or minimum < 1:
raise PreregistrationError(
"seed_strategy.minimum_independent_seeds must be a positive integer."
)
freeze_value: object = prereg.get("freeze")
if not isinstance(freeze_value, dict):
raise PreregistrationError("freeze must be an object.")
freeze = cast(dict[str, Any], freeze_value)
if freeze.get("immutable_after_first_run") is not True:
raise PreregistrationError("Preregistration must be immutable after first run.")
if freeze.get("human_review_required") is not True:
raise PreregistrationError("Preregistration must require human review.")
if freeze.get("status") not in {"REGISTERED", "FROZEN", "AMENDED"}:
raise PreregistrationError("Invalid preregistration freeze status.")
def validate_operational_protocol(
research_root: Path,
*,
question_id: str,
hypothesis_id: str,
protocol_id: str,
seed_count: int,
) -> dict[str, Any]:
"""Validate RQ/H/protocol linkage and return the frozen preregistration."""
protocol = protocol_by_id(research_root, protocol_id)
if protocol is None:
raise PreregistrationError(f"Unknown operational protocol '{protocol_id}'.")
if protocol.get("research_question") != question_id:
raise PreregistrationError(
f"Protocol '{protocol_id}' is not registered for {question_id}."
)
if protocol.get("hypothesis") != hypothesis_id:
raise PreregistrationError(
f"Protocol '{protocol_id}' is not registered for {hypothesis_id}."
)
prereg_value: object = protocol.get("preregistration")
if not isinstance(prereg_value, str) or not prereg_value:
raise PreregistrationError("Operational protocol lacks preregistration path.")
prereg_path = research_root / prereg_value
if not prereg_path.is_file():
raise PreregistrationError(
f"Preregistration artifact not found: {prereg_value}"
)
prereg = _json_object(prereg_path)
_validate_prereg_object(prereg, protocol=protocol)
seed_strategy_value: object = prereg["seed_strategy"]
if not isinstance(seed_strategy_value, dict):
raise PreregistrationError("seed_strategy must be an object.")
seed_strategy = cast(dict[str, Any], seed_strategy_value)
minimum_value: object = seed_strategy["minimum_independent_seeds"]
if isinstance(minimum_value, bool) or not isinstance(minimum_value, int):
raise PreregistrationError("Invalid minimum independent seed count.")
if seed_count < minimum_value:
raise PreregistrationError(
f"Protocol '{protocol_id}' requires at least {minimum_value} independent "
f"seeds; got {seed_count}."
)
return prereg
def _condition_label(item: object) -> str:
if isinstance(item, dict):
mapping = cast(dict[str, Any], item)
condition_id = (
mapping.get("id") or mapping.get("condition_id") or mapping.get("name")
)
role = mapping.get("role")
if condition_id:
return f"{condition_id}{f' ({role})' if role else ''}"
return ""
return str(item) if item else ""
def protocol_catalog(research_root: Path) -> list[dict[str, Any]]:
"""Return operational protocols enriched with their frozen user-facing contract.
The dashboard uses this payload to show the scientific execution requirements
before a run starts. The data is read only from the registered protocol and its
frozen preregistration, so the UI cannot silently invent or weaken requirements.
"""
catalog: list[dict[str, Any]] = []
for protocol in load_operational_protocols(research_root):
protocol_id = protocol.get("id")
question_id = protocol.get("research_question")
hypothesis_id = protocol.get("hypothesis")
prereg_value = protocol.get("preregistration")
if not isinstance(protocol_id, str) or not isinstance(question_id, str):
raise PreregistrationError("Operational protocol ID/RQ must be strings.")
if not isinstance(hypothesis_id, str):
raise PreregistrationError(
"Operational protocol hypothesis must be a string."
)
if not isinstance(prereg_value, str) or not prereg_value:
raise PreregistrationError(
"Operational protocol lacks preregistration path."
)
prereg_path = research_root / prereg_value
prereg = _json_object(prereg_path)
_validate_prereg_object(prereg, protocol=protocol)
seed_strategy = cast(dict[str, Any], prereg["seed_strategy"])
analysis_plan = cast(dict[str, Any], prereg["analysis_plan"])
minimum_seeds = int(seed_strategy.get("minimum_independent_seeds", 1))
default_seed_expression = (
"101" if minimum_seeds <= 1 else f"101-{100 + minimum_seeds}"
)
prereg_conditions_value: object = prereg.get("conditions", [])
prereg_conditions = (
cast(list[object], prereg_conditions_value)
if isinstance(prereg_conditions_value, list)
else []
)
condition_labels = [_condition_label(item) for item in prereg_conditions]
condition_labels = [item for item in condition_labels if item]
controls = [str(item) for item in protocol.get("controls", [])]
treatments = [str(item) for item in protocol.get("treatments", [])]
condition_profiles = {
"standard": "; ".join(condition_labels),
"controls": "; ".join(controls),
"treatments": "; ".join(treatments),
}
catalog.append(
{
"id": protocol_id,
"label": f"{protocol_id} — {question_id}",
"research_question": question_id,
"hypothesis": hypothesis_id,
"mode": prereg.get("mode"),
"preregistration": prereg_value,
"default_ticks": protocol.get("default_ticks"),
"tick_aware": protocol.get("tick_aware", False),
"minimum_independent_seeds": minimum_seeds,
"default_seed_expression": default_seed_expression,
"seed_rule": seed_strategy.get("rule"),
"condition_profiles": condition_profiles,
"conditions": prereg_conditions,
"controls": protocol.get("controls", []),
"treatments": protocol.get("treatments", []),
"primary_outcomes": prereg.get("primary_outcomes", []),
"secondary_outcomes": prereg.get("secondary_outcomes", []),
"inclusion_criteria": prereg.get("inclusion_criteria", []),
"exclusion_criteria": prereg.get("exclusion_criteria", []),
"inference_policy": analysis_plan.get("inference_policy"),
"freeze": prereg.get("freeze", {}),
}
)
return catalog