Brain-5D-Space / src /research /evidence_engine.py
github-actions[bot]
Sync: publish Space API fix
5e0b58b
Raw History Blame Contribute Delete
18.4 kB
"""
Evidence Engine — Evaluates experimental results and updates evidence status.
After each experiment, the evidence engine:
1. Creates evidence entries from results
2. Links evidence to claims and hypotheses
3. Re-evaluates claim/hypothesis status based on accumulated evidence
4. Generates automated follow-up questions
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from pathlib import Path
from typing import Any, cast
from .registry import (
REGISTRY_DIR,
REPO_ROOT,
Claim,
Hypothesis,
ResearchRegistry,
)
EXPERIMENTS_DIR = REPO_ROOT / "research" / "experiments"
EVIDENCE_DIR = REGISTRY_DIR / "evidence"
EVIDENCE_DIR.mkdir(exist_ok=True)
def _next_evidence_id() -> str:
"""Generate the next evidence ID: EVID-{YYYY}-{NN}."""
year = datetime.now().year
existing = list(EVIDENCE_DIR.glob(f"EVID-{year}-*.json"))
n = len(existing) + 1
return f"EVID-{year}-{n:02d}"
_INVALID_EVIDENCE_STATUSES = frozenset(
{
"template",
"not_started",
"running",
"failed",
"invalid",
}
)
"""Experiment statuses that cannot produce scientific evidence."""
_VALID_EVIDENCE_MODES = frozenset(
{
"deterministic_verification",
"stochastic_experiment",
"observational_experiment",
}
)
def _json_digest(value: object) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _check_experiment_valid(experiment_id: str) -> dict[str, Any] | None:
"""Check whether an experiment manifest is valid scientific evidence.
Returns the manifest dict if valid, None if invalid.
The EvidenceEngine MUST NOT accept:
- template (template/example experiments)
- not_started (not yet executed)
- running (incomplete execution)
- failed (execution failure/crash)
- invalid (scientifically unusable run)
Only ``completed`` experiments with ``validity.valid == True`` may
generate scientific evidence.
"""
manifest_path = EXPERIMENTS_DIR / experiment_id / "manifest.json"
if not manifest_path.exists():
return None
try:
manifest: dict[str, Any] = json.loads(manifest_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
status = manifest.get("experiment_status", "not_started")
if status != "completed" or status in _INVALID_EVIDENCE_STATUSES:
return None
validity_raw = manifest.get("validity", {})
if not isinstance(validity_raw, dict):
return None
validity = cast(dict[str, object], validity_raw)
valid = validity.get("valid")
if valid is not True:
return None
runtime_error_count = validity.get("runtime_error_count")
fatal_error_count = validity.get("fatal_error_count")
if any(
not isinstance(count, int) or isinstance(count, bool) or count < 0
for count in (runtime_error_count, fatal_error_count)
):
return None
if runtime_error_count != 0 or fatal_error_count != 0:
return None
causal_taint = manifest.get("causal_taint", "PURE")
if causal_taint not in {"PURE", "OBSERVED", "PROPOSED", "AI_INFLUENCED"}:
return None
if causal_taint != "PURE":
treatment_raw = manifest.get("ai_treatment")
if not isinstance(treatment_raw, dict):
return None
treatment = cast(dict[str, object], treatment_raw)
protocol_id = treatment.get("protocol_id")
if (
treatment.get("registered") is not True
or not isinstance(protocol_id, str)
or not protocol_id.strip()
or treatment.get("mode", "confirmatory")
not in {"exploratory", "confirmatory"}
):
return None
git_raw = manifest.get("git", {})
if not isinstance(git_raw, dict):
return None
git = cast(dict[str, Any], git_raw)
if git.get("dirty") is not False:
return None
return manifest
class EvidenceEngine:
"""Evaluates experimental evidence and updates claim/hypothesis status.
The engine enforces scientific validity:
- Only ``completed`` experiments with ``validity.valid == True``
can produce evidence.
- Invalid, failed, or template experiments are silently rejected.
"""
def __init__(self, registry: ResearchRegistry):
self.registry = registry
def record_human_review(
self,
experiment_id: str,
*,
reviewer: str,
decision: str,
comments: str,
) -> Path:
"""Write the mandatory human review artifact for an experiment."""
if not experiment_id or "/" in experiment_id or "\\" in experiment_id:
raise ValueError("Invalid experiment id")
if not reviewer.strip() or not comments.strip():
raise ValueError("Human review requires reviewer and comments")
if decision not in {"supports", "refutes", "inconclusive"}:
raise ValueError("Human review decision is invalid")
experiment_dir = EXPERIMENTS_DIR / experiment_id
if not (experiment_dir / "manifest.json").is_file():
raise ValueError("Experiment manifest does not exist")
review_path = experiment_dir / "human_review.json"
if review_path.exists():
raise FileExistsError(f"Human review already exists: {experiment_id}")
review = {
"experiment_id": experiment_id,
"reviewer": reviewer.strip(),
"decision": decision,
"comments": comments.strip(),
"reviewed_at": datetime.now().isoformat(),
}
review_path.write_text(
json.dumps(review, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
return review_path
def promote_validated_experiment(
self,
experiment_id: str,
claim_id: str,
hypothesis_id: str,
result_summary: str,
*,
evidence_mode: str = "observational_experiment",
effect_size: dict[str, Any] | None = None,
verification: dict[str, Any] | None = None,
limitations: str | None = None,
) -> str:
"""Promote DATA to EVID only after source freeze and human review."""
manifest = _check_experiment_valid(experiment_id)
if manifest is None:
raise ValueError("Experiment is not eligible for validated promotion")
provenance_raw = manifest.get("provenance_digests")
source_freeze_sha = manifest.get("source_freeze_sha")
if not isinstance(provenance_raw, dict) or not isinstance(
source_freeze_sha, str
):
raise ValueError("Validated promotion requires source-freeze digests")
provenance = cast(dict[str, object], provenance_raw)
if _json_digest(provenance) != source_freeze_sha:
raise ValueError("Source-freeze digest does not match provenance digests")
review_path = EXPERIMENTS_DIR / experiment_id / "human_review.json"
try:
review_raw: object = json.loads(review_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("Validated promotion requires a human review") from exc
if not isinstance(review_raw, dict):
raise ValueError("Human review artifact is invalid")
review = cast(dict[str, object], review_raw)
if review.get("experiment_id") != experiment_id:
raise ValueError("Human review experiment identity does not match")
if (
not isinstance(review.get("reviewer"), str)
or not str(review["reviewer"]).strip()
):
raise ValueError("Human review requires a reviewer")
if (
not isinstance(review.get("comments"), str)
or not str(review["comments"]).strip()
):
raise ValueError("Human review requires comments")
decision = review.get("decision")
if not isinstance(decision, str) or decision not in {
"supports",
"refutes",
"inconclusive",
}:
raise ValueError("Human review decision is invalid")
merged_verification = dict(verification or {})
merged_verification["source_freeze_sha"] = source_freeze_sha
merged_verification["human_review"] = review
return self.evaluate_experiment(
experiment_id=experiment_id,
claim_id=claim_id,
hypothesis_id=hypothesis_id,
result_summary=result_summary,
effect_size=effect_size,
evidence_mode=evidence_mode,
verification=merged_verification,
status=decision,
limitations=limitations,
)
def evaluate_experiment(
self,
experiment_id: str,
claim_id: str,
hypothesis_id: str,
result_summary: str,
effect_size: dict[str, Any] | None = None,
statistical_significance: dict[str, Any] | None = None,
evidence_mode: str = "observational_experiment",
verification: dict[str, Any] | None = None,
status: str = "inconclusive",
limitations: str | None = None,
) -> str:
"""
Evaluate an experiment and create an evidence entry.
Args:
experiment_id: ID of the experiment to evaluate.
claim_id: ID of the claim to link evidence to.
hypothesis_id: ID of the hypothesis to link evidence to.
result_summary: Summary of the experimental result.
effect_size: Optional effect size metrics.
statistical_significance: Optional statistical significance data.
status: Evidence status (supports | refutes | inconclusive).
limitations: Optional limitations text.
Returns:
The evidence ID.
Raises:
ValueError: If the experiment is not scientifically valid
(template, not_started, running, failed, or invalid).
"""
if evidence_mode not in _VALID_EVIDENCE_MODES:
raise ValueError(f"Unknown evidence mode: {evidence_mode!r}")
if evidence_mode == "deterministic_verification" and statistical_significance:
raise ValueError(
"Deterministic verification must not claim statistical significance."
)
# Phase 1: Reject scientifically invalid experiments
manifest = _check_experiment_valid(experiment_id)
if manifest is None:
raise ValueError(
f"Cannot create evidence from experiment {experiment_id}: "
"experiment is not scientifically valid. "
"Only 'completed' experiments with valid execution may "
"produce evidence."
)
evidence_id = _next_evidence_id()
# Include experiment validity info in evidence record
validity = manifest.get("validity", {})
evidence: dict[str, Any] = {
"evidence_id": evidence_id,
"experiment_id": experiment_id,
"claim_id": claim_id,
"hypothesis_id": hypothesis_id,
"result_summary": result_summary,
"effect_size": effect_size or {},
"statistical_significance": statistical_significance or {},
"evidence_mode": evidence_mode,
"verification": verification or {},
"status": status, # supports | refutes | inconclusive | pending
"limitations": limitations or "",
"artifacts": {"figures": [], "data_files": []},
"generated": datetime.now().isoformat(),
"experiment_validity": {
"valid": validity.get("valid", True),
"runtime_error_count": validity.get("runtime_error_count", 0),
"fatal_error_count": validity.get("fatal_error_count", 0),
},
}
# Save evidence
evidence_path = EVIDENCE_DIR / f"{evidence_id}.json"
with open(evidence_path, "w", encoding="utf-8") as f:
json.dump(evidence, f, indent=2, ensure_ascii=False)
# Update claim
claim = self.registry.claims.get(claim_id)
if claim:
claim.evidence.append(evidence_id)
claim.experiments.append(experiment_id)
self._update_claim_status(claim)
self.registry.save_claims()
# Update hypothesis
hypothesis = self.registry.hypotheses.get(hypothesis_id)
if hypothesis:
hypothesis.evidence.append(evidence_id)
self._update_hypothesis_status(hypothesis)
self.registry.save_hypotheses()
self.update_research_question(claim_id, evidence_id)
return evidence_id
def _update_claim_status(self, claim: Claim) -> None:
"""Re-evaluate claim status based on all accumulated evidence."""
if not claim.evidence:
claim.status = "untested"
claim.confidence = "none"
return
supporting = 0
refuting = 0
inconclusive = 0
for evid_id in claim.evidence:
evid = self._load_evidence(evid_id)
if evid:
s = evid.get("status", "inconclusive")
if s == "supports":
supporting += 1
elif s == "refutes":
refuting += 1
else:
inconclusive += 1
total = supporting + refuting + inconclusive
# Check minimum runs requirement
if total < claim.minimum_runs:
claim.status = "inconclusive"
claim.confidence = "low"
return
# Decision logic
if supporting >= 2 * refuting and supporting >= 3 and supporting > total * 0.7:
claim.status = "supported"
claim.confidence = "high" if supporting >= 10 else "medium"
elif refuting >= 2 * supporting and refuting >= 3:
claim.status = "refuted"
claim.confidence = "high" if refuting >= 10 else "medium"
elif supporting > refuting:
claim.status = "inconclusive"
claim.confidence = "low"
else:
claim.status = "inconclusive"
claim.confidence = "none"
def _update_hypothesis_status(self, hypothesis: Hypothesis) -> None:
"""Simple hypothesis status based on linked evidence."""
if not hypothesis.evidence:
hypothesis.status = "untested"
return
supporting = 0
refuting = 0
for evid_id in hypothesis.evidence:
evid = self._load_evidence(evid_id)
if evid:
s = evid.get("status", "inconclusive")
if s == "supports":
supporting += 1
elif s == "refutes":
refuting += 1
if supporting > refuting and supporting >= 2:
hypothesis.status = "supported"
elif refuting > supporting and refuting >= 2:
hypothesis.status = "refuted"
else:
hypothesis.status = "inconclusive"
def update_research_question(self, claim_id: str, evidence_id: str) -> None:
"""Reflect claim maturity in its question without authoring an answer.
``ready_for_answer`` is deliberately a review state. A human researcher
must still evaluate methods and limitations before setting an answer to
``provisionally_answered`` or ``answered``.
"""
claim = self.registry.claims.get(claim_id)
if claim is None:
return
question = self.registry.questions.get(claim.research_question)
if question is None or question.status in {"answered", "superseded"}:
return
if evidence_id not in question.evidence:
question.evidence.append(evidence_id)
related_claims = self.registry.claims_for_question(question.id)
if related_claims and all(
item.status in {"supported", "refuted"} for item in related_claims
):
question.status = "ready_for_answer"
elif any(item.status == "inconclusive" for item in related_claims):
question.status = "inconclusive"
else:
question.status = "in_progress"
self.registry.save_questions()
def _load_evidence(self, evidence_id: str) -> dict[str, Any] | None:
path = EVIDENCE_DIR / f"{evidence_id}.json"
if not path.exists():
return None
with open(path, encoding="utf-8") as f:
data: Any = json.load(f)
return cast("dict[str, Any]", data) if isinstance(data, dict) else None
def generate_followup_questions(self, experiment_id: str) -> list[dict[str, str]]:
"""
Automatically generate follow-up research questions based on
experimental outcomes. Returns candidate questions for human review.
"""
manifest_path = EXPERIMENTS_DIR / experiment_id / "manifest.json"
if not manifest_path.exists():
return []
with open(manifest_path, encoding="utf-8") as f:
manifest = json.load(f)
sim = manifest.get("simulation", {})
questions: list[dict[str, str]] = []
# Pattern: STDP + Homeostasis → check interaction
if sim.get("learning") and sim.get("homeostasis"):
questions.append(
{
"id": "RQ-AUTO-CANDIDATE",
"question": "Ist die beobachtete Stabilität hauptsächlich durch Homeostase statt STDP verursacht?",
"source": "auto_generated",
"trigger": "STDP + Homeostasis co-occurrence",
}
)
# Pattern: Single dimension → suggest ablation
dims = sim.get("dimensions", [])
if len(dims) == 5:
questions.append(
{
"id": "RQ-AUTO-CANDIDATE",
"question": "Wäre der Effekt in niedrigeren Dimensionen (1D/2D/3D) derselbe?",
"source": "auto_generated",
"trigger": "5D experiment without lower-dimensional control",
}
)
return questions