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