Sfoglia il codice sorgente

组合生成:由 Host 推导 StructuredScript GoalCoverage

Compose 从被采用的 Structure、Paragraph 与 ElementSet 血缘自动建立多对多 Goal 覆盖,模型提交参数中不暴露覆盖字段。

冻结前拒绝缺失 Goal、悬空来源、陈旧 Direction,以及采用 Paragraph 却没有覆盖其 scope 的 Structure;Portfolio 复用同一闭包校验。
SamLee 1 giorno fa
parent
commit
eda965aa23

+ 162 - 1
script_build_host/src/script_build_host/application/phase_two_candidates.py

@@ -24,6 +24,7 @@ from script_build_host.domain.artifacts import (
     ArtifactKind,
     ArtifactState,
     ArtifactVersion,
+    DirectionArtifact,
     EvidenceRecordV1,
 )
 from script_build_host.domain.candidate_validation import (
@@ -32,6 +33,10 @@ from script_build_host.domain.candidate_validation import (
 )
 from script_build_host.domain.defects import ScriptDefectV1
 from script_build_host.domain.errors import ProtocolViolation, ScriptBuildError
+from script_build_host.domain.goal_coverage import (
+    build_goal_coverage,
+    validate_goal_coverage,
+)
 from script_build_host.domain.phase_two_artifacts import (
     CandidateLineageV1,
     CandidatePortfolioArtifactV1,
@@ -359,6 +364,7 @@ class PhaseTwoCandidateService:
                 and lineage.input_snapshot_ref == f"script-build://inputs/{scope.input_snapshot_id}"
                 and lineage.input_closure_digest == bundle.input_closure_digest
                 and lineage.write_scope == scope.contract.write_scope
+                and lineage.goal_ids == scope.contract.goal_ids
                 and lineage.supersedes_decision_ids == scope.contract.supersedes_decision_ids
                 and lineage.base_artifact_ref == (base.uri if base else None)
                 and lineage.base_artifact_digest == (base.digest if base else None)
@@ -406,6 +412,65 @@ class PhaseTwoCandidateService:
                     ),
                 }
             )
+        if isinstance(
+            version.artifact,
+            (StructuredScriptArtifactV1, CandidatePortfolioArtifactV1),
+        ):
+            coverage_error: str | None = None
+            try:
+                binding = await self._bindings.get_by_root(scope.root_trace_id)
+                direction_id = binding.active_direction_artifact_version_id
+                if direction_id is None:
+                    raise PhaseTwoCandidateError(
+                        "GOAL_SCOPE_INVALID", "active Direction is missing"
+                    )
+                direction_version = await self._artifacts.get_by_id(
+                    direction_id, script_build_id=scope.script_build_id
+                )
+                if not isinstance(direction_version.artifact, DirectionArtifact):
+                    raise PhaseTwoCandidateError(
+                        "GOAL_SCOPE_INVALID", "active Direction has the wrong type"
+                    )
+                structured = version.artifact
+                if isinstance(structured, CandidatePortfolioArtifactV1):
+                    adopted_artifact = (
+                        await self._artifacts.get_by_id(
+                            _artifact_version_id(structured.adopted_structured_script_ref),
+                            script_build_id=scope.script_build_id,
+                        )
+                    ).artifact
+                    if not isinstance(adopted_artifact, StructuredScriptArtifactV1):
+                        raise PhaseTwoCandidateError(
+                            "GOAL_COVERAGE_INVALID",
+                            "Portfolio adoption is not a StructuredScript",
+                        )
+                    structured = adopted_artifact
+                if not isinstance(structured, StructuredScriptArtifactV1):
+                    raise PhaseTwoCandidateError(
+                        "GOAL_COVERAGE_INVALID", "Portfolio adoption is not a StructuredScript"
+                    )
+                if structured.direction_ref != (
+                    f"script-build://artifact-versions/{direction_id}"
+                ):
+                    raise PhaseTwoCandidateError(
+                        "GOAL_COVERAGE_INVALID", "StructuredScript uses a stale Direction"
+                    )
+                validate_goal_coverage(
+                    direction_goal_ids=tuple(
+                        item.goal_id for item in direction_version.artifact.goals
+                    ),
+                    coverage=structured.goal_coverage,
+                    adopted_source_refs=structured.source_artifact_refs,
+                )
+            except ScriptBuildError as exc:
+                coverage_error = f"{exc.code}: {exc.summary}"
+            rules.append(
+                {
+                    "rule_id": "goal-coverage",
+                    "verdict": "failed" if coverage_error else "passed",
+                    "reason": coverage_error or "Every Direction Goal has adopted creative sources",
+                }
+            )
         return tuple(rules)
 
     async def read_attempt_workspace(self, *, context: Mapping[str, Any]) -> Mapping[str, Any]:
@@ -603,6 +668,28 @@ class PhaseTwoCandidateService:
             [await self._read_accepted_version(scope, item) for item in bundle.inputs]
         )
         direction_ref, evidence_refs = _compose_support_refs(support_versions)
