test_phase_two_inputs_validation.py 26 KB

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