cernora 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. cernora/__init__.py +94 -0
  2. cernora/__main__.py +5 -0
  3. cernora/adapter.py +31 -0
  4. cernora/cli/__init__.py +1 -0
  5. cernora/cli/main.py +121 -0
  6. cernora/cli/profiles.py +25 -0
  7. cernora/composition/__init__.py +1 -0
  8. cernora/composition/gating.py +126 -0
  9. cernora/composition/scoring.py +18 -0
  10. cernora/conformance.py +195 -0
  11. cernora/core/__init__.py +1 -0
  12. cernora/core/canonical.py +65 -0
  13. cernora/core/case.py +62 -0
  14. cernora/core/errors.py +13 -0
  15. cernora/core/evidence.py +124 -0
  16. cernora/core/evidence_bundle_v2.py +391 -0
  17. cernora/core/gate.py +72 -0
  18. cernora/core/identity.py +69 -0
  19. cernora/core/score.py +41 -0
  20. cernora/evaluation/__init__.py +1 -0
  21. cernora/evaluation/contracts.py +209 -0
  22. cernora/evaluation/imported_case.py +255 -0
  23. cernora/evaluation/package.py +370 -0
  24. cernora/examples/__init__.py +1 -0
  25. cernora/examples/coding_task/__init__.py +11 -0
  26. cernora/examples/coding_task/__main__.py +20 -0
  27. cernora/examples/coding_task/adapter.py +402 -0
  28. cernora/examples/coding_task/resources/candidates/backend-v1.json +1 -0
  29. cernora/examples/coding_task/resources/candidates/fail-closed-v1.json +1 -0
  30. cernora/examples/coding_task/resources/candidates/frontend-v1.json +1 -0
  31. cernora/examples/coding_task/workflow.py +115 -0
  32. cernora/examples/offline_workflow/__init__.py +13 -0
  33. cernora/examples/offline_workflow/__main__.py +21 -0
  34. cernora/examples/offline_workflow/adapter.py +390 -0
  35. cernora/examples/offline_workflow/resources/completed-export.json +1 -0
  36. cernora/examples/offline_workflow/workflow.py +106 -0
  37. cernora/ingestion/__init__.py +1 -0
  38. cernora/ingestion/contracts_v2.py +192 -0
  39. cernora/ingestion/errors.py +31 -0
  40. cernora/ingestion/package_v2.py +952 -0
  41. cernora/profile.py +56 -0
  42. cernora/profile_loader.py +98 -0
  43. cernora/profile_workspace.py +324 -0
  44. cernora/profiles/coding_task/__init__.py +318 -0
  45. cernora/profiles/coding_task/resources/checks/backend-v1.json +1 -0
  46. cernora/profiles/coding_task/resources/checks/fail-closed-v1.json +1 -0
  47. cernora/profiles/coding_task/resources/checks/frontend-v1.json +1 -0
  48. cernora/profiles/coding_task/resources/profile.json +44 -0
  49. cernora/profiles/offline_workflow/__init__.py +270 -0
  50. cernora/profiles/offline_workflow/resources/lookup-result.json +1 -0
  51. cernora/profiles/offline_workflow/resources/profile.json +38 -0
  52. cernora/py.typed +1 -0
  53. cernora/resources.py +27 -0
  54. cernora/schemas/__init__.py +1 -0
  55. cernora/schemas/case-profile-v1.schema.json +71 -0
  56. cernora/schemas/evidence-bundle-v2.schema.json +321 -0
  57. cernora/schemas/evidence-v1.schema.json +109 -0
  58. cernora/schemas/gate-decision-v1.schema.json +55 -0
  59. cernora/schemas/import-manifest-v2.schema.json +38 -0
  60. cernora/schemas/import-receipt-v2.schema.json +169 -0
  61. cernora/schemas/imported-evaluation-authority-v1.schema.json +218 -0
  62. cernora/schemas/imported-evaluation-manifest-v1.schema.json +55 -0
  63. cernora/schemas/imported-evaluation-receipt-v1.schema.json +469 -0
  64. cernora/schemas/score-v1.schema.json +37 -0
  65. cernora-0.1.0.dist-info/METADATA +299 -0
  66. cernora-0.1.0.dist-info/RECORD +69 -0
  67. cernora-0.1.0.dist-info/WHEEL +4 -0
  68. cernora-0.1.0.dist-info/entry_points.txt +2 -0
  69. cernora-0.1.0.dist-info/licenses/LICENSE +201 -0
cernora/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """Cernora: evidence-bound evaluation for tool-using agents."""
2
+
3
+ from cernora.adapter import AdaptedBundle, Adapter, CompletedExport
4
+ from cernora.conformance import (
5
+ AdapterConformance,
6
+ ConformanceError,
7
+ ProfileConformance,
8
+ check_adapter_conformance,
9
+ check_profile_conformance,
10
+ )
11
+ from cernora.core.case import (
12
+ Case,
13
+ CaseInput,
14
+ CaseProfile,
15
+ FixtureReference,
16
+ GatePolicy,
17
+ ScorerPolicy,
18
+ )
19
+ from cernora.core.evidence import (
20
+ AnswerClaim,
21
+ Artifact,
22
+ Evidence,
23
+ EvidenceReference,
24
+ Failure,
25
+ ProcessResult,
26
+ StructuredAnswer,
27
+ ToolAction,
28
+ )
29
+ from cernora.core.evidence_bundle_v2 import EvidenceBundleV2
30
+ from cernora.core.gate import GateDecision
31
+ from cernora.core.identity import (
32
+ ComponentIdentity,
33
+ ExternalProducerIdentity,
34
+ component_identity,
35
+ external_producer_identity,
36
+ )
37
+ from cernora.core.score import Score, ScoreObservation
38
+ from cernora.evaluation.package import evaluate_imported_case, read_imported_evaluation
39
+ from cernora.ingestion.contracts_v2 import AuthorityBoundImportPackageV2
40
+ from cernora.ingestion.package_v2 import import_evidence_bundle_v2
41
+ from cernora.profile import Profile, ProfileAssessment, ProfileEvaluationContext
42
+ from cernora.profile_loader import ProfileLoadError, load_local_profile
43
+ from cernora.profile_workspace import ProfileInitResult, ProfileWorkspaceError, init_profile
44
+ from cernora.resources import PUBLIC_SCHEMAS, read_public_schema
45
+
46
+ __version__ = "0.1.0"
47
+
48
+ __all__ = [
49
+ "PUBLIC_SCHEMAS",
50
+ "AdaptedBundle",
51
+ "Adapter",
52
+ "AdapterConformance",
53
+ "AnswerClaim",
54
+ "Artifact",
55
+ "AuthorityBoundImportPackageV2",
56
+ "Case",
57
+ "CaseInput",
58
+ "CaseProfile",
59
+ "CompletedExport",
60
+ "ComponentIdentity",
61
+ "ConformanceError",
62
+ "Evidence",
63
+ "EvidenceBundleV2",
64
+ "EvidenceReference",
65
+ "ExternalProducerIdentity",
66
+ "Failure",
67
+ "FixtureReference",
68
+ "GateDecision",
69
+ "GatePolicy",
70
+ "ProcessResult",
71
+ "Profile",
72
+ "ProfileAssessment",
73
+ "ProfileConformance",
74
+ "ProfileEvaluationContext",
75
+ "ProfileInitResult",
76
+ "ProfileLoadError",
77
+ "ProfileWorkspaceError",
78
+ "Score",
79
+ "ScoreObservation",
80
+ "ScorerPolicy",
81
+ "StructuredAnswer",
82
+ "ToolAction",
83
+ "__version__",
84
+ "check_adapter_conformance",
85
+ "check_profile_conformance",
86
+ "component_identity",
87
+ "evaluate_imported_case",
88
+ "external_producer_identity",
89
+ "import_evidence_bundle_v2",
90
+ "init_profile",
91
+ "load_local_profile",
92
+ "read_imported_evaluation",
93
+ "read_public_schema",
94
+ ]
cernora/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run Cernora as `python -m cernora`."""
2
+
3
+ from cernora.cli.main import main
4
+
5
+ raise SystemExit(main())
cernora/adapter.py ADDED
@@ -0,0 +1,31 @@
1
+ """Preview completed-export Adapter interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Protocol, runtime_checkable
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CompletedExport:
12
+ """One already terminal ordinary-file export supplied by a third party."""
13
+
14
+ root: Path
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AdaptedBundle:
19
+ """Paths to one normalized EvidenceBundle v2 and its sibling artifacts."""
20
+
21
+ bundle_path: Path
22
+
23
+
24
+ @runtime_checkable
25
+ class Adapter(Protocol):
26
+ """Normalize completed local files without execution, credentials or network."""
27
+
28
+ def adapt(self, completed_export: CompletedExport, output: Path) -> AdaptedBundle: ...
29
+
30
+
31
+ __all__ = ["AdaptedBundle", "Adapter", "CompletedExport"]
@@ -0,0 +1 @@
1
+ """Cernora command-line interface."""
cernora/cli/main.py ADDED
@@ -0,0 +1,121 @@
1
+ """Public completed-evidence CLI with stable fail-closed exit classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, NoReturn
9
+
10
+ from cernora import __version__
11
+ from cernora.cli.profiles import BUILTIN_PROFILE_SELECTORS, load_builtin_profile
12
+ from cernora.conformance import ConformanceError, check_profile_conformance
13
+ from cernora.core.canonical import canonical_json
14
+ from cernora.core.errors import ContractError
15
+ from cernora.evaluation.package import evaluate_imported_case
16
+ from cernora.ingestion.errors import IngestionConfigurationError, IngestionIntegrityError
17
+ from cernora.ingestion.package_v2 import import_evidence_bundle_v2
18
+ from cernora.profile import Profile
19
+ from cernora.profile_loader import ProfileLoadError, load_local_profile
20
+ from cernora.profile_workspace import ProfileWorkspaceError, init_profile
21
+
22
+
23
+ class UsageParser(argparse.ArgumentParser):
24
+ def error(self, message: str) -> NoReturn:
25
+ self.print_usage(sys.stderr)
26
+ print(f"cernora: error: {message}", file=sys.stderr)
27
+ raise SystemExit(2)
28
+
29
+
30
+ def parser() -> argparse.ArgumentParser:
31
+ root = UsageParser(prog="cernora")
32
+ root.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
33
+ commands = root.add_subparsers(dest="command", required=True)
34
+
35
+ profile = commands.add_parser("profile")
36
+ profile_commands = profile.add_subparsers(dest="profile_command", required=True)
37
+ init = profile_commands.add_parser("init")
38
+ init.add_argument("name")
39
+ init.add_argument("--output", type=Path)
40
+ validate = profile_commands.add_parser("validate")
41
+ _add_profile_selector(validate)
42
+
43
+ evidence = commands.add_parser("evidence")
44
+ evidence_commands = evidence.add_subparsers(dest="evidence_command", required=True)
45
+ evidence_import = evidence_commands.add_parser("import")
46
+ _add_profile_selector(evidence_import)
47
+ evidence_import.add_argument("--bundle", type=Path, required=True)
48
+ evidence_import.add_argument("--output", type=Path, required=True)
49
+ evidence_evaluate = evidence_commands.add_parser("evaluate")
50
+ _add_profile_selector(evidence_evaluate)
51
+ evidence_evaluate.add_argument("--import-root", type=Path, required=True)
52
+ evidence_evaluate.add_argument("--output", type=Path, required=True)
53
+ return root
54
+
55
+
56
+ def _add_profile_selector(command: argparse.ArgumentParser) -> None:
57
+ selection = command.add_mutually_exclusive_group(required=True)
58
+ selection.add_argument("--profile", choices=BUILTIN_PROFILE_SELECTORS)
59
+ selection.add_argument("--profile-path", type=Path)
60
+
61
+
62
+ def _load_selected_profile(args: argparse.Namespace) -> Profile:
63
+ if args.profile_path is not None:
64
+ return load_local_profile(args.profile_path)
65
+ return load_builtin_profile(args.profile)
66
+
67
+
68
+ def _emit(value: Any) -> None:
69
+ sys.stdout.buffer.write(canonical_json(value) + b"\n")
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> int:
73
+ args = parser().parse_args(argv)
74
+ result: Any
75
+ try:
76
+ if args.command == "profile" and args.profile_command == "init":
77
+ result = init_profile(args.name, output=args.output)
78
+ code = 0
79
+ else:
80
+ profile = _load_selected_profile(args)
81
+ if args.command == "profile" and args.profile_command == "validate":
82
+ check_profile_conformance(profile)
83
+ result = profile.authority
84
+ code = 0
85
+ elif args.command == "evidence" and args.evidence_command == "import":
86
+ result = import_evidence_bundle_v2(
87
+ profile=profile,
88
+ bundle_path=args.bundle,
89
+ output=args.output,
90
+ )
91
+ code = 0
92
+ elif args.command == "evidence":
93
+ result = evaluate_imported_case(
94
+ profile=profile,
95
+ import_root=args.import_root,
96
+ output=args.output,
97
+ )
98
+ code = {"pass": 0, "fail": 1, "inconclusive": 3}[result.case_outcome]
99
+ except (
100
+ ConformanceError,
101
+ IngestionConfigurationError,
102
+ ProfileLoadError,
103
+ ProfileWorkspaceError,
104
+ ) as exc:
105
+ print(str(exc), file=sys.stderr)
106
+ return 2
107
+ except IngestionIntegrityError as exc:
108
+ print(str(exc), file=sys.stderr)
109
+ return 3
110
+ except (ContractError, OSError, ValueError, KeyError) as exc:
111
+ print(str(exc), file=sys.stderr)
112
+ return 2 if args.command == "profile" else 3
113
+ except Exception as exc:
114
+ print(f"evaluation failed closed: {type(exc).__name__}", file=sys.stderr)
115
+ return 3
116
+ _emit(result)
117
+ return code
118
+
119
+
120
+ if __name__ == "__main__":
121
+ raise SystemExit(main())
@@ -0,0 +1,25 @@
1
+ """Explicit CLI wiring for wheel-bundled public Profiles."""
2
+
3
+ from cernora.profile import Profile
4
+
5
+ BUILTIN_PROFILE_SELECTORS = ("builtin:coding-task", "builtin:offline-workflow")
6
+
7
+
8
+ def load_builtin_profile(selector: str) -> Profile:
9
+ """Instantiate one explicitly selected built-in Profile.
10
+
11
+ This closed CLI switch is not an SDK registry or discovery mechanism.
12
+ """
13
+
14
+ if selector == "builtin:offline-workflow":
15
+ from cernora.profiles.offline_workflow import OfflineWorkflowProfile
16
+
17
+ return OfflineWorkflowProfile()
18
+ if selector == "builtin:coding-task":
19
+ from cernora.profiles.coding_task import CodingTaskProfile
20
+
21
+ return CodingTaskProfile()
22
+ raise ValueError(f"unknown built-in Profile selector: {selector}")
23
+
24
+
25
+ __all__ = ["BUILTIN_PROFILE_SELECTORS", "load_builtin_profile"]
@@ -0,0 +1 @@
1
+ """Evaluator-owned Score and Gate composition."""
@@ -0,0 +1,126 @@
1
+ """Deterministic fail-closed GateDecision composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Sequence
6
+
7
+ from cernora.composition.scoring import index_scores
8
+ from cernora.core.evidence import (
9
+ EvidenceReference,
10
+ Failure,
11
+ evidence_reference_sort_key,
12
+ )
13
+ from cernora.core.gate import GateDecision
14
+ from cernora.core.identity import component_identity
15
+ from cernora.core.score import Score
16
+
17
+
18
+ def compose_gate(
19
+ *,
20
+ decision_id: str,
21
+ policy_version: str,
22
+ required_score_ids: Sequence[str],
23
+ required_observations: Sequence[str],
24
+ scores: Iterable[Score],
25
+ ) -> GateDecision:
26
+ """Compose a decision without converting invalid/missing observations into pass."""
27
+
28
+ indexed = index_scores(scores)
29
+ blocking: list[str] = []
30
+ inconclusive: list[str] = []
31
+ evidence: dict[tuple[str, str, str | None], EvidenceReference] = {}
32
+
33
+ for score_id in required_score_ids:
34
+ score = indexed.get(score_id)
35
+ if score is None:
36
+ inconclusive.append(f"missing required score: {score_id}")
37
+ continue
38
+ observations = {item.observation_id: item for item in score.observations}
39
+ for observation in score.observations:
40
+ for reference in observation.evidence_references:
41
+ evidence[(reference.evidence_id, reference.locator, reference.sha256)] = reference
42
+ for observation_id in required_observations:
43
+ required = observations.get(observation_id)
44
+ if required is None:
45
+ inconclusive.append(f"missing required observation {score_id}/{observation_id}")
46
+ elif required.applicability == "invalid":
47
+ inconclusive.append(
48
+ f"invalid observation {score_id}/{required.observation_id}: {required.reason}"
49
+ )
50
+ elif required.applicability == "not_applicable":
51
+ inconclusive.append(
52
+ f"required observation not applicable {score_id}/{required.observation_id}: "
53
+ f"{required.reason}"
54
+ )
55
+ elif required.applicability == "observed" and required.value is not True:
56
+ blocking.append(f"failed observation {score_id}/{required.observation_id}")
57
+
58
+ present_score_ids = tuple(score_id for score_id in required_score_ids if score_id in indexed)
59
+ references = tuple(sorted(evidence.values(), key=evidence_reference_sort_key))
60
+ scorer_identities = tuple(
61
+ component_identity("scorer", indexed[score_id].scorer_version)
62
+ for score_id in present_score_ids
63
+ )
64
+ input_digests = tuple(
65
+ sorted({reference.sha256 for reference in references if reference.sha256 is not None})
66
+ )
67
+ if inconclusive:
68
+ message = "; ".join(inconclusive)
69
+ return GateDecision(
70
+ schema_version="agent.evaluator.gate-decision/v1",
71
+ decision_id=decision_id,
72
+ decision="inconclusive",
73
+ policy_version=policy_version,
74
+ policy_identity=component_identity("gate_policy", policy_version),
75
+ blocking_reasons=tuple(inconclusive),
76
+ score_ids=present_score_ids,
77
+ evidence_references=references,
78
+ infrastructure_failure="required_score_invalid_or_missing",
79
+ eligible=False,
80
+ failure=Failure(
81
+ domain="scorer",
82
+ code="required_score_invalid_or_missing",
83
+ message=message,
84
+ evidence_references=references,
85
+ ),
86
+ scorer_identities=scorer_identities,
87
+ input_digests=input_digests,
88
+ harness_contribution="blocks_harness",
89
+ )
90
+ if blocking:
91
+ message = "; ".join(blocking)
92
+ return GateDecision(
93
+ schema_version="agent.evaluator.gate-decision/v1",
94
+ decision_id=decision_id,
95
+ decision="fail",
96
+ policy_version=policy_version,
97
+ policy_identity=component_identity("gate_policy", policy_version),
98
+ blocking_reasons=tuple(blocking),
99
+ score_ids=present_score_ids,
100
+ evidence_references=references,
101
+ eligible=True,
102
+ failure=Failure(
103
+ domain="agent",
104
+ code="required_observation_failed",
105
+ message=message,
106
+ evidence_references=references,
107
+ ),
108
+ scorer_identities=scorer_identities,
109
+ input_digests=input_digests,
110
+ harness_contribution="eligible_evaluation",
111
+ )
112
+ return GateDecision(
113
+ schema_version="agent.evaluator.gate-decision/v1",
114
+ decision_id=decision_id,
115
+ decision="pass",
116
+ policy_version=policy_version,
117
+ policy_identity=component_identity("gate_policy", policy_version),
118
+ blocking_reasons=(),
119
+ score_ids=present_score_ids,
120
+ evidence_references=references,
121
+ eligible=True,
122
+ failure=None,
123
+ scorer_identities=scorer_identities,
124
+ input_digests=input_digests,
125
+ harness_contribution="eligible_evaluation",
126
+ )
@@ -0,0 +1,18 @@
1
+ """Generic score helpers shared by profile adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from cernora.core.score import Score
8
+
9
+
10
+ def index_scores(scores: Iterable[Score]) -> dict[str, Score]:
11
+ """Index scores by ID and reject ambiguous duplicate identities."""
12
+
13
+ indexed: dict[str, Score] = {}
14
+ for score in scores:
15
+ if score.score_id in indexed:
16
+ raise ValueError(f"duplicate score ID: {score.score_id}")
17
+ indexed[score.score_id] = score
18
+ return indexed
cernora/conformance.py ADDED
@@ -0,0 +1,195 @@
1
+ """Preview conformance helpers for public Profile and Adapter authors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from cernora.adapter import AdaptedBundle, Adapter, CompletedExport
11
+ from cernora.core.canonical import canonical_json
12
+ from cernora.core.case import CaseProfile
13
+ from cernora.core.evidence_bundle_v2 import (
14
+ decode_evidence_bundle_v2,
15
+ verify_artifact_payloads_v2,
16
+ )
17
+ from cernora.profile import Profile
18
+
19
+ _MAX_CONFORMANCE_FILE_BYTES = 16_000_000
20
+
21
+
22
+ class ConformanceError(ValueError):
23
+ """A public Profile or Adapter does not satisfy the Preview contract."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ProfileConformance:
28
+ """Validated public identity summary for one Profile."""
29
+
30
+ profile_id: str
31
+ profile_version: str
32
+ projection_version: str
33
+ case_ids: tuple[str, ...]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AdapterConformance:
38
+ """Validated identity summary for one canonical Adapter output."""
39
+
40
+ bundle_id: str
41
+ bundle_sha256: str
42
+ artifact_ids: tuple[str, ...]
43
+ bundle_path: Path
44
+
45
+
46
+ def check_profile_conformance(candidate: object) -> ProfileConformance:
47
+ """Validate the static public Profile contract without evaluating evidence.
48
+
49
+ Local Profile Python is trusted code. This helper validates its exposed authority
50
+ and identity; a real import/evaluation remains the behavioral acceptance test.
51
+ """
52
+
53
+ if not isinstance(candidate, Profile):
54
+ raise ConformanceError("object does not implement the Cernora Profile contract")
55
+ authority = candidate.authority
56
+ if not isinstance(authority, CaseProfile):
57
+ raise ConformanceError("Profile authority must be a CaseProfile")
58
+ projection_version = candidate.projection_version
59
+ if not isinstance(projection_version, str) or not projection_version:
60
+ raise ConformanceError("Profile projection_version must be a non-empty string")
61
+ case_ids = tuple(case.case_id for case in authority.cases)
62
+ if not case_ids or len(case_ids) != len(set(case_ids)):
63
+ raise ConformanceError("Profile authority must contain uniquely identified Cases")
64
+ try:
65
+ canonical_json(authority)
66
+ except (TypeError, ValueError) as exc:
67
+ raise ConformanceError("Profile authority must have canonical JSON bytes") from exc
68
+ return ProfileConformance(
69
+ profile_id=authority.profile_id,
70
+ profile_version=authority.profile_version,
71
+ projection_version=projection_version,
72
+ case_ids=case_ids,
73
+ )
74
+
75
+
76
+ def _read_ordinary_file(path: Path) -> bytes:
77
+ try:
78
+ before = path.lstat()
79
+ except OSError as exc:
80
+ raise ConformanceError("Adapter output contains an unreadable path") from exc
81
+ if not stat.S_ISREG(before.st_mode):
82
+ raise ConformanceError("Adapter output files must be ordinary files")
83
+ descriptor = -1
84
+ try:
85
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
86
+ opened = os.fstat(descriptor)
87
+ if (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino):
88
+ raise ConformanceError("Adapter output changed while it was inspected")
89
+ if opened.st_size > _MAX_CONFORMANCE_FILE_BYTES:
90
+ raise ConformanceError("Adapter output file exceeds the conformance size limit")
91
+ with os.fdopen(descriptor, "rb", closefd=True) as stream:
92
+ descriptor = -1
93
+ payload = stream.read(_MAX_CONFORMANCE_FILE_BYTES + 1)
94
+ if len(payload) > _MAX_CONFORMANCE_FILE_BYTES:
95
+ raise ConformanceError("Adapter output file exceeds the conformance size limit")
96
+ return payload
97
+ except OSError as exc:
98
+ raise ConformanceError("Adapter output contains an unreadable path") from exc
99
+ finally:
100
+ if descriptor >= 0:
101
+ os.close(descriptor)
102
+
103
+
104
+ def _closed_output_files(root: Path) -> dict[str, bytes]:
105
+ try:
106
+ root_info = root.lstat()
107
+ except OSError as exc:
108
+ raise ConformanceError("Adapter output root is missing or unreadable") from exc
109
+ if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode):
110
+ raise ConformanceError("Adapter output root must be an ordinary directory")
111
+ files: dict[str, bytes] = {}
112
+ for path in sorted(root.rglob("*")):
113
+ try:
114
+ info = path.lstat()
115
+ except OSError as exc:
116
+ raise ConformanceError("Adapter output contains an unreadable path") from exc
117
+ if stat.S_ISLNK(info.st_mode):
118
+ raise ConformanceError("Adapter output must not contain symbolic links")
119
+ if stat.S_ISDIR(info.st_mode):
120
+ continue
121
+ if not stat.S_ISREG(info.st_mode):
122
+ raise ConformanceError("Adapter output must contain only ordinary files")
123
+ relative = path.relative_to(root).as_posix()
124
+ files[relative] = _read_ordinary_file(path)
125
+ return files
126
+
127
+
128
+ def check_adapter_conformance(
129
+ candidate: object,
130
+ completed_export: CompletedExport,
131
+ output: Path,
132
+ ) -> AdapterConformance:
133
+ """Run one Adapter and verify its closed canonical EvidenceBundle v2 tree.
134
+
135
+ The caller supplies a disposable, non-existing output path. Determinism should be
136
+ tested by invoking this helper repeatedly with equivalent completed exports.
137
+ """
138
+
139
+ if not isinstance(candidate, Adapter):
140
+ raise ConformanceError("object does not implement the Cernora Adapter contract")
141
+ if output.exists() or output.is_symlink():
142
+ raise ConformanceError("Adapter conformance output must not already exist")
143
+ try:
144
+ result = candidate.adapt(completed_export, output)
145
+ except Exception as exc:
146
+ raise ConformanceError("Adapter rejected the supplied completed export") from exc
147
+ if not isinstance(result, AdaptedBundle):
148
+ raise ConformanceError("Adapter must return a Cernora AdaptedBundle")
149
+ try:
150
+ expected_bundle_path = (output / "bundle.json").resolve(strict=True)
151
+ actual_bundle_path = result.bundle_path.resolve(strict=True)
152
+ except OSError as exc:
153
+ raise ConformanceError("Adapter did not return a readable bundle path") from exc
154
+ if actual_bundle_path != expected_bundle_path:
155
+ raise ConformanceError("Adapter bundle path must be <output>/bundle.json")
156
+
157
+ files = _closed_output_files(output)
158
+ bundle_bytes = files.get("bundle.json")
159
+ if bundle_bytes is None:
160
+ raise ConformanceError("Adapter output is missing bundle.json")
161
+ try:
162
+ bundle = decode_evidence_bundle_v2(bundle_bytes)
163
+ except (TypeError, ValueError) as exc:
164
+ raise ConformanceError("Adapter bundle.json is not strict EvidenceBundle v2") from exc
165
+ if bundle_bytes != canonical_json(bundle):
166
+ raise ConformanceError("Adapter bundle.json is not canonical JSON")
167
+
168
+ expected_files = {"bundle.json", *(artifact.path for artifact in bundle.artifacts)}
169
+ if set(files) != expected_files:
170
+ raise ConformanceError("Adapter output does not match the Bundle artifact set")
171
+ payloads = {artifact.artifact_id: files[artifact.path] for artifact in bundle.artifacts}
172
+ try:
173
+ verify_artifact_payloads_v2(bundle, payloads)
174
+ except (TypeError, ValueError) as exc:
175
+ raise ConformanceError("Adapter artifact payloads do not match the Bundle") from exc
176
+ if bundle.terminal.answer is not None:
177
+ answer_id = bundle.terminal.answer.artifact.artifact_id
178
+ if payloads[answer_id] != bundle.terminal.answer.content.encode("utf-8"):
179
+ raise ConformanceError("Adapter terminal answer bytes do not match its artifact")
180
+
181
+ return AdapterConformance(
182
+ bundle_id=bundle.bundle_id,
183
+ bundle_sha256=bundle.bundle_sha256,
184
+ artifact_ids=tuple(artifact.artifact_id for artifact in bundle.artifacts),
185
+ bundle_path=result.bundle_path,
186
+ )
187
+
188
+
189
+ __all__ = [
190
+ "AdapterConformance",
191
+ "ConformanceError",
192
+ "ProfileConformance",
193
+ "check_adapter_conformance",
194
+ "check_profile_conformance",
195
+ ]
@@ -0,0 +1 @@
1
+ """Stable Cernora contracts and deterministic primitives."""