+        direction = _accepted_direction(support_versions)
+        source_refs = tuple(item.artifact_ref.uri for item in frontier)
+        goal_coverage = build_goal_coverage(
+            direction_goal_ids=tuple(item.goal_id for item in direction.goals),
+            creative_sources=(
+                (
+                    f"script-build://artifact-versions/{version.artifact_version_id}",
+                    version.artifact.lineage.goal_ids,
+                )
+                for version in versions
+                if isinstance(
+                    version.artifact,
+                    (StructureArtifactV1, ParagraphArtifactV1, ElementSetArtifactV1),
+                )
+            ),
+        )
+        _require_paragraph_structures(versions)
+        validate_goal_coverage(
+            direction_goal_ids=tuple(item.goal_id for item in direction.goals),
+            coverage=goal_coverage,
+            adopted_source_refs=source_refs,
+        )
         workspace = await self._workspace_repo().get_or_create(
             scope.write_context, artifact_kind=ArtifactKind.STRUCTURED_SCRIPT
         )
@@ -620,7 +707,8 @@ class PhaseTwoCandidateService:
             lineage=await self._lineage(scope, bundle),
             structured_script={
                 "direction_ref": direction_ref,
-                "source_artifact_refs": [item.artifact_ref.uri for item in frontier],
+                "source_artifact_refs": source_refs,
+                "goal_coverage": goal_coverage,
                 "evidence_refs": evidence_refs,
                 "acceptance_notes": tuple(acceptance_notes),
             },
@@ -680,6 +768,34 @@ class PhaseTwoCandidateService:
             candidate_refs.append(item.artifact_ref.uri)
             candidate_versions.append(version)
         adopted = by_decision[scope.contract.adopted_decision_ids[0]].artifact_ref.uri
+        adopted_version = next(
+            item
+            for item in candidate_versions
+            if f"script-build://artifact-versions/{item.artifact_version_id}" == adopted
+        )
+        active_binding = await self._bindings.get_by_root(scope.root_trace_id)
+        direction_version_id = active_binding.active_direction_artifact_version_id
+        if direction_version_id is None:
+            raise PhaseTwoCandidateError("GOAL_SCOPE_INVALID", "Portfolio has no active Direction")
+        direction_version = await self._artifacts.get_by_id(
+            direction_version_id, script_build_id=scope.script_build_id
+        )
+        if not isinstance(direction_version.artifact, DirectionArtifact):
+            raise PhaseTwoCandidateError(
+                "GOAL_SCOPE_INVALID", "Portfolio active Direction has the wrong type"
+            )
+        adopted_script = cast(StructuredScriptArtifactV1, adopted_version.artifact)
+        if adopted_script.direction_ref != (
+            f"script-build://artifact-versions/{direction_version_id}"
+        ):
+            raise PhaseTwoCandidateError(
+                "GOAL_COVERAGE_INVALID", "StructuredScript uses a stale Direction"
+            )
+        validate_goal_coverage(
+            direction_goal_ids=tuple(item.goal_id for item in direction_version.artifact.goals),
+            coverage=adopted_script.goal_coverage,
+            adopted_source_refs=adopted_script.source_artifact_refs,
+        )
         superseded = _text_sequence(payload.get("superseded_decision_ids"))
         if not set(superseded) <= set(scope.contract.held_or_rejected_decision_ids):
             raise PhaseTwoCandidateError(
@@ -1070,6 +1186,7 @@ class PhaseTwoCandidateService:
             base_artifact_digest=base.digest if base is not None else None,
             base_revision=base_version.artifact_version_id if base_version is not None else None,
             write_scope=scope.contract.write_scope,
+            goal_ids=scope.contract.goal_ids,
             supersedes_decision_ids=scope.contract.supersedes_decision_ids,
             source_lineage=tuple(
                 {
@@ -1459,6 +1576,46 @@ def _compose_support_refs(versions: Sequence[ArtifactVersion]) -> tuple[str, tup
     return directions[0], evidence
 
 
+def _artifact_version_id(uri: str) -> int:
+    value = uri.removeprefix("script-build://artifact-versions/")
+    if not value.isdigit() or f"script-build://artifact-versions/{value}" != uri:
+        raise PhaseTwoCandidateError(
+            "GOAL_COVERAGE_INVALID", "Goal coverage uses a non-immutable artifact reference"
+        )
+    return int(value)
+
+
+def _accepted_direction(versions: Sequence[ArtifactVersion]) -> DirectionArtifact:
+    directions = [
+        item.artifact for item in versions if isinstance(item.artifact, DirectionArtifact)
+    ]
+    if len(directions) != 1:
+        raise PhaseTwoCandidateError(
+            "CHILD_DECISION_INVALID", "Compose must read exactly one accepted Direction"
+        )
+    return directions[0]
+
+
+def _require_paragraph_structures(versions: Sequence[ArtifactVersion]) -> None:
+    structures = [
+        item.artifact
+        for item in versions
+        if isinstance(item.artifact, StructureArtifactV1)
+    ]
+    for version in versions:
+        paragraph = version.artifact
+        if not isinstance(paragraph, ParagraphArtifactV1):
+            continue
+        if not any(
+            _scope_contains(structure.lineage.scope_ref, paragraph.lineage.scope_ref)
+            for structure in structures
+        ):
+            raise PhaseTwoCandidateError(
+                "INPUT_SCOPE_MISMATCH",
+                "Compose cannot adopt a Paragraph without a covering Structure",
+            )
+
+
 def _image_media_type(content: bytes) -> str:
     if content.startswith(b"\x89PNG\r\n\x1a\n"):
         return "image/png"
@@ -1620,4 +1777,8 @@ def _scope_compatible(left: str, right: str) -> bool:
     )
 
 
+def _scope_contains(container: str, value: str) -> bool:
+    return value == container or value.startswith(f"{container.rstrip('/')}/")
+
+
 __all__ = ["PhaseTwoCandidateError", "PhaseTwoCandidateService"]