|
@@ -0,0 +1,586 @@
|
|
|
|
|
+"""Canonical phase-two candidate artifacts, independent of ORM storage."""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+from collections.abc import Mapping
|
|
|
|
|
+from dataclasses import dataclass, field
|
|
|
|
|
+from typing import Any, Self
|
|
|
|
|
+
|
|
|
|
|
+from .defects import MAX_DEFECTS_PER_VALIDATION, DefectSeverity, ScriptDefectV1
|
|
|
|
|
+from .digests import Sha256Digest
|
|
|
|
|
+from .errors import ProtocolViolation
|
|
|
|
|
+
|
|
|
|
|
+_CONTROL_PREFIX = "script-build://"
|
|
|
|
|
+_ARTIFACT_PREFIX = "script-build://artifact-versions/"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class CandidateLineageV1:
|
|
|
|
|
+ scope_ref: str
|
|
|
|
|
+ input_snapshot_ref: str
|
|
|
|
|
+ input_closure_digest: str
|
|
|
|
|
+ write_scope: tuple[str, ...]
|
|
|
|
|
+ base_artifact_ref: str | None = None
|
|
|
|
|
+ base_artifact_digest: str | None = None
|
|
|
|
|
+ base_revision: int | None = None
|
|
|
|
|
+ supersedes_decision_ids: tuple[str, ...] = ()
|
|
|
|
|
+ source_lineage: tuple[dict[str, Any], ...] = ()
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ _control_uri(self.scope_ref, "scope_ref")
|
|
|
|
|
+ _control_uri(self.input_snapshot_ref, "input_snapshot_ref")
|
|
|
|
|
+ Sha256Digest.from_wire(self.input_closure_digest)
|
|
|
|
|
+ _unique_nonblank(self.write_scope, "write_scope")
|
|
|
|
|
+ for item in self.write_scope:
|
|
|
|
|
+ _control_uri(item, "write_scope")
|
|
|
|
|
+ _unique_nonblank(self.supersedes_decision_ids, "supersedes_decision_ids")
|
|
|
|
|
+ if (self.base_artifact_ref is None) != (self.base_artifact_digest is None):
|
|
|
|
|
+ raise ProtocolViolation("base artifact URI and digest must be supplied together")
|
|
|
|
|
+ if self.base_artifact_ref is not None:
|
|
|
|
|
+ _artifact_uri(self.base_artifact_ref, "base_artifact_ref")
|
|
|
|
|
+ Sha256Digest.from_wire(self.base_artifact_digest or "")
|
|
|
|
|
+ if not isinstance(self.base_revision, int) or self.base_revision < 1:
|
|
|
|
|
+ raise ProtocolViolation("base revision must pin one positive artifact version")
|
|
|
|
|
+ elif self.base_revision is not None:
|
|
|
|
|
+ raise ProtocolViolation("base revision requires a base artifact")
|
|
|
|
|
+
|
|
|
|
|
+ def to_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "scope_ref": self.scope_ref,
|
|
|
|
|
+ "input_snapshot_ref": self.input_snapshot_ref,
|
|
|
|
|
+ "input_closure_digest": self.input_closure_digest,
|
|
|
|
|
+ "base_artifact_ref": self.base_artifact_ref,
|
|
|
|
|
+ "base_artifact_digest": self.base_artifact_digest,
|
|
|
|
|
+ "base_revision": self.base_revision,
|
|
|
|
|
+ "write_scope": list(self.write_scope),
|
|
|
|
|
+ "supersedes_decision_ids": list(self.supersedes_decision_ids),
|
|
|
|
|
+ "source_lineage": list(self.source_lineage),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def from_payload(cls, value: Mapping[str, Any]) -> Self:
|
|
|
|
|
+ return cls(
|
|
|
|
|
+ scope_ref=str(value.get("scope_ref", "")),
|
|
|
|
|
+ input_snapshot_ref=str(value.get("input_snapshot_ref", "")),
|
|
|
|
|
+ input_closure_digest=str(value.get("input_closure_digest", "")),
|
|
|
|
|
+ base_artifact_ref=_optional_text(value.get("base_artifact_ref")),
|
|
|
|
|
+ base_artifact_digest=_optional_text(value.get("base_artifact_digest")),
|
|
|
|
|
+ base_revision=(
|
|
|
|
|
+ int(value["base_revision"]) if value.get("base_revision") is not None else None
|
|
|
|
|
+ ),
|
|
|
|
|
+ write_scope=_text_tuple(value.get("write_scope")),
|
|
|
|
|
+ supersedes_decision_ids=_text_tuple(value.get("supersedes_decision_ids")),
|
|
|
|
|
+ source_lineage=_mapping_tuple(value.get("source_lineage")),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class ScriptParagraphV1:
|
|
|
|
|
+ paragraph_id: int
|
|
|
|
|
+ paragraph_index: int
|
|
|
|
|
+ level: int
|
|
|
|
|
+ parent_id: int | None
|
|
|
|
|
+ name: str
|
|
|
|
|
+ content_range: dict[str, Any]
|
|
|
|
|
+ theme: str | None = None
|
|
|
|
|
+ form: str | None = None
|
|
|
|
|
+ function: str | None = None
|
|
|
|
|
+ feeling: str | None = None
|
|
|
|
|
+ theme_elements: tuple[dict[str, Any], ...] = ()
|
|
|
|
|
+ form_elements: tuple[dict[str, Any], ...] = ()
|
|
|
|
|
+ function_elements: tuple[dict[str, Any], ...] = ()
|
|
|
|
|
+ feeling_elements: tuple[dict[str, Any], ...] = ()
|
|
|
|
|
+ description: str | None = None
|
|
|
|
|
+ full_description: str | None = None
|
|
|
|
|
+ is_active: bool = True
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ if self.paragraph_id < 1 or self.paragraph_index < 1:
|
|
|
|
|
+ raise ProtocolViolation("paragraph identities and indexes must be positive")
|
|
|
|
|
+ if self.level not in (1, 2):
|
|
|
|
|
+ raise ProtocolViolation("paragraph level must be 1 or 2")
|
|
|
|
|
+ if self.level == 1 and self.parent_id is not None:
|
|
|
|
|
+ raise ProtocolViolation("level-one paragraphs cannot have a parent")
|
|
|
|
|
+ if self.level == 2 and (self.parent_id is None or self.parent_id < 1):
|
|
|
|
|
+ raise ProtocolViolation("level-two paragraphs require a parent")
|
|
|
|
|
+ if not self.name.strip():
|
|
|
|
|
+ raise ProtocolViolation("paragraph name must not be blank")
|
|
|
|
|
+
|
|
|
|
|
+ def to_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "paragraph_id": self.paragraph_id,
|
|
|
|
|
+ "paragraph_index": self.paragraph_index,
|
|
|
|
|
+ "level": self.level,
|
|
|
|
|
+ "parent_id": self.parent_id,
|
|
|
|
|
+ "name": self.name,
|
|
|
|
|
+ "content_range": self.content_range,
|
|
|
|
|
+ "theme": self.theme,
|
|
|
|
|
+ "form": self.form,
|
|
|
|
|
+ "function": self.function,
|
|
|
|
|
+ "feeling": self.feeling,
|
|
|
|
|
+ "theme_elements": list(self.theme_elements),
|
|
|
|
|
+ "form_elements": list(self.form_elements),
|
|
|
|
|
+ "function_elements": list(self.function_elements),
|
|
|
|
|
+ "feeling_elements": list(self.feeling_elements),
|
|
|
|
|
+ "description": self.description,
|
|
|
|
|
+ "full_description": self.full_description,
|
|
|
|
|
+ "is_active": self.is_active,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def from_payload(cls, value: Mapping[str, Any]) -> Self:
|
|
|
|
|
+ return cls(
|
|
|
|
|
+ paragraph_id=int(value["paragraph_id"]),
|
|
|
|
|
+ paragraph_index=int(value["paragraph_index"]),
|
|
|
|
|
+ level=int(value.get("level", 1)),
|
|
|
|
|
+ parent_id=int(value["parent_id"]) if value.get("parent_id") is not None else None,
|
|
|
|
|
+ name=str(value.get("name", "")),
|
|
|
|
|
+ content_range=dict(value.get("content_range") or {}),
|
|
|
|
|
+ theme=_optional_text(value.get("theme")),
|
|
|
|
|
+ form=_optional_text(value.get("form")),
|
|
|
|
|
+ function=_optional_text(value.get("function")),
|
|
|
|
|
+ feeling=_optional_text(value.get("feeling")),
|
|
|
|
|
+ theme_elements=_mapping_tuple(value.get("theme_elements")),
|
|
|
|
|
+ form_elements=_mapping_tuple(value.get("form_elements")),
|
|
|
|
|
+ function_elements=_mapping_tuple(value.get("function_elements")),
|
|
|
|
|
+ feeling_elements=_mapping_tuple(value.get("feeling_elements")),
|
|
|
|
|
+ description=_optional_text(value.get("description")),
|
|
|
|
|
+ full_description=_optional_text(value.get("full_description")),
|
|
|
|
|
+ is_active=bool(value.get("is_active", True)),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class ScriptElementV1:
|
|
|
|
|
+ element_id: int
|
|
|
|
|
+ name: str
|
|
|
|
|
+ dimension_primary: str
|
|
|
|
|
+ dimension_secondary: str
|
|
|
|
|
+ commonality_analysis: dict[str, Any] | None = None
|
|
|
|
|
+ topic_support: dict[str, Any] | None = None
|
|
|
|
|
+ weight_score: dict[str, Any] | None = None
|
|
|
|
|
+ support_elements: tuple[dict[str, Any], ...] = ()
|
|
|
|
|
+ is_active: bool = True
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ if self.element_id < 1:
|
|
|
|
|
+ raise ProtocolViolation("element identity must be positive")
|
|
|
|
|
+ if self.dimension_primary not in {"实质", "形式"}:
|
|
|
|
|
+ raise ProtocolViolation("element primary dimension must be 实质 or 形式")
|
|
|
|
|
+ if not self.name.strip() or not self.dimension_secondary.strip():
|
|
|
|
|
+ raise ProtocolViolation("element name and secondary dimension must not be blank")
|
|
|
|
|
+
|
|
|
|
|
+ def to_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "element_id": self.element_id,
|
|
|
|
|
+ "name": self.name,
|
|
|
|
|
+ "dimension_primary": self.dimension_primary,
|
|
|
|
|
+ "dimension_secondary": self.dimension_secondary,
|
|
|
|
|
+ "commonality_analysis": self.commonality_analysis,
|
|
|
|
|
+ "topic_support": self.topic_support,
|
|
|
|
|
+ "weight_score": self.weight_score,
|
|
|
|
|
+ "support_elements": list(self.support_elements),
|
|
|
|
|
+ "is_active": self.is_active,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def from_payload(cls, value: Mapping[str, Any]) -> Self:
|
|
|
|
|
+ return cls(
|
|
|
|
|
+ element_id=int(value["element_id"]),
|
|
|
|
|
+ name=str(value.get("name", "")),
|
|
|
|
|
+ dimension_primary=str(value.get("dimension_primary", "")),
|
|
|
|
|
+ dimension_secondary=str(value.get("dimension_secondary", "")),
|
|
|
|
|
+ commonality_analysis=_optional_mapping(value.get("commonality_analysis")),
|
|
|
|
|
+ topic_support=_optional_mapping(value.get("topic_support")),
|
|
|
|
|
+ weight_score=_optional_mapping(value.get("weight_score")),
|
|
|
|
|
+ support_elements=_mapping_tuple(value.get("support_elements")),
|
|
|
|
|
+ is_active=bool(value.get("is_active", True)),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class ScriptParagraphElementLinkV1:
|
|
|
|
|
+ paragraph_id: int
|
|
|
|
|
+ element_id: int
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ if self.paragraph_id < 1 or self.element_id < 1:
|
|
|
|
|
+ raise ProtocolViolation("paragraph-element links require positive identities")
|
|
|
|
|
+
|
|
|
|
|
+ def to_payload(self) -> dict[str, int]:
|
|
|
|
|
+ return {"paragraph_id": self.paragraph_id, "element_id": self.element_id}
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def from_payload(cls, value: Mapping[str, Any]) -> Self:
|
|
|
|
|
+ return cls(paragraph_id=int(value["paragraph_id"]), element_id=int(value["element_id"]))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class StructureArtifactV1:
|
|
|
|
|
+ lineage: CandidateLineageV1
|
|
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...]
|
|
|
|
|
+ canonical_sha256: str = ""
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ _validate_paragraphs(self.paragraphs)
|
|
|
|
|
+
|
|
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "schema_version": "structure-artifact/v1",
|
|
|
|
|
+ **self.lineage.to_payload(),
|
|
|
|
|
+ "paragraphs": [item.to_payload() for item in self.paragraphs],
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class ParagraphArtifactV1:
|
|
|
|
|
+ lineage: CandidateLineageV1
|
|
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...]
|
|
|
|
|
+ elements: tuple[ScriptElementV1, ...] = ()
|
|
|
|
|
+ paragraph_element_links: tuple[ScriptParagraphElementLinkV1, ...] = ()
|
|
|
|
|
+ change_manifest: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
+ canonical_sha256: str = ""
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ _validate_paragraphs(self.paragraphs)
|
|
|
|
|
+ _validate_elements_and_links(
|
|
|
|
|
+ self.paragraphs,
|
|
|
|
|
+ self.elements,
|
|
|
|
|
+ self.paragraph_element_links,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ schema = "paragraph-patch/v1" if self.lineage.base_artifact_ref else "paragraph-artifact/v1"
|
|
|
|
|
+ return {
|
|
|
|
|
+ "schema_version": schema,
|
|
|
|
|
+ **self.lineage.to_payload(),
|
|
|
|
|
+ "paragraphs": [item.to_payload() for item in self.paragraphs],
|
|
|
|
|
+ "elements": [item.to_payload() for item in self.elements],
|
|
|
|
|
+ "paragraph_element_links": [item.to_payload() for item in self.paragraph_element_links],
|
|
|
|
|
+ "change_manifest": self.change_manifest,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class ElementSetArtifactV1:
|
|
|
|
|
+ lineage: CandidateLineageV1
|
|
|
|
|
+ elements: tuple[ScriptElementV1, ...]
|
|
|
|
|
+ paragraph_element_links: tuple[ScriptParagraphElementLinkV1, ...]
|
|
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...] = ()
|
|
|
|
|
+ change_manifest: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
+ canonical_sha256: str = ""
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ if self.paragraph_element_links and not self.paragraphs:
|
|
|
|
|
+ raise ProtocolViolation("element-set projection must include every linked paragraph")
|
|
|
|
|
+ if self.paragraphs:
|
|
|
|
|
+ _validate_paragraphs(self.paragraphs)
|
|
|
|
|
+ _validate_elements_and_links(
|
|
|
|
|
+ self.paragraphs,
|
|
|
|
|
+ self.elements,
|
|
|
|
|
+ self.paragraph_element_links,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ schema = (
|
|
|
|
|
+ "element-set-patch/v1" if self.lineage.base_artifact_ref else "element-set-artifact/v1"
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ "schema_version": schema,
|
|
|
|
|
+ **self.lineage.to_payload(),
|
|
|
|
|
+ "paragraphs": [item.to_payload() for item in self.paragraphs],
|
|
|
|
|
+ "elements": [item.to_payload() for item in self.elements],
|
|
|
|
|
+ "paragraph_element_links": [item.to_payload() for item in self.paragraph_element_links],
|
|
|
|
|
+ "change_manifest": self.change_manifest,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class ComparisonArtifactV1:
|
|
|
|
|
+ lineage: CandidateLineageV1
|
|
|
|
|
+ candidate_artifact_refs: tuple[str, ...]
|
|
|
|
|
+ criterion_results: tuple[dict[str, Any], ...]
|
|
|
|
|
+ conflicts: tuple[str, ...]
|
|
|
|
|
+ recommendation: str
|
|
|
|
|
+ canonical_sha256: str = ""
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ if len(self.candidate_artifact_refs) < 2:
|
|
|
|
|
+ raise ProtocolViolation("comparison requires at least two immutable candidates")
|
|
|
|
|
+ _unique_nonblank(self.candidate_artifact_refs, "candidate_artifact_refs")
|
|
|
|
|
+ for item in self.candidate_artifact_refs:
|
|
|
|
|
+ _artifact_uri(item, "candidate_artifact_ref")
|
|
|
|
|
+ if not self.criterion_results or not self.recommendation.strip():
|
|
|
|
|
+ raise ProtocolViolation("comparison requires criterion results and a recommendation")
|
|
|
|
|
+
|
|
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "schema_version": "comparison-artifact/v1",
|
|
|
|
|
+ **self.lineage.to_payload(),
|
|
|
|
|
+ "candidate_artifact_refs": list(self.candidate_artifact_refs),
|
|
|
|
|
+ "criterion_results": list(self.criterion_results),
|
|
|
|
|
+ "conflicts": list(self.conflicts),
|
|
|
|
|
+ "recommendation": self.recommendation,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class StructuredScriptArtifactV1:
|
|
|
|
|
+ direction_ref: str
|
|
|
|
|
+ input_closure_digest: str
|
|
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...]
|
|
|
|
|
+ elements: tuple[ScriptElementV1, ...]
|
|
|
|
|
+ paragraph_element_links: tuple[ScriptParagraphElementLinkV1, ...]
|
|
|
|
|
+ source_artifact_refs: tuple[str, ...]
|
|
|
|
|
+ evidence_refs: tuple[str, ...]
|
|
|
|
|
+ acceptance_notes: tuple[str, ...]
|
|
|
|
|
+ canonical_sha256: str = ""
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ _artifact_uri(self.direction_ref, "direction_ref")
|
|
|
|
|
+ Sha256Digest.from_wire(self.input_closure_digest)
|
|
|
|
|
+ _validate_paragraphs(self.paragraphs)
|
|
|
|
|
+ _validate_elements_and_links(self.paragraphs, self.elements, self.paragraph_element_links)
|
|
|
|
|
+ for label, refs in (
|
|
|
|
|
+ ("source_artifact_refs", self.source_artifact_refs),
|
|
|
|
|
+ ("evidence_refs", self.evidence_refs),
|
|
|
|
|
+ ):
|
|
|
|
|
+ _unique_nonblank(refs, label)
|
|
|
|
|
+ for item in refs:
|
|
|
|
|
+ _artifact_uri(item, label)
|
|
|
|
|
+
|
|
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "schema_version": "structured-script/v1",
|
|
|
|
|
+ "direction_ref": self.direction_ref,
|
|
|
|
|
+ "input_closure_digest": self.input_closure_digest,
|
|
|
|
|
+ "paragraphs": [item.to_payload() for item in self.paragraphs],
|
|
|
|
|
+ "elements": [item.to_payload() for item in self.elements],
|
|
|
|
|
+ "paragraph_element_links": [item.to_payload() for item in self.paragraph_element_links],
|
|
|
|
|
+ "source_artifact_refs": list(self.source_artifact_refs),
|
|
|
|
|
+ "evidence_refs": list(self.evidence_refs),
|
|
|
|
|
+ "acceptance_notes": list(self.acceptance_notes),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@dataclass(frozen=True, slots=True)
|
|
|
|
|
+class CandidatePortfolioArtifactV1:
|
|
|
|
|
+ adopted_structured_script_ref: str
|
|
|
|
|
+ candidate_structured_script_refs: tuple[str, ...]
|
|
|
|
|
+ accepted_decision_ids: tuple[str, ...]
|
|
|
|
|
+ superseded_decision_ids: tuple[str, ...]
|
|
|
|
|
+ rejected_or_held_decision_ids: tuple[str, ...]
|
|
|
|
|
+ input_closure_digest: str
|
|
|
|
|
+ unresolved_defects: tuple[ScriptDefectV1, ...]
|
|
|
|
|
+ compose_order: tuple[str, ...]
|
|
|
|
|
+ canonical_sha256: str = ""
|
|
|
|
|
+
|
|
|
|
|
+ def __post_init__(self) -> None:
|
|
|
|
|
+ _artifact_uri(self.adopted_structured_script_ref, "adopted_structured_script_ref")
|
|
|
|
|
+ _unique_nonblank(self.candidate_structured_script_refs, "candidate_structured_script_refs")
|
|
|
|
|
+ for item in self.candidate_structured_script_refs:
|
|
|
|
|
+ _artifact_uri(item, "candidate_structured_script_ref")
|
|
|
|
|
+ if self.adopted_structured_script_ref not in self.candidate_structured_script_refs:
|
|
|
|
|
+ raise ProtocolViolation("adopted StructuredScript must be in the candidate set")
|
|
|
|
|
+ adopted = set(self.accepted_decision_ids)
|
|
|
|
|
+ disposed = set(self.superseded_decision_ids) | set(self.rejected_or_held_decision_ids)
|
|
|
|
|
+ if adopted & disposed:
|
|
|
|
|
+ raise ProtocolViolation("accepted and disposed decisions must be disjoint")
|
|
|
|
|
+ if len(self.unresolved_defects) > MAX_DEFECTS_PER_VALIDATION:
|
|
|
|
|
+ raise ProtocolViolation("portfolio may retain at most 50 warning defects")
|
|
|
|
|
+ for defect in self.unresolved_defects:
|
|
|
|
|
+ if not isinstance(defect, ScriptDefectV1):
|
|
|
|
|
+ raise ProtocolViolation("portfolio defects must use script-defect/v1")
|
|
|
|
|
+ if defect.severity is not DefectSeverity.WARNING:
|
|
|
|
|
+ raise ProtocolViolation("portfolio cannot retain hard or critical defects")
|
|
|
|
|
+ Sha256Digest.from_wire(self.input_closure_digest)
|
|
|
|
|
+
|
|
|
|
|
+ def content_payload(self) -> dict[str, Any]:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "schema_version": "candidate-portfolio/v1",
|
|
|
|
|
+ "adopted_structured_script_ref": self.adopted_structured_script_ref,
|
|
|
|
|
+ "candidate_structured_script_refs": list(self.candidate_structured_script_refs),
|
|
|
|
|
+ "accepted_decision_ids": list(self.accepted_decision_ids),
|
|
|
|
|
+ "superseded_decision_ids": list(self.superseded_decision_ids),
|
|
|
|
|
+ "rejected_or_held_decision_ids": list(self.rejected_or_held_decision_ids),
|
|
|
|
|
+ "input_closure_digest": self.input_closure_digest,
|
|
|
|
|
+ "unresolved_defects": [item.to_payload() for item in self.unresolved_defects],
|
|
|
|
|
+ "compose_order": list(self.compose_order),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+PhaseTwoBusinessArtifact = (
|
|
|
|
|
+ StructureArtifactV1
|
|
|
|
|
+ | ParagraphArtifactV1
|
|
|
|
|
+ | ElementSetArtifactV1
|
|
|
|
|
+ | ComparisonArtifactV1
|
|
|
|
|
+ | StructuredScriptArtifactV1
|
|
|
|
|
+ | CandidatePortfolioArtifactV1
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def hydrate_phase_two_artifact(payload: Mapping[str, Any]) -> PhaseTwoBusinessArtifact:
|
|
|
|
|
+ schema = payload.get("schema_version")
|
|
|
|
|
+ if schema == "structure-artifact/v1":
|
|
|
|
|
+ return StructureArtifactV1(
|
|
|
|
|
+ lineage=CandidateLineageV1.from_payload(payload),
|
|
|
|
|
+ paragraphs=_paragraphs(payload.get("paragraphs")),
|
|
|
|
|
+ )
|
|
|
|
|
+ if schema in {"paragraph-artifact/v1", "paragraph-patch/v1"}:
|
|
|
|
|
+ return ParagraphArtifactV1(
|
|
|
|
|
+ lineage=CandidateLineageV1.from_payload(payload),
|
|
|
|
|
+ paragraphs=_paragraphs(payload.get("paragraphs")),
|
|
|
|
|
+ elements=_elements(payload.get("elements")),
|
|
|
|
|
+ paragraph_element_links=_links(payload.get("paragraph_element_links")),
|
|
|
|
|
+ change_manifest=dict(payload.get("change_manifest") or {}),
|
|
|
|
|
+ )
|
|
|
|
|
+ if schema in {"element-set-artifact/v1", "element-set-patch/v1"}:
|
|
|
|
|
+ return ElementSetArtifactV1(
|
|
|
|
|
+ lineage=CandidateLineageV1.from_payload(payload),
|
|
|
|
|
+ elements=_elements(payload.get("elements")),
|
|
|
|
|
+ paragraph_element_links=_links(payload.get("paragraph_element_links")),
|
|
|
|
|
+ paragraphs=_paragraphs(payload.get("paragraphs")),
|
|
|
|
|
+ change_manifest=dict(payload.get("change_manifest") or {}),
|
|
|
|
|
+ )
|
|
|
|
|
+ if schema == "comparison-artifact/v1":
|
|
|
|
|
+ return ComparisonArtifactV1(
|
|
|
|
|
+ lineage=CandidateLineageV1.from_payload(payload),
|
|
|
|
|
+ candidate_artifact_refs=_text_tuple(payload.get("candidate_artifact_refs")),
|
|
|
|
|
+ criterion_results=_mapping_tuple(payload.get("criterion_results")),
|
|
|
|
|
+ conflicts=_text_tuple(payload.get("conflicts")),
|
|
|
|
|
+ recommendation=str(payload.get("recommendation", "")),
|
|
|
|
|
+ )
|
|
|
|
|
+ if schema == "structured-script/v1":
|
|
|
|
|
+ return StructuredScriptArtifactV1(
|
|
|
|
|
+ direction_ref=str(payload.get("direction_ref", "")),
|
|
|
|
|
+ input_closure_digest=str(payload.get("input_closure_digest", "")),
|
|
|
|
|
+ paragraphs=_paragraphs(payload.get("paragraphs")),
|
|
|
|
|
+ elements=_elements(payload.get("elements")),
|
|
|
|
|
+ paragraph_element_links=_links(payload.get("paragraph_element_links")),
|
|
|
|
|
+ source_artifact_refs=_text_tuple(payload.get("source_artifact_refs")),
|
|
|
|
|
+ evidence_refs=_text_tuple(payload.get("evidence_refs")),
|
|
|
|
|
+ acceptance_notes=_text_tuple(payload.get("acceptance_notes")),
|
|
|
|
|
+ )
|
|
|
|
|
+ if schema == "candidate-portfolio/v1":
|
|
|
|
|
+ return CandidatePortfolioArtifactV1(
|
|
|
|
|
+ adopted_structured_script_ref=str(payload.get("adopted_structured_script_ref", "")),
|
|
|
|
|
+ candidate_structured_script_refs=_text_tuple(
|
|
|
|
|
+ payload.get("candidate_structured_script_refs")
|
|
|
|
|
+ ),
|
|
|
|
|
+ accepted_decision_ids=_text_tuple(payload.get("accepted_decision_ids")),
|
|
|
|
|
+ superseded_decision_ids=_text_tuple(payload.get("superseded_decision_ids")),
|
|
|
|
|
+ rejected_or_held_decision_ids=_text_tuple(payload.get("rejected_or_held_decision_ids")),
|
|
|
|
|
+ input_closure_digest=str(payload.get("input_closure_digest", "")),
|
|
|
|
|
+ unresolved_defects=tuple(
|
|
|
|
|
+ ScriptDefectV1.from_payload(item)
|
|
|
|
|
+ for item in _mapping_tuple(payload.get("unresolved_defects"))
|
|
|
|
|
+ ),
|
|
|
|
|
+ compose_order=_text_tuple(payload.get("compose_order")),
|
|
|
|
|
+ )
|
|
|
|
|
+ raise ProtocolViolation("unsupported phase-two artifact schema")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _validate_paragraphs(values: tuple[ScriptParagraphV1, ...]) -> None:
|
|
|
|
|
+ if not values:
|
|
|
|
|
+ raise ProtocolViolation("candidate paragraph set must not be empty")
|
|
|
|
|
+ ids = {item.paragraph_id for item in values}
|
|
|
|
|
+ if len(ids) != len(values):
|
|
|
|
|
+ raise ProtocolViolation("paragraph identities must be unique")
|
|
|
|
|
+ active_indexes = [item.paragraph_index for item in values if item.is_active]
|
|
|
|
|
+ if len(set(active_indexes)) != len(active_indexes):
|
|
|
|
|
+ raise ProtocolViolation("active paragraph indexes must be unique")
|
|
|
|
|
+ for item in values:
|
|
|
|
|
+ if item.parent_id is not None and item.parent_id not in ids:
|
|
|
|
|
+ raise ProtocolViolation("paragraph parent reference is dangling")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _validate_elements_and_links(
|
|
|
|
|
+ paragraphs: tuple[ScriptParagraphV1, ...],
|
|
|
|
|
+ elements: tuple[ScriptElementV1, ...],
|
|
|
|
|
+ links: tuple[ScriptParagraphElementLinkV1, ...],
|
|
|
|
|
+ *,
|
|
|
|
|
+ allow_unknown_paragraph: bool = False,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ element_ids = {item.element_id for item in elements if item.is_active}
|
|
|
|
|
+ if len({item.element_id for item in elements}) != len(elements):
|
|
|
|
|
+ raise ProtocolViolation("element identities must be unique")
|
|
|
|
|
+ paragraph_ids = {item.paragraph_id for item in paragraphs if item.is_active}
|
|
|
|
|
+ pairs = {(item.paragraph_id, item.element_id) for item in links}
|
|
|
|
|
+ if len(pairs) != len(links):
|
|
|
|
|
+ raise ProtocolViolation("paragraph-element links must be unique")
|
|
|
|
|
+ for item in links:
|
|
|
|
|
+ if item.element_id not in element_ids:
|
|
|
|
|
+ raise ProtocolViolation("paragraph-element link points to an inactive element")
|
|
|
|
|
+ if not allow_unknown_paragraph and item.paragraph_id not in paragraph_ids:
|
|
|
|
|
+ raise ProtocolViolation("paragraph-element link points to an inactive paragraph")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _control_uri(value: str, label: str) -> None:
|
|
|
|
|
+ if not value.startswith(_CONTROL_PREFIX):
|
|
|
|
|
+ raise ProtocolViolation(f"{label} must use script-build://")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _artifact_uri(value: str, label: str) -> None:
|
|
|
|
|
+ if not value.startswith(_ARTIFACT_PREFIX) or not value.removeprefix(_ARTIFACT_PREFIX).isdigit():
|
|
|
|
|
+ raise ProtocolViolation(f"{label} must pin one immutable artifact version")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _unique_nonblank(values: tuple[str, ...], label: str) -> None:
|
|
|
|
|
+ if any(not item.strip() for item in values) or len(set(values)) != len(values):
|
|
|
|
|
+ raise ProtocolViolation(f"{label} must contain unique non-blank values")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _optional_text(value: Any) -> str | None:
|
|
|
|
|
+ return str(value) if value is not None else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _optional_mapping(value: Any) -> dict[str, Any] | None:
|
|
|
|
|
+ if value is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ if not isinstance(value, Mapping):
|
|
|
|
|
+ raise ProtocolViolation("artifact JSON field must be an object")
|
|
|
|
|
+ return dict(value)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _mapping_tuple(value: Any) -> tuple[dict[str, Any], ...]:
|
|
|
|
|
+ if value is None:
|
|
|
|
|
+ return ()
|
|
|
|
|
+ if not isinstance(value, list) or any(not isinstance(item, Mapping) for item in value):
|
|
|
|
|
+ raise ProtocolViolation("artifact field must be an object array")
|
|
|
|
|
+ return tuple(dict(item) for item in value)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _text_tuple(value: Any) -> tuple[str, ...]:
|
|
|
|
|
+ if value is None:
|
|
|
|
|
+ return ()
|
|
|
|
|
+ if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
|
|
|
+ raise ProtocolViolation("artifact field must be a string array")
|
|
|
|
|
+ return tuple(value)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _paragraphs(value: Any) -> tuple[ScriptParagraphV1, ...]:
|
|
|
|
|
+ return tuple(ScriptParagraphV1.from_payload(item) for item in _mapping_tuple(value))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _elements(value: Any) -> tuple[ScriptElementV1, ...]:
|
|
|
|
|
+ return tuple(ScriptElementV1.from_payload(item) for item in _mapping_tuple(value))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _links(value: Any) -> tuple[ScriptParagraphElementLinkV1, ...]:
|
|
|
|
|
+ return tuple(ScriptParagraphElementLinkV1.from_payload(item) for item in _mapping_tuple(value))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+__all__ = [
|
|
|
|
|
+ "CandidateLineageV1",
|
|
|
|
|
+ "CandidatePortfolioArtifactV1",
|
|
|
|
|
+ "ComparisonArtifactV1",
|
|
|
|
|
+ "ElementSetArtifactV1",
|
|
|
|
|
+ "ParagraphArtifactV1",
|
|
|
|
|
+ "PhaseTwoBusinessArtifact",
|
|
|
|
|
+ "ScriptElementV1",
|
|
|
|
|
+ "ScriptParagraphElementLinkV1",
|
|
|
|
|
+ "ScriptParagraphV1",
|
|
|
|
|
+ "StructureArtifactV1",
|
|
|
|
|
+ "StructuredScriptArtifactV1",
|
|
|
|
|
+ "hydrate_phase_two_artifact",
|
|
|
|
|
+]
|