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.
Files changed (75) hide show
  1. conjecture_solver/__init__.py +10 -0
  2. conjecture_solver/__main__.py +4 -0
  3. conjecture_solver/action_handlers.py +459 -0
  4. conjecture_solver/adapters/__init__.py +7 -0
  5. conjecture_solver/adapters/base.py +96 -0
  6. conjecture_solver/adapters/fake.py +155 -0
  7. conjecture_solver/adapters/pic.py +196 -0
  8. conjecture_solver/adapters/warpx.py +1375 -0
  9. conjecture_solver/autonomous_research.py +1071 -0
  10. conjecture_solver/benchmarks/__init__.py +6 -0
  11. conjecture_solver/benchmarks/electrostatic_pic.py +547 -0
  12. conjecture_solver/benchmarks/kinetic_sufficiency.py +318 -0
  13. conjecture_solver/builtin_skills/warpx/SKILL.md +151 -0
  14. conjecture_solver/builtin_skills/warpx/agents/openai.yaml +4 -0
  15. conjecture_solver/builtin_skills/warpx/examples/implicit_em_smoke.py +74 -0
  16. conjecture_solver/builtin_skills/warpx/examples/minimal_smoke.py +54 -0
  17. conjecture_solver/builtin_skills/warpx/examples/openpmd_field_smoke.py +87 -0
  18. conjecture_solver/builtin_skills/warpx/manifest.json +11 -0
  19. conjecture_solver/builtin_skills/warpx/references/2d-xz-commissioning.md +145 -0
  20. conjecture_solver/builtin_skills/warpx/references/cpu-launch-tuning.md +54 -0
  21. conjecture_solver/builtin_skills/warpx/references/diagnostics.md +139 -0
  22. conjecture_solver/builtin_skills/warpx/references/gpu-launch-tuning.md +79 -0
  23. conjecture_solver/builtin_skills/warpx/references/local-cuda-deployment.md +87 -0
  24. conjecture_solver/builtin_skills/warpx/references/numerical-risks.md +32 -0
  25. conjecture_solver/builtin_skills/warpx/references/picmi-interface.md +118 -0
  26. conjecture_solver/builtin_skills/warpx/references/resource-scaling.md +24 -0
  27. conjecture_solver/builtin_skills/warpx/references/time-integration.md +58 -0
  28. conjecture_solver/builtin_skills/warpx/scripts/benchmark_cpu_threads.py +207 -0
  29. conjecture_solver/builtin_skills/warpx/scripts/benchmark_gpu.py +242 -0
  30. conjecture_solver/builtin_skills/warpx/scripts/bootstrap_local_cuda.sh +82 -0
  31. conjecture_solver/builtin_skills/warpx/scripts/probe_local_cuda.py +110 -0
  32. conjecture_solver/builtin_skills/warpx/scripts/reduced_energy_budget.py +195 -0
  33. conjecture_solver/builtin_skills/warpx/scripts/run_local_cuda.sh +39 -0
  34. conjecture_solver/campaign.py +406 -0
  35. conjecture_solver/cli.py +963 -0
  36. conjecture_solver/confirmation.py +287 -0
  37. conjecture_solver/control.py +234 -0
  38. conjecture_solver/deployment.py +874 -0
  39. conjecture_solver/discovery.py +68 -0
  40. conjecture_solver/domains/__init__.py +40 -0
  41. conjecture_solver/domains/base.py +157 -0
  42. conjecture_solver/domains/kinetic_sufficiency.py +521 -0
  43. conjecture_solver/ledger.py +238 -0
  44. conjecture_solver/lifecycle.py +131 -0
  45. conjecture_solver/literature.py +494 -0
  46. conjecture_solver/llm.py +228 -0
  47. conjecture_solver/models.py +279 -0
  48. conjecture_solver/mvp_agent.py +3212 -0
  49. conjecture_solver/mvp_claims.py +1279 -0
  50. conjecture_solver/mvp_control.py +322 -0
  51. conjecture_solver/mvp_guidance.py +172 -0
  52. conjecture_solver/mvp_launch.py +1296 -0
  53. conjecture_solver/mvp_monitor.py +1487 -0
  54. conjecture_solver/mvp_skills.py +538 -0
  55. conjecture_solver/orchestration.py +578 -0
  56. conjecture_solver/outbox.py +526 -0
  57. conjecture_solver/parameters.py +394 -0
  58. conjecture_solver/proposals.py +427 -0
  59. conjecture_solver/research_tools.py +278 -0
  60. conjecture_solver/schema_export.py +246 -0
  61. conjecture_solver/search.py +1047 -0
  62. conjecture_solver/semantics.py +146 -0
  63. conjecture_solver/tui/__init__.py +7 -0
  64. conjecture_solver/tui/app.py +202 -0
  65. conjecture_solver/tui/claim_views.py +213 -0
  66. conjecture_solver/tui/screens.py +1014 -0
  67. conjecture_solver/warpx_analysis.py +184 -0
  68. conjecture_solver/warpx_campaign.py +148 -0
  69. conjecture_solver/warpx_confirmation.py +617 -0
  70. simjecture-0.1.0.dist-info/METADATA +400 -0
  71. simjecture-0.1.0.dist-info/RECORD +75 -0
  72. simjecture-0.1.0.dist-info/WHEEL +4 -0
  73. simjecture-0.1.0.dist-info/entry_points.txt +4 -0
  74. simjecture-0.1.0.dist-info/licenses/LICENSE +202 -0
  75. 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)