test_architecture_contract_compiler.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. from __future__ import annotations
  2. from types import SimpleNamespace
  3. import pytest
  4. from agent.orchestration import ArtifactRef, ResourceClaim, TaskStatus
  5. from script_build_host.application.direction_evidence_gate import DirectionEvidenceGate
  6. from script_build_host.application.phase_two_planning import (
  7. _claim_serialized_waves,
  8. )
  9. from script_build_host.domain.errors import RequiredEvidenceUnsatisfied
  10. from script_build_host.domain.evidence_requirements import (
  11. DirectionEvidenceRequirementFactory,
  12. EvidenceClosureEvaluator,
  13. EvidenceGate,
  14. EvidenceItem,
  15. EvidenceProvider,
  16. )
  17. from script_build_host.domain.task_contract_compiler import (
  18. PlanningContainer,
  19. PlanningDecision,
  20. PlanningSnapshot,
  21. PlanningTarget,
  22. TaskContractCompiler,
  23. TaskContractMutation,
  24. )
  25. from script_build_host.domain.task_contracts import (
  26. AcceptedDecisionRef,
  27. PlannerTaskIntentV2,
  28. ScriptCriterion,
  29. ScriptTaskBudget,
  30. ScriptTaskKind,
  31. TaskContractError,
  32. )
  33. from script_build_host.infrastructure.task_contract_store import (
  34. FileScriptTaskContractStore,
  35. )
  36. def _decision(
  37. identity: str, kind: ScriptTaskKind, branch: str
  38. ) -> PlanningDecision:
  39. return PlanningDecision(
  40. AcceptedDecisionRef(
  41. identity,
  42. ArtifactRef(
  43. uri="script-build://artifact-versions/1",
  44. kind="artifact",
  45. version="1",
  46. digest="sha256:" + "1" * 64,
  47. ),
  48. f"script-build://scopes/{branch}/{kind.value}",
  49. kind,
  50. ),
  51. f"producer-{identity}",
  52. f"script-build://branches/{branch}",
  53. ("goal-a",),
  54. )
  55. def test_same_workspace_lane_is_serialized_while_distinct_lanes_share_a_wave() -> None:
  56. lane_a = (ResourceClaim("script-build://resources/workspace/a"),)
  57. lane_b = (ResourceClaim("script-build://resources/workspace/b"),)
  58. waves = _claim_serialized_waves(
  59. (
  60. ("paragraph-a", "worker", lane_a),
  61. ("element-a", "worker", lane_a),
  62. ("paragraph-b", "worker", lane_b),
  63. )
  64. )
  65. assert tuple(item[0] for item in waves[0]) == (
  66. "paragraph-a",
  67. "paragraph-b",
  68. )
  69. assert tuple(item[0] for item in waves[1]) == ("element-a",)
  70. def test_host_owned_root_delivery_uses_the_v2_catalog_contract() -> None:
  71. direction = _decision(
  72. "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
  73. ).ref
  74. portfolio = _decision(
  75. "portfolio-1", ScriptTaskKind.CANDIDATE_PORTFOLIO, "portfolio"
  76. ).ref
  77. contract = TaskContractCompiler().compile_host_contract(
  78. task_id="root-task",
  79. parent_task_id="root-task",
  80. task_kind=ScriptTaskKind.ROOT_DELIVERY,
  81. objective="publish",
  82. scope_ref=direction.scope_ref,
  83. branch_ref="script-build://branches/root-delivery",
  84. input_decision_refs=(direction, portfolio),
  85. criteria=(ScriptCriterion("published", "publish", True),),
  86. budget=ScriptTaskBudget(),
  87. goal_ids=("goal-a",),
  88. )
  89. assert contract.to_payload()["schema_version"] == "script-task-contract/v2"
  90. assert contract.execution_profile.worker_preset == "script_root_worker"
  91. assert contract.catalog_digest == TaskContractCompiler().capabilities.digest
  92. @pytest.mark.asyncio
  93. async def test_file_contract_reader_preserves_v2_frozen_execution_profile(
  94. tmp_path,
  95. ) -> None:
  96. direction = _decision(
  97. "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
  98. ).ref
  99. portfolio = _decision(
  100. "portfolio-1", ScriptTaskKind.CANDIDATE_PORTFOLIO, "portfolio"
  101. ).ref
  102. contract = TaskContractCompiler().compile_host_contract(
  103. task_id="root-task",
  104. parent_task_id="root-task",
  105. task_kind=ScriptTaskKind.ROOT_DELIVERY,
  106. objective="publish",
  107. scope_ref=direction.scope_ref,
  108. branch_ref="script-build://branches/root-delivery",
  109. input_decision_refs=(direction, portfolio),
  110. criteria=(ScriptCriterion("published", "publish", True),),
  111. budget=ScriptTaskBudget(),
  112. goal_ids=("goal-a",),
  113. )
  114. store = FileScriptTaskContractStore(tmp_path)
  115. frozen = await store.freeze("root", contract)
  116. replay = await store.read("root", frozen.uri)
  117. assert replay.contract == contract
  118. def test_intent_order_does_not_change_compiled_contracts() -> None:
  119. snapshot = PlanningSnapshot(
  120. root_trace_id="root",
  121. phase=2,
  122. containers=(
  123. PlanningContainer(
  124. "root-task",
  125. None,
  126. None,
  127. None,
  128. ),
  129. ),
  130. decisions=(),
  131. active_direction_ref=_decision(
  132. "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
  133. ).ref,
  134. direction_goal_ids=("goal-a",),
  135. )
  136. intents = (
  137. PlannerTaskIntentV2(
  138. ScriptTaskKind.STRUCTURE, "approach a", target_goal_ids=("goal-a",)
  139. ),
  140. PlannerTaskIntentV2(
  141. ScriptTaskKind.STRUCTURE, "approach b", target_goal_ids=("goal-a",)
  142. ),
  143. )
  144. compiler = TaskContractCompiler()
  145. first = compiler.compile_batch(
  146. intents, snapshot, command_id="command", budget=ScriptTaskBudget()
  147. )
  148. second = compiler.compile_batch(
  149. tuple(reversed(intents)),
  150. snapshot,
  151. command_id="command",
  152. budget=ScriptTaskBudget(),
  153. )
  154. assert [item.contract.to_payload() for item in first] == [
  155. item.contract.to_payload() for item in second
  156. ]
  157. assert [item.contract.task_kind for item in first].count(
  158. ScriptTaskKind.CANDIDATE_PORTFOLIO
  159. ) == 1
  160. assert [item.contract.task_kind for item in first].count(
  161. ScriptTaskKind.COMPOSE
  162. ) == 2
  163. def test_phase_two_scaffolds_then_closes_without_planner_contract_fields() -> None:
  164. compiler = TaskContractCompiler()
  165. direction = _decision("direction-1", ScriptTaskKind.DIRECTION, "phase-one").ref
  166. initial = PlanningSnapshot(
  167. "root",
  168. 2,
  169. (PlanningContainer("root-task", None, None, None),),
  170. (),
  171. active_direction_ref=direction,
  172. direction_goal_ids=("goal-a",),
  173. )
  174. opened = compiler.compile_batch(
  175. (
  176. PlannerTaskIntentV2(
  177. ScriptTaskKind.STRUCTURE,
  178. "open one creative approach",
  179. target_goal_ids=("goal-a",),
  180. ),
  181. ),
  182. initial,
  183. command_id="open",
  184. budget=ScriptTaskBudget(),
  185. )
  186. portfolio = next(
  187. item
  188. for item in opened
  189. if item.contract.task_kind is ScriptTaskKind.CANDIDATE_PORTFOLIO
  190. )
  191. compose = next(
  192. item for item in opened if item.contract.task_kind is ScriptTaskKind.COMPOSE
  193. )
  194. structure = next(
  195. item for item in opened if item.contract.task_kind is ScriptTaskKind.STRUCTURE
  196. )
  197. assert not portfolio.contract.execution_ready
  198. assert not compose.contract.execution_ready
  199. assert structure.parent_task_id == compose.task_id
  200. structure_decision = PlanningDecision(
  201. AcceptedDecisionRef(
  202. "structure-1",
  203. ArtifactRef(
  204. uri="script-build://artifact-versions/10",
  205. kind="artifact",
  206. version="10",
  207. digest="sha256:" + "a" * 64,
  208. ),
  209. structure.contract.scope_ref,
  210. ScriptTaskKind.STRUCTURE,
  211. ),
  212. structure.task_id,
  213. structure.contract.branch_ref,
  214. structure.contract.goal_ids,
  215. )
  216. containers = (
  217. PlanningContainer("root-task", None, None, None),
  218. PlanningContainer(
  219. portfolio.task_id,
  220. portfolio.contract.task_kind,
  221. portfolio.contract.scope_ref,
  222. portfolio.contract.branch_ref,
  223. portfolio.contract.goal_ids,
  224. portfolio.parent_task_id,
  225. True,
  226. ),
  227. PlanningContainer(
  228. compose.task_id,
  229. compose.contract.task_kind,
  230. compose.contract.scope_ref,
  231. compose.contract.branch_ref,
  232. compose.contract.goal_ids,
  233. compose.parent_task_id,
  234. True,
  235. ),
  236. )
  237. paragraph = compiler.compile_batch(
  238. (
  239. PlannerTaskIntentV2(
  240. ScriptTaskKind.PARAGRAPH,
  241. "write paragraph",
  242. decision_ids=("structure-1",),
  243. target_goal_ids=("goal-a",),
  244. ),
  245. ),
  246. PlanningSnapshot(
  247. "root",
  248. 2,
  249. containers,
  250. (structure_decision,),
  251. (
  252. PlanningTarget(
  253. "script-build://targets/paragraph-1",
  254. "structure-1",
  255. ScriptTaskKind.STRUCTURE,
  256. structure.contract.branch_ref,
  257. ("goal-a",),
  258. ),
  259. ),
  260. direction,
  261. ("goal-a",),
  262. ),
  263. command_id="paragraph",
  264. budget=ScriptTaskBudget(),
  265. )[0]
  266. paragraph_decision = PlanningDecision(
  267. AcceptedDecisionRef(
  268. "paragraph-1",
  269. ArtifactRef(
  270. uri="script-build://artifact-versions/11",
  271. kind="artifact",
  272. version="11",
  273. digest="sha256:" + "b" * 64,
  274. ),
  275. paragraph.contract.scope_ref,
  276. ScriptTaskKind.PARAGRAPH,
  277. ),
  278. paragraph.task_id,
  279. paragraph.contract.branch_ref,
  280. paragraph.contract.goal_ids,
  281. )
  282. element = compiler.compile_batch(
  283. (
  284. PlannerTaskIntentV2(
  285. ScriptTaskKind.ELEMENT_SET,
  286. "add elements",
  287. decision_ids=("paragraph-1",),
  288. target_goal_ids=("goal-a",),
  289. ),
  290. ),
  291. PlanningSnapshot(
  292. "root",
  293. 2,
  294. containers,
  295. (paragraph_decision,),
  296. (
  297. PlanningTarget(
  298. "script-build://targets/paragraph-1",
  299. "paragraph-1",
  300. ScriptTaskKind.PARAGRAPH,
  301. paragraph.contract.branch_ref,
  302. ("goal-a",),
  303. ),
  304. ),
  305. direction,
  306. ("goal-a",),
  307. ),
  308. command_id="element",
  309. budget=ScriptTaskBudget(),
  310. )[0]
  311. element_decision = PlanningDecision(
  312. AcceptedDecisionRef(
  313. "element-1",
  314. ArtifactRef(
  315. uri="script-build://artifact-versions/12",
  316. kind="artifact",
  317. version="12",
  318. digest="sha256:" + "c" * 64,
  319. ),
  320. element.contract.scope_ref,
  321. ScriptTaskKind.ELEMENT_SET,
  322. ),
  323. element.task_id,
  324. element.contract.branch_ref,
  325. element.contract.goal_ids,
  326. )
  327. closed_compose = compiler.compile_batch(
  328. (
  329. PlannerTaskIntentV2(
  330. ScriptTaskKind.COMPOSE,
  331. "compose accepted branch",
  332. decision_ids=("structure-1", "paragraph-1", "element-1"),
  333. ),
  334. ),
  335. PlanningSnapshot(
  336. "root",
  337. 2,
  338. containers,
  339. (structure_decision, paragraph_decision, element_decision),
  340. active_direction_ref=direction,
  341. direction_goal_ids=("goal-a",),
  342. ),
  343. command_id="compose",
  344. budget=ScriptTaskBudget(),
  345. )[0]
  346. assert closed_compose.task_id == compose.task_id
  347. assert closed_compose.mutation is TaskContractMutation.REVISE
  348. assert closed_compose.contract.execution_ready
  349. compose_decision = PlanningDecision(
  350. AcceptedDecisionRef(
  351. "compose-1",
  352. ArtifactRef(
  353. uri="script-build://artifact-versions/13",
  354. kind="artifact",
  355. version="13",
  356. digest="sha256:" + "d" * 64,
  357. ),
  358. closed_compose.contract.scope_ref,
  359. ScriptTaskKind.COMPOSE,
  360. ),
  361. compose.task_id,
  362. compose.contract.branch_ref,
  363. closed_compose.contract.goal_ids,
  364. )
  365. closed_portfolio = compiler.compile_batch(
  366. (
  367. PlannerTaskIntentV2(
  368. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  369. "adopt accepted script",
  370. decision_ids=("compose-1",),
  371. ),
  372. ),
  373. PlanningSnapshot(
  374. "root",
  375. 2,
  376. containers,
  377. (compose_decision,),
  378. active_direction_ref=direction,
  379. direction_goal_ids=("goal-a",),
  380. ),
  381. command_id="portfolio",
  382. budget=ScriptTaskBudget(),
  383. )[0]
  384. assert closed_portfolio.task_id == portfolio.task_id
  385. assert closed_portfolio.mutation is TaskContractMutation.REVISE
  386. assert closed_portfolio.contract.execution_ready
  387. def test_compose_scaffold_cannot_close_before_child_frontier_is_terminal() -> None:
  388. decisions = tuple(
  389. _decision(f"{kind.value}-1", kind, "branch-a")
  390. for kind in (
  391. ScriptTaskKind.STRUCTURE,
  392. ScriptTaskKind.PARAGRAPH,
  393. ScriptTaskKind.ELEMENT_SET,
  394. )
  395. )
  396. snapshot = PlanningSnapshot(
  397. "root",
  398. 2,
  399. (
  400. PlanningContainer("root-task", None, None, None),
  401. PlanningContainer(
  402. "compose-parent",
  403. ScriptTaskKind.COMPOSE,
  404. "script-build://scopes/compose",
  405. "script-build://branches/branch-a",
  406. ("goal-a",),
  407. "portfolio-parent",
  408. False,
  409. ),
  410. ),
  411. decisions,
  412. active_direction_ref=_decision(
  413. "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
  414. ).ref,
  415. direction_goal_ids=("goal-a",),
  416. )
  417. with pytest.raises(TaskContractError, match="nonterminal child work"):
  418. TaskContractCompiler().compile_batch(
  419. (
  420. PlannerTaskIntentV2(
  421. ScriptTaskKind.COMPOSE,
  422. "close too early",
  423. decision_ids=tuple(
  424. item.ref.decision_id for item in decisions
  425. ),
  426. ),
  427. ),
  428. snapshot,
  429. command_id="premature-compose",
  430. budget=ScriptTaskBudget(),
  431. )
  432. def test_paragraph_target_is_host_semantic_target_not_objective_text() -> None:
  433. structure = _decision("structure-1", ScriptTaskKind.STRUCTURE, "branch-a")
  434. snapshot = PlanningSnapshot(
  435. root_trace_id="root",
  436. phase=2,
  437. containers=(
  438. PlanningContainer(
  439. "compose-parent",
  440. ScriptTaskKind.COMPOSE,
  441. None,
  442. "script-build://branches/branch-a",
  443. ("goal-a",),
  444. ),
  445. ),
  446. decisions=(structure,),
  447. targets=(
  448. PlanningTarget(
  449. "script-build://targets/paragraph-a",
  450. structure.ref.decision_id,
  451. ScriptTaskKind.STRUCTURE,
  452. "script-build://branches/branch-a",
  453. ("goal-a",),
  454. ),
  455. ),
  456. active_direction_ref=_decision(
  457. "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
  458. ).ref,
  459. direction_goal_ids=("goal-a",),
  460. )
  461. compiled = TaskContractCompiler().compile_batch(
  462. (
  463. PlannerTaskIntentV2(
  464. ScriptTaskKind.PARAGRAPH,
  465. "wording can change freely",
  466. decision_ids=("structure-1",),
  467. target_goal_ids=("goal-a",),
  468. ),
  469. ),
  470. snapshot,
  471. command_id="command",
  472. budget=ScriptTaskBudget(),
  473. )[0]
  474. assert compiled.contract.workspace_seed is not None
  475. assert (
  476. compiled.contract.workspace_seed.semantic_target_ref
  477. == "script-build://targets/paragraph-a"
  478. )
  479. def test_ambiguous_semantic_target_fails_before_task_creation() -> None:
  480. structure = _decision("structure-1", ScriptTaskKind.STRUCTURE, "branch-a")
  481. targets = tuple(
  482. PlanningTarget(
  483. f"script-build://targets/{suffix}",
  484. structure.ref.decision_id,
  485. ScriptTaskKind.STRUCTURE,
  486. "script-build://branches/branch-a",
  487. ("goal-a",),
  488. )
  489. for suffix in ("a", "b")
  490. )
  491. snapshot = PlanningSnapshot(
  492. "root",
  493. 2,
  494. (
  495. PlanningContainer(
  496. "compose-parent",
  497. ScriptTaskKind.COMPOSE,
  498. None,
  499. "script-build://branches/branch-a",
  500. ("goal-a",),
  501. ),
  502. ),
  503. (structure,),
  504. targets,
  505. _decision("direction-1", ScriptTaskKind.DIRECTION, "phase-one").ref,
  506. ("goal-a",),
  507. )
  508. with pytest.raises(TaskContractError, match="semantic targets"):
  509. TaskContractCompiler().compile_batch(
  510. (
  511. PlannerTaskIntentV2(
  512. ScriptTaskKind.PARAGRAPH,
  513. "paragraph",
  514. decision_ids=("structure-1",),
  515. target_goal_ids=("goal-a",),
  516. ),
  517. ),
  518. snapshot,
  519. command_id="command",
  520. budget=ScriptTaskBudget(),
  521. )
  522. def test_pattern_evidence_cannot_satisfy_missing_persona() -> None:
  523. manifest = DirectionEvidenceRequirementFactory().compile(
  524. topic_input_ref="input:topic",
  525. requested_account_name="account",
  526. persona_points=(),
  527. persona_input_ref=None,
  528. section_patterns=(),
  529. section_pattern_input_ref=None,
  530. )
  531. closure = EvidenceClosureEvaluator().evaluate(
  532. manifest,
  533. (
  534. EvidenceItem(
  535. "direction.section_pattern",
  536. "pattern:1",
  537. EvidenceProvider.PATTERN,
  538. ),
  539. ),
  540. gate=EvidenceGate.DIRECTION_ACCEPT,
  541. )
  542. assert "direction.persona" in closure.unsatisfied_hard_requirement_ids
  543. @pytest.mark.asyncio
  544. async def test_direction_gate_rejects_accept_from_stale_child_spec() -> None:
  545. manifest = DirectionEvidenceRequirementFactory().compile(
  546. topic_input_ref="input:topic",
  547. requested_account_name="account",
  548. persona_points=(),
  549. persona_input_ref=None,
  550. section_patterns=({"pattern": "hook"},),
  551. section_pattern_input_ref="input:patterns",
  552. )
  553. direction = _decision(
  554. "direction-1", ScriptTaskKind.DIRECTION, "phase-one"
  555. ).ref
  556. portfolio = _decision(
  557. "portfolio-1", ScriptTaskKind.CANDIDATE_PORTFOLIO, "portfolio"
  558. ).ref
  559. contract = TaskContractCompiler().compile_host_contract(
  560. task_id="root-task",
  561. parent_task_id="root-task",
  562. task_kind=ScriptTaskKind.ROOT_DELIVERY,
  563. objective="publish",
  564. scope_ref=direction.scope_ref,
  565. branch_ref="script-build://branches/root-delivery",
  566. input_decision_refs=(direction, portfolio),
  567. criteria=(ScriptCriterion("published", "publish", True),),
  568. budget=ScriptTaskBudget(),
  569. goal_ids=("goal-a",),
  570. evidence_requirements=manifest,
  571. )
  572. child = SimpleNamespace(
  573. task_id="evidence-child",
  574. status=TaskStatus.COMPLETED,
  575. current_spec_version=2,
  576. current_spec=SimpleNamespace(
  577. context_refs=("script-build://task-kinds/evidence-retrieval",)
  578. ),
  579. decision_ids=["accepted-old-spec"],
  580. )
  581. ledger = SimpleNamespace(
  582. tasks={child.task_id: child},
  583. decisions={
  584. "accepted-old-spec": SimpleNamespace(
  585. action=SimpleNamespace(value="accept"),
  586. attempt_id="attempt-v1",
  587. )
  588. },
  589. attempts={
  590. "attempt-v1": SimpleNamespace(
  591. attempt_id="attempt-v1",
  592. spec_version=1,
  593. submission=SimpleNamespace(evidence_refs=("evidence://persona",)),
  594. )
  595. },
  596. )
  597. class UnreadArtifacts:
  598. async def read_by_ref(self, *_: object, **__: object) -> object:
  599. raise AssertionError("stale accepted evidence must not be read")
  600. with pytest.raises(RequiredEvidenceUnsatisfied):
  601. await DirectionEvidenceGate(UnreadArtifacts()).require(
  602. script_build_id=1,
  603. ledger=ledger,
  604. direction_task=SimpleNamespace(child_task_ids=(child.task_id,)),
  605. contract=contract,
  606. gate=EvidenceGate.DIRECTION_ACCEPT,
  607. )