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,1279 @@
1
+ """Domain-neutral working-claim ledger for the natural-language MVP.
2
+
3
+ This is not a domain-specific daughter schema. Claims are free-text scientific,
4
+ instrument, diagnostic, or control statements with lineage and evidence links.
5
+ The agent still designs all physics and experiments; the ledger only forces
6
+ identity, parent relation, and disposition so audits do not depend on scanning
7
+ the full transcript.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ from enum import StrEnum
17
+ from pathlib import Path, PurePosixPath
18
+ from typing import Any, Literal
19
+
20
+ from pydantic import Field, model_validator
21
+
22
+ from .models import StrictModel
23
+
24
+ CLAIM_ID_PATTERN = re.compile(r"^claim_[a-z0-9_]+$")
25
+ ROOT_CLAIM_ID = "claim_root"
26
+
27
+
28
+ class ClaimKind(StrEnum):
29
+ SCIENTIFIC = "scientific"
30
+ INSTRUMENT = "instrument"
31
+ DIAGNOSTIC = "diagnostic"
32
+ CONTROL = "control"
33
+
34
+
35
+ class ClaimRelation(StrEnum):
36
+ ROOT = "root"
37
+ REFINES = "refines"
38
+ ALTERNATE = "alternate"
39
+ DIAGNOSTIC_OF = "diagnostic_of"
40
+ INSTRUMENT_OF = "instrument_of"
41
+ CONTROL_FOR = "control_for"
42
+ SUCCEEDS = "succeeds"
43
+
44
+
45
+ class ClaimDisposition(StrEnum):
46
+ OPEN = "open"
47
+ SUPPORTED = "supported"
48
+ WEAKENED = "weakened"
49
+ FALSIFIED = "falsified"
50
+ SUPERSEDED = "superseded"
51
+ UNRESOLVED = "unresolved"
52
+ INSTRUMENT_LIMITED = "instrument_limited"
53
+
54
+
55
+ class CommissioningAspect(StrEnum):
56
+ """Domain-neutral aspects required before a scientific capability run."""
57
+
58
+ INTERFACE = "interface"
59
+ REPRESENTATION = "representation"
60
+ PHYSICS_CONTROLS = "physics_controls"
61
+ BOUNDARIES = "boundaries"
62
+ DIAGNOSTICS = "diagnostics"
63
+ NUMERICAL_REGIME = "numerical_regime"
64
+
65
+
66
+ REQUIRED_SCIENTIFIC_COMMISSIONING_ASPECTS = frozenset(
67
+ {
68
+ CommissioningAspect.REPRESENTATION,
69
+ CommissioningAspect.PHYSICS_CONTROLS,
70
+ CommissioningAspect.BOUNDARIES,
71
+ CommissioningAspect.DIAGNOSTICS,
72
+ CommissioningAspect.NUMERICAL_REGIME,
73
+ }
74
+ )
75
+
76
+
77
+ def _validation_scalars_equal(actual: Any, expected: Any) -> bool:
78
+ """Compare JSON scalars without treating booleans as numeric values."""
79
+
80
+ if isinstance(actual, bool) or isinstance(expected, bool):
81
+ return type(actual) is type(expected) and actual == expected
82
+ if isinstance(actual, (int, float)) and isinstance(expected, (int, float)):
83
+ return (
84
+ math.isfinite(actual)
85
+ and math.isfinite(expected)
86
+ and actual == expected
87
+ )
88
+ return type(actual) is type(expected) and actual == expected
89
+
90
+
91
+ class ClaimEvidenceValidationCheck(StrictModel):
92
+ """One exact assertion over a JSON evidence summary."""
93
+
94
+ aspect: CommissioningAspect | None = Field(
95
+ default=None,
96
+ description=(
97
+ "Commissioning aspect tested by this assertion. Instrument evidence "
98
+ "used to unlock scientific capability execution must cover every required "
99
+ "aspect in one prospectively registered contract."
100
+ ),
101
+ )
102
+ json_path: str = Field(
103
+ pattern=r"^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$",
104
+ description="Dot-separated object keys; array indexing is deliberately unsupported",
105
+ )
106
+ expected_value: str | int | float | bool | None
107
+
108
+
109
+ class ClaimEvidenceValidationResult(StrictModel):
110
+ """Deterministic evaluation of one prospective JSON assertion."""
111
+
112
+ aspect: CommissioningAspect | None = None
113
+ json_path: str
114
+ expected_value: str | int | float | bool | None
115
+ actual_value: str | int | float | bool | None = None
116
+ passed: bool
117
+ error: str | None = None
118
+
119
+
120
+ class ClaimExecutionBinding(StrictModel):
121
+ """Prospective command identity for commissioning and later science runs."""
122
+
123
+ capability: str = Field(pattern=r"^[a-z][a-z0-9_.-]*$")
124
+ program_path: str = Field(min_length=1)
125
+ program_sha256: str | None = Field(
126
+ default=None,
127
+ pattern=r"^[0-9a-f]{64}$",
128
+ description=(
129
+ "Runner-attested source identity sealed before the first bound "
130
+ "capability execution"
131
+ ),
132
+ )
133
+ commissioning_argv: tuple[str, ...] = Field(min_length=1)
134
+ allowed_scientific_argv: tuple[tuple[str, ...], ...] = ()
135
+
136
+ @model_validator(mode="after")
137
+ def paths_and_commands_are_consistent(self) -> ClaimExecutionBinding:
138
+ path = PurePosixPath(self.program_path)
139
+ if (
140
+ path.is_absolute()
141
+ or self.program_path in {"", "."}
142
+ or ".." in path.parts
143
+ or "\x00" in self.program_path
144
+ ):
145
+ raise ValueError("execution binding program_path must stay in the workspace")
146
+ commands = (self.commissioning_argv, *self.allowed_scientific_argv)
147
+ for argv in commands:
148
+ if not argv:
149
+ raise ValueError("execution binding commands cannot be empty")
150
+ if argv[0] != self.program_path:
151
+ raise ValueError(
152
+ "every bound command must use program_path as argv[0]"
153
+ )
154
+ if any("\x00" in item for item in argv):
155
+ raise ValueError("execution binding arguments cannot contain NUL bytes")
156
+ if len(set(self.allowed_scientific_argv)) != len(
157
+ self.allowed_scientific_argv
158
+ ):
159
+ raise ValueError("allowed_scientific_argv cannot contain duplicates")
160
+ return self
161
+
162
+ def allows_evidence_argv(self, argv: tuple[str, ...], *, instrument: bool) -> bool:
163
+ """Return whether a prospectively bound command may produce claim evidence."""
164
+
165
+ if instrument:
166
+ return argv == self.commissioning_argv
167
+ return argv in self.allowed_scientific_argv
168
+
169
+
170
+ class ClaimEvidenceContract(StrictModel):
171
+ """Prospective, domain-neutral rule for interpreting evidence for one claim."""
172
+
173
+ version: int = Field(ge=1)
174
+ observable: str = Field(min_length=8)
175
+ expected_outcomes: str = Field(min_length=8)
176
+ decision_rule: str = Field(min_length=8)
177
+ required_observation: str = Field(min_length=8)
178
+ uncertainty_criterion: str = Field(min_length=8)
179
+ inconclusive_conditions: str = Field(min_length=8)
180
+ validation_checks: tuple[ClaimEvidenceValidationCheck, ...] = ()
181
+ execution_binding: ClaimExecutionBinding | None = None
182
+ additional_execution_bindings: tuple[ClaimExecutionBinding, ...] = ()
183
+ registered_iteration: int = Field(ge=0)
184
+
185
+ @model_validator(mode="after")
186
+ def execution_pipeline_is_unambiguous(self) -> ClaimEvidenceContract:
187
+ if self.additional_execution_bindings and self.execution_binding is None:
188
+ raise ValueError(
189
+ "additional_execution_bindings require a primary execution_binding"
190
+ )
191
+ seen_commands: set[tuple[str, tuple[str, ...]]] = set()
192
+ for binding in self.all_execution_bindings():
193
+ for argv in binding.allowed_scientific_argv:
194
+ identity = (binding.capability, argv)
195
+ if identity in seen_commands:
196
+ raise ValueError(
197
+ "scientific execution commands cannot be duplicated across "
198
+ "execution bindings"
199
+ )
200
+ seen_commands.add(identity)
201
+ return self
202
+
203
+ def all_execution_bindings(self) -> tuple[ClaimExecutionBinding, ...]:
204
+ """Return the primary and any prospectively declared pipeline stages."""
205
+
206
+ if self.execution_binding is None:
207
+ return ()
208
+ return (self.execution_binding, *self.additional_execution_bindings)
209
+
210
+
211
+ class ClaimEvidenceProvenance(StrictModel):
212
+ """Immutable identity and generating-action metadata for a workspace artifact."""
213
+
214
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
215
+ bytes: int = Field(ge=0)
216
+ tracked: bool
217
+ generated_iteration: int | None = Field(default=None, ge=0)
218
+ action: str | None = None
219
+ action_sha256: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$")
220
+ command_argv: tuple[str, ...] = ()
221
+ capability: str | None = None
222
+ program_path: str | None = None
223
+ program_sha256: str | None = Field(
224
+ default=None,
225
+ pattern=r"^[0-9a-f]{64}$",
226
+ )
227
+ execution_succeeded: bool | None = None
228
+ execution_returncode: int | None = None
229
+ execution_timed_out: bool | None = None
230
+ execution_workspace_exceeded: bool | None = None
231
+ execution_stage: Literal["workbench", "evidence"] | None = None
232
+ evidence_eligible: bool = True
233
+
234
+
235
+ class ClaimEvidenceLink(StrictModel):
236
+ path: str = Field(min_length=1)
237
+ note: str = Field(min_length=1)
238
+ iteration: int = Field(ge=0)
239
+ contract_version: int | None = Field(default=None, ge=1)
240
+ observation_sufficient: bool | None = None
241
+ observation_note: str | None = None
242
+ validation_passed: bool | None = None
243
+ validation_results: tuple[ClaimEvidenceValidationResult, ...] = ()
244
+ commissioning_claim_id: str | None = Field(
245
+ default=None,
246
+ pattern=r"^claim_[a-z0-9_]+$",
247
+ )
248
+ provenance: ClaimEvidenceProvenance | None = None
249
+
250
+
251
+ class MVPClaim(StrictModel):
252
+ id: str = Field(pattern=r"^claim_[a-z0-9_]+$")
253
+ statement: str = Field(min_length=8)
254
+ kind: ClaimKind
255
+ relation: ClaimRelation
256
+ parent_id: str | None = None
257
+ status: ClaimDisposition = ClaimDisposition.OPEN
258
+ rationale: str = Field(min_length=8)
259
+ evidence_contracts: tuple[ClaimEvidenceContract, ...] = ()
260
+ evidence: tuple[ClaimEvidenceLink, ...] = ()
261
+ closed_reason: str | None = None
262
+ created_iteration: int = Field(ge=0)
263
+ updated_iteration: int = Field(ge=0)
264
+
265
+ @model_validator(mode="after")
266
+ def relation_and_parent_are_consistent(self) -> MVPClaim:
267
+ if self.relation == ClaimRelation.ROOT:
268
+ if self.id != ROOT_CLAIM_ID:
269
+ raise ValueError("only claim_root may use relation=root")
270
+ if self.parent_id is not None:
271
+ raise ValueError("root claim cannot have a parent_id")
272
+ else:
273
+ if self.parent_id is None:
274
+ raise ValueError("non-root claims require parent_id")
275
+ if not CLAIM_ID_PATTERN.match(self.parent_id):
276
+ raise ValueError("parent_id must match claim_[a-z0-9_]+")
277
+ if self.status != ClaimDisposition.OPEN and (
278
+ self.closed_reason is None or not self.closed_reason.strip()
279
+ ):
280
+ raise ValueError("closed claims require closed_reason")
281
+ if self.status == ClaimDisposition.OPEN and self.closed_reason is not None:
282
+ raise ValueError("open claims cannot carry closed_reason")
283
+ return self
284
+
285
+
286
+ class MVPClaimLedger(StrictModel):
287
+ schema_version: Literal[
288
+ "0.1.0",
289
+ "0.2.0",
290
+ "0.3.0",
291
+ "0.4.0",
292
+ "0.5.0",
293
+ "0.6.0",
294
+ "0.7.0",
295
+ "0.8.0",
296
+ ] = (
297
+ "0.8.0"
298
+ )
299
+ root_hypothesis: str = Field(min_length=1)
300
+ claims: tuple[MVPClaim, ...] = ()
301
+
302
+ def by_id(self) -> dict[str, MVPClaim]:
303
+ return {claim.id: claim for claim in self.claims}
304
+
305
+ def open_claims(self) -> tuple[MVPClaim, ...]:
306
+ return tuple(
307
+ claim for claim in self.claims if claim.status == ClaimDisposition.OPEN
308
+ )
309
+
310
+ def compact_summary(self, *, max_claims: int = 24) -> dict[str, Any]:
311
+ """Short ledger view safe to inject into tool results / context."""
312
+ ordered = sorted(self.claims, key=lambda claim: claim.updated_iteration)
313
+ tail = ordered[-max_claims:]
314
+ return {
315
+ "schema_version": self.schema_version,
316
+ "root_hypothesis": self.root_hypothesis,
317
+ "claim_count": len(self.claims),
318
+ "open_count": len(self.open_claims()),
319
+ "claims": [
320
+ {
321
+ "id": claim.id,
322
+ "kind": claim.kind.value,
323
+ "relation": claim.relation.value,
324
+ "parent_id": claim.parent_id,
325
+ "status": claim.status.value,
326
+ "statement": claim.statement,
327
+ "evidence_contract_count": len(claim.evidence_contracts),
328
+ "evidence_count": len(claim.evidence),
329
+ }
330
+ for claim in tail
331
+ ],
332
+ }
333
+
334
+
335
+ class MVPClaimLedgerStore:
336
+ """Durable claim ledger beside the MVP report and transcript."""
337
+
338
+ def __init__(self, path: str | Path, *, root_hypothesis: str) -> None:
339
+ self.path = Path(path).resolve()
340
+ self.root_hypothesis = root_hypothesis.strip()
341
+ if not self.root_hypothesis:
342
+ raise ValueError("root hypothesis cannot be empty")
343
+ self._ledger = self._load_or_create()
344
+
345
+ @property
346
+ def ledger(self) -> MVPClaimLedger:
347
+ return self._ledger
348
+
349
+ def _load_or_create(self) -> MVPClaimLedger:
350
+ if self.path.exists():
351
+ ledger = MVPClaimLedger.model_validate_json(self.path.read_text())
352
+ if ledger.root_hypothesis != self.root_hypothesis:
353
+ raise ValueError("claim ledger belongs to a different root hypothesis")
354
+ return ledger
355
+ root = MVPClaim(
356
+ id=ROOT_CLAIM_ID,
357
+ statement=self.root_hypothesis,
358
+ kind=ClaimKind.SCIENTIFIC,
359
+ relation=ClaimRelation.ROOT,
360
+ parent_id=None,
361
+ status=ClaimDisposition.OPEN,
362
+ rationale="Immutable root hypothesis supplied by the campaign operator.",
363
+ evidence_contracts=(),
364
+ evidence=(),
365
+ closed_reason=None,
366
+ created_iteration=0,
367
+ updated_iteration=0,
368
+ )
369
+ ledger = MVPClaimLedger(root_hypothesis=self.root_hypothesis, claims=(root,))
370
+ self._persist(ledger)
371
+ return ledger
372
+
373
+ def _persist(self, ledger: MVPClaimLedger) -> None:
374
+ self.path.parent.mkdir(parents=True, exist_ok=True)
375
+ temporary = self.path.with_suffix(self.path.suffix + ".tmp")
376
+ temporary.write_text(ledger.model_dump_json(indent=2) + "\n")
377
+ os.replace(temporary, self.path)
378
+
379
+ def _replace_claim(self, claim: MVPClaim) -> MVPClaimLedger:
380
+ claims = [existing for existing in self._ledger.claims if existing.id != claim.id]
381
+ claims.append(claim)
382
+ claims.sort(key=lambda item: (item.created_iteration, item.id))
383
+ self._ledger = MVPClaimLedger(
384
+ root_hypothesis=self.root_hypothesis,
385
+ claims=tuple(claims),
386
+ )
387
+ self._persist(self._ledger)
388
+ return self._ledger
389
+
390
+ def register(
391
+ self,
392
+ *,
393
+ claim_id: str,
394
+ statement: str,
395
+ kind: ClaimKind,
396
+ relation: ClaimRelation,
397
+ parent_id: str,
398
+ rationale: str,
399
+ iteration: int,
400
+ ) -> dict[str, Any]:
401
+ claim_id = claim_id.strip().casefold()
402
+ parent_id = parent_id.strip().casefold()
403
+ if relation == ClaimRelation.ROOT:
404
+ raise ValueError("cannot re-register the root claim")
405
+ if claim_id == ROOT_CLAIM_ID:
406
+ raise ValueError("claim_root is reserved")
407
+ if not CLAIM_ID_PATTERN.match(claim_id):
408
+ raise ValueError("claim_id must match claim_[a-z0-9_]+")
409
+ existing = self._ledger.by_id()
410
+ if claim_id in existing:
411
+ raise ValueError(f"claim_id already exists: {claim_id}")
412
+ if parent_id not in existing:
413
+ raise ValueError(f"unknown parent_id: {parent_id}")
414
+ parent = existing[parent_id]
415
+ relation_kinds = {
416
+ ClaimRelation.INSTRUMENT_OF: ClaimKind.INSTRUMENT,
417
+ ClaimRelation.DIAGNOSTIC_OF: ClaimKind.DIAGNOSTIC,
418
+ ClaimRelation.CONTROL_FOR: ClaimKind.CONTROL,
419
+ }
420
+ expected_kind = relation_kinds.get(relation)
421
+ if expected_kind is not None:
422
+ if kind != expected_kind or parent.kind != ClaimKind.SCIENTIFIC:
423
+ if relation == ClaimRelation.CONTROL_FOR:
424
+ raise ValueError(
425
+ f"relation={relation.value} requires a "
426
+ f"{expected_kind.value} claim whose parent is scientific "
427
+ f"(got kind={kind.value}, parent_kind={parent.kind.value}); "
428
+ "for non-scientific exact-reuse smokes use control_for "
429
+ "with parent_id of a scientific claim such as claim_root, "
430
+ "not an instrument claim"
431
+ )
432
+ raise ValueError(
433
+ f"relation={relation.value} requires a {expected_kind.value} "
434
+ "claim whose parent is scientific"
435
+ )
436
+ elif relation in {ClaimRelation.REFINES, ClaimRelation.ALTERNATE}:
437
+ if kind != ClaimKind.SCIENTIFIC or parent.kind != ClaimKind.SCIENTIFIC:
438
+ raise ValueError(
439
+ f"relation={relation.value} requires scientific child and parent"
440
+ )
441
+ elif relation == ClaimRelation.SUCCEEDS:
442
+ if kind != parent.kind:
443
+ raise ValueError(
444
+ "relation=succeeds requires child and predecessor to have the "
445
+ "same claim kind"
446
+ )
447
+ if parent.status == ClaimDisposition.OPEN:
448
+ raise ValueError(
449
+ "relation=succeeds requires a closed predecessor claim"
450
+ )
451
+ claim = MVPClaim(
452
+ id=claim_id,
453
+ statement=statement,
454
+ kind=kind,
455
+ relation=relation,
456
+ parent_id=parent_id,
457
+ status=ClaimDisposition.OPEN,
458
+ rationale=rationale,
459
+ evidence_contracts=(),
460
+ evidence=(),
461
+ closed_reason=None,
462
+ created_iteration=iteration,
463
+ updated_iteration=iteration,
464
+ )
465
+ ledger = self._replace_claim(claim)
466
+ return {
467
+ "registered": claim.model_dump(mode="json"),
468
+ "claim_ledger": ledger.compact_summary(),
469
+ }
470
+
471
+ def register_evidence_contract(
472
+ self,
473
+ *,
474
+ claim_id: str,
475
+ observable: str,
476
+ expected_outcomes: str,
477
+ decision_rule: str,
478
+ required_observation: str,
479
+ uncertainty_criterion: str,
480
+ inconclusive_conditions: str,
481
+ validation_checks: tuple[ClaimEvidenceValidationCheck, ...] = (),
482
+ execution_binding: ClaimExecutionBinding | None = None,
483
+ additional_execution_bindings: tuple[ClaimExecutionBinding, ...] = (),
484
+ iteration: int,
485
+ ) -> dict[str, Any]:
486
+ claim_id = claim_id.strip().casefold()
487
+ existing = self._ledger.by_id()
488
+ if claim_id not in existing:
489
+ raise ValueError(f"unknown claim_id: {claim_id}")
490
+ claim = existing[claim_id]
491
+ if claim.status != ClaimDisposition.OPEN:
492
+ raise ValueError(f"claim is already closed: {claim_id}")
493
+ declared_aspects = {
494
+ check.aspect for check in validation_checks if check.aspect is not None
495
+ }
496
+ is_complete_instrument_contract = (
497
+ claim.kind == ClaimKind.INSTRUMENT
498
+ and claim.relation == ClaimRelation.INSTRUMENT_OF
499
+ and REQUIRED_SCIENTIFIC_COMMISSIONING_ASPECTS.issubset(
500
+ declared_aspects
501
+ )
502
+ )
503
+ if is_complete_instrument_contract and (
504
+ execution_binding is None
505
+ or not execution_binding.allowed_scientific_argv
506
+ ):
507
+ raise ValueError(
508
+ "complete instrument commissioning requires a prospective "
509
+ "execution_binding with at least one exact allowed_scientific_argv "
510
+ "command"
511
+ )
512
+ if claim.kind == ClaimKind.INSTRUMENT and additional_execution_bindings:
513
+ raise ValueError(
514
+ "instrument claims bind one program; register a separate instrument_of "
515
+ "claim for each additional evidence-pipeline program"
516
+ )
517
+ contract = ClaimEvidenceContract(
518
+ version=len(claim.evidence_contracts) + 1,
519
+ observable=observable,
520
+ expected_outcomes=expected_outcomes,
521
+ decision_rule=decision_rule,
522
+ required_observation=required_observation,
523
+ uncertainty_criterion=uncertainty_criterion,
524
+ inconclusive_conditions=inconclusive_conditions,
525
+ validation_checks=validation_checks,
526
+ execution_binding=execution_binding,
527
+ additional_execution_bindings=additional_execution_bindings,
528
+ registered_iteration=iteration,
529
+ )
530
+ updated = MVPClaim(
531
+ id=claim.id,
532
+ statement=claim.statement,
533
+ kind=claim.kind,
534
+ relation=claim.relation,
535
+ parent_id=claim.parent_id,
536
+ status=claim.status,
537
+ rationale=claim.rationale,
538
+ evidence_contracts=claim.evidence_contracts + (contract,),
539
+ evidence=claim.evidence,
540
+ closed_reason=claim.closed_reason,
541
+ created_iteration=claim.created_iteration,
542
+ updated_iteration=iteration,
543
+ )
544
+ ledger = self._replace_claim(updated)
545
+ result: dict[str, Any] = {
546
+ "registered_evidence_contract": contract.model_dump(mode="json"),
547
+ "claim_ledger": ledger.compact_summary(),
548
+ }
549
+ if claim.evidence:
550
+ result["adaptive_contract_warning"] = (
551
+ "This contract was registered after evidence was already linked. "
552
+ "Only evidence generated under this contract can justify a new "
553
+ "supported or falsified disposition."
554
+ )
555
+ return result
556
+
557
+ @staticmethod
558
+ def _evaluate_validation_checks(
559
+ checks: tuple[ClaimEvidenceValidationCheck, ...],
560
+ *,
561
+ evidence_document: Any | None,
562
+ evidence_document_error: str | None,
563
+ ) -> tuple[bool | None, tuple[ClaimEvidenceValidationResult, ...]]:
564
+ if not checks:
565
+ return None, ()
566
+ results: list[ClaimEvidenceValidationResult] = []
567
+ for check in checks:
568
+ actual: Any = evidence_document
569
+ error = evidence_document_error
570
+ if error is None:
571
+ for key in check.json_path.split("."):
572
+ if not isinstance(actual, dict) or key not in actual:
573
+ error = f"JSON path is missing: {check.json_path}"
574
+ actual = None
575
+ break
576
+ actual = actual[key]
577
+ if error is None and not isinstance(
578
+ actual,
579
+ (str, int, float, bool, type(None)),
580
+ ):
581
+ error = f"JSON path is not a scalar: {check.json_path}"
582
+ actual = None
583
+ passed = error is None and _validation_scalars_equal(
584
+ actual, check.expected_value
585
+ )
586
+ results.append(
587
+ ClaimEvidenceValidationResult(
588
+ aspect=check.aspect,
589
+ json_path=check.json_path,
590
+ expected_value=check.expected_value,
591
+ actual_value=actual,
592
+ passed=passed,
593
+ error=error,
594
+ )
595
+ )
596
+ frozen = tuple(results)
597
+ return all(result.passed for result in frozen), frozen
598
+
599
+ @staticmethod
600
+ def _qualifying_contract_evidence(claim: MVPClaim) -> tuple[ClaimEvidenceLink, ...]:
601
+ contracts = {contract.version: contract for contract in claim.evidence_contracts}
602
+ qualifying: list[ClaimEvidenceLink] = []
603
+ for evidence in claim.evidence:
604
+ contract = contracts.get(evidence.contract_version)
605
+ provenance = evidence.provenance
606
+ if (
607
+ contract is None
608
+ or evidence.observation_sufficient is not True
609
+ or provenance is None
610
+ or not provenance.tracked
611
+ or provenance.generated_iteration is None
612
+ or provenance.generated_iteration < contract.registered_iteration
613
+ ):
614
+ continue
615
+ if (
616
+ claim.kind == ClaimKind.INSTRUMENT
617
+ and provenance.capability is not None
618
+ and provenance.execution_succeeded is not True
619
+ ):
620
+ continue
621
+ if contract.validation_checks and evidence.validation_passed is not True:
622
+ continue
623
+ qualifying.append(evidence)
624
+ return tuple(qualifying)
625
+
626
+ def _validate_commissioning_claim(
627
+ self,
628
+ *,
629
+ scientific_claim: MVPClaim,
630
+ commissioning_claim_id: str | None,
631
+ scientific_provenance: ClaimEvidenceProvenance,
632
+ ) -> str:
633
+ if commissioning_claim_id is None:
634
+ raise ValueError(
635
+ f"capability evidence for scientific claim {scientific_claim.id} "
636
+ "requires commissioning_claim_id"
637
+ )
638
+ commissioning_claim_id = commissioning_claim_id.strip().casefold()
639
+ claims = self._ledger.by_id()
640
+ if commissioning_claim_id not in claims:
641
+ raise ValueError(f"unknown commissioning_claim_id: {commissioning_claim_id}")
642
+ commissioning = claims[commissioning_claim_id]
643
+ if (
644
+ commissioning.kind != ClaimKind.INSTRUMENT
645
+ or commissioning.relation != ClaimRelation.INSTRUMENT_OF
646
+ or commissioning.parent_id != scientific_claim.id
647
+ ):
648
+ raise ValueError(
649
+ "commissioning claim must be kind=instrument, relation=instrument_of, "
650
+ f"and parent_id={scientific_claim.id}"
651
+ )
652
+ if commissioning.status != ClaimDisposition.SUPPORTED:
653
+ raise ValueError(
654
+ f"commissioning claim {commissioning_claim_id} is not supported"
655
+ )
656
+ if not commissioning.evidence_contracts or not any(
657
+ contract.validation_checks for contract in commissioning.evidence_contracts
658
+ ):
659
+ raise ValueError(
660
+ f"commissioning claim {commissioning_claim_id} requires prospective "
661
+ "machine-checkable validation_checks"
662
+ )
663
+ if scientific_provenance.program_sha256 is None:
664
+ raise ValueError(
665
+ "scientific capability evidence requires a stable workspace program "
666
+ "whose source hash can be matched to commissioning"
667
+ )
668
+ generated_iteration = scientific_provenance.generated_iteration
669
+ if generated_iteration is None or commissioning.updated_iteration >= generated_iteration:
670
+ raise ValueError(
671
+ f"commissioning claim {commissioning_claim_id} must be supported before "
672
+ "the scientific artifact is generated"
673
+ )
674
+ contracts = {
675
+ contract.version: contract for contract in commissioning.evidence_contracts
676
+ }
677
+ qualifying: list[ClaimEvidenceLink] = []
678
+ observed_aspects: set[CommissioningAspect] = set()
679
+ complete_same_capability = False
680
+ complete_same_source = False
681
+ complete_binding_present = False
682
+ for evidence in self._qualifying_contract_evidence(commissioning):
683
+ contract = contracts.get(evidence.contract_version)
684
+ if (
685
+ contract is None
686
+ or not contract.validation_checks
687
+ or evidence.validation_passed is not True
688
+ ):
689
+ continue
690
+ if scientific_provenance.capability is not None and (
691
+ evidence.provenance is None
692
+ or evidence.provenance.capability != scientific_provenance.capability
693
+ ):
694
+ continue
695
+ contract_aspects = {
696
+ check.aspect
697
+ for check in contract.validation_checks
698
+ if check.aspect is not None
699
+ }
700
+ observed_aspects.update(contract_aspects)
701
+ if contract_aspects >= REQUIRED_SCIENTIFIC_COMMISSIONING_ASPECTS:
702
+ complete_same_capability = True
703
+ if (
704
+ evidence.provenance is not None
705
+ and evidence.provenance.program_sha256
706
+ == scientific_provenance.program_sha256
707
+ ):
708
+ complete_same_source = True
709
+ binding = contract.execution_binding
710
+ if (
711
+ binding is None
712
+ or not binding.allowed_scientific_argv
713
+ ):
714
+ continue
715
+ complete_binding_present = True
716
+ if (
717
+ scientific_provenance.capability != binding.capability
718
+ or scientific_provenance.program_path
719
+ != binding.program_path
720
+ or (
721
+ binding.program_sha256 is not None
722
+ and scientific_provenance.program_sha256
723
+ != binding.program_sha256
724
+ )
725
+ or scientific_provenance.command_argv
726
+ not in binding.allowed_scientific_argv
727
+ ):
728
+ continue
729
+ qualifying.append(evidence)
730
+ if not qualifying:
731
+ missing_aspects = sorted(
732
+ aspect.value
733
+ for aspect in REQUIRED_SCIENTIFIC_COMMISSIONING_ASPECTS - observed_aspects
734
+ )
735
+ if missing_aspects:
736
+ raise ValueError(
737
+ f"commissioning claim {commissioning_claim_id} is missing required "
738
+ f"machine-checked aspects {missing_aspects}; one qualifying evidence "
739
+ "contract must cover representation, physics_controls, boundaries, "
740
+ "diagnostics, and numerical_regime"
741
+ )
742
+ if complete_same_capability:
743
+ if not complete_same_source:
744
+ raise ValueError(
745
+ f"commissioning claim {commissioning_claim_id} has complete "
746
+ "machine-checked evidence for the capability but not for the "
747
+ "same program source; parameterize and reuse the commissioned "
748
+ "program or recommission the changed source"
749
+ )
750
+ if not complete_binding_present:
751
+ raise ValueError(
752
+ f"commissioning claim {commissioning_claim_id} requires a "
753
+ "prospective execution_binding with at least one exact "
754
+ "allowed_scientific_argv command"
755
+ )
756
+ raise ValueError(
757
+ f"scientific command {list(scientific_provenance.command_argv)!r} "
758
+ f"is outside commissioning claim {commissioning_claim_id}'s "
759
+ "prospectively allowed scientific argv"
760
+ )
761
+ raise ValueError(
762
+ f"commissioning claim {commissioning_claim_id} has no qualifying "
763
+ "complete machine-checked evidence from the scientific capability"
764
+ )
765
+ return commissioning_claim_id
766
+
767
+ @staticmethod
768
+ def _matching_execution_binding(
769
+ contract: ClaimEvidenceContract,
770
+ *,
771
+ capability: str | None,
772
+ program_path: str | None,
773
+ program_sha256: str | None,
774
+ argv: tuple[str, ...],
775
+ instrument: bool,
776
+ ) -> tuple[int, ClaimExecutionBinding] | None:
777
+ """Resolve one exact command within a prospectively declared pipeline."""
778
+
779
+ for index, binding in enumerate(contract.all_execution_bindings()):
780
+ if (
781
+ capability == binding.capability
782
+ and program_path == binding.program_path
783
+ and binding.allows_evidence_argv(argv, instrument=instrument)
784
+ and (
785
+ binding.program_sha256 is None
786
+ or binding.program_sha256 == program_sha256
787
+ )
788
+ ):
789
+ return index, binding
790
+ return None
791
+
792
+ def _seal_scientific_execution_binding(
793
+ self,
794
+ *,
795
+ claim: MVPClaim,
796
+ binding_index: int,
797
+ program_sha256: str,
798
+ iteration: int,
799
+ ) -> MVPClaim:
800
+ """Persist the first runner-attested hash for one scientific pipeline stage."""
801
+
802
+ contract = claim.evidence_contracts[-1]
803
+ bindings = list(contract.all_execution_bindings())
804
+ binding = bindings[binding_index]
805
+ if binding.program_sha256 is not None:
806
+ return claim
807
+ bindings[binding_index] = binding.model_copy(
808
+ update={"program_sha256": program_sha256}
809
+ )
810
+ updated_contract = contract.model_copy(
811
+ update={
812
+ "execution_binding": bindings[0],
813
+ "additional_execution_bindings": tuple(bindings[1:]),
814
+ }
815
+ )
816
+ updated_claim = claim.model_copy(
817
+ update={
818
+ "evidence_contracts": (
819
+ *claim.evidence_contracts[:-1],
820
+ updated_contract,
821
+ ),
822
+ "updated_iteration": iteration,
823
+ }
824
+ )
825
+ self._replace_claim(updated_claim)
826
+ return updated_claim
827
+
828
+ def validate_capability_execution(
829
+ self,
830
+ *,
831
+ claim_id: str,
832
+ capability: str,
833
+ argv: tuple[str, ...],
834
+ program_sha256: str | None,
835
+ iteration: int,
836
+ ) -> str | None:
837
+ """Validate stage ordering before a capability can create side effects."""
838
+ claim_id = claim_id.strip().casefold()
839
+ claims = self._ledger.by_id()
840
+ if claim_id not in claims:
841
+ raise ValueError(f"unknown active_claim_id: {claim_id}")
842
+ claim = claims[claim_id]
843
+ if claim.status != ClaimDisposition.OPEN:
844
+ raise ValueError(
845
+ f"active_claim_id {claim_id} is not open ({claim.status.value})"
846
+ )
847
+ if not claim.evidence_contracts:
848
+ raise ValueError(
849
+ f"cannot execute capability {capability!r} for claim {claim_id}: "
850
+ "register a prospective evidence contract on the active claim before "
851
+ "capability execution"
852
+ )
853
+ if claim.evidence_contracts[-1].registered_iteration >= iteration:
854
+ raise ValueError(
855
+ f"cannot execute capability {capability!r} for claim {claim_id}: "
856
+ "the active evidence contract must be registered in an earlier turn"
857
+ )
858
+ active_contract = claim.evidence_contracts[-1]
859
+ active_binding = active_contract.execution_binding
860
+ if (
861
+ claim.kind == ClaimKind.INSTRUMENT
862
+ and active_binding is not None
863
+ and (
864
+ active_binding.capability != capability
865
+ or active_binding.commissioning_argv != argv
866
+ )
867
+ ):
868
+ raise ValueError(
869
+ f"capability execution for bound instrument claim {claim_id} "
870
+ "must exactly match execution_binding.capability and "
871
+ "commissioning_argv; use a separate prospective interface claim "
872
+ "for scouting probes"
873
+ )
874
+ if claim.kind == ClaimKind.INSTRUMENT and active_binding is not None:
875
+ if program_sha256 is None:
876
+ raise ValueError(
877
+ f"capability execution for bound instrument claim {claim_id} "
878
+ "requires a stable workspace program source"
879
+ )
880
+ if active_binding.program_sha256 is None:
881
+ active_binding = active_binding.model_copy(
882
+ update={"program_sha256": program_sha256}
883
+ )
884
+ active_contract = claim.evidence_contracts[-1].model_copy(
885
+ update={"execution_binding": active_binding}
886
+ )
887
+ claim = claim.model_copy(
888
+ update={
889
+ "evidence_contracts": (
890
+ *claim.evidence_contracts[:-1],
891
+ active_contract,
892
+ ),
893
+ "updated_iteration": iteration,
894
+ }
895
+ )
896
+ self._replace_claim(claim)
897
+ claims = self._ledger.by_id()
898
+ elif active_binding.program_sha256 != program_sha256:
899
+ raise ValueError(
900
+ f"capability execution for bound instrument claim {claim_id} "
901
+ "does not match its sealed program source; register a new "
902
+ "prospective contract and recommission the changed source"
903
+ )
904
+ if (
905
+ claim.kind == ClaimKind.INSTRUMENT
906
+ and claim.relation == ClaimRelation.INSTRUMENT_OF
907
+ ):
908
+ prior_interface_claims: list[str] = []
909
+ for sibling in claims.values():
910
+ if (
911
+ sibling.id == claim.id
912
+ or sibling.kind != ClaimKind.INSTRUMENT
913
+ or sibling.relation != ClaimRelation.INSTRUMENT_OF
914
+ or sibling.parent_id != claim.parent_id
915
+ or sibling.status != ClaimDisposition.SUPPORTED
916
+ ):
917
+ continue
918
+ contracts = {
919
+ contract.version: contract
920
+ for contract in sibling.evidence_contracts
921
+ }
922
+ for evidence in self._qualifying_contract_evidence(sibling):
923
+ contract = contracts.get(evidence.contract_version)
924
+ provenance = evidence.provenance
925
+ if contract is None or provenance is None:
926
+ continue
927
+ aspects = {
928
+ check.aspect
929
+ for check in contract.validation_checks
930
+ if check.aspect is not None
931
+ }
932
+ if (
933
+ provenance.capability == capability
934
+ and aspects
935
+ and aspects <= {CommissioningAspect.INTERFACE}
936
+ ):
937
+ prior_interface_claims.append(sibling.id)
938
+ break
939
+ active_aspects = {
940
+ check.aspect
941
+ for check in claim.evidence_contracts[-1].validation_checks
942
+ if check.aspect is not None
943
+ }
944
+ if (
945
+ prior_interface_claims
946
+ and not active_aspects >= REQUIRED_SCIENTIFIC_COMMISSIONING_ASPECTS
947
+ ):
948
+ raise ValueError(
949
+ f"cannot execute another incomplete instrument_of stage for "
950
+ f"capability {capability!r} and parent {claim.parent_id}: supported "
951
+ f"interface discovery already exists in {sorted(prior_interface_claims)}; "
952
+ "the active contract must now cover representation, "
953
+ "physics_controls, boundaries, diagnostics, and "
954
+ "numerical_regime together"
955
+ )
956
+ if claim.kind != ClaimKind.SCIENTIFIC:
957
+ return None
958
+ if program_sha256 is None:
959
+ raise ValueError(
960
+ f"cannot execute capability {capability!r} for scientific claim "
961
+ f"{claim_id}: use a stable workspace program that can be matched to "
962
+ "commissioning"
963
+ )
964
+ matched_binding = self._matching_execution_binding(
965
+ active_contract,
966
+ capability=capability,
967
+ program_path=argv[0],
968
+ program_sha256=program_sha256,
969
+ argv=argv,
970
+ instrument=False,
971
+ )
972
+ if active_contract.all_execution_bindings() and matched_binding is None:
973
+ raise ValueError(
974
+ f"capability execution for scientific claim {claim_id} does not "
975
+ "match an allowed_scientific_argv command in its active prospective "
976
+ "execution_binding pipeline"
977
+ )
978
+ if matched_binding is not None:
979
+ binding_index, binding = matched_binding
980
+ if binding.program_sha256 is None:
981
+ claim = self._seal_scientific_execution_binding(
982
+ claim=claim,
983
+ binding_index=binding_index,
984
+ program_sha256=program_sha256,
985
+ iteration=iteration,
986
+ )
987
+
988
+ synthetic_provenance = ClaimEvidenceProvenance(
989
+ sha256="0" * 64,
990
+ bytes=0,
991
+ tracked=True,
992
+ generated_iteration=iteration,
993
+ action="capability_execution_preflight",
994
+ command_argv=argv,
995
+ capability=capability,
996
+ program_path=argv[0],
997
+ program_sha256=program_sha256,
998
+ )
999
+ candidates = sorted(
1000
+ child.id
1001
+ for child in claims.values()
1002
+ if child.kind == ClaimKind.INSTRUMENT
1003
+ and child.relation == ClaimRelation.INSTRUMENT_OF
1004
+ and child.parent_id == claim_id
1005
+ and child.status == ClaimDisposition.SUPPORTED
1006
+ )
1007
+ for candidate in candidates:
1008
+ try:
1009
+ return self._validate_commissioning_claim(
1010
+ scientific_claim=claim,
1011
+ commissioning_claim_id=candidate,
1012
+ scientific_provenance=synthetic_provenance,
1013
+ )
1014
+ except ValueError:
1015
+ continue
1016
+ raise ValueError(
1017
+ f"cannot execute capability {capability!r} for scientific claim {claim_id}: "
1018
+ "require a supported machine-checked instrument_of claim from the same "
1019
+ "capability, closed before this execution, with one contract covering "
1020
+ "representation, physics_controls, boundaries, diagnostics, and "
1021
+ "numerical_regime for the "
1022
+ "same program source and a prospective execution_binding that allows "
1023
+ "this exact scientific argv; bind scouting and commissioning runs to "
1024
+ "an instrument claim"
1025
+ )
1026
+
1027
+ def link_evidence(
1028
+ self,
1029
+ *,
1030
+ claim_id: str,
1031
+ path: str,
1032
+ note: str,
1033
+ observation_sufficient: bool,
1034
+ observation_note: str,
1035
+ provenance: ClaimEvidenceProvenance,
1036
+ commissioning_claim_id: str | None = None,
1037
+ evidence_document: Any | None = None,
1038
+ evidence_document_error: str | None = None,
1039
+ iteration: int,
1040
+ ) -> dict[str, Any]:
1041
+ claim_id = claim_id.strip().casefold()
1042
+ existing = self._ledger.by_id()
1043
+ if claim_id not in existing:
1044
+ raise ValueError(f"unknown claim_id: {claim_id}")
1045
+ claim = existing[claim_id]
1046
+ if claim.status != ClaimDisposition.OPEN:
1047
+ raise ValueError(f"claim is already closed: {claim_id}")
1048
+ contract = claim.evidence_contracts[-1] if claim.evidence_contracts else None
1049
+ contract_version = contract.version if contract is not None else None
1050
+ validation_passed, validation_results = self._evaluate_validation_checks(
1051
+ contract.validation_checks if contract is not None else (),
1052
+ evidence_document=evidence_document,
1053
+ evidence_document_error=evidence_document_error,
1054
+ )
1055
+ if observation_sufficient:
1056
+ if contract is None:
1057
+ raise ValueError(
1058
+ f"cannot mark evidence sufficient for {claim_id} without a "
1059
+ "prospective evidence contract"
1060
+ )
1061
+ if not provenance.evidence_eligible:
1062
+ raise ValueError(
1063
+ "cannot mark a workbench artifact sufficient; freeze and promote "
1064
+ "the program through prospective commissioning, then generate a "
1065
+ "fresh evidence-stage artifact"
1066
+ )
1067
+ if (
1068
+ not provenance.tracked
1069
+ or provenance.generated_iteration is None
1070
+ or provenance.generated_iteration < contract.registered_iteration
1071
+ ):
1072
+ raise ValueError(
1073
+ "cannot mark evidence sufficient unless it is provenance-tracked "
1074
+ "and generated after the active contract"
1075
+ )
1076
+ if contract.validation_checks and validation_passed is not True:
1077
+ failures = [
1078
+ result.json_path
1079
+ for result in validation_results
1080
+ if not result.passed
1081
+ ]
1082
+ raise ValueError(
1083
+ "cannot mark evidence sufficient: machine-checkable validation "
1084
+ f"failed for {failures}"
1085
+ )
1086
+ bindings = contract.all_execution_bindings()
1087
+ if bindings and self._matching_execution_binding(
1088
+ contract,
1089
+ capability=provenance.capability,
1090
+ program_path=provenance.program_path,
1091
+ program_sha256=provenance.program_sha256,
1092
+ argv=provenance.command_argv,
1093
+ instrument=claim.kind == ClaimKind.INSTRUMENT,
1094
+ ) is None:
1095
+ raise ValueError(
1096
+ "cannot mark evidence sufficient: artifact provenance does not "
1097
+ "match a prospectively authorized evidence command in the "
1098
+ "execution_binding pipeline"
1099
+ )
1100
+ if (
1101
+ claim.kind == ClaimKind.INSTRUMENT
1102
+ and claim.relation == ClaimRelation.INSTRUMENT_OF
1103
+ and provenance.capability is not None
1104
+ and provenance.execution_succeeded is not True
1105
+ ):
1106
+ raise ValueError(
1107
+ "cannot mark commissioning evidence sufficient unless the "
1108
+ "runner witnessed a successful capability execution (return code "
1109
+ "zero, no timeout, and no workspace-limit termination)"
1110
+ )
1111
+ if claim.kind == ClaimKind.SCIENTIFIC and provenance.capability is not None:
1112
+ commissioning_claim_id = self._validate_commissioning_claim(
1113
+ scientific_claim=claim,
1114
+ commissioning_claim_id=commissioning_claim_id,
1115
+ scientific_provenance=provenance,
1116
+ )
1117
+ link = ClaimEvidenceLink(
1118
+ path=path,
1119
+ note=note,
1120
+ iteration=iteration,
1121
+ contract_version=contract_version,
1122
+ observation_sufficient=observation_sufficient,
1123
+ observation_note=observation_note,
1124
+ validation_passed=validation_passed,
1125
+ validation_results=validation_results,
1126
+ commissioning_claim_id=commissioning_claim_id,
1127
+ provenance=provenance,
1128
+ )
1129
+ updated = MVPClaim(
1130
+ id=claim.id,
1131
+ statement=claim.statement,
1132
+ kind=claim.kind,
1133
+ relation=claim.relation,
1134
+ parent_id=claim.parent_id,
1135
+ status=claim.status,
1136
+ rationale=claim.rationale,
1137
+ evidence_contracts=claim.evidence_contracts,
1138
+ evidence=claim.evidence + (link,),
1139
+ closed_reason=claim.closed_reason,
1140
+ created_iteration=claim.created_iteration,
1141
+ updated_iteration=iteration,
1142
+ )
1143
+ ledger = self._replace_claim(updated)
1144
+ return {
1145
+ "updated": updated.model_dump(mode="json"),
1146
+ "artifact_exists": True,
1147
+ "artifact_provenance": provenance.model_dump(mode="json"),
1148
+ "claim_ledger": ledger.compact_summary(),
1149
+ }
1150
+
1151
+ def close(
1152
+ self,
1153
+ *,
1154
+ claim_id: str,
1155
+ status: ClaimDisposition,
1156
+ reason: str,
1157
+ iteration: int,
1158
+ ) -> dict[str, Any]:
1159
+ claim_id = claim_id.strip().casefold()
1160
+ if status == ClaimDisposition.OPEN:
1161
+ raise ValueError("close_claim requires a non-open disposition")
1162
+ existing = self._ledger.by_id()
1163
+ if claim_id not in existing:
1164
+ raise ValueError(f"unknown claim_id: {claim_id}")
1165
+ claim = existing[claim_id]
1166
+ if claim.status != ClaimDisposition.OPEN:
1167
+ raise ValueError(f"claim is already closed: {claim_id}")
1168
+ if status in {ClaimDisposition.SUPPORTED, ClaimDisposition.FALSIFIED}:
1169
+ if not claim.evidence_contracts:
1170
+ raise ValueError(
1171
+ f"cannot close {claim_id} as {status.value} without a registered "
1172
+ "evidence contract"
1173
+ )
1174
+ active_contract = claim.evidence_contracts[-1]
1175
+ if (
1176
+ status == ClaimDisposition.SUPPORTED
1177
+ and claim.kind == ClaimKind.INSTRUMENT
1178
+ and claim.relation == ClaimRelation.INSTRUMENT_OF
1179
+ and active_contract.execution_binding is not None
1180
+ and active_contract.execution_binding.allowed_scientific_argv
1181
+ ):
1182
+ active_aspects = {
1183
+ check.aspect
1184
+ for check in active_contract.validation_checks
1185
+ if check.aspect is not None
1186
+ }
1187
+ missing_aspects = sorted(
1188
+ aspect.value
1189
+ for aspect in (
1190
+ REQUIRED_SCIENTIFIC_COMMISSIONING_ASPECTS
1191
+ - active_aspects
1192
+ )
1193
+ )
1194
+ if missing_aspects:
1195
+ raise ValueError(
1196
+ f"cannot close capability-bound instrument claim {claim_id} "
1197
+ "as supported: its active contract is intended to authorize "
1198
+ "scientific execution but is missing required machine-checked "
1199
+ f"aspects {missing_aspects}; one contract must cover "
1200
+ "representation, physics_controls, boundaries, diagnostics, "
1201
+ "and numerical_regime"
1202
+ )
1203
+ active_contract_version = claim.evidence_contracts[-1].version
1204
+ prior_contract_evidence = [
1205
+ evidence
1206
+ for evidence in claim.evidence
1207
+ if evidence.contract_version is not None
1208
+ and evidence.contract_version < active_contract_version
1209
+ ]
1210
+ if prior_contract_evidence:
1211
+ raise ValueError(
1212
+ f"cannot close {claim_id} as {status.value} under an adaptive "
1213
+ "contract registered after evidence was already linked; close "
1214
+ "non-decisively or register a successor/refinement claim with a "
1215
+ "prospective pipeline and collect fresh observations"
1216
+ )
1217
+ qualifying_evidence = list(self._qualifying_contract_evidence(claim))
1218
+ if claim.kind == ClaimKind.SCIENTIFIC:
1219
+ commissioned: list[ClaimEvidenceLink] = []
1220
+ for evidence in qualifying_evidence:
1221
+ provenance = evidence.provenance
1222
+ assert provenance is not None
1223
+ if provenance.capability is None:
1224
+ commissioned.append(evidence)
1225
+ continue
1226
+ try:
1227
+ self._validate_commissioning_claim(
1228
+ scientific_claim=claim,
1229
+ commissioning_claim_id=evidence.commissioning_claim_id,
1230
+ scientific_provenance=provenance,
1231
+ )
1232
+ except ValueError:
1233
+ continue
1234
+ commissioned.append(evidence)
1235
+ qualifying_evidence = commissioned
1236
+ if not qualifying_evidence:
1237
+ raise ValueError(
1238
+ f"cannot close {claim_id} as {status.value}: require linked, "
1239
+ "provenance-tracked evidence generated under an evidence contract "
1240
+ "and marked observation_sufficient=true; capability-generated "
1241
+ "scientific evidence also requires prior machine-checked commissioning"
1242
+ )
1243
+ updated = MVPClaim(
1244
+ id=claim.id,
1245
+ statement=claim.statement,
1246
+ kind=claim.kind,
1247
+ relation=claim.relation,
1248
+ parent_id=claim.parent_id,
1249
+ status=status,
1250
+ rationale=claim.rationale,
1251
+ evidence_contracts=claim.evidence_contracts,
1252
+ evidence=claim.evidence,
1253
+ closed_reason=reason,
1254
+ created_iteration=claim.created_iteration,
1255
+ updated_iteration=iteration,
1256
+ )
1257
+ ledger = self._replace_claim(updated)
1258
+ result: dict[str, Any] = {
1259
+ "closed": updated.model_dump(mode="json"),
1260
+ "claim_ledger": ledger.compact_summary(),
1261
+ }
1262
+ # Non-evidentiary dispositions remain available so a bounded campaign can
1263
+ # finish honestly even when no decisive artifact was produced.
1264
+ if not claim.evidence and claim_id != ROOT_CLAIM_ID:
1265
+ result["evidence_warning"] = (
1266
+ f"closed {claim_id} with status={status.value} and no linked evidence; "
1267
+ "prefer link_claim_evidence before close_claim when workspace "
1268
+ "artifacts exist"
1269
+ )
1270
+ return result
1271
+
1272
+ def list_claims(self) -> dict[str, Any]:
1273
+ return {
1274
+ "claim_ledger": self._ledger.compact_summary(max_claims=200),
1275
+ "claims": [claim.model_dump(mode="json") for claim in self._ledger.claims],
1276
+ }
1277
+
1278
+ def snapshot(self) -> dict[str, Any]:
1279
+ return json.loads(self._ledger.model_dump_json())