test_phase_two_inputs_validation.py 28 KB

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