test_goal_coverage.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import UTC, datetime
  4. from inspect import signature
  5. from types import SimpleNamespace
  6. from typing import Any, cast
  7. import pytest
  8. from agent.orchestration import ArtifactRef, AttemptSubmission
  9. from script_build_host.agents.prompts.contracts import ROOT_VALIDATOR_PROMPT
  10. from script_build_host.application.phase_two_candidates import (
  11. PhaseTwoCandidateError,
  12. PhaseTwoCandidateService,
  13. _require_paragraph_structures,
  14. )
  15. from script_build_host.application.phase_two_planning import (
  16. PhasePolicyGuard,
  17. PhaseTwoPlanningService,
  18. )
  19. from script_build_host.application.root_delivery import (
  20. RootDeliveryService,
  21. _closure_digest,
  22. _RootClosure,
  23. )
  24. from script_build_host.domain.artifacts import (
  25. ArtifactKind,
  26. ArtifactState,
  27. ArtifactVersion,
  28. DirectionArtifact,
  29. DirectionConstraint,
  30. DirectionGoal,
  31. DirectionPreference,
  32. )
  33. from script_build_host.domain.goal_coverage import (
  34. GoalCoverage,
  35. GoalPolicyError,
  36. build_goal_coverage,
  37. validate_goal_coverage,
  38. )
  39. from script_build_host.domain.phase_three_artifacts import (
  40. RootDeliveryManifestV1,
  41. canonicalize_legacy_projection,
  42. )
  43. from script_build_host.domain.phase_two_artifacts import (
  44. CandidateLineageV1,
  45. CandidatePortfolioArtifactV1,
  46. ParagraphArtifactV1,
  47. ScriptParagraphV1,
  48. StructuredScriptArtifactV1,
  49. hydrate_phase_two_artifact,
  50. )
  51. from script_build_host.domain.task_contracts import (
  52. AcceptedDecisionRef,
  53. ScriptCriterion,
  54. ScriptIntentClass,
  55. ScriptTaskBudget,
  56. ScriptTaskContractV1,
  57. ScriptTaskKind,
  58. TaskContractError,
  59. )
  60. _DIGEST = "sha256:" + "a" * 64
  61. def test_goal_coverage_supports_parent_child_and_many_to_many_sources() -> None:
  62. coverage = build_goal_coverage(
  63. direction_goal_ids=("parent", "child", "tone"),
  64. creative_sources=(
  65. ("script-build://artifact-versions/11", ("parent", "tone")),
  66. ("script-build://artifact-versions/12", ("parent", "child")),
  67. ),
  68. )
  69. assert tuple(item.goal_id for item in coverage) == ("parent", "child", "tone")
  70. assert coverage[0].source_artifact_refs == (
  71. "script-build://artifact-versions/11",
  72. "script-build://artifact-versions/12",
  73. )
  74. validate_goal_coverage(
  75. direction_goal_ids=("parent", "child", "tone"),
  76. coverage=coverage,
  77. adopted_source_refs=(
  78. "script-build://artifact-versions/11",
  79. "script-build://artifact-versions/12",
  80. ),
  81. )
  82. @pytest.mark.parametrize(
  83. ("sources", "code"),
  84. [
  85. ((("script-build://artifact-versions/11", ("parent",)),), "GOAL_COVERAGE_INCOMPLETE"),
  86. ((("script-build://artifact-versions/11", ("unknown",)),), "GOAL_SCOPE_INVALID"),
  87. ],
  88. )
  89. def test_goal_coverage_rejects_missing_and_unknown_goals(
  90. sources: tuple[tuple[str, tuple[str, ...]], ...], code: str
  91. ) -> None:
  92. with pytest.raises(GoalPolicyError) as caught:
  93. build_goal_coverage(
  94. direction_goal_ids=("parent", "child"),
  95. creative_sources=sources,
  96. )
  97. assert caught.value.code == code
  98. def test_goal_coverage_rejects_dangling_source_and_old_payloads() -> None:
  99. with pytest.raises(GoalPolicyError, match="non-adopted"):
  100. validate_goal_coverage(
  101. direction_goal_ids=("goal-1",),
  102. coverage=(
  103. GoalCoverage("goal-1", ("script-build://artifact-versions/12",)),
  104. ),
  105. adopted_source_refs=("script-build://artifact-versions/11",),
  106. )
  107. with pytest.raises(GoalPolicyError) as missing_coverage:
  108. hydrate_phase_two_artifact({"schema_version": "structured-script/v1"})
  109. assert missing_coverage.value.code == "GOAL_COVERAGE_INCOMPLETE"
  110. payload = _contract(ScriptTaskKind.STRUCTURE).to_payload()
  111. payload.pop("goal_ids")
  112. with pytest.raises(TaskContractError) as missing_goals:
  113. ScriptTaskContractV1.from_payload(payload)
  114. assert missing_goals.value.code == "GOAL_SCOPE_INVALID"
  115. def test_paragraph_first_is_allowed_but_structure_patch_remains_pinned() -> None:
  116. paragraph = _contract(ScriptTaskKind.PARAGRAPH)
  117. PhasePolicyGuard._phase_two_contracts((paragraph,))
  118. patched = replace(
  119. paragraph,
  120. base_artifact_ref=ArtifactRef(
  121. "script-build://artifact-versions/11", "structure", "11", _DIGEST
  122. ),
  123. )
  124. with pytest.raises(TaskContractError, match="Paragraph patch requires"):
  125. PhasePolicyGuard._phase_two_contracts((patched,))
  126. def test_duplicate_goal_ids_are_rejected_by_the_frozen_contract() -> None:
  127. with pytest.raises(TaskContractError) as caught:
  128. replace(_contract(ScriptTaskKind.STRUCTURE), goal_ids=("goal-1", "goal-1"))
  129. assert caught.value.code == "GOAL_SCOPE_INVALID"
  130. def test_compose_derives_coverage_and_requires_structure_for_adopted_paragraph() -> None:
  131. assert set(signature(PhaseTwoCandidateService.save_structured_script_candidate).parameters) == {
  132. "self",
  133. "acceptance_notes",
  134. "context",
  135. }
  136. lineage = CandidateLineageV1(
  137. scope_ref="script-build://scopes/full/opening",
  138. input_snapshot_ref="script-build://inputs/11",
  139. input_closure_digest=_DIGEST,
  140. write_scope=("script-build://writes/paragraphs/full/opening",),
  141. goal_ids=("goal-1",),
  142. )
  143. paragraph = ParagraphArtifactV1(
  144. lineage=lineage,
  145. paragraphs=(ScriptParagraphV1(1, 1, 1, None, "opening", {}),),
  146. )
  147. with pytest.raises(PhaseTwoCandidateError, match="covering Structure"):
  148. _require_paragraph_structures(
  149. (_version(11, ArtifactKind.PARAGRAPH, paragraph, datetime.now(UTC)),)
  150. )
  151. @pytest.mark.asyncio
  152. @pytest.mark.parametrize("goal_ids", [(), ("unknown",)])
  153. async def test_phase_two_goal_guard_rejects_invalid_scope_before_dispatch(
  154. goal_ids: tuple[str, ...],
  155. ) -> None:
  156. direction = DirectionArtifact(
  157. goals=(
  158. DirectionGoal("goal-1", "Complete", "needed", None, ("complete",)),
  159. DirectionGoal("goal-2", "Specific", "needed", "goal-1", ("specific",)),
  160. ),
  161. evidence_refs=("script-build://artifact-versions/90",),
  162. )
  163. service = PhaseTwoPlanningService(
  164. coordinator=cast(Any, SimpleNamespace()),
  165. bindings=cast(Any, SimpleNamespace()),
  166. contracts=cast(Any, SimpleNamespace()),
  167. artifacts=cast(Any, _DirectionArtifacts(direction)),
  168. )
  169. direction_ref = AcceptedDecisionRef(
  170. "direction-accept",
  171. ArtifactRef("script-build://artifact-versions/10", "direction", "10", _DIGEST),
  172. "script-build://scopes/full",
  173. ScriptTaskKind.DIRECTION,
  174. )
  175. contract = replace(
  176. _contract(ScriptTaskKind.STRUCTURE),
  177. goal_ids=goal_ids,
  178. input_decision_refs=(direction_ref,),
  179. )
  180. with pytest.raises(GoalPolicyError) as caught:
  181. await service._guard_phase_two_goals(
  182. context={"phase": 2},
  183. binding=SimpleNamespace(
  184. active_direction_artifact_version_id=10,
  185. script_build_id=7,
  186. ),
  187. root_trace_id="root",
  188. ledger=SimpleNamespace(root_task_id="root"),
  189. contracts=(contract,),
  190. parent=SimpleNamespace(task_id="root"),
  191. )
  192. assert caught.value.code == "GOAL_SCOPE_INVALID"
  193. @pytest.mark.asyncio
  194. async def test_root_preflight_checks_all_goal_nodes_and_frozen_direction_contract() -> None:
  195. now = datetime.now(UTC)
  196. direction = DirectionArtifact(
  197. goals=(
  198. DirectionGoal(
  199. "parent", "Make a useful script", "business goal", None, ("clear outcome",)
  200. ),
  201. DirectionGoal(
  202. "child", "Give one concrete action", "supports parent", "parent", ("action exists",)
  203. ),
  204. ),
  205. constraints=(DirectionConstraint("constraint-1", "No fabricated facts"),),
  206. preferences=(DirectionPreference("preference-1", "Prefer a brisk tone"),),
  207. evidence_refs=("script-build://artifact-versions/90",),
  208. )
  209. structured = StructuredScriptArtifactV1(
  210. direction_ref="script-build://artifact-versions/1",
  211. input_closure_digest=_DIGEST,
  212. paragraphs=(ScriptParagraphV1(1, 1, 1, None, "body", {}, description="do this"),),
  213. elements=(),
  214. paragraph_element_links=(),
  215. source_artifact_refs=("script-build://artifact-versions/3",),
  216. goal_coverage=(
  217. GoalCoverage("parent", ("script-build://artifact-versions/3",)),
  218. GoalCoverage("child", ("script-build://artifact-versions/3",)),
  219. ),
  220. evidence_refs=(),
  221. acceptance_notes=(),
  222. )
  223. portfolio = CandidatePortfolioArtifactV1(
  224. adopted_structured_script_ref="script-build://artifact-versions/3",
  225. candidate_structured_script_refs=("script-build://artifact-versions/3",),
  226. accepted_decision_ids=("compose-accept",),
  227. superseded_decision_ids=(),
  228. rejected_or_held_decision_ids=(),
  229. input_closure_digest=_DIGEST,
  230. unresolved_defects=(),
  231. compose_order=("compose-accept",),
  232. )
  233. closure = _RootClosure(
  234. _version(1, ArtifactKind.DIRECTION, direction, now),
  235. _version(2, ArtifactKind.CANDIDATE_PORTFOLIO, portfolio, now),
  236. _version(3, ArtifactKind.STRUCTURED_SCRIPT, structured, now),
  237. )
  238. projection = canonicalize_legacy_projection(
  239. structured, direction=direction.legacy_markdown, summary="complete"
  240. )
  241. manifest = RootDeliveryManifestV1(
  242. direction_ref="script-build://artifact-versions/1",
  243. candidate_portfolio_ref="script-build://artifact-versions/2",
  244. structured_script_ref="script-build://artifact-versions/3",
  245. input_closure_digest=_closure_digest(closure),
  246. legacy_projection_digest=projection.canonical_sha256,
  247. build_summary="complete",
  248. )
  249. manifest_version = _version(4, ArtifactKind.ROOT_DELIVERY_MANIFEST, manifest, now)
  250. manifest_ref = ArtifactRef(
  251. "script-build://artifact-versions/4", "root_delivery_manifest", "4", _DIGEST
  252. )
  253. ledger = SimpleNamespace(
  254. attempts={
  255. "attempt-root": SimpleNamespace(
  256. attempt_id="attempt-root",
  257. submission=AttemptSubmission("complete", artifact_refs=[manifest_ref]),
  258. task_id="root",
  259. )
  260. }
  261. )
  262. service = RootDeliveryService(
  263. bindings=cast(Any, _Bindings()),
  264. task_store=cast(Any, _TaskStore(ledger)),
  265. artifacts=cast(Any, _Artifacts(manifest_version)),
  266. )
  267. async def closure_override(_: Any) -> _RootClosure:
  268. return closure
  269. service._closure = closure_override # type: ignore[method-assign]
  270. results = await service.deterministic_precheck(
  271. context={"root_trace_id": "root", "attempt_id": "attempt-root"}
  272. )
  273. by_id = {item["rule_id"]: item["verdict"] for item in results}
  274. assert by_id["root-goal-coverage"] == "passed"
  275. assert by_id["root-direction-contract"] == "passed"
  276. assert "child never proves its parent automatically" in ROOT_VALIDATOR_PROMPT
  277. assert "do not reject solely" in " ".join(ROOT_VALIDATOR_PROMPT.split())
  278. def _contract(kind: ScriptTaskKind) -> ScriptTaskContractV1:
  279. output = {
  280. ScriptTaskKind.STRUCTURE: "structure-artifact/v1",
  281. ScriptTaskKind.PARAGRAPH: "paragraph-artifact/v1",
  282. }[kind]
  283. return ScriptTaskContractV1(
  284. task_kind=kind,
  285. scope_ref="script-build://scopes/full",
  286. intent_class=ScriptIntentClass.EXPLORE,
  287. objective="produce one concrete increment",
  288. input_decision_refs=(),
  289. base_artifact_ref=None,
  290. write_scope=("script-build://writes/paragraphs/full",),
  291. gap_ref=None,
  292. output_schema=output,
  293. criteria=(ScriptCriterion("closed", "complete"),),
  294. budget=ScriptTaskBudget(),
  295. goal_ids=("goal-1",),
  296. )
  297. def _version(
  298. identifier: int, kind: ArtifactKind, artifact: Any, now: datetime
  299. ) -> ArtifactVersion:
  300. return ArtifactVersion(
  301. identifier,
  302. 7,
  303. f"task-{identifier}",
  304. f"attempt-{identifier}",
  305. 1,
  306. kind,
  307. _DIGEST,
  308. ArtifactState.FROZEN,
  309. artifact,
  310. now,
  311. now,
  312. )
  313. class _Bindings:
  314. async def get_by_root(self, _: str) -> Any:
  315. return SimpleNamespace(script_build_id=7, root_trace_id="root")
  316. class _TaskStore:
  317. def __init__(self, ledger: Any) -> None:
  318. self.ledger = ledger
  319. async def load(self, _: str) -> Any:
  320. return self.ledger
  321. class _Artifacts:
  322. def __init__(self, manifest: ArtifactVersion) -> None:
  323. self.manifest = manifest
  324. async def read_by_ref(self, *_: Any, **__: Any) -> ArtifactVersion:
  325. return self.manifest
  326. class _DirectionArtifacts:
  327. def __init__(self, direction: DirectionArtifact) -> None:
  328. self.direction = direction
  329. async def get_by_id(self, *_: Any, **__: Any) -> Any:
  330. return SimpleNamespace(artifact=self.direction)