test_phase_two_candidates.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import UTC, datetime
  4. from hashlib import sha256
  5. from types import SimpleNamespace
  6. from typing import Any, cast
  7. import pytest
  8. from agent.orchestration import ArtifactRef
  9. from script_build_host.application.phase_two_candidates import (
  10. PhaseTwoCandidateError,
  11. PhaseTwoCandidateService,
  12. )
  13. from script_build_host.application.phase_two_inputs import (
  14. ActiveFrontierResolver,
  15. PhaseTwoInputError,
  16. )
  17. from script_build_host.domain.artifacts import (
  18. ArtifactKind,
  19. ArtifactState,
  20. ArtifactVersion,
  21. EvidenceRecordV1,
  22. )
  23. from script_build_host.domain.errors import ProtocolViolation
  24. from script_build_host.domain.phase_two_artifacts import (
  25. CandidatePortfolioArtifactV1,
  26. ComparisonArtifactV1,
  27. ScriptParagraphV1,
  28. StructuredScriptArtifactV1,
  29. )
  30. from script_build_host.domain.task_contracts import (
  31. AcceptedDecisionRef,
  32. AcceptedInput,
  33. AcceptedInputBundleV1,
  34. ScriptCriterion,
  35. ScriptIntentClass,
  36. ScriptTaskBudget,
  37. ScriptTaskContractV1,
  38. ScriptTaskKind,
  39. )
  40. from script_build_host.domain.workspaces import CandidateWriteContext, WorkspaceError
  41. from script_build_host.infrastructure.canonical_json import canonical_sha256
  42. from script_build_host.repositories.sqlalchemy import (
  43. SqlAlchemyScriptBusinessArtifactRepository,
  44. )
  45. from script_build_host.repositories.workspace import (
  46. SqlAlchemyCandidateWorkspaceRepository,
  47. )
  48. DIGEST = "sha256:" + "a" * 64
  49. SCOPE = "script-build://scopes/opening"
  50. WRITE = "script-build://writes/paragraphs/opening"
  51. def _contract(
  52. kind: ScriptTaskKind,
  53. *,
  54. refs: tuple[AcceptedDecisionRef, ...] = (),
  55. comparison_refs: tuple[AcceptedDecisionRef, ...] = (),
  56. adopted: tuple[str, ...] = (),
  57. held: tuple[str, ...] = (),
  58. order: tuple[str, ...] = (),
  59. ) -> ScriptTaskContractV1:
  60. schemas = {
  61. ScriptTaskKind.PARAGRAPH: "paragraph-artifact/v1",
  62. ScriptTaskKind.DECODE_RETRIEVAL: "evidence-record/v1",
  63. ScriptTaskKind.COMPARE: "comparison-artifact/v1",
  64. ScriptTaskKind.COMPOSE: "structured-script/v1",
  65. ScriptTaskKind.CANDIDATE_PORTFOLIO: "candidate-portfolio/v1",
  66. }
  67. return ScriptTaskContractV1(
  68. task_kind=kind,
  69. scope_ref=SCOPE,
  70. intent_class=(
  71. ScriptIntentClass.COMPOSE
  72. if kind is ScriptTaskKind.COMPOSE
  73. else ScriptIntentClass.COMPARE
  74. if kind is ScriptTaskKind.COMPARE
  75. else ScriptIntentClass.EXPLORE
  76. ),
  77. objective="produce one bounded candidate increment",
  78. input_decision_refs=refs if kind is ScriptTaskKind.PARAGRAPH else (),
  79. base_artifact_ref=None,
  80. write_scope=(WRITE,),
  81. gap_ref=None,
  82. output_schema=schemas[kind],
  83. criteria=(ScriptCriterion("closed", "output is concretely complete"),),
  84. budget=ScriptTaskBudget(),
  85. candidate_closure_decision_refs=(
  86. refs if kind in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO} else ()
  87. ),
  88. adopted_decision_ids=adopted,
  89. held_or_rejected_decision_ids=held,
  90. compose_order=order,
  91. comparison_decision_refs=comparison_refs,
  92. )
  93. class _Bindings:
  94. async def get_by_root(self, root_trace_id: str) -> Any:
  95. return SimpleNamespace(root_trace_id=root_trace_id, script_build_id=7, input_snapshot_id=11)
  96. class _TaskStore:
  97. def __init__(self, ledger: Any) -> None:
  98. self.ledger = ledger
  99. async def load(self, root_trace_id: str) -> Any:
  100. assert root_trace_id == "root"
  101. return self.ledger
  102. class _AcceptedInputs:
  103. def __init__(
  104. self, contracts: dict[str, ScriptTaskContractV1], bundle: AcceptedInputBundleV1
  105. ) -> None:
  106. self.contracts = contracts
  107. self.bundle = bundle
  108. async def contract_for_task(self, *, task: Any, **_: Any) -> ScriptTaskContractV1:
  109. return self.contracts[task.task_id]
  110. async def resolve(self, **_: Any) -> AcceptedInputBundleV1:
  111. return self.bundle
  112. class _FrameworkArtifacts:
  113. async def get(self, *_: Any) -> Any:
  114. raise AssertionError("framework snapshot should not be read")
  115. class _SnapshotArtifacts:
  116. def __init__(self, ref: ArtifactRef) -> None:
  117. self.ref = ref
  118. async def get(self, root_trace_id: str, snapshot_id: str) -> Any:
  119. assert (root_trace_id, snapshot_id) == ("root", "validation-snapshot")
  120. return SimpleNamespace(artifact_refs=[self.ref], evidence_refs=[])
  121. class _RawStore:
  122. def __init__(self, content: bytes) -> None:
  123. self.content = content
  124. async def read_bytes(self, ref: str) -> bytes:
  125. assert ref.endswith(sha256(self.content).hexdigest())
  126. return self.content
  127. class _Artifacts:
  128. def __init__(self, versions: dict[str, ArtifactVersion]) -> None:
  129. self.versions = versions
  130. self.frozen: ArtifactVersion | None = None
  131. async def read_by_ref(self, ref: ArtifactRef, **_: Any) -> ArtifactVersion:
  132. return self.versions[ref.uri]
  133. async def get_by_attempt(self, **owners: Any) -> ArtifactVersion:
  134. for value in self.versions.values():
  135. if value.attempt_id == owners["attempt_id"]:
  136. return value
  137. raise AssertionError("Attempt artifact not found")
  138. async def freeze(self, *, artifact: Any, **owners: Any) -> tuple[ArtifactVersion, ArtifactRef]:
  139. digest = canonical_sha256(artifact.content_payload()).wire
  140. artifact = replace(artifact, canonical_sha256=digest)
  141. artifact_kind = (
  142. ArtifactKind.COMPARISON
  143. if isinstance(artifact, ComparisonArtifactV1)
  144. else ArtifactKind.CANDIDATE_PORTFOLIO
  145. )
  146. version = ArtifactVersion(
  147. artifact_version_id=99,
  148. script_build_id=owners["script_build_id"],
  149. task_id=owners["task_id"],
  150. attempt_id=owners["attempt_id"],
  151. spec_version=owners["spec_version"],
  152. artifact_type=artifact_kind,
  153. canonical_sha256=digest,
  154. state=ArtifactState.FROZEN,
  155. artifact=artifact,
  156. created_at=datetime.now(UTC),
  157. frozen_at=datetime.now(UTC),
  158. )
  159. self.frozen = version
  160. ref = ArtifactRef(
  161. "script-build://artifact-versions/99",
  162. artifact_kind.value,
  163. "99",
  164. digest,
  165. )
  166. self.versions[ref.uri] = version
  167. return version, ref
  168. def _scope_fixture(
  169. contract: ScriptTaskContractV1,
  170. *,
  171. bundle: AcceptedInputBundleV1 | None = None,
  172. task_id: str = "task",
  173. attempt_id: str = "attempt",
  174. ) -> tuple[Any, dict[str, Any]]:
  175. task = SimpleNamespace(task_id=task_id, attempt_ids=[attempt_id])
  176. attempt = SimpleNamespace(
  177. attempt_id=attempt_id,
  178. task_id=task_id,
  179. spec_version=1,
  180. accepted_child_decision_ids=(),
  181. )
  182. ledger = SimpleNamespace(
  183. tasks={task_id: task},
  184. attempts={attempt_id: attempt},
  185. decisions={},
  186. )
  187. accepted = _AcceptedInputs(
  188. {task_id: contract},
  189. bundle or AcceptedInputBundleV1((), DIGEST),
  190. )
  191. return (ledger, accepted), {
  192. "root_trace_id": "root",
  193. "task_id": task_id,
  194. "attempt_id": attempt_id,
  195. "spec_version": 1,
  196. }
  197. @pytest.mark.asyncio
  198. async def test_phase_one_manifest_does_not_require_candidate_workspace() -> None:
  199. contract = _contract(ScriptTaskKind.DECODE_RETRIEVAL)
  200. (ledger, accepted), context = _scope_fixture(contract)
  201. evidence = EvidenceRecordV1(
  202. evidence_id="evidence",
  203. source_type="decode",
  204. tool_name="retrieve_decode",
  205. query={},
  206. source_refs=("source",),
  207. raw_artifact_ref=None,
  208. summary="bounded evidence",
  209. supports=("goal",),
  210. confidence="high",
  211. limitations=(),
  212. content_sha256=DIGEST,
  213. created_at=datetime.now(UTC),
  214. )
  215. version = ArtifactVersion(
  216. 1,
  217. 7,
  218. "task",
  219. "attempt",
  220. 1,
  221. ArtifactKind.EVIDENCE,
  222. DIGEST,
  223. ArtifactState.FROZEN,
  224. evidence,
  225. datetime.now(UTC),
  226. datetime.now(UTC),
  227. )
  228. service = PhaseTwoCandidateService(
  229. bindings=cast(Any, _Bindings()),
  230. task_store=_TaskStore(ledger),
  231. framework_artifact_store=_FrameworkArtifacts(),
  232. artifacts=cast(Any, _Artifacts({"script-build://artifact-versions/1": version})),
  233. accepted_inputs=accepted,
  234. active_frontier=ActiveFrontierResolver(),
  235. workspaces=None,
  236. )
  237. manifest = await service.resolve_attempt_manifest(context=context)
  238. assert manifest.artifact_ref.kind == "evidence"
  239. assert manifest.scope_ref == SCOPE
  240. @pytest.mark.asyncio
  241. async def test_paragraph_workspace_uses_positive_branch_and_freezes_on_manifest(
  242. database: Any,
  243. ) -> None:
  244. _, sessions = database
  245. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  246. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  247. contract = _contract(ScriptTaskKind.PARAGRAPH)
  248. (ledger, accepted), context = _scope_fixture(contract)
  249. service = PhaseTwoCandidateService(
  250. bindings=cast(Any, _Bindings()),
  251. task_store=_TaskStore(ledger),
  252. framework_artifact_store=_FrameworkArtifacts(),
  253. artifacts=repository,
  254. accepted_inputs=accepted,
  255. active_frontier=ActiveFrontierResolver(),
  256. workspaces=workspaces,
  257. )
  258. created = await service.create_script_paragraph(
  259. payload={"paragraph_index": 1, "name": "opening", "content_range": {}},
  260. context=context,
  261. )
  262. manifest = await service.resolve_attempt_manifest(context=context)
  263. version = await repository.get_by_attempt(
  264. script_build_id=7, task_id="task", attempt_id="attempt"
  265. )
  266. assert created["paragraph_id"] > 0
  267. assert version.artifact_version_id > 0
  268. assert manifest.artifact_ref.digest == version.canonical_sha256
  269. assert manifest.artifact_ref.kind == "paragraph"
  270. with pytest.raises(PhaseTwoCandidateError, match="WRITE_SCOPE_VIOLATION"):
  271. await service.create_script_paragraph(
  272. payload={
  273. "paragraph_index": 2,
  274. "name": "forged",
  275. "content_range": {},
  276. "branch_id": 0,
  277. },
  278. context=context,
  279. )
  280. @pytest.mark.asyncio
  281. async def test_paragraph_batch_maps_parent_keys_and_rolls_back_atomically(database: Any) -> None:
  282. _, sessions = database
  283. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  284. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  285. contract = _contract(ScriptTaskKind.PARAGRAPH)
  286. (ledger, accepted), context = _scope_fixture(contract)
  287. service = PhaseTwoCandidateService(
  288. bindings=cast(Any, _Bindings()),
  289. task_store=_TaskStore(ledger),
  290. framework_artifact_store=_FrameworkArtifacts(),
  291. artifacts=repository,
  292. accepted_inputs=accepted,
  293. active_frontier=ActiveFrontierResolver(),
  294. workspaces=workspaces,
  295. )
  296. created = await service.create_script_paragraphs(
  297. paragraphs=(
  298. {
  299. "client_key": "root",
  300. "paragraph_index": 1,
  301. "name": "opening",
  302. "content_range": {"beats": ["setup", "turn"]},
  303. },
  304. {
  305. "client_key": "child",
  306. "parent_client_key": "root",
  307. "paragraph_index": 2,
  308. "level": 2,
  309. "name": "turn",
  310. "content_range": {"beats": ["turn"]},
  311. },
  312. ),
  313. context=context,
  314. )
  315. assert created["created"] == 2
  316. assert set(created["paragraph_ids_by_client_key"]) == {"root", "child"}
  317. contract2 = replace(contract, objective="second isolated workspace")
  318. (ledger2, accepted2), context2 = _scope_fixture(
  319. contract2, task_id="task-2", attempt_id="attempt-2"
  320. )
  321. service2 = PhaseTwoCandidateService(
  322. bindings=cast(Any, _Bindings()),
  323. task_store=_TaskStore(ledger2),
  324. framework_artifact_store=_FrameworkArtifacts(),
  325. artifacts=repository,
  326. accepted_inputs=accepted2,
  327. active_frontier=ActiveFrontierResolver(),
  328. workspaces=workspaces,
  329. )
  330. with pytest.raises(WorkspaceError, match="child content range"):
  331. await service2.create_script_paragraphs(
  332. paragraphs=(
  333. {
  334. "client_key": "root",
  335. "paragraph_index": 1,
  336. "name": "root",
  337. "content_range": {"beats": ["setup"]},
  338. },
  339. {
  340. "client_key": "child",
  341. "parent_client_key": "root",
  342. "paragraph_index": 2,
  343. "level": 2,
  344. "name": "child",
  345. "content_range": {"beats": ["missing"]},
  346. },
  347. ),
  348. context=context2,
  349. )
  350. write_context = CandidateWriteContext(
  351. script_build_id=7,
  352. task_id="task-2",
  353. attempt_id="attempt-2",
  354. spec_version=1,
  355. objective=contract2.objective,
  356. input_refs=(),
  357. write_scope=contract2.write_scope,
  358. )
  359. assert not (await workspaces.snapshot(write_context)).paragraphs
  360. @pytest.mark.asyncio
  361. async def test_candidate_service_discards_existing_draft_workspace_on_abandoned_attempt(
  362. database: Any,
  363. ) -> None:
  364. _, sessions = database
  365. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  366. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  367. contract = _contract(ScriptTaskKind.PARAGRAPH)
  368. (ledger, accepted), context = _scope_fixture(contract)
  369. service = PhaseTwoCandidateService(
  370. bindings=cast(Any, _Bindings()),
  371. task_store=_TaskStore(ledger),
  372. framework_artifact_store=_FrameworkArtifacts(),
  373. artifacts=repository,
  374. accepted_inputs=accepted,
  375. active_frontier=ActiveFrontierResolver(),
  376. workspaces=workspaces,
  377. )
  378. await service.create_script_paragraph(
  379. payload={"paragraph_index": 1, "name": "draft", "content_range": {}},
  380. context=context,
  381. )
  382. await service.discard_attempt(
  383. root_trace_id="root",
  384. task_id="task",
  385. attempt_id="attempt",
  386. reason="validator blocked the bounded increment",
  387. )
  388. write_context = CandidateWriteContext(
  389. script_build_id=7,
  390. task_id="task",
  391. attempt_id="attempt",
  392. spec_version=1,
  393. objective=contract.objective,
  394. input_refs=(),
  395. write_scope=contract.write_scope,
  396. )
  397. assert (await workspaces.require(write_context)).state is ArtifactState.DISCARDED
  398. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  399. await service.create_script_paragraph(
  400. payload={"paragraph_index": 2, "name": "late", "content_range": {}},
  401. context=context,
  402. )
  403. @pytest.mark.asyncio
  404. async def test_frozen_image_must_be_accepted_and_digest_and_mime_are_rechecked() -> None:
  405. content = b"\x89PNG\r\n\x1a\n" + b"safe"
  406. raw_ref = f"script-build://raw-artifacts/sha256/{sha256(content).hexdigest()}"
  407. ref = ArtifactRef("script-build://artifact-versions/2", "evidence", "2", DIGEST)
  408. item = AcceptedInput("d1", ref, ScriptTaskKind.DECODE_RETRIEVAL, SCOPE, "explicit")
  409. bundle = AcceptedInputBundleV1((item,), DIGEST)
  410. contract = _contract(ScriptTaskKind.PARAGRAPH)
  411. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  412. evidence = EvidenceRecordV1(
  413. "e",
  414. "decode",
  415. "retrieve_decode",
  416. {},
  417. ("source",),
  418. raw_ref,
  419. "summary",
  420. ("goal",),
  421. "high",
  422. (),
  423. DIGEST,
  424. datetime.now(UTC),
  425. )
  426. version = ArtifactVersion(
  427. 2,
  428. 7,
  429. "child",
  430. "child-attempt",
  431. 1,
  432. ArtifactKind.EVIDENCE,
  433. DIGEST,
  434. ArtifactState.FROZEN,
  435. evidence,
  436. datetime.now(UTC),
  437. datetime.now(UTC),
  438. )
  439. service = PhaseTwoCandidateService(
  440. bindings=cast(Any, _Bindings()),
  441. task_store=_TaskStore(ledger),
  442. framework_artifact_store=_FrameworkArtifacts(),
  443. artifacts=cast(Any, _Artifacts({ref.uri: version})),
  444. accepted_inputs=accepted,
  445. active_frontier=ActiveFrontierResolver(),
  446. workspaces=None,
  447. raw_artifacts=_RawStore(content),
  448. )
  449. images = await service.view_frozen_images(raw_artifact_refs=[raw_ref], context=context)
  450. assert images[0]["media_type"] == "image/png"
  451. assert "url" not in images[0]
  452. with pytest.raises(PhaseTwoCandidateError, match="outside the accepted"):
  453. await service.view_frozen_images(raw_artifact_refs=[_RAW_OTHER], context=context)
  454. @pytest.mark.asyncio
  455. async def test_active_frontier_write_conflict_is_not_resolved_by_completion_order() -> None:
  456. ref1 = ArtifactRef("script-build://artifact-versions/1", "paragraph", "1", DIGEST)
  457. ref2 = ArtifactRef("script-build://artifact-versions/2", "paragraph", "2", DIGEST)
  458. refs = (
  459. AcceptedDecisionRef("d1", ref1, SCOPE, ScriptTaskKind.PARAGRAPH),
  460. AcceptedDecisionRef("d2", ref2, SCOPE, ScriptTaskKind.PARAGRAPH),
  461. )
  462. contract = _contract(
  463. ScriptTaskKind.COMPOSE, refs=refs, adopted=("d1", "d2"), order=("d1", "d2")
  464. )
  465. bundle = AcceptedInputBundleV1(
  466. (
  467. AcceptedInput("d1", ref1, ScriptTaskKind.PARAGRAPH, SCOPE, "explicit"),
  468. AcceptedInput("d2", ref2, ScriptTaskKind.PARAGRAPH, SCOPE, "explicit"),
  469. ),
  470. DIGEST,
  471. )
  472. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  473. child_contract = _contract(ScriptTaskKind.PARAGRAPH)
  474. for decision_id in ("d1", "d2"):
  475. task_id = f"task-{decision_id}"
  476. ledger.tasks[task_id] = SimpleNamespace(task_id=task_id, attempt_ids=[f"a-{decision_id}"])
  477. ledger.attempts[f"a-{decision_id}"] = SimpleNamespace(
  478. attempt_id=f"a-{decision_id}", task_id=task_id, spec_version=1
  479. )
  480. ledger.decisions[decision_id] = SimpleNamespace(
  481. task_id=task_id, attempt_id=f"a-{decision_id}"
  482. )
  483. accepted.contracts[task_id] = child_contract
  484. service = PhaseTwoCandidateService(
  485. bindings=cast(Any, _Bindings()),
  486. task_store=_TaskStore(ledger),
  487. framework_artifact_store=_FrameworkArtifacts(),
  488. artifacts=cast(Any, _Artifacts({})),
  489. accepted_inputs=accepted,
  490. active_frontier=ActiveFrontierResolver(),
  491. workspaces=None,
  492. )
  493. with pytest.raises(PhaseTwoInputError, match="WRITE_SCOPE_CONFLICT"):
  494. await service.read_active_frontier(context=context)
  495. @pytest.mark.asyncio
  496. async def test_comparison_candidates_are_contract_derived_and_pass_fairness_precheck() -> None:
  497. candidate_refs = (
  498. ArtifactRef("script-build://artifact-versions/1", "paragraph", "1", DIGEST),
  499. ArtifactRef("script-build://artifact-versions/2", "paragraph", "2", DIGEST),
  500. )
  501. decision_refs = tuple(
  502. AcceptedDecisionRef(
  503. f"candidate-{index}",
  504. ref,
  505. SCOPE,
  506. ScriptTaskKind.PARAGRAPH,
  507. )
  508. for index, ref in enumerate(candidate_refs, start=1)
  509. )
  510. bundle = AcceptedInputBundleV1(
  511. tuple(
  512. AcceptedInput(
  513. decision_ref.decision_id,
  514. decision_ref.artifact_ref,
  515. ScriptTaskKind.PARAGRAPH,
  516. SCOPE,
  517. "explicit",
  518. )
  519. for decision_ref in decision_refs
  520. ),
  521. DIGEST,
  522. )
  523. contract = _contract(ScriptTaskKind.COMPARE, comparison_refs=decision_refs)
  524. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  525. artifacts = _Artifacts({})
  526. service = PhaseTwoCandidateService(
  527. bindings=cast(Any, _Bindings()),
  528. task_store=_TaskStore(ledger),
  529. framework_artifact_store=_FrameworkArtifacts(),
  530. artifacts=cast(Any, artifacts),
  531. accepted_inputs=accepted,
  532. active_frontier=ActiveFrontierResolver(),
  533. workspaces=None,
  534. )
  535. criterion_results = [
  536. {
  537. "criterion_id": "closed",
  538. "candidate_results": [
  539. {"artifact_ref": candidate_refs[0].uri, "reason": "uses one visible detail"},
  540. {"artifact_ref": candidate_refs[1].uri, "reason": "remains more abstract"},
  541. ],
  542. }
  543. ]
  544. frozen = await service.save_comparison_candidate(
  545. payload={
  546. "criterion_results": criterion_results,
  547. "conflicts": ["both candidates write the opening scope"],
  548. "recommendation": candidate_refs[0].uri,
  549. },
  550. context=context,
  551. )
  552. assert artifacts.frozen is not None
  553. comparison = artifacts.frozen.artifact
  554. assert isinstance(comparison, ComparisonArtifactV1)
  555. assert comparison.candidate_artifact_refs == tuple(ref.uri for ref in candidate_refs)
  556. assert comparison.criterion_results == tuple(criterion_results)
  557. frozen_ref = ArtifactRef.from_dict(frozen["artifact_ref"])
  558. service._framework_artifact_store = _SnapshotArtifacts(frozen_ref)
  559. rules = await service.deterministic_precheck(
  560. context={**context, "snapshot_id": "validation-snapshot"}
  561. )
  562. assert {str(item["rule_id"]): item["verdict"] for item in rules} == {
  563. "business-artifact-owner": "passed",
  564. "realized-content": "passed",
  565. "candidate-lineage": "passed",
  566. "comparison-fairness": "passed",
  567. }
  568. with pytest.raises(PhaseTwoCandidateError, match="derived from the frozen contract"):
  569. await service.save_comparison_candidate(
  570. payload={
  571. "candidate_artifact_refs": [candidate_refs[1].uri],
  572. "criterion_results": criterion_results,
  573. "recommendation": candidate_refs[1].uri,
  574. },
  575. context=context,
  576. )
  577. @pytest.mark.asyncio
  578. async def test_portfolio_adoption_and_canonical_digest_come_from_frozen_contract() -> None:
  579. ref1 = ArtifactRef("script-build://artifact-versions/1", "structured_script", "1", DIGEST)
  580. ref2 = ArtifactRef("script-build://artifact-versions/2", "structured_script", "2", DIGEST)
  581. refs = (
  582. AcceptedDecisionRef("d1", ref1, SCOPE, ScriptTaskKind.COMPOSE),
  583. AcceptedDecisionRef("d2", ref2, SCOPE, ScriptTaskKind.COMPOSE),
  584. )
  585. contract = _contract(
  586. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  587. refs=refs,
  588. adopted=("d1",),
  589. held=("d2",),
  590. order=("d1",),
  591. )
  592. bundle = AcceptedInputBundleV1(
  593. (
  594. AcceptedInput("d1", ref1, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  595. AcceptedInput("d2", ref2, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  596. ),
  597. DIGEST,
  598. )
  599. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  600. paragraph = ScriptParagraphV1(1, 1, 1, None, "opening", {})
  601. def structured(identifier: int) -> ArtifactVersion:
  602. artifact = StructuredScriptArtifactV1(
  603. direction_ref="script-build://artifact-versions/10",
  604. input_closure_digest=DIGEST,
  605. paragraphs=(paragraph,),
  606. elements=(),
  607. paragraph_element_links=(),
  608. source_artifact_refs=(f"script-build://artifact-versions/{20 + identifier}",),
  609. evidence_refs=(),
  610. acceptance_notes=("accepted",),
  611. canonical_sha256=DIGEST,
  612. )
  613. return ArtifactVersion(
  614. identifier,
  615. 7,
  616. f"compose-{identifier}",
  617. f"compose-attempt-{identifier}",
  618. 1,
  619. ArtifactKind.STRUCTURED_SCRIPT,
  620. DIGEST,
  621. ArtifactState.FROZEN,
  622. artifact,
  623. datetime.now(UTC),
  624. datetime.now(UTC),
  625. )
  626. artifacts = _Artifacts({ref1.uri: structured(1), ref2.uri: structured(2)})
  627. service = PhaseTwoCandidateService(
  628. bindings=cast(Any, _Bindings()),
  629. task_store=_TaskStore(ledger),
  630. framework_artifact_store=_FrameworkArtifacts(),
  631. artifacts=cast(Any, artifacts),
  632. accepted_inputs=accepted,
  633. active_frontier=ActiveFrontierResolver(),
  634. workspaces=None,
  635. )
  636. result = await service.save_candidate_portfolio(
  637. payload={
  638. "unresolved_defects": [
  639. {
  640. "severity": "warning",
  641. "defect_code": "MINOR_STYLE",
  642. "criterion_id": "closed",
  643. "scope_ref": SCOPE,
  644. "observed_excerpt": "opening cadence is slightly repetitive",
  645. "evidence_refs": [],
  646. "invalidated_inputs": [],
  647. "recommended_action_class": "revise",
  648. }
  649. ],
  650. },
  651. context=context,
  652. )
  653. assert artifacts.frozen is not None
  654. portfolio = artifacts.frozen.artifact
  655. assert isinstance(portfolio, CandidatePortfolioArtifactV1)
  656. assert portfolio.accepted_decision_ids == ("d1",)
  657. assert portfolio.rejected_or_held_decision_ids == ("d2",)
  658. assert result["artifact_ref"]["digest"] == canonical_sha256(portfolio.content_payload()).wire
  659. with pytest.raises(PhaseTwoCandidateError, match="outside the accepted closure"):
  660. await service.save_candidate_portfolio(
  661. payload={
  662. "unresolved_defects": [
  663. {
  664. "severity": "warning",
  665. "defect_code": "UNTRUSTED_EVIDENCE",
  666. "criterion_id": "closed",
  667. "scope_ref": SCOPE,
  668. "observed_excerpt": "unsupported warning",
  669. "evidence_refs": ["script-build://artifact-versions/999"],
  670. "invalidated_inputs": [],
  671. "recommended_action_class": "revise",
  672. }
  673. ],
  674. },
  675. context=context,
  676. )
  677. with pytest.raises(ProtocolViolation, match=r"unsupported enum|must not be blank"):
  678. await service.save_candidate_portfolio(
  679. payload={
  680. "unresolved_defects": [
  681. {"severity": "warning", "defect_code": "INCOMPLETE_WARNING"}
  682. ],
  683. },
  684. context=context,
  685. )
  686. with pytest.raises(PhaseTwoCandidateError, match="derived from the frozen contract"):
  687. await service.save_candidate_portfolio(
  688. payload={"adopted_structured_script_ref": ref2.uri}, context=context
  689. )
  690. @pytest.mark.asyncio
  691. async def test_candidate_portfolio_command_replay_reuses_one_frozen_version(database: Any) -> None:
  692. _, sessions = database
  693. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  694. paragraph = ScriptParagraphV1(1, 1, 1, None, "opening", {})
  695. async def freeze_structured(identifier: int) -> tuple[ArtifactVersion, ArtifactRef]:
  696. return await repository.freeze(
  697. script_build_id=7,
  698. task_id=f"compose-{identifier}",
  699. attempt_id=f"compose-attempt-{identifier}",
  700. spec_version=1,
  701. artifact=StructuredScriptArtifactV1(
  702. direction_ref="script-build://artifact-versions/10",
  703. input_closure_digest=DIGEST,
  704. paragraphs=(paragraph,),
  705. elements=(),
  706. paragraph_element_links=(),
  707. source_artifact_refs=(f"script-build://artifact-versions/{20 + identifier}",),
  708. evidence_refs=(),
  709. acceptance_notes=("accepted",),
  710. ),
  711. )
  712. (_, stored_ref1), (_, stored_ref2) = await freeze_structured(1), await freeze_structured(2)
  713. ref1 = ArtifactRef(
  714. stored_ref1.uri,
  715. stored_ref1.kind,
  716. stored_ref1.version,
  717. stored_ref1.digest,
  718. )
  719. ref2 = ArtifactRef(
  720. stored_ref2.uri,
  721. stored_ref2.kind,
  722. stored_ref2.version,
  723. stored_ref2.digest,
  724. )
  725. refs = (
  726. AcceptedDecisionRef("d1", ref1, SCOPE, ScriptTaskKind.COMPOSE),
  727. AcceptedDecisionRef("d2", ref2, SCOPE, ScriptTaskKind.COMPOSE),
  728. )
  729. contract = _contract(
  730. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  731. refs=refs,
  732. adopted=("d1",),
  733. held=("d2",),
  734. order=("d1",),
  735. )
  736. bundle = AcceptedInputBundleV1(
  737. (
  738. AcceptedInput("d1", ref1, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  739. AcceptedInput("d2", ref2, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  740. ),
  741. DIGEST,
  742. )
  743. (ledger, accepted), context = _scope_fixture(
  744. contract,
  745. bundle=bundle,
  746. task_id="portfolio",
  747. attempt_id="portfolio-attempt",
  748. )
  749. service = PhaseTwoCandidateService(
  750. bindings=cast(Any, _Bindings()),
  751. task_store=_TaskStore(ledger),
  752. framework_artifact_store=_FrameworkArtifacts(),
  753. artifacts=repository,
  754. accepted_inputs=accepted,
  755. active_frontier=ActiveFrontierResolver(),
  756. workspaces=None,
  757. )
  758. first = await service.save_candidate_portfolio(
  759. payload={"unresolved_defects": []}, context=context
  760. )
  761. replay = await service.save_candidate_portfolio(
  762. payload={"unresolved_defects": []}, context=context
  763. )
  764. frozen = await repository.get_by_attempt(
  765. script_build_id=7,
  766. task_id="portfolio",
  767. attempt_id="portfolio-attempt",
  768. )
  769. assert first == replay
  770. assert first["artifact_ref"]["version"] == str(frozen.artifact_version_id)
  771. assert frozen.artifact_type is ArtifactKind.CANDIDATE_PORTFOLIO
  772. _RAW_OTHER = "script-build://raw-artifacts/sha256/" + "b" * 64