simjecture 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- conjecture_solver/__init__.py +10 -0
- conjecture_solver/__main__.py +4 -0
- conjecture_solver/action_handlers.py +459 -0
- conjecture_solver/adapters/__init__.py +7 -0
- conjecture_solver/adapters/base.py +96 -0
- conjecture_solver/adapters/fake.py +155 -0
- conjecture_solver/adapters/pic.py +196 -0
- conjecture_solver/adapters/warpx.py +1375 -0
- conjecture_solver/autonomous_research.py +1071 -0
- conjecture_solver/benchmarks/__init__.py +6 -0
- conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
- conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
- conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
- conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
- conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
- conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
- conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
- conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
- conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
- conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
- conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
- conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
- conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
- conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
- conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
- conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
- conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
- conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
- conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
- conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
- conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
- conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
- conjecture_solver/campaign.py +406 -0
- conjecture_solver/cli.py +963 -0
- conjecture_solver/confirmation.py +287 -0
- conjecture_solver/control.py +234 -0
- conjecture_solver/deployment.py +874 -0
- conjecture_solver/discovery.py +68 -0
- conjecture_solver/domains/__init__.py +40 -0
- conjecture_solver/domains/base.py +157 -0
- conjecture_solver/domains/kinetic_sufficiency.py +521 -0
- conjecture_solver/ledger.py +238 -0
- conjecture_solver/lifecycle.py +131 -0
- conjecture_solver/literature.py +494 -0
- conjecture_solver/llm.py +228 -0
- conjecture_solver/models.py +279 -0
- conjecture_solver/mvp_agent.py +3212 -0
- conjecture_solver/mvp_claims.py +1279 -0
- conjecture_solver/mvp_control.py +322 -0
- conjecture_solver/mvp_guidance.py +172 -0
- conjecture_solver/mvp_launch.py +1296 -0
- conjecture_solver/mvp_monitor.py +1487 -0
- conjecture_solver/mvp_skills.py +538 -0
- conjecture_solver/orchestration.py +578 -0
- conjecture_solver/outbox.py +526 -0
- conjecture_solver/parameters.py +394 -0
- conjecture_solver/proposals.py +427 -0
- conjecture_solver/research_tools.py +278 -0
- conjecture_solver/schema_export.py +246 -0
- conjecture_solver/search.py +1047 -0
- conjecture_solver/semantics.py +146 -0
- conjecture_solver/tui/__init__.py +7 -0
- conjecture_solver/tui/app.py +202 -0
- conjecture_solver/tui/claim_views.py +213 -0
- conjecture_solver/tui/screens.py +1014 -0
- conjecture_solver/warpx_analysis.py +184 -0
- conjecture_solver/warpx_campaign.py +148 -0
- conjecture_solver/warpx_confirmation.py +617 -0
- simjecture-0.1.0.dist-info/METADATA +400 -0
- simjecture-0.1.0.dist-info/RECORD +75 -0
- simjecture-0.1.0.dist-info/WHEEL +4 -0
- simjecture-0.1.0.dist-info/entry_points.txt +4 -0
- simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
- simjecture-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
"""Domain-neutral, verifier-governed autonomous research loop.
|
|
2
|
+
|
|
3
|
+
The model owns the scientific path. Infrastructure owns immutable history,
|
|
4
|
+
tool admission, preregistration, budgets, evidence eligibility, and stopping.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Literal, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from pydantic import Field, model_validator
|
|
19
|
+
|
|
20
|
+
from .adapters.base import CostEstimate, ValidationReport
|
|
21
|
+
from .control import CampaignControl
|
|
22
|
+
from .ledger import SQLiteEventLedger, StoredEvent
|
|
23
|
+
from .llm import ModelRoute
|
|
24
|
+
from .models import EvidenceRole, HypothesisOrigin, StrictModel, utc_now
|
|
25
|
+
from .outbox import ExternalOperation, ExternalOutbox, create_intent
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _canonical_hash(value: object) -> str:
|
|
29
|
+
canonical = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
30
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResearchResultClass(StrEnum):
|
|
34
|
+
COUNTEREXAMPLE = "counterexample"
|
|
35
|
+
SCALING_LAW = "scaling_law"
|
|
36
|
+
PHASE_BOUNDARY = "phase_boundary"
|
|
37
|
+
CLOSURE = "closure"
|
|
38
|
+
IMPOSSIBILITY = "impossibility"
|
|
39
|
+
BOUNDED_NULL = "bounded_null"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResearchPropositionClass(StrEnum):
|
|
43
|
+
CONTRIBUTION = "contribution"
|
|
44
|
+
SUFFICIENCY = "sufficiency"
|
|
45
|
+
INVARIANCE = "invariance"
|
|
46
|
+
THRESHOLD = "threshold"
|
|
47
|
+
PATH_DEPENDENCE = "path_dependence"
|
|
48
|
+
SCALING_LAW = "scaling_law"
|
|
49
|
+
PHASE_BOUNDARY = "phase_boundary"
|
|
50
|
+
CLOSURE = "closure"
|
|
51
|
+
AUXILIARY_VALIDITY = "auxiliary_validity"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ResearchRelationKind(StrEnum):
|
|
55
|
+
SPECIALIZES = "specializes"
|
|
56
|
+
PREDICTED_BY = "predicted_by"
|
|
57
|
+
TESTS = "tests"
|
|
58
|
+
ALTERNATIVE_TO = "alternative_to"
|
|
59
|
+
REPAIRS = "repairs"
|
|
60
|
+
AUXILIARY_TO = "auxiliary_to"
|
|
61
|
+
REQUIRES = "requires"
|
|
62
|
+
INCOMPATIBLE_WITH = "incompatible_with"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ResearchToolKind(StrEnum):
|
|
66
|
+
SIMULATOR = "simulator"
|
|
67
|
+
ANALYSIS = "analysis"
|
|
68
|
+
VERIFIER = "verifier"
|
|
69
|
+
LITERATURE_SEARCH = "literature_search"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ResearchEvidenceStage(StrEnum):
|
|
73
|
+
EXPLORATION = "exploration"
|
|
74
|
+
QUALIFICATION = "qualification"
|
|
75
|
+
CONFIRMATION = "confirmation"
|
|
76
|
+
FALSIFICATION = "falsification"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ResearchDecisionKind(StrEnum):
|
|
80
|
+
USE_TOOL = "use_tool"
|
|
81
|
+
CONCLUDE = "conclude"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ResearchDisposition(StrEnum):
|
|
85
|
+
VALIDATED_WITHIN_TOOLS = "validated_within_tools"
|
|
86
|
+
BOUNDED_NULL = "bounded_null"
|
|
87
|
+
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
|
|
88
|
+
BUDGET_EXHAUSTED = "budget_exhausted"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NoveltyStatus(StrEnum):
|
|
92
|
+
UNASSESSED = "unassessed"
|
|
93
|
+
KNOWN_RESULT = "known_result"
|
|
94
|
+
CANDIDATE_NEW = "candidate_new"
|
|
95
|
+
VERIFIED_NEW = "verified_new"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ResearchHypothesis(StrictModel):
|
|
99
|
+
"""Open scientific proposition whose details are authored during the run."""
|
|
100
|
+
|
|
101
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
102
|
+
id: str = Field(min_length=1)
|
|
103
|
+
statement: str = Field(min_length=1)
|
|
104
|
+
machine_predicate: str = Field(min_length=1)
|
|
105
|
+
formal_specification: dict[str, Any]
|
|
106
|
+
proposition_class: ResearchPropositionClass
|
|
107
|
+
model_family: str = Field(min_length=1)
|
|
108
|
+
scope: str = Field(min_length=1)
|
|
109
|
+
coordinates: tuple[str, ...] = Field(min_length=1)
|
|
110
|
+
observables: tuple[str, ...] = Field(min_length=1)
|
|
111
|
+
assumptions: tuple[str, ...] = ()
|
|
112
|
+
origin: HypothesisOrigin
|
|
113
|
+
parent_ids: tuple[str, ...] = ()
|
|
114
|
+
motivating_evidence_ids: tuple[str, ...] = ()
|
|
115
|
+
|
|
116
|
+
@model_validator(mode="after")
|
|
117
|
+
def declarative_and_unique(self) -> ResearchHypothesis:
|
|
118
|
+
if self.statement.endswith("?"):
|
|
119
|
+
raise ValueError("a research hypothesis must be declarative")
|
|
120
|
+
if len(self.coordinates) != len(set(self.coordinates)):
|
|
121
|
+
raise ValueError("hypothesis coordinates must be unique")
|
|
122
|
+
if len(self.observables) != len(set(self.observables)):
|
|
123
|
+
raise ValueError("hypothesis observables must be unique")
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ResearchRelation(StrictModel):
|
|
128
|
+
source_hypothesis_id: str
|
|
129
|
+
target_hypothesis_id: str
|
|
130
|
+
kind: ResearchRelationKind
|
|
131
|
+
evidence_rule: str = Field(min_length=1)
|
|
132
|
+
justification: str = Field(min_length=1)
|
|
133
|
+
|
|
134
|
+
@model_validator(mode="after")
|
|
135
|
+
def distinct_nodes(self) -> ResearchRelation:
|
|
136
|
+
if self.source_hypothesis_id == self.target_hypothesis_id:
|
|
137
|
+
raise ValueError("a hypothesis relation must connect distinct nodes")
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ResearchBudget(StrictModel):
|
|
142
|
+
maximum_iterations: int = Field(ge=1)
|
|
143
|
+
maximum_tool_calls: int = Field(ge=0)
|
|
144
|
+
maximum_compute_units: float = Field(gt=0)
|
|
145
|
+
maximum_wall_seconds: float = Field(gt=0)
|
|
146
|
+
maximum_storage_bytes: int = Field(gt=0)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ResearchEvidencePolicy(StrictModel):
|
|
150
|
+
preregistration_required: Literal[True] = True
|
|
151
|
+
require_numerical_validity: Literal[True] = True
|
|
152
|
+
minimum_independent_confirmations: int = Field(default=1, ge=1)
|
|
153
|
+
require_deliberate_falsification: bool = True
|
|
154
|
+
allow_bounded_null: bool = True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ResearchProblemContract(StrictModel):
|
|
158
|
+
"""Human direction and constitution, without a prescribed research path."""
|
|
159
|
+
|
|
160
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
161
|
+
id: str = Field(min_length=1)
|
|
162
|
+
question: str = Field(min_length=1)
|
|
163
|
+
significance: str = Field(min_length=1)
|
|
164
|
+
root_hypotheses: tuple[ResearchHypothesis, ...] = Field(min_length=1)
|
|
165
|
+
allowed_result_classes: tuple[ResearchResultClass, ...] = Field(min_length=1)
|
|
166
|
+
allowed_model_families: tuple[str, ...] = Field(min_length=1)
|
|
167
|
+
allowed_tools: tuple[str, ...] = Field(min_length=1)
|
|
168
|
+
forbidden_shortcuts: tuple[str, ...] = ()
|
|
169
|
+
scientific_constraints: tuple[str, ...] = ()
|
|
170
|
+
budget: ResearchBudget
|
|
171
|
+
evidence_policy: ResearchEvidencePolicy = Field(default_factory=ResearchEvidencePolicy)
|
|
172
|
+
maximum_invalid_decisions_per_iteration: int = Field(default=3, ge=1, le=10)
|
|
173
|
+
|
|
174
|
+
@model_validator(mode="after")
|
|
175
|
+
def coherent_contract(self) -> ResearchProblemContract:
|
|
176
|
+
ids = [item.id for item in self.root_hypotheses]
|
|
177
|
+
if len(ids) != len(set(ids)):
|
|
178
|
+
raise ValueError("root hypothesis IDs must be unique")
|
|
179
|
+
if len(self.allowed_tools) != len(set(self.allowed_tools)):
|
|
180
|
+
raise ValueError("allowed tools must be unique")
|
|
181
|
+
if any(
|
|
182
|
+
item.model_family not in self.allowed_model_families
|
|
183
|
+
for item in self.root_hypotheses
|
|
184
|
+
):
|
|
185
|
+
raise ValueError("every root hypothesis must use an allowed model family")
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def contract_hash(self) -> str:
|
|
190
|
+
return _canonical_hash(self.model_dump(mode="json"))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ResearchToolManifest(StrictModel):
|
|
194
|
+
name: str = Field(pattern=r"^[a-z][a-z0-9_.-]*$")
|
|
195
|
+
version: str = Field(min_length=1)
|
|
196
|
+
description: str = Field(min_length=1)
|
|
197
|
+
kind: ResearchToolKind
|
|
198
|
+
input_schema: dict[str, Any]
|
|
199
|
+
output_schema: dict[str, Any]
|
|
200
|
+
supported_model_families: tuple[str, ...] = Field(min_length=1)
|
|
201
|
+
supported_coordinates: tuple[str, ...] = ()
|
|
202
|
+
supported_observables: tuple[str, ...] = ()
|
|
203
|
+
idempotent: Literal[True] = True
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class ObservablePrediction(StrictModel):
|
|
207
|
+
hypothesis_id: str = Field(min_length=1)
|
|
208
|
+
observable: str = Field(min_length=1)
|
|
209
|
+
condition: str = Field(min_length=1)
|
|
210
|
+
minimum: float | None = None
|
|
211
|
+
maximum: float | None = None
|
|
212
|
+
exact: bool | str | None = None
|
|
213
|
+
|
|
214
|
+
@model_validator(mode="after")
|
|
215
|
+
def one_testable_predicate(self) -> ObservablePrediction:
|
|
216
|
+
has_interval = self.minimum is not None or self.maximum is not None
|
|
217
|
+
if has_interval == (self.exact is not None):
|
|
218
|
+
raise ValueError("prediction requires either a numeric interval or exact value")
|
|
219
|
+
if (
|
|
220
|
+
self.minimum is not None
|
|
221
|
+
and self.maximum is not None
|
|
222
|
+
and self.minimum > self.maximum
|
|
223
|
+
):
|
|
224
|
+
raise ValueError("prediction minimum cannot exceed maximum")
|
|
225
|
+
return self
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ProposedToolCall(StrictModel):
|
|
229
|
+
tool_name: str = Field(min_length=1)
|
|
230
|
+
arguments: dict[str, Any]
|
|
231
|
+
purpose: str = Field(min_length=1)
|
|
232
|
+
evidence_stage: ResearchEvidenceStage
|
|
233
|
+
evidence_role: EvidenceRole
|
|
234
|
+
independence_group: str = Field(min_length=1)
|
|
235
|
+
predictions: tuple[ObservablePrediction, ...] = Field(min_length=1)
|
|
236
|
+
falsification_condition: str = Field(min_length=1)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class CandidateResult(StrictModel):
|
|
240
|
+
id: str = Field(min_length=1)
|
|
241
|
+
hypothesis_id: str = Field(min_length=1)
|
|
242
|
+
result_class: ResearchResultClass
|
|
243
|
+
statement: str = Field(min_length=1)
|
|
244
|
+
formal_specification: dict[str, Any]
|
|
245
|
+
scope: str = Field(min_length=1)
|
|
246
|
+
supporting_evidence_ids: tuple[str, ...] = Field(min_length=1)
|
|
247
|
+
contradicting_evidence_ids: tuple[str, ...] = ()
|
|
248
|
+
limitations: tuple[str, ...] = Field(min_length=1)
|
|
249
|
+
novelty_status: NoveltyStatus = NoveltyStatus.UNASSESSED
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ResearchConclusion(StrictModel):
|
|
253
|
+
disposition: ResearchDisposition
|
|
254
|
+
reason: str = Field(min_length=1)
|
|
255
|
+
evidence_ids: tuple[str, ...] = ()
|
|
256
|
+
candidate: CandidateResult | None = None
|
|
257
|
+
|
|
258
|
+
@model_validator(mode="after")
|
|
259
|
+
def candidate_matches_disposition(self) -> ResearchConclusion:
|
|
260
|
+
if self.disposition is ResearchDisposition.VALIDATED_WITHIN_TOOLS:
|
|
261
|
+
if self.candidate is None:
|
|
262
|
+
raise ValueError("a validated result requires a candidate")
|
|
263
|
+
elif self.candidate is not None:
|
|
264
|
+
raise ValueError("only a validated result may carry a candidate")
|
|
265
|
+
return self
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ResearchDecision(StrictModel):
|
|
269
|
+
"""One model-authored transition, committed before any tool execution."""
|
|
270
|
+
|
|
271
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
272
|
+
id: str = Field(min_length=1)
|
|
273
|
+
iteration: int = Field(ge=0)
|
|
274
|
+
kind: ResearchDecisionKind
|
|
275
|
+
rationale: str = Field(min_length=1)
|
|
276
|
+
new_hypotheses: tuple[ResearchHypothesis, ...] = ()
|
|
277
|
+
new_relations: tuple[ResearchRelation, ...] = ()
|
|
278
|
+
tool_call: ProposedToolCall | None = None
|
|
279
|
+
conclusion: ResearchConclusion | None = None
|
|
280
|
+
|
|
281
|
+
@model_validator(mode="after")
|
|
282
|
+
def exactly_one_transition(self) -> ResearchDecision:
|
|
283
|
+
if self.kind is ResearchDecisionKind.USE_TOOL:
|
|
284
|
+
if self.tool_call is None or self.conclusion is not None:
|
|
285
|
+
raise ValueError("use_tool decision requires only a tool call")
|
|
286
|
+
elif self.conclusion is None or self.tool_call is not None:
|
|
287
|
+
raise ValueError("conclude decision requires only a conclusion")
|
|
288
|
+
ids = [item.id for item in self.new_hypotheses]
|
|
289
|
+
if len(ids) != len(set(ids)):
|
|
290
|
+
raise ValueError("new hypothesis IDs must be unique within a decision")
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class ResearchToolResult(StrictModel):
|
|
295
|
+
completed: bool
|
|
296
|
+
observables: dict[str, float | bool | str] = Field(default_factory=dict)
|
|
297
|
+
diagnostics: dict[str, Any] = Field(default_factory=dict)
|
|
298
|
+
validity_checks: dict[str, bool] = Field(default_factory=dict)
|
|
299
|
+
artifact_hashes: tuple[str, ...] = ()
|
|
300
|
+
cost: CostEstimate
|
|
301
|
+
scientific_scope: str = Field(min_length=1)
|
|
302
|
+
failure_detail: str | None = None
|
|
303
|
+
|
|
304
|
+
@model_validator(mode="after")
|
|
305
|
+
def coherent_completion(self) -> ResearchToolResult:
|
|
306
|
+
if self.completed and self.failure_detail is not None:
|
|
307
|
+
raise ValueError("completed tool result cannot carry a failure")
|
|
308
|
+
if not self.completed and not self.failure_detail:
|
|
309
|
+
raise ValueError("incomplete tool result requires a failure detail")
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class ResearchObservation(StrictModel):
|
|
314
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
315
|
+
id: str = Field(min_length=1)
|
|
316
|
+
iteration: int = Field(ge=0)
|
|
317
|
+
decision_id: str
|
|
318
|
+
tool_name: str
|
|
319
|
+
evidence_stage: ResearchEvidenceStage
|
|
320
|
+
evidence_role: EvidenceRole
|
|
321
|
+
independence_group: str
|
|
322
|
+
hypothesis_ids: tuple[str, ...] = Field(min_length=1)
|
|
323
|
+
preregistered_predictions: tuple[ObservablePrediction, ...] = Field(min_length=1)
|
|
324
|
+
prediction_checks: dict[str, bool]
|
|
325
|
+
result: ResearchToolResult
|
|
326
|
+
evidence_eligible: bool
|
|
327
|
+
|
|
328
|
+
@model_validator(mode="after")
|
|
329
|
+
def eligibility_is_derived(self) -> ResearchObservation:
|
|
330
|
+
expected = (
|
|
331
|
+
self.result.completed
|
|
332
|
+
and bool(self.result.validity_checks)
|
|
333
|
+
and all(self.result.validity_checks.values())
|
|
334
|
+
)
|
|
335
|
+
if self.evidence_eligible != expected:
|
|
336
|
+
raise ValueError("observation eligibility must follow tool validity checks")
|
|
337
|
+
return self
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class ResearchCampaignReport(StrictModel):
|
|
341
|
+
schema_version: Literal["0.1.0"] = "0.1.0"
|
|
342
|
+
package_kind: Literal["universal_research_campaign"] = "universal_research_campaign"
|
|
343
|
+
campaign_id: str
|
|
344
|
+
contract: ResearchProblemContract
|
|
345
|
+
hypotheses: tuple[ResearchHypothesis, ...]
|
|
346
|
+
relations: tuple[ResearchRelation, ...]
|
|
347
|
+
decisions: tuple[ResearchDecision, ...]
|
|
348
|
+
observations: tuple[ResearchObservation, ...]
|
|
349
|
+
conclusion: ResearchConclusion
|
|
350
|
+
consumed_tool_calls: int = Field(ge=0)
|
|
351
|
+
consumed_compute_units: float = Field(ge=0)
|
|
352
|
+
consumed_wall_seconds: float = Field(ge=0)
|
|
353
|
+
consumed_storage_bytes: int = Field(ge=0)
|
|
354
|
+
budget_overrun: bool
|
|
355
|
+
provenance_event_hashes: tuple[str, ...] = Field(min_length=1)
|
|
356
|
+
generated_at: datetime
|
|
357
|
+
package_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
358
|
+
|
|
359
|
+
@model_validator(mode="after")
|
|
360
|
+
def hash_and_budget_are_valid(self) -> ResearchCampaignReport:
|
|
361
|
+
if self.package_hash != self.calculated_hash():
|
|
362
|
+
raise ValueError("research campaign package hash does not match its content")
|
|
363
|
+
budget = self.contract.budget
|
|
364
|
+
expected_overrun = (
|
|
365
|
+
self.consumed_tool_calls > budget.maximum_tool_calls
|
|
366
|
+
or self.consumed_compute_units > budget.maximum_compute_units + 1e-12
|
|
367
|
+
or self.consumed_wall_seconds > budget.maximum_wall_seconds + 1e-12
|
|
368
|
+
or self.consumed_storage_bytes > budget.maximum_storage_bytes
|
|
369
|
+
)
|
|
370
|
+
if self.budget_overrun != expected_overrun:
|
|
371
|
+
raise ValueError("research report budget-overrun flag is not derived")
|
|
372
|
+
return self
|
|
373
|
+
|
|
374
|
+
def calculated_hash(self) -> str:
|
|
375
|
+
return _canonical_hash(self.model_dump(mode="json", exclude={"package_hash"}))
|
|
376
|
+
|
|
377
|
+
@classmethod
|
|
378
|
+
def create(cls, **values: object) -> ResearchCampaignReport:
|
|
379
|
+
provisional = cls.model_construct(package_hash="0" * 64, **values)
|
|
380
|
+
return cls.model_validate(
|
|
381
|
+
{
|
|
382
|
+
**provisional.model_dump(mode="json", exclude={"package_hash"}),
|
|
383
|
+
"package_hash": provisional.calculated_hash(),
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def write(self, output_directory: str | Path) -> Path:
|
|
388
|
+
output = Path(output_directory)
|
|
389
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
390
|
+
path = output / "research_campaign_report.json"
|
|
391
|
+
temporary = output / ".research_campaign_report.json.tmp"
|
|
392
|
+
temporary.write_text(self.model_dump_json(indent=2) + "\n")
|
|
393
|
+
os.replace(temporary, path)
|
|
394
|
+
return path
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def read_verified(cls, path: str | Path) -> ResearchCampaignReport:
|
|
398
|
+
report = cls.model_validate_json(Path(path).read_text())
|
|
399
|
+
if report.package_hash != report.calculated_hash():
|
|
400
|
+
raise ValueError("research campaign package failed hash verification")
|
|
401
|
+
return report
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@runtime_checkable
|
|
405
|
+
class ResearchTool(Protocol):
|
|
406
|
+
manifest: ResearchToolManifest
|
|
407
|
+
|
|
408
|
+
def validate(self, arguments: dict[str, Any]) -> ValidationReport: ...
|
|
409
|
+
|
|
410
|
+
def estimate_cost(self, arguments: dict[str, Any]) -> CostEstimate: ...
|
|
411
|
+
|
|
412
|
+
def execute(
|
|
413
|
+
self,
|
|
414
|
+
arguments: dict[str, Any],
|
|
415
|
+
*,
|
|
416
|
+
idempotency_key: str,
|
|
417
|
+
) -> ResearchToolResult: ...
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@dataclass(frozen=True)
|
|
421
|
+
class ResearchToolRegistry:
|
|
422
|
+
tools: tuple[ResearchTool, ...]
|
|
423
|
+
|
|
424
|
+
def __post_init__(self) -> None:
|
|
425
|
+
names = [tool.manifest.name for tool in self.tools]
|
|
426
|
+
if len(names) != len(set(names)):
|
|
427
|
+
raise ValueError("research tool names must be unique")
|
|
428
|
+
|
|
429
|
+
def get(self, name: str) -> ResearchTool:
|
|
430
|
+
for tool in self.tools:
|
|
431
|
+
if tool.manifest.name == name:
|
|
432
|
+
return tool
|
|
433
|
+
raise ValueError(f"unknown research tool {name!r}")
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def manifests(self) -> tuple[ResearchToolManifest, ...]:
|
|
437
|
+
return tuple(sorted((tool.manifest for tool in self.tools), key=lambda item: item.name))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class ResearchDecisionRejected(ValueError):
|
|
441
|
+
pass
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class ResearchCrashPoint(StrEnum):
|
|
445
|
+
AFTER_CONTRACT_REGISTERED = "after_contract_registered"
|
|
446
|
+
AFTER_DECISION_COMMITTED = "after_decision_committed"
|
|
447
|
+
AFTER_TOOL_RECEIPT = "after_tool_receipt"
|
|
448
|
+
AFTER_OBSERVATION_COMMITTED = "after_observation_committed"
|
|
449
|
+
AFTER_REPORT_COMMITTED = "after_report_committed"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class InjectedResearchCrash(RuntimeError):
|
|
453
|
+
def __init__(self, point: ResearchCrashPoint) -> None:
|
|
454
|
+
super().__init__(f"injected universal research crash at {point.value}")
|
|
455
|
+
self.point = point
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
class ResearchProjection:
|
|
459
|
+
def __init__(self) -> None:
|
|
460
|
+
self.contract: ResearchProblemContract | None = None
|
|
461
|
+
self.decisions: dict[int, ResearchDecision] = {}
|
|
462
|
+
self.observations: dict[int, ResearchObservation] = {}
|
|
463
|
+
self.report: ResearchCampaignReport | None = None
|
|
464
|
+
|
|
465
|
+
@classmethod
|
|
466
|
+
def replay(cls, events: tuple[StoredEvent, ...]) -> ResearchProjection:
|
|
467
|
+
state = cls()
|
|
468
|
+
for event in events:
|
|
469
|
+
if event.event_type == "research_contract_registered":
|
|
470
|
+
state.contract = ResearchProblemContract.model_validate(event.payload["contract"])
|
|
471
|
+
elif event.event_type == "research_decision_committed":
|
|
472
|
+
decision = ResearchDecision.model_validate(event.payload["decision"])
|
|
473
|
+
state.decisions[decision.iteration] = decision
|
|
474
|
+
elif event.event_type == "research_observation_committed":
|
|
475
|
+
observation = ResearchObservation.model_validate(event.payload["observation"])
|
|
476
|
+
state.observations[observation.iteration] = observation
|
|
477
|
+
elif event.event_type == "research_campaign_completed":
|
|
478
|
+
state.report = ResearchCampaignReport.model_validate(event.payload["report"])
|
|
479
|
+
return state
|
|
480
|
+
|
|
481
|
+
def hypotheses(self) -> tuple[ResearchHypothesis, ...]:
|
|
482
|
+
if self.contract is None:
|
|
483
|
+
return ()
|
|
484
|
+
result = {item.id: item for item in self.contract.root_hypotheses}
|
|
485
|
+
for decision in self.ordered_decisions:
|
|
486
|
+
result.update({item.id: item for item in decision.new_hypotheses})
|
|
487
|
+
return tuple(result.values())
|
|
488
|
+
|
|
489
|
+
def relations(self) -> tuple[ResearchRelation, ...]:
|
|
490
|
+
return tuple(
|
|
491
|
+
relation
|
|
492
|
+
for decision in self.ordered_decisions
|
|
493
|
+
for relation in decision.new_relations
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def ordered_decisions(self) -> tuple[ResearchDecision, ...]:
|
|
498
|
+
return tuple(self.decisions[key] for key in sorted(self.decisions))
|
|
499
|
+
|
|
500
|
+
@property
|
|
501
|
+
def ordered_observations(self) -> tuple[ResearchObservation, ...]:
|
|
502
|
+
return tuple(self.observations[key] for key in sorted(self.observations))
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def consumed_tool_calls(self) -> int:
|
|
506
|
+
return len(self.observations)
|
|
507
|
+
|
|
508
|
+
@property
|
|
509
|
+
def consumed_cost(self) -> CostEstimate:
|
|
510
|
+
return CostEstimate(
|
|
511
|
+
compute_units=sum(
|
|
512
|
+
item.result.cost.compute_units for item in self.observations.values()
|
|
513
|
+
),
|
|
514
|
+
wall_seconds=sum(item.result.cost.wall_seconds for item in self.observations.values()),
|
|
515
|
+
storage_bytes=sum(
|
|
516
|
+
item.result.cost.storage_bytes for item in self.observations.values()
|
|
517
|
+
),
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class UniversalResearchRunner:
|
|
522
|
+
"""Restart-safe open-ended loop controlled by model decisions and hard verifiers."""
|
|
523
|
+
|
|
524
|
+
def __init__(
|
|
525
|
+
self,
|
|
526
|
+
*,
|
|
527
|
+
campaign_id: str,
|
|
528
|
+
ledger: SQLiteEventLedger,
|
|
529
|
+
contract: ResearchProblemContract,
|
|
530
|
+
completion_client: Any,
|
|
531
|
+
tools: ResearchToolRegistry,
|
|
532
|
+
control: CampaignControl | None = None,
|
|
533
|
+
route: ModelRoute = ModelRoute.DEFAULT,
|
|
534
|
+
escalation_reason: str | None = None,
|
|
535
|
+
crash_at: ResearchCrashPoint | None = None,
|
|
536
|
+
) -> None:
|
|
537
|
+
self.campaign_id = campaign_id
|
|
538
|
+
self.ledger = ledger
|
|
539
|
+
self.contract = contract
|
|
540
|
+
self.completion_client = completion_client
|
|
541
|
+
self.tools = tools
|
|
542
|
+
self.control = control
|
|
543
|
+
self.route = route
|
|
544
|
+
self.escalation_reason = escalation_reason
|
|
545
|
+
self.crash_at = crash_at
|
|
546
|
+
installed = {item.name for item in tools.manifests}
|
|
547
|
+
missing = set(contract.allowed_tools) - installed
|
|
548
|
+
if missing:
|
|
549
|
+
raise ValueError(f"problem contract names unavailable tools: {sorted(missing)}")
|
|
550
|
+
|
|
551
|
+
def _crash(self, point: ResearchCrashPoint) -> None:
|
|
552
|
+
if self.crash_at is point:
|
|
553
|
+
raise InjectedResearchCrash(point)
|
|
554
|
+
|
|
555
|
+
def _state(self) -> ResearchProjection:
|
|
556
|
+
return ResearchProjection.replay(self.ledger.load(self.campaign_id))
|
|
557
|
+
|
|
558
|
+
def _append(
|
|
559
|
+
self,
|
|
560
|
+
event_type: str,
|
|
561
|
+
aggregate_type: str,
|
|
562
|
+
aggregate_id: str,
|
|
563
|
+
payload: dict[str, Any],
|
|
564
|
+
suffix: str,
|
|
565
|
+
) -> None:
|
|
566
|
+
self.ledger.append(
|
|
567
|
+
campaign_id=self.campaign_id,
|
|
568
|
+
event_type=event_type,
|
|
569
|
+
aggregate_type=aggregate_type,
|
|
570
|
+
aggregate_id=aggregate_id,
|
|
571
|
+
payload=payload,
|
|
572
|
+
idempotency_key=f"{self.campaign_id}:research:{suffix}",
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
def _register_contract(self) -> None:
|
|
576
|
+
state = self._state()
|
|
577
|
+
if state.contract is not None:
|
|
578
|
+
if state.contract != self.contract:
|
|
579
|
+
raise ValueError("campaign already has a different research contract")
|
|
580
|
+
return
|
|
581
|
+
self._append(
|
|
582
|
+
"research_contract_registered",
|
|
583
|
+
"research_contract",
|
|
584
|
+
self.contract.id,
|
|
585
|
+
{"contract": self.contract.model_dump(mode="json")},
|
|
586
|
+
"contract",
|
|
587
|
+
)
|
|
588
|
+
self._crash(ResearchCrashPoint.AFTER_CONTRACT_REGISTERED)
|
|
589
|
+
|
|
590
|
+
def _prompt_state(self, state: ResearchProjection, iteration: int) -> dict[str, Any]:
|
|
591
|
+
cost = state.consumed_cost
|
|
592
|
+
return {
|
|
593
|
+
"iteration": iteration,
|
|
594
|
+
"contract": self.contract.model_dump(mode="json"),
|
|
595
|
+
"available_tools": [item.model_dump(mode="json") for item in self.tools.manifests],
|
|
596
|
+
"hypotheses": [item.model_dump(mode="json") for item in state.hypotheses()],
|
|
597
|
+
"relations": [item.model_dump(mode="json") for item in state.relations()],
|
|
598
|
+
"observations": [
|
|
599
|
+
item.model_dump(mode="json") for item in state.ordered_observations
|
|
600
|
+
],
|
|
601
|
+
"budget_used": {
|
|
602
|
+
"iterations": len(state.decisions),
|
|
603
|
+
"tool_calls": state.consumed_tool_calls,
|
|
604
|
+
"compute_units": cost.compute_units,
|
|
605
|
+
"wall_seconds": cost.wall_seconds,
|
|
606
|
+
"storage_bytes": cost.storage_bytes,
|
|
607
|
+
},
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
def _messages(
|
|
611
|
+
self,
|
|
612
|
+
state: ResearchProjection,
|
|
613
|
+
iteration: int,
|
|
614
|
+
rejected: list[tuple[str, str]],
|
|
615
|
+
) -> list[dict[str, str]]:
|
|
616
|
+
system = (
|
|
617
|
+
"You are the scientific decision-maker inside a universal computational "
|
|
618
|
+
"conjecture solver. You own hypothesis formation, coordinates, experiment "
|
|
619
|
+
"selection, analysis, falsification, and stopping. Infrastructure owns tool "
|
|
620
|
+
"admission, immutable evidence, preregistration, and budgets. Return exactly "
|
|
621
|
+
"one JSON object matching the supplied ResearchDecision schema, with no code "
|
|
622
|
+
"fence. Never invent a tool or evidence ID. A tool decision must preregister "
|
|
623
|
+
"numeric intervals or exact predictions before execution. Register a new "
|
|
624
|
+
"hypothesis before using a tool to test it. Prefer destructive tests. Do not "
|
|
625
|
+
"claim scientific novelty unless literature-search evidence verifies it. A "
|
|
626
|
+
"bounded null or insufficient-evidence conclusion is valid. When an allowed "
|
|
627
|
+
"literature-search tool is installed, the first research action must attempt "
|
|
628
|
+
"reconnaissance. No useful hit is required; the recorded attempt is enough "
|
|
629
|
+
"to continue."
|
|
630
|
+
)
|
|
631
|
+
payload = {
|
|
632
|
+
"research_state": self._prompt_state(state, iteration),
|
|
633
|
+
"decision_schema": ResearchDecision.model_json_schema(),
|
|
634
|
+
}
|
|
635
|
+
messages = [
|
|
636
|
+
{"role": "system", "content": system},
|
|
637
|
+
{"role": "user", "content": json.dumps(payload, sort_keys=True)},
|
|
638
|
+
]
|
|
639
|
+
for content, error in rejected:
|
|
640
|
+
messages.extend(
|
|
641
|
+
(
|
|
642
|
+
{"role": "assistant", "content": content},
|
|
643
|
+
{
|
|
644
|
+
"role": "user",
|
|
645
|
+
"content": (
|
|
646
|
+
"The deterministic admission gate rejected that decision: "
|
|
647
|
+
f"{error}. Return one corrected complete JSON object only."
|
|
648
|
+
),
|
|
649
|
+
},
|
|
650
|
+
)
|
|
651
|
+
)
|
|
652
|
+
return messages
|
|
653
|
+
|
|
654
|
+
def _remaining_budget(self, state: ResearchProjection) -> CostEstimate:
|
|
655
|
+
used = state.consumed_cost
|
|
656
|
+
budget = self.contract.budget
|
|
657
|
+
return CostEstimate(
|
|
658
|
+
compute_units=max(0.0, budget.maximum_compute_units - used.compute_units),
|
|
659
|
+
wall_seconds=max(0.0, budget.maximum_wall_seconds - used.wall_seconds),
|
|
660
|
+
storage_bytes=max(0, budget.maximum_storage_bytes - used.storage_bytes),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
def _admit_decision(
|
|
664
|
+
self,
|
|
665
|
+
decision: ResearchDecision,
|
|
666
|
+
state: ResearchProjection,
|
|
667
|
+
iteration: int,
|
|
668
|
+
) -> CostEstimate | None:
|
|
669
|
+
if decision.iteration != iteration:
|
|
670
|
+
raise ResearchDecisionRejected("decision iteration does not match campaign state")
|
|
671
|
+
known = {item.id for item in state.hypotheses()}
|
|
672
|
+
new = {item.id for item in decision.new_hypotheses}
|
|
673
|
+
if known & new:
|
|
674
|
+
raise ResearchDecisionRejected("new hypothesis ID already exists")
|
|
675
|
+
all_hypotheses = known | new
|
|
676
|
+
evidence_ids = {item.id for item in state.ordered_observations}
|
|
677
|
+
literature_tools = {
|
|
678
|
+
item.name
|
|
679
|
+
for item in self.tools.manifests
|
|
680
|
+
if item.kind is ResearchToolKind.LITERATURE_SEARCH
|
|
681
|
+
and item.name in self.contract.allowed_tools
|
|
682
|
+
}
|
|
683
|
+
literature_attempted = any(
|
|
684
|
+
item.tool_name in literature_tools for item in state.ordered_observations
|
|
685
|
+
)
|
|
686
|
+
if (
|
|
687
|
+
literature_tools
|
|
688
|
+
and not literature_attempted
|
|
689
|
+
and (
|
|
690
|
+
decision.kind is not ResearchDecisionKind.USE_TOOL
|
|
691
|
+
or decision.tool_call is None
|
|
692
|
+
or decision.tool_call.tool_name not in literature_tools
|
|
693
|
+
)
|
|
694
|
+
):
|
|
695
|
+
raise ResearchDecisionRejected(
|
|
696
|
+
"an allowed literature-search tool is available; attempt it before "
|
|
697
|
+
"simulation or conclusion. A completed no-hit or unavailable result "
|
|
698
|
+
"satisfies this startup requirement"
|
|
699
|
+
)
|
|
700
|
+
for hypothesis in decision.new_hypotheses:
|
|
701
|
+
if hypothesis.model_family not in self.contract.allowed_model_families:
|
|
702
|
+
raise ResearchDecisionRejected("new hypothesis uses a forbidden model family")
|
|
703
|
+
if not set(hypothesis.parent_ids) <= all_hypotheses:
|
|
704
|
+
raise ResearchDecisionRejected("new hypothesis names an unknown parent")
|
|
705
|
+
if not set(hypothesis.motivating_evidence_ids) <= evidence_ids:
|
|
706
|
+
raise ResearchDecisionRejected("new hypothesis names unknown motivating evidence")
|
|
707
|
+
for relation in decision.new_relations:
|
|
708
|
+
if not {
|
|
709
|
+
relation.source_hypothesis_id,
|
|
710
|
+
relation.target_hypothesis_id,
|
|
711
|
+
} <= all_hypotheses:
|
|
712
|
+
raise ResearchDecisionRejected("new relation names an unknown hypothesis")
|
|
713
|
+
|
|
714
|
+
if decision.kind is ResearchDecisionKind.USE_TOOL:
|
|
715
|
+
call = decision.tool_call
|
|
716
|
+
assert call is not None
|
|
717
|
+
if call.tool_name not in self.contract.allowed_tools:
|
|
718
|
+
raise ResearchDecisionRejected("decision selected a tool outside the contract")
|
|
719
|
+
if state.consumed_tool_calls >= self.contract.budget.maximum_tool_calls:
|
|
720
|
+
raise ResearchDecisionRejected("tool-call budget is exhausted")
|
|
721
|
+
tool = self.tools.get(call.tool_name)
|
|
722
|
+
if not set(item.hypothesis_id for item in call.predictions) <= all_hypotheses:
|
|
723
|
+
raise ResearchDecisionRejected("prediction names an unknown hypothesis")
|
|
724
|
+
if any(
|
|
725
|
+
item.observable not in tool.manifest.supported_observables
|
|
726
|
+
for item in call.predictions
|
|
727
|
+
):
|
|
728
|
+
raise ResearchDecisionRejected("prediction requests an unsupported observable")
|
|
729
|
+
report = tool.validate(call.arguments)
|
|
730
|
+
if not report.valid:
|
|
731
|
+
raise ResearchDecisionRejected(
|
|
732
|
+
"tool rejected arguments: " + "; ".join(report.errors)
|
|
733
|
+
)
|
|
734
|
+
estimate = tool.estimate_cost(call.arguments)
|
|
735
|
+
remaining = self._remaining_budget(state)
|
|
736
|
+
if (
|
|
737
|
+
estimate.compute_units > remaining.compute_units + 1e-12
|
|
738
|
+
or estimate.wall_seconds > remaining.wall_seconds + 1e-12
|
|
739
|
+
or estimate.storage_bytes > remaining.storage_bytes
|
|
740
|
+
):
|
|
741
|
+
raise ResearchDecisionRejected("tool estimate exceeds remaining campaign budget")
|
|
742
|
+
if call.evidence_stage in {
|
|
743
|
+
ResearchEvidenceStage.CONFIRMATION,
|
|
744
|
+
ResearchEvidenceStage.FALSIFICATION,
|
|
745
|
+
}:
|
|
746
|
+
prior_groups = {
|
|
747
|
+
item.independence_group for item in state.ordered_observations
|
|
748
|
+
}
|
|
749
|
+
if call.independence_group in prior_groups:
|
|
750
|
+
raise ResearchDecisionRejected(
|
|
751
|
+
"confirmation or falsification must use a fresh independence group"
|
|
752
|
+
)
|
|
753
|
+
return estimate
|
|
754
|
+
|
|
755
|
+
conclusion = decision.conclusion
|
|
756
|
+
assert conclusion is not None
|
|
757
|
+
if not set(conclusion.evidence_ids) <= evidence_ids:
|
|
758
|
+
raise ResearchDecisionRejected("conclusion names unknown evidence")
|
|
759
|
+
if conclusion.disposition is ResearchDisposition.BOUNDED_NULL:
|
|
760
|
+
if not self.contract.evidence_policy.allow_bounded_null:
|
|
761
|
+
raise ResearchDecisionRejected("contract does not allow a bounded null")
|
|
762
|
+
return None
|
|
763
|
+
if conclusion.disposition is not ResearchDisposition.VALIDATED_WITHIN_TOOLS:
|
|
764
|
+
return None
|
|
765
|
+
candidate = conclusion.candidate
|
|
766
|
+
assert candidate is not None
|
|
767
|
+
if candidate.hypothesis_id not in all_hypotheses:
|
|
768
|
+
raise ResearchDecisionRejected("candidate result names an unknown hypothesis")
|
|
769
|
+
if candidate.result_class not in self.contract.allowed_result_classes:
|
|
770
|
+
raise ResearchDecisionRejected("candidate result class is outside the contract")
|
|
771
|
+
if set(candidate.supporting_evidence_ids) != set(conclusion.evidence_ids):
|
|
772
|
+
raise ResearchDecisionRejected("candidate and conclusion evidence sets differ")
|
|
773
|
+
if not set(candidate.contradicting_evidence_ids) <= evidence_ids:
|
|
774
|
+
raise ResearchDecisionRejected("candidate names unknown contradicting evidence")
|
|
775
|
+
by_id = {item.id: item for item in state.ordered_observations}
|
|
776
|
+
support = [by_id[item] for item in conclusion.evidence_ids]
|
|
777
|
+
if not support or any(not item.evidence_eligible for item in support):
|
|
778
|
+
raise ResearchDecisionRejected("validated result requires eligible evidence")
|
|
779
|
+
confirmation = [
|
|
780
|
+
item
|
|
781
|
+
for item in support
|
|
782
|
+
if item.evidence_stage
|
|
783
|
+
in {ResearchEvidenceStage.CONFIRMATION, ResearchEvidenceStage.FALSIFICATION}
|
|
784
|
+
]
|
|
785
|
+
groups = {item.independence_group for item in confirmation}
|
|
786
|
+
if len(groups) < self.contract.evidence_policy.minimum_independent_confirmations:
|
|
787
|
+
raise ResearchDecisionRejected("validated result lacks independent confirmation")
|
|
788
|
+
if any(not all(item.prediction_checks.values()) for item in confirmation):
|
|
789
|
+
raise ResearchDecisionRejected("confirmation violated a preregistered prediction")
|
|
790
|
+
if any(candidate.hypothesis_id not in item.hypothesis_ids for item in confirmation):
|
|
791
|
+
raise ResearchDecisionRejected(
|
|
792
|
+
"candidate hypothesis was not preregistered in its confirmation"
|
|
793
|
+
)
|
|
794
|
+
if self.contract.evidence_policy.require_deliberate_falsification and not any(
|
|
795
|
+
item.evidence_stage is ResearchEvidenceStage.FALSIFICATION for item in support
|
|
796
|
+
):
|
|
797
|
+
raise ResearchDecisionRejected("validated result lacks a deliberate falsification test")
|
|
798
|
+
if (
|
|
799
|
+
candidate.novelty_status is NoveltyStatus.VERIFIED_NEW
|
|
800
|
+
and not any(item.tool_name in literature_tools for item in support)
|
|
801
|
+
):
|
|
802
|
+
raise ResearchDecisionRejected(
|
|
803
|
+
"verified-new status requires literature-search evidence"
|
|
804
|
+
)
|
|
805
|
+
return None
|
|
806
|
+
|
|
807
|
+
def _generate_decision(
|
|
808
|
+
self,
|
|
809
|
+
state: ResearchProjection,
|
|
810
|
+
iteration: int,
|
|
811
|
+
) -> tuple[ResearchDecision, CostEstimate | None]:
|
|
812
|
+
rejected: list[tuple[str, str]] = []
|
|
813
|
+
for _attempt in range(self.contract.maximum_invalid_decisions_per_iteration):
|
|
814
|
+
result = self.completion_client.complete(
|
|
815
|
+
self._messages(state, iteration, rejected),
|
|
816
|
+
route=self.route,
|
|
817
|
+
escalation_reason=self.escalation_reason,
|
|
818
|
+
max_tokens=None,
|
|
819
|
+
temperature=0.2,
|
|
820
|
+
)
|
|
821
|
+
if result.finish_reason != "stop":
|
|
822
|
+
rejected.append(
|
|
823
|
+
(result.content, f"incomplete completion: {result.finish_reason}")
|
|
824
|
+
)
|
|
825
|
+
continue
|
|
826
|
+
try:
|
|
827
|
+
decision = ResearchDecision.model_validate_json(result.content)
|
|
828
|
+
estimate = self._admit_decision(decision, state, iteration)
|
|
829
|
+
return decision, estimate
|
|
830
|
+
except (ValueError, ResearchDecisionRejected) as error:
|
|
831
|
+
rejected.append((result.content, str(error)))
|
|
832
|
+
raise ResearchDecisionRejected(
|
|
833
|
+
"model exhausted structural decision repairs: "
|
|
834
|
+
+ (rejected[-1][1] if rejected else "no decision")
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
@staticmethod
|
|
838
|
+
def _prediction_checks(
|
|
839
|
+
predictions: tuple[ObservablePrediction, ...],
|
|
840
|
+
result: ResearchToolResult,
|
|
841
|
+
) -> dict[str, bool]:
|
|
842
|
+
checks: dict[str, bool] = {}
|
|
843
|
+
for index, prediction in enumerate(predictions):
|
|
844
|
+
key = f"{index}:{prediction.hypothesis_id}:{prediction.observable}"
|
|
845
|
+
observed = result.observables.get(prediction.observable)
|
|
846
|
+
if prediction.exact is not None:
|
|
847
|
+
checks[key] = observed == prediction.exact
|
|
848
|
+
continue
|
|
849
|
+
if not isinstance(observed, (int, float)) or isinstance(observed, bool):
|
|
850
|
+
checks[key] = False
|
|
851
|
+
continue
|
|
852
|
+
checks[key] = (
|
|
853
|
+
(prediction.minimum is None or observed >= prediction.minimum)
|
|
854
|
+
and (prediction.maximum is None or observed <= prediction.maximum)
|
|
855
|
+
)
|
|
856
|
+
return checks
|
|
857
|
+
|
|
858
|
+
def _tool_result(
|
|
859
|
+
self,
|
|
860
|
+
decision: ResearchDecision,
|
|
861
|
+
estimate: CostEstimate,
|
|
862
|
+
) -> ResearchToolResult:
|
|
863
|
+
call = decision.tool_call
|
|
864
|
+
assert call is not None
|
|
865
|
+
tool = self.tools.get(call.tool_name)
|
|
866
|
+
payload = {
|
|
867
|
+
"tool": tool.manifest.model_dump(mode="json"),
|
|
868
|
+
"arguments": call.arguments,
|
|
869
|
+
"estimated_cost": estimate.model_dump(mode="json"),
|
|
870
|
+
"decision_id": decision.id,
|
|
871
|
+
}
|
|
872
|
+
intent = create_intent(
|
|
873
|
+
campaign_id=self.campaign_id,
|
|
874
|
+
operation=ExternalOperation.RESEARCH_TOOL,
|
|
875
|
+
logical_action_id=decision.id,
|
|
876
|
+
idempotency_key=f"research-tool:{self.campaign_id}:{decision.id}",
|
|
877
|
+
payload=payload,
|
|
878
|
+
external_idempotency_supported=True,
|
|
879
|
+
)
|
|
880
|
+
outbox = ExternalOutbox(campaign_id=self.campaign_id, ledger=self.ledger)
|
|
881
|
+
state = outbox.register(intent)
|
|
882
|
+
if state.receipt is not None:
|
|
883
|
+
return ResearchToolResult.model_validate(state.receipt.response["result"])
|
|
884
|
+
if state.active_attempt is not None:
|
|
885
|
+
outbox.interrupt_active(
|
|
886
|
+
intent.id,
|
|
887
|
+
"controller restarted; idempotent research tool will reattach or replay",
|
|
888
|
+
)
|
|
889
|
+
outbox.begin(intent.id)
|
|
890
|
+
try:
|
|
891
|
+
result = tool.execute(call.arguments, idempotency_key=intent.idempotency_key)
|
|
892
|
+
except Exception as error:
|
|
893
|
+
outbox.fail(intent.id, f"{type(error).__name__}: {error}")
|
|
894
|
+
raise
|
|
895
|
+
outbox.succeed(
|
|
896
|
+
intent.id,
|
|
897
|
+
response={"result": result.model_dump(mode="json")},
|
|
898
|
+
external_id=intent.idempotency_key,
|
|
899
|
+
)
|
|
900
|
+
self._crash(ResearchCrashPoint.AFTER_TOOL_RECEIPT)
|
|
901
|
+
return result
|
|
902
|
+
|
|
903
|
+
def _commit_observation(
|
|
904
|
+
self,
|
|
905
|
+
decision: ResearchDecision,
|
|
906
|
+
result: ResearchToolResult,
|
|
907
|
+
estimate: CostEstimate,
|
|
908
|
+
) -> ResearchObservation:
|
|
909
|
+
call = decision.tool_call
|
|
910
|
+
assert call is not None
|
|
911
|
+
if (
|
|
912
|
+
result.cost.compute_units > estimate.compute_units + 1e-12
|
|
913
|
+
or result.cost.wall_seconds > estimate.wall_seconds + 1e-12
|
|
914
|
+
or result.cost.storage_bytes > estimate.storage_bytes
|
|
915
|
+
):
|
|
916
|
+
result = result.model_copy(
|
|
917
|
+
update={
|
|
918
|
+
"validity_checks": {
|
|
919
|
+
**result.validity_checks,
|
|
920
|
+
"preauthorized_cost_not_exceeded": False,
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
)
|
|
924
|
+
checks = self._prediction_checks(call.predictions, result)
|
|
925
|
+
observation = ResearchObservation(
|
|
926
|
+
id=f"observation_{_canonical_hash(decision.id)[:20]}",
|
|
927
|
+
iteration=decision.iteration,
|
|
928
|
+
decision_id=decision.id,
|
|
929
|
+
tool_name=call.tool_name,
|
|
930
|
+
evidence_stage=call.evidence_stage,
|
|
931
|
+
evidence_role=call.evidence_role,
|
|
932
|
+
independence_group=call.independence_group,
|
|
933
|
+
hypothesis_ids=tuple(sorted({item.hypothesis_id for item in call.predictions})),
|
|
934
|
+
preregistered_predictions=call.predictions,
|
|
935
|
+
prediction_checks=checks,
|
|
936
|
+
result=result,
|
|
937
|
+
evidence_eligible=(
|
|
938
|
+
result.completed
|
|
939
|
+
and bool(result.validity_checks)
|
|
940
|
+
and all(result.validity_checks.values())
|
|
941
|
+
),
|
|
942
|
+
)
|
|
943
|
+
self._append(
|
|
944
|
+
"research_observation_committed",
|
|
945
|
+
"research_observation",
|
|
946
|
+
observation.id,
|
|
947
|
+
{"observation": observation.model_dump(mode="json")},
|
|
948
|
+
f"iteration:{decision.iteration}:observation",
|
|
949
|
+
)
|
|
950
|
+
self._crash(ResearchCrashPoint.AFTER_OBSERVATION_COMMITTED)
|
|
951
|
+
return observation
|
|
952
|
+
|
|
953
|
+
def _budget_conclusion(self, reason: str) -> ResearchConclusion:
|
|
954
|
+
return ResearchConclusion(
|
|
955
|
+
disposition=ResearchDisposition.BUDGET_EXHAUSTED,
|
|
956
|
+
reason=reason,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
def _build_report(
|
|
960
|
+
self,
|
|
961
|
+
state: ResearchProjection,
|
|
962
|
+
conclusion: ResearchConclusion,
|
|
963
|
+
) -> ResearchCampaignReport:
|
|
964
|
+
cost = state.consumed_cost
|
|
965
|
+
events = self.ledger.load(self.campaign_id)
|
|
966
|
+
return ResearchCampaignReport.create(
|
|
967
|
+
campaign_id=self.campaign_id,
|
|
968
|
+
contract=self.contract,
|
|
969
|
+
hypotheses=state.hypotheses(),
|
|
970
|
+
relations=state.relations(),
|
|
971
|
+
decisions=state.ordered_decisions,
|
|
972
|
+
observations=state.ordered_observations,
|
|
973
|
+
conclusion=conclusion,
|
|
974
|
+
consumed_tool_calls=state.consumed_tool_calls,
|
|
975
|
+
consumed_compute_units=cost.compute_units,
|
|
976
|
+
consumed_wall_seconds=cost.wall_seconds,
|
|
977
|
+
consumed_storage_bytes=cost.storage_bytes,
|
|
978
|
+
budget_overrun=(
|
|
979
|
+
state.consumed_tool_calls > self.contract.budget.maximum_tool_calls
|
|
980
|
+
or cost.compute_units
|
|
981
|
+
> self.contract.budget.maximum_compute_units + 1e-12
|
|
982
|
+
or cost.wall_seconds > self.contract.budget.maximum_wall_seconds + 1e-12
|
|
983
|
+
or cost.storage_bytes > self.contract.budget.maximum_storage_bytes
|
|
984
|
+
),
|
|
985
|
+
provenance_event_hashes=tuple(event.event_hash for event in events),
|
|
986
|
+
generated_at=utc_now(),
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
def _complete(
|
|
990
|
+
self,
|
|
991
|
+
state: ResearchProjection,
|
|
992
|
+
conclusion: ResearchConclusion,
|
|
993
|
+
) -> ResearchCampaignReport:
|
|
994
|
+
report = self._build_report(state, conclusion)
|
|
995
|
+
self._append(
|
|
996
|
+
"research_campaign_completed",
|
|
997
|
+
"research_campaign",
|
|
998
|
+
self.campaign_id,
|
|
999
|
+
{"report": report.model_dump(mode="json")},
|
|
1000
|
+
"completed",
|
|
1001
|
+
)
|
|
1002
|
+
self._crash(ResearchCrashPoint.AFTER_REPORT_COMMITTED)
|
|
1003
|
+
committed = self._state().report
|
|
1004
|
+
assert committed is not None
|
|
1005
|
+
return committed
|
|
1006
|
+
|
|
1007
|
+
def run(self) -> ResearchCampaignReport:
|
|
1008
|
+
self._register_contract()
|
|
1009
|
+
while True:
|
|
1010
|
+
state = self._state()
|
|
1011
|
+
if state.report is not None:
|
|
1012
|
+
return state.report
|
|
1013
|
+
concluding = next(
|
|
1014
|
+
(
|
|
1015
|
+
item
|
|
1016
|
+
for item in reversed(state.ordered_decisions)
|
|
1017
|
+
if item.kind is ResearchDecisionKind.CONCLUDE
|
|
1018
|
+
),
|
|
1019
|
+
None,
|
|
1020
|
+
)
|
|
1021
|
+
if concluding is not None:
|
|
1022
|
+
conclusion = concluding.conclusion
|
|
1023
|
+
assert conclusion is not None
|
|
1024
|
+
return self._complete(state, conclusion)
|
|
1025
|
+
pending = next(
|
|
1026
|
+
(
|
|
1027
|
+
item
|
|
1028
|
+
for item in state.ordered_decisions
|
|
1029
|
+
if item.kind is ResearchDecisionKind.USE_TOOL
|
|
1030
|
+
and item.iteration not in state.observations
|
|
1031
|
+
),
|
|
1032
|
+
None,
|
|
1033
|
+
)
|
|
1034
|
+
iteration = pending.iteration if pending is not None else len(state.decisions)
|
|
1035
|
+
if iteration >= self.contract.budget.maximum_iterations:
|
|
1036
|
+
return self._complete(
|
|
1037
|
+
state,
|
|
1038
|
+
self._budget_conclusion("maximum autonomous iterations exhausted"),
|
|
1039
|
+
)
|
|
1040
|
+
if self.control is not None:
|
|
1041
|
+
self.control.require_processing_authority()
|
|
1042
|
+
decision = pending
|
|
1043
|
+
estimate: CostEstimate | None = None
|
|
1044
|
+
if decision is None:
|
|
1045
|
+
decision, estimate = self._generate_decision(state, iteration)
|
|
1046
|
+
self._append(
|
|
1047
|
+
"research_decision_committed",
|
|
1048
|
+
"research_decision",
|
|
1049
|
+
decision.id,
|
|
1050
|
+
{"decision": decision.model_dump(mode="json")},
|
|
1051
|
+
f"iteration:{iteration}:decision",
|
|
1052
|
+
)
|
|
1053
|
+
self._crash(ResearchCrashPoint.AFTER_DECISION_COMMITTED)
|
|
1054
|
+
state = self._state()
|
|
1055
|
+
else:
|
|
1056
|
+
call = decision.tool_call
|
|
1057
|
+
assert call is not None
|
|
1058
|
+
estimate = self.tools.get(call.tool_name).estimate_cost(call.arguments)
|
|
1059
|
+
|
|
1060
|
+
if decision.kind is ResearchDecisionKind.CONCLUDE:
|
|
1061
|
+
conclusion = decision.conclusion
|
|
1062
|
+
assert conclusion is not None
|
|
1063
|
+
return self._complete(self._state(), conclusion)
|
|
1064
|
+
|
|
1065
|
+
state = self._state()
|
|
1066
|
+
if iteration not in state.observations:
|
|
1067
|
+
if estimate is None:
|
|
1068
|
+
estimate = self._admit_decision(decision, state, iteration)
|
|
1069
|
+
assert estimate is not None
|
|
1070
|
+
result = self._tool_result(decision, estimate)
|
|
1071
|
+
self._commit_observation(decision, result, estimate)
|