File size: 11,664 Bytes
f0b4759 a0e9e53 f0b4759 a0e9e53 f0b4759 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | """Source-bound method comparison; researcher assertions are not execution evidence."""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime, timezone
import hashlib
import json
from .models import Run
from .interventions import record_human_event
from .review import approved_claims, review_packet, source_fingerprint
CHECKS = ("data", "code", "environment", "parameters", "protocol", "metrics")
CHECK_STATES = ("unknown", "documented", "missing", "not_applicable")
CRITERIA = ("expected_value", "resources", "validation", "limitations", "novelty")
SCHEMA = "frontierlab.method-evaluation.v1"
def _digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(",", ":"), allow_nan=False).encode()).hexdigest()
def allowed_sources(run: Run) -> list[str]:
if not run.hypotheses:
raise ValueError("Generate hypotheses before evaluating methods")
if run.mode == "demo":
return sorted(p.paper_id for p in run.papers if p.synthetic)
if run.mode != "live":
raise ValueError("Unsupported research mode")
if not run.review or run.review.get("dataset_id") != review_packet(run)["dataset_id"]:
raise PermissionError("Current source-bound human review required")
ids = sorted({c.paper_id for c in approved_claims(run)})
if run.review.get("status") != "READY_FOR_HUMAN_GATED_SYNTHESIS" or len(ids) < 2:
raise PermissionError("Two human-supported sources and complete review required")
if any(p.synthetic for p in run.papers):
raise PermissionError("Synthetic publications cannot enter a live method evaluation")
return ids
def context_id(run: Run) -> str:
allowed_sources(run)
return _digest({
"question": run.question, "mode": run.mode, "model": run.model, "backend": run.backend,
"sources": [source_fingerprint(p) for p in run.papers],
"claims": [c.__dict__ for c in run.audit.verified_claims],
"conflicts": run.audit.conflicts, "hypotheses": run.hypotheses,
"human_review": run.review,
})
def _text(value, label, minimum=1):
if not isinstance(value, str) or not minimum <= len(value.strip()) <= 3000:
raise ValueError(f"{label} requires {minimum}..3000 characters")
return value.strip()
def blank_candidate(candidate_id: str, name: str = "") -> dict:
return {
"candidate_id": candidate_id, "name": name, "evidence_ids": [],
"scope": "",
"checks": {key: {"status": "unknown", "detail": "", "reference": ""}
for key in CHECKS},
"comparison": {key: "" for key in CRITERIA},
}
def _candidate(raw, known):
if not isinstance(raw, dict):
raise ValueError("Candidate must be an object")
cid = _text(raw.get("candidate_id"), "Candidate ID")
name = _text(raw.get("name"), "Method name")
ids = raw.get("evidence_ids")
if (not isinstance(ids, list) or not ids or
any(not isinstance(x, str) or x not in known for x in ids)):
raise ValueError("Method references must use reviewed source IDs")
scope = _text(raw.get("scope"), "Applicability and scope")
checks, gaps = {}, []
supplied = raw.get("checks", {})
if not isinstance(supplied, dict):
raise ValueError("Reproducibility checks must be an object")
for key in CHECKS:
item = supplied.get(key, {})
if not isinstance(item, dict) or item.get("status", "unknown") not in CHECK_STATES:
raise ValueError("Invalid reproducibility check status")
status = item.get("status", "unknown")
detail = item.get("detail", "")
reference = item.get("reference", "")
if not isinstance(detail, str) or not isinstance(reference, str):
raise ValueError("Check details and references must be text")
if status != "unknown":
detail = _text(detail, key + " explanation")
if status == "documented":
reference = _text(reference, key + " location/reference")
checks[key] = {"status": status, "detail": detail.strip(), "reference": reference.strip()}
if status in ("unknown", "missing"):
gaps.append({"item": key, "status": status,
"reason": detail.strip() or "Not assessed; inspect original methods."})
comparison = raw.get("comparison", {})
if not isinstance(comparison, dict):
raise ValueError("Comparison criteria must be an object")
comparison = {key: _text(comparison.get(key), key) for key in CRITERIA}
return {
"candidate_id": cid, "name": name, "evidence_ids": sorted(set(ids)),
"scope": scope, "checks": checks, "comparison": comparison,
"reproducibility_status": "INCOMPLETE_DOCUMENTATION" if gaps else "DOCUMENTED_NOT_EXECUTED",
"open_checks": gaps,
}
def build_evaluation(run: Run, candidates: list[dict], *, selected_id: str | None,
rationale: str) -> dict:
known = allowed_sources(run)
if not isinstance(candidates, list) or not 0 <= len(candidates) <= 4:
raise ValueError("Provide zero to four candidate methods")
normalized = [_candidate(c, known) for c in candidates]
ids = [c["candidate_id"] for c in normalized]
if len(ids) != len(set(ids)):
raise ValueError("Duplicate candidate IDs")
rationale = _text(rationale, "Selection or deferral rationale", minimum=8)
if selected_id is not None and (not isinstance(selected_id, str) or selected_id not in ids):
raise ValueError("Select a known method or defer")
if selected_id is not None and len(normalized) < 2:
raise ValueError("At least two candidates required for a comparative selection; otherwise defer")
status = "READY_FOR_DRAFT_PLANNING" if selected_id is not None else "DEFERRED_INSUFFICIENT_COMPARISON"
result = {
"schema": SCHEMA, "context_id": context_id(run), "status": status,
"origin": "SYNTHETIC_FIXTURE" if run.mode == "demo" else "RESEARCHER_ASSESSED",
"candidates": normalized, "selected_candidate_id": selected_id, "rationale": rationale,
"uncertainty": {
"source_count": len(known),
"machine_flagged_tensions": deepcopy(run.audit.conflicts),
"source_scope": "Abstract-level claim review; method records are researcher assertions.",
"method_references_independently_checked": False,
"confidence_calibrated": False,
"scientific_execution_verified": False,
"note": "Selection is a reasoned draft decision, not a probability of success or proven superiority.",
},
}
result["evaluation_id"] = _digest(result)
return result
def save_evaluation(run: Run, candidates: list[dict], *, selected_id: str | None,
rationale: str) -> dict:
result = build_evaluation(run, candidates, selected_id=selected_id, rationale=rationale)
run.evaluation = result
run.plan = None
run.log.append({"time_utc": datetime.now(timezone.utc).isoformat(),
"role": "Method Evaluation", "status": result["status"],
"note": "Researcher-saved method comparison; evaluation_id=" + result["evaluation_id"]})
record_human_event(run, "METHOD_EVALUATION_SAVED", result["evaluation_id"], note=result["status"])
return result
def require_evaluation(run: Run) -> dict:
saved = run.evaluation
if not isinstance(saved, dict) or saved.get("schema") != SCHEMA:
raise PermissionError("Complete method evaluation before planning")
rebuilt = build_evaluation(run, saved.get("candidates"),
selected_id=saved.get("selected_candidate_id"),
rationale=saved.get("rationale"))
if rebuilt != saved:
raise PermissionError("Method evaluation is stale or changed; review it again")
if saved["status"] != "READY_FOR_DRAFT_PLANNING":
raise PermissionError("Method comparison was deferred; planning remains blocked")
return saved
def selected_method(evaluation: dict) -> dict:
return next(c for c in evaluation["candidates"]
if c["candidate_id"] == evaluation["selected_candidate_id"])
def suggest_methods(run: Run, *, model_client) -> list[dict]:
"""One optional model call; output remains an editable, unreviewed proposal."""
known = allowed_sources(run)
if run.mode != "live" or model_client is None:
raise PermissionError("Method suggestions require a live, connected model")
if run.backend not in ("none", "unspecified") and getattr(model_client, "backend", "unspecified") != run.backend:
raise PermissionError("Use this run's original model backend for method suggestions")
response = model_client.json("Method Evaluation Agent", (
'Propose up to two alternative research methods relevant to the hypotheses. '
'Return {"candidates":[{"name":string,"evidence_ids":[provided IDs],"scope":string,'
'"comparison":{"expected_value":string,"resources":string,"validation":string,'
'"limitations":string,"novelty":string}}]}. '
'Use only provided abstracts and reviewed claims. Say UNKNOWN when details, '
'novelty or resources cannot be inferred. Do not claim code was executed or '
'that structure confidence establishes binding, pH selectivity or agonism. '
'Do not treat complementary pipeline tools as competing methods. '
'Return an empty candidates list if no meaningful alternatives are supported.'
), {"question": run.question, "hypotheses": run.hypotheses,
"papers": [p.__dict__ for p in run.papers if p.paper_id in known],
"reviewed_claims": [c.__dict__ for c in approved_claims(run)]})
raw = response.get("candidates")
if not isinstance(raw, list) or len(raw) > 2:
raise ValueError("Expected zero to two method proposals")
out = []
for i, item in enumerate(raw, 1):
if not isinstance(item, dict):
raise ValueError("Invalid method proposal")
candidate = blank_candidate(f"M{i}", item.get("name", ""))
candidate.update({key: item.get(key) for key in ("evidence_ids", "scope", "comparison")})
checked = _candidate(candidate, known)
out.append({key: checked[key] for key in candidate})
return out
def demo_candidates() -> list[dict]:
"""Invented strategies; no real experiment or method comparison was run."""
out = []
for cid, name, scope, benefit in [
("M1", "[FICTIONAL] Matched-condition replication",
"Compare the same invented binder under matched buffer and assay conditions.",
"Could separate a buffer confound from an apparent pH effect."),
("M2", "[FICTIONAL] Cross-assay confirmation",
"Compare the invented observation across independent assay formats.",
"Could expose assay-format dependence before interpreting the pH response."),
]:
c = blank_candidate(cid, name)
c.update(evidence_ids=["SYNTHETIC:A", "SYNTHETIC:B", "SYNTHETIC:C"], scope=scope)
c["comparison"] = {
"expected_value": benefit,
"resources": "Unknown; obtain protocol, materials and access estimates.",
"validation": "Require independent controls and repeats; no experiment has run.",
"limitations": "Entirely fictional abstracts; actual methods and effect sizes unavailable.",
"novelty": "Not assessed; these are illustrative validation strategies.",
}
out.append(c)
return out
|