test_phase_two_inputs_validation.py 27 KB

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