test_phase_two_candidates.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452
  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. CandidateScope,
  11. PhaseTwoCandidateError,
  12. PhaseTwoCandidateService,
  13. _candidate_state_revision,
  14. )
  15. from script_build_host.application.phase_two_inputs import (
  16. ActiveFrontierResolver,
  17. PhaseTwoInputError,
  18. )
  19. from script_build_host.domain.artifacts import (
  20. ArtifactKind,
  21. ArtifactState,
  22. ArtifactVersion,
  23. DirectionArtifact,
  24. DirectionGoal,
  25. EvidenceRecordV1,
  26. )
  27. from script_build_host.domain.errors import ProtocolViolation
  28. from script_build_host.domain.phase_two_artifacts import (
  29. CandidateLineageV1,
  30. CandidatePortfolioArtifactV1,
  31. ComparisonArtifactV1,
  32. ElementSetArtifactV1,
  33. GoalCoverage,
  34. ParagraphArtifactV1,
  35. ScriptElementV1,
  36. ScriptParagraphElementLinkV1,
  37. ScriptParagraphV1,
  38. StructureArtifactV1,
  39. StructuredScriptArtifactV1,
  40. )
  41. from script_build_host.domain.task_contracts import (
  42. AcceptedDecisionRef,
  43. AcceptedInput,
  44. AcceptedInputBundleV1,
  45. ScriptCriterion,
  46. ScriptIntentClass,
  47. ScriptTaskBudget,
  48. ScriptTaskContractV1,
  49. ScriptTaskKind,
  50. )
  51. from script_build_host.domain.workbench import paragraph_target_key, semantic_handle
  52. from script_build_host.domain.workspaces import CandidateWriteContext, WorkspaceError
  53. from script_build_host.infrastructure.canonical_json import canonical_sha256
  54. from script_build_host.repositories.sqlalchemy import (
  55. SqlAlchemyScriptBusinessArtifactRepository,
  56. )
  57. from script_build_host.repositories.workspace import (
  58. SqlAlchemyCandidateWorkspaceRepository,
  59. )
  60. DIGEST = "sha256:" + "a" * 64
  61. SCOPE = "script-build://scopes/opening"
  62. WRITE = "script-build://writes/paragraphs/opening"
  63. def _complete_paragraph() -> ScriptParagraphV1:
  64. atom = ({"原子点": "内容", "维度": "开场", "维度类型": "主维度"},)
  65. return ScriptParagraphV1(
  66. 1,
  67. 1,
  68. 1,
  69. None,
  70. "opening",
  71. {"beats": ["抛出问题", "给出答案"]},
  72. theme="为什么要主动改变",
  73. form="问题与回答",
  74. function="建立紧迫感",
  75. feeling="坚定且真诚",
  76. theme_elements=atom,
  77. form_elements=atom,
  78. function_elements=atom,
  79. feeling_elements=atom,
  80. description="先指出停滞的代价。再给出可执行的转折。",
  81. full_description="你可能一直在等待更好的时机。但真正的改变来自今天的第一步。",
  82. )
  83. def _complete_element() -> ScriptElementV1:
  84. return ScriptElementV1(1, "停滞代价", "实质", "核心论点")
  85. def _complete_link() -> ScriptParagraphElementLinkV1:
  86. return ScriptParagraphElementLinkV1(1, 1)
  87. def _lineage() -> CandidateLineageV1:
  88. return CandidateLineageV1(
  89. scope_ref=SCOPE,
  90. input_snapshot_ref="script-build://input-snapshots/11",
  91. input_closure_digest=DIGEST,
  92. write_scope=(WRITE,),
  93. goal_ids=("goal-1",),
  94. )
  95. def _source_artifacts() -> tuple[
  96. StructureArtifactV1,
  97. ParagraphArtifactV1,
  98. ElementSetArtifactV1,
  99. ]:
  100. paragraph = _complete_paragraph()
  101. element = _complete_element()
  102. link = _complete_link()
  103. lineage = _lineage()
  104. return (
  105. StructureArtifactV1(lineage, (paragraph,)),
  106. ParagraphArtifactV1(
  107. lineage,
  108. (paragraph,),
  109. (element,),
  110. (link,),
  111. {"created": {"paragraph_ids": [1]}},
  112. ),
  113. ElementSetArtifactV1(
  114. lineage,
  115. (element,),
  116. (link,),
  117. (paragraph,),
  118. {"created": {"element_ids": [1]}},
  119. ),
  120. )
  121. def _contract(
  122. kind: ScriptTaskKind,
  123. *,
  124. refs: tuple[AcceptedDecisionRef, ...] = (),
  125. comparison_refs: tuple[AcceptedDecisionRef, ...] = (),
  126. adopted: tuple[str, ...] = (),
  127. held: tuple[str, ...] = (),
  128. order: tuple[str, ...] = (),
  129. ) -> ScriptTaskContractV1:
  130. schemas = {
  131. ScriptTaskKind.PARAGRAPH: "paragraph-artifact/v1",
  132. ScriptTaskKind.EVIDENCE_RETRIEVAL: "evidence-record/v1",
  133. ScriptTaskKind.DECODE_RETRIEVAL: "evidence-record/v1",
  134. ScriptTaskKind.COMPARE: "comparison-artifact/v1",
  135. ScriptTaskKind.COMPOSE: "structured-script/v1",
  136. ScriptTaskKind.CANDIDATE_PORTFOLIO: "candidate-portfolio/v1",
  137. }
  138. return ScriptTaskContractV1(
  139. task_kind=kind,
  140. scope_ref=SCOPE,
  141. intent_class=(
  142. ScriptIntentClass.COMPOSE
  143. if kind is ScriptTaskKind.COMPOSE
  144. else ScriptIntentClass.COMPARE
  145. if kind is ScriptTaskKind.COMPARE
  146. else ScriptIntentClass.EXPLORE
  147. ),
  148. objective="produce one bounded candidate increment",
  149. input_decision_refs=refs if kind is ScriptTaskKind.PARAGRAPH else (),
  150. base_artifact_ref=None,
  151. write_scope=(WRITE,),
  152. gap_ref=None,
  153. output_schema=schemas[kind],
  154. criteria=(ScriptCriterion("closed", "output is concretely complete"),),
  155. budget=ScriptTaskBudget(),
  156. goal_ids=("goal-1",),
  157. candidate_closure_decision_refs=(
  158. refs if kind in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO} else ()
  159. ),
  160. adopted_decision_ids=adopted,
  161. held_or_rejected_decision_ids=held,
  162. compose_order=order,
  163. comparison_decision_refs=comparison_refs,
  164. )
  165. class _Bindings:
  166. def __init__(self, active_direction_artifact_version_id: int = 10) -> None:
  167. self.active_direction_artifact_version_id = active_direction_artifact_version_id
  168. async def get_by_root(self, root_trace_id: str) -> Any:
  169. return SimpleNamespace(
  170. root_trace_id=root_trace_id,
  171. script_build_id=7,
  172. input_snapshot_id=11,
  173. active_direction_artifact_version_id=self.active_direction_artifact_version_id,
  174. )
  175. class _TaskStore:
  176. def __init__(self, ledger: Any) -> None:
  177. self.ledger = ledger
  178. async def load(self, root_trace_id: str) -> Any:
  179. assert root_trace_id == "root"
  180. return self.ledger
  181. class _AcceptedInputs:
  182. def __init__(
  183. self, contracts: dict[str, ScriptTaskContractV1], bundle: AcceptedInputBundleV1
  184. ) -> None:
  185. self.contracts = contracts
  186. self.bundle = bundle
  187. async def contract_for_task(self, *, task: Any, **_: Any) -> ScriptTaskContractV1:
  188. return self.contracts[task.task_id]
  189. async def resolve(self, **_: Any) -> AcceptedInputBundleV1:
  190. return self.bundle
  191. class _FrameworkArtifacts:
  192. async def get(self, *_: Any) -> Any:
  193. raise AssertionError("framework snapshot should not be read")
  194. class _SnapshotArtifacts:
  195. def __init__(self, ref: ArtifactRef) -> None:
  196. self.ref = ref
  197. async def get(self, root_trace_id: str, snapshot_id: str) -> Any:
  198. assert (root_trace_id, snapshot_id) == ("root", "validation-snapshot")
  199. return SimpleNamespace(artifact_refs=[self.ref], evidence_refs=[])
  200. class _RawStore:
  201. def __init__(self, content: bytes) -> None:
  202. self.content = content
  203. async def read_bytes(self, ref: str) -> bytes:
  204. assert ref.endswith(sha256(self.content).hexdigest())
  205. return self.content
  206. class _Artifacts:
  207. def __init__(self, versions: dict[str, ArtifactVersion]) -> None:
  208. self.versions = versions
  209. self.frozen: ArtifactVersion | None = None
  210. async def read_by_ref(self, ref: ArtifactRef, **_: Any) -> ArtifactVersion:
  211. return self.versions[ref.uri]
  212. async def get_by_attempt(self, **owners: Any) -> ArtifactVersion:
  213. for value in self.versions.values():
  214. if value.attempt_id == owners["attempt_id"]:
  215. return value
  216. raise AssertionError("Attempt artifact not found")
  217. async def get_by_id(self, identifier: int, **_: Any) -> ArtifactVersion:
  218. for value in self.versions.values():
  219. if value.artifact_version_id == identifier:
  220. return value
  221. if identifier != 10:
  222. raise AssertionError("Artifact version not found")
  223. direction = DirectionArtifact(
  224. goals=(
  225. DirectionGoal(
  226. "goal-1",
  227. "complete the script",
  228. "the delivery needs one complete script",
  229. success_criteria=("the script is complete",),
  230. ),
  231. ),
  232. evidence_refs=("script-build://artifact-versions/90",),
  233. )
  234. return ArtifactVersion(
  235. 10,
  236. 7,
  237. "direction",
  238. "direction-attempt",
  239. 1,
  240. ArtifactKind.DIRECTION,
  241. DIGEST,
  242. ArtifactState.FROZEN,
  243. direction,
  244. datetime.now(UTC),
  245. datetime.now(UTC),
  246. )
  247. async def freeze(self, *, artifact: Any, **owners: Any) -> tuple[ArtifactVersion, ArtifactRef]:
  248. digest = canonical_sha256(artifact.content_payload()).wire
  249. artifact = replace(artifact, canonical_sha256=digest)
  250. artifact_kind = (
  251. ArtifactKind.COMPARISON
  252. if isinstance(artifact, ComparisonArtifactV1)
  253. else ArtifactKind.CANDIDATE_PORTFOLIO
  254. )
  255. version = ArtifactVersion(
  256. artifact_version_id=99,
  257. script_build_id=owners["script_build_id"],
  258. task_id=owners["task_id"],
  259. attempt_id=owners["attempt_id"],
  260. spec_version=owners["spec_version"],
  261. artifact_type=artifact_kind,
  262. canonical_sha256=digest,
  263. state=ArtifactState.FROZEN,
  264. artifact=artifact,
  265. created_at=datetime.now(UTC),
  266. frozen_at=datetime.now(UTC),
  267. )
  268. self.frozen = version
  269. ref = ArtifactRef(
  270. "script-build://artifact-versions/99",
  271. artifact_kind.value,
  272. "99",
  273. digest,
  274. )
  275. self.versions[ref.uri] = version
  276. return version, ref
  277. def _scope_fixture(
  278. contract: ScriptTaskContractV1,
  279. *,
  280. bundle: AcceptedInputBundleV1 | None = None,
  281. task_id: str = "task",
  282. attempt_id: str = "attempt",
  283. ) -> tuple[Any, dict[str, Any]]:
  284. task = SimpleNamespace(task_id=task_id, attempt_ids=[attempt_id])
  285. attempt = SimpleNamespace(
  286. attempt_id=attempt_id,
  287. task_id=task_id,
  288. spec_version=1,
  289. accepted_child_decision_ids=(),
  290. )
  291. ledger = SimpleNamespace(
  292. tasks={task_id: task},
  293. attempts={attempt_id: attempt},
  294. decisions={},
  295. revision=1,
  296. )
  297. accepted = _AcceptedInputs(
  298. {task_id: contract},
  299. bundle or AcceptedInputBundleV1((), DIGEST),
  300. )
  301. return (ledger, accepted), {
  302. "root_trace_id": "root",
  303. "task_id": task_id,
  304. "attempt_id": attempt_id,
  305. "spec_version": 1,
  306. }
  307. @pytest.mark.asyncio
  308. async def test_phase_one_manifest_does_not_require_candidate_workspace() -> None:
  309. contract = _contract(ScriptTaskKind.EVIDENCE_RETRIEVAL)
  310. (ledger, accepted), context = _scope_fixture(contract)
  311. evidence = EvidenceRecordV1(
  312. evidence_id="evidence",
  313. source_type="knowledge",
  314. tool_name="retrieval_pipeline",
  315. query={},
  316. source_refs=("source",),
  317. raw_artifact_ref=None,
  318. summary="bounded evidence",
  319. supports=("goal",),
  320. confidence="high",
  321. limitations=(),
  322. content_sha256=DIGEST,
  323. created_at=datetime.now(UTC),
  324. )
  325. version = ArtifactVersion(
  326. 1,
  327. 7,
  328. "task",
  329. "attempt",
  330. 1,
  331. ArtifactKind.EVIDENCE,
  332. DIGEST,
  333. ArtifactState.FROZEN,
  334. evidence,
  335. datetime.now(UTC),
  336. datetime.now(UTC),
  337. )
  338. service = PhaseTwoCandidateService(
  339. bindings=cast(Any, _Bindings()),
  340. task_store=_TaskStore(ledger),
  341. framework_artifact_store=_FrameworkArtifacts(),
  342. artifacts=cast(Any, _Artifacts({"script-build://artifact-versions/1": version})),
  343. accepted_inputs=accepted,
  344. active_frontier=ActiveFrontierResolver(),
  345. workspaces=None,
  346. )
  347. manifest = await service.resolve_attempt_manifest(context=context)
  348. assert manifest.artifact_ref.kind == "evidence"
  349. assert manifest.scope_ref == SCOPE
  350. @pytest.mark.asyncio
  351. async def test_paragraph_workspace_uses_positive_branch_and_freezes_on_manifest(
  352. database: Any,
  353. ) -> None:
  354. _, sessions = database
  355. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  356. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  357. contract = _contract(ScriptTaskKind.PARAGRAPH)
  358. (ledger, accepted), context = _scope_fixture(contract)
  359. service = PhaseTwoCandidateService(
  360. bindings=cast(Any, _Bindings()),
  361. task_store=_TaskStore(ledger),
  362. framework_artifact_store=_FrameworkArtifacts(),
  363. artifacts=repository,
  364. accepted_inputs=accepted,
  365. active_frontier=ActiveFrontierResolver(),
  366. workspaces=workspaces,
  367. )
  368. created = await service.create_script_paragraph(
  369. payload={"paragraph_index": 1, "name": "opening", "content_range": {}},
  370. context=context,
  371. )
  372. manifest = await service.resolve_attempt_manifest(context=context)
  373. version = await repository.get_by_attempt(
  374. script_build_id=7, task_id="task", attempt_id="attempt"
  375. )
  376. assert created["paragraph_id"] > 0
  377. assert version.artifact_version_id > 0
  378. assert manifest.artifact_ref.digest == version.canonical_sha256
  379. assert manifest.artifact_ref.kind == "paragraph"
  380. with pytest.raises(PhaseTwoCandidateError, match="WRITE_SCOPE_VIOLATION"):
  381. await service.create_script_paragraph(
  382. payload={
  383. "paragraph_index": 2,
  384. "name": "forged",
  385. "content_range": {},
  386. "branch_id": 0,
  387. },
  388. context=context,
  389. )
  390. @pytest.mark.asyncio
  391. async def test_semantic_paragraph_save_is_atomic_and_exact_replay_is_idempotent(
  392. database: Any,
  393. ) -> None:
  394. _, sessions = database
  395. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  396. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  397. contract = _contract(ScriptTaskKind.PARAGRAPH)
  398. (ledger, accepted), context = _scope_fixture(contract)
  399. service = PhaseTwoCandidateService(
  400. bindings=cast(Any, _Bindings()),
  401. task_store=_TaskStore(ledger),
  402. framework_artifact_store=_FrameworkArtifacts(),
  403. artifacts=repository,
  404. accepted_inputs=accepted,
  405. active_frontier=ActiveFrontierResolver(),
  406. workspaces=workspaces,
  407. )
  408. scope = await service._workspace_scope(context, paragraph=True)
  409. before = await workspaces.snapshot(scope.write_context)
  410. revision = _candidate_state_revision(scope, before, ledger.revision)
  411. atom = [{"原子点": "observable reversal", "维度": "opening", "维度类型": "主维度"}]
  412. payload = (
  413. {
  414. "client_key": "opening",
  415. "paragraph_index": 1,
  416. "name": "opening",
  417. "content_range": {"scope": "opening"},
  418. "level": 1,
  419. "theme_elements": atom,
  420. "form_elements": atom,
  421. "function_elements": atom,
  422. "feeling_elements": [{"原子点": "curiosity", "维度": "tone"}],
  423. "theme": "A visible detail changes the original assumption.",
  424. "form": "Concrete setup followed by a concise reversal.",
  425. "function": "Give the audience a reason to continue.",
  426. "feeling": "Measured surprise grounded in observation.",
  427. "description": "The opening overturns one assumption with evidence.",
  428. "full_description": (
  429. "Start from a familiar expectation and show exactly how the observed "
  430. "detail changes it."
  431. ),
  432. },
  433. )
  434. first = await service.save_script_paragraphs(
  435. paragraphs=payload,
  436. expected_state_revision=revision,
  437. context=context,
  438. )
  439. replay = await service.save_script_paragraphs(
  440. paragraphs=payload,
  441. expected_state_revision=revision,
  442. context=context,
  443. )
  444. assert first["status"] == "saved"
  445. assert first["created"] == 1
  446. assert replay["status"] == "not_modified"
  447. assert replay["created"] == replay["updated"] == 0
  448. assert replay["changed_target_keys"] == first["changed_target_keys"]
  449. assert len((await workspaces.snapshot(scope.write_context)).paragraphs) == 1
  450. changed_payload = (dict(payload[0], name="changed after stale projection"),)
  451. with pytest.raises(PhaseTwoCandidateError, match="STALE_WORKBENCH_STATE"):
  452. await service.save_script_paragraphs(
  453. paragraphs=changed_payload,
  454. expected_state_revision=revision,
  455. context=context,
  456. )
  457. @pytest.mark.asyncio
  458. async def test_paragraph_batch_maps_parent_keys_and_rolls_back_atomically(database: Any) -> None:
  459. _, sessions = database
  460. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  461. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  462. contract = _contract(ScriptTaskKind.PARAGRAPH)
  463. (ledger, accepted), context = _scope_fixture(contract)
  464. service = PhaseTwoCandidateService(
  465. bindings=cast(Any, _Bindings()),
  466. task_store=_TaskStore(ledger),
  467. framework_artifact_store=_FrameworkArtifacts(),
  468. artifacts=repository,
  469. accepted_inputs=accepted,
  470. active_frontier=ActiveFrontierResolver(),
  471. workspaces=workspaces,
  472. )
  473. created = await service.create_script_paragraphs(
  474. paragraphs=(
  475. {
  476. "client_key": "root",
  477. "paragraph_index": 1,
  478. "name": "opening",
  479. "content_range": {"beats": ["setup", "turn"]},
  480. "theme": "具体主题正文",
  481. "form": "对话漫画",
  482. "function": "建立冲突",
  483. "feeling": "好奇",
  484. "description": "角色在器械旁发生可见冲突。",
  485. "full_description": "角色先指责别人占用器械,随后被发现自己也在刷手机。",
  486. },
  487. {
  488. "client_key": "child",
  489. "parent_client_key": "root",
  490. "paragraph_index": 2,
  491. "level": 2,
  492. "name": "turn",
  493. "content_range": {"beats": ["turn"]},
  494. },
  495. ),
  496. context=context,
  497. )
  498. assert created["created"] == 2
  499. assert set(created["paragraph_ids_by_client_key"]) == {"root", "child"}
  500. workspace = await service.read_attempt_workspace(context=context)
  501. root_row = next(item for item in workspace["paragraphs"] if item["name"] == "opening")
  502. assert root_row["theme"] == "具体主题正文"
  503. assert root_row["full_description"].endswith("自己也在刷手机。")
  504. contract2 = replace(contract, objective="second isolated workspace")
  505. (ledger2, accepted2), context2 = _scope_fixture(
  506. contract2, task_id="task-2", attempt_id="attempt-2"
  507. )
  508. service2 = PhaseTwoCandidateService(
  509. bindings=cast(Any, _Bindings()),
  510. task_store=_TaskStore(ledger2),
  511. framework_artifact_store=_FrameworkArtifacts(),
  512. artifacts=repository,
  513. accepted_inputs=accepted2,
  514. active_frontier=ActiveFrontierResolver(),
  515. workspaces=workspaces,
  516. )
  517. with pytest.raises(WorkspaceError, match="child content range"):
  518. await service2.create_script_paragraphs(
  519. paragraphs=(
  520. {
  521. "client_key": "root",
  522. "paragraph_index": 1,
  523. "name": "root",
  524. "content_range": {"beats": ["setup"]},
  525. },
  526. {
  527. "client_key": "child",
  528. "parent_client_key": "root",
  529. "paragraph_index": 2,
  530. "level": 2,
  531. "name": "child",
  532. "content_range": {"beats": ["missing"]},
  533. },
  534. ),
  535. context=context2,
  536. )
  537. write_context = CandidateWriteContext(
  538. script_build_id=7,
  539. task_id="task-2",
  540. attempt_id="attempt-2",
  541. spec_version=1,
  542. objective=contract2.objective,
  543. input_refs=(),
  544. write_scope=contract2.write_scope,
  545. )
  546. assert not (await workspaces.snapshot(write_context)).paragraphs
  547. @pytest.mark.asyncio
  548. async def test_element_set_materializes_all_accepted_paragraphs_before_linking(
  549. database: Any,
  550. ) -> None:
  551. _, sessions = database
  552. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  553. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  554. paragraph = replace(
  555. _complete_paragraph(),
  556. theme_elements=(({"原子点": "内容", "维度": "主题", "维度类型": "主维度"}),),
  557. form_elements=(({"原子点": "对话", "维度": "形式", "维度类型": "主维度"}),),
  558. function_elements=(({"原子点": "冲突", "维度": "作用", "维度类型": "主维度"}),),
  559. feeling_elements=(({"原子点": "好奇", "维度": "感受"}),),
  560. )
  561. _, paragraph_ref = await repository.freeze(
  562. script_build_id=7,
  563. task_id="paragraph-source",
  564. attempt_id="paragraph-source-attempt",
  565. spec_version=1,
  566. artifact=ParagraphArtifactV1(
  567. _lineage(),
  568. (paragraph,),
  569. (),
  570. (),
  571. {"created": {"paragraph_ids": [1]}},
  572. ),
  573. )
  574. paragraph_ref = ArtifactRef(
  575. paragraph_ref.uri,
  576. paragraph_ref.kind,
  577. paragraph_ref.version,
  578. paragraph_ref.digest,
  579. )
  580. accepted_ref = AcceptedDecisionRef(
  581. "paragraph-decision",
  582. paragraph_ref,
  583. SCOPE,
  584. ScriptTaskKind.PARAGRAPH,
  585. )
  586. contract = ScriptTaskContractV1(
  587. task_kind=ScriptTaskKind.ELEMENT_SET,
  588. scope_ref=SCOPE,
  589. intent_class=ScriptIntentClass.EXPLORE,
  590. objective="attach concrete elements to every accepted paragraph",
  591. input_decision_refs=(accepted_ref,),
  592. base_artifact_ref=None,
  593. write_scope=("script-build://writes/elements",),
  594. gap_ref=None,
  595. output_schema="element-set-artifact/v1",
  596. criteria=(ScriptCriterion("closed", "all elements are linked"),),
  597. budget=ScriptTaskBudget(),
  598. goal_ids=("goal-1",),
  599. )
  600. bundle = AcceptedInputBundleV1(
  601. (
  602. AcceptedInput(
  603. "paragraph-decision",
  604. paragraph_ref,
  605. ScriptTaskKind.PARAGRAPH,
  606. SCOPE,
  607. "explicit",
  608. ),
  609. ),
  610. DIGEST,
  611. )
  612. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  613. service = PhaseTwoCandidateService(
  614. bindings=cast(Any, _Bindings()),
  615. task_store=_TaskStore(ledger),
  616. framework_artifact_store=_FrameworkArtifacts(),
  617. artifacts=repository,
  618. accepted_inputs=accepted,
  619. active_frontier=ActiveFrontierResolver(),
  620. workspaces=workspaces,
  621. )
  622. workspace = await service.read_attempt_workspace(context=context)
  623. assert len(workspace["paragraphs"]) == 1
  624. paragraph_id = workspace["paragraphs"][0]["paragraph_id"]
  625. assert paragraph_id > 0
  626. assert workspace["paragraphs"][0]["full_description"]
  627. with pytest.raises(WorkspaceError, match="link paragraph"):
  628. await service.create_script_elements(
  629. elements=(
  630. {
  631. "client_key": "invalid",
  632. "name": "不应落库的元素",
  633. "dimension_primary": "实质",
  634. "dimension_secondary": "回滚验证",
  635. },
  636. ),
  637. links=(
  638. {
  639. "paragraph_id": paragraph_id + 999,
  640. "element_client_keys": ["invalid"],
  641. },
  642. ),
  643. context=context,
  644. )
  645. rolled_back = await service.read_attempt_workspace(context=context)
  646. assert rolled_back["elements"] == []
  647. assert rolled_back["paragraph_element_links"] == []
  648. scope = await service._workspace_scope(context, element=True)
  649. before = await workspaces.snapshot(scope.write_context)
  650. revision = _candidate_state_revision(scope, before, ledger.revision)
  651. target = paragraph_target_key("root", "task", "attempt", paragraph_id)
  652. elements = (
  653. {
  654. "client_key": "machine",
  655. "name": "被占用的高位下拉机",
  656. "dimension_primary": "实质",
  657. "dimension_secondary": "冲突道具",
  658. },
  659. {
  660. "client_key": "bubble",
  661. "name": "占着不练的对话气泡",
  662. "dimension_primary": "形式",
  663. "dimension_secondary": "视觉文字",
  664. },
  665. )
  666. links = (
  667. {
  668. "paragraph_target_key": target,
  669. "element_client_keys": ["machine", "bubble"],
  670. },
  671. )
  672. created = await service.save_script_elements(
  673. elements=elements,
  674. links=links,
  675. expected_state_revision=revision,
  676. context=context,
  677. )
  678. replay = await service.save_script_elements(
  679. elements=elements,
  680. links=links,
  681. expected_state_revision=revision,
  682. context=context,
  683. )
  684. assert created["status"] == "saved"
  685. assert created["created"] == 2
  686. assert created["linked"] == 2
  687. assert replay["status"] == "not_modified"
  688. final = await workspaces.snapshot(scope.write_context)
  689. assert len(final.elements) == 2
  690. assert len(final.links) == 2
  691. changed_elements = (dict(elements[0], name="changed after stale projection"), elements[1])
  692. with pytest.raises(PhaseTwoCandidateError, match="STALE_WORKBENCH_STATE"):
  693. await service.save_script_elements(
  694. elements=changed_elements,
  695. links=links,
  696. expected_state_revision=revision,
  697. context=context,
  698. )
  699. manifest = await service.resolve_attempt_manifest(context=context)
  700. assert manifest.artifact_ref.kind == ArtifactKind.ELEMENT_SET.value
  701. @pytest.mark.asyncio
  702. async def test_element_seed_lineage_survives_full_compose_projection(database: Any) -> None:
  703. _, sessions = database
  704. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  705. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  706. paragraph = _complete_paragraph()
  707. structure_version, structure_ref = await artifacts.freeze(
  708. script_build_id=7,
  709. task_id="structure-source",
  710. attempt_id="structure-attempt",
  711. spec_version=1,
  712. artifact=StructureArtifactV1(_lineage(), (paragraph,)),
  713. )
  714. paragraph_lineage = replace(
  715. _lineage(),
  716. base_artifact_ref=structure_ref.uri,
  717. base_artifact_digest=structure_ref.digest,
  718. base_revision=structure_version.artifact_version_id,
  719. source_lineage=(
  720. {"entity": "paragraph", "local_id": 1, "source_local_id": 1},
  721. ),
  722. )
  723. paragraph_version, paragraph_ref = await artifacts.freeze(
  724. script_build_id=7,
  725. task_id="paragraph-source",
  726. attempt_id="paragraph-attempt",
  727. spec_version=1,
  728. artifact=ParagraphArtifactV1(
  729. paragraph_lineage,
  730. (paragraph,),
  731. (),
  732. (),
  733. {"created": {"paragraph_ids": [1]}},
  734. ),
  735. )
  736. element_context = CandidateWriteContext(
  737. script_build_id=7,
  738. task_id="element-task",
  739. attempt_id="element-attempt",
  740. spec_version=1,
  741. objective="link one grounded element",
  742. input_refs=(paragraph_ref.uri,),
  743. write_scope=("script-build://writes/elements",),
  744. )
  745. await workspaces.get_or_create(element_context, artifact_kind=ArtifactKind.ELEMENT_SET)
  746. seeded = await workspaces.seed_from_accepted_artifacts(
  747. element_context, (paragraph_version,)
  748. )
  749. seeded_id = seeded.paragraphs[0].paragraph_id
  750. await workspaces.create_elements(
  751. element_context,
  752. (
  753. {
  754. "client_key": "element",
  755. "name": "grounded element",
  756. "dimension_primary": "实质",
  757. "dimension_secondary": "观察",
  758. },
  759. ),
  760. ({"paragraph_id": seeded_id, "element_client_keys": ["element"]},),
  761. )
  762. element_version, element_ref = await workspaces.freeze(
  763. element_context,
  764. lineage=replace(
  765. _lineage(),
  766. write_scope=("script-build://writes/elements",),
  767. ),
  768. )
  769. assert element_version.artifact.lineage.source_lineage[0]["source_artifact_ref"] == (
  770. paragraph_ref.uri
  771. )
  772. compose_contract = replace(
  773. _contract(ScriptTaskKind.COMPOSE),
  774. write_scope=("script-build://writes",),
  775. )
  776. compose_context = CandidateWriteContext(
  777. script_build_id=7,
  778. task_id="compose-task",
  779. attempt_id="compose-attempt",
  780. spec_version=1,
  781. objective=compose_contract.objective,
  782. input_refs=(structure_ref.uri, paragraph_ref.uri, element_ref.uri),
  783. write_scope=compose_contract.write_scope,
  784. )
  785. await workspaces.get_or_create(
  786. compose_context, artifact_kind=ArtifactKind.STRUCTURED_SCRIPT
  787. )
  788. scope = CandidateScope(
  789. root_trace_id="root",
  790. script_build_id=7,
  791. input_snapshot_id="11",
  792. task=SimpleNamespace(task_id="compose-task"),
  793. attempt=SimpleNamespace(attempt_id="compose-attempt", spec_version=1),
  794. contract=compose_contract,
  795. write_context=compose_context,
  796. )
  797. service = PhaseTwoCandidateService(
  798. bindings=cast(Any, _Bindings()),
  799. task_store=cast(Any, _TaskStore(SimpleNamespace())),
  800. framework_artifact_store=_FrameworkArtifacts(),
  801. artifacts=artifacts,
  802. accepted_inputs=cast(Any, None),
  803. active_frontier=ActiveFrontierResolver(),
  804. workspaces=workspaces,
  805. )
  806. await service._materialize(
  807. scope,
  808. (structure_version, paragraph_version, element_version),
  809. (),
  810. )
  811. composed = await workspaces.snapshot(compose_context)
  812. assert len(composed.paragraphs) == 1
  813. assert len(composed.elements) == 1
  814. assert len(composed.links) == 1
  815. @pytest.mark.asyncio
  816. async def test_element_seed_disambiguates_same_local_id_and_replays_idempotently(
  817. database: Any,
  818. ) -> None:
  819. _, sessions = database
  820. artifacts = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  821. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, artifacts)
  822. versions: list[ArtifactVersion] = []
  823. refs: list[ArtifactRef] = []
  824. for index in (1, 2):
  825. paragraph = replace(
  826. _complete_paragraph(),
  827. name=f"paragraph-{index}",
  828. full_description=f"complete source paragraph {index}",
  829. )
  830. version, ref = await artifacts.freeze(
  831. script_build_id=7,
  832. task_id=f"paragraph-{index}",
  833. attempt_id=f"paragraph-attempt-{index}",
  834. spec_version=1,
  835. artifact=ParagraphArtifactV1(
  836. _lineage(),
  837. (paragraph,),
  838. (),
  839. (),
  840. {"created": {"paragraph_ids": [1]}},
  841. ),
  842. )
  843. versions.append(version)
  844. refs.append(ref)
  845. context = CandidateWriteContext(
  846. script_build_id=7,
  847. task_id="element-multi",
  848. attempt_id="element-multi-attempt",
  849. spec_version=1,
  850. objective="seed two accepted paragraphs",
  851. input_refs=tuple(item.uri for item in refs),
  852. write_scope=("script-build://writes/elements",),
  853. )
  854. await workspaces.get_or_create(context, artifact_kind=ArtifactKind.ELEMENT_SET)
  855. first = await workspaces.seed_from_accepted_artifacts(context, tuple(versions))
  856. replay = await workspaces.seed_from_accepted_artifacts(context, tuple(reversed(versions)))
  857. assert len(first.paragraphs) == len(replay.paragraphs) == 2
  858. assert {item["source_local_id"] for item in first.source_identity_map} == {1}
  859. assert {item["source_artifact_ref"] for item in first.source_identity_map} == {
  860. item.uri for item in refs
  861. }
  862. @pytest.mark.asyncio
  863. async def test_candidate_service_discards_existing_draft_workspace_on_abandoned_attempt(
  864. database: Any,
  865. ) -> None:
  866. _, sessions = database
  867. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  868. workspaces = SqlAlchemyCandidateWorkspaceRepository(sessions, repository)
  869. contract = _contract(ScriptTaskKind.PARAGRAPH)
  870. (ledger, accepted), context = _scope_fixture(contract)
  871. service = PhaseTwoCandidateService(
  872. bindings=cast(Any, _Bindings()),
  873. task_store=_TaskStore(ledger),
  874. framework_artifact_store=_FrameworkArtifacts(),
  875. artifacts=repository,
  876. accepted_inputs=accepted,
  877. active_frontier=ActiveFrontierResolver(),
  878. workspaces=workspaces,
  879. )
  880. await service.create_script_paragraph(
  881. payload={"paragraph_index": 1, "name": "draft", "content_range": {}},
  882. context=context,
  883. )
  884. await service.discard_attempt(
  885. root_trace_id="root",
  886. task_id="task",
  887. attempt_id="attempt",
  888. reason="validator blocked the bounded increment",
  889. )
  890. write_context = CandidateWriteContext(
  891. script_build_id=7,
  892. task_id="task",
  893. attempt_id="attempt",
  894. spec_version=1,
  895. objective=contract.objective,
  896. input_refs=(),
  897. write_scope=contract.write_scope,
  898. )
  899. assert (await workspaces.require(write_context)).state is ArtifactState.DISCARDED
  900. with pytest.raises(WorkspaceError, match="ATTEMPT_WORKSPACE_FROZEN"):
  901. await service.create_script_paragraph(
  902. payload={"paragraph_index": 2, "name": "late", "content_range": {}},
  903. context=context,
  904. )
  905. @pytest.mark.asyncio
  906. async def test_frozen_image_must_be_accepted_and_digest_and_mime_are_rechecked() -> None:
  907. content = b"\x89PNG\r\n\x1a\n" + b"safe"
  908. raw_ref = f"script-build://raw-artifacts/sha256/{sha256(content).hexdigest()}"
  909. ref = ArtifactRef("script-build://artifact-versions/2", "evidence", "2", DIGEST)
  910. item = AcceptedInput("d1", ref, ScriptTaskKind.DECODE_RETRIEVAL, SCOPE, "explicit")
  911. bundle = AcceptedInputBundleV1((item,), DIGEST)
  912. contract = _contract(ScriptTaskKind.PARAGRAPH)
  913. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  914. evidence = EvidenceRecordV1(
  915. "e",
  916. "decode",
  917. "retrieve_decode",
  918. {},
  919. ("source",),
  920. raw_ref,
  921. "summary",
  922. ("goal",),
  923. "high",
  924. (),
  925. DIGEST,
  926. datetime.now(UTC),
  927. )
  928. version = ArtifactVersion(
  929. 2,
  930. 7,
  931. "child",
  932. "child-attempt",
  933. 1,
  934. ArtifactKind.EVIDENCE,
  935. DIGEST,
  936. ArtifactState.FROZEN,
  937. evidence,
  938. datetime.now(UTC),
  939. datetime.now(UTC),
  940. )
  941. service = PhaseTwoCandidateService(
  942. bindings=cast(Any, _Bindings()),
  943. task_store=_TaskStore(ledger),
  944. framework_artifact_store=_FrameworkArtifacts(),
  945. artifacts=cast(Any, _Artifacts({ref.uri: version})),
  946. accepted_inputs=accepted,
  947. active_frontier=ActiveFrontierResolver(),
  948. workspaces=None,
  949. raw_artifacts=_RawStore(content),
  950. )
  951. images = await service.view_frozen_images(
  952. image_handles=[semantic_handle("image", raw_ref)], context=context
  953. )
  954. assert images[0]["media_type"] == "image/png"
  955. assert "url" not in images[0]
  956. with pytest.raises(PhaseTwoCandidateError, match="outside the accepted"):
  957. await service.view_frozen_images(
  958. image_handles=[semantic_handle("image", _RAW_OTHER)], context=context
  959. )
  960. @pytest.mark.asyncio
  961. async def test_active_frontier_write_conflict_is_not_resolved_by_completion_order() -> None:
  962. ref1 = ArtifactRef("script-build://artifact-versions/1", "paragraph", "1", DIGEST)
  963. ref2 = ArtifactRef("script-build://artifact-versions/2", "paragraph", "2", DIGEST)
  964. refs = (
  965. AcceptedDecisionRef("d1", ref1, SCOPE, ScriptTaskKind.PARAGRAPH),
  966. AcceptedDecisionRef("d2", ref2, SCOPE, ScriptTaskKind.PARAGRAPH),
  967. )
  968. contract = _contract(
  969. ScriptTaskKind.COMPOSE, refs=refs, adopted=("d1", "d2"), order=("d1", "d2")
  970. )
  971. bundle = AcceptedInputBundleV1(
  972. (
  973. AcceptedInput("d1", ref1, ScriptTaskKind.PARAGRAPH, SCOPE, "explicit"),
  974. AcceptedInput("d2", ref2, ScriptTaskKind.PARAGRAPH, SCOPE, "explicit"),
  975. ),
  976. DIGEST,
  977. )
  978. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  979. child_contract = _contract(ScriptTaskKind.PARAGRAPH)
  980. for decision_id in ("d1", "d2"):
  981. task_id = f"task-{decision_id}"
  982. ledger.tasks[task_id] = SimpleNamespace(task_id=task_id, attempt_ids=[f"a-{decision_id}"])
  983. ledger.attempts[f"a-{decision_id}"] = SimpleNamespace(
  984. attempt_id=f"a-{decision_id}", task_id=task_id, spec_version=1
  985. )
  986. ledger.decisions[decision_id] = SimpleNamespace(
  987. task_id=task_id, attempt_id=f"a-{decision_id}"
  988. )
  989. accepted.contracts[task_id] = child_contract
  990. service = PhaseTwoCandidateService(
  991. bindings=cast(Any, _Bindings()),
  992. task_store=_TaskStore(ledger),
  993. framework_artifact_store=_FrameworkArtifacts(),
  994. artifacts=cast(Any, _Artifacts({})),
  995. accepted_inputs=accepted,
  996. active_frontier=ActiveFrontierResolver(),
  997. workspaces=None,
  998. )
  999. with pytest.raises(PhaseTwoInputError, match="WRITE_SCOPE_CONFLICT"):
  1000. await service.read_active_frontier(context=context)
  1001. @pytest.mark.asyncio
  1002. async def test_comparison_candidates_are_contract_derived_and_pass_fairness_precheck() -> None:
  1003. candidate_refs = (
  1004. ArtifactRef("script-build://artifact-versions/1", "paragraph", "1", DIGEST),
  1005. ArtifactRef("script-build://artifact-versions/2", "paragraph", "2", DIGEST),
  1006. )
  1007. decision_refs = tuple(
  1008. AcceptedDecisionRef(
  1009. f"candidate-{index}",
  1010. ref,
  1011. SCOPE,
  1012. ScriptTaskKind.PARAGRAPH,
  1013. )
  1014. for index, ref in enumerate(candidate_refs, start=1)
  1015. )
  1016. bundle = AcceptedInputBundleV1(
  1017. tuple(
  1018. AcceptedInput(
  1019. decision_ref.decision_id,
  1020. decision_ref.artifact_ref,
  1021. ScriptTaskKind.PARAGRAPH,
  1022. SCOPE,
  1023. "explicit",
  1024. )
  1025. for decision_ref in decision_refs
  1026. ),
  1027. DIGEST,
  1028. )
  1029. contract = _contract(ScriptTaskKind.COMPARE, comparison_refs=decision_refs)
  1030. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  1031. artifacts = _Artifacts({})
  1032. service = PhaseTwoCandidateService(
  1033. bindings=cast(Any, _Bindings()),
  1034. task_store=_TaskStore(ledger),
  1035. framework_artifact_store=_FrameworkArtifacts(),
  1036. artifacts=cast(Any, artifacts),
  1037. accepted_inputs=accepted,
  1038. active_frontier=ActiveFrontierResolver(),
  1039. workspaces=None,
  1040. )
  1041. criterion_results = [
  1042. {
  1043. "criterion_id": "closed",
  1044. "candidate_results": [
  1045. {"decision_id": "candidate-1", "reason": "uses one visible detail"},
  1046. {"decision_id": "candidate-2", "reason": "remains more abstract"},
  1047. ],
  1048. }
  1049. ]
  1050. frozen = await service.save_comparison_candidate(
  1051. payload={
  1052. "criterion_results": criterion_results,
  1053. "conflicts": ["both candidates write the opening scope"],
  1054. "recommended_decision_id": "candidate-1",
  1055. },
  1056. context=context,
  1057. )
  1058. assert artifacts.frozen is not None
  1059. comparison = artifacts.frozen.artifact
  1060. assert isinstance(comparison, ComparisonArtifactV1)
  1061. assert comparison.candidate_artifact_refs == tuple(ref.uri for ref in candidate_refs)
  1062. assert [
  1063. item["artifact_ref"] for item in comparison.criterion_results[0]["candidate_results"]
  1064. ] == [ref.uri for ref in candidate_refs]
  1065. frozen_ref = ArtifactRef.from_dict(frozen["artifact_ref"])
  1066. service._framework_artifact_store = _SnapshotArtifacts(frozen_ref)
  1067. rules = await service.deterministic_precheck(
  1068. context={**context, "snapshot_id": "validation-snapshot"}
  1069. )
  1070. assert {str(item["rule_id"]): item["verdict"] for item in rules} == {
  1071. "business-artifact-owner": "passed",
  1072. "realized-content": "passed",
  1073. "candidate-lineage": "passed",
  1074. "comparison-fairness": "passed",
  1075. }
  1076. with pytest.raises(PhaseTwoCandidateError, match="derived from the frozen contract"):
  1077. await service.save_comparison_candidate(
  1078. payload={
  1079. "candidate_artifact_refs": [candidate_refs[1].uri],
  1080. "criterion_results": criterion_results,
  1081. "recommended_decision_id": "candidate-2",
  1082. },
  1083. context=context,
  1084. )
  1085. @pytest.mark.asyncio
  1086. async def test_portfolio_adoption_and_canonical_digest_come_from_frozen_contract() -> None:
  1087. ref1 = ArtifactRef("script-build://artifact-versions/1", "structured_script", "1", DIGEST)
  1088. ref2 = ArtifactRef("script-build://artifact-versions/2", "structured_script", "2", DIGEST)
  1089. refs = (
  1090. AcceptedDecisionRef("d1", ref1, SCOPE, ScriptTaskKind.COMPOSE),
  1091. AcceptedDecisionRef("d2", ref2, SCOPE, ScriptTaskKind.COMPOSE),
  1092. )
  1093. contract = _contract(
  1094. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  1095. refs=refs,
  1096. adopted=("d1",),
  1097. held=("d2",),
  1098. order=("d1",),
  1099. )
  1100. bundle = AcceptedInputBundleV1(
  1101. (
  1102. AcceptedInput("d1", ref1, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  1103. AcceptedInput("d2", ref2, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  1104. ),
  1105. DIGEST,
  1106. )
  1107. (ledger, accepted), context = _scope_fixture(contract, bundle=bundle)
  1108. paragraph = _complete_paragraph()
  1109. element = _complete_element()
  1110. link = _complete_link()
  1111. def source_versions(identifier: int) -> dict[str, ArtifactVersion]:
  1112. base = 20 + identifier * 10
  1113. versions: dict[str, ArtifactVersion] = {}
  1114. for offset, (kind, artifact) in enumerate(
  1115. zip(
  1116. (
  1117. ArtifactKind.STRUCTURE,
  1118. ArtifactKind.PARAGRAPH,
  1119. ArtifactKind.ELEMENT_SET,
  1120. ),
  1121. _source_artifacts(),
  1122. strict=True,
  1123. ),
  1124. start=1,
  1125. ):
  1126. version_id = base + offset
  1127. uri = f"script-build://artifact-versions/{version_id}"
  1128. versions[uri] = ArtifactVersion(
  1129. version_id,
  1130. 7,
  1131. f"source-{version_id}",
  1132. f"source-attempt-{version_id}",
  1133. 1,
  1134. kind,
  1135. DIGEST,
  1136. ArtifactState.FROZEN,
  1137. artifact,
  1138. datetime.now(UTC),
  1139. datetime.now(UTC),
  1140. )
  1141. return versions
  1142. def structured(identifier: int) -> ArtifactVersion:
  1143. source_refs = tuple(source_versions(identifier))
  1144. artifact = StructuredScriptArtifactV1(
  1145. direction_ref="script-build://artifact-versions/10",
  1146. input_closure_digest=DIGEST,
  1147. paragraphs=(paragraph,),
  1148. elements=(element,),
  1149. paragraph_element_links=(link,),
  1150. source_artifact_refs=source_refs,
  1151. goal_coverage=(GoalCoverage("goal-1", (source_refs[1], source_refs[2])),),
  1152. evidence_refs=(),
  1153. acceptance_notes=("accepted",),
  1154. canonical_sha256=DIGEST,
  1155. )
  1156. return ArtifactVersion(
  1157. identifier,
  1158. 7,
  1159. f"compose-{identifier}",
  1160. f"compose-attempt-{identifier}",
  1161. 1,
  1162. ArtifactKind.STRUCTURED_SCRIPT,
  1163. DIGEST,
  1164. ArtifactState.FROZEN,
  1165. artifact,
  1166. datetime.now(UTC),
  1167. datetime.now(UTC),
  1168. )
  1169. versions = {
  1170. **source_versions(1),
  1171. **source_versions(2),
  1172. ref1.uri: structured(1),
  1173. ref2.uri: structured(2),
  1174. }
  1175. artifacts = _Artifacts(versions)
  1176. service = PhaseTwoCandidateService(
  1177. bindings=cast(Any, _Bindings()),
  1178. task_store=_TaskStore(ledger),
  1179. framework_artifact_store=_FrameworkArtifacts(),
  1180. artifacts=cast(Any, artifacts),
  1181. accepted_inputs=accepted,
  1182. active_frontier=ActiveFrontierResolver(),
  1183. workspaces=None,
  1184. )
  1185. result = await service.save_candidate_portfolio(
  1186. payload={
  1187. "unresolved_defects": [
  1188. {
  1189. "severity": "warning",
  1190. "defect_code": "MINOR_STYLE",
  1191. "criterion_id": "closed",
  1192. "scope_ref": SCOPE,
  1193. "observed_excerpt": "opening cadence is slightly repetitive",
  1194. "evidence_refs": [],
  1195. "invalidated_inputs": [],
  1196. "recommended_action_class": "revise",
  1197. }
  1198. ],
  1199. },
  1200. context=context,
  1201. )
  1202. assert artifacts.frozen is not None
  1203. portfolio = artifacts.frozen.artifact
  1204. assert isinstance(portfolio, CandidatePortfolioArtifactV1)
  1205. assert portfolio.accepted_decision_ids == ("d1",)
  1206. assert portfolio.rejected_or_held_decision_ids == ("d2",)
  1207. assert result["artifact_ref"]["digest"] == canonical_sha256(portfolio.content_payload()).wire
  1208. with pytest.raises(PhaseTwoCandidateError, match="outside the accepted closure"):
  1209. await service.save_candidate_portfolio(
  1210. payload={
  1211. "unresolved_defects": [
  1212. {
  1213. "severity": "warning",
  1214. "defect_code": "UNTRUSTED_EVIDENCE",
  1215. "criterion_id": "closed",
  1216. "scope_ref": SCOPE,
  1217. "observed_excerpt": "unsupported warning",
  1218. "evidence_refs": ["script-build://artifact-versions/999"],
  1219. "invalidated_inputs": [],
  1220. "recommended_action_class": "revise",
  1221. }
  1222. ],
  1223. },
  1224. context=context,
  1225. )
  1226. with pytest.raises(ProtocolViolation, match=r"unsupported enum|must not be blank"):
  1227. await service.save_candidate_portfolio(
  1228. payload={
  1229. "unresolved_defects": [
  1230. {"severity": "warning", "defect_code": "INCOMPLETE_WARNING"}
  1231. ],
  1232. },
  1233. context=context,
  1234. )
  1235. with pytest.raises(PhaseTwoCandidateError, match="derived from the frozen contract"):
  1236. await service.save_candidate_portfolio(
  1237. payload={"adopted_structured_script_ref": ref2.uri}, context=context
  1238. )
  1239. @pytest.mark.asyncio
  1240. async def test_candidate_portfolio_command_replay_reuses_one_frozen_version(database: Any) -> None:
  1241. _, sessions = database
  1242. repository = SqlAlchemyScriptBusinessArtifactRepository(sessions)
  1243. paragraph = _complete_paragraph()
  1244. element = _complete_element()
  1245. link = _complete_link()
  1246. direction_version, direction_ref = await repository.freeze(
  1247. script_build_id=7,
  1248. task_id="direction",
  1249. attempt_id="direction-attempt",
  1250. spec_version=1,
  1251. artifact=DirectionArtifact(
  1252. goals=(
  1253. DirectionGoal(
  1254. "goal-1",
  1255. "complete the script",
  1256. "the delivery needs one complete script",
  1257. success_criteria=("the script is complete",),
  1258. ),
  1259. ),
  1260. evidence_refs=("script-build://artifact-versions/90",),
  1261. ),
  1262. )
  1263. async def freeze_structured(identifier: int) -> tuple[ArtifactVersion, ArtifactRef]:
  1264. structure, paragraph_source, element_source = _source_artifacts()
  1265. source_refs: list[str] = []
  1266. for suffix, source in (
  1267. ("structure", structure),
  1268. ("paragraph", paragraph_source),
  1269. ("elements", element_source),
  1270. ):
  1271. _, source_ref = await repository.freeze(
  1272. script_build_id=7,
  1273. task_id=f"{suffix}-{identifier}",
  1274. attempt_id=f"{suffix}-attempt-{identifier}",
  1275. spec_version=1,
  1276. artifact=source,
  1277. )
  1278. source_refs.append(source_ref.uri)
  1279. return await repository.freeze(
  1280. script_build_id=7,
  1281. task_id=f"compose-{identifier}",
  1282. attempt_id=f"compose-attempt-{identifier}",
  1283. spec_version=1,
  1284. artifact=StructuredScriptArtifactV1(
  1285. direction_ref=direction_ref.uri,
  1286. input_closure_digest=DIGEST,
  1287. paragraphs=(paragraph,),
  1288. elements=(element,),
  1289. paragraph_element_links=(link,),
  1290. source_artifact_refs=tuple(source_refs),
  1291. goal_coverage=(GoalCoverage("goal-1", (source_refs[1], source_refs[2])),),
  1292. evidence_refs=(),
  1293. acceptance_notes=("accepted",),
  1294. ),
  1295. )
  1296. (_, stored_ref1), (_, stored_ref2) = await freeze_structured(1), await freeze_structured(2)
  1297. ref1 = ArtifactRef(
  1298. stored_ref1.uri,
  1299. stored_ref1.kind,
  1300. stored_ref1.version,
  1301. stored_ref1.digest,
  1302. )
  1303. ref2 = ArtifactRef(
  1304. stored_ref2.uri,
  1305. stored_ref2.kind,
  1306. stored_ref2.version,
  1307. stored_ref2.digest,
  1308. )
  1309. refs = (
  1310. AcceptedDecisionRef("d1", ref1, SCOPE, ScriptTaskKind.COMPOSE),
  1311. AcceptedDecisionRef("d2", ref2, SCOPE, ScriptTaskKind.COMPOSE),
  1312. )
  1313. contract = _contract(
  1314. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  1315. refs=refs,
  1316. adopted=("d1",),
  1317. held=("d2",),
  1318. order=("d1",),
  1319. )
  1320. bundle = AcceptedInputBundleV1(
  1321. (
  1322. AcceptedInput("d1", ref1, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  1323. AcceptedInput("d2", ref2, ScriptTaskKind.COMPOSE, SCOPE, "explicit"),
  1324. ),
  1325. DIGEST,
  1326. )
  1327. (ledger, accepted), context = _scope_fixture(
  1328. contract,
  1329. bundle=bundle,
  1330. task_id="portfolio",
  1331. attempt_id="portfolio-attempt",
  1332. )
  1333. service = PhaseTwoCandidateService(
  1334. bindings=cast(Any, _Bindings(direction_version.artifact_version_id)),
  1335. task_store=_TaskStore(ledger),
  1336. framework_artifact_store=_FrameworkArtifacts(),
  1337. artifacts=repository,
  1338. accepted_inputs=accepted,
  1339. active_frontier=ActiveFrontierResolver(),
  1340. workspaces=None,
  1341. )
  1342. first = await service.save_candidate_portfolio(
  1343. payload={"unresolved_defects": []}, context=context
  1344. )
  1345. replay = await service.save_candidate_portfolio(
  1346. payload={"unresolved_defects": []}, context=context
  1347. )
  1348. frozen = await repository.get_by_attempt(
  1349. script_build_id=7,
  1350. task_id="portfolio",
  1351. attempt_id="portfolio-attempt",
  1352. )
  1353. assert first == replay
  1354. assert first["artifact_ref"]["version"] == str(frozen.artifact_version_id)
  1355. assert frozen.artifact_type is ArtifactKind.CANDIDATE_PORTFOLIO
  1356. _RAW_OTHER = "script-build://raw-artifacts/sha256/" + "b" * 64