|
|
@@ -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"]
|