actseal 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.
actseal/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """Actseal: verify frozen categorical decision policies with sealed, replayable evidence.
2
+
3
+ The runtime core depends only on the Python standard library. Importing this
4
+ package never imports an optional model stack.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from actseal.errors import ActsealError, IntegrityError, ProviderSetupError, SchemaError
10
+ from actseal.records import (
11
+ Action,
12
+ CapturedOutcome,
13
+ Case,
14
+ CaseRef,
15
+ ChoiceAnswer,
16
+ ChoiceQuestion,
17
+ Contract,
18
+ DecisionRecord,
19
+ DecisionRequest,
20
+ EvidenceBundle,
21
+ EvidenceScope,
22
+ FaultResult,
23
+ FaultSpec,
24
+ GateLimits,
25
+ Interval,
26
+ LockedPolicy,
27
+ ModelIdentity,
28
+ Option,
29
+ Outcome,
30
+ PlanLock,
31
+ PolicyDecision,
32
+ ProviderFailure,
33
+ Status,
34
+ Verdict,
35
+ )
36
+ from actseal.serialization import (
37
+ canonical_json,
38
+ from_data,
39
+ implementation_fingerprint,
40
+ sha256_bytes,
41
+ strict_json_loads,
42
+ to_data,
43
+ )
44
+
45
+ __version__ = "0.1.0"
46
+
47
+ __all__ = [
48
+ "Action",
49
+ "ActsealError",
50
+ "CapturedOutcome",
51
+ "Case",
52
+ "CaseRef",
53
+ "ChoiceAnswer",
54
+ "ChoiceQuestion",
55
+ "Contract",
56
+ "DecisionRecord",
57
+ "DecisionRequest",
58
+ "EvidenceBundle",
59
+ "EvidenceScope",
60
+ "FaultResult",
61
+ "FaultSpec",
62
+ "GateLimits",
63
+ "IntegrityError",
64
+ "Interval",
65
+ "LockedPolicy",
66
+ "ModelIdentity",
67
+ "Option",
68
+ "Outcome",
69
+ "PlanLock",
70
+ "PolicyDecision",
71
+ "ProviderFailure",
72
+ "ProviderSetupError",
73
+ "SchemaError",
74
+ "Status",
75
+ "Verdict",
76
+ "__version__",
77
+ "canonical_json",
78
+ "from_data",
79
+ "implementation_fingerprint",
80
+ "sha256_bytes",
81
+ "strict_json_loads",
82
+ "to_data",
83
+ ]
actseal/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m actseal`` entry point; delegates to :func:`actseal.cli.main`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from actseal.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,8 @@
1
+ """Decision-model adapters.
2
+
3
+ Importing this package loads nothing beyond the standard library. Concrete
4
+ adapters live in submodules; the native Laya stack is imported solely inside
5
+ the spawned worker process.
6
+ """
7
+
8
+ from __future__ import annotations
@@ -0,0 +1,35 @@
1
+ """The decision-model protocol (plan/CONTRACTS.md section 4).
2
+
3
+ A provider captures raw evidence; it never normalizes, retries, falls back or
4
+ authorizes an action. ``decide`` returns a :class:`CapturedOutcome` whose
5
+ ``request_sha256`` is the canonical hash of the request, whose ``identity`` is
6
+ the provider's actual identity, and whose body/failure are exclusive. Setup
7
+ problems raise :class:`actseal.errors.ProviderSetupError`; captured failures
8
+ use the frozen failure codes.
9
+
10
+ This module imports nothing beyond the standard library and ``records``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Protocol
16
+
17
+ from actseal.records import CapturedOutcome, DecisionRequest, ModelIdentity
18
+
19
+ __all__ = ["DecisionModel"]
20
+
21
+
22
+ class DecisionModel(Protocol):
23
+ """Sequential capture of locked decision requests against one resident model."""
24
+
25
+ def identity(self) -> ModelIdentity:
26
+ """The actual identity of the loaded model, fixed for the object's lifetime."""
27
+ ...
28
+
29
+ def decide(self, request: DecisionRequest, *, timeout_s: float) -> CapturedOutcome:
30
+ """Capture one request within a finite positive deadline; never raise for a failure."""
31
+ ...
32
+
33
+ def close(self) -> None:
34
+ """Release resources; idempotent and leaves no worker alive."""
35
+ ...
@@ -0,0 +1,166 @@
1
+ """Recorded-response fixture adapter.
2
+
3
+ The fixture file is JSONL with rows ``{case_id, body_json, failure_code,
4
+ warnings}``; ``body_json`` is a string (the recorded inner answer object, see
5
+ docs/providers.md), exactly one of ``body_json``/``failure_code`` is present,
6
+ and ``warnings`` is a list of nonempty strings. Rows are at most 1 MiB each and
7
+ the whole file at most 128 MiB (ADR 0011), enforced with a bounded read of at
8
+ most limit+1 bytes before decoding, splitting or hashing.
9
+ The file supplies no identity: the identity is derived from the raw file bytes
10
+ so that any change to the recording changes the revision.
11
+
12
+ A requested case that is not in the file is a :class:`ProviderSetupError`,
13
+ never a skip. Captures are deterministic and carry ``fallback_used=False``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from pathlib import Path
20
+ from typing import Final
21
+
22
+ from actseal.errors import ProviderSetupError, SchemaError
23
+ from actseal.normalization import NORMALIZER_VERSION, request_sha256
24
+ from actseal.records import (
25
+ FAILURE_CODES,
26
+ CapturedOutcome,
27
+ DecisionRequest,
28
+ ModelIdentity,
29
+ )
30
+ from actseal.serialization import sha256_bytes, strict_json_loads
31
+
32
+ __all__ = [
33
+ "ADAPTER_VERSION",
34
+ "MAX_FIXTURE_BYTES",
35
+ "MAX_ROW_BYTES",
36
+ "MODEL_NAME",
37
+ "FixtureModel",
38
+ "validate_timeout",
39
+ ]
40
+
41
+ ADAPTER_VERSION: Final = "1"
42
+ MODEL_NAME: Final = "recorded-choice-v1"
43
+ MAX_ROW_BYTES: Final = 1024 * 1024
44
+ MAX_FIXTURE_BYTES: Final = 128 * 1024 * 1024
45
+ _ROW_KEYS: Final[frozenset[str]] = frozenset({"case_id", "body_json", "failure_code", "warnings"})
46
+
47
+
48
+ def validate_timeout(timeout_s: object) -> float:
49
+ """Return ``timeout_s`` as a finite positive float or raise :class:`SchemaError`."""
50
+ if type(timeout_s) is bool or not isinstance(timeout_s, int | float):
51
+ raise SchemaError("timeout_s: must be a number")
52
+ try:
53
+ value = float(timeout_s)
54
+ except OverflowError:
55
+ raise SchemaError("timeout_s: must be finite") from None
56
+ if not math.isfinite(value) or value <= 0.0:
57
+ raise SchemaError("timeout_s: must be a finite positive number")
58
+ return value
59
+
60
+
61
+ class _Row:
62
+ __slots__ = ("body_json", "failure_code", "warnings")
63
+
64
+ def __init__(
65
+ self, body_json: str | None, failure_code: str | None, warnings: tuple[str, ...]
66
+ ) -> None:
67
+ self.body_json = body_json
68
+ self.failure_code = failure_code
69
+ self.warnings = warnings
70
+
71
+
72
+ def _parse_row(index: int, text: str) -> tuple[str, _Row]:
73
+ path = f"responses[{index}]"
74
+ if len(text.encode("utf-8")) > MAX_ROW_BYTES:
75
+ raise SchemaError(f"{path}: row exceeds {MAX_ROW_BYTES} bytes")
76
+ try:
77
+ value = strict_json_loads(text)
78
+ except SchemaError as exc:
79
+ raise SchemaError(f"{path}: {exc}") from None
80
+ if type(value) is not dict:
81
+ raise SchemaError(f"{path}: expected object")
82
+ if set(value) != _ROW_KEYS:
83
+ raise SchemaError(f"{path}: expected exactly case_id, body_json, failure_code, warnings")
84
+ case_id = value["case_id"]
85
+ if type(case_id) is not str or not case_id:
86
+ raise SchemaError(f"{path}.case_id: must be a nonempty string")
87
+ body = value["body_json"]
88
+ failure = value["failure_code"]
89
+ if body is not None and type(body) is not str:
90
+ raise SchemaError(f"{path}.body_json: must be a string or null")
91
+ if failure is not None and (type(failure) is not str or failure not in FAILURE_CODES):
92
+ raise SchemaError(f"{path}.failure_code: unsupported value")
93
+ if (body is None) == (failure is None):
94
+ raise SchemaError(f"{path}: exactly one of body_json/failure_code must be present")
95
+ warnings = value["warnings"]
96
+ if type(warnings) is not list or any(type(w) is not str or not w for w in warnings):
97
+ raise SchemaError(f"{path}.warnings: must be a list of nonempty strings")
98
+ return case_id, _Row(body, failure, tuple(warnings))
99
+
100
+
101
+ def _parse_rows(data: bytes) -> dict[str, _Row]:
102
+ try:
103
+ text = data.decode("utf-8")
104
+ except UnicodeDecodeError:
105
+ raise SchemaError("responses: must be valid UTF-8") from None
106
+ lines = text.split("\n")
107
+ if lines and lines[-1] == "":
108
+ lines.pop()
109
+ if not lines:
110
+ raise SchemaError("responses: must contain at least one row")
111
+ rows: dict[str, _Row] = {}
112
+ for index, line in enumerate(lines):
113
+ case_id, row = _parse_row(index, line)
114
+ if case_id in rows:
115
+ raise SchemaError(f"responses[{index}].case_id: duplicate case id")
116
+ rows[case_id] = row
117
+ return rows
118
+
119
+
120
+ class FixtureModel:
121
+ """Replay recorded provider responses keyed by case id."""
122
+
123
+ def __init__(self, responses: Path) -> None:
124
+ if not isinstance(responses, Path):
125
+ raise SchemaError("responses: must be a Path")
126
+ try:
127
+ with responses.open("rb") as handle:
128
+ data = handle.read(MAX_FIXTURE_BYTES + 1)
129
+ except OSError:
130
+ raise ProviderSetupError("responses: fixture file could not be read") from None
131
+ if len(data) > MAX_FIXTURE_BYTES:
132
+ raise SchemaError(f"responses: file exceeds {MAX_FIXTURE_BYTES} bytes")
133
+ self._rows = _parse_rows(data)
134
+ file_hash = sha256_bytes(data)
135
+ self._identity = ModelIdentity(
136
+ "fixture",
137
+ MODEL_NAME,
138
+ file_hash,
139
+ (("responses", file_hash),),
140
+ ADAPTER_VERSION,
141
+ NORMALIZER_VERSION,
142
+ (),
143
+ )
144
+
145
+ def identity(self) -> ModelIdentity:
146
+ return self._identity
147
+
148
+ def decide(self, request: DecisionRequest, *, timeout_s: float) -> CapturedOutcome:
149
+ validate_timeout(timeout_s)
150
+ if not isinstance(request, DecisionRequest):
151
+ raise SchemaError("request: must be DecisionRequest")
152
+ row = self._rows.get(request.case_id)
153
+ if row is None:
154
+ raise ProviderSetupError("responses: requested case id is not recorded")
155
+ return CapturedOutcome(
156
+ request_sha256(request),
157
+ self._identity,
158
+ row.body_json,
159
+ row.failure_code,
160
+ row.warnings,
161
+ fallback_used=False,
162
+ )
163
+
164
+ def close(self) -> None:
165
+ """Nothing to release; recorded responses stay available until the object is dropped."""
166
+ return