Add adapter SDK conformance proofs
This commit is contained in:
parent
85f6629cb1
commit
e6523c0c00
5 changed files with 634 additions and 34 deletions
|
|
@ -147,6 +147,13 @@ class AdapterAssembly:
|
|||
logic: tuple[LogicProjection, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CompleteAdapterAssemblyLoader(Protocol):
|
||||
"""Load one cache-independent complete graph and Logic equivalence oracle."""
|
||||
|
||||
def load_complete_assembly(self) -> AdapterAssembly: ...
|
||||
|
||||
|
||||
class AdapterLoader(Protocol):
|
||||
"""Load one current, deterministic, project-confined adapter projection."""
|
||||
|
||||
|
|
@ -201,6 +208,14 @@ def _extract_adapter_source(
|
|||
return loader.extract_source(source)
|
||||
|
||||
|
||||
def _load_complete_adapter_assembly(
|
||||
loader: CompleteAdapterAssemblyLoader,
|
||||
) -> AdapterAssembly:
|
||||
increment("adapter_projection_loads")
|
||||
with stage("adapter.projection"):
|
||||
return loader.load_complete_assembly()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterImplementation:
|
||||
"""One confined implementation boundary that must remain stable for a process."""
|
||||
|
|
@ -232,6 +247,38 @@ class _ImplementationSnapshot:
|
|||
files: tuple[tuple[str, str], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdapterConformanceReport:
|
||||
"""Stable evidence that one adapter passed its complete and incremental contracts."""
|
||||
|
||||
schema_version: int
|
||||
project_id: str
|
||||
adapter_id: str
|
||||
adapter_version: str
|
||||
revision: str
|
||||
source_hash: str
|
||||
assembly_hash: str
|
||||
node_count: int
|
||||
edge_count: int
|
||||
logic_projection_count: int
|
||||
incremental: bool
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"project_id": self.project_id,
|
||||
"adapter_id": self.adapter_id,
|
||||
"adapter_version": self.adapter_version,
|
||||
"revision": self.revision,
|
||||
"source_hash": self.source_hash,
|
||||
"assembly_hash": self.assembly_hash,
|
||||
"node_count": self.node_count,
|
||||
"edge_count": self.edge_count,
|
||||
"logic_projection_count": self.logic_projection_count,
|
||||
"incremental": self.incremental,
|
||||
}
|
||||
|
||||
|
||||
class AdapterProject:
|
||||
"""Expose a validated adapter projection through the standard index boundary."""
|
||||
|
||||
|
|
@ -523,43 +570,57 @@ class AdapterProject:
|
|||
return self._last_logic
|
||||
|
||||
def verify_incremental_equivalence(self) -> dict[str, object]:
|
||||
"""Prove the incremental and full loader contracts produce the same graph."""
|
||||
"""Prove incremental graph and Logic output matches an independent complete oracle."""
|
||||
|
||||
self.validate_runtime()
|
||||
if self._incremental_loader is None:
|
||||
loader = self._incremental_loader
|
||||
if loader is None:
|
||||
raise DocForgeError(
|
||||
"incremental_disabled", "Adapter does not implement incremental extraction"
|
||||
)
|
||||
incremental = self._load_incremental()
|
||||
incremental = self._load_incremental_assembly()
|
||||
full = _load_adapter_projection(self.loader)
|
||||
validate_projection(full)
|
||||
fields = {
|
||||
"project_id": incremental.project_id == full.project_id,
|
||||
"adapter_id": incremental.adapter_id == full.adapter_id,
|
||||
"adapter_version": incremental.adapter_version == full.adapter_version,
|
||||
"revision": incremental.revision == full.revision,
|
||||
"source_hash": incremental.source_hash == full.source_hash,
|
||||
"nodes": incremental.nodes == full.nodes,
|
||||
"edges": incremental.edges == full.edges,
|
||||
}
|
||||
complete = AdapterAssembly(full)
|
||||
if isinstance(loader, CompleteAdapterAssemblyLoader):
|
||||
complete = _load_complete_adapter_assembly(loader)
|
||||
_validate_adapter_assembly(complete)
|
||||
oracle_fields = _projection_equivalence_fields(complete.projection, full)
|
||||
oracle_mismatches = [field for field, matches in oracle_fields.items() if not matches]
|
||||
if oracle_mismatches:
|
||||
raise DocForgeError(
|
||||
"complete_oracle_mismatch",
|
||||
"Complete adapter assembly does not match load_projection()",
|
||||
fields=oracle_mismatches,
|
||||
)
|
||||
elif incremental.logic:
|
||||
raise DocForgeError(
|
||||
"complete_logic_oracle_required",
|
||||
"Incremental Logic publication requires load_complete_assembly()",
|
||||
)
|
||||
fields = _assembly_equivalence_fields(incremental, complete)
|
||||
mismatches = [field for field, matches in fields.items() if not matches]
|
||||
if mismatches:
|
||||
raise DocForgeError(
|
||||
"incremental_mismatch",
|
||||
"Incremental extraction does not match a full adapter projection",
|
||||
"Incremental extraction does not match a complete adapter assembly",
|
||||
fields=mismatches,
|
||||
)
|
||||
self.validate_runtime()
|
||||
return {
|
||||
"status": "ok",
|
||||
"project_id": incremental.project_id,
|
||||
"revision": incremental.revision,
|
||||
"source_hash": incremental.source_hash,
|
||||
"node_count": len(incremental.nodes),
|
||||
"edge_count": len(incremental.edges),
|
||||
"project_id": incremental.projection.project_id,
|
||||
"revision": incremental.projection.revision,
|
||||
"source_hash": incremental.projection.source_hash,
|
||||
"node_count": len(incremental.projection.nodes),
|
||||
"edge_count": len(incremental.projection.edges),
|
||||
"logic_projection_count": len(incremental.logic),
|
||||
}
|
||||
|
||||
def _load_incremental(self) -> AdapterProjection:
|
||||
return self._load_incremental_assembly().projection
|
||||
|
||||
def _load_incremental_assembly(self) -> AdapterAssembly:
|
||||
loader = self._incremental_loader
|
||||
if loader is None:
|
||||
raise DocForgeError("incremental_disabled", "Incremental adapter is not configured")
|
||||
|
|
@ -658,6 +719,7 @@ class AdapterProject:
|
|||
key=lambda projection: projection.owner_node_id,
|
||||
)
|
||||
)
|
||||
assembly = AdapterAssembly(projection=projection, logic=logic_projections)
|
||||
identity = (
|
||||
projection.project_id,
|
||||
projection.adapter_id,
|
||||
|
|
@ -674,21 +736,7 @@ class AdapterProject:
|
|||
"invalid_adapter",
|
||||
"Incremental assembly changed the manifest-bound project identity",
|
||||
)
|
||||
validate_projection(projection)
|
||||
owners = [projection.owner_node_id for projection in logic_projections]
|
||||
if len(owners) != len(set(owners)):
|
||||
raise DocForgeError(
|
||||
"invalid_adapter", "A primary graph node may own only one logic projection"
|
||||
)
|
||||
node_ids = {item.node.node_id for item in projection.nodes}
|
||||
for logic_projection in logic_projections:
|
||||
validate_logic_projection(logic_projection)
|
||||
if logic_projection.owner_node_id not in node_ids:
|
||||
raise DocForgeError(
|
||||
"invalid_adapter",
|
||||
"A Logic projection owner must exist in the assembled primary graph",
|
||||
owner_node_id=logic_projection.owner_node_id,
|
||||
)
|
||||
_validate_adapter_assembly(assembly)
|
||||
stable = loader.load_manifest()
|
||||
validate_manifest(stable)
|
||||
if stable != manifest:
|
||||
|
|
@ -716,7 +764,7 @@ class AdapterProject:
|
|||
"cache_hit_ids": hits,
|
||||
"reparsed_source_ids": reparsed,
|
||||
}
|
||||
return projection
|
||||
return assembly
|
||||
|
||||
def canonical_source_paths(self) -> tuple[Path, ...]:
|
||||
"""Adapters validate their own source sets before producing a projection."""
|
||||
|
|
@ -1032,6 +1080,141 @@ class AdapterProject:
|
|||
outputs.add(output)
|
||||
|
||||
|
||||
def _validate_adapter_assembly(assembly: AdapterAssembly) -> None:
|
||||
validate_projection(assembly.projection)
|
||||
ordered_logic = tuple(sorted(assembly.logic, key=lambda projection: projection.owner_node_id))
|
||||
if assembly.logic != ordered_logic:
|
||||
raise DocForgeError(
|
||||
"invalid_adapter",
|
||||
"Complete Logic projections must be deterministically ordered",
|
||||
)
|
||||
owners = [projection.owner_node_id for projection in assembly.logic]
|
||||
if len(owners) != len(set(owners)):
|
||||
raise DocForgeError(
|
||||
"invalid_adapter", "A primary graph node may own only one logic projection"
|
||||
)
|
||||
node_ids = {item.node.node_id for item in assembly.projection.nodes}
|
||||
for logic_projection in assembly.logic:
|
||||
validate_logic_projection(logic_projection)
|
||||
if logic_projection.owner_node_id not in node_ids:
|
||||
raise DocForgeError(
|
||||
"invalid_adapter",
|
||||
"A Logic projection owner must exist in the assembled primary graph",
|
||||
owner_node_id=logic_projection.owner_node_id,
|
||||
)
|
||||
|
||||
|
||||
def _projection_equivalence_fields(
|
||||
candidate: AdapterProjection,
|
||||
reference: AdapterProjection,
|
||||
) -> dict[str, bool]:
|
||||
return {
|
||||
"project_id": candidate.project_id == reference.project_id,
|
||||
"title": candidate.title == reference.title,
|
||||
"adapter_id": candidate.adapter_id == reference.adapter_id,
|
||||
"adapter_version": candidate.adapter_version == reference.adapter_version,
|
||||
"root": candidate.root == reference.root,
|
||||
"revision": candidate.revision == reference.revision,
|
||||
"source_hash": candidate.source_hash == reference.source_hash,
|
||||
"nodes": candidate.nodes == reference.nodes,
|
||||
"edges": candidate.edges == reference.edges,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_equivalence_fields(
|
||||
candidate: AdapterAssembly,
|
||||
reference: AdapterAssembly,
|
||||
) -> dict[str, bool]:
|
||||
return {
|
||||
**_projection_equivalence_fields(candidate.projection, reference.projection),
|
||||
"logic": candidate.logic == reference.logic,
|
||||
}
|
||||
|
||||
|
||||
def _assembly_hash(assembly: AdapterAssembly) -> str:
|
||||
projection = assembly.projection
|
||||
payload = {
|
||||
"projection": {
|
||||
"project_id": projection.project_id,
|
||||
"title": projection.title,
|
||||
"adapter_id": projection.adapter_id,
|
||||
"adapter_version": projection.adapter_version,
|
||||
"root": str(projection.root),
|
||||
"revision": projection.revision,
|
||||
"source_hash": projection.source_hash,
|
||||
"nodes": [item.as_dict() for item in projection.nodes],
|
||||
"edges": [item.as_dict() for item in projection.edges],
|
||||
},
|
||||
"logic": [logic_projection.as_dict() for logic_projection in assembly.logic],
|
||||
}
|
||||
return hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _complete_reference_assembly(loader: AdapterLoader) -> AdapterAssembly:
|
||||
if isinstance(loader, CompleteAdapterAssemblyLoader):
|
||||
assembly = _load_complete_adapter_assembly(loader)
|
||||
else:
|
||||
assembly = AdapterAssembly(_load_adapter_projection(loader))
|
||||
_validate_adapter_assembly(assembly)
|
||||
return assembly
|
||||
|
||||
|
||||
def verify_adapter_conformance(
|
||||
loader: AdapterLoader,
|
||||
*,
|
||||
cache_root: Path,
|
||||
settings: AdapterProjectSettings | None = None,
|
||||
) -> AdapterConformanceReport:
|
||||
"""Prove deterministic complete output and, when supported, incremental parity."""
|
||||
|
||||
project = AdapterProject(loader, cache_root=cache_root, settings=settings)
|
||||
project.validate_runtime()
|
||||
first = _complete_reference_assembly(loader)
|
||||
second = _complete_reference_assembly(loader)
|
||||
deterministic_fields = _assembly_equivalence_fields(first, second)
|
||||
nondeterministic = [field for field, matches in deterministic_fields.items() if not matches]
|
||||
if nondeterministic:
|
||||
raise DocForgeError(
|
||||
"nondeterministic_adapter",
|
||||
"Repeated complete adapter assemblies do not match",
|
||||
fields=nondeterministic,
|
||||
)
|
||||
|
||||
if isinstance(loader, CompleteAdapterAssemblyLoader):
|
||||
graph_oracle = _load_adapter_projection(loader)
|
||||
validate_projection(graph_oracle)
|
||||
oracle_fields = _projection_equivalence_fields(first.projection, graph_oracle)
|
||||
oracle_mismatches = [field for field, matches in oracle_fields.items() if not matches]
|
||||
if oracle_mismatches:
|
||||
raise DocForgeError(
|
||||
"complete_oracle_mismatch",
|
||||
"Complete adapter assembly does not match load_projection()",
|
||||
fields=oracle_mismatches,
|
||||
)
|
||||
|
||||
incremental = isinstance(loader, IncrementalAdapterLoader)
|
||||
if incremental:
|
||||
project.verify_incremental_equivalence()
|
||||
|
||||
project.validate_runtime()
|
||||
projection = first.projection
|
||||
return AdapterConformanceReport(
|
||||
schema_version=1,
|
||||
project_id=projection.project_id,
|
||||
adapter_id=projection.adapter_id,
|
||||
adapter_version=projection.adapter_version,
|
||||
revision=projection.revision,
|
||||
source_hash=projection.source_hash,
|
||||
assembly_hash=_assembly_hash(first),
|
||||
node_count=len(projection.nodes),
|
||||
edge_count=len(projection.edges),
|
||||
logic_projection_count=len(first.logic),
|
||||
incremental=incremental,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ShadowArtifact:
|
||||
"""One named deterministic byte artifact used by a shadow comparison."""
|
||||
|
|
|
|||
69
src/docforge/adapter_sdk.py
Normal file
69
src/docforge/adapter_sdk.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Public typed surface for project adapter implementations and conformance checks."""
|
||||
|
||||
from .adapter_contract import (
|
||||
AdapterAssembly,
|
||||
AdapterConformanceReport,
|
||||
AdapterEdge,
|
||||
AdapterImplementation,
|
||||
AdapterLoader,
|
||||
AdapterManifest,
|
||||
AdapterNode,
|
||||
AdapterProject,
|
||||
AdapterProjection,
|
||||
AdapterProjectSettings,
|
||||
AdapterSource,
|
||||
AdapterSourceProjection,
|
||||
CompleteAdapterAssemblyLoader,
|
||||
IncrementalAdapterAssembler,
|
||||
IncrementalAdapterLoader,
|
||||
verify_adapter_conformance,
|
||||
)
|
||||
from .adapter_validation import (
|
||||
validate_logic_projection,
|
||||
validate_manifest,
|
||||
validate_projection,
|
||||
validate_source_projection,
|
||||
)
|
||||
from .models import (
|
||||
Edge,
|
||||
Limits,
|
||||
LogicEdge,
|
||||
LogicNode,
|
||||
LogicProjection,
|
||||
Node,
|
||||
ProposalWriter,
|
||||
RenderConfig,
|
||||
RenderView,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AdapterAssembly",
|
||||
"AdapterConformanceReport",
|
||||
"AdapterEdge",
|
||||
"AdapterImplementation",
|
||||
"AdapterLoader",
|
||||
"AdapterManifest",
|
||||
"AdapterNode",
|
||||
"AdapterProject",
|
||||
"AdapterProjection",
|
||||
"AdapterProjectSettings",
|
||||
"AdapterSource",
|
||||
"AdapterSourceProjection",
|
||||
"CompleteAdapterAssemblyLoader",
|
||||
"Edge",
|
||||
"IncrementalAdapterAssembler",
|
||||
"IncrementalAdapterLoader",
|
||||
"Limits",
|
||||
"LogicEdge",
|
||||
"LogicNode",
|
||||
"LogicProjection",
|
||||
"Node",
|
||||
"ProposalWriter",
|
||||
"RenderConfig",
|
||||
"RenderView",
|
||||
"validate_logic_projection",
|
||||
"validate_manifest",
|
||||
"validate_projection",
|
||||
"validate_source_projection",
|
||||
"verify_adapter_conformance",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue