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, ...] = ()
|
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):
|
class AdapterLoader(Protocol):
|
||||||
"""Load one current, deterministic, project-confined adapter projection."""
|
"""Load one current, deterministic, project-confined adapter projection."""
|
||||||
|
|
||||||
|
|
@ -201,6 +208,14 @@ def _extract_adapter_source(
|
||||||
return loader.extract_source(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)
|
@dataclass(frozen=True)
|
||||||
class AdapterImplementation:
|
class AdapterImplementation:
|
||||||
"""One confined implementation boundary that must remain stable for a process."""
|
"""One confined implementation boundary that must remain stable for a process."""
|
||||||
|
|
@ -232,6 +247,38 @@ class _ImplementationSnapshot:
|
||||||
files: tuple[tuple[str, str], ...]
|
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:
|
class AdapterProject:
|
||||||
"""Expose a validated adapter projection through the standard index boundary."""
|
"""Expose a validated adapter projection through the standard index boundary."""
|
||||||
|
|
||||||
|
|
@ -523,43 +570,57 @@ class AdapterProject:
|
||||||
return self._last_logic
|
return self._last_logic
|
||||||
|
|
||||||
def verify_incremental_equivalence(self) -> dict[str, object]:
|
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()
|
self.validate_runtime()
|
||||||
if self._incremental_loader is None:
|
loader = self._incremental_loader
|
||||||
|
if loader is None:
|
||||||
raise DocForgeError(
|
raise DocForgeError(
|
||||||
"incremental_disabled", "Adapter does not implement incremental extraction"
|
"incremental_disabled", "Adapter does not implement incremental extraction"
|
||||||
)
|
)
|
||||||
incremental = self._load_incremental()
|
incremental = self._load_incremental_assembly()
|
||||||
full = _load_adapter_projection(self.loader)
|
full = _load_adapter_projection(self.loader)
|
||||||
validate_projection(full)
|
validate_projection(full)
|
||||||
fields = {
|
complete = AdapterAssembly(full)
|
||||||
"project_id": incremental.project_id == full.project_id,
|
if isinstance(loader, CompleteAdapterAssemblyLoader):
|
||||||
"adapter_id": incremental.adapter_id == full.adapter_id,
|
complete = _load_complete_adapter_assembly(loader)
|
||||||
"adapter_version": incremental.adapter_version == full.adapter_version,
|
_validate_adapter_assembly(complete)
|
||||||
"revision": incremental.revision == full.revision,
|
oracle_fields = _projection_equivalence_fields(complete.projection, full)
|
||||||
"source_hash": incremental.source_hash == full.source_hash,
|
oracle_mismatches = [field for field, matches in oracle_fields.items() if not matches]
|
||||||
"nodes": incremental.nodes == full.nodes,
|
if oracle_mismatches:
|
||||||
"edges": incremental.edges == full.edges,
|
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]
|
mismatches = [field for field, matches in fields.items() if not matches]
|
||||||
if mismatches:
|
if mismatches:
|
||||||
raise DocForgeError(
|
raise DocForgeError(
|
||||||
"incremental_mismatch",
|
"incremental_mismatch",
|
||||||
"Incremental extraction does not match a full adapter projection",
|
"Incremental extraction does not match a complete adapter assembly",
|
||||||
fields=mismatches,
|
fields=mismatches,
|
||||||
)
|
)
|
||||||
self.validate_runtime()
|
self.validate_runtime()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"project_id": incremental.project_id,
|
"project_id": incremental.projection.project_id,
|
||||||
"revision": incremental.revision,
|
"revision": incremental.projection.revision,
|
||||||
"source_hash": incremental.source_hash,
|
"source_hash": incremental.projection.source_hash,
|
||||||
"node_count": len(incremental.nodes),
|
"node_count": len(incremental.projection.nodes),
|
||||||
"edge_count": len(incremental.edges),
|
"edge_count": len(incremental.projection.edges),
|
||||||
|
"logic_projection_count": len(incremental.logic),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _load_incremental(self) -> AdapterProjection:
|
def _load_incremental(self) -> AdapterProjection:
|
||||||
|
return self._load_incremental_assembly().projection
|
||||||
|
|
||||||
|
def _load_incremental_assembly(self) -> AdapterAssembly:
|
||||||
loader = self._incremental_loader
|
loader = self._incremental_loader
|
||||||
if loader is None:
|
if loader is None:
|
||||||
raise DocForgeError("incremental_disabled", "Incremental adapter is not configured")
|
raise DocForgeError("incremental_disabled", "Incremental adapter is not configured")
|
||||||
|
|
@ -658,6 +719,7 @@ class AdapterProject:
|
||||||
key=lambda projection: projection.owner_node_id,
|
key=lambda projection: projection.owner_node_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
assembly = AdapterAssembly(projection=projection, logic=logic_projections)
|
||||||
identity = (
|
identity = (
|
||||||
projection.project_id,
|
projection.project_id,
|
||||||
projection.adapter_id,
|
projection.adapter_id,
|
||||||
|
|
@ -674,21 +736,7 @@ class AdapterProject:
|
||||||
"invalid_adapter",
|
"invalid_adapter",
|
||||||
"Incremental assembly changed the manifest-bound project identity",
|
"Incremental assembly changed the manifest-bound project identity",
|
||||||
)
|
)
|
||||||
validate_projection(projection)
|
_validate_adapter_assembly(assembly)
|
||||||
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,
|
|
||||||
)
|
|
||||||
stable = loader.load_manifest()
|
stable = loader.load_manifest()
|
||||||
validate_manifest(stable)
|
validate_manifest(stable)
|
||||||
if stable != manifest:
|
if stable != manifest:
|
||||||
|
|
@ -716,7 +764,7 @@ class AdapterProject:
|
||||||
"cache_hit_ids": hits,
|
"cache_hit_ids": hits,
|
||||||
"reparsed_source_ids": reparsed,
|
"reparsed_source_ids": reparsed,
|
||||||
}
|
}
|
||||||
return projection
|
return assembly
|
||||||
|
|
||||||
def canonical_source_paths(self) -> tuple[Path, ...]:
|
def canonical_source_paths(self) -> tuple[Path, ...]:
|
||||||
"""Adapters validate their own source sets before producing a projection."""
|
"""Adapters validate their own source sets before producing a projection."""
|
||||||
|
|
@ -1032,6 +1080,141 @@ class AdapterProject:
|
||||||
outputs.add(output)
|
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)
|
@dataclass(frozen=True)
|
||||||
class ShadowArtifact:
|
class ShadowArtifact:
|
||||||
"""One named deterministic byte artifact used by a shadow comparison."""
|
"""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",
|
||||||
|
]
|
||||||
|
|
@ -248,6 +248,36 @@ class NonLogicIncrementalLoader(IncrementalLoader):
|
||||||
return replace(super().extract_source(source), logic=())
|
return replace(super().extract_source(source), logic=())
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteLogicIncrementalLoader(IncrementalLoader):
|
||||||
|
"""Supply an independent complete Logic oracle alongside the legacy graph oracle."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path, *, mismatched_logic: bool = False) -> None:
|
||||||
|
super().__init__(root)
|
||||||
|
self.mismatched_logic = mismatched_logic
|
||||||
|
|
||||||
|
def load_complete_assembly(self) -> AdapterAssembly:
|
||||||
|
projection = super().load_projection()
|
||||||
|
if "guide.workflow" not in self.sources:
|
||||||
|
return AdapterAssembly(projection)
|
||||||
|
manifest = self.load_manifest()
|
||||||
|
workflow = next(
|
||||||
|
source for source in manifest.sources if source.source_id == "guide.workflow"
|
||||||
|
)
|
||||||
|
logic = self.extract_source(workflow).logic
|
||||||
|
if self.mismatched_logic:
|
||||||
|
current = logic[0]
|
||||||
|
logic = (
|
||||||
|
replace(
|
||||||
|
current,
|
||||||
|
nodes=(
|
||||||
|
current.nodes[0],
|
||||||
|
replace(current.nodes[1], label="Different complete return"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return AdapterAssembly(projection, logic)
|
||||||
|
|
||||||
|
|
||||||
class AdapterContractTests(unittest.TestCase):
|
class AdapterContractTests(unittest.TestCase):
|
||||||
def projection(self, root: Path) -> AdapterProjection:
|
def projection(self, root: Path) -> AdapterProjection:
|
||||||
foundation = Node(
|
foundation = Node(
|
||||||
|
|
@ -782,6 +812,46 @@ class AdapterContractTests(unittest.TestCase):
|
||||||
self.assertEqual("ok", equivalent["status"])
|
self.assertEqual("ok", equivalent["status"])
|
||||||
self.assertEqual(1, equivalent["node_count"])
|
self.assertEqual(1, equivalent["node_count"])
|
||||||
|
|
||||||
|
def test_logic_equivalence_requires_an_independent_complete_oracle(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
project = AdapterProject(
|
||||||
|
IncrementalLoader(root),
|
||||||
|
cache_root=root / ".cache" / "logic-without-oracle",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
project.verify_incremental_equivalence()
|
||||||
|
|
||||||
|
self.assertEqual("complete_logic_oracle_required", captured.exception.code)
|
||||||
|
|
||||||
|
def test_logic_equivalence_uses_the_complete_assembly_oracle(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
project = AdapterProject(
|
||||||
|
CompleteLogicIncrementalLoader(root),
|
||||||
|
cache_root=root / ".cache" / "logic-oracle",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = project.verify_incremental_equivalence()
|
||||||
|
|
||||||
|
self.assertEqual("ok", result["status"])
|
||||||
|
self.assertEqual(1, result["logic_projection_count"])
|
||||||
|
|
||||||
|
def test_logic_equivalence_reports_exact_mismatch_field(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
project = AdapterProject(
|
||||||
|
CompleteLogicIncrementalLoader(root, mismatched_logic=True),
|
||||||
|
cache_root=root / ".cache" / "logic-mismatch",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
project.verify_incremental_equivalence()
|
||||||
|
|
||||||
|
self.assertEqual("incremental_mismatch", captured.exception.code)
|
||||||
|
self.assertEqual(["logic"], captured.exception.details["fields"])
|
||||||
|
|
||||||
def test_incremental_adapter_can_assemble_overlapping_source_evidence(self) -> None:
|
def test_incremental_adapter_can_assemble_overlapping_source_evidence(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory).resolve()
|
root = Path(directory).resolve()
|
||||||
|
|
|
||||||
261
tests/test_adapter_sdk.py
Normal file
261
tests/test_adapter_sdk.py
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import docforge.adapter_sdk as sdk
|
||||||
|
from docforge.errors import DocForgeError
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceIncrementalAdapter:
|
||||||
|
"""Small public-SDK-only adapter used to prove the conformance entry point."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash(value: str) -> str:
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()
|
||||||
|
|
||||||
|
def _node(self) -> sdk.AdapterNode:
|
||||||
|
content = "A deterministic reference function."
|
||||||
|
return sdk.AdapterNode(
|
||||||
|
sdk.Node(
|
||||||
|
node_id="reference.run",
|
||||||
|
title="Reference run",
|
||||||
|
family="source",
|
||||||
|
authority="derived",
|
||||||
|
status="current",
|
||||||
|
tags=("python",),
|
||||||
|
summary=content,
|
||||||
|
content=content,
|
||||||
|
source_path="src/reference.py",
|
||||||
|
source_anchor="L1",
|
||||||
|
content_hash=self._hash(content),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _logic() -> sdk.LogicProjection:
|
||||||
|
return sdk.LogicProjection(
|
||||||
|
owner_node_id="reference.run",
|
||||||
|
source_id="source.reference",
|
||||||
|
nodes=(
|
||||||
|
sdk.LogicNode("entry", "entry", "Enter run", "L1"),
|
||||||
|
sdk.LogicNode("return", "return", "Return", "L2"),
|
||||||
|
),
|
||||||
|
edges=(sdk.LogicEdge("entry", "return", "return", "RETURN", 0),),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_manifest(self) -> sdk.AdapterManifest:
|
||||||
|
fingerprint = self._hash("def run(): return True\n")
|
||||||
|
return sdk.AdapterManifest(
|
||||||
|
project_id="sdk-reference",
|
||||||
|
title="SDK reference",
|
||||||
|
adapter_id="python-reference",
|
||||||
|
adapter_version="1",
|
||||||
|
root=self.root,
|
||||||
|
revision=fingerprint[:12],
|
||||||
|
source_hash=fingerprint,
|
||||||
|
families=("source",),
|
||||||
|
allowed_relations=(),
|
||||||
|
sources=(
|
||||||
|
sdk.AdapterSource(
|
||||||
|
source_id="source.reference",
|
||||||
|
source_path="src/reference.py",
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
extractor_version="python-reference@1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
estimated_nodes=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract_source(self, source: sdk.AdapterSource) -> sdk.AdapterSourceProjection:
|
||||||
|
return sdk.AdapterSourceProjection(
|
||||||
|
source_id=source.source_id,
|
||||||
|
fingerprint=source.fingerprint,
|
||||||
|
nodes=(self._node(),),
|
||||||
|
edges=(),
|
||||||
|
logic=(self._logic(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_projection(self) -> sdk.AdapterProjection:
|
||||||
|
manifest = self.load_manifest()
|
||||||
|
return sdk.AdapterProjection(
|
||||||
|
project_id=manifest.project_id,
|
||||||
|
title=manifest.title,
|
||||||
|
adapter_id=manifest.adapter_id,
|
||||||
|
adapter_version=manifest.adapter_version,
|
||||||
|
root=manifest.root,
|
||||||
|
revision=manifest.revision,
|
||||||
|
source_hash=manifest.source_hash,
|
||||||
|
nodes=(self._node(),),
|
||||||
|
edges=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_complete_assembly(self) -> sdk.AdapterAssembly:
|
||||||
|
return sdk.AdapterAssembly(
|
||||||
|
projection=self.load_projection(),
|
||||||
|
logic=(self._logic(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyAdapter:
|
||||||
|
def __init__(self, projection: sdk.AdapterProjection) -> None:
|
||||||
|
self.projection = projection
|
||||||
|
|
||||||
|
def load_projection(self) -> sdk.AdapterProjection:
|
||||||
|
return self.projection
|
||||||
|
|
||||||
|
|
||||||
|
class LogicWithoutCompleteAdapter:
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.reference = ReferenceIncrementalAdapter(root)
|
||||||
|
|
||||||
|
def load_manifest(self) -> sdk.AdapterManifest:
|
||||||
|
return self.reference.load_manifest()
|
||||||
|
|
||||||
|
def extract_source(self, source: sdk.AdapterSource) -> sdk.AdapterSourceProjection:
|
||||||
|
return self.reference.extract_source(source)
|
||||||
|
|
||||||
|
def load_projection(self) -> sdk.AdapterProjection:
|
||||||
|
return self.reference.load_projection()
|
||||||
|
|
||||||
|
|
||||||
|
class NondeterministicCompleteAdapter(ReferenceIncrementalAdapter):
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
super().__init__(root)
|
||||||
|
self.complete_calls = 0
|
||||||
|
|
||||||
|
def load_complete_assembly(self) -> sdk.AdapterAssembly:
|
||||||
|
self.complete_calls += 1
|
||||||
|
assembly = super().load_complete_assembly()
|
||||||
|
if self.complete_calls % 2 == 0:
|
||||||
|
logic = assembly.logic[0]
|
||||||
|
return replace(
|
||||||
|
assembly,
|
||||||
|
logic=(
|
||||||
|
replace(
|
||||||
|
logic,
|
||||||
|
nodes=(
|
||||||
|
logic.nodes[0],
|
||||||
|
replace(logic.nodes[1], label="Unstable return"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return assembly
|
||||||
|
|
||||||
|
|
||||||
|
class AdapterSdkTests(unittest.TestCase):
|
||||||
|
def test_public_facade_contains_the_complete_authoring_surface(self) -> None:
|
||||||
|
expected = {
|
||||||
|
"AdapterAssembly",
|
||||||
|
"AdapterConformanceReport",
|
||||||
|
"AdapterManifest",
|
||||||
|
"AdapterNode",
|
||||||
|
"AdapterProjection",
|
||||||
|
"AdapterSource",
|
||||||
|
"AdapterSourceProjection",
|
||||||
|
"CompleteAdapterAssemblyLoader",
|
||||||
|
"Edge",
|
||||||
|
"LogicEdge",
|
||||||
|
"LogicNode",
|
||||||
|
"LogicProjection",
|
||||||
|
"Node",
|
||||||
|
"verify_adapter_conformance",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertLessEqual(expected, set(sdk.__all__))
|
||||||
|
for name in expected:
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.assertTrue(hasattr(sdk, name))
|
||||||
|
|
||||||
|
def test_conformance_proves_deterministic_complete_graph_and_logic_parity(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
report = sdk.verify_adapter_conformance(
|
||||||
|
ReferenceIncrementalAdapter(root),
|
||||||
|
cache_root=root / ".cache" / "conformance",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, report.schema_version)
|
||||||
|
self.assertTrue(report.incremental)
|
||||||
|
self.assertEqual(1, report.node_count)
|
||||||
|
self.assertEqual(0, report.edge_count)
|
||||||
|
self.assertEqual(1, report.logic_projection_count)
|
||||||
|
self.assertEqual(64, len(report.assembly_hash))
|
||||||
|
self.assertEqual(report.assembly_hash, report.as_dict()["assembly_hash"])
|
||||||
|
|
||||||
|
def test_conformance_keeps_load_projection_only_adapters_first_class(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
content = "Legacy adapter content."
|
||||||
|
projection = sdk.AdapterProjection(
|
||||||
|
project_id="legacy-sdk",
|
||||||
|
title="Legacy SDK",
|
||||||
|
adapter_id="legacy",
|
||||||
|
adapter_version="1",
|
||||||
|
root=root,
|
||||||
|
revision="legacy",
|
||||||
|
source_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||||
|
nodes=(
|
||||||
|
sdk.AdapterNode(
|
||||||
|
sdk.Node(
|
||||||
|
node_id="legacy.node",
|
||||||
|
title="Legacy node",
|
||||||
|
family="guide",
|
||||||
|
authority="authoritative",
|
||||||
|
status="current",
|
||||||
|
tags=("legacy",),
|
||||||
|
summary=content,
|
||||||
|
content=content,
|
||||||
|
source_path="docs/legacy.md",
|
||||||
|
source_anchor=None,
|
||||||
|
content_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
edges=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
report = sdk.verify_adapter_conformance(
|
||||||
|
LegacyAdapter(projection),
|
||||||
|
cache_root=root / ".cache" / "legacy-conformance",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(report.incremental)
|
||||||
|
self.assertEqual(0, report.logic_projection_count)
|
||||||
|
self.assertEqual(projection.identity(), projection.identity())
|
||||||
|
|
||||||
|
def test_conformance_requires_a_complete_oracle_for_incremental_logic(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
sdk.verify_adapter_conformance(
|
||||||
|
LogicWithoutCompleteAdapter(root),
|
||||||
|
cache_root=root / ".cache" / "missing-logic-oracle",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("complete_logic_oracle_required", captured.exception.code)
|
||||||
|
|
||||||
|
def test_conformance_rejects_nondeterministic_complete_logic(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory).resolve()
|
||||||
|
|
||||||
|
with self.assertRaises(DocForgeError) as captured:
|
||||||
|
sdk.verify_adapter_conformance(
|
||||||
|
NondeterministicCompleteAdapter(root),
|
||||||
|
cache_root=root / ".cache" / "nondeterministic",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("nondeterministic_adapter", captured.exception.code)
|
||||||
|
self.assertEqual(["logic"], captured.exception.details["fields"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -43,6 +43,7 @@ PUBLIC_IMPORTS = {
|
||||||
),
|
),
|
||||||
"docforge.adapter_contract": (
|
"docforge.adapter_contract": (
|
||||||
"AdapterAssembly",
|
"AdapterAssembly",
|
||||||
|
"AdapterConformanceReport",
|
||||||
"AdapterEdge",
|
"AdapterEdge",
|
||||||
"AdapterImplementation",
|
"AdapterImplementation",
|
||||||
"AdapterLoader",
|
"AdapterLoader",
|
||||||
|
|
@ -53,8 +54,24 @@ PUBLIC_IMPORTS = {
|
||||||
"AdapterProjectSettings",
|
"AdapterProjectSettings",
|
||||||
"AdapterSource",
|
"AdapterSource",
|
||||||
"AdapterSourceProjection",
|
"AdapterSourceProjection",
|
||||||
|
"CompleteAdapterAssemblyLoader",
|
||||||
"IncrementalAdapterAssembler",
|
"IncrementalAdapterAssembler",
|
||||||
"IncrementalAdapterLoader",
|
"IncrementalAdapterLoader",
|
||||||
|
"verify_adapter_conformance",
|
||||||
|
),
|
||||||
|
"docforge.adapter_sdk": (
|
||||||
|
"AdapterAssembly",
|
||||||
|
"AdapterConformanceReport",
|
||||||
|
"AdapterLoader",
|
||||||
|
"AdapterManifest",
|
||||||
|
"AdapterProject",
|
||||||
|
"AdapterProjection",
|
||||||
|
"AdapterSourceProjection",
|
||||||
|
"CompleteAdapterAssemblyLoader",
|
||||||
|
"IncrementalAdapterLoader",
|
||||||
|
"LogicProjection",
|
||||||
|
"Node",
|
||||||
|
"verify_adapter_conformance",
|
||||||
),
|
),
|
||||||
"docforge.application": (
|
"docforge.application": (
|
||||||
"CanonicalApplier",
|
"CanonicalApplier",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue