Przeglądaj źródła

覆盖测试:验证 Goal 多对多组合与 Portfolio 边界

覆盖父子 Goal 独立验收、一个 Artifact 实现多个 Goal、一个 Goal 跨多个 Artifact、缺失和悬空来源拒绝。

同时验证 Paragraph-first 可探索、缺少覆盖 Structure 不可组合、GoalCoverage 由 Host 派生,以及 Portfolio boundary 的完整闭包。
SamLee 15 godzin temu
rodzic
commit
35aaee7f50

+ 371 - 0
script_build_host/tests/test_goal_coverage.py

@@ -0,0 +1,371 @@
+from __future__ import annotations
+
+from dataclasses import replace
+from datetime import UTC, datetime
+from inspect import signature
+from types import SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from agent.orchestration import ArtifactRef, AttemptSubmission
+
+from script_build_host.agents.prompts.contracts import ROOT_VALIDATOR_PROMPT
+from script_build_host.application.phase_two_candidates import (
+    PhaseTwoCandidateError,
+    PhaseTwoCandidateService,
+    _require_paragraph_structures,
+)
+from script_build_host.application.phase_two_planning import (
+    PhasePolicyGuard,
+    PhaseTwoPlanningService,
+)
+from script_build_host.application.root_delivery import (
+    RootDeliveryService,
+    _closure_digest,
+    _RootClosure,
+)
+from script_build_host.domain.artifacts import (
+    ArtifactKind,
+    ArtifactState,
+    ArtifactVersion,
+    DirectionArtifact,
+    DirectionConstraint,
+    DirectionGoal,
+    DirectionPreference,
+)
+from script_build_host.domain.goal_coverage import (
+    GoalCoverage,
+    GoalPolicyError,
+    build_goal_coverage,
+    validate_goal_coverage,
+)
+from script_build_host.domain.phase_three_artifacts import (
+    RootDeliveryManifestV1,
+    canonicalize_legacy_projection,
+)
+from script_build_host.domain.phase_two_artifacts import (
+    CandidateLineageV1,
+    CandidatePortfolioArtifactV1,
+    ParagraphArtifactV1,
+    ScriptParagraphV1,
+    StructuredScriptArtifactV1,
+    hydrate_phase_two_artifact,
+)
+from script_build_host.domain.task_contracts import (
+    AcceptedDecisionRef,
+    ScriptCriterion,
+    ScriptIntentClass,
+    ScriptTaskBudget,
+    ScriptTaskContractV1,
+    ScriptTaskKind,
+    TaskContractError,
+)
+
+_DIGEST = "sha256:" + "a" * 64
+
+
+def test_goal_coverage_supports_parent_child_and_many_to_many_sources() -> None:
+    coverage = build_goal_coverage(
+        direction_goal_ids=("parent", "child", "tone"),
+        creative_sources=(
+            ("script-build://artifact-versions/11", ("parent", "tone")),
+            ("script-build://artifact-versions/12", ("parent", "child")),
+        ),
+    )
+
+    assert tuple(item.goal_id for item in coverage) == ("parent", "child", "tone")
+    assert coverage[0].source_artifact_refs == (
+        "script-build://artifact-versions/11",
+        "script-build://artifact-versions/12",
+    )
+    validate_goal_coverage(
+        direction_goal_ids=("parent", "child", "tone"),
+        coverage=coverage,
+        adopted_source_refs=(
+            "script-build://artifact-versions/11",
+            "script-build://artifact-versions/12",
+        ),
+    )
+
+
+@pytest.mark.parametrize(
+    ("sources", "code"),
+    [
+        ((("script-build://artifact-versions/11", ("parent",)),), "GOAL_COVERAGE_INCOMPLETE"),
+        ((("script-build://artifact-versions/11", ("unknown",)),), "GOAL_SCOPE_INVALID"),
+    ],
+)
+def test_goal_coverage_rejects_missing_and_unknown_goals(
+    sources: tuple[tuple[str, tuple[str, ...]], ...], code: str
+) -> None:
+    with pytest.raises(GoalPolicyError) as caught:
+        build_goal_coverage(
+            direction_goal_ids=("parent", "child"),
+            creative_sources=sources,
+        )
+    assert caught.value.code == code
+
+
+def test_goal_coverage_rejects_dangling_source_and_old_payloads() -> None:
+    with pytest.raises(GoalPolicyError, match="non-adopted"):
+        validate_goal_coverage(
+            direction_goal_ids=("goal-1",),
+            coverage=(
+                GoalCoverage("goal-1", ("script-build://artifact-versions/12",)),
+            ),
+            adopted_source_refs=("script-build://artifact-versions/11",),
+        )
+    with pytest.raises(GoalPolicyError) as missing_coverage:
+        hydrate_phase_two_artifact({"schema_version": "structured-script/v1"})
+    assert missing_coverage.value.code == "GOAL_COVERAGE_INCOMPLETE"
+
+    payload = _contract(ScriptTaskKind.STRUCTURE).to_payload()
+    payload.pop("goal_ids")
+    with pytest.raises(TaskContractError) as missing_goals:
+        ScriptTaskContractV1.from_payload(payload)
+    assert missing_goals.value.code == "GOAL_SCOPE_INVALID"
+
+
+def test_paragraph_first_is_allowed_but_structure_patch_remains_pinned() -> None:
+    paragraph = _contract(ScriptTaskKind.PARAGRAPH)
+    PhasePolicyGuard._phase_two_contracts((paragraph,))
+
+    patched = replace(
+        paragraph,
+        base_artifact_ref=ArtifactRef(
+            "script-build://artifact-versions/11", "structure", "11", _DIGEST
+        ),
+    )
+    with pytest.raises(TaskContractError, match="Paragraph patch requires"):
+        PhasePolicyGuard._phase_two_contracts((patched,))
+
+
+def test_duplicate_goal_ids_are_rejected_by_the_frozen_contract() -> None:
+    with pytest.raises(TaskContractError) as caught:
+        replace(_contract(ScriptTaskKind.STRUCTURE), goal_ids=("goal-1", "goal-1"))
+    assert caught.value.code == "GOAL_SCOPE_INVALID"
+
+
+def test_compose_derives_coverage_and_requires_structure_for_adopted_paragraph() -> None:
+    assert set(signature(PhaseTwoCandidateService.save_structured_script_candidate).parameters) == {
+        "self",
+        "acceptance_notes",
+        "context",
+    }
+    lineage = CandidateLineageV1(
+        scope_ref="script-build://scopes/full/opening",
+        input_snapshot_ref="script-build://inputs/11",
+        input_closure_digest=_DIGEST,
+        write_scope=("script-build://writes/paragraphs/full/opening",),
+        goal_ids=("goal-1",),
+    )
+    paragraph = ParagraphArtifactV1(
+        lineage=lineage,
+        paragraphs=(ScriptParagraphV1(1, 1, 1, None, "opening", {}),),
+    )
+    with pytest.raises(PhaseTwoCandidateError, match="covering Structure"):
+        _require_paragraph_structures(
+            (_version(11, ArtifactKind.PARAGRAPH, paragraph, datetime.now(UTC)),)
+        )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("goal_ids", [(), ("unknown",)])
+async def test_phase_two_goal_guard_rejects_invalid_scope_before_dispatch(
+    goal_ids: tuple[str, ...],
+) -> None:
+    direction = DirectionArtifact(
+        goals=(
+            DirectionGoal("goal-1", "Complete", "needed", None, ("complete",)),
+            DirectionGoal("goal-2", "Specific", "needed", "goal-1", ("specific",)),
+        ),
+        evidence_refs=("script-build://artifact-versions/90",),
+    )
+    service = PhaseTwoPlanningService(
+        coordinator=cast(Any, SimpleNamespace()),
+        bindings=cast(Any, SimpleNamespace()),
+        contracts=cast(Any, SimpleNamespace()),
+        artifacts=cast(Any, _DirectionArtifacts(direction)),
+    )
+    direction_ref = AcceptedDecisionRef(
+        "direction-accept",
+        ArtifactRef("script-build://artifact-versions/10", "direction", "10", _DIGEST),
+        "script-build://scopes/full",
+        ScriptTaskKind.DIRECTION,
+    )
+    contract = replace(
+        _contract(ScriptTaskKind.STRUCTURE),
+        goal_ids=goal_ids,
+        input_decision_refs=(direction_ref,),
+    )
+
+    with pytest.raises(GoalPolicyError) as caught:
+        await service._guard_phase_two_goals(
+            context={"phase": 2},
+            binding=SimpleNamespace(
+                active_direction_artifact_version_id=10,
+                script_build_id=7,
+            ),
+            root_trace_id="root",
+            ledger=SimpleNamespace(root_task_id="root"),
+            contracts=(contract,),
+            parent=SimpleNamespace(task_id="root"),
+        )
+    assert caught.value.code == "GOAL_SCOPE_INVALID"
+
+
+@pytest.mark.asyncio
+async def test_root_preflight_checks_all_goal_nodes_and_frozen_direction_contract() -> None:
+    now = datetime.now(UTC)
+    direction = DirectionArtifact(
+        goals=(
+            DirectionGoal(
+                "parent", "Make a useful script", "business goal", None, ("clear outcome",)
+            ),
+            DirectionGoal(
+                "child", "Give one concrete action", "supports parent", "parent", ("action exists",)
+            ),
+        ),
+        constraints=(DirectionConstraint("constraint-1", "No fabricated facts"),),
+        preferences=(DirectionPreference("preference-1", "Prefer a brisk tone"),),
+        evidence_refs=("script-build://artifact-versions/90",),
+    )
+    structured = StructuredScriptArtifactV1(
+        direction_ref="script-build://artifact-versions/1",
+        input_closure_digest=_DIGEST,
+        paragraphs=(ScriptParagraphV1(1, 1, 1, None, "body", {}, description="do this"),),
+        elements=(),
+        paragraph_element_links=(),
+        source_artifact_refs=("script-build://artifact-versions/3",),
+        goal_coverage=(
+            GoalCoverage("parent", ("script-build://artifact-versions/3",)),
+            GoalCoverage("child", ("script-build://artifact-versions/3",)),
+        ),
+        evidence_refs=(),
+        acceptance_notes=(),
+    )
+    portfolio = CandidatePortfolioArtifactV1(
+        adopted_structured_script_ref="script-build://artifact-versions/3",
+        candidate_structured_script_refs=("script-build://artifact-versions/3",),
+        accepted_decision_ids=("compose-accept",),
+        superseded_decision_ids=(),
+        rejected_or_held_decision_ids=(),
+        input_closure_digest=_DIGEST,
+        unresolved_defects=(),
+        compose_order=("compose-accept",),
+    )
+    closure = _RootClosure(
+        _version(1, ArtifactKind.DIRECTION, direction, now),
+        _version(2, ArtifactKind.CANDIDATE_PORTFOLIO, portfolio, now),
+        _version(3, ArtifactKind.STRUCTURED_SCRIPT, structured, now),
+    )
+    projection = canonicalize_legacy_projection(
+        structured, direction=direction.legacy_markdown, summary="complete"
+    )
+    manifest = RootDeliveryManifestV1(
+        direction_ref="script-build://artifact-versions/1",
+        candidate_portfolio_ref="script-build://artifact-versions/2",
+        structured_script_ref="script-build://artifact-versions/3",
+        input_closure_digest=_closure_digest(closure),
+        legacy_projection_digest=projection.canonical_sha256,
+        build_summary="complete",
+    )
+    manifest_version = _version(4, ArtifactKind.ROOT_DELIVERY_MANIFEST, manifest, now)
+    manifest_ref = ArtifactRef(
+        "script-build://artifact-versions/4", "root_delivery_manifest", "4", _DIGEST
+    )
+    ledger = SimpleNamespace(
+        attempts={
+            "attempt-root": SimpleNamespace(
+                attempt_id="attempt-root",
+                submission=AttemptSubmission("complete", artifact_refs=[manifest_ref]),
+                task_id="root",
+            )
+        }
+    )
+    service = RootDeliveryService(
+        bindings=cast(Any, _Bindings()),
+        task_store=cast(Any, _TaskStore(ledger)),
+        artifacts=cast(Any, _Artifacts(manifest_version)),
+    )
+
+    async def closure_override(_: Any) -> _RootClosure:
+        return closure
+
+    service._closure = closure_override  # type: ignore[method-assign]
+    results = await service.deterministic_precheck(
+        context={"root_trace_id": "root", "attempt_id": "attempt-root"}
+    )
+    by_id = {item["rule_id"]: item["verdict"] for item in results}
+    assert by_id["root-goal-coverage"] == "passed"
+    assert by_id["root-direction-contract"] == "passed"
+    assert "child never proves its parent automatically" in ROOT_VALIDATOR_PROMPT
+    assert "do not reject solely" in " ".join(ROOT_VALIDATOR_PROMPT.split())
+
+
+def _contract(kind: ScriptTaskKind) -> ScriptTaskContractV1:
+    output = {
+        ScriptTaskKind.STRUCTURE: "structure-artifact/v1",
+        ScriptTaskKind.PARAGRAPH: "paragraph-artifact/v1",
+    }[kind]
+    return ScriptTaskContractV1(
+        task_kind=kind,
+        scope_ref="script-build://scopes/full",
+        intent_class=ScriptIntentClass.EXPLORE,
+        objective="produce one concrete increment",
+        input_decision_refs=(),
+        base_artifact_ref=None,
+        write_scope=("script-build://writes/paragraphs/full",),
+        gap_ref=None,
+        output_schema=output,
+        criteria=(ScriptCriterion("closed", "complete"),),
+        budget=ScriptTaskBudget(),
+        goal_ids=("goal-1",),
+    )
+
+
+def _version(
+    identifier: int, kind: ArtifactKind, artifact: Any, now: datetime
+) -> ArtifactVersion:
+    return ArtifactVersion(
+        identifier,
+        7,
+        f"task-{identifier}",
+        f"attempt-{identifier}",
+        1,
+        kind,
+        _DIGEST,
+        ArtifactState.FROZEN,
+        artifact,
+        now,
+        now,
+    )
+
+
+class _Bindings:
+    async def get_by_root(self, _: str) -> Any:
+        return SimpleNamespace(script_build_id=7, root_trace_id="root")
+
+
+class _TaskStore:
+    def __init__(self, ledger: Any) -> None:
+        self.ledger = ledger
+
+    async def load(self, _: str) -> Any:
+        return self.ledger
+
+
+class _Artifacts:
+    def __init__(self, manifest: ArtifactVersion) -> None:
+        self.manifest = manifest
+
+    async def read_by_ref(self, *_: Any, **__: Any) -> ArtifactVersion:
+        return self.manifest
+
+
+class _DirectionArtifacts:
+    def __init__(self, direction: DirectionArtifact) -> None:
+        self.direction = direction
+
+    async def get_by_id(self, *_: Any, **__: Any) -> Any:
+        return SimpleNamespace(artifact=self.direction)

+ 32 - 4
script_build_host/tests/test_phase_two_boundary.py

@@ -26,10 +26,17 @@ from agent.orchestration import (
 
 from script_build_host.application.phase_two_boundary import ScriptPhaseTwoBoundaryVerifier
 from script_build_host.application.phase_two_inputs import AcceptedInputResolver
-from script_build_host.domain.artifacts import ArtifactKind, ArtifactState, ArtifactVersion
+from script_build_host.domain.artifacts import (
+    ArtifactKind,
+    ArtifactState,
+    ArtifactVersion,
+    DirectionArtifact,
+    DirectionGoal,
+)
 from script_build_host.domain.errors import PhaseTwoBoundaryNotReady
 from script_build_host.domain.phase_two_artifacts import (
     CandidatePortfolioArtifactV1,
+    GoalCoverage,
     ScriptParagraphV1,
     StructuredScriptArtifactV1,
 )
@@ -85,6 +92,7 @@ def _compose_contract() -> ScriptTaskContractV1:
         "structured-script/v1",
         (ScriptCriterion("closed", "closed"),),
         ScriptTaskBudget(),
+        ("goal-1",),
     )
 
 
@@ -102,6 +110,7 @@ def _portfolio_contract(ref: ArtifactRef) -> ScriptTaskContractV1:
         "candidate-portfolio/v1",
         (ScriptCriterion("closed", "closed"),),
         ScriptTaskBudget(),
+        ("goal-1",),
         candidate_closure_decision_refs=(accepted,),
         adopted_decision_ids=("decision-compose",),
         compose_order=("decision-compose",),
@@ -160,7 +169,7 @@ class _Bindings:
     async def get_by_build(self, script_build_id: int) -> MissionBinding:
         assert script_build_id == 7
         now = datetime.now(UTC)
-        return MissionBinding(1, 7, "root", 11, None, None, "test", "v1", now, now)
+        return MissionBinding(1, 7, "root", 11, 9, None, "test", "v1", now, now)
 
 
 def _version(
@@ -275,7 +284,8 @@ async def _fixture() -> tuple[ScriptPhaseTwoBoundaryVerifier, TaskLedger]:
         (paragraph,),
         (),
         (),
-        ("script-build://artifact-versions/9",),
+        ("script-build://artifact-versions/8",),
+        (GoalCoverage("goal-1", ("script-build://artifact-versions/8",)),),
         (),
         (),
         SCRIPT_DIGEST,
@@ -289,7 +299,25 @@ async def _fixture() -> tuple[ScriptPhaseTwoBoundaryVerifier, TaskLedger]:
                 ArtifactKind.STRUCTURED_SCRIPT,
                 SCRIPT_DIGEST,
                 structured,
-            )
+            ),
+            9: _version(
+                9,
+                "direction",
+                "attempt-direction",
+                ArtifactKind.DIRECTION,
+                INPUT_DIGEST,
+                DirectionArtifact(
+                    goals=(
+                        DirectionGoal(
+                            "goal-1",
+                            "complete the script",
+                            "the delivery needs one complete script",
+                            success_criteria=("the script is complete",),
+                        ),
+                    ),
+                    evidence_refs=("script-build://artifact-versions/99",),
+                ),
+            ),
         }
     )
     contracts = _Contracts(

+ 51 - 1
script_build_host/tests/test_phase_two_candidates.py

@@ -21,12 +21,15 @@ from script_build_host.domain.artifacts import (
     ArtifactKind,
     ArtifactState,
     ArtifactVersion,
+    DirectionArtifact,
+    DirectionGoal,
     EvidenceRecordV1,
 )
 from script_build_host.domain.errors import ProtocolViolation
 from script_build_host.domain.phase_two_artifacts import (
     CandidatePortfolioArtifactV1,
     ComparisonArtifactV1,
+    GoalCoverage,
     ScriptParagraphV1,
     StructuredScriptArtifactV1,
 )
@@ -88,6 +91,7 @@ def _contract(
         output_schema=schemas[kind],
         criteria=(ScriptCriterion("closed", "output is concretely complete"),),
         budget=ScriptTaskBudget(),
+        goal_ids=("goal-1",),
         candidate_closure_decision_refs=(
             refs if kind in {ScriptTaskKind.COMPOSE, ScriptTaskKind.CANDIDATE_PORTFOLIO} else ()
         ),
@@ -100,7 +104,12 @@ def _contract(
 
 class _Bindings:
     async def get_by_root(self, root_trace_id: str) -> Any:
-        return SimpleNamespace(root_trace_id=root_trace_id, script_build_id=7, input_snapshot_id=11)
+        return SimpleNamespace(
+            root_trace_id=root_trace_id,
+            script_build_id=7,
+            input_snapshot_id=11,
+            active_direction_artifact_version_id=10,
+        )
 
 
 class _TaskStore:
@@ -163,6 +172,37 @@ class _Artifacts:
                 return value
         raise AssertionError("Attempt artifact not found")
 
+    async def get_by_id(self, identifier: int, **_: Any) -> ArtifactVersion:
+        for value in self.versions.values():
+            if value.artifact_version_id == identifier:
+                return value
+        if identifier != 10:
+            raise AssertionError("Artifact version not found")
+        direction = DirectionArtifact(
+            goals=(
+                DirectionGoal(
+                    "goal-1",
+                    "complete the script",
+                    "the delivery needs one complete script",
+                    success_criteria=("the script is complete",),
+                ),
+            ),
+            evidence_refs=("script-build://artifact-versions/90",),
+        )
+        return ArtifactVersion(
+            10,
+            7,
+            "direction",
+            "direction-attempt",
+            1,
+            ArtifactKind.DIRECTION,
+            DIGEST,
+            ArtifactState.FROZEN,
+            direction,
+            datetime.now(UTC),
+            datetime.now(UTC),
+        )
+
     async def freeze(self, *, artifact: Any, **owners: Any) -> tuple[ArtifactVersion, ArtifactRef]:
         digest = canonical_sha256(artifact.content_payload()).wire
         artifact = replace(artifact, canonical_sha256=digest)
@@ -658,6 +698,11 @@ async def test_portfolio_adoption_and_canonical_digest_come_from_frozen_contract
             elements=(),
             paragraph_element_links=(),
             source_artifact_refs=(f"script-build://artifact-versions/{20 + identifier}",),
+            goal_coverage=(
+                GoalCoverage(
+                    "goal-1", (f"script-build://artifact-versions/{20 + identifier}",)
+                ),
+            ),
             evidence_refs=(),
             acceptance_notes=("accepted",),
             canonical_sha256=DIGEST,
@@ -764,6 +809,11 @@ async def test_candidate_portfolio_command_replay_reuses_one_frozen_version(data
                 elements=(),
                 paragraph_element_links=(),
                 source_artifact_refs=(f"script-build://artifact-versions/{20 + identifier}",),
+                goal_coverage=(
+                    GoalCoverage(
+                        "goal-1", (f"script-build://artifact-versions/{20 + identifier}",)
+                    ),
+                ),
                 evidence_refs=(),
                 acceptance_notes=("accepted",),
             ),

+ 3 - 0
script_build_host/tests/test_phase_two_workspace.py

@@ -49,6 +49,7 @@ def _lineage(
         input_snapshot_ref="script-build://inputs/11",
         input_closure_digest="sha256:" + "a" * 64,
         write_scope=("script-build://scopes/opening",),
+        goal_ids=("goal-1",),
         base_artifact_ref=base_ref,
         base_artifact_digest=base_digest,
         base_revision=base_revision,
@@ -162,6 +163,7 @@ async def test_workspace_allocates_positive_branch_and_freezes_full_paragraph(da
                 input_snapshot_ref="script-build://inputs/12",
                 input_closure_digest="sha256:" + "b" * 64,
                 write_scope=("script-build://scopes/opening",),
+                goal_ids=("goal-1",),
             ),
         )
     async with sessions() as session:
@@ -371,6 +373,7 @@ async def test_wrong_adapter_cross_context_and_stale_base_are_rejected(database)
                 input_snapshot_ref="script-build://inputs/11",
                 input_closure_digest="sha256:" + "a" * 64,
                 write_scope=("script-build://scopes/opening",),
+                goal_ids=("goal-1",),
                 base_artifact_ref="script-build://artifact-versions/999",
                 base_artifact_digest="sha256:" + "b" * 64,
                 base_revision=999,