243 lines
8.5 KiB
Python
243 lines
8.5 KiB
Python
"""Intent-neutral provenance verification. Model output is untrusted input.
|
|
|
|
Canonical source texts come only from a locally built SourceRegistry.
|
|
Downstream stages consume a locally materialized VerifiedArtifact.
|
|
This module has no journal, dialogue, or product-intent vocabulary.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Mapping
|
|
|
|
COVERAGE_ALL_SELECTED_SOURCES = "all_selected_sources"
|
|
COVERAGE_SELECTED_EVIDENCE = "selected_evidence"
|
|
COVERAGE_MODES = frozenset({COVERAGE_ALL_SELECTED_SOURCES, COVERAGE_SELECTED_EVIDENCE})
|
|
ARTIFACT_KIND = "verified_artifact"
|
|
|
|
|
|
class ProvenanceError(Exception):
|
|
"""Fail-closed verification error. Domain mapping happens at the caller boundary."""
|
|
|
|
def __init__(self, reason: str, **details: Any):
|
|
super().__init__(reason)
|
|
self.reason = reason
|
|
self.details = details
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceRecord:
|
|
source_id: str
|
|
role: str
|
|
text: str
|
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
class SourceRegistry:
|
|
"""Local authority for original content. Model text never replaces these records."""
|
|
|
|
def __init__(self, sources: list[SourceRecord] | None = None):
|
|
self._sources: dict[str, SourceRecord] = {}
|
|
self._order: list[str] = []
|
|
for source in sources or []:
|
|
self.add(source)
|
|
|
|
def add(self, source: SourceRecord) -> None:
|
|
source_id = (source.source_id or "").strip()
|
|
if not source_id:
|
|
raise ProvenanceError("missing_source_id")
|
|
if source_id in self._sources:
|
|
raise ProvenanceError("duplicate_source_id", source_id=source_id)
|
|
self._sources[source_id] = SourceRecord(
|
|
source_id=source_id,
|
|
role=source.role,
|
|
text=source.text,
|
|
metadata=dict(source.metadata or {}),
|
|
)
|
|
self._order.append(source_id)
|
|
|
|
def get(self, source_id: str) -> SourceRecord | None:
|
|
return self._sources.get(source_id)
|
|
|
|
def order(self) -> list[str]:
|
|
return list(self._order)
|
|
|
|
def items(self) -> list[SourceRecord]:
|
|
return [self._sources[source_id] for source_id in self._order]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EvidenceRef:
|
|
source_id: str
|
|
excerpt: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerifiedEvidence:
|
|
source_id: str
|
|
excerpt: str
|
|
start: int
|
|
end: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerificationPolicy:
|
|
allowed_roles: frozenset[str]
|
|
coverage: str = COVERAGE_ALL_SELECTED_SOURCES
|
|
selected_ids: tuple[str, ...] = ()
|
|
source_order: tuple[str, ...] = ()
|
|
include_unverified_annotations: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerifiedArtifact:
|
|
coverage: str
|
|
source_order: tuple[str, ...]
|
|
sources: tuple[dict[str, str], ...]
|
|
evidence: tuple[VerifiedEvidence, ...]
|
|
annotations_unverified: tuple[dict[str, Any], ...] = ()
|
|
|
|
def texts(self) -> list[str]:
|
|
return [item.get("text") or "" for item in self.sources if item.get("text")]
|
|
|
|
def to_payload(self) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"kind": ARTIFACT_KIND,
|
|
"coverage": self.coverage,
|
|
"source_order": list(self.source_order),
|
|
"order": [item.get("source_id") for item in self.sources],
|
|
"sources": [dict(item) for item in self.sources],
|
|
"evidence": [
|
|
{
|
|
"source_id": item.source_id,
|
|
"excerpt": item.excerpt,
|
|
"start": item.start,
|
|
"end": item.end,
|
|
}
|
|
for item in self.evidence
|
|
],
|
|
}
|
|
if self.annotations_unverified:
|
|
payload["annotations_unverified"] = [dict(item) for item in self.annotations_unverified]
|
|
return payload
|
|
|
|
|
|
def locate_excerpt(text: str, excerpt: str) -> tuple[int, int] | None:
|
|
"""Local span from a confirmed literal excerpt. Model offsets are ignored."""
|
|
if not excerpt:
|
|
return None
|
|
start = (text or "").find(excerpt)
|
|
if start < 0:
|
|
return None
|
|
return start, start + len(excerpt)
|
|
|
|
|
|
def texts_from_payload(data: Mapping[str, Any] | None) -> list[str]:
|
|
if not isinstance(data, Mapping):
|
|
return []
|
|
parts: list[str] = []
|
|
for item in data.get("sources") or []:
|
|
if isinstance(item, Mapping) and item.get("text"):
|
|
parts.append(str(item["text"]))
|
|
return parts
|
|
|
|
|
|
def verify_evidence(
|
|
registry: SourceRegistry,
|
|
ref: EvidenceRef,
|
|
policy: VerificationPolicy,
|
|
) -> VerifiedEvidence:
|
|
source_id = (ref.source_id or "").strip()
|
|
excerpt = ref.excerpt or ""
|
|
if not source_id:
|
|
raise ProvenanceError("missing_source_id")
|
|
if not excerpt.strip():
|
|
raise ProvenanceError("unverified_excerpt", source_id=source_id)
|
|
if policy.selected_ids and source_id not in policy.selected_ids:
|
|
raise ProvenanceError("unknown_source", source_id=source_id)
|
|
source = registry.get(source_id)
|
|
if source is None:
|
|
raise ProvenanceError("unknown_source", source_id=source_id)
|
|
if source.role not in policy.allowed_roles:
|
|
raise ProvenanceError("role_not_allowed", source_id=source_id, role=source.role)
|
|
located = locate_excerpt(source.text, excerpt)
|
|
if located is not None:
|
|
return VerifiedEvidence(source_id, excerpt, located[0], located[1])
|
|
for other in registry.items():
|
|
if other.source_id == source_id or excerpt not in (other.text or ""):
|
|
continue
|
|
if other.role not in policy.allowed_roles:
|
|
raise ProvenanceError("role_not_allowed", source_id=other.source_id, role=other.role)
|
|
raise ProvenanceError("wrong_source", source_id=source_id, excerpt=excerpt[:120])
|
|
raise ProvenanceError("unverified_excerpt", source_id=source_id, excerpt=excerpt[:120])
|
|
|
|
|
|
def _selected_records(registry: SourceRegistry, policy: VerificationPolicy) -> list[SourceRecord]:
|
|
if policy.coverage not in COVERAGE_MODES:
|
|
raise ProvenanceError("unknown_coverage", coverage=policy.coverage)
|
|
selected = tuple(item for item in policy.selected_ids if item)
|
|
if not selected:
|
|
raise ProvenanceError("selection_empty")
|
|
if len(set(selected)) != len(selected):
|
|
raise ProvenanceError("duplicate_source_id", ids=list(selected))
|
|
records: list[SourceRecord] = []
|
|
for source_id in selected:
|
|
source = registry.get(source_id)
|
|
if source is None:
|
|
raise ProvenanceError("unknown_source", source_id=source_id)
|
|
if source.role not in policy.allowed_roles:
|
|
raise ProvenanceError("role_not_allowed", source_id=source_id, role=source.role)
|
|
records.append(source)
|
|
return records
|
|
|
|
|
|
def verify(
|
|
registry: SourceRegistry,
|
|
*,
|
|
policy: VerificationPolicy,
|
|
evidence: list[EvidenceRef] | None = None,
|
|
annotations: list[dict[str, Any]] | None = None,
|
|
proposed_texts: Mapping[str, str] | None = None,
|
|
) -> VerifiedArtifact:
|
|
"""Materialize a VerifiedArtifact from local sources plus untrusted references."""
|
|
selected = _selected_records(registry, policy)
|
|
for source in selected:
|
|
proposed = (proposed_texts or {}).get(source.source_id)
|
|
if proposed is not None and proposed != source.text:
|
|
raise ProvenanceError("canonical_overwrite", source_id=source.source_id)
|
|
|
|
verified: list[VerifiedEvidence] = []
|
|
for ref in evidence or []:
|
|
verified.append(verify_evidence(registry, ref, policy))
|
|
|
|
if policy.coverage == COVERAGE_ALL_SELECTED_SOURCES:
|
|
sources = tuple(
|
|
{"source_id": source.source_id, "role": source.role, "text": source.text} for source in selected
|
|
)
|
|
else:
|
|
grouped: dict[str, list[str]] = {source.source_id: [] for source in selected}
|
|
for item in verified:
|
|
grouped[item.source_id].append(item.excerpt)
|
|
sources = tuple(
|
|
{
|
|
"source_id": source.source_id,
|
|
"role": source.role,
|
|
"text": "\n".join(grouped[source.source_id]),
|
|
}
|
|
for source in selected
|
|
)
|
|
|
|
unverified: tuple[dict[str, Any], ...] = ()
|
|
if policy.include_unverified_annotations and annotations:
|
|
unverified = tuple(dict(item) for item in annotations)
|
|
|
|
encounter = tuple(item for item in policy.source_order if item) or tuple(
|
|
source.source_id for source in selected
|
|
)
|
|
return VerifiedArtifact(
|
|
coverage=policy.coverage,
|
|
source_order=encounter,
|
|
sources=sources,
|
|
evidence=tuple(verified),
|
|
annotations_unverified=unverified,
|
|
)
|