test_phase_two_inputs_validation.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. from dataclasses import replace
  5. from datetime import UTC, datetime
  6. from types import SimpleNamespace
  7. import pytest
  8. from agent.orchestration import (
  9. AcceptanceCriterion,
  10. ArtifactRef,
  11. ArtifactSnapshot,
  12. AttemptStatus,
  13. DecisionAction,
  14. PlannerDecision,
  15. TaskAttempt,
  16. TaskLedger,
  17. TaskRecord,
  18. TaskSpec,
  19. TaskStatus,
  20. ValidationReport,
  21. ValidationRunStatus,
  22. ValidationVerdict,
  23. )
  24. from script_build_host.agents.validation import (
  25. ScriptBuildValidationPolicy,
  26. precheck_business_artifact,
  27. validate_phase_two_defects,
  28. validation_layer,
  29. )
  30. from script_build_host.application.phase_two_inputs import (
  31. AcceptedInputResolver,
  32. ActiveFrontierResolver,
  33. PhaseTwoInputError,
  34. )
  35. from script_build_host.domain.artifacts import (
  36. ArtifactKind,
  37. ArtifactState,
  38. ArtifactVersion,
  39. EvidenceRecordV1,
  40. )
  41. from script_build_host.domain.legacy_input_compatibility import (
  42. AcceptedInputCompatibilityError,
  43. AcceptedInputSource,
  44. validate_accepted_input,
  45. )
  46. from script_build_host.domain.phase_two_artifacts import (
  47. CandidateLineageV1,
  48. ParagraphArtifactV1,
  49. ScriptParagraphV1,
  50. )
  51. from script_build_host.domain.task_contracts import (
  52. AcceptedDecisionRef,
  53. AcceptedInput,
  54. AcceptedInputBundleV1,
  55. ScriptCriterion,
  56. ScriptIntentClass,
  57. ScriptTaskBudget,
  58. ScriptTaskContractV1,
  59. ScriptTaskKind,
  60. )
  61. from script_build_host.infrastructure.canonical_json import canonical_sha256
  62. DIGEST = "sha256:" + "a" * 64
  63. SNAPSHOT_REF = "script-build://inputs/11"
  64. SCOPE = "script-build://scopes/opening"
  65. WRITE = "script-build://writes/paragraphs/1"
  66. def test_candidate_portfolio_can_pin_the_active_direction_explicitly() -> None:
  67. validate_accepted_input(
  68. consumer_kind=ScriptTaskKind.CANDIDATE_PORTFOLIO,
  69. consumer_scope="script-build://scopes/full",
  70. producer_kind=ScriptTaskKind.DIRECTION,
  71. producer_scope="script-build://scopes/full",
  72. source=AcceptedInputSource.EXPLICIT,
  73. )
  74. def test_legacy_input_rules_do_not_infer_branch_identity_from_siblings() -> None:
  75. with pytest.raises(AcceptedInputCompatibilityError, match="outside the consumer scope"):
  76. validate_accepted_input(
  77. consumer_kind=ScriptTaskKind.PARAGRAPH,
  78. consumer_scope="script-build://scopes/direction/portfolio/compose/paragraph",
  79. producer_kind=ScriptTaskKind.STRUCTURE,
  80. producer_scope="script-build://scopes/direction/portfolio/compose/structure",
  81. source=AcceptedInputSource.EXPLICIT,
  82. )
  83. def test_explicit_creative_inputs_cannot_cross_candidate_branches() -> None:
  84. with pytest.raises(AcceptedInputCompatibilityError, match="outside the consumer scope"):
  85. validate_accepted_input(
  86. consumer_kind=ScriptTaskKind.PARAGRAPH,
  87. consumer_scope="script-build://scopes/direction/portfolio/compose-a/paragraph",
  88. producer_kind=ScriptTaskKind.STRUCTURE,
  89. producer_scope="script-build://scopes/direction/portfolio/compose-b/structure",
  90. source=AcceptedInputSource.EXPLICIT,
  91. )
  92. def _contract(
  93. *,
  94. kind: ScriptTaskKind = ScriptTaskKind.PARAGRAPH,
  95. scope: str = SCOPE,
  96. write_scope: tuple[str, ...] = (WRITE,),
  97. supersedes: tuple[str, ...] = (),
  98. ) -> ScriptTaskContractV1:
  99. schemas = {
  100. ScriptTaskKind.PARAGRAPH: "paragraph-artifact/v1",
  101. ScriptTaskKind.ELEMENT_SET: "element-set-artifact/v1",
  102. ScriptTaskKind.COMPARE: "comparison-artifact/v1",
  103. ScriptTaskKind.DECODE_RETRIEVAL: "evidence-record/v1",
  104. }
  105. return ScriptTaskContractV1(
  106. task_kind=kind,
  107. scope_ref=scope,
  108. intent_class=ScriptIntentClass.EXPLORE,
  109. objective="produce one independently verifiable increment",
  110. input_decision_refs=(),
  111. base_artifact_ref=None,
  112. write_scope=write_scope,
  113. gap_ref=None,
  114. output_schema=schemas[kind],
  115. criteria=(ScriptCriterion("closed", "candidate is concretely realized"),),
  116. budget=ScriptTaskBudget(),
  117. goal_ids=("goal-1",),
  118. supersedes_decision_ids=supersedes,
  119. )
  120. def _paragraph_artifact(*, text: str = "specific opening") -> ParagraphArtifactV1:
  121. return ParagraphArtifactV1(
  122. lineage=CandidateLineageV1(
  123. scope_ref=SCOPE,
  124. input_snapshot_ref=SNAPSHOT_REF,
  125. input_closure_digest=DIGEST,
  126. write_scope=(WRITE,),
  127. goal_ids=("goal-1",),
  128. ),
  129. paragraphs=(
  130. ScriptParagraphV1(
  131. paragraph_id=1,
  132. paragraph_index=1,
  133. level=1,
  134. parent_id=None,
  135. name="opening",
  136. content_range={},
  137. description=text,
  138. ),
  139. ),
  140. )
  141. def _task(task_id: str, *, parent: str | None, kind: str, status: TaskStatus) -> TaskRecord:
  142. return TaskRecord(
  143. task_id=task_id,
  144. parent_task_id=parent,
  145. display_path=task_id,
  146. specs=[
  147. TaskSpec(
  148. version=1,
  149. objective="bounded increment",
  150. acceptance_criteria=(AcceptanceCriterion("closed", "closed"),),
  151. context_refs=(
  152. f"script-build://task-kinds/{kind}",
  153. SNAPSHOT_REF,
  154. ),
  155. )
  156. ],
  157. status=status,
  158. )
  159. class _TaskStore:
  160. def __init__(self, ledger: TaskLedger) -> None:
  161. self.ledger = ledger
  162. async def load(self, root_trace_id: str) -> TaskLedger:
  163. assert root_trace_id == self.ledger.root_trace_id
  164. return self.ledger
  165. class _SnapshotStore:
  166. def __init__(self, snapshot: ArtifactSnapshot) -> None:
  167. self.snapshot = snapshot
  168. async def get(self, root_trace_id: str, snapshot_id: str) -> ArtifactSnapshot:
  169. assert root_trace_id == "root"
  170. assert snapshot_id == self.snapshot.snapshot_id
  171. return self.snapshot
  172. class _Artifacts:
  173. def __init__(self, version: ArtifactVersion) -> None:
  174. self.version = version
  175. async def read_by_ref(self, ref: ArtifactRef, **owners: object) -> ArtifactVersion:
  176. assert ref.digest == self.version.canonical_sha256
  177. assert owners == {
  178. "script_build_id": 7,
  179. "task_id": "child",
  180. "attempt_id": "attempt-child",
  181. }
  182. return self.version
  183. class _Contracts:
  184. def __init__(self, values: dict[str, ScriptTaskContractV1]) -> None:
  185. self.values = values
  186. async def read_for_task(
  187. self, *, root_trace_id: str, task: TaskRecord, spec_version: int | None = None
  188. ) -> ScriptTaskContractV1:
  189. assert root_trace_id == "root"
  190. assert spec_version == 1
  191. return self.values[task.task_id]
  192. def _closed_fixture() -> tuple[AcceptedInputResolver, TaskLedger, TaskAttempt]:
  193. root = _task("root-task", parent=None, kind="root", status=TaskStatus.NEEDS_REPLAN)
  194. parent = _task("parent", parent="root-task", kind="compose", status=TaskStatus.RUNNING)
  195. child = _task("child", parent="parent", kind="paragraph", status=TaskStatus.COMPLETED)
  196. parent.child_task_ids.append("child")
  197. root.child_task_ids.append("parent")
  198. ref = ArtifactRef(
  199. uri="script-build://artifact-versions/1",
  200. kind=ArtifactKind.PARAGRAPH.value,
  201. version="1",
  202. digest=DIGEST,
  203. )
  204. child_attempt = TaskAttempt(
  205. attempt_id="attempt-child",
  206. task_id="child",
  207. spec_version=1,
  208. worker_trace_id="worker",
  209. worker_preset="script_paragraph_worker",
  210. execution_mode="new",
  211. accepted_child_decision_ids=(),
  212. status=AttemptStatus.SUBMITTED,
  213. snapshot_id="artifact-snapshot",
  214. submission=SimpleNamespace(artifact_refs=[ref], evidence_refs=[]),
  215. )
  216. validation = ValidationReport(
  217. validation_id="validation-child",
  218. task_id="child",
  219. attempt_id="attempt-child",
  220. spec_version=1,
  221. snapshot_id="artifact-snapshot",
  222. validator_trace_id="validator",
  223. status=ValidationRunStatus.COMPLETED,
  224. verdict=ValidationVerdict.PASSED,
  225. )
  226. decision = PlannerDecision(
  227. decision_id="decision-child",
  228. task_id="child",
  229. action=DecisionAction.ACCEPT,
  230. reason="passed",
  231. from_status=TaskStatus.AWAITING_DECISION,
  232. to_status=TaskStatus.COMPLETED,
  233. attempt_id="attempt-child",
  234. validation_id="validation-child",
  235. )
  236. child.attempt_ids.append(child_attempt.attempt_id)
  237. child.validation_ids.append(validation.validation_id)
  238. child.decision_ids.append(decision.decision_id)
  239. parent_attempt = TaskAttempt(
  240. attempt_id="attempt-parent",
  241. task_id="parent",
  242. spec_version=1,
  243. worker_trace_id="compose-worker",
  244. worker_preset="script_compose_worker",
  245. execution_mode="new",
  246. accepted_child_decision_ids=(decision.decision_id,),
  247. )
  248. ledger = TaskLedger(
  249. root_trace_id="root",
  250. mission="mission",
  251. root_task_id="root-task",
  252. tasks={item.task_id: item for item in (root, parent, child)},
  253. attempts={
  254. child_attempt.attempt_id: child_attempt,
  255. parent_attempt.attempt_id: parent_attempt,
  256. },
  257. validations={validation.validation_id: validation},
  258. decisions={decision.decision_id: decision},
  259. )
  260. normalized = {
  261. "summary": "bounded",
  262. "artifact_refs": [
  263. {
  264. "uri": ref.uri,
  265. "kind": ref.kind,
  266. "version": ref.version,
  267. "digest": ref.digest,
  268. "summary": ref.summary,
  269. "metadata": ref.metadata,
  270. }
  271. ],
  272. "evidence_refs": [],
  273. }
  274. snapshot = ArtifactSnapshot(
  275. snapshot_id="artifact-snapshot",
  276. attempt_id=child_attempt.attempt_id,
  277. normalized_content=normalized,
  278. sha256=_snapshot_sha(normalized),
  279. artifact_refs=[ref],
  280. evidence_refs=[],
  281. )
  282. artifact = replace(_paragraph_artifact(), canonical_sha256=DIGEST)
  283. version = ArtifactVersion(
  284. artifact_version_id=1,
  285. script_build_id=7,
  286. task_id="child",
  287. attempt_id=child_attempt.attempt_id,
  288. spec_version=1,
  289. artifact_type=ArtifactKind.PARAGRAPH,
  290. canonical_sha256=DIGEST,
  291. state=ArtifactState.FROZEN,
  292. artifact=artifact,
  293. created_at=datetime.now(UTC),
  294. frozen_at=datetime.now(UTC),
  295. )
  296. resolver = AcceptedInputResolver(
  297. task_store=_TaskStore(ledger),
  298. artifact_store=_SnapshotStore(snapshot),
  299. artifacts=_Artifacts(version),
  300. contracts=_Contracts({"child": _contract(), "parent": _contract()}),
  301. )
  302. return resolver, ledger, parent_attempt
  303. @pytest.mark.asyncio
  304. async def test_accepted_input_closes_decision_validation_snapshot_and_artifact() -> None:
  305. resolver, _, attempt = _closed_fixture()
  306. bundle = await resolver.resolve(
  307. root_trace_id="root",
  308. script_build_id=7,
  309. task_id="parent",
  310. attempt_id=attempt.attempt_id,
  311. contract=_contract(),
  312. input_snapshot_id="11",
  313. )
  314. assert [item.decision_id for item in bundle.inputs] == ["decision-child"]
  315. assert bundle.inputs[0].source == "direct-child"
  316. assert bundle.input_closure_digest.startswith("sha256:")
  317. @pytest.mark.asyncio
  318. async def test_legacy_attempt_without_frozen_child_set_fails_closed() -> None:
  319. resolver, ledger, attempt = _closed_fixture()
  320. ledger.attempts[attempt.attempt_id] = replace(attempt, accepted_child_decision_ids=None)
  321. with pytest.raises(PhaseTwoInputError, match="CHILD_DECISION_INVALID"):
  322. await resolver.resolve(
  323. root_trace_id="root",
  324. script_build_id=7,
  325. task_id="parent",
  326. attempt_id=attempt.attempt_id,
  327. contract=_contract(),
  328. input_snapshot_id="11",
  329. )
  330. @pytest.mark.asyncio
  331. async def test_mid_retrieval_replacement_freezes_new_evidence_and_old_candidate() -> None:
  332. resolver, ledger, parent_attempt = _closed_fixture()
  333. old_ref = ledger.attempts["attempt-child"].submission.artifact_refs[0]
  334. old_contract = _contract()
  335. # The old accepted Paragraph remains a completed sibling. The replacement
  336. # consumer gets new Evidence from its own direct Retrieval child and imports
  337. # the immutable old Decision explicitly; it never reads a mutable "latest".
  338. ledger.tasks["child"].parent_task_id = "other-branch"
  339. ledger.tasks["root-task"].child_task_ids = ["child", "parent"]
  340. replacement_task = _task(
  341. "parent", parent="root-task", kind="paragraph", status=TaskStatus.RUNNING
  342. )
  343. replacement_task.child_task_ids = ["retrieval"]
  344. replacement_task.attempt_ids = [parent_attempt.attempt_id]
  345. ledger.tasks["parent"] = replacement_task
  346. retrieval = _task(
  347. "retrieval", parent="parent", kind="decode-retrieval", status=TaskStatus.COMPLETED
  348. )
  349. retrieval_ref = ArtifactRef(
  350. "script-build://artifact-versions/2",
  351. ArtifactKind.EVIDENCE.value,
  352. "2",
  353. DIGEST,
  354. )
  355. retrieval_attempt = TaskAttempt(
  356. attempt_id="attempt-retrieval",
  357. task_id="retrieval",
  358. spec_version=1,
  359. worker_trace_id="retrieval-worker",
  360. worker_preset="script_decode_retrieval_worker",
  361. execution_mode="new",
  362. accepted_child_decision_ids=(),
  363. status=AttemptStatus.SUBMITTED,
  364. snapshot_id="retrieval-snapshot",
  365. submission=SimpleNamespace(artifact_refs=[], evidence_refs=[retrieval_ref]),
  366. )
  367. retrieval_validation = ValidationReport(
  368. validation_id="validation-retrieval",
  369. task_id="retrieval",
  370. attempt_id=retrieval_attempt.attempt_id,
  371. spec_version=1,
  372. snapshot_id="retrieval-snapshot",
  373. validator_trace_id="retrieval-validator",
  374. status=ValidationRunStatus.COMPLETED,
  375. verdict=ValidationVerdict.PASSED,
  376. )
  377. retrieval_decision = PlannerDecision(
  378. decision_id="decision-retrieval",
  379. task_id="retrieval",
  380. action=DecisionAction.ACCEPT,
  381. reason="new evidence passed",
  382. from_status=TaskStatus.AWAITING_DECISION,
  383. to_status=TaskStatus.COMPLETED,
  384. attempt_id=retrieval_attempt.attempt_id,
  385. validation_id=retrieval_validation.validation_id,
  386. )
  387. retrieval.attempt_ids = [retrieval_attempt.attempt_id]
  388. retrieval.validation_ids = [retrieval_validation.validation_id]
  389. retrieval.decision_ids = [retrieval_decision.decision_id]
  390. ledger.tasks[retrieval.task_id] = retrieval
  391. ledger.attempts[retrieval_attempt.attempt_id] = retrieval_attempt
  392. ledger.validations[retrieval_validation.validation_id] = retrieval_validation
  393. ledger.decisions[retrieval_decision.decision_id] = retrieval_decision
  394. ledger.attempts[parent_attempt.attempt_id] = replace(
  395. parent_attempt,
  396. accepted_child_decision_ids=(retrieval_decision.decision_id,),
  397. )
  398. retrieval_normalized = {
  399. "summary": "bounded retrieval evidence",
  400. "artifact_refs": [],
  401. "evidence_refs": [
  402. {
  403. "uri": retrieval_ref.uri,
  404. "kind": retrieval_ref.kind,
  405. "version": retrieval_ref.version,
  406. "digest": retrieval_ref.digest,
  407. "summary": retrieval_ref.summary,
  408. "metadata": retrieval_ref.metadata,
  409. }
  410. ],
  411. }
  412. retrieval_snapshot = ArtifactSnapshot(
  413. snapshot_id="retrieval-snapshot",
  414. attempt_id=retrieval_attempt.attempt_id,
  415. normalized_content=retrieval_normalized,
  416. sha256=_snapshot_sha(retrieval_normalized),
  417. artifact_refs=[],
  418. evidence_refs=[retrieval_ref],
  419. )
  420. evidence = EvidenceRecordV1(
  421. evidence_id="mid-retrieval",
  422. source_type="decode",
  423. tool_name="retrieve_decode",
  424. query={"query": "opening detail", "top_k": 3},
  425. source_refs=("decode-index://fixture/new-evidence",),
  426. raw_artifact_ref=None,
  427. summary="One visible detail contradicts the original opening assumption.",
  428. supports=(SCOPE,),
  429. confidence="high",
  430. limitations=(),
  431. content_sha256=DIGEST,
  432. created_at=datetime.now(UTC),
  433. )
  434. evidence_version = ArtifactVersion(
  435. artifact_version_id=2,
  436. script_build_id=7,
  437. task_id="retrieval",
  438. attempt_id=retrieval_attempt.attempt_id,
  439. spec_version=1,
  440. artifact_type=ArtifactKind.EVIDENCE,
  441. canonical_sha256=DIGEST,
  442. state=ArtifactState.FROZEN,
  443. artifact=evidence,
  444. created_at=datetime.now(UTC),
  445. frozen_at=datetime.now(UTC),
  446. )
  447. old_snapshot = resolver._artifact_store.snapshot
  448. class SnapshotStore:
  449. async def get(self, root_trace_id: str, snapshot_id: str) -> ArtifactSnapshot:
  450. assert root_trace_id == "root"
  451. return {
  452. old_snapshot.snapshot_id: old_snapshot,
  453. retrieval_snapshot.snapshot_id: retrieval_snapshot,
  454. }[snapshot_id]
  455. old_version = resolver._artifacts.version
  456. class Artifacts:
  457. async def read_by_ref(self, ref: ArtifactRef, **owners: object) -> ArtifactVersion:
  458. version = {old_ref.uri: old_version, retrieval_ref.uri: evidence_version}[ref.uri]
  459. assert owners == {
  460. "script_build_id": 7,
  461. "task_id": version.task_id,
  462. "attempt_id": version.attempt_id,
  463. }
  464. return version
  465. replacement_contract = replace(
  466. old_contract,
  467. intent_class=ScriptIntentClass.REPLACE,
  468. input_decision_refs=(
  469. AcceptedDecisionRef(
  470. "decision-child",
  471. old_ref,
  472. SCOPE,
  473. ScriptTaskKind.PARAGRAPH,
  474. ),
  475. ),
  476. supersedes_decision_ids=("decision-child",),
  477. )
  478. resolver._artifact_store = SnapshotStore()
  479. resolver._artifacts = Artifacts()
  480. resolver._contracts.values = {
  481. "child": old_contract,
  482. "retrieval": replace(
  483. _contract(kind=ScriptTaskKind.DECODE_RETRIEVAL),
  484. write_scope=(),
  485. ),
  486. "parent": replacement_contract,
  487. }
  488. bundle = await resolver.resolve(
  489. root_trace_id="root",
  490. script_build_id=7,
  491. task_id="parent",
  492. attempt_id=parent_attempt.attempt_id,
  493. contract=replacement_contract,
  494. input_snapshot_id="11",
  495. )
  496. assert [item.decision_id for item in bundle.inputs] == [
  497. "decision-retrieval",
  498. "decision-child",
  499. ]
  500. assert [item.source for item in bundle.inputs] == ["direct-child", "explicit"]
  501. assert bundle.superseded_decision_ids == ("decision-child",)
  502. assert bundle.input_closure_digest.startswith("sha256:")
  503. @pytest.mark.asyncio
  504. async def test_direct_child_is_revalidated_against_consumer_scope_and_kind() -> None:
  505. resolver, _, attempt = _closed_fixture()
  506. resolver._contracts.values["parent"] = replace(
  507. _contract(), scope_ref="script-build://scopes/ending"
  508. )
  509. with pytest.raises(PhaseTwoInputError, match="INPUT_SCOPE_MISMATCH"):
  510. await resolver.resolve(
  511. root_trace_id="root",
  512. script_build_id=7,
  513. task_id="parent",
  514. attempt_id=attempt.attempt_id,
  515. contract=resolver._contracts.values["parent"],
  516. input_snapshot_id="11",
  517. )
  518. resolver, _, attempt = _closed_fixture()
  519. consumer = replace(
  520. _contract(),
  521. task_kind=ScriptTaskKind.CANDIDATE_PORTFOLIO,
  522. intent_class=ScriptIntentClass.PORTFOLIO,
  523. output_schema="candidate-portfolio/v1",
  524. )
  525. resolver._contracts.values["parent"] = consumer
  526. with pytest.raises(PhaseTwoInputError, match="INPUT_SCOPE_MISMATCH"):
  527. await resolver.resolve(
  528. root_trace_id="root",
  529. script_build_id=7,
  530. task_id="parent",
  531. attempt_id=attempt.attempt_id,
  532. contract=consumer,
  533. input_snapshot_id="11",
  534. )
  535. @pytest.mark.asyncio
  536. async def test_explicit_input_is_revalidated_against_consumer_scope() -> None:
  537. resolver, ledger, attempt = _closed_fixture()
  538. child_ref = ledger.attempts["attempt-child"].submission.artifact_refs[0]
  539. ledger.tasks["child"].parent_task_id = "other-branch"
  540. ledger.tasks["parent"].child_task_ids = []
  541. ledger.attempts[attempt.attempt_id] = replace(
  542. attempt,
  543. accepted_child_decision_ids=(),
  544. )
  545. consumer = replace(
  546. _contract(),
  547. scope_ref="script-build://scopes/ending",
  548. input_decision_refs=(
  549. AcceptedDecisionRef(
  550. "decision-child",
  551. child_ref,
  552. SCOPE,
  553. ScriptTaskKind.PARAGRAPH,
  554. ),
  555. ),
  556. )
  557. resolver._contracts.values["parent"] = consumer
  558. with pytest.raises(PhaseTwoInputError, match="INPUT_SCOPE_MISMATCH"):
  559. await resolver.resolve(
  560. root_trace_id="root",
  561. script_build_id=7,
  562. task_id="parent",
  563. attempt_id=attempt.attempt_id,
  564. contract=consumer,
  565. input_snapshot_id="11",
  566. )
  567. @pytest.mark.asyncio
  568. @pytest.mark.parametrize("mutation", ["verdict", "snapshot", "digest", "not-current"])
  569. async def test_accepted_input_rejects_broken_causal_edges(mutation: str) -> None:
  570. resolver, ledger, attempt = _closed_fixture()
  571. if mutation == "verdict":
  572. ledger.validations["validation-child"].verdict = ValidationVerdict.FAILED
  573. elif mutation == "snapshot":
  574. ledger.validations["validation-child"].snapshot_id = "other"
  575. elif mutation == "digest":
  576. resolver._artifacts.version = replace(
  577. resolver._artifacts.version, canonical_sha256="sha256:" + "b" * 64
  578. )
  579. else:
  580. ledger.tasks["child"].decision_ids.append("other-decision")
  581. with pytest.raises((PhaseTwoInputError, AssertionError)):
  582. await resolver.resolve(
  583. root_trace_id="root",
  584. script_build_id=7,
  585. task_id="parent",
  586. attempt_id=attempt.attempt_id,
  587. contract=_contract(),
  588. input_snapshot_id="11",
  589. )
  590. def test_active_frontier_replacement_cycle_scope_and_write_conflicts() -> None:
  591. ref1 = ArtifactRef("script-build://artifact-versions/1", "paragraph", "1", DIGEST)
  592. ref2 = ArtifactRef("script-build://artifact-versions/2", "paragraph", "2", DIGEST)
  593. bundle = AcceptedInputBundleV1(
  594. inputs=(
  595. AcceptedInput("d1", ref1, ScriptTaskKind.PARAGRAPH, SCOPE, "explicit"),
  596. AcceptedInput("d2", ref2, ScriptTaskKind.PARAGRAPH, SCOPE, "explicit"),
  597. ),
  598. input_closure_digest=DIGEST,
  599. )
  600. frontier = ActiveFrontierResolver()
  601. with pytest.raises(PhaseTwoInputError, match="WRITE_SCOPE_CONFLICT"):
  602. frontier.resolve(bundle, contracts_by_decision={"d1": _contract(), "d2": _contract()})
  603. active = frontier.resolve(
  604. bundle,
  605. contracts_by_decision={
  606. "d1": _contract(),
  607. "d2": _contract(supersedes=("d1",)),
  608. },
  609. )
  610. assert [item.decision_id for item in active] == ["d2"]
  611. exact_base_patch = replace(_contract(), base_artifact_ref=ref1)
  612. active = frontier.resolve(
  613. bundle,
  614. contracts_by_decision={"d1": _contract(), "d2": exact_base_patch},
  615. )
  616. assert [item.decision_id for item in active] == ["d1", "d2"]
  617. stale_base_patch = replace(
  618. _contract(),
  619. base_artifact_ref=replace(ref1, digest="sha256:" + "b" * 64),
  620. )
  621. with pytest.raises(PhaseTwoInputError, match="WRITE_SCOPE_CONFLICT"):
  622. frontier.resolve(
  623. bundle,
  624. contracts_by_decision={"d1": _contract(), "d2": stale_base_patch},
  625. )
  626. with pytest.raises(PhaseTwoInputError, match="SUPERSESSION_CYCLE"):
  627. frontier.resolve(
  628. bundle,
  629. contracts_by_decision={
  630. "d1": _contract(supersedes=("d2",)),
  631. "d2": _contract(supersedes=("d1",)),
  632. },
  633. )
  634. incompatible = AcceptedInputBundleV1(
  635. inputs=(
  636. bundle.inputs[0],
  637. replace(bundle.inputs[1], scope_ref="script-build://scopes/ending"),
  638. ),
  639. input_closure_digest=DIGEST,
  640. )
  641. with pytest.raises(PhaseTwoInputError, match="SUPERSESSION_SCOPE_MISMATCH"):
  642. frontier.resolve(
  643. incompatible,
  644. contracts_by_decision={
  645. "d1": _contract(),
  646. "d2": _contract(scope="script-build://scopes/ending", supersedes=("d1",)),
  647. },
  648. )
  649. def test_stale_base_revision_fails_closed() -> None:
  650. artifact = replace(_paragraph_artifact(), canonical_sha256=DIGEST)
  651. version = ArtifactVersion(
  652. 1,
  653. 7,
  654. "child",
  655. "attempt-child",
  656. 1,
  657. ArtifactKind.PARAGRAPH,
  658. DIGEST,
  659. ArtifactState.FROZEN,
  660. artifact,
  661. datetime.now(UTC),
  662. datetime.now(UTC),
  663. )
  664. lineage = replace(
  665. artifact.lineage,
  666. base_artifact_ref="script-build://artifact-versions/1",
  667. base_artifact_digest=DIGEST,
  668. base_revision=1,
  669. )
  670. ActiveFrontierResolver.verify_base(lineage=lineage, base=version)
  671. with pytest.raises(PhaseTwoInputError, match="STALE_BASE_REVISION"):
  672. ActiveFrontierResolver.verify_base(
  673. lineage=lineage, base=replace(version, artifact_version_id=2)
  674. )
  675. def test_four_validation_layers_defects_and_placeholder_preflight() -> None:
  676. assert validation_layer("paragraph") == "local"
  677. assert validation_layer("compare") == "compare"
  678. assert validation_layer("compose") == "global"
  679. assert validation_layer("candidate-portfolio") == "governance"
  680. with pytest.raises(ValueError, match="frozen executable contract"):
  681. ScriptBuildValidationPolicy().plan(
  682. SimpleNamespace(
  683. task=_task(
  684. "t", parent=None, kind="compose", status=TaskStatus.RUNNING
  685. )
  686. )
  687. )
  688. payload = {
  689. "defect_code": "REALIZATION_PLACEHOLDER",
  690. "criterion_id": "closed",
  691. "scope_ref": SCOPE,
  692. "observed_excerpt": "TODO",
  693. "evidence_refs": ["script-build://artifact-versions/1"],
  694. "severity": "hard",
  695. "invalidated_inputs": ["decision-child"],
  696. "recommended_action_class": "replace",
  697. }
  698. with pytest.raises(Exception, match="passed validation"):
  699. validate_phase_two_defects(
  700. [payload],
  701. criterion_ids={"closed"},
  702. allowed_evidence_refs={"script-build://artifact-versions/1"},
  703. passed=True,
  704. )
  705. defects = validate_phase_two_defects(
  706. [payload],
  707. criterion_ids={"closed"},
  708. allowed_evidence_refs={"script-build://artifact-versions/1"},
  709. passed=False,
  710. )
  711. assert defects[0].blocks_acceptance
  712. assert precheck_business_artifact(_paragraph_artifact(text="TODO: later")) == (
  713. "artifact.paragraphs[0].description",
  714. )
  715. assert precheck_business_artifact(_paragraph_artifact(text="此处描述其存在")) == (
  716. "artifact.paragraphs[0].description",
  717. )
  718. def test_defect_rejects_unknown_criterion_evidence_and_unbounded_excerpt() -> None:
  719. base = {
  720. "defect_code": "X",
  721. "criterion_id": "closed",
  722. "scope_ref": SCOPE,
  723. "observed_excerpt": "x",
  724. "evidence_refs": [],
  725. "severity": "warning",
  726. "invalidated_inputs": [],
  727. "recommended_action_class": "repair",
  728. }
  729. with pytest.raises(Exception, match="outside the frozen contract"):
  730. validate_phase_two_defects(
  731. [{**base, "criterion_id": "other"}],
  732. criterion_ids={"closed"},
  733. allowed_evidence_refs=set(),
  734. passed=False,
  735. )
  736. with pytest.raises(Exception, match="500 characters"):
  737. validate_phase_two_defects(
  738. [{**base, "observed_excerpt": "x" * 501}],
  739. criterion_ids={"closed"},
  740. allowed_evidence_refs=set(),
  741. passed=False,
  742. )
  743. def test_bundle_digest_changes_when_source_or_order_changes() -> None:
  744. first = canonical_sha256(["a", "b"]).wire
  745. second = canonical_sha256(["b", "a"]).wire
  746. assert first != second
  747. def _snapshot_sha(value: dict[str, object]) -> str:
  748. canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  749. return hashlib.sha256(canonical.encode()).hexdigest()