test_phase_two_boundary.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. from __future__ import annotations
  2. import hashlib
  3. import json
  4. from dataclasses import replace
  5. from datetime import UTC, datetime
  6. import pytest
  7. from agent.orchestration import (
  8. AcceptanceCriterion,
  9. ArtifactRef,
  10. ArtifactSnapshot,
  11. AttemptStatus,
  12. AttemptSubmission,
  13. DecisionAction,
  14. PlannerDecision,
  15. TaskAttempt,
  16. TaskLedger,
  17. TaskRecord,
  18. TaskSpec,
  19. TaskStatus,
  20. ValidationReport,
  21. ValidationRunStatus,
  22. ValidationVerdict,
  23. )
  24. from script_build_host.application.phase_two_boundary import ScriptPhaseTwoBoundaryVerifier
  25. from script_build_host.application.phase_two_inputs import AcceptedInputResolver
  26. from script_build_host.domain.artifacts import (
  27. ArtifactKind,
  28. ArtifactState,
  29. ArtifactVersion,
  30. DirectionArtifact,
  31. DirectionGoal,
  32. )
  33. from script_build_host.domain.errors import PhaseTwoBoundaryNotReady
  34. from script_build_host.domain.phase_two_artifacts import (
  35. CandidateLineageV1,
  36. CandidatePortfolioArtifactV1,
  37. ElementSetArtifactV1,
  38. GoalCoverage,
  39. ParagraphArtifactV1,
  40. ScriptElementV1,
  41. ScriptParagraphElementLinkV1,
  42. ScriptParagraphV1,
  43. StructureArtifactV1,
  44. StructuredScriptArtifactV1,
  45. )
  46. from script_build_host.domain.records import MissionBinding
  47. from script_build_host.domain.task_contracts import (
  48. AcceptedDecisionRef,
  49. ScriptCriterion,
  50. ScriptIntentClass,
  51. ScriptTaskBudget,
  52. ScriptTaskContractV1,
  53. ScriptTaskKind,
  54. )
  55. SNAPSHOT_ID = "11"
  56. INPUT_DIGEST = "sha256:" + "c" * 64
  57. SCRIPT_DIGEST = "sha256:" + "a" * 64
  58. PORTFOLIO_DIGEST = "sha256:" + "b" * 64
  59. SCOPE = "script-build://scopes/full"
  60. WRITE = "script-build://writes/full"
  61. def _complete_paragraph() -> ScriptParagraphV1:
  62. atom = ({"原子点": "反转", "维度": "开场", "维度类型": "主维度"},)
  63. return ScriptParagraphV1(
  64. 1,
  65. 1,
  66. 1,
  67. None,
  68. "opening",
  69. {"scope": "opening"},
  70. theme="具体反转",
  71. form="对比",
  72. function="吸引注意",
  73. feeling="好奇",
  74. theme_elements=atom,
  75. form_elements=atom,
  76. function_elements=atom,
  77. feeling_elements=atom,
  78. description="用可观察的细节推翻初始判断。",
  79. full_description="大家以为问题来自天赋。但一个真实行动已经改变了结果。",
  80. )
  81. def _task(task_id: str, parent: str | None, kind: str, status: TaskStatus) -> TaskRecord:
  82. return TaskRecord(
  83. task_id=task_id,
  84. parent_task_id=parent,
  85. display_path=task_id,
  86. specs=[
  87. TaskSpec(
  88. version=1,
  89. objective="bounded",
  90. acceptance_criteria=(AcceptanceCriterion("closed", "closed"),),
  91. context_refs=(
  92. f"script-build://task-kinds/{kind}",
  93. f"script-build://inputs/{SNAPSHOT_ID}",
  94. ),
  95. )
  96. ],
  97. status=status,
  98. )
  99. def _compose_contract() -> ScriptTaskContractV1:
  100. return ScriptTaskContractV1(
  101. ScriptTaskKind.COMPOSE,
  102. SCOPE,
  103. ScriptIntentClass.COMPOSE,
  104. "compose one complete candidate",
  105. (),
  106. None,
  107. (WRITE,),
  108. None,
  109. "structured-script/v1",
  110. (ScriptCriterion("closed", "closed"),),
  111. ScriptTaskBudget(),
  112. ("goal-1",),
  113. )
  114. def _portfolio_contract(ref: ArtifactRef) -> ScriptTaskContractV1:
  115. accepted = AcceptedDecisionRef("decision-compose", ref, SCOPE, ScriptTaskKind.COMPOSE)
  116. return ScriptTaskContractV1(
  117. ScriptTaskKind.CANDIDATE_PORTFOLIO,
  118. SCOPE,
  119. ScriptIntentClass.PORTFOLIO,
  120. "govern the closed candidate set",
  121. (accepted,),
  122. None,
  123. (WRITE,),
  124. None,
  125. "candidate-portfolio/v1",
  126. (ScriptCriterion("closed", "closed"),),
  127. ScriptTaskBudget(),
  128. ("goal-1",),
  129. candidate_closure_decision_refs=(accepted,),
  130. adopted_decision_ids=("decision-compose",),
  131. compose_order=("decision-compose",),
  132. )
  133. class _Store:
  134. def __init__(self, value: object) -> None:
  135. self.value = value
  136. async def load(self, root: str) -> TaskLedger:
  137. assert root == "root"
  138. assert isinstance(self.value, TaskLedger)
  139. return self.value
  140. async def get(self, root: str, snapshot: str) -> ArtifactSnapshot:
  141. assert root == "root"
  142. return self.value[snapshot] # type: ignore[index]
  143. class _Contracts:
  144. def __init__(self, values: dict[str, ScriptTaskContractV1]) -> None:
  145. self.values = values
  146. async def read_for_task(
  147. self, *, root_trace_id: str, task: TaskRecord, spec_version: int | None = None
  148. ) -> ScriptTaskContractV1:
  149. assert root_trace_id == "root" and spec_version == 1
  150. return self.values[task.task_id]
  151. class _Artifacts:
  152. def __init__(self, versions: dict[int, ArtifactVersion]) -> None:
  153. self.versions = versions
  154. async def read_by_ref(
  155. self,
  156. ref: ArtifactRef,
  157. *,
  158. script_build_id: int,
  159. task_id: str | None = None,
  160. attempt_id: str | None = None,
  161. ) -> ArtifactVersion:
  162. assert script_build_id == 7 and ref.version is not None
  163. value = self.versions[int(ref.version)]
  164. assert value.task_id == task_id and value.attempt_id == attempt_id
  165. assert value.canonical_sha256 == ref.digest
  166. return value
  167. async def get_by_id(self, identifier: int, *, script_build_id: int) -> ArtifactVersion:
  168. assert script_build_id == 7
  169. return self.versions[identifier]
  170. class _Bindings:
  171. async def get_by_build(self, script_build_id: int) -> MissionBinding:
  172. assert script_build_id == 7
  173. now = datetime.now(UTC)
  174. return MissionBinding(1, 7, "root", 11, 9, None, "test", "v1", now, now)
  175. def _version(
  176. identifier: int,
  177. task_id: str,
  178. attempt_id: str,
  179. kind: ArtifactKind,
  180. digest: str,
  181. artifact: object,
  182. ) -> ArtifactVersion:
  183. now = datetime.now(UTC)
  184. return ArtifactVersion(
  185. identifier,
  186. 7,
  187. task_id,
  188. attempt_id,
  189. 1,
  190. kind,
  191. digest,
  192. ArtifactState.FROZEN,
  193. artifact, # type: ignore[arg-type]
  194. now,
  195. now,
  196. )
  197. async def _fixture() -> tuple[ScriptPhaseTwoBoundaryVerifier, TaskLedger]:
  198. script_ref = ArtifactRef(
  199. "script-build://artifact-versions/1",
  200. ArtifactKind.STRUCTURED_SCRIPT.value,
  201. "1",
  202. SCRIPT_DIGEST,
  203. )
  204. root = _task("root-task", None, "root", TaskStatus.BLOCKED)
  205. portfolio_task = _task("portfolio", "root-task", "candidate-portfolio", TaskStatus.RUNNING)
  206. compose_task = _task("compose", "portfolio", "compose", TaskStatus.COMPLETED)
  207. root.child_task_ids.append("portfolio")
  208. portfolio_task.child_task_ids.append("compose")
  209. compose_attempt = TaskAttempt(
  210. "attempt-compose",
  211. "compose",
  212. 1,
  213. "worker-compose",
  214. "script_compose_worker",
  215. "new",
  216. (),
  217. AttemptStatus.SUBMITTED,
  218. snapshot_id="snapshot-compose",
  219. submission=AttemptSubmission("closed", artifact_refs=[script_ref]),
  220. )
  221. compose_validation = ValidationReport(
  222. "validation-compose",
  223. "compose",
  224. "attempt-compose",
  225. 1,
  226. "snapshot-compose",
  227. "validator-compose",
  228. status=ValidationRunStatus.COMPLETED,
  229. verdict=ValidationVerdict.PASSED,
  230. )
  231. compose_decision = PlannerDecision(
  232. "decision-compose",
  233. "compose",
  234. DecisionAction.ACCEPT,
  235. "passed",
  236. TaskStatus.AWAITING_DECISION,
  237. TaskStatus.COMPLETED,
  238. "attempt-compose",
  239. "validation-compose",
  240. )
  241. compose_task.attempt_ids.append("attempt-compose")
  242. compose_task.validation_ids.append("validation-compose")
  243. compose_task.decision_ids.append("decision-compose")
  244. portfolio_attempt = TaskAttempt(
  245. "attempt-portfolio",
  246. "portfolio",
  247. 1,
  248. "worker-portfolio",
  249. "script_candidate_portfolio_worker",
  250. "new",
  251. ("decision-compose",),
  252. )
  253. ledger = TaskLedger(
  254. "root",
  255. "mission",
  256. "root-task",
  257. tasks={item.task_id: item for item in (root, portfolio_task, compose_task)},
  258. attempts={
  259. compose_attempt.attempt_id: compose_attempt,
  260. portfolio_attempt.attempt_id: portfolio_attempt,
  261. },
  262. validations={compose_validation.validation_id: compose_validation},
  263. decisions={compose_decision.decision_id: compose_decision},
  264. )
  265. compose_normalized = _normalized("closed", script_ref)
  266. snapshots = {
  267. "snapshot-compose": ArtifactSnapshot(
  268. "snapshot-compose",
  269. "attempt-compose",
  270. compose_normalized,
  271. _snapshot_sha(compose_normalized),
  272. [script_ref],
  273. [],
  274. )
  275. }
  276. paragraph = _complete_paragraph()
  277. element = ScriptElementV1(1, "可观察反转", "实质", "叙事证据")
  278. link = ScriptParagraphElementLinkV1(1, 1)
  279. lineage = CandidateLineageV1(
  280. scope_ref=SCOPE,
  281. input_snapshot_ref=f"script-build://inputs/{SNAPSHOT_ID}",
  282. input_closure_digest=INPUT_DIGEST,
  283. write_scope=("script-build://writes/paragraphs",),
  284. goal_ids=("goal-1",),
  285. )
  286. structured = StructuredScriptArtifactV1(
  287. "script-build://artifact-versions/9",
  288. INPUT_DIGEST,
  289. (paragraph,),
  290. (element,),
  291. (link,),
  292. (
  293. "script-build://artifact-versions/6",
  294. "script-build://artifact-versions/7",
  295. "script-build://artifact-versions/8",
  296. ),
  297. (
  298. GoalCoverage(
  299. "goal-1",
  300. (
  301. "script-build://artifact-versions/7",
  302. "script-build://artifact-versions/8",
  303. ),
  304. ),
  305. ),
  306. (),
  307. (),
  308. SCRIPT_DIGEST,
  309. )
  310. artifacts = _Artifacts(
  311. {
  312. 1: _version(
  313. 1,
  314. "compose",
  315. "attempt-compose",
  316. ArtifactKind.STRUCTURED_SCRIPT,
  317. SCRIPT_DIGEST,
  318. structured,
  319. ),
  320. 9: _version(
  321. 9,
  322. "direction",
  323. "attempt-direction",
  324. ArtifactKind.DIRECTION,
  325. INPUT_DIGEST,
  326. DirectionArtifact(
  327. goals=(
  328. DirectionGoal(
  329. "goal-1",
  330. "complete the script",
  331. "the delivery needs one complete script",
  332. success_criteria=("the script is complete",),
  333. ),
  334. ),
  335. evidence_refs=("script-build://artifact-versions/99",),
  336. ),
  337. ),
  338. 6: _version(
  339. 6,
  340. "structure",
  341. "attempt-structure",
  342. ArtifactKind.STRUCTURE,
  343. INPUT_DIGEST,
  344. StructureArtifactV1(lineage, (paragraph,)),
  345. ),
  346. 7: _version(
  347. 7,
  348. "paragraph",
  349. "attempt-paragraph",
  350. ArtifactKind.PARAGRAPH,
  351. INPUT_DIGEST,
  352. ParagraphArtifactV1(
  353. lineage,
  354. (paragraph,),
  355. (element,),
  356. (link,),
  357. {"created": {"paragraph_ids": [1]}},
  358. ),
  359. ),
  360. 8: _version(
  361. 8,
  362. "elements",
  363. "attempt-elements",
  364. ArtifactKind.ELEMENT_SET,
  365. INPUT_DIGEST,
  366. ElementSetArtifactV1(
  367. replace(lineage, write_scope=("script-build://writes/elements",)),
  368. (element,),
  369. (link,),
  370. (paragraph,),
  371. {"created": {"element_ids": [1]}},
  372. ),
  373. ),
  374. }
  375. )
  376. contracts = _Contracts(
  377. {"compose": _compose_contract(), "portfolio": _portfolio_contract(script_ref)}
  378. )
  379. accepted = AcceptedInputResolver(
  380. task_store=_Store(ledger),
  381. artifact_store=_Store(snapshots),
  382. artifacts=artifacts,
  383. contracts=contracts,
  384. )
  385. bundle = await accepted.resolve(
  386. root_trace_id="root",
  387. script_build_id=7,
  388. task_id="portfolio",
  389. attempt_id="attempt-portfolio",
  390. contract=contracts.values["portfolio"],
  391. input_snapshot_id=SNAPSHOT_ID,
  392. )
  393. portfolio = CandidatePortfolioArtifactV1(
  394. script_ref.uri,
  395. (script_ref.uri,),
  396. ("decision-compose",),
  397. (),
  398. (),
  399. bundle.input_closure_digest,
  400. (),
  401. ("decision-compose",),
  402. PORTFOLIO_DIGEST,
  403. )
  404. portfolio_ref = ArtifactRef(
  405. "script-build://artifact-versions/2",
  406. ArtifactKind.CANDIDATE_PORTFOLIO.value,
  407. "2",
  408. PORTFOLIO_DIGEST,
  409. )
  410. portfolio_attempt.status = AttemptStatus.SUBMITTED
  411. portfolio_attempt.snapshot_id = "snapshot-portfolio"
  412. portfolio_attempt.submission = AttemptSubmission("closed", artifact_refs=[portfolio_ref])
  413. portfolio_validation = ValidationReport(
  414. "validation-portfolio",
  415. "portfolio",
  416. "attempt-portfolio",
  417. 1,
  418. "snapshot-portfolio",
  419. "validator-portfolio",
  420. status=ValidationRunStatus.COMPLETED,
  421. verdict=ValidationVerdict.PASSED,
  422. )
  423. portfolio_decision = PlannerDecision(
  424. "decision-portfolio",
  425. "portfolio",
  426. DecisionAction.ACCEPT,
  427. "passed",
  428. TaskStatus.AWAITING_DECISION,
  429. TaskStatus.COMPLETED,
  430. "attempt-portfolio",
  431. "validation-portfolio",
  432. )
  433. portfolio_task.status = TaskStatus.COMPLETED
  434. portfolio_task.attempt_ids.append("attempt-portfolio")
  435. portfolio_task.validation_ids.append("validation-portfolio")
  436. portfolio_task.decision_ids.append("decision-portfolio")
  437. ledger.validations[portfolio_validation.validation_id] = portfolio_validation
  438. ledger.decisions[portfolio_decision.decision_id] = portfolio_decision
  439. portfolio_normalized = _normalized("closed", portfolio_ref)
  440. snapshots["snapshot-portfolio"] = ArtifactSnapshot(
  441. "snapshot-portfolio",
  442. "attempt-portfolio",
  443. portfolio_normalized,
  444. _snapshot_sha(portfolio_normalized),
  445. [portfolio_ref],
  446. [],
  447. )
  448. artifacts.versions[2] = _version(
  449. 2,
  450. "portfolio",
  451. "attempt-portfolio",
  452. ArtifactKind.CANDIDATE_PORTFOLIO,
  453. PORTFOLIO_DIGEST,
  454. portfolio,
  455. )
  456. verifier = ScriptPhaseTwoBoundaryVerifier(
  457. task_store=_Store(ledger),
  458. artifact_store=_Store(snapshots),
  459. artifacts=artifacts,
  460. bindings=_Bindings(),
  461. accepted_inputs=accepted,
  462. )
  463. return verifier, ledger
  464. @pytest.mark.asyncio
  465. async def test_phase_two_boundary_requires_one_closed_adopted_portfolio() -> None:
  466. verifier, _ = await _fixture()
  467. await verifier.verify_checkpoint(
  468. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  469. )
  470. @pytest.mark.asyncio
  471. async def test_phase_two_boundary_rejects_nonterminal_subtree_and_early_advance() -> None:
  472. verifier, ledger = await _fixture()
  473. ledger.tasks["compose"].status = TaskStatus.RUNNING
  474. with pytest.raises(PhaseTwoBoundaryNotReady, match="subtree is not terminal"):
  475. await verifier.verify_checkpoint(
  476. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  477. )
  478. with pytest.raises(PhaseTwoBoundaryNotReady, match="portfolio already exists"):
  479. await verifier.verify_before_advance(
  480. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  481. )
  482. @pytest.mark.asyncio
  483. async def test_phase_two_boundary_rejects_closure_digest_drift() -> None:
  484. verifier, _ = await _fixture()
  485. version = verifier._artifacts.versions[2]
  486. artifact = replace(version.artifact, input_closure_digest="sha256:" + "f" * 64)
  487. verifier._artifacts.versions[2] = replace(version, artifact=artifact)
  488. with pytest.raises(PhaseTwoBoundaryNotReady, match="closure digest"):
  489. await verifier.verify_checkpoint(
  490. script_build_id=7, root_trace_id="root", input_snapshot_id=SNAPSHOT_ID
  491. )
  492. def _normalized(summary: str, ref: ArtifactRef) -> dict[str, object]:
  493. return {
  494. "summary": summary,
  495. "artifact_refs": [
  496. {
  497. "uri": ref.uri,
  498. "kind": ref.kind,
  499. "version": ref.version,
  500. "digest": ref.digest,
  501. "summary": ref.summary,
  502. "metadata": ref.metadata,
  503. }
  504. ],
  505. "evidence_refs": [],
  506. }
  507. def _snapshot_sha(value: dict[str, object]) -> str:
  508. canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  509. return hashlib.sha256(canonical.encode()).hexdigest